- commit
- 7e06247f1bdcbf8f9c68999925c108e17543593d
- parent
- d2b869533fbea5777c4120c444523fcdd9a3ba31
- author
- Dami <dami.etoile@gmail.com>
@@ -165,7 +165,6 @@ Several shorthand names are accepted for games: `hashi`/`bridges` for Hashiwokak
165165 | `Ctrl+R` | Reset puzzle |166166 | `Ctrl+H` | Toggle full help |167167 | `Ctrl+E` | Toggle debug overlay |168-| `Ctrl+Y` | Yank puzzle markdown snippet |169168 | `Ctrl+C` | Quit |170169 171170 ### Navigation@@ -3,14 +3,13 @@ package app
33 import "charm.land/bubbles/v2/key"44 55 type rootKeyMap struct {6- Quit key.Binding7- MainMenu key.Binding8- Enter key.Binding9- Escape key.Binding10- Debug key.Binding11- FullHelp key.Binding12- ResetGame key.Binding13- YankPuzzle key.Binding6+ Quit key.Binding7+ MainMenu key.Binding8+ Enter key.Binding9+ Escape key.Binding10+ Debug key.Binding11+ FullHelp key.Binding12+ ResetGame key.Binding1413 }1514 1615 var rootKeys = rootKeyMap{@@ -35,7 +34,4 @@ var rootKeys = rootKeyMap{
3534 ResetGame: key.NewBinding(3635 key.WithKeys("ctrl+r"),3736 ),38- YankPuzzle: key.NewBinding(39- key.WithKeys("ctrl+y"),40- ),4137 }@@ -175,11 +175,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
175175 if m.state == gameView && m.game != nil {176176 m.game = m.game.Reset()177177 }178- case key.Matches(msg, rootKeys.YankPuzzle):179- if m.state == gameView && m.game != nil {180- return m, m.yankPuzzleCmd()181- }182- return m, nil183178 }184179 }185180 @@ -1,9 +1,7 @@
11 package app22 33 import (4- "errors"54 "path/filepath"6- "strings"75 "testing"86 "time"97 @@ -12,11 +10,9 @@ import (
1210 tea "charm.land/bubbletea/v2"1311 "github.com/FelineStateMachine/puzzletea/daily"1412 "github.com/FelineStateMachine/puzzletea/game"15- "github.com/FelineStateMachine/puzzletea/lightsout"1613 "github.com/FelineStateMachine/puzzletea/namegen"1714 "github.com/FelineStateMachine/puzzletea/resolve"1815 "github.com/FelineStateMachine/puzzletea/store"19- "github.com/FelineStateMachine/puzzletea/sudoku"2016 )2117 2218 type escapeTrackingGame struct {@@ -242,100 +238,3 @@ func openAppTestStore(t *testing.T) *store.Store {
242238 t.Cleanup(func() { _ = s.Close() })243239 return s244240 }245-246-func TestCtrlYYanksSupportedPuzzle(t *testing.T) {247- previousCopy := copyToClipboard248- t.Cleanup(func() { copyToClipboard = previousCopy })249-250- var copied string251- copyToClipboard = func(s string) error {252- copied = s253- return nil254- }255-256- m := model{257- state: gameView,258- game: sudoku.Model{},259- }260-261- _, cmd := m.Update(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl})262- if cmd != nil {263- t.Fatal("expected nil command when native clipboard copy succeeds")264- }265- if copied == "" {266- t.Fatal("expected markdown snippet to be copied")267- }268- if !strings.Contains(copied, "Given Grid") {269- t.Fatalf("expected sudoku markdown snippet, got:\n%s", copied)270- }271-}272-273-func TestCtrlYYankUnsupportedGameIsNoOp(t *testing.T) {274- previousCopy := copyToClipboard275- t.Cleanup(func() { copyToClipboard = previousCopy })276-277- calls := 0278- copyToClipboard = func(string) error {279- calls++280- return nil281- }282-283- m := model{284- state: gameView,285- game: lightsout.Model{},286- }287-288- _, cmd := m.Update(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl})289- if cmd != nil {290- t.Fatal("expected nil command for unsupported game yank")291- }292- if calls != 0 {293- t.Fatalf("clipboard should not be called, got %d calls", calls)294- }295-}296-297-func TestCtrlYYankOutsideGameViewIsNoOp(t *testing.T) {298- previousCopy := copyToClipboard299- t.Cleanup(func() { copyToClipboard = previousCopy })300-301- calls := 0302- copyToClipboard = func(string) error {303- calls++304- return nil305- }306-307- m := model{308- state: mainMenuView,309- game: sudoku.Model{},310- }311-312- _, cmd := m.Update(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl})313- if cmd != nil {314- t.Fatal("expected nil command outside game view")315- }316- if calls != 0 {317- t.Fatalf("clipboard should not be called outside game view, got %d calls", calls)318- }319-}320-321-func TestCtrlYYankFallsBackToOSC52(t *testing.T) {322- previousCopy := copyToClipboard323- t.Cleanup(func() { copyToClipboard = previousCopy })324-325- copyToClipboard = func(string) error {326- return errors.New("clipboard unavailable")327- }328-329- m := model{330- state: gameView,331- game: sudoku.Model{},332- }333-334- _, cmd := m.Update(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl})335- if cmd == nil {336- t.Fatal("expected fallback clipboard command")337- }338- if msg := cmd(); msg == nil {339- t.Fatal("expected fallback clipboard message")340- }341-}@@ -1,42 +0,0 @@
1-package app2-3-import (4- "errors"5-6- "github.com/FelineStateMachine/puzzletea/markdownexport"7-8- tea "charm.land/bubbletea/v2"9- "github.com/atotto/clipboard"10-)11-12-var copyToClipboard = clipboard.WriteAll13-14-func (m model) yankPuzzleCmd() tea.Cmd {15- if m.state != gameView || m.game == nil {16- return nil17- }18-19- gameType, err := markdownexport.DetectGameType(m.game)20- if err != nil || !markdownexport.SupportsGameType(gameType) {21- return nil22- }23-24- save, err := m.game.GetSave()25- if err != nil {26- return nil27- }28-29- snippet, err := markdownexport.RenderPuzzleSnippet(gameType, "", save)30- if err != nil {31- if errors.Is(err, markdownexport.ErrUnsupportedGame) {32- return nil33- }34- return nil35- }36-37- if err := copyToClipboard(snippet); err == nil {38- return nil39- }40-41- return tea.SetClipboard(snippet)42-}@@ -50,7 +50,7 @@ func runExportPDF(cmd *cobra.Command, args []string) error {
5050 5151 puzzles := flattenPuzzles(docs)5252 if len(puzzles) == 0 {53- return fmt.Errorf("no puzzles found in input jsonl files")53+ return nil5454 }5555 5656 lookup := buildModeDifficultyLookup(app.Categories)@@ -1,11 +1,17 @@
11 package cmd22 33 import (4+ "bytes"5+ "encoding/json"6+ "os"7+ "path/filepath"48 "strings"59 "testing"610 "time"711 812 "github.com/FelineStateMachine/puzzletea/pdfexport"13+14+ "github.com/spf13/cobra"915 )1016 1117 func TestExportPDFVolumeFlagDefault(t *testing.T) {@@ -100,6 +106,132 @@ func TestBuildRenderConfigForPDFDefaultsSubtitleFromDocs(t *testing.T) {
100106 }101107 }102108 109+func TestRunExportPDFSilentlyNoOpsWhenAllPuzzlesUnsupported(t *testing.T) {110+ reset := snapshotExportPDFFlags()111+ defer reset()112+113+ dir := t.TempDir()114+ input := filepath.Join(dir, "lights.jsonl")115+ output := filepath.Join(dir, "lights.pdf")116+117+ record := pdfexport.JSONLRecord{118+ Schema: pdfexport.ExportSchemaV1,119+ Pack: pdfexport.JSONLPackMeta{120+ Generated: "2026-02-22T10:00:00Z",121+ Version: "v-test",122+ Category: "Lights Out",123+ ModeSelection: "Standard",124+ Count: 1,125+ },126+ Puzzle: pdfexport.JSONLPuzzle{127+ Index: 1,128+ Name: "glow-shore",129+ Game: "Lights Out",130+ Mode: "Standard",131+ Save: json.RawMessage(`{"size":5}`),132+ },133+ }134+ data, err := json.Marshal(record)135+ if err != nil {136+ t.Fatal(err)137+ }138+ if err := os.WriteFile(input, append(data, '\n'), 0o644); err != nil {139+ t.Fatal(err)140+ }141+142+ flagPDFOutput = output143+ flagPDFVolume = 1144+145+ var out bytes.Buffer146+ cmd := &cobra.Command{}147+ cmd.SetOut(&out)148+149+ if err := runExportPDF(cmd, []string{input}); err != nil {150+ t.Fatalf("expected no-op success, got %v", err)151+ }152+ if out.String() != "" {153+ t.Fatalf("expected no stdout output, got %q", out.String())154+ }155+ if _, err := os.Stat(output); !os.IsNotExist(err) {156+ t.Fatalf("expected no output file, stat err = %v", err)157+ }158+}159+160+func TestRunExportPDFSkipsUnsupportedRecordsWhenMixed(t *testing.T) {161+ reset := snapshotExportPDFFlags()162+ defer reset()163+164+ dir := t.TempDir()165+ input := filepath.Join(dir, "mixed.jsonl")166+ output := filepath.Join(dir, "mixed.pdf")167+168+ records := []pdfexport.JSONLRecord{169+ {170+ Schema: pdfexport.ExportSchemaV1,171+ Pack: pdfexport.JSONLPackMeta{172+ Generated: "2026-02-22T10:00:00Z",173+ Version: "v-test",174+ Category: "Lights Out",175+ ModeSelection: "Standard",176+ Count: 2,177+ },178+ Puzzle: pdfexport.JSONLPuzzle{179+ Index: 1,180+ Name: "glow-shore",181+ Game: "Lights Out",182+ Mode: "Standard",183+ Save: json.RawMessage(`{"size":5}`),184+ },185+ },186+ {187+ Schema: pdfexport.ExportSchemaV1,188+ Pack: pdfexport.JSONLPackMeta{189+ Generated: "2026-02-22T10:00:00Z",190+ Version: "v-test",191+ Category: "Sudoku",192+ ModeSelection: "Easy",193+ Count: 2,194+ },195+ Puzzle: pdfexport.JSONLPuzzle{196+ Index: 2,197+ Name: "moss-pine",198+ Game: "Sudoku",199+ Mode: "Easy",200+ Save: json.RawMessage(`{"provided":[{"x":0,"y":0,"v":5}]}`),201+ },202+ },203+ }204+ var lines []byte205+ for _, record := range records {206+ line, err := json.Marshal(record)207+ if err != nil {208+ t.Fatal(err)209+ }210+ lines = append(lines, line...)211+ lines = append(lines, '\n')212+ }213+ if err := os.WriteFile(input, lines, 0o644); err != nil {214+ t.Fatal(err)215+ }216+217+ flagPDFOutput = output218+ flagPDFVolume = 1219+220+ cmd := &cobra.Command{}221+ cmd.SetOut(&bytes.Buffer{})222+ if err := runExportPDF(cmd, []string{input}); err != nil {223+ t.Fatalf("expected mixed export success, got %v", err)224+ }225+226+ info, err := os.Stat(output)227+ if err != nil {228+ t.Fatalf("expected output file, got stat error: %v", err)229+ }230+ if info.Size() == 0 {231+ t.Fatal("expected non-empty output PDF")232+ }233+}234+103235 func snapshotExportPDFFlags() func() {104236 oldTitle := flagPDFTitle105237 oldHeader := flagPDFHeader@@ -2,7 +2,6 @@ package cmd
22 33 import (44 "encoding/json"5- "errors"65 "fmt"76 "io"87 "math/rand/v2"@@ -13,7 +12,6 @@ import (
1312 1413 "github.com/FelineStateMachine/puzzletea/app"1514 "github.com/FelineStateMachine/puzzletea/game"16- "github.com/FelineStateMachine/puzzletea/markdownexport"1715 "github.com/FelineStateMachine/puzzletea/namegen"1816 "github.com/FelineStateMachine/puzzletea/pdfexport"1917 "github.com/FelineStateMachine/puzzletea/resolve"@@ -37,8 +35,8 @@ func runNewExport(cmd *cobra.Command, args []string) error {
3735 if err != nil {3836 return err3937 }40- if !markdownexport.SupportsGameType(cat.Name) {41- return fmt.Errorf("game %q does not support export", cat.Name)38+ if !game.HasPrintAdapter(cat.Name) {39+ return nil4240 }4341 4442 modeArg := ""@@ -158,14 +156,6 @@ func buildExportRecords(
158156 return nil, fmt.Errorf("serialize puzzle %d: save payload is not valid JSON", i+1)159157 }160158 161- snippet, err := markdownexport.RenderPuzzleSnippet(gameType, entry.mode, save)162- if err != nil {163- if errors.Is(err, markdownexport.ErrUnsupportedGame) {164- return nil, fmt.Errorf("game %q does not support export", gameType)165- }166- return nil, fmt.Errorf("render puzzle %d: %w", i+1, err)167- }168-169159 records = append(records, pdfexport.JSONLRecord{170160 Schema: pdfexport.ExportSchemaV1,171161 Pack: pdfexport.JSONLPackMeta{@@ -177,12 +167,11 @@ func buildExportRecords(
177167 Seed: seed,178168 },179169 Puzzle: pdfexport.JSONLPuzzle{180- Index: i + 1,181- Name: namegen.GenerateSeeded(nameRNG),182- Game: gameType,183- Mode: entry.mode,184- Save: json.RawMessage(save),185- Snippet: snippet,170+ Index: i + 1,171+ Name: namegen.GenerateSeeded(nameRNG),172+ Game: gameType,173+ Mode: entry.mode,174+ Save: json.RawMessage(save),186175 },187176 })188177 }@@ -17,15 +17,16 @@ import (
1717 1818 func TestRunNewExportRejectsUnsupportedGame(t *testing.T) {1919 withExportFlagReset(t)20- flagOutput = filepath.Join(t.TempDir(), "lights.jsonl")20+ output := filepath.Join(t.TempDir(), "lights.jsonl")21+ flagOutput = output2122 2223 cmd, _ := newExportTestCmd(t, false)2324 err := runNewExport(cmd, []string{"lights-out"})24- if err == nil {25- t.Fatal("expected unsupported game error")25+ if err != nil {26+ t.Fatalf("expected silent no-op for unsupported game, got: %v", err)2627 }27- if !strings.Contains(err.Error(), "does not support export") {28- t.Fatalf("unexpected error: %v", err)28+ if _, statErr := os.Stat(output); !os.IsNotExist(statErr) {29+ t.Fatalf("expected no output file, stat err = %v", statErr)2930 }3031 }3132 @@ -173,6 +174,14 @@ func TestRunNewExportOmitsPrintPayload(t *testing.T) {
173174 if _, ok := record["print"]; ok {174175 t.Fatal("did not expect print payload in export record")175176 }177+178+ puzzle, ok := record["puzzle"].(map[string]any)179+ if !ok {180+ t.Fatalf("expected puzzle object, got %T", record["puzzle"])181+ }182+ if _, ok := puzzle["snippet"]; ok {183+ t.Fatal("did not expect markdown snippet in export record")184+ }176185 }177186 178187 func withExportFlagReset(t *testing.T) {@@ -0,0 +1,65 @@
1+package game2+3+import (4+ "reflect"5+ "strings"6+7+ "github.com/go-pdf/fpdf"8+)9+10+type PrintAdapter interface {11+ CanonicalGameType() string12+ Aliases() []string13+ RenderMarkdownSnippet(save []byte) (string, error)14+ BuildPDFPayload(save []byte, snippet string) (any, error)15+ RenderPDFBody(pdf *fpdf.Fpdf, payload any) error16+}17+18+var printAdapterRegistry = map[string]PrintAdapter{}19+20+func RegisterPrintAdapter(adapter PrintAdapter) {21+ canonical := normalizeGameTypeToken(adapter.CanonicalGameType())22+ if canonical == "" {23+ return24+ }25+26+ printAdapterRegistry[canonical] = adapter27+ for _, alias := range adapter.Aliases() {28+ normalized := normalizeGameTypeToken(alias)29+ if normalized == "" {30+ continue31+ }32+ printAdapterRegistry[normalized] = adapter33+ }34+}35+36+func LookupPrintAdapter(gameType string) (PrintAdapter, bool) {37+ adapter, ok := printAdapterRegistry[normalizeGameTypeToken(gameType)]38+ return adapter, ok39+}40+41+func HasPrintAdapter(gameType string) bool {42+ _, ok := LookupPrintAdapter(gameType)43+ return ok44+}45+46+func IsNilPrintPayload(payload any) bool {47+ if payload == nil {48+ return true49+ }50+51+ v := reflect.ValueOf(payload)52+ switch v.Kind() {53+ case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:54+ return v.IsNil()55+ default:56+ return false57+ }58+}59+60+func normalizeGameTypeToken(s string) string {61+ s = strings.ToLower(strings.TrimSpace(s))62+ s = strings.ReplaceAll(s, "-", " ")63+ s = strings.ReplaceAll(s, "_", " ")64+ return strings.Join(strings.Fields(s), " ")65+}@@ -0,0 +1,66 @@
1+package game2+3+import (4+ "testing"5+6+ "github.com/go-pdf/fpdf"7+)8+9+type testPrintAdapter struct {10+ canonical string11+ aliases []string12+}13+14+func (a testPrintAdapter) CanonicalGameType() string { return a.canonical }15+func (a testPrintAdapter) Aliases() []string { return a.aliases }16+func (a testPrintAdapter) RenderMarkdownSnippet([]byte) (string, error) {17+ return "", nil18+}19+func (a testPrintAdapter) BuildPDFPayload([]byte, string) (any, error) { return nil, nil }20+func (a testPrintAdapter) RenderPDFBody(*fpdf.Fpdf, any) error { return nil }21+22+func TestPrintAdapterRegistryLookupAndAliases(t *testing.T) {23+ original := printAdapterRegistry24+ printAdapterRegistry = map[string]PrintAdapter{}25+ t.Cleanup(func() { printAdapterRegistry = original })26+27+ adapter := testPrintAdapter{28+ canonical: "Word Search",29+ aliases: []string{"wordsearch", "word-search"},30+ }31+ RegisterPrintAdapter(adapter)32+33+ if !HasPrintAdapter("Word Search") {34+ t.Fatal("expected canonical lookup to be supported")35+ }36+ if !HasPrintAdapter("word_search") {37+ t.Fatal("expected underscore alias lookup to be supported")38+ }39+ if !HasPrintAdapter("wordsearch") {40+ t.Fatal("expected compact alias lookup to be supported")41+ }42+ if HasPrintAdapter("lights out") {43+ t.Fatal("expected unknown type to be unsupported")44+ }45+}46+47+func TestRegisterPrintAdapterSkipsBlankCanonical(t *testing.T) {48+ original := printAdapterRegistry49+ printAdapterRegistry = map[string]PrintAdapter{}50+ t.Cleanup(func() { printAdapterRegistry = original })51+52+ RegisterPrintAdapter(testPrintAdapter{canonical: " "})53+ if len(printAdapterRegistry) != 0 {54+ t.Fatalf("registry size = %d, want 0", len(printAdapterRegistry))55+ }56+}57+58+func TestIsNilPrintPayload(t *testing.T) {59+ var ptr *int60+ if !IsNilPrintPayload(ptr) {61+ t.Fatal("expected typed nil pointer to be treated as nil payload")62+ }63+ if IsNilPrintPayload(5) {64+ t.Fatal("expected concrete payload to be non-nil")65+ }66+}@@ -0,0 +1,192 @@
1+package game2+3+import (4+ "fmt"5+ "strconv"6+ "strings"7+)8+9+func SplitLines(s string) []string {10+ if strings.TrimSpace(s) == "" {11+ return []string{}12+ }13+ return strings.Split(s, "\n")14+}15+16+func EscapeMarkdownCell(s string) string {17+ return strings.ReplaceAll(s, "|", "\\|")18+}19+20+func MakeStringGrid(width, height int, fill string) [][]string {21+ if width <= 0 || height <= 0 {22+ return [][]string{}23+ }24+25+ grid := make([][]string, height)26+ for y := range height {27+ grid[y] = make([]string, width)28+ for x := range width {29+ grid[y][x] = fill30+ }31+ }32+ return grid33+}34+35+func RenderGridTable(cells [][]string) string {36+ if len(cells) == 0 {37+ return "_(empty grid)_"38+ }39+40+ width := 041+ for _, row := range cells {42+ if len(row) > width {43+ width = len(row)44+ }45+ }46+ if width <= 0 {47+ return "_(empty grid)_"48+ }49+50+ var b strings.Builder51+ b.WriteString("| |")52+ for x := range width {53+ fmt.Fprintf(&b, " %d |", x+1)54+ }55+ b.WriteString("\n| --- |")56+ for range width {57+ b.WriteString(" --- |")58+ }59+ b.WriteString("\n")60+61+ for y := range len(cells) {62+ fmt.Fprintf(&b, "| %d |", y+1)63+ for x := range width {64+ cell := "."65+ if x < len(cells[y]) && strings.TrimSpace(cells[y][x]) != "" {66+ cell = cells[y][x]67+ }68+ fmt.Fprintf(&b, " %s |", EscapeMarkdownCell(cell))69+ }70+ b.WriteString("\n")71+ }72+73+ return strings.TrimRight(b.String(), "\n")74+}75+76+func ParseCSVIntGrid(raw string, width, height int) [][]int {77+ if width <= 0 || height <= 0 {78+ return [][]int{}79+ }80+81+ clues := make([][]int, height)82+ for y := range height {83+ clues[y] = make([]int, width)84+ }85+86+ rows := SplitLines(raw)87+ for y := range min(len(rows), len(clues)) {88+ parts := strings.Split(rows[y], ",")89+ for x := range min(len(parts), len(clues[y])) {90+ val, err := strconv.Atoi(strings.TrimSpace(parts[x]))91+ if err != nil {92+ continue93+ }94+ clues[y][x] = val95+ }96+ }97+ return clues98+}99+100+func NormalizeNonogramHints(src [][]int, size int) [][]int {101+ if size <= 0 {102+ return [][]int{}103+ }104+105+ hints := make([][]int, size)106+ for i := range len(hints) {107+ if i >= len(src) || len(src[i]) == 0 {108+ hints[i] = []int{0}109+ continue110+ }111+ hints[i] = append([]int(nil), src[i]...)112+ }113+ return hints114+}115+116+func MaxNonogramHintLen(hints [][]int) int {117+ maxLen := 0118+ for _, hint := range hints {119+ if len(hint) > maxLen {120+ maxLen = len(hint)121+ }122+ }123+ return maxLen124+}125+126+func RenderNonogramTable(127+ rowHints, colHints [][]int,128+ width, height, rowHintCols, colHintRows int,129+) string {130+ var b strings.Builder131+132+ b.WriteString("|")133+ for i := range rowHintCols {134+ fmt.Fprintf(&b, " R%d |", i+1)135+ }136+ for x := range width {137+ fmt.Fprintf(&b, " C%d |", x+1)138+ }139+ b.WriteString("\n|")140+ for range rowHintCols + width {141+ b.WriteString(" --- |")142+ }143+ b.WriteString("\n")144+145+ for hintRow := range colHintRows {146+ b.WriteString("|")147+ for range rowHintCols {148+ b.WriteString(" . |")149+ }150+ for x := range width {151+ b.WriteString(" ")152+ b.WriteString(renderColumnHintCell(colHints[x], colHintRows, hintRow))153+ b.WriteString(" |")154+ }155+ b.WriteString("\n")156+ }157+158+ for y := range height {159+ rowHint := rowHints[y]160+ hintStart := rowHintCols - len(rowHint)161+162+ b.WriteString("|")163+ for hintCol := range rowHintCols {164+ if hintCol < hintStart {165+ b.WriteString(" . |")166+ continue167+ }168+ fmt.Fprintf(&b, " %d |", rowHint[hintCol-hintStart])169+ }170+ for range width {171+ b.WriteString(" . |")172+ }173+ b.WriteString("\n")174+ }175+176+ return strings.TrimRight(b.String(), "\n")177+}178+179+func renderColumnHintCell(hint []int, depth, row int) string {180+ start := depth - len(hint)181+ if row < start {182+ return "."183+ }184+ return strconv.Itoa(hint[row-start])185+}186+187+func min(a, b int) int {188+ if a < b {189+ return a190+ }191+ return b192+}@@ -6,7 +6,6 @@ require (
66 charm.land/bubbles/v2 v2.0.0-rc.177 charm.land/bubbletea/v2 v2.0.0-rc.288 charm.land/lipgloss/v2 v2.0.0-beta.3.0.20260212100304-e18737634dea9- github.com/atotto/clipboard v0.1.4109 github.com/go-pdf/fpdf v0.9.01110 github.com/spf13/cobra v1.10.21211 modernc.org/sqlite v1.44.3@@ -14,6 +13,7 @@ require (
1413 1514 require (1615 github.com/alecthomas/chroma/v2 v2.14.0 // indirect16+ github.com/atotto/clipboard v0.1.4 // indirect1717 github.com/aymerick/douceur v0.2.0 // indirect1818 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect1919 github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect@@ -0,0 +1,78 @@
1+package hashiwokakero2+3+import (4+ "encoding/json"5+ "fmt"6+ "strconv"7+ "strings"8+9+ "github.com/FelineStateMachine/puzzletea/game"10+ "github.com/FelineStateMachine/puzzletea/pdfexport"11+ "github.com/go-pdf/fpdf"12+)13+14+type printAdapter struct{}15+16+func (printAdapter) CanonicalGameType() string { return "Hashiwokakero" }17+func (printAdapter) Aliases() []string {18+ return []string{"hashi", "hashiwokakero", "hashi wokakero"}19+}20+21+func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) {22+ var data Save23+ if err := json.Unmarshal(save, &data); err != nil {24+ return "", fmt.Errorf("decode hashiwokakero save: %w", err)25+ }26+27+ cells := game.MakeStringGrid(data.Width, data.Height, ".")28+ for _, island := range data.Islands {29+ if island.Y < 0 || island.Y >= len(cells) {30+ continue31+ }32+ if island.X < 0 || island.X >= len(cells[island.Y]) {33+ continue34+ }35+ cells[island.Y][island.X] = strconv.Itoa(island.Required)36+ }37+38+ var b strings.Builder39+ b.WriteString("### Grid\n\n")40+ b.WriteString(game.RenderGridTable(cells))41+ b.WriteString("\n\n")42+ b.WriteString("Rules: connect numbered islands with horizontal/vertical bridges. ")43+ b.WriteString("Use up to two bridges per connection and never cross bridges.")44+ return b.String(), nil45+}46+47+func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) {48+ payload, err := pdfexport.ParseHashiPrintData(save)49+ if err != nil {50+ return nil, err51+ }52+ if !game.IsNilPrintPayload(payload) {53+ return payload, nil54+ }55+ if strings.TrimSpace(snippet) == "" {56+ return nil, nil57+ }58+59+ _, table, err := pdfexport.ParsePrintableFromSnippet("Hashiwokakero", snippet)60+ if err != nil {61+ return nil, err62+ }63+ return table, nil64+}65+66+func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {67+ switch data := payload.(type) {68+ case *pdfexport.HashiData:69+ pdfexport.RenderHashiPage(pdf, data)70+ case *pdfexport.GridTable:71+ pdfexport.RenderGridTablePage(pdf, data)72+ }73+ return nil74+}75+76+func init() {77+ game.RegisterPrintAdapter(printAdapter{})78+}@@ -0,0 +1,97 @@
1+package hitori2+3+import (4+ "encoding/json"5+ "fmt"6+ "strings"7+8+ "github.com/FelineStateMachine/puzzletea/game"9+ "github.com/FelineStateMachine/puzzletea/pdfexport"10+ "github.com/go-pdf/fpdf"11+)12+13+type printAdapter struct{}14+15+func (printAdapter) CanonicalGameType() string { return "Hitori" }16+func (printAdapter) Aliases() []string { return []string{"hitori"} }17+18+func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) {19+ var data Save20+ if err := json.Unmarshal(save, &data); err != nil {21+ return "", fmt.Errorf("decode hitori save: %w", err)22+ }23+24+ rows := game.SplitLines(data.Numbers)25+ targetHeight := data.Size26+ if targetHeight < len(rows) {27+ targetHeight = len(rows)28+ }29+ if targetHeight < 1 {30+ targetHeight = 131+ }32+33+ cells := make([][]string, 0, targetHeight)34+ for y := range targetHeight {35+ row := []rune{}36+ if y < len(rows) {37+ row = []rune(rows[y])38+ }39+ targetWidth := data.Size40+ if targetWidth < len(row) {41+ targetWidth = len(row)42+ }43+ if targetWidth < 1 {44+ targetWidth = 145+ }46+47+ cellsRow := make([]string, targetWidth)48+ for x := range len(cellsRow) {49+ cellsRow[x] = "."50+ if x < len(row) && row[x] != ' ' {51+ cellsRow[x] = string(row[x])52+ }53+ }54+ cells = append(cells, cellsRow)55+ }56+57+ var b strings.Builder58+ b.WriteString("### Grid\n\n")59+ b.WriteString(game.RenderGridTable(cells))60+ b.WriteString("\n\n")61+ b.WriteString("Goal: shade cells so no row or column has duplicate unshaded values, ")62+ b.WriteString("shaded cells do not touch orthogonally, and all unshaded cells stay connected.")63+ return b.String(), nil64+}65+66+func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) {67+ payload, err := pdfexport.ParseHitoriPrintData(save)68+ if err != nil {69+ return nil, err70+ }71+ if !game.IsNilPrintPayload(payload) {72+ return payload, nil73+ }74+ if strings.TrimSpace(snippet) == "" {75+ return nil, nil76+ }77+78+ _, table, err := pdfexport.ParsePrintableFromSnippet("Hitori", snippet)79+ if err != nil {80+ return nil, err81+ }82+ return table, nil83+}84+85+func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {86+ switch data := payload.(type) {87+ case *pdfexport.HitoriData:88+ pdfexport.RenderHitoriPage(pdf, data)89+ case *pdfexport.GridTable:90+ pdfexport.RenderGridTablePage(pdf, data)91+ }92+ return nil93+}94+95+func init() {96+ game.RegisterPrintAdapter(printAdapter{})97+}@@ -1,41 +0,0 @@
1-package markdownexport2-3-import (4- "fmt"5-6- "github.com/FelineStateMachine/puzzletea/game"7- "github.com/FelineStateMachine/puzzletea/hashiwokakero"8- "github.com/FelineStateMachine/puzzletea/hitori"9- "github.com/FelineStateMachine/puzzletea/lightsout"10- "github.com/FelineStateMachine/puzzletea/nonogram"11- "github.com/FelineStateMachine/puzzletea/nurikabe"12- "github.com/FelineStateMachine/puzzletea/shikaku"13- "github.com/FelineStateMachine/puzzletea/sudoku"14- "github.com/FelineStateMachine/puzzletea/takuzu"15- "github.com/FelineStateMachine/puzzletea/wordsearch"16-)17-18-func DetectGameType(g game.Gamer) (string, error) {19- switch g.(type) {20- case hashiwokakero.Model, *hashiwokakero.Model:21- return "Hashiwokakero", nil22- case hitori.Model, *hitori.Model:23- return "Hitori", nil24- case lightsout.Model, *lightsout.Model:25- return "Lights Out", nil26- case nonogram.Model, *nonogram.Model:27- return "Nonogram", nil28- case nurikabe.Model, *nurikabe.Model:29- return "Nurikabe", nil30- case shikaku.Model, *shikaku.Model:31- return "Shikaku", nil32- case sudoku.Model, *sudoku.Model:33- return "Sudoku", nil34- case takuzu.Model, *takuzu.Model:35- return "Takuzu", nil36- case wordsearch.Model, *wordsearch.Model:37- return "Word Search", nil38- default:39- return "", fmt.Errorf("cannot detect game type for %T", g)40- }41-}@@ -1,71 +0,0 @@
1-package markdownexport2-3-import (4- "fmt"5- "hash/fnv"6- "math/rand/v2"7- "strings"8- "time"9-10- "github.com/FelineStateMachine/puzzletea/namegen"11-)12-13-type DocumentConfig struct {14- Version string15- Category string16- ModeSelection string17- Count int18- Seed string19- GeneratedAt time.Time20-}21-22-type PuzzleSection struct {23- Index int24- GameType string25- Mode string26- Body string27-}28-29-func BuildDocument(cfg DocumentConfig, puzzles []PuzzleSection) string {30- var b strings.Builder31-32- seed := cfg.Seed33- if strings.TrimSpace(seed) == "" {34- seed = "none"35- }36- nameRNG := exportNameRNG(cfg)37-38- fmt.Fprintf(&b, "# PuzzleTea Export\n\n")39- fmt.Fprintf(&b, "- Generated: %s\n", cfg.GeneratedAt.Format(time.RFC3339))40- fmt.Fprintf(&b, "- Version: %s\n", cfg.Version)41- fmt.Fprintf(&b, "- Category: %s\n", cfg.Category)42- fmt.Fprintf(&b, "- Mode Selection: %s\n", cfg.ModeSelection)43- fmt.Fprintf(&b, "- Count: %d\n", cfg.Count)44- fmt.Fprintf(&b, "- Seed: %s\n\n", seed)45-46- for i, puzzle := range puzzles {47- if i > 0 {48- b.WriteString("\n---\n\n")49- }50- fmt.Fprintf(&b, "## %s - %d\n\n", namegen.GenerateSeeded(nameRNG), puzzle.Index)51- b.WriteString(strings.TrimSpace(puzzle.Body))52- b.WriteString("\n")53- }54-55- return b.String()56-}57-58-func exportNameRNG(cfg DocumentConfig) *rand.Rand {59- seed := cfg.Seed60- if strings.TrimSpace(seed) == "" {61- seed = cfg.GeneratedAt.Format(time.RFC3339Nano)62- }63- return rngFromString("export-names:" + seed)64-}65-66-func rngFromString(seed string) *rand.Rand {67- h := fnv.New64a()68- h.Write([]byte(seed))69- s := h.Sum64()70- return rand.New(rand.NewPCG(s, ^s))71-}@@ -1,467 +0,0 @@
1-package markdownexport2-3-import (4- "encoding/json"5- "fmt"6- "strconv"7- "strings"8-9- "github.com/FelineStateMachine/puzzletea/hashiwokakero"10- "github.com/FelineStateMachine/puzzletea/hitori"11- "github.com/FelineStateMachine/puzzletea/nonogram"12- "github.com/FelineStateMachine/puzzletea/nurikabe"13- "github.com/FelineStateMachine/puzzletea/shikaku"14- "github.com/FelineStateMachine/puzzletea/sudoku"15- "github.com/FelineStateMachine/puzzletea/takuzu"16- "github.com/FelineStateMachine/puzzletea/wordsearch"17-)18-19-func RenderPuzzleSnippet(gameType, _ string, save []byte) (string, error) {20- switch normalizeGameType(gameType) {21- case "hashiwokakero":22- return renderHashi(save)23- case "hitori":24- return renderHitori(save)25- case "nonogram":26- return renderNonogram(save)27- case "nurikabe":28- return renderNurikabe(save)29- case "shikaku":30- return renderShikaku(save)31- case "sudoku":32- return renderSudoku(save)33- case "takuzu":34- return renderTakuzu(save)35- case "word search", "wordsearch":36- return renderWordSearch(save)37- case "lights out", "lightsout":38- return "", ErrUnsupportedGame39- default:40- return "", ErrUnsupportedGame41- }42-}43-44-func renderHashi(data []byte) (string, error) {45- var save hashiwokakero.Save46- if err := json.Unmarshal(data, &save); err != nil {47- return "", fmt.Errorf("decode hashiwokakero save: %w", err)48- }49-50- cells := makeGrid(save.Width, save.Height, ".")51- for _, island := range save.Islands {52- if island.Y >= 0 && island.Y < len(cells) && island.X >= 0 && island.X < len(cells[island.Y]) {53- cells[island.Y][island.X] = strconv.Itoa(island.Required)54- }55- }56-57- var b strings.Builder58- b.WriteString("### Grid\n\n")59- b.WriteString(renderGridTable(cells))60- b.WriteString("\n\n")61- b.WriteString("Rules: connect numbered islands with horizontal/vertical bridges. ")62- b.WriteString("Use up to two bridges per connection and never cross bridges.")63- return b.String(), nil64-}65-66-func renderHitori(data []byte) (string, error) {67- var save hitori.Save68- if err := json.Unmarshal(data, &save); err != nil {69- return "", fmt.Errorf("decode hitori save: %w", err)70- }71-72- rows := splitLines(save.Numbers)73- cells := make([][]string, 0, max(len(rows), save.Size))74- targetHeight := max(len(rows), save.Size)75- for y := range targetHeight {76- row := []rune{}77- if y < len(rows) {78- row = []rune(rows[y])79- }80- cellsRow := make([]string, max(len(row), save.Size))81- if len(cellsRow) == 0 {82- cellsRow = []string{"."}83- }84- for x := range len(cellsRow) {85- if x < len(row) {86- cellsRow[x] = string(row[x])87- } else {88- cellsRow[x] = "."89- }90- }91- cells = append(cells, cellsRow)92- }93- if len(cells) == 0 {94- cells = [][]string{{"."}}95- }96-97- var b strings.Builder98- b.WriteString("### Grid\n\n")99- b.WriteString(renderGridTable(cells))100- b.WriteString("\n\n")101- b.WriteString("Goal: shade cells so no row or column has duplicate unshaded values, ")102- b.WriteString("shaded cells do not touch orthogonally, and all unshaded cells stay connected.")103- return b.String(), nil104-}105-106-func renderNonogram(data []byte) (string, error) {107- var save nonogram.Save108- if err := json.Unmarshal(data, &save); err != nil {109- return "", fmt.Errorf("decode nonogram save: %w", err)110- }111-112- width := save.Width113- height := save.Height114- if width <= 0 {115- width = len(save.ColHints)116- }117- if height <= 0 {118- height = len(save.RowHints)119- }120- if width <= 0 || height <= 0 {121- return "### Puzzle Grid with Integrated Hints\n\n_(empty grid)_", nil122- }123-124- rowHints := normalizeNonogramHints(save.RowHints, height)125- colHints := normalizeNonogramHints(save.ColHints, width)126-127- rowHintCols := maxNonogramHintLen(rowHints)128- colHintRows := maxNonogramHintLen(colHints)129- if rowHintCols < 1 {130- rowHintCols = 1131- }132- if colHintRows < 1 {133- colHintRows = 1134- }135-136- var b strings.Builder137- b.WriteString("### Puzzle Grid with Integrated Hints\n\n")138- b.WriteString(renderNonogramTable(rowHints, colHints, width, height, rowHintCols, colHintRows))139- b.WriteString("\n\n")140- b.WriteString("Row hints are right-aligned beside each row. ")141- b.WriteString("Column hints are stacked above each column and bottom-aligned to the grid.")142- return b.String(), nil143-}144-145-func renderNurikabe(data []byte) (string, error) {146- var save nurikabe.Save147- if err := json.Unmarshal(data, &save); err != nil {148- return "", fmt.Errorf("decode nurikabe save: %w", err)149- }150-151- clues := parseNurikabeClues(save.Clues, save.Width, save.Height)152- cells := makeGrid(save.Width, save.Height, ".")153- for y := range len(cells) {154- for x := range len(cells[y]) {155- if y < len(clues) && x < len(clues[y]) && clues[y][x] > 0 {156- cells[y][x] = strconv.Itoa(clues[y][x])157- }158- }159- }160-161- var b strings.Builder162- b.WriteString("### Clue Grid\n\n")163- b.WriteString(renderGridTable(cells))164- b.WriteString("\n\n")165- b.WriteString("Goal: build one connected sea while each numbered island has the exact size of its clue.")166- return b.String(), nil167-}168-169-func renderShikaku(data []byte) (string, error) {170- var save shikaku.Save171- if err := json.Unmarshal(data, &save); err != nil {172- return "", fmt.Errorf("decode shikaku save: %w", err)173- }174-175- cells := makeGrid(save.Width, save.Height, ".")176- for _, clue := range save.Clues {177- if clue.Y >= 0 && clue.Y < len(cells) && clue.X >= 0 && clue.X < len(cells[clue.Y]) {178- cells[clue.Y][clue.X] = strconv.Itoa(clue.Value)179- }180- }181-182- var b strings.Builder183- b.WriteString("### Clue Grid\n\n")184- b.WriteString(renderGridTable(cells))185- b.WriteString("\n\n")186- b.WriteString("Goal: partition the grid into rectangles so each rectangle contains one clue and its area matches that clue.")187- return b.String(), nil188-}189-190-func renderSudoku(data []byte) (string, error) {191- var save sudoku.Save192- if err := json.Unmarshal(data, &save); err != nil {193- return "", fmt.Errorf("decode sudoku save: %w", err)194- }195-196- cells := makeGrid(9, 9, ".")197- for _, provided := range save.Provided {198- if provided.Y >= 0 && provided.Y < len(cells) && provided.X >= 0 && provided.X < len(cells[provided.Y]) {199- cells[provided.Y][provided.X] = strconv.Itoa(provided.V)200- }201- }202-203- var b strings.Builder204- b.WriteString("### Given Grid\n\n")205- b.WriteString(renderGridTable(cells))206- b.WriteString("\n\n")207- b.WriteString("Goal: fill each row, column, and 3x3 box with digits 1-9 exactly once.")208- return b.String(), nil209-}210-211-func renderTakuzu(data []byte) (string, error) {212- var save takuzu.Save213- if err := json.Unmarshal(data, &save); err != nil {214- return "", fmt.Errorf("decode takuzu save: %w", err)215- }216-217- cells := makeGrid(save.Size, save.Size, ".")218- stateRows := splitLines(save.State)219- providedRows := splitLines(save.Provided)220-221- for y := range len(cells) {222- for x := range len(cells[y]) {223- provided := y < len(providedRows) && x < len(providedRows[y]) && providedRows[y][x] == '#'224- if !provided {225- continue226- }227-228- if y < len(stateRows) && x < len(stateRows[y]) {229- switch stateRows[y][x] {230- case '0', '1':231- cells[y][x] = string(stateRows[y][x])232- default:233- cells[y][x] = "."234- }235- }236- }237- }238-239- var b strings.Builder240- b.WriteString("### Given Grid\n\n")241- b.WriteString(renderGridTable(cells))242- b.WriteString("\n\n")243- b.WriteString("Goal: fill with 0/1 so no three equal adjacent cells appear, each row/column has equal 0 and 1 counts, and rows/columns are unique.")244- return b.String(), nil245-}246-247-func renderWordSearch(data []byte) (string, error) {248- var save wordsearch.Save249- if err := json.Unmarshal(data, &save); err != nil {250- return "", fmt.Errorf("decode word search save: %w", err)251- }252-253- rows := splitLines(save.Grid)254- height := max(save.Height, len(rows))255- width := save.Width256- if width <= 0 {257- for _, row := range rows {258- if len([]rune(row)) > width {259- width = len([]rune(row))260- }261- }262- }263-264- cells := makeGrid(width, height, ".")265- for y := range len(cells) {266- if y >= len(rows) {267- continue268- }269- row := []rune(rows[y])270- for x := range len(cells[y]) {271- if x < len(row) {272- cells[y][x] = string(row[x])273- }274- }275- }276-277- var b strings.Builder278- b.WriteString("### Grid\n\n")279- b.WriteString(renderGridTable(cells))280- b.WriteString("\n\n")281-282- b.WriteString("### Word List\n\n")283- b.WriteString("| # | Word |\n")284- b.WriteString("| --- | --- |\n")285- for i, word := range save.Words {286- fmt.Fprintf(&b, "| %d | %s |\n", i+1, escapeCell(word.Text))287- }288- if len(save.Words) == 0 {289- b.WriteString("| 1 | (none) |\n")290- }291-292- b.WriteString("\nGoal: find all listed words in the grid.")293- return b.String(), nil294-}295-296-func makeGrid(width, height int, fill string) [][]string {297- if width <= 0 || height <= 0 {298- return [][]string{}299- }300- grid := make([][]string, height)301- for y := range height {302- grid[y] = make([]string, width)303- for x := range width {304- grid[y][x] = fill305- }306- }307- return grid308-}309-310-func renderGridTable(cells [][]string) string {311- if len(cells) == 0 {312- return "_(empty grid)_"313- }314- width := 0315- for _, row := range cells {316- if len(row) > width {317- width = len(row)318- }319- }320- if width == 0 {321- return "_(empty grid)_"322- }323-324- var b strings.Builder325- b.WriteString("| |")326- for x := range width {327- fmt.Fprintf(&b, " %d |", x+1)328- }329- b.WriteString("\n| --- |")330- for range width {331- b.WriteString(" --- |")332- }333- b.WriteString("\n")334-335- for y := range len(cells) {336- fmt.Fprintf(&b, "| %d |", y+1)337- for x := range width {338- cell := "."339- if x < len(cells[y]) && strings.TrimSpace(cells[y][x]) != "" {340- cell = cells[y][x]341- }342- fmt.Fprintf(&b, " %s |", escapeCell(cell))343- }344- b.WriteString("\n")345- }346-347- return strings.TrimRight(b.String(), "\n")348-}349-350-func parseNurikabeClues(raw string, width, height int) [][]int {351- clues := make([][]int, max(height, 0))352- for y := range len(clues) {353- clues[y] = make([]int, max(width, 0))354- }355-356- rows := splitLines(raw)357- for y := range min(len(rows), len(clues)) {358- parts := strings.Split(rows[y], ",")359- for x := range min(len(parts), len(clues[y])) {360- val, err := strconv.Atoi(strings.TrimSpace(parts[x]))361- if err != nil {362- continue363- }364- clues[y][x] = val365- }366- }367-368- return clues369-}370-371-func splitLines(s string) []string {372- if strings.TrimSpace(s) == "" {373- return []string{}374- }375- return strings.Split(s, "\n")376-}377-378-func escapeCell(s string) string {379- return strings.ReplaceAll(s, "|", "\\|")380-}381-382-func normalizeNonogramHints(src nonogram.TomographyDefinition, size int) [][]int {383- hints := make([][]int, max(size, 0))384- for i := range len(hints) {385- if i >= len(src) {386- hints[i] = []int{0}387- continue388- }389- if len(src[i]) == 0 {390- hints[i] = []int{0}391- continue392- }393- hints[i] = append([]int(nil), src[i]...)394- }395- return hints396-}397-398-func maxNonogramHintLen(hints [][]int) int {399- maxLen := 0400- for _, hint := range hints {401- if len(hint) > maxLen {402- maxLen = len(hint)403- }404- }405- return maxLen406-}407-408-func renderNonogramTable(409- rowHints, colHints [][]int,410- width, height, rowHintCols, colHintRows int,411-) string {412- var b strings.Builder413-414- b.WriteString("|")415- for i := range rowHintCols {416- fmt.Fprintf(&b, " R%d |", i+1)417- }418- for x := range width {419- fmt.Fprintf(&b, " C%d |", x+1)420- }421- b.WriteString("\n|")422- for range rowHintCols + width {423- b.WriteString(" --- |")424- }425- b.WriteString("\n")426-427- for hintRow := range colHintRows {428- b.WriteString("|")429- for range rowHintCols {430- b.WriteString(" . |")431- }432- for x := range width {433- b.WriteString(" ")434- b.WriteString(renderColumnHintCell(colHints[x], colHintRows, hintRow))435- b.WriteString(" |")436- }437- b.WriteString("\n")438- }439-440- for y := range height {441- rowHint := rowHints[y]442- hintStart := rowHintCols - len(rowHint)443-444- b.WriteString("|")445- for hintCol := range rowHintCols {446- if hintCol < hintStart {447- b.WriteString(" . |")448- continue449- }450- fmt.Fprintf(&b, " %d |", rowHint[hintCol-hintStart])451- }452- for range width {453- b.WriteString(" . |")454- }455- b.WriteString("\n")456- }457-458- return strings.TrimRight(b.String(), "\n")459-}460-461-func renderColumnHintCell(hint []int, depth, row int) string {462- start := depth - len(hint)463- if row < start {464- return "."465- }466- return strconv.Itoa(hint[row-start])467-}@@ -1,188 +0,0 @@
1-package markdownexport2-3-import (4- "errors"5- "regexp"6- "strings"7- "testing"8- "time"9-10- "github.com/FelineStateMachine/puzzletea/game"11- "github.com/FelineStateMachine/puzzletea/sudoku"12- "github.com/FelineStateMachine/puzzletea/wordsearch"13-14- "charm.land/bubbles/v2/key"15- tea "charm.land/bubbletea/v2"16-)17-18-func TestSupportsGameType(t *testing.T) {19- if !SupportsGameType("Sudoku") {20- t.Fatal("expected sudoku to be supported")21- }22- if SupportsGameType("Lights Out") {23- t.Fatal("expected lights out to be unsupported")24- }25-}26-27-func TestRenderPuzzleSnippetUnsupported(t *testing.T) {28- _, err := RenderPuzzleSnippet("Lights Out", "", []byte(`{}`))29- if !errors.Is(err, ErrUnsupportedGame) {30- t.Fatalf("expected ErrUnsupportedGame, got %v", err)31- }32-}33-34-func TestRenderPuzzleSnippetSudokuUsesProvidedOnly(t *testing.T) {35- data := []byte(`{36- "grid":"500000000\n600000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000",37- "provided":[{"x":0,"y":0,"v":5}]38- }`)39-40- snippet, err := RenderPuzzleSnippet("Sudoku", "", data)41- if err != nil {42- t.Fatal(err)43- }44-45- if !strings.Contains(snippet, "| 1 | 5 | . | . | . | . | . | . | . | . |") {46- t.Fatalf("expected given row in snippet, got:\n%s", snippet)47- }48- if !strings.Contains(snippet, "| 2 | . | . | . | . | . | . | . | . | . |") {49- t.Fatalf("expected user-entered row to be blank in snippet, got:\n%s", snippet)50- }51-}52-53-func TestRenderPuzzleSnippetTakuzuUsesProvidedOnly(t *testing.T) {54- data := []byte(`{55- "size":2,56- "state":"01\n10",57- "provided":"#.\n.#",58- "mode_title":"Test"59- }`)60-61- snippet, err := RenderPuzzleSnippet("Takuzu", "", data)62- if err != nil {63- t.Fatal(err)64- }65-66- if !strings.Contains(snippet, "| 1 | 0 | . |") {67- t.Fatalf("expected first row to keep only provided value, got:\n%s", snippet)68- }69- if !strings.Contains(snippet, "| 2 | . | 0 |") {70- t.Fatalf("expected second row to keep only provided value, got:\n%s", snippet)71- }72-}73-74-func TestRenderPuzzleSnippetNonogramIntegratedHintsLayout(t *testing.T) {75- data := []byte(`{76- "width":3,77- "height":2,78- "row-hints":[[3],[1,1]],79- "col-hints":[[1],[2],[1]]80- }`)81-82- snippet, err := RenderPuzzleSnippet("Nonogram", "", data)83- if err != nil {84- t.Fatal(err)85- }86-87- if !strings.Contains(snippet, "### Puzzle Grid with Integrated Hints") {88- t.Fatalf("expected integrated nonogram heading, got:\n%s", snippet)89- }90- if strings.Contains(snippet, "### Row Hints") || strings.Contains(snippet, "### Column Hints") {91- t.Fatalf("expected legacy split hint sections to be removed, got:\n%s", snippet)92- }93- if !strings.Contains(snippet, "| . | . | 1 | 2 | 1 |") {94- t.Fatalf("expected column hints row aligned above puzzle grid, got:\n%s", snippet)95- }96- if !strings.Contains(snippet, "| . | 3 | . | . | . |") {97- t.Fatalf("expected single row hint to be right-aligned near grid, got:\n%s", snippet)98- }99- if !strings.Contains(snippet, "| 1 | 1 | . | . | . |") {100- t.Fatalf("expected multi-value row hint to render beside puzzle row, got:\n%s", snippet)101- }102-}103-104-func TestRenderPuzzleSnippetNonogramColumnHintsBottomAligned(t *testing.T) {105- data := []byte(`{106- "width":2,107- "height":1,108- "row-hints":[[1]],109- "col-hints":[[2,1],[3]]110- }`)111-112- snippet, err := RenderPuzzleSnippet("Nonogram", "", data)113- if err != nil {114- t.Fatal(err)115- }116-117- if !strings.Contains(snippet, "| . | 2 | . |") {118- t.Fatalf("expected top column hint row to pad shorter hints, got:\n%s", snippet)119- }120- if !strings.Contains(snippet, "| . | 1 | 3 |") {121- t.Fatalf("expected lower column hint row to sit closest to grid, got:\n%s", snippet)122- }123-}124-125-func TestDetectGameType(t *testing.T) {126- gameType, err := DetectGameType(sudoku.Model{})127- if err != nil {128- t.Fatal(err)129- }130- if gameType != "Sudoku" {131- t.Fatalf("gameType = %q, want %q", gameType, "Sudoku")132- }133-134- gameType, err = DetectGameType(&wordsearch.Model{})135- if err != nil {136- t.Fatal(err)137- }138- if gameType != "Word Search" {139- t.Fatalf("gameType = %q, want %q", gameType, "Word Search")140- }141-142- _, err = DetectGameType(testUnknownGamer{})143- if err == nil {144- t.Fatal("expected unknown gamer detection error")145- }146-}147-148-func TestBuildDocument(t *testing.T) {149- doc := BuildDocument(DocumentConfig{150- Version: "v-test",151- Category: "Sudoku",152- ModeSelection: "mixed modes",153- Count: 2,154- Seed: "seed-1",155- GeneratedAt: time.Date(2026, 2, 22, 10, 0, 0, 0, time.UTC),156- }, []PuzzleSection{157- {Index: 1, GameType: "Sudoku", Mode: "Easy", Body: "body-one"},158- {Index: 2, GameType: "Sudoku", Mode: "Hard", Body: "body-two"},159- })160-161- if !strings.Contains(doc, "# PuzzleTea Export") {162- t.Fatal("expected export title in markdown document")163- }164- if !strings.Contains(doc, "Version: v-test") {165- t.Fatal("expected version metadata in markdown document")166- }167- if matched := regexp.MustCompile(`## [a-z]+-[a-z]+ - 1`).MatchString(doc); !matched {168- t.Fatal("expected first puzzle heading in adjective-noun pattern")169- }170- if matched := regexp.MustCompile(`\n---\n\n## [a-z]+-[a-z]+ - 2`).MatchString(doc); !matched {171- t.Fatal("expected puzzle separator and second heading in adjective-noun pattern")172- }173- if strings.Contains(doc, "## Puzzle 1 - Sudoku (Easy)") {174- t.Fatal("expected legacy puzzle heading format to be removed")175- }176-}177-178-type testUnknownGamer struct{}179-180-func (testUnknownGamer) GetDebugInfo() string { return "" }181-func (testUnknownGamer) GetFullHelp() [][]key.Binding { return nil }182-func (testUnknownGamer) GetSave() ([]byte, error) { return nil, nil }183-func (testUnknownGamer) IsSolved() bool { return false }184-func (testUnknownGamer) Reset() game.Gamer { return testUnknownGamer{} }185-func (testUnknownGamer) SetTitle(string) game.Gamer { return testUnknownGamer{} }186-func (testUnknownGamer) Init() tea.Cmd { return nil }187-func (testUnknownGamer) View() string { return "" }188-func (testUnknownGamer) Update(tea.Msg) (game.Gamer, tea.Cmd) { return testUnknownGamer{}, nil }@@ -1,26 +0,0 @@
1-package markdownexport2-3-import (4- "errors"5- "strings"6-)7-8-var ErrUnsupportedGame = errors.New("game does not support markdown export")9-10-func SupportsGameType(gameType string) bool {11- switch normalizeGameType(gameType) {12- case "hashiwokakero", "hitori", "nonogram", "nurikabe", "shikaku", "sudoku", "takuzu", "word search", "wordsearch":13- return true14- case "lights out", "lights-out", "lightsout", "lights":15- return false16- default:17- return false18- }19-}20-21-func normalizeGameType(s string) string {22- s = strings.ToLower(strings.TrimSpace(s))23- s = strings.ReplaceAll(s, "-", " ")24- s = strings.ReplaceAll(s, "_", " ")25- return strings.Join(strings.Fields(s), " ")26-}@@ -0,0 +1,87 @@
1+package nonogram2+3+import (4+ "encoding/json"5+ "fmt"6+ "strings"7+8+ "github.com/FelineStateMachine/puzzletea/game"9+ "github.com/FelineStateMachine/puzzletea/pdfexport"10+ "github.com/go-pdf/fpdf"11+)12+13+type printAdapter struct{}14+15+func (printAdapter) CanonicalGameType() string { return "Nonogram" }16+func (printAdapter) Aliases() []string { return []string{"nonogram"} }17+18+func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) {19+ var data Save20+ if err := json.Unmarshal(save, &data); err != nil {21+ return "", fmt.Errorf("decode nonogram save: %w", err)22+ }23+24+ width := data.Width25+ height := data.Height26+ if width <= 0 {27+ width = len(data.ColHints)28+ }29+ if height <= 0 {30+ height = len(data.RowHints)31+ }32+ if width <= 0 || height <= 0 {33+ return "### Puzzle Grid with Integrated Hints\n\n_(empty grid)_", nil34+ }35+36+ rowHints := game.NormalizeNonogramHints(data.RowHints, height)37+ colHints := game.NormalizeNonogramHints(data.ColHints, width)38+ rowHintCols := game.MaxNonogramHintLen(rowHints)39+ colHintRows := game.MaxNonogramHintLen(colHints)40+ if rowHintCols < 1 {41+ rowHintCols = 142+ }43+ if colHintRows < 1 {44+ colHintRows = 145+ }46+47+ var b strings.Builder48+ b.WriteString("### Puzzle Grid with Integrated Hints\n\n")49+ b.WriteString(game.RenderNonogramTable(rowHints, colHints, width, height, rowHintCols, colHintRows))50+ b.WriteString("\n\n")51+ b.WriteString("Row hints are right-aligned beside each row. ")52+ b.WriteString("Column hints are stacked above each column and bottom-aligned to the grid.")53+ return b.String(), nil54+}55+56+func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) {57+ payload, err := pdfexport.ParseNonogramPrintData(save)58+ if err != nil {59+ return nil, err60+ }61+ if !game.IsNilPrintPayload(payload) {62+ return payload, nil63+ }64+ if strings.TrimSpace(snippet) == "" {65+ return nil, nil66+ }67+68+ nonogram, _, err := pdfexport.ParsePrintableFromSnippet("Nonogram", snippet)69+ if err != nil {70+ return nil, err71+ }72+ return nonogram, nil73+}74+75+func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {76+ switch data := payload.(type) {77+ case *pdfexport.NonogramData:78+ pdfexport.RenderNonogramPage(pdf, data)79+ case *pdfexport.GridTable:80+ pdfexport.RenderGridTablePage(pdf, data)81+ }82+ return nil83+}84+85+func init() {86+ game.RegisterPrintAdapter(printAdapter{})87+}@@ -0,0 +1,74 @@
1+package nurikabe2+3+import (4+ "encoding/json"5+ "fmt"6+ "strconv"7+ "strings"8+9+ "github.com/FelineStateMachine/puzzletea/game"10+ "github.com/FelineStateMachine/puzzletea/pdfexport"11+ "github.com/go-pdf/fpdf"12+)13+14+type printAdapter struct{}15+16+func (printAdapter) CanonicalGameType() string { return "Nurikabe" }17+func (printAdapter) Aliases() []string { return []string{"nurikabe"} }18+19+func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) {20+ var data Save21+ if err := json.Unmarshal(save, &data); err != nil {22+ return "", fmt.Errorf("decode nurikabe save: %w", err)23+ }24+25+ clues := game.ParseCSVIntGrid(data.Clues, data.Width, data.Height)26+ cells := game.MakeStringGrid(data.Width, data.Height, ".")27+ for y := range len(cells) {28+ for x := range len(cells[y]) {29+ if y < len(clues) && x < len(clues[y]) && clues[y][x] > 0 {30+ cells[y][x] = strconv.Itoa(clues[y][x])31+ }32+ }33+ }34+35+ var b strings.Builder36+ b.WriteString("### Clue Grid\n\n")37+ b.WriteString(game.RenderGridTable(cells))38+ b.WriteString("\n\n")39+ b.WriteString("Goal: build one connected sea while each numbered island has the exact size of its clue.")40+ return b.String(), nil41+}42+43+func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) {44+ payload, err := pdfexport.ParseNurikabePrintData(save)45+ if err != nil {46+ return nil, err47+ }48+ if !game.IsNilPrintPayload(payload) {49+ return payload, nil50+ }51+ if strings.TrimSpace(snippet) == "" {52+ return nil, nil53+ }54+55+ _, table, err := pdfexport.ParsePrintableFromSnippet("Nurikabe", snippet)56+ if err != nil {57+ return nil, err58+ }59+ return table, nil60+}61+62+func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {63+ switch data := payload.(type) {64+ case *pdfexport.NurikabeData:65+ pdfexport.RenderNurikabePage(pdf, data)66+ case *pdfexport.GridTable:67+ pdfexport.RenderGridTablePage(pdf, data)68+ }69+ return nil70+}71+72+func init() {73+ game.RegisterPrintAdapter(printAdapter{})74+}@@ -7,6 +7,8 @@ import (
77 "os"88 "path/filepath"99 "strings"10+11+ "github.com/FelineStateMachine/puzzletea/game"1012 )1113 1214 const ExportSchemaV1 = "puzzletea.export.v1"@@ -112,16 +114,19 @@ func ParseJSONLFile(path string) (PackDocument, error) {
112114 Body: record.Puzzle.Snippet,113115 SaveData: append([]byte(nil), record.Puzzle.Save...),114116 }115- hydratePuzzlePrintData(&p)116-117- if !hasRenderablePrintData(p) && strings.TrimSpace(record.Puzzle.Snippet) != "" {118- nonogram, table, err := ParsePrintableFromSnippet(category, record.Puzzle.Snippet)119- if err != nil {120- return PackDocument{}, fmt.Errorf("%s:%d: parse printable snippet: %w", path, lineNo, err)121- }122- p.Nonogram = nonogram123- p.Table = table117+118+ adapter, ok := game.LookupPrintAdapter(category)119+ if !ok {120+ continue121+ }122+ payload, err := adapter.BuildPDFPayload(p.SaveData, record.Puzzle.Snippet)123+ if err != nil {124+ return PackDocument{}, fmt.Errorf("%s:%d: build print payload: %w", path, lineNo, err)124125 }126+ if game.IsNilPrintPayload(payload) {127+ continue128+ }129+ p.PrintPayload = payload125130 126131 puzzles = append(puzzles, p)127132 }@@ -132,10 +137,6 @@ func ParseJSONLFile(path string) (PackDocument, error) {
132137 if !seenAny {133138 return PackDocument{}, fmt.Errorf("%s: input jsonl is empty", path)134139 }135- if len(puzzles) == 0 {136- return PackDocument{}, fmt.Errorf("%s: no puzzle records found", path)137- }138-139140 if doc.Metadata.Count == 0 {140141 doc.Metadata.Count = len(puzzles)141142 }@@ -159,15 +160,3 @@ func ParsePrintableFromSnippet(category, snippet string) (*NonogramData, *GridTa
159160 }160161 return nil, table, nil161162 }162-163-func hasRenderablePrintData(p Puzzle) bool {164- return p.Nonogram != nil ||165- p.Nurikabe != nil ||166- p.Shikaku != nil ||167- p.Hashi != nil ||168- p.Hitori != nil ||169- p.Takuzu != nil ||170- p.Sudoku != nil ||171- p.WordSearch != nil ||172- p.Table != nil173-}@@ -4,8 +4,13 @@ import (
44 "encoding/json"55 "os"66 "path/filepath"7+ "reflect"78 "strings"9+ "sync"810 "testing"11+12+ "github.com/FelineStateMachine/puzzletea/game"13+ "github.com/go-pdf/fpdf"914 )1015 1116 func TestParseJSONLFile(t *testing.T) {@@ -42,7 +47,8 @@ func TestParseJSONLFile(t *testing.T) {
4247 if got, want := len(doc.Puzzles), 1; got != want {4348 t.Fatalf("puzzles = %d, want %d", got, want)4449 }45- if doc.Puzzles[0].Nonogram == nil {50+ payload, ok := doc.Puzzles[0].PrintPayload.(*NonogramData)51+ if !ok || payload == nil {4652 t.Fatal("expected nonogram print payload from snippet fallback")4753 }4854 }@@ -75,7 +81,8 @@ func TestParseJSONLFileHydratesNonogramFromSaveWithoutSnippet(t *testing.T) {
7581 if got, want := len(doc.Puzzles), 1; got != want {7682 t.Fatalf("puzzles = %d, want %d", got, want)7783 }78- if doc.Puzzles[0].Nonogram == nil {84+ payload, ok := doc.Puzzles[0].PrintPayload.(*NonogramData)85+ if !ok || payload == nil {7986 t.Fatal("expected nonogram print payload from save hydration")8087 }8188 }@@ -124,10 +131,11 @@ func TestParseJSONLFileHydratesTakuzuFromSave(t *testing.T) {
124131 if got, want := len(doc.Puzzles), 1; got != want {125132 t.Fatalf("puzzles = %d, want %d", got, want)126133 }127- if doc.Puzzles[0].Takuzu == nil {134+ payload, ok := doc.Puzzles[0].PrintPayload.(*TakuzuData)135+ if !ok || payload == nil {128136 t.Fatal("expected takuzu print payload from save hydration")129137 }130- if got, want := doc.Puzzles[0].Takuzu.Givens[1][1], ""; got != want {138+ if got, want := payload.Givens[1][1], ""; got != want {131139 t.Fatalf("takuzu row 1 col 1 = %q, want empty", got)132140 }133141 }@@ -160,10 +168,11 @@ func TestParseJSONLFileHydratesSudokuFromSave(t *testing.T) {
160168 if got, want := len(doc.Puzzles), 1; got != want {161169 t.Fatalf("puzzles = %d, want %d", got, want)162170 }163- if doc.Puzzles[0].Sudoku == nil {171+ payload, ok := doc.Puzzles[0].PrintPayload.(*SudokuData)172+ if !ok || payload == nil {164173 t.Fatal("expected sudoku print payload from save hydration")165174 }166- if got, want := doc.Puzzles[0].Sudoku.Givens[0][0], 5; got != want {175+ if got, want := payload.Givens[0][0], 5; got != want {167176 t.Fatalf("sudoku givens[0][0] = %d, want %d", got, want)168177 }169178 }@@ -196,13 +205,14 @@ func TestParseJSONLFileHydratesWordSearchFromSave(t *testing.T) {
196205 if got, want := len(doc.Puzzles), 1; got != want {197206 t.Fatalf("puzzles = %d, want %d", got, want)198207 }199- if doc.Puzzles[0].WordSearch == nil {208+ payload, ok := doc.Puzzles[0].PrintPayload.(*WordSearchData)209+ if !ok || payload == nil {200210 t.Fatal("expected word search print payload from save hydration")201211 }202- if got, want := len(doc.Puzzles[0].WordSearch.Words), 2; got != want {212+ if got, want := len(payload.Words), 2; got != want {203213 t.Fatalf("word count = %d, want %d", got, want)204214 }205- if got, want := doc.Puzzles[0].WordSearch.Words[0], "ACE"; got != want {215+ if got, want := payload.Words[0], "ACE"; got != want {206216 t.Fatalf("first word = %q, want %q", got, want)207217 }208218 }@@ -235,10 +245,11 @@ func TestParseJSONLFileHydratesNurikabeFromSave(t *testing.T) {
235245 if got, want := len(doc.Puzzles), 1; got != want {236246 t.Fatalf("puzzles = %d, want %d", got, want)237247 }238- if doc.Puzzles[0].Nurikabe == nil {248+ payload, ok := doc.Puzzles[0].PrintPayload.(*NurikabeData)249+ if !ok || payload == nil {239250 t.Fatal("expected nurikabe print payload from save hydration")240251 }241- if got, want := doc.Puzzles[0].Nurikabe.Clues[1][1], 2; got != want {252+ if got, want := payload.Clues[1][1], 2; got != want {242253 t.Fatalf("nurikabe clues[1][1] = %d, want %d", got, want)243254 }244255 }@@ -271,10 +282,11 @@ func TestParseJSONLFileHydratesShikakuFromSave(t *testing.T) {
271282 if got, want := len(doc.Puzzles), 1; got != want {272283 t.Fatalf("puzzles = %d, want %d", got, want)273284 }274- if doc.Puzzles[0].Shikaku == nil {285+ payload, ok := doc.Puzzles[0].PrintPayload.(*ShikakuData)286+ if !ok || payload == nil {275287 t.Fatal("expected shikaku print payload from save hydration")276288 }277- if got, want := doc.Puzzles[0].Shikaku.Clues[1][1], 4; got != want {289+ if got, want := payload.Clues[1][1], 4; got != want {278290 t.Fatalf("shikaku clues[1][1] = %d, want %d", got, want)279291 }280292 }@@ -307,10 +319,11 @@ func TestParseJSONLFileHydratesHashiFromSave(t *testing.T) {
307319 if got, want := len(doc.Puzzles), 1; got != want {308320 t.Fatalf("puzzles = %d, want %d", got, want)309321 }310- if doc.Puzzles[0].Hashi == nil {322+ payload, ok := doc.Puzzles[0].PrintPayload.(*HashiData)323+ if !ok || payload == nil {311324 t.Fatal("expected hashi print payload from save hydration")312325 }313- if got, want := len(doc.Puzzles[0].Hashi.Islands), 2; got != want {326+ if got, want := len(payload.Islands), 2; got != want {314327 t.Fatalf("hashi island count = %d, want %d", got, want)315328 }316329 }@@ -344,12 +357,13 @@ func TestParseJSONLFileIgnoresMalformedSnippetWhenSaveHydrated(t *testing.T) {
344357 if got, want := len(doc.Puzzles), 1; got != want {345358 t.Fatalf("puzzles = %d, want %d", got, want)346359 }347- if doc.Puzzles[0].Sudoku == nil {360+ payload, ok := doc.Puzzles[0].PrintPayload.(*SudokuData)361+ if !ok || payload == nil {348362 t.Fatal("expected sudoku print payload from save hydration")349363 }350364 }351365 352-func TestParseJSONLFileFailsMalformedSnippetWithoutRenderablePayload(t *testing.T) {366+func TestParseJSONLFileSilentlySkipsUnsupportedGame(t *testing.T) {353367 path := filepath.Join(t.TempDir(), "lights-malformed-snippet.jsonl")354368 record := JSONLRecord{355369 Schema: ExportSchemaV1,@@ -371,12 +385,12 @@ func TestParseJSONLFileFailsMalformedSnippetWithoutRenderablePayload(t *testing.
371385 }372386 writeSingleJSONLRecord(t, path, record)373387 374- _, err := ParseJSONLFile(path)375- if err == nil {376- t.Fatal("expected parse error when neither save nor snippet produces renderable payload")388+ doc, err := ParseJSONLFile(path)389+ if err != nil {390+ t.Fatalf("expected silent no-op for unsupported game, got: %v", err)377391 }378- if !strings.Contains(err.Error(), "parse printable snippet") {379- t.Fatalf("unexpected error: %v", err)392+ if got := len(doc.Puzzles); got != 0 {393+ t.Fatalf("puzzles = %d, want 0", got)380394 }381395 }382396 @@ -390,3 +404,97 @@ func writeSingleJSONLRecord(t *testing.T, path string, record JSONLRecord) {
390404 t.Fatal(err)391405 }392406 }407+408+var registerJSONLAdaptersOnce sync.Once409+410+func init() {411+ ensureJSONLTestAdapters()412+}413+414+func ensureJSONLTestAdapters() {415+ registerJSONLAdaptersOnce.Do(func() {416+ register := func(category string, build func(save []byte, snippet string) (any, error), aliases ...string) {417+ game.RegisterPrintAdapter(jsonlTestAdapter{418+ category: category,419+ aliases: aliases,420+ build: build,421+ })422+ }423+424+ register("Nonogram", buildPayloadAdapter("Nonogram", func(save []byte) (any, error) {425+ return ParseNonogramPrintData(save)426+ }), "nonogram")427+ register("Takuzu", buildPayloadAdapter("Takuzu", func(save []byte) (any, error) {428+ return ParseTakuzuPrintData(save)429+ }), "takuzu")430+ register("Sudoku", buildPayloadAdapter("Sudoku", func(save []byte) (any, error) {431+ return ParseSudokuPrintData(save)432+ }), "sudoku")433+ register("Word Search", buildPayloadAdapter("Word Search", func(save []byte) (any, error) {434+ return ParseWordSearchPrintData(save)435+ }), "wordsearch")436+ register("Nurikabe", buildPayloadAdapter("Nurikabe", func(save []byte) (any, error) {437+ return ParseNurikabePrintData(save)438+ }), "nurikabe")439+ register("Shikaku", buildPayloadAdapter("Shikaku", func(save []byte) (any, error) {440+ return ParseShikakuPrintData(save)441+ }), "shikaku")442+ register("Hashiwokakero", buildPayloadAdapter("Hashiwokakero", func(save []byte) (any, error) {443+ return ParseHashiPrintData(save)444+ }), "hashi")445+ })446+}447+448+func buildPayloadAdapter(category string, parse func(save []byte) (any, error)) func([]byte, string) (any, error) {449+ return func(save []byte, snippet string) (any, error) {450+ payload, err := parse(save)451+ if err != nil {452+ return nil, err453+ }454+ if !isNilAny(payload) {455+ return payload, nil456+ }457+ if strings.TrimSpace(snippet) == "" {458+ return nil, nil459+ }460+461+ nonogram, table, err := ParsePrintableFromSnippet(category, snippet)462+ if err != nil {463+ return nil, err464+ }465+ if nonogram != nil {466+ return nonogram, nil467+ }468+ return table, nil469+ }470+}471+472+func isNilAny(v any) bool {473+ if v == nil {474+ return true475+ }476+ rv := reflect.ValueOf(v)477+ switch rv.Kind() {478+ case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:479+ return rv.IsNil()480+ default:481+ return false482+ }483+}484+485+type jsonlTestAdapter struct {486+ category string487+ aliases []string488+ build func(save []byte, snippet string) (any, error)489+}490+491+func (a jsonlTestAdapter) CanonicalGameType() string { return a.category }492+func (a jsonlTestAdapter) Aliases() []string { return a.aliases }493+func (a jsonlTestAdapter) RenderMarkdownSnippet([]byte) (string, error) {494+ return "", nil495+}496+497+func (a jsonlTestAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) {498+ return a.build(save, snippet)499+}500+func (a jsonlTestAdapter) RenderPDFBody(*fpdf.Fpdf, any) error { return nil }@@ -178,7 +178,7 @@ func parsePuzzleSection(section []string, path string, startLine int, meta PackM
178178 if err != nil {179179 return Puzzle{}, err180180 }181- p.Nonogram = nonogram181+ p.PrintPayload = nonogram182182 return p, nil183183 }184184 @@ -186,7 +186,7 @@ func parsePuzzleSection(section []string, path string, startLine int, meta PackM
186186 if err != nil {187187 return Puzzle{}, err188188 }189- p.Table = table189+ p.PrintPayload = table190190 191191 return p, nil192192 }@@ -21,21 +21,22 @@ func TestParseMarkdownNonogram(t *testing.T) {
2121 }2222 2323 p := doc.Puzzles[0]24- if p.Nonogram == nil {24+ nonogram, ok := p.PrintPayload.(*NonogramData)25+ if !ok || nonogram == nil {2526 t.Fatal("expected parsed nonogram data")2627 }27- if p.Nonogram.Width != 2 || p.Nonogram.Height != 2 {28- t.Fatalf("nonogram size = %dx%d, want 2x2", p.Nonogram.Width, p.Nonogram.Height)28+ if nonogram.Width != 2 || nonogram.Height != 2 {29+ t.Fatalf("nonogram size = %dx%d, want 2x2", nonogram.Width, nonogram.Height)2930 }3031 31- if got, want := p.Nonogram.RowHints[0][0], 1; got != want {32+ if got, want := nonogram.RowHints[0][0], 1; got != want {3233 t.Fatalf("first row first hint = %d, want %d", got, want)3334 }34- if got, want := p.Nonogram.ColHints[0][0], 1; got != want {35+ if got, want := nonogram.ColHints[0][0], 1; got != want {3536 t.Fatalf("first col first hint = %d, want %d", got, want)3637 }3738 38- if got, want := p.Nonogram.Grid[0][0], " "; got != want {39+ if got, want := nonogram.Grid[0][0], " "; got != want {3940 t.Fatalf("grid dot replacement = %q, want %q", got, want)4041 }4142 }@@ -80,20 +81,18 @@ func TestParseMarkdownTakuzuTable(t *testing.T) {
8081 }8182 8283 p := doc.Puzzles[0]83- if p.Nonogram != nil {84- t.Fatal("expected nonogram data to be nil for takuzu")85- }86- if p.Table == nil {84+ table, ok := p.PrintPayload.(*GridTable)85+ if !ok || table == nil {8786 t.Fatal("expected parsed grid table for takuzu")8887 }89- if !p.Table.HasHeaderRow {88+ if !table.HasHeaderRow {9089 t.Fatal("expected takuzu table to detect a header row")9190 }92- if !p.Table.HasHeaderCol {91+ if !table.HasHeaderCol {9392 t.Fatal("expected takuzu table to detect a header column")9493 }9594 96- if got, want := p.Table.Rows[1][1], "."; got != want {95+ if got, want := table.Rows[1][1], "."; got != want {9796 t.Fatalf("table cell = %q, want %q", got, want)9897 }9998 }@@ -399,86 +399,6 @@ func ParseWordSearchPrintData(saveData []byte) (*WordSearchData, error) {
399399 }, nil400400 }401401 402-func hydratePuzzlePrintData(p *Puzzle) {403- if p == nil {404- return405- }406-407- switch normalizePrintableCategory(p.Category) {408- case "nonogram":409- if p.Nonogram != nil {410- return411- }412- nonogram, err := ParseNonogramPrintData(p.SaveData)413- if err == nil {414- p.Nonogram = nonogram415- }416- case "hashiwokakero":417- if p.Hashi != nil {418- return419- }420- hashi, err := ParseHashiPrintData(p.SaveData)421- if err == nil {422- p.Hashi = hashi423- }424- case "nurikabe":425- if p.Nurikabe != nil {426- return427- }428- nurikabe, err := ParseNurikabePrintData(p.SaveData)429- if err == nil {430- p.Nurikabe = nurikabe431- }432- case "shikaku":433- if p.Shikaku != nil {434- return435- }436- shikaku, err := ParseShikakuPrintData(p.SaveData)437- if err == nil {438- p.Shikaku = shikaku439- }440- case "hitori":441- if p.Hitori != nil {442- return443- }444- hitori, err := ParseHitoriPrintData(p.SaveData)445- if err == nil {446- p.Hitori = hitori447- }448- case "takuzu":449- if p.Takuzu != nil {450- return451- }452- takuzu, err := ParseTakuzuPrintData(p.SaveData)453- if err == nil {454- p.Takuzu = takuzu455- }456- case "sudoku":457- if p.Sudoku != nil {458- return459- }460- sudoku, err := ParseSudokuPrintData(p.SaveData)461- if err == nil {462- p.Sudoku = sudoku463- }464- case "wordsearch":465- if p.WordSearch != nil {466- return467- }468- wordSearch, err := ParseWordSearchPrintData(p.SaveData)469- if err == nil {470- p.WordSearch = wordSearch471- }472- }473-}474-475-func normalizePrintableCategory(category string) string {476- category = strings.ToLower(strings.TrimSpace(category))477- category = strings.ReplaceAll(category, "-", "")478- category = strings.Join(strings.Fields(category), "")479- return category480-}481-482402 func isSudokuCellInBounds(x, y int) bool {483403 return x >= 0 && x < 9 && y >= 0 && y < 9484404 }@@ -181,83 +181,3 @@ func TestParseTakuzuPrintData(t *testing.T) {
181181 t.Fatalf("row 3 col 1 = %q, want empty", got)182182 }183183 }184-185-func TestHydratePuzzlePrintDataForTakuzu(t *testing.T) {186- p := Puzzle{187- Category: "Takuzu",188- SaveData: []byte(`{"size":2,"state":"01\n10","provided":"##\n#."}`),189- }190-191- hydratePuzzlePrintData(&p)192- if p.Takuzu == nil {193- t.Fatal("expected takuzu payload from save hydration")194- }195- if got, want := p.Takuzu.Givens[0][1], "1"; got != want {196- t.Fatalf("row 0 col 1 = %q, want %q", got, want)197- }198- if got, want := p.Takuzu.Givens[1][1], ""; got != want {199- t.Fatalf("row 1 col 1 = %q, want empty", got)200- }201-}202-203-func TestHydratePuzzlePrintDataForNonogram(t *testing.T) {204- p := Puzzle{205- Category: "Nonogram",206- SaveData: []byte(`{"width":2,"height":2,"row-hints":[[1],[1]],"col-hints":[[1],[1]],"state":" \n "}`),207- }208-209- hydratePuzzlePrintData(&p)210- if p.Nonogram == nil {211- t.Fatal("expected nonogram payload from save hydration")212- }213- if got, want := p.Nonogram.Width, 2; got != want {214- t.Fatalf("width = %d, want %d", got, want)215- }216- if got, want := p.Nonogram.Height, 2; got != want {217- t.Fatalf("height = %d, want %d", got, want)218- }219-}220-221-func TestHydratePuzzlePrintDataForNurikabeAndShikaku(t *testing.T) {222- nurikabePuzzle := Puzzle{223- Category: "Nurikabe",224- SaveData: []byte(`{"width":2,"height":2,"clues":"1,0\n0,2","marks":"??\n??"}`),225- }226- hydratePuzzlePrintData(&nurikabePuzzle)227- if nurikabePuzzle.Nurikabe == nil {228- t.Fatal("expected nurikabe payload from save hydration")229- }230- if got, want := nurikabePuzzle.Nurikabe.Clues[1][1], 2; got != want {231- t.Fatalf("nurikabe row 1 col 1 = %d, want %d", got, want)232- }233-234- shikakuPuzzle := Puzzle{235- Category: "Shikaku",236- SaveData: []byte(`{"width":2,"height":2,"clues":[{"x":0,"y":0,"value":1},{"x":1,"y":1,"value":3}]}`),237- }238- hydratePuzzlePrintData(&shikakuPuzzle)239- if shikakuPuzzle.Shikaku == nil {240- t.Fatal("expected shikaku payload from save hydration")241- }242- if got, want := shikakuPuzzle.Shikaku.Clues[1][1], 3; got != want {243- t.Fatalf("shikaku row 1 col 1 = %d, want %d", got, want)244- }245-}246-247-func TestHydratePuzzlePrintDataForHashi(t *testing.T) {248- puzzle := Puzzle{249- Category: "Hashiwokakero",250- SaveData: []byte(`{"width":5,"height":5,"islands":[{"x":0,"y":0,"required":2},{"x":4,"y":4,"required":3}],"bridges":[]}`),251- }252-253- hydratePuzzlePrintData(&puzzle)254- if puzzle.Hashi == nil {255- t.Fatal("expected hashi payload from save hydration")256- }257- if got, want := len(puzzle.Hashi.Islands), 2; got != want {258- t.Fatalf("island count = %d, want %d", got, want)259- }260- if got, want := puzzle.Hashi.Islands[1].Required, 3; got != want {261- t.Fatalf("island[1].required = %d, want %d", got, want)262- }263-}@@ -10,6 +10,7 @@ import (
1010 "strings"1111 "time"1212 13+ "github.com/FelineStateMachine/puzzletea/game"1314 "github.com/go-pdf/fpdf"1415 )1516 @@ -17,8 +18,10 @@ func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg Rend
1718 if strings.TrimSpace(outputPath) == "" {1819 return fmt.Errorf("output path is required")1920 }20- if len(puzzles) == 0 {21- return fmt.Errorf("no puzzles to render")21+22+ printablePuzzles := filterPrintablePuzzles(puzzles)23+ if len(printablePuzzles) == 0 {24+ return nil2225 }2326 2427 if cfg.GeneratedAt.IsZero() {@@ -68,9 +71,9 @@ func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg Rend
6871 })6972 7073 coverColor := resolveCoverColor(cfg)71- renderCoverPage(pdf, puzzles, cfg)72- renderTitlePage(pdf, docs, puzzles, cfg)73- for _, puzzle := range puzzles {74+ renderCoverPage(pdf, printablePuzzles, cfg)75+ renderTitlePage(pdf, docs, printablePuzzles, cfg)76+ for _, puzzle := range printablePuzzles {7477 renderPuzzlePage(pdf, puzzle)7578 }7679 @@ -108,6 +111,20 @@ func saddleStitchPadCount(totalPages int) int {
108111 return 4 - remainder109112 }110113 114+func filterPrintablePuzzles(puzzles []Puzzle) []Puzzle {115+ printable := make([]Puzzle, 0, len(puzzles))116+ for _, puzzle := range puzzles {117+ if game.IsNilPrintPayload(puzzle.PrintPayload) {118+ continue119+ }120+ if _, ok := game.LookupPrintAdapter(puzzle.Category); !ok {121+ continue122+ }123+ printable = append(printable, puzzle)124+ }125+ return printable126+}127+111128 func renderPadPage(pdf *fpdf.Fpdf) {112129 pdf.AddPage()113130 }@@ -239,9 +256,16 @@ func titlePageSourceTableWhitespace(maxY, sourceStartY float64, docCount int) fl
239256 }240257 241258 func renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) {259+ if game.IsNilPrintPayload(puzzle.PrintPayload) {260+ return261+ }262+ adapter, ok := game.LookupPrintAdapter(puzzle.Category)263+ if !ok {264+ return265+ }266+242267 pdf.AddPage()243- pageW, pageH := pdf.GetPageSize()244- hydratePuzzlePrintData(&puzzle)268+ pageW, _ := pdf.GetPageSize()245269 246270 setPuzzleTitleStyle(pdf)247271 pdf.SetXY(0, 10)@@ -256,44 +280,7 @@ func renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) {
256280 }257281 subtitle := strings.Join(subtitleParts, " | ")258282 pdf.CellFormat(pageW, 5, subtitle, "", 0, "C", false, 0, "")259-260- if puzzle.Nonogram != nil {261- renderNonogramPage(pdf, puzzle.Nonogram)262- return263- }264- if puzzle.Nurikabe != nil {265- renderNurikabePage(pdf, puzzle.Nurikabe)266- return267- }268- if puzzle.Shikaku != nil {269- renderShikakuPage(pdf, puzzle.Shikaku)270- return271- }272- if puzzle.Hashi != nil {273- renderHashiPage(pdf, puzzle.Hashi)274- return275- }276- if puzzle.Hitori != nil {277- renderHitoriPage(pdf, puzzle.Hitori)278- return279- }280- if puzzle.Takuzu != nil {281- renderTakuzuPage(pdf, puzzle.Takuzu)282- return283- }284- if puzzle.Sudoku != nil {285- renderSudokuPage(pdf, puzzle.Sudoku)286- return287- }288- if puzzle.WordSearch != nil {289- renderWordSearchPage(pdf, puzzle.WordSearch)290- return291- }292- if puzzle.Table != nil {293- renderGridTablePage(pdf, puzzle.Table)294- return295- }296- renderFallbackPage(pdf, puzzle, pageH)283+ _ = adapter.RenderPDFBody(pdf, puzzle.PrintPayload)297284 }298285 299286 func renderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) {@@ -856,74 +843,6 @@ func renderGridTablePage(pdf *fpdf.Fpdf, table *GridTable) {
856843 pdf.Rect(startX, startY, blockW, blockH, "D")857844 }858845 859-func renderFallbackPage(pdf *fpdf.Fpdf, puzzle Puzzle, pageH float64) {860- pageW, _ := pdf.GetPageSize()861- area := puzzleBoardRect(pageW, pageH, pdf.PageNo(), 0)862- availW := area.w863- availH := area.h864-865- lines := sanitizeBody(puzzle.Body)866- fontSize := 9.2867- lineHeight := 4.8868-869- pdf.SetFont("Courier", "", fontSize)870- wrapped := make([]string, 0, len(lines))871- for _, line := range lines {872- chunks := pdf.SplitLines([]byte(line), availW)873- if len(chunks) == 0 {874- wrapped = append(wrapped, "")875- continue876- }877- for _, raw := range chunks {878- wrapped = append(wrapped, string(raw))879- }880- }881-882- if total := float64(len(wrapped)) * lineHeight; total > availH && len(wrapped) > 0 {883- maxLines := int(availH / lineHeight)884- if maxLines < len(wrapped) {885- wrapped = append(wrapped[:max(0, maxLines-1)], "...")886- }887- }888-889- blockH := float64(len(wrapped)) * lineHeight890- startY := area.y + (availH-blockH)/2891-892- pdf.SetTextColor(50, 50, 50)893- y := startY894- for _, line := range wrapped {895- w := pdf.GetStringWidth(line)896- x := (pageW - w) / 2897- if x < area.x {898- x = area.x899- }900- pdf.SetXY(x, y)901- pdf.CellFormat(availW, lineHeight, line, "", 0, "L", false, 0, "")902- y += lineHeight903- }904-}905-906-func sanitizeBody(body string) []string {907- lines := strings.Split(body, "\n")908- cleaned := make([]string, 0, len(lines))909- for _, line := range lines {910- trimmed := strings.TrimSpace(line)911- if trimmed == "" || trimmed == "---" {912- cleaned = append(cleaned, "")913- continue914- }915- if strings.HasPrefix(trimmed, "### ") {916- cleaned = append(cleaned, strings.TrimSpace(strings.TrimPrefix(trimmed, "### ")))917- continue918- }919- if strings.HasPrefix(trimmed, "|") {920- line = strings.ReplaceAll(line, ".", " ")921- }922- cleaned = append(cleaned, line)923- }924- return cleaned925-}926-927846 func drawCellText(pdf *fpdf.Fpdf, x, y, w, h float64, text string, dim bool) {928847 if strings.TrimSpace(text) == "" {929848 return@@ -0,0 +1,39 @@
1+package pdfexport2+3+import "github.com/go-pdf/fpdf"4+5+func RenderNonogramPage(pdf *fpdf.Fpdf, data *NonogramData) {6+ renderNonogramPage(pdf, data)7+}8+9+func RenderNurikabePage(pdf *fpdf.Fpdf, data *NurikabeData) {10+ renderNurikabePage(pdf, data)11+}12+13+func RenderShikakuPage(pdf *fpdf.Fpdf, data *ShikakuData) {14+ renderShikakuPage(pdf, data)15+}16+17+func RenderHashiPage(pdf *fpdf.Fpdf, data *HashiData) {18+ renderHashiPage(pdf, data)19+}20+21+func RenderHitoriPage(pdf *fpdf.Fpdf, data *HitoriData) {22+ renderHitoriPage(pdf, data)23+}24+25+func RenderTakuzuPage(pdf *fpdf.Fpdf, data *TakuzuData) {26+ renderTakuzuPage(pdf, data)27+}28+29+func RenderSudokuPage(pdf *fpdf.Fpdf, data *SudokuData) {30+ renderSudokuPage(pdf, data)31+}32+33+func RenderWordSearchPage(pdf *fpdf.Fpdf, data *WordSearchData) {34+ renderWordSearchPage(pdf, data)35+}36+37+func RenderGridTablePage(pdf *fpdf.Fpdf, table *GridTable) {38+ renderGridTablePage(pdf, table)39+}@@ -36,15 +36,7 @@ type Puzzle struct {
3636 Index int3737 Body string3838 SaveData []byte39- Nonogram *NonogramData40- Nurikabe *NurikabeData41- Shikaku *ShikakuData42- Hashi *HashiData43- Hitori *HitoriData44- Takuzu *TakuzuData45- Sudoku *SudokuData46- WordSearch *WordSearchData47- Table *GridTable39+ PrintPayload any4840 DifficultyScore float644941 DifficultyConfidence DifficultyConfidence5042 DifficultySource string@@ -0,0 +1,75 @@
1+package shikaku2+3+import (4+ "encoding/json"5+ "fmt"6+ "strconv"7+ "strings"8+9+ "github.com/FelineStateMachine/puzzletea/game"10+ "github.com/FelineStateMachine/puzzletea/pdfexport"11+ "github.com/go-pdf/fpdf"12+)13+14+type printAdapter struct{}15+16+func (printAdapter) CanonicalGameType() string { return "Shikaku" }17+func (printAdapter) Aliases() []string { return []string{"shikaku"} }18+19+func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) {20+ var data Save21+ if err := json.Unmarshal(save, &data); err != nil {22+ return "", fmt.Errorf("decode shikaku save: %w", err)23+ }24+25+ cells := game.MakeStringGrid(data.Width, data.Height, ".")26+ for _, clue := range data.Clues {27+ if clue.Y < 0 || clue.Y >= len(cells) {28+ continue29+ }30+ if clue.X < 0 || clue.X >= len(cells[clue.Y]) {31+ continue32+ }33+ cells[clue.Y][clue.X] = strconv.Itoa(clue.Value)34+ }35+36+ var b strings.Builder37+ b.WriteString("### Clue Grid\n\n")38+ b.WriteString(game.RenderGridTable(cells))39+ b.WriteString("\n\n")40+ b.WriteString("Goal: partition the grid into rectangles so each rectangle contains one clue and its area matches that clue.")41+ return b.String(), nil42+}43+44+func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) {45+ payload, err := pdfexport.ParseShikakuPrintData(save)46+ if err != nil {47+ return nil, err48+ }49+ if !game.IsNilPrintPayload(payload) {50+ return payload, nil51+ }52+ if strings.TrimSpace(snippet) == "" {53+ return nil, nil54+ }55+56+ _, table, err := pdfexport.ParsePrintableFromSnippet("Shikaku", snippet)57+ if err != nil {58+ return nil, err59+ }60+ return table, nil61+}62+63+func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {64+ switch data := payload.(type) {65+ case *pdfexport.ShikakuData:66+ pdfexport.RenderShikakuPage(pdf, data)67+ case *pdfexport.GridTable:68+ pdfexport.RenderGridTablePage(pdf, data)69+ }70+ return nil71+}72+73+func init() {74+ game.RegisterPrintAdapter(printAdapter{})75+}@@ -0,0 +1,74 @@
1+package sudoku2+3+import (4+ "encoding/json"5+ "fmt"6+ "strings"7+8+ "github.com/FelineStateMachine/puzzletea/game"9+ "github.com/FelineStateMachine/puzzletea/pdfexport"10+ "github.com/go-pdf/fpdf"11+)12+13+type printAdapter struct{}14+15+func (printAdapter) CanonicalGameType() string { return "Sudoku" }16+func (printAdapter) Aliases() []string { return []string{"sudoku"} }17+18+func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) {19+ var data Save20+ if err := json.Unmarshal(save, &data); err != nil {21+ return "", fmt.Errorf("decode sudoku save: %w", err)22+ }23+24+ cells := game.MakeStringGrid(9, 9, ".")25+ for _, provided := range data.Provided {26+ if provided.Y < 0 || provided.Y >= len(cells) {27+ continue28+ }29+ if provided.X < 0 || provided.X >= len(cells[provided.Y]) {30+ continue31+ }32+ cells[provided.Y][provided.X] = fmt.Sprintf("%d", provided.V)33+ }34+35+ var b strings.Builder36+ b.WriteString("### Given Grid\n\n")37+ b.WriteString(game.RenderGridTable(cells))38+ b.WriteString("\n\n")39+ b.WriteString("Goal: fill each row, column, and 3x3 box with digits 1-9 exactly once.")40+ return b.String(), nil41+}42+43+func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) {44+ payload, err := pdfexport.ParseSudokuPrintData(save)45+ if err != nil {46+ return nil, err47+ }48+ if !game.IsNilPrintPayload(payload) {49+ return payload, nil50+ }51+ if strings.TrimSpace(snippet) == "" {52+ return nil, nil53+ }54+55+ _, table, err := pdfexport.ParsePrintableFromSnippet("Sudoku", snippet)56+ if err != nil {57+ return nil, err58+ }59+ return table, nil60+}61+62+func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {63+ switch data := payload.(type) {64+ case *pdfexport.SudokuData:65+ pdfexport.RenderSudokuPage(pdf, data)66+ case *pdfexport.GridTable:67+ pdfexport.RenderGridTablePage(pdf, data)68+ }69+ return nil70+}71+72+func init() {73+ game.RegisterPrintAdapter(printAdapter{})74+}@@ -0,0 +1,83 @@
1+package takuzu2+3+import (4+ "encoding/json"5+ "fmt"6+ "strings"7+8+ "github.com/FelineStateMachine/puzzletea/game"9+ "github.com/FelineStateMachine/puzzletea/pdfexport"10+ "github.com/go-pdf/fpdf"11+)12+13+type printAdapter struct{}14+15+func (printAdapter) CanonicalGameType() string { return "Takuzu" }16+func (printAdapter) Aliases() []string { return []string{"takuzu"} }17+18+func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) {19+ var data Save20+ if err := json.Unmarshal(save, &data); err != nil {21+ return "", fmt.Errorf("decode takuzu save: %w", err)22+ }23+24+ cells := game.MakeStringGrid(data.Size, data.Size, ".")25+ stateRows := game.SplitLines(data.State)26+ providedRows := game.SplitLines(data.Provided)27+ for y := range len(cells) {28+ for x := range len(cells[y]) {29+ provided := y < len(providedRows) && x < len(providedRows[y]) && providedRows[y][x] == '#'30+ if !provided {31+ continue32+ }33+ if y >= len(stateRows) || x >= len(stateRows[y]) {34+ continue35+ }36+ switch stateRows[y][x] {37+ case '0', '1':38+ cells[y][x] = string(stateRows[y][x])39+ }40+ }41+ }42+43+ var b strings.Builder44+ b.WriteString("### Given Grid\n\n")45+ b.WriteString(game.RenderGridTable(cells))46+ b.WriteString("\n\n")47+ b.WriteString("Goal: fill with 0/1 so no three equal adjacent cells appear, ")48+ b.WriteString("each row/column has equal 0 and 1 counts, and rows/columns are unique.")49+ return b.String(), nil50+}51+52+func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) {53+ payload, err := pdfexport.ParseTakuzuPrintData(save)54+ if err != nil {55+ return nil, err56+ }57+ if !game.IsNilPrintPayload(payload) {58+ return payload, nil59+ }60+ if strings.TrimSpace(snippet) == "" {61+ return nil, nil62+ }63+64+ _, table, err := pdfexport.ParsePrintableFromSnippet("Takuzu", snippet)65+ if err != nil {66+ return nil, err67+ }68+ return table, nil69+}70+71+func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {72+ switch data := payload.(type) {73+ case *pdfexport.TakuzuData:74+ pdfexport.RenderTakuzuPage(pdf, data)75+ case *pdfexport.GridTable:76+ pdfexport.RenderGridTablePage(pdf, data)77+ }78+ return nil79+}80+81+func init() {82+ game.RegisterPrintAdapter(printAdapter{})83+}@@ -0,0 +1,99 @@
1+package wordsearch2+3+import (4+ "encoding/json"5+ "fmt"6+ "strings"7+8+ "github.com/FelineStateMachine/puzzletea/game"9+ "github.com/FelineStateMachine/puzzletea/pdfexport"10+ "github.com/go-pdf/fpdf"11+)12+13+type printAdapter struct{}14+15+func (printAdapter) CanonicalGameType() string { return "Word Search" }16+func (printAdapter) Aliases() []string {17+ return []string{"word search", "wordsearch"}18+}19+20+func (printAdapter) RenderMarkdownSnippet(save []byte) (string, error) {21+ var data Save22+ if err := json.Unmarshal(save, &data); err != nil {23+ return "", fmt.Errorf("decode word search save: %w", err)24+ }25+26+ rows := game.SplitLines(data.Grid)27+ height := data.Height28+ if height < len(rows) {29+ height = len(rows)30+ }31+ width := data.Width32+ for _, row := range rows {33+ if n := len([]rune(row)); n > width {34+ width = n35+ }36+ }37+38+ cells := game.MakeStringGrid(width, height, ".")39+ for y := range len(cells) {40+ if y >= len(rows) {41+ continue42+ }43+ rowRunes := []rune(rows[y])44+ for x := range len(cells[y]) {45+ if x < len(rowRunes) {46+ cells[y][x] = string(rowRunes[x])47+ }48+ }49+ }50+51+ var b strings.Builder52+ b.WriteString("### Grid\n\n")53+ b.WriteString(game.RenderGridTable(cells))54+ b.WriteString("\n\n")55+ b.WriteString("### Word List\n\n")56+ b.WriteString("| # | Word |\n")57+ b.WriteString("| --- | --- |\n")58+ for i, word := range data.Words {59+ fmt.Fprintf(&b, "| %d | %s |\n", i+1, game.EscapeMarkdownCell(word.Text))60+ }61+ if len(data.Words) == 0 {62+ b.WriteString("| 1 | (none) |\n")63+ }64+ b.WriteString("\nGoal: find all listed words in the grid.")65+ return b.String(), nil66+}67+68+func (printAdapter) BuildPDFPayload(save []byte, snippet string) (any, error) {69+ payload, err := pdfexport.ParseWordSearchPrintData(save)70+ if err != nil {71+ return nil, err72+ }73+ if !game.IsNilPrintPayload(payload) {74+ return payload, nil75+ }76+ if strings.TrimSpace(snippet) == "" {77+ return nil, nil78+ }79+80+ _, table, err := pdfexport.ParsePrintableFromSnippet("Word Search", snippet)81+ if err != nil {82+ return nil, err83+ }84+ return table, nil85+}86+87+func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {88+ switch data := payload.(type) {89+ case *pdfexport.WordSearchData:90+ pdfexport.RenderWordSearchPage(pdf, data)91+ case *pdfexport.GridTable:92+ pdfexport.RenderGridTablePage(pdf, data)93+ }94+ return nil95+}96+97+func init() {98+ game.RegisterPrintAdapter(printAdapter{})99+}