From 482369521b2dd4adf74eb31fc06bea0f78e187e7 Mon Sep 17 00:00:00 2001 From: Dami Date: Sat, 14 Mar 2026 14:12:36 -0600 Subject: [PATCH 01/10] initial pass --- README.md | 12 +- catalog/catalog_test.go | 1 + cmd/root.go | 2 +- cmd/test.go | 159 ++++++++++ cmd/test_test.go | 200 ++++++++++++ netwalk/README.md | 37 +++ netwalk/export.go | 60 ++++ netwalk/gamemode.go | 75 +++++ netwalk/generator.go | 228 ++++++++++++++ netwalk/grid.go | 450 +++++++++++++++++++++++++++ netwalk/help.md | 30 ++ netwalk/keys.go | 49 +++ netwalk/model.go | 200 ++++++++++++ netwalk/netwalk_test.go | 163 ++++++++++ netwalk/print_adapter.go | 122 ++++++++ netwalk/style.go | 195 ++++++++++++ netwalk/testdata/visual_states.jsonl | 10 + netwalk/visual_fixture.go | 229 ++++++++++++++ netwalk/visual_fixture_test.go | 98 ++++++ pdfexport/parse_netwalk.go | 104 +++++++ pdfexport/types.go | 8 + registry/registry.go | 2 + registry/registry_test.go | 1 + 23 files changed, 2429 insertions(+), 6 deletions(-) create mode 100644 cmd/test.go create mode 100644 cmd/test_test.go create mode 100644 netwalk/README.md create mode 100644 netwalk/export.go create mode 100644 netwalk/gamemode.go create mode 100644 netwalk/generator.go create mode 100644 netwalk/grid.go create mode 100644 netwalk/help.md create mode 100644 netwalk/keys.go create mode 100644 netwalk/model.go create mode 100644 netwalk/netwalk_test.go create mode 100644 netwalk/print_adapter.go create mode 100644 netwalk/style.go create mode 100644 netwalk/testdata/visual_states.jsonl create mode 100644 netwalk/visual_fixture.go create mode 100644 netwalk/visual_fixture_test.go create mode 100644 pdfexport/parse_netwalk.go diff --git a/README.md b/README.md index 0b22c62..8167cd4 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,19 @@ A terminal-based puzzle game collection built with [Bubble Tea](https://github.com/charmbracelet/bubbletea). -Fourteen puzzle games, multiple difficulty modes, daily and weekly deterministic challenges, XP progression, 365 theme options, and an explicit built-in registry plus metadata catalog for adding new games. +Fifteen puzzle games, multiple difficulty modes, daily and weekly deterministic challenges, XP progression, 365 theme options, and an explicit built-in registry plus metadata catalog for adding new games. ![PuzzleTea menu](vhs/menu.gif) ## Features -- **14 puzzle games** -- Fillomino, Nonogram, Nurikabe, Ripple Effect, Shikaku, Sudoku, Sudoku RGB, Spell Puzzle, Word Search, Hashiwokakero, Hitori, Lights Out, Takuzu, Takuzu+ +- **15 puzzle games** -- Fillomino, Netwalk, Nonogram, Nurikabe, Ripple Effect, Shikaku, Sudoku, Sudoku RGB, Spell Puzzle, Word Search, Hashiwokakero, Hitori, Lights Out, Takuzu, Takuzu+ - **Daily puzzles** -- A unique puzzle generated each day using deterministic seeding. Same date, same puzzle for everyone. Streak tracking rewards consecutive daily completions. - **Weekly gauntlet** -- Each ISO calendar week has a shared 99-puzzle ladder. The current week unlocks sequentially from `#01` to `#99`; past weeks can be reviewed from completed saves only. - **XP and leveling** -- Per-category levels based on victories. Harder modes yield more XP. Daily puzzles grant 2x XP, and weekly puzzles add slot-based bonus XP. - **Stats dashboard** -- Profile level, daily streak status, weekly completion progress, victory counts, and XP progress bars per category. - **365 color themes** -- Live-preview theme picker with WCAG-compliant contrast enforcement. Dark and light themes included. -- **Mouse support** -- Drag interactions in Nonogram, Nurikabe, Shikaku, Spell Puzzle, and Word Search; click-to-focus in Fillomino, Hashiwokakero, Hitori, Sudoku, Sudoku RGB, Takuzu, and Takuzu+; click-to-toggle in Lights Out. +- **Mouse support** -- Drag interactions in Nonogram, Nurikabe, Shikaku, Spell Puzzle, and Word Search; click-to-focus in Fillomino, Hashiwokakero, Hitori, Sudoku, Sudoku RGB, Takuzu, and Takuzu+; click-to-rotate in Netwalk; click-to-toggle in Lights Out. - **Seeded puzzles** -- Share a seed string to generate identical puzzles across sessions and machines. - **Save/load persistence** -- Games auto-save to SQLite. Resume any in-progress game by name. @@ -34,6 +34,7 @@ Fourteen puzzle games, multiple difficulty modes, daily and weekly deterministic | **Hashiwokakero** | Connect islands with bridges | 12 modes across 7x7 to 13x13 grids | | **Hitori** | Shade cells to eliminate duplicates | 6 modes from 5x5 to 12x12 | | **Lights Out** | Toggle lights to turn all off | Easy (3x3) to Extreme (9x9) | +| **Netwalk** | Rotate network tiles until every computer reaches the server | Mini 5x5 through Expert 13x13 | | **Takuzu** | Fill grid with two symbols | 7 modes from 6x6 to 14x14 | | **Takuzu+** | Fill grid with symbols plus `=` and `x` relation clues | 7 modes from 6x6 to 14x14 | @@ -107,6 +108,7 @@ puzzletea new sudoku hard puzzletea new ripeto expert puzzletea new spell beginner puzzletea new lights-out +puzzletea new netwalk "Easy 7x7" puzzletea new hashi "Easy 7x7" ``` @@ -178,7 +180,7 @@ puzzletea --continue amber-falcon ### CLI Aliases -Several shorthand names are accepted for games: `polyomino`/`regions` for Fillomino, `hashi`/`bridges` for Hashiwokakero, `lights` for Lights Out, `islands`/`sea` for Nurikabe, `ripple` for Ripple Effect, `spell`/`spellpuzzle` for Spell Puzzle, `rgb sudoku`/`ripeto`/`sudoku ripeto` for Sudoku RGB, `binairo`/`binary` for Takuzu, `takuzu plus`/`binario+`/`binario plus` for Takuzu+, `words`/`wordsearch`/`ws` for Word Search, and `rectangles` for Shikaku. +Several shorthand names are accepted for games: `polyomino`/`regions` for Fillomino, `hashi`/`bridges` for Hashiwokakero, `lights` for Lights Out, `network` for Netwalk, `islands`/`sea` for Nurikabe, `ripple` for Ripple Effect, `spell`/`spellpuzzle` for Spell Puzzle, `rgb sudoku`/`ripeto`/`sudoku ripeto` for Sudoku RGB, `binairo`/`binary` for Takuzu, `takuzu plus`/`binario+`/`binario plus` for Takuzu+, `words`/`wordsearch`/`ws` for Word Search, and `rectangles` for Shikaku. ## Controls @@ -198,7 +200,7 @@ Arrow keys, WASD, and Vim bindings (`hjkl`) are supported for grid movement acro ### Mouse -Nonogram, Nurikabe, Shikaku, Spell Puzzle, and Word Search support drag interactions. Fillomino, Hashiwokakero, Hitori, Sudoku, Sudoku RGB, Takuzu, and Takuzu+ support mouse focus or click-to-cycle interactions. Lights Out supports click to toggle. See each game's help for details. +Nonogram, Nurikabe, Shikaku, Spell Puzzle, and Word Search support drag interactions. Fillomino, Hashiwokakero, Hitori, Sudoku, Sudoku RGB, Takuzu, and Takuzu+ support mouse focus or click-to-cycle interactions. Netwalk supports click-to-rotate and right-click lock toggles. Lights Out supports click to toggle. See each game's help for details. ## Game Persistence diff --git a/catalog/catalog_test.go b/catalog/catalog_test.go index 80566fc..4f58cf0 100644 --- a/catalog/catalog_test.go +++ b/catalog/catalog_test.go @@ -25,6 +25,7 @@ func TestResolveSupportsCanonicalNamesAndAliases(t *testing.T) { {input: "fillomino", want: "Fillomino"}, {input: "hashi", want: "Hashiwokakero"}, {input: "lights", want: "Lights Out"}, + {input: "network", want: "Netwalk"}, {input: "ripple", want: "Ripple Effect"}, {input: "spell", want: "Spell Puzzle"}, {input: "wordsearch", want: "Word Search"}, diff --git a/cmd/root.go b/cmd/root.go index 5597b15..625ecac 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -81,7 +81,7 @@ func init() { RootCmd.PersistentFlags().StringVar(&flagConfigPath, "config", "", "path to config file (default: ~/.puzzletea/config.json)") RootCmd.PersistentFlags().StringVar(&flagTheme, "theme", "", "color theme name (overrides config)") - RootCmd.AddCommand(newCmd, continueCmd, listCmd, exportPDFCmd) + RootCmd.AddCommand(newCmd, continueCmd, listCmd, exportPDFCmd, testCmd) } func loadActiveConfig() *config.Config { diff --git a/cmd/test.go b/cmd/test.go new file mode 100644 index 0000000..e89bccd --- /dev/null +++ b/cmd/test.go @@ -0,0 +1,159 @@ +package cmd + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/FelineStateMachine/puzzletea/pdfexport" + "github.com/FelineStateMachine/puzzletea/registry" + "github.com/spf13/cobra" +) + +const ( + testRenderWidth = 120 + testRenderHeight = 40 +) + +var testOutput string + +type testInputRecord struct { + lineNo int + record pdfexport.JSONLRecord +} + +var testCmd = &cobra.Command{ + Use: "test ", + Short: "Render saved puzzle exports into an ANSI review artifact", + Long: "Parse a PuzzleTea export JSONL file, import each saved puzzle, and render the full board view in a stable ANSI text format for visual review.", + Args: cobra.ExactArgs(1), + RunE: runTest, +} + +func init() { + testCmd.Flags().StringVarP(&testOutput, "output", "o", "", "write rendered output to a file (defaults to stdout)") +} + +func runTest(cmd *cobra.Command, args []string) error { + loadActiveConfig() + + records, err := loadTestRecords(args[0]) + if err != nil { + return err + } + + output, err := renderTestRecords(args[0], records) + if err != nil { + return err + } + + return writeTestOutput(cmd.OutOrStdout(), testOutput, output) +} + +func loadTestRecords(path string) ([]testInputRecord, error) { + if !strings.EqualFold(filepath.Ext(path), ".jsonl") { + return nil, fmt.Errorf("%s: expected .jsonl input", path) + } + + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open input jsonl: %w", err) + } + defer f.Close() + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + + records := make([]testInputRecord, 0, 16) + lineNo := 0 + for scanner.Scan() { + lineNo++ + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + var record pdfexport.JSONLRecord + if err := json.Unmarshal([]byte(line), &record); err != nil { + return nil, fmt.Errorf("%s:%d: decode jsonl record: %w", path, lineNo, err) + } + if record.Schema != pdfexport.ExportSchemaV1 { + return nil, fmt.Errorf("%s:%d: unsupported schema %q", path, lineNo, record.Schema) + } + + records = append(records, testInputRecord{lineNo: lineNo, record: record}) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read input jsonl: %w", err) + } + if len(records) == 0 { + return nil, fmt.Errorf("%s: input jsonl is empty", path) + } + + return records, nil +} + +func renderTestRecords(path string, records []testInputRecord) (string, error) { + var b strings.Builder + + for _, item := range records { + record := item.record + gameType := strings.TrimSpace(record.Puzzle.Game) + if gameType == "" { + gameType = strings.TrimSpace(record.Pack.Category) + } + if gameType == "" { + return "", fmt.Errorf("%s:%d: missing puzzle game/category", path, item.lineNo) + } + + g, err := registry.Import(gameType, record.Puzzle.Save) + if err != nil { + return "", fmt.Errorf("%s:%d: import %q: %w", path, item.lineNo, gameType, err) + } + + sized, _ := g.Update(tea.WindowSizeMsg{Width: testRenderWidth, Height: testRenderHeight}) + fmt.Fprintf( + &b, + "=== %s | %s | %s | #%d ===\n", + gameType, + testHeaderValue(record.Puzzle.Mode, record.Pack.ModeSelection), + testHeaderValue(record.Puzzle.Name, "unnamed"), + record.Puzzle.Index, + ) + b.WriteString(sized.View()) + b.WriteString("\n\n") + } + + return b.String(), nil +} + +func testHeaderValue(value, fallback string) string { + value = strings.TrimSpace(value) + if value != "" { + return value + } + return fallback +} + +func writeTestOutput(stdout io.Writer, path, content string) error { + if strings.TrimSpace(path) == "" { + _, err := io.WriteString(stdout, content) + return err + } + + dir := filepath.Dir(path) + if dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create output directory: %w", err) + } + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return fmt.Errorf("write output file: %w", err) + } + return nil +} diff --git a/cmd/test_test.go b/cmd/test_test.go new file mode 100644 index 0000000..f1061b2 --- /dev/null +++ b/cmd/test_test.go @@ -0,0 +1,200 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "math/rand/v2" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/netwalk" + "github.com/FelineStateMachine/puzzletea/pdfexport" + "github.com/FelineStateMachine/puzzletea/sudoku" + "github.com/spf13/cobra" +) + +func TestRunTestRendersANSIToStdout(t *testing.T) { + reset := snapshotTestFlags() + defer reset() + + input := filepath.Join(t.TempDir(), "visual.jsonl") + data, err := netwalk.VisualFixtureJSONL() + if err != nil { + t.Fatalf("VisualFixtureJSONL() error = %v", err) + } + if err := os.WriteFile(input, data, 0o644); err != nil { + t.Fatal(err) + } + + cmd, out := newTestCmd() + if err := runTest(cmd, []string{input}); err != nil { + t.Fatalf("runTest() error = %v", err) + } + + rendered := out.String() + if !strings.Contains(rendered, "=== Netwalk | Visual Fixture | cursor-root-horizontal | #1 ===") { + t.Fatalf("expected first section header, got:\n%s", rendered) + } + if !strings.Contains(rendered, "\x1b[") { + t.Fatal("expected ANSI escape sequences in review output") + } +} + +func TestRunTestWritesOutputFile(t *testing.T) { + reset := snapshotTestFlags() + defer reset() + + input := filepath.Join(t.TempDir(), "visual.jsonl") + data, err := netwalk.VisualFixtureJSONL() + if err != nil { + t.Fatalf("VisualFixtureJSONL() error = %v", err) + } + if err := os.WriteFile(input, data, 0o644); err != nil { + t.Fatal(err) + } + + testOutput = filepath.Join(t.TempDir(), "review.txt") + cmd, out := newTestCmd() + if err := runTest(cmd, []string{input}); err != nil { + t.Fatalf("runTest() error = %v", err) + } + if out.Len() != 0 { + t.Fatalf("expected stdout to stay empty when --output is set, got %q", out.String()) + } + + written, err := os.ReadFile(testOutput) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(written), "solved-with-empty-cells") { + t.Fatalf("expected written output to contain fixture case names, got:\n%s", string(written)) + } +} + +func TestRunTestRejectsBadSchema(t *testing.T) { + reset := snapshotTestFlags() + defer reset() + + input := filepath.Join(t.TempDir(), "bad.jsonl") + record := pdfexport.JSONLRecord{Schema: "bad.schema"} + line, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(input, append(line, '\n'), 0o644); err != nil { + t.Fatal(err) + } + + cmd, _ := newTestCmd() + err = runTest(cmd, []string{input}) + if err == nil { + t.Fatal("expected unsupported schema error") + } + if !strings.Contains(err.Error(), "unsupported schema") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunTestRendersMixedGamesInInputOrder(t *testing.T) { + reset := snapshotTestFlags() + defer reset() + + input := filepath.Join(t.TempDir(), "mixed.jsonl") + records := []pdfexport.JSONLRecord{ + testJSONLRecord(t, "Netwalk", "Visual Fixture", "netwalk-first", 1, spawnSeededSave(t, netwalk.Modes[0].(game.SeededSpawner))), + testJSONLRecord(t, "Sudoku", "Easy", "sudoku-second", 2, spawnSeededSave(t, sudoku.Modes[0].(game.SeededSpawner))), + } + writeTestJSONL(t, input, records) + + cmd, out := newTestCmd() + if err := runTest(cmd, []string{input}); err != nil { + t.Fatalf("runTest() error = %v", err) + } + + rendered := out.String() + first := strings.Index(rendered, "netwalk-first") + second := strings.Index(rendered, "sudoku-second") + if first == -1 || second == -1 { + t.Fatalf("expected both records in output, got:\n%s", rendered) + } + if first > second { + t.Fatalf("expected input order to be preserved, got:\n%s", rendered) + } +} + +func snapshotTestFlags() func() { + prevOutput := testOutput + testOutput = "" + return func() { + testOutput = prevOutput + } +} + +func newTestCmd() (*cobra.Command, *bytes.Buffer) { + cmd := &cobra.Command{} + var out bytes.Buffer + cmd.SetOut(&out) + return cmd, &out +} + +func writeTestJSONL(t *testing.T, path string, records []pdfexport.JSONLRecord) { + t.Helper() + + var data []byte + for _, record := range records { + line, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + data = append(data, line...) + data = append(data, '\n') + } + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } +} + +func testJSONLRecord( + t *testing.T, + gameName, modeName, name string, + index int, + save []byte, +) pdfexport.JSONLRecord { + t.Helper() + + return pdfexport.JSONLRecord{ + Schema: pdfexport.ExportSchemaV1, + Pack: pdfexport.JSONLPackMeta{ + Generated: "2026-03-14T00:00:00Z", + Version: "test", + Category: gameName, + ModeSelection: modeName, + Count: 2, + }, + Puzzle: pdfexport.JSONLPuzzle{ + Index: index, + Name: name, + Game: gameName, + Mode: modeName, + Save: json.RawMessage(save), + }, + } +} + +func spawnSeededSave(t *testing.T, spawner game.SeededSpawner) []byte { + t.Helper() + + rng := rand.New(rand.NewPCG(1, 2)) + g, err := spawner.SpawnSeeded(rng) + if err != nil { + t.Fatalf("SpawnSeeded() error = %v", err) + } + save, err := g.GetSave() + if err != nil { + t.Fatalf("GetSave() error = %v", err) + } + return save +} diff --git a/netwalk/README.md b/netwalk/README.md new file mode 100644 index 0000000..0b904a5 --- /dev/null +++ b/netwalk/README.md @@ -0,0 +1,37 @@ +# Netwalk + +Rotate network tiles until every active connection reaches the server in one loop-free tree. + +## Quick Start + +```bash +puzzletea new netwalk "Mini 5x5" +puzzletea new netwalk "Easy 7x7" +puzzletea new network medium +``` + +## Rules + +- Every active tile contains a fixed connector pattern that can only be rotated. +- All connectors must match neighboring connectors exactly. +- No connector may point off the board or into an empty cell. +- The final network must be a single connected tree rooted at the server. + +## Controls + +| Key | Action | +|-----|--------| +| `Arrows` / `wasd` / `hjkl` | Move cursor | +| `Enter` / `Space` | Rotate clockwise | +| `Backspace` | Rotate counter-clockwise | +| `l` | Toggle lock | + +## Modes + +| Mode | Board | +|------|-------| +| `Mini 5x5` | Small starter tree | +| `Easy 7x7` | Moderate branch count | +| `Medium 9x9` | More global interaction | +| `Hard 11x11` | Longer branch chains | +| `Expert 13x13` | Largest network | diff --git a/netwalk/export.go b/netwalk/export.go new file mode 100644 index 0000000..ff3ebf2 --- /dev/null +++ b/netwalk/export.go @@ -0,0 +1,60 @@ +package netwalk + +import ( + "encoding/json" + "fmt" + + "github.com/FelineStateMachine/puzzletea/game" +) + +type Save struct { + Size int `json:"size"` + Masks string `json:"masks"` + Rotations string `json:"rotations"` + InitialRotations string `json:"initial_rotations"` + Kinds string `json:"kinds"` + Locks string `json:"locks"` + ModeTitle string `json:"mode_title"` +} + +func (m Model) GetSave() ([]byte, error) { + save := Save{ + Size: m.puzzle.Size, + Masks: encodeMaskRows(m.puzzle.Tiles), + Rotations: encodeRotationRows(m.puzzle.Tiles, false), + InitialRotations: encodeRotationRows(m.puzzle.Tiles, true), + Kinds: encodeKindRows(m.puzzle.Tiles), + Locks: encodeLockRows(m.puzzle.Tiles), + ModeTitle: m.modeTitle, + } + data, err := json.Marshal(save) + if err != nil { + return nil, fmt.Errorf("marshal netwalk save: %w", err) + } + return data, nil +} + +func ImportModel(data []byte) (*Model, error) { + var save Save + if err := json.Unmarshal(data, &save); err != nil { + return nil, fmt.Errorf("unmarshal netwalk save: %w", err) + } + + if save.Locks == "" { + save.Locks = encodeRows(save.Size, func(_, _ int) byte { return '.' }) + } + puzzle, err := decodePuzzle(save.Size, save.Masks, save.Rotations, save.InitialRotations, save.Kinds, save.Locks) + if err != nil { + return nil, err + } + + cursor := puzzle.firstActive() + m := &Model{ + puzzle: puzzle, + cursor: game.Cursor{X: cursor.X, Y: cursor.Y}, + keys: DefaultKeyMap, + modeTitle: save.ModeTitle, + } + m.recompute() + return m, nil +} diff --git a/netwalk/gamemode.go b/netwalk/gamemode.go new file mode 100644 index 0000000..e091a1d --- /dev/null +++ b/netwalk/gamemode.go @@ -0,0 +1,75 @@ +package netwalk + +import ( + _ "embed" + "math/rand/v2" + + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/gameentry" + "github.com/FelineStateMachine/puzzletea/puzzle" +) + +//go:embed help.md +var HelpContent string + +type NetwalkMode struct { + game.BaseMode + Size int + TargetActive int +} + +var ( + _ game.Mode = NetwalkMode{} + _ game.Spawner = NetwalkMode{} + _ game.SeededSpawner = NetwalkMode{} +) + +func NewMode(title, desc string, size, targetActive int) NetwalkMode { + return NetwalkMode{ + BaseMode: game.NewBaseMode(title, desc), + Size: size, + TargetActive: targetActive, + } +} + +func (m NetwalkMode) Spawn() (game.Gamer, error) { + p, err := Generate(m.Size, m.TargetActive) + if err != nil { + return nil, err + } + return New(m, p) +} + +func (m NetwalkMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) { + p, err := GenerateSeeded(m.Size, m.TargetActive, rng) + if err != nil { + return nil, err + } + return New(m, p) +} + +var Modes = []game.Mode{ + NewMode("Mini 5x5", "Compact 5×5 network. Good first deduction pass.", 5, 8), + NewMode("Easy 7x7", "7×7 board with a modest tree and clear local constraints.", 7, 14), + NewMode("Medium 9x9", "Larger network with more branches and ambiguous elbows.", 9, 22), + NewMode("Hard 11x11", "Dense mid-size network that rewards global checking.", 11, 30), + NewMode("Expert 13x13", "Longer branch interactions and more disconnected-looking scrambles.", 13, 40), +} + +var ModeDefinitions = gameentry.BuildModeDefs(Modes) + +var Definition = puzzle.NewDefinition(puzzle.DefinitionSpec{ + Name: "Netwalk", + Description: "Rotate network tiles until every computer connects to the server.", + Aliases: []string{"network"}, + Modes: ModeDefinitions, + DailyModeIDs: puzzle.SelectModeIDsByIndex(ModeDefinitions, 1, 2), +}) + +var Entry = gameentry.NewEntry(gameentry.EntrySpec{ + Definition: Definition, + Help: HelpContent, + Import: game.AdaptImport(ImportModel), + Modes: Modes, + Print: PDFPrintAdapter, +}) diff --git a/netwalk/generator.go b/netwalk/generator.go new file mode 100644 index 0000000..ef667f3 --- /dev/null +++ b/netwalk/generator.go @@ -0,0 +1,228 @@ +package netwalk + +import ( + "errors" + "math/rand/v2" +) + +const maxGenerateAttempts = 64 + +func Generate(size, targetActive int) (Puzzle, error) { + return GenerateSeeded(size, targetActive, rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))) +} + +func GenerateSeeded(size, targetActive int, rng *rand.Rand) (Puzzle, error) { + if size <= 1 { + return Puzzle{}, errors.New("netwalk size must be at least 2") + } + if targetActive < 2 { + targetActive = 2 + } + if targetActive > size*size { + targetActive = size * size + } + + for attempt := 0; attempt < maxGenerateAttempts; attempt++ { + puzzle := buildTreePuzzle(size, targetActive, rng) + scramblePuzzle(&puzzle, rng) + if !analyzePuzzle(puzzle).solved { + return puzzle, nil + } + } + + return Puzzle{}, errors.New("failed to generate netwalk puzzle") +} + +type frontierEdge struct { + from point + to point +} + +func buildTreePuzzle(size, targetActive int, rng *rand.Rand) Puzzle { + puzzle := newPuzzle(size) + root := point{X: size / 2, Y: size / 2} + puzzle.Root = root + + active := map[point]struct{}{root: {}} + adjacency := map[point]directionMask{root: 0} + + for len(active) < targetActive { + frontier := collectFrontier(size, active) + if len(frontier) == 0 { + break + } + + edge := frontier[weightedFrontierIndex(frontier, adjacency, root, rng)] + active[edge.to] = struct{}{} + adjacency[edge.to] = 0 + + dir := directionBetween(edge.from, edge.to) + adjacency[edge.from] |= dir + adjacency[edge.to] |= opposite(dir) + } + + for p := range active { + kind := nodeCell + if p == root { + kind = serverCell + } + puzzle.Tiles[p.Y][p.X] = tile{ + BaseMask: adjacency[p], + Kind: kind, + } + } + + return puzzle +} + +func collectFrontier(size int, active map[point]struct{}) []frontierEdge { + frontier := make([]frontierEdge, 0, len(active)*2) + for y := range size { + for x := range size { + cell := point{X: x, Y: y} + if _, ok := active[cell]; !ok { + continue + } + for _, dir := range directions { + next := point{X: cell.X + dir.dx, Y: cell.Y + dir.dy} + if next.X < 0 || next.X >= size || next.Y < 0 || next.Y >= size { + continue + } + if _, ok := active[next]; ok { + continue + } + frontier = append(frontier, frontierEdge{from: cell, to: next}) + } + } + } + return frontier +} + +func weightedFrontierIndex( + frontier []frontierEdge, + adjacency map[point]directionMask, + root point, + rng *rand.Rand, +) int { + if len(frontier) == 1 { + return 0 + } + + weights := make([]int, len(frontier)) + total := 0 + for i, edge := range frontier { + weight := 10 + deg := degree(adjacency[edge.from]) + switch { + case deg <= 1: + weight += 6 + case deg == 2: + weight += 3 + case deg >= 3: + weight -= 2 + } + + dx := edge.to.X - root.X + if dx < 0 { + dx = -dx + } + dy := edge.to.Y - root.Y + if dy < 0 { + dy = -dy + } + weight += max(0, 6-(dx+dy)) + if weight < 1 { + weight = 1 + } + weights[i] = weight + total += weight + } + + pick := rng.IntN(total) + running := 0 + for i, weight := range weights { + running += weight + if pick < running { + return i + } + } + return len(frontier) - 1 +} + +func scramblePuzzle(puzzle *Puzzle, rng *rand.Rand) { + if puzzle == nil { + return + } + + changed := 0 + nonEmpty := 0 + for y := range puzzle.Size { + for x := range puzzle.Size { + t := &puzzle.Tiles[y][x] + if !isActive(*t) { + continue + } + nonEmpty++ + options := uniqueRotations(t.BaseMask) + rotation := options[rng.IntN(len(options))] + t.Rotation = rotation + t.InitialRotation = rotation + if rotation != 0 { + changed++ + } + } + } + + if changed > 0 || nonEmpty == 0 { + return + } + + active := make([]point, 0, nonEmpty) + for y := range puzzle.Size { + for x := range puzzle.Size { + if isActive(puzzle.Tiles[y][x]) { + active = append(active, point{X: x, Y: y}) + } + } + } + for _, p := range active { + t := &puzzle.Tiles[p.Y][p.X] + options := uniqueRotations(t.BaseMask) + if len(options) <= 1 { + continue + } + t.Rotation = options[1%len(options)] + t.InitialRotation = t.Rotation + return + } +} + +func directionBetween(from, to point) directionMask { + switch { + case to.X == from.X && to.Y == from.Y-1: + return north + case to.X == from.X+1 && to.Y == from.Y: + return east + case to.X == from.X && to.Y == from.Y+1: + return south + case to.X == from.X-1 && to.Y == from.Y: + return west + default: + return 0 + } +} + +func opposite(mask directionMask) directionMask { + switch mask { + case north: + return south + case east: + return west + case south: + return north + case west: + return east + default: + return 0 + } +} diff --git a/netwalk/grid.go b/netwalk/grid.go new file mode 100644 index 0000000..6f4cc89 --- /dev/null +++ b/netwalk/grid.go @@ -0,0 +1,450 @@ +package netwalk + +import ( + "fmt" + "math/bits" +) + +type directionMask uint8 + +const ( + north directionMask = 1 << iota + east + south + west +) + +const allDirections = north | east | south | west + +type cellKind uint8 + +const ( + emptyCell cellKind = iota + nodeCell + serverCell +) + +type point struct { + X int + Y int +} + +type tile struct { + BaseMask directionMask + Rotation uint8 + InitialRotation uint8 + Locked bool + Kind cellKind +} + +type Puzzle struct { + Size int + Root point + Tiles [][]tile +} + +type boardState struct { + nonEmpty int + connected int + dangling int + locked int + solved bool + allMatched bool + connectedToRoot [][]bool + tileHasDangling [][]bool + rotatedMasks [][]directionMask +} + +type directionSpec struct { + dx int + dy int + bit directionMask + opp directionMask +} + +var directions = []directionSpec{ + {dx: 0, dy: -1, bit: north, opp: south}, + {dx: 1, dy: 0, bit: east, opp: west}, + {dx: 0, dy: 1, bit: south, opp: north}, + {dx: -1, dy: 0, bit: west, opp: east}, +} + +func newPuzzle(size int) Puzzle { + tiles := make([][]tile, size) + for y := range size { + tiles[y] = make([]tile, size) + } + return Puzzle{Size: size, Tiles: tiles} +} + +func (p Puzzle) inBounds(x, y int) bool { + return x >= 0 && x < p.Size && y >= 0 && y < p.Size +} + +func (p Puzzle) activeAt(x, y int) bool { + return p.inBounds(x, y) && p.Tiles[y][x].Kind != emptyCell +} + +func (p Puzzle) firstActive() point { + if p.activeAt(p.Root.X, p.Root.Y) { + return p.Root + } + for y := range p.Size { + for x := range p.Size { + if p.activeAt(x, y) { + return point{X: x, Y: y} + } + } + } + return point{} +} + +func isActive(t tile) bool { + return t.Kind != emptyCell +} + +func rotateMask(mask directionMask, rotation uint8) directionMask { + shift := rotation % 4 + if shift == 0 { + return mask + } + value := uint8(mask & allDirections) + return directionMask(((value << shift) | (value >> (4 - shift))) & uint8(allDirections)) +} + +func degree(mask directionMask) int { + return bits.OnesCount8(uint8(mask)) +} + +func uniqueRotations(mask directionMask) []uint8 { + seen := make(map[directionMask]struct{}, 4) + rotations := make([]uint8, 0, 4) + for rot := uint8(0); rot < 4; rot++ { + rotated := rotateMask(mask, rot) + if _, ok := seen[rotated]; ok { + continue + } + seen[rotated] = struct{}{} + rotations = append(rotations, rot) + } + return rotations +} + +func maskGlyph(mask directionMask) string { + switch mask { + case 0: + return " " + case north: + return "╵" + case east: + return "╶" + case south: + return "╷" + case west: + return "╴" + case north | south: + return "│" + case east | west: + return "─" + case north | east: + return "└" + case east | south: + return "┌" + case south | west: + return "┐" + case west | north: + return "┘" + case north | east | south: + return "├" + case east | south | west: + return "┬" + case south | west | north: + return "┤" + case west | north | east: + return "┴" + case north | east | south | west: + return "┼" + default: + return "?" + } +} + +func encodeMaskRows(tiles [][]tile) string { + return encodeRows(len(tiles), func(x, y int) byte { + return nibbleHex(uint8(tiles[y][x].BaseMask)) + }) +} + +func encodeRotationRows(tiles [][]tile, initial bool) string { + return encodeRows(len(tiles), func(x, y int) byte { + value := tiles[y][x].Rotation + if initial { + value = tiles[y][x].InitialRotation + } + return byte('0' + value%4) + }) +} + +func encodeKindRows(tiles [][]tile) string { + return encodeRows(len(tiles), func(x, y int) byte { + switch tiles[y][x].Kind { + case serverCell: + return 'S' + case nodeCell: + return '#' + default: + return '.' + } + }) +} + +func encodeLockRows(tiles [][]tile) string { + return encodeRows(len(tiles), func(x, y int) byte { + if tiles[y][x].Locked { + return '#' + } + return '.' + }) +} + +func decodePuzzle(size int, masks, rotations, initial, kinds, locks string) (Puzzle, error) { + if size <= 0 { + return Puzzle{}, fmt.Errorf("invalid netwalk size %d", size) + } + maskRows, err := decodeRows(size, masks) + if err != nil { + return Puzzle{}, fmt.Errorf("decode masks: %w", err) + } + rotationRows, err := decodeRows(size, rotations) + if err != nil { + return Puzzle{}, fmt.Errorf("decode rotations: %w", err) + } + initialRows, err := decodeRows(size, initial) + if err != nil { + return Puzzle{}, fmt.Errorf("decode initial rotations: %w", err) + } + kindRows, err := decodeRows(size, kinds) + if err != nil { + return Puzzle{}, fmt.Errorf("decode kinds: %w", err) + } + lockRows, err := decodeRows(size, locks) + if err != nil { + return Puzzle{}, fmt.Errorf("decode locks: %w", err) + } + + puzzle := newPuzzle(size) + rootCount := 0 + for y := range size { + for x := range size { + maskValue, ok := parseNibble(maskRows[y][x]) + if !ok { + return Puzzle{}, fmt.Errorf("invalid mask value %q at (%d,%d)", maskRows[y][x], x, y) + } + rotationValue := rotationRows[y][x] + initialValue := initialRows[y][x] + if rotationValue < '0' || rotationValue > '3' || initialValue < '0' || initialValue > '3' { + return Puzzle{}, fmt.Errorf("invalid rotation at (%d,%d)", x, y) + } + + t := tile{ + BaseMask: directionMask(maskValue), + Rotation: uint8(rotationValue - '0'), + InitialRotation: uint8(initialValue - '0'), + } + + switch kindRows[y][x] { + case 'S': + t.Kind = serverCell + puzzle.Root = point{X: x, Y: y} + rootCount++ + case '#': + t.Kind = nodeCell + case '.': + t.Kind = emptyCell + default: + return Puzzle{}, fmt.Errorf("invalid cell kind %q at (%d,%d)", kindRows[y][x], x, y) + } + + if t.Kind == emptyCell && t.BaseMask != 0 { + return Puzzle{}, fmt.Errorf("empty tile at (%d,%d) has mask", x, y) + } + if t.Kind != emptyCell && t.BaseMask == 0 { + return Puzzle{}, fmt.Errorf("active tile at (%d,%d) has empty mask", x, y) + } + if lockRows[y][x] == '#' { + t.Locked = true + } + puzzle.Tiles[y][x] = t + } + } + + if rootCount != 1 { + return Puzzle{}, fmt.Errorf("expected exactly one root, got %d", rootCount) + } + + return puzzle, nil +} + +func encodeRows(size int, valueAt func(x, y int) byte) string { + buf := make([]byte, 0, size*size+max(size-1, 0)) + for y := range size { + for x := range size { + buf = append(buf, valueAt(x, y)) + } + if y < size-1 { + buf = append(buf, '\n') + } + } + return string(buf) +} + +func decodeRows(size int, raw string) ([][]byte, error) { + rows := make([][]byte, size) + currentRow := 0 + currentCol := 0 + rows[currentRow] = make([]byte, size) + + for i := 0; i < len(raw); i++ { + ch := raw[i] + if ch == '\r' { + continue + } + if ch == '\n' { + if currentCol != size { + return nil, fmt.Errorf("row %d has width %d, want %d", currentRow, currentCol, size) + } + currentRow++ + if currentRow >= size { + return nil, fmt.Errorf("too many rows") + } + rows[currentRow] = make([]byte, size) + currentCol = 0 + continue + } + if currentCol >= size { + return nil, fmt.Errorf("row %d exceeds width %d", currentRow, size) + } + rows[currentRow][currentCol] = ch + currentCol++ + } + + if currentRow != size-1 || currentCol != size { + return nil, fmt.Errorf("incomplete grid") + } + + return rows, nil +} + +func nibbleHex(value uint8) byte { + if value < 10 { + return '0' + value + } + return 'a' + (value - 10) +} + +func parseNibble(value byte) (uint8, bool) { + switch { + case value >= '0' && value <= '9': + return value - '0', true + case value >= 'a' && value <= 'f': + return 10 + value - 'a', true + case value >= 'A' && value <= 'F': + return 10 + value - 'A', true + default: + return 0, false + } +} + +func analyzePuzzle(p Puzzle) boardState { + state := boardState{ + connectedToRoot: make([][]bool, p.Size), + tileHasDangling: make([][]bool, p.Size), + rotatedMasks: make([][]directionMask, p.Size), + } + for y := range p.Size { + state.connectedToRoot[y] = make([]bool, p.Size) + state.tileHasDangling[y] = make([]bool, p.Size) + state.rotatedMasks[y] = make([]directionMask, p.Size) + for x := range p.Size { + t := p.Tiles[y][x] + if !isActive(t) { + continue + } + state.nonEmpty++ + if t.Locked { + state.locked++ + } + state.rotatedMasks[y][x] = rotateMask(t.BaseMask, t.Rotation) + } + } + + allMatched := true + halfEdges := 0 + for y := range p.Size { + for x := range p.Size { + t := p.Tiles[y][x] + if !isActive(t) { + continue + } + mask := state.rotatedMasks[y][x] + for _, dir := range directions { + if mask&dir.bit == 0 { + continue + } + halfEdges++ + nx := x + dir.dx + ny := y + dir.dy + if !p.activeAt(nx, ny) { + state.tileHasDangling[y][x] = true + state.dangling++ + allMatched = false + continue + } + neighborMask := state.rotatedMasks[ny][nx] + if neighborMask&dir.opp == 0 { + state.tileHasDangling[y][x] = true + state.dangling++ + allMatched = false + } + } + } + } + + state.allMatched = allMatched + if !p.activeAt(p.Root.X, p.Root.Y) { + return state + } + + queue := []point{p.Root} + state.connectedToRoot[p.Root.Y][p.Root.X] = true + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + state.connected++ + mask := state.rotatedMasks[cur.Y][cur.X] + for _, dir := range directions { + if mask&dir.bit == 0 { + continue + } + nx := cur.X + dir.dx + ny := cur.Y + dir.dy + if !p.activeAt(nx, ny) || state.connectedToRoot[ny][nx] { + continue + } + neighborMask := state.rotatedMasks[ny][nx] + if neighborMask&dir.opp == 0 { + continue + } + state.connectedToRoot[ny][nx] = true + queue = append(queue, point{X: nx, Y: ny}) + } + } + + matchedEdges := halfEdges / 2 + state.solved = state.nonEmpty > 0 && + allMatched && + state.connected == state.nonEmpty && + matchedEdges == state.nonEmpty-1 + + return state +} diff --git a/netwalk/help.md b/netwalk/help.md new file mode 100644 index 0000000..ef2470c --- /dev/null +++ b/netwalk/help.md @@ -0,0 +1,30 @@ +# Netwalk + +Rotate network tiles until every active tile connects back to the server in one clean network. + +## Rules + +- Each active tile contains fixed connectors that may only be **rotated**. +- Every connector must meet a matching connector on the neighboring tile. +- Connectors may not point off the board or into empty cells. +- The puzzle is solved when every active tile connects to the **server** and the finished network has **no loops**. + +## Controls + +| Key | Action | +|-----|--------| +| `Arrows` / `wasd` / `hjkl` | Move cursor | +| `Enter` / `Space` | Rotate current tile clockwise | +| `Backspace` | Rotate current tile counter-clockwise | +| `l` | Toggle lock on current tile | +| `Mouse left-click` | Rotate clicked tile | +| `Mouse right-click` | Toggle lock on clicked tile | +| `Ctrl+R` | Reset puzzle | +| `Ctrl+H` | Toggle full help | +| `Escape` | Return to main menu | + +## Tips + +- **Start at the server.** Expand outward from the known connected region and fix obvious dead ends first. +- **Watch the borders.** Edge and corner tiles have fewer legal orientations because they cannot point off the board. +- **Lock confirmed tiles.** Once a branch is clearly correct, lock it to avoid undoing progress while tracing the rest of the network. diff --git a/netwalk/keys.go b/netwalk/keys.go new file mode 100644 index 0000000..7177623 --- /dev/null +++ b/netwalk/keys.go @@ -0,0 +1,49 @@ +package netwalk + +import ( + "charm.land/bubbles/v2/key" + "github.com/FelineStateMachine/puzzletea/game" +) + +type KeyMap struct { + game.CursorKeyMap + Rotate key.Binding + RotateBack key.Binding + Lock key.Binding +} + +var DefaultKeyMap = KeyMap{ + CursorKeyMap: game.DefaultCursorKeyMap, + Rotate: key.NewBinding( + key.WithKeys("enter", "space"), + key.WithHelp("enter/space", "Rotate"), + ), + RotateBack: key.NewBinding( + key.WithKeys("backspace", "shift+space"), + key.WithHelp("bkspc", "Rotate back"), + ), + Lock: key.NewBinding( + key.WithKeys("l"), + key.WithHelp("l", "Toggle lock"), + ), +} + +func (m *Model) updateKeyBindings() { + m.keys.Up.SetEnabled(m.cursor.Y > 0) + m.keys.Down.SetEnabled(m.cursor.Y < m.puzzle.Size-1) + m.keys.Left.SetEnabled(m.cursor.X > 0) + m.keys.Right.SetEnabled(m.cursor.X < m.puzzle.Size-1) + + current := m.puzzle.Tiles[m.cursor.Y][m.cursor.X] + canAct := !m.state.solved && isActive(current) + m.keys.Rotate.SetEnabled(canAct && !current.Locked) + m.keys.RotateBack.SetEnabled(canAct && !current.Locked) + m.keys.Lock.SetEnabled(canAct) +} + +func (m Model) GetFullHelp() [][]key.Binding { + return [][]key.Binding{ + {m.keys.Up, m.keys.Down, m.keys.Left, m.keys.Right}, + {m.keys.Rotate, m.keys.RotateBack, m.keys.Lock}, + } +} diff --git a/netwalk/model.go b/netwalk/model.go new file mode 100644 index 0000000..c0b61d8 --- /dev/null +++ b/netwalk/model.go @@ -0,0 +1,200 @@ +package netwalk + +import ( + "fmt" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/FelineStateMachine/puzzletea/game" +) + +type Model struct { + puzzle Puzzle + cursor game.Cursor + keys KeyMap + modeTitle string + showFullHelp bool + termWidth int + termHeight int + originX int + originY int + originValid bool + state boardState +} + +var _ game.Gamer = Model{} + +func New(mode NetwalkMode, puzzle Puzzle) (game.Gamer, error) { + cursor := puzzle.firstActive() + m := Model{ + puzzle: puzzle, + cursor: game.Cursor{X: cursor.X, Y: cursor.Y}, + keys: DefaultKeyMap, + modeTitle: mode.Title(), + } + m.recompute() + return m, nil +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) { + switch msg := msg.(type) { + case game.HelpToggleMsg: + m.showFullHelp = msg.Show + m.originValid = false + case tea.WindowSizeMsg: + m.termWidth = msg.Width + m.termHeight = msg.Height + m.originValid = false + case tea.MouseClickMsg: + m = m.handleMouseClick(msg) + case tea.KeyPressMsg: + switch { + case key.Matches(msg, m.keys.Rotate): + m.rotateCurrent(1) + case key.Matches(msg, m.keys.RotateBack): + m.rotateCurrent(3) + case key.Matches(msg, m.keys.Lock): + m.toggleCurrentLock() + default: + m.cursor.Move(m.keys.CursorKeyMap, msg, m.puzzle.Size-1, m.puzzle.Size-1) + } + } + m.updateKeyBindings() + return m, nil +} + +func (m Model) View() string { + title := game.TitleBarView("Netwalk", m.modeTitle, m.state.solved) + grid := gridView(m) + if m.state.solved { + return game.ComposeGameView(title, grid) + } + return game.ComposeGameViewRows( + title, + grid, + game.StableRow(statusBarView(m, m.showFullHelp), statusBarView(m, false), statusBarView(m, true)), + ) +} + +func (m Model) SetTitle(t string) game.Gamer { + m.modeTitle = t + return m +} + +func (m Model) IsSolved() bool { + return m.state.solved +} + +func (m Model) Reset() game.Gamer { + for y := range m.puzzle.Size { + for x := range m.puzzle.Size { + m.puzzle.Tiles[y][x].Rotation = m.puzzle.Tiles[y][x].InitialRotation + m.puzzle.Tiles[y][x].Locked = false + } + } + cursor := m.puzzle.firstActive() + m.cursor = game.Cursor{X: cursor.X, Y: cursor.Y} + m.originValid = false + m.recompute() + return m +} + +func (m Model) GetDebugInfo() string { + return game.DebugHeader("Netwalk", [][2]string{ + {"Status", stateLabel(m.state.solved)}, + {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)}, + {"Grid Size", fmt.Sprintf("%d×%d", m.puzzle.Size, m.puzzle.Size)}, + {"Root", fmt.Sprintf("(%d, %d)", m.puzzle.Root.X, m.puzzle.Root.Y)}, + {"Connected", fmt.Sprintf("%d / %d", m.state.connected, m.state.nonEmpty)}, + {"Dangling", fmt.Sprintf("%d", m.state.dangling)}, + {"Locked", fmt.Sprintf("%d", m.state.locked)}, + }) +} + +func stateLabel(solved bool) string { + if solved { + return "Solved" + } + return "In Progress" +} + +func (m *Model) rotateCurrent(delta uint8) { + if m.state.solved { + return + } + t := &m.puzzle.Tiles[m.cursor.Y][m.cursor.X] + if !isActive(*t) || t.Locked { + return + } + t.Rotation = (t.Rotation + delta) % 4 + m.recompute() +} + +func (m *Model) toggleCurrentLock() { + if m.state.solved { + return + } + t := &m.puzzle.Tiles[m.cursor.Y][m.cursor.X] + if !isActive(*t) { + return + } + t.Locked = !t.Locked + m.recompute() +} + +func (m *Model) recompute() { + m.state = analyzePuzzle(m.puzzle) +} + +func (m Model) handleMouseClick(msg tea.MouseClickMsg) Model { + col, row, ok := m.screenToGrid(msg.X, msg.Y) + if !ok { + return m + } + m.cursor.X = col + m.cursor.Y = row + + switch msg.Button { + case tea.MouseLeft: + m.rotateCurrent(1) + case tea.MouseRight: + m.toggleCurrentLock() + } + return m +} + +func (m *Model) screenToGrid(screenX, screenY int) (col, row int, ok bool) { + ox, oy := m.cachedGridOrigin() + return game.DynamicGridScreenToCell( + game.DynamicGridMetrics{ + Width: m.puzzle.Size, + Height: m.puzzle.Size, + CellWidth: cellWidth, + }, + ox, + oy, + screenX, + screenY, + false, + ) +} + +func (m *Model) cachedGridOrigin() (x, y int) { + if m.originValid { + return m.originX, m.originY + } + x, y = m.gridOrigin() + m.originX, m.originY = x, y + m.originValid = true + return x, y +} + +func (m *Model) gridOrigin() (x, y int) { + title := game.TitleBarView("Netwalk", m.modeTitle, m.state.solved) + grid := gridView(*m) + return game.DynamicGridOrigin(m.termWidth, m.termHeight, m.View(), title, grid) +} diff --git a/netwalk/netwalk_test.go b/netwalk/netwalk_test.go new file mode 100644 index 0000000..a2dd002 --- /dev/null +++ b/netwalk/netwalk_test.go @@ -0,0 +1,163 @@ +package netwalk + +import ( + "math/rand/v2" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +func TestRotateMask(t *testing.T) { + mask := north | east + if got := rotateMask(mask, 1); got != east|south { + t.Fatalf("rotateMask(..., 1) = %v, want %v", got, east|south) + } + if got := rotateMask(mask, 2); got != south|west { + t.Fatalf("rotateMask(..., 2) = %v, want %v", got, south|west) + } +} + +func TestAnalyzePuzzleSolvedAndDangling(t *testing.T) { + puzzle := newPuzzle(2) + puzzle.Root = point{X: 0, Y: 0} + puzzle.Tiles[0][0] = tile{BaseMask: east, Kind: serverCell} + puzzle.Tiles[0][1] = tile{BaseMask: west, Kind: nodeCell} + + state := analyzePuzzle(puzzle) + if !state.solved { + t.Fatal("expected simple 2-cell puzzle to be solved") + } + + puzzle.Tiles[0][1].Rotation = 1 + state = analyzePuzzle(puzzle) + if state.solved { + t.Fatal("rotated puzzle should not be solved") + } + if state.dangling == 0 { + t.Fatal("expected dangling connectors after bad rotation") + } +} + +func TestSaveImportRoundTrip(t *testing.T) { + puzzle := newPuzzle(3) + puzzle.Root = point{X: 1, Y: 1} + puzzle.Tiles[1][1] = tile{BaseMask: north | east | south, Rotation: 1, InitialRotation: 2, Kind: serverCell, Locked: true} + puzzle.Tiles[0][1] = tile{BaseMask: south, Rotation: 3, InitialRotation: 3, Kind: nodeCell} + puzzle.Tiles[1][2] = tile{BaseMask: west, Rotation: 0, InitialRotation: 1, Kind: nodeCell} + puzzle.Tiles[2][1] = tile{BaseMask: north, Rotation: 2, InitialRotation: 2, Kind: nodeCell} + + m := Model{puzzle: puzzle, keys: DefaultKeyMap, modeTitle: "Test"} + m.recompute() + save, err := m.GetSave() + if err != nil { + t.Fatalf("GetSave() error = %v", err) + } + + imported, err := ImportModel(save) + if err != nil { + t.Fatalf("ImportModel() error = %v", err) + } + + if imported.puzzle.Size != puzzle.Size { + t.Fatalf("size = %d, want %d", imported.puzzle.Size, puzzle.Size) + } + if imported.puzzle.Root != puzzle.Root { + t.Fatalf("root = %+v, want %+v", imported.puzzle.Root, puzzle.Root) + } + if !imported.puzzle.Tiles[1][1].Locked { + t.Fatal("expected lock state to round-trip") + } + if imported.puzzle.Tiles[1][1].Rotation != 1 || imported.puzzle.Tiles[1][1].InitialRotation != 2 { + t.Fatal("expected rotations to round-trip") + } +} + +func TestGenerateSeededDeterministic(t *testing.T) { + rngA := rand.New(rand.NewPCG(10, 20)) + rngB := rand.New(rand.NewPCG(10, 20)) + + a, err := GenerateSeeded(7, 14, rngA) + if err != nil { + t.Fatalf("GenerateSeeded() error = %v", err) + } + b, err := GenerateSeeded(7, 14, rngB) + if err != nil { + t.Fatalf("GenerateSeeded() error = %v", err) + } + + if got, want := encodeMaskRows(a.Tiles), encodeMaskRows(b.Tiles); got != want { + t.Fatalf("mask encoding mismatch\n got %q\nwant %q", got, want) + } + if got, want := encodeRotationRows(a.Tiles, true), encodeRotationRows(b.Tiles, true); got != want { + t.Fatalf("initial rotations mismatch\n got %q\nwant %q", got, want) + } +} + +func TestCellTextUsesStarAndDot(t *testing.T) { + m := Model{} + + rootTile := tile{Kind: serverCell} + leafTile := tile{Kind: nodeCell} + junctionTile := tile{Kind: nodeCell} + + m.puzzle = newPuzzle(3) + m.puzzle.Tiles[1][1] = rootTile + m.puzzle.Tiles[1][2] = leafTile + m.puzzle.Tiles[0][1] = junctionTile + m.state.rotatedMasks = make([][]directionMask, 3) + for y := range 3 { + m.state.rotatedMasks[y] = make([]directionMask, 3) + } + m.state.rotatedMasks[1][1] = east | west + m.state.rotatedMasks[1][2] = west + m.state.rotatedMasks[0][1] = east | south | west + + if got := cellText(m, 1, 1); got != "──★──" { + t.Fatalf("root cellText = %q, want %q", got, "──★──") + } + if got := cellText(m, 2, 1); got != "──• " { + t.Fatalf("leaf cellText = %q, want %q", got, "──• ") + } + if got := cellText(m, 1, 0); got != "──┬──" { + t.Fatalf("junction cellText = %q, want %q", got, "──┬──") + } +} + +func TestBridgeTextSpansSeparators(t *testing.T) { + m := Model{} + m.puzzle = newPuzzle(2) + m.state.rotatedMasks = [][]directionMask{ + {east, west}, + {south, north}, + } + + if got := verticalBridgeText(m, 1, 0); got != "─" { + t.Fatalf("verticalBridgeText = %q, want %q", got, "─") + } + + m.state.rotatedMasks = [][]directionMask{ + {south, 0}, + {north, 0}, + } + if got := horizontalBridgeText(m, 0, 1); got != " │ " { + t.Fatalf("horizontalBridgeText = %q, want %q", got, " │ ") + } +} + +func TestGridViewUsesMinimalFrameWithoutInteriorBoxes(t *testing.T) { + m := Model{ + puzzle: newPuzzle(2), + } + m.recompute() + + lines := strings.Split(ansi.Strip(gridView(m)), "\n") + if len(lines) != 5 { + t.Fatalf("rendered line count = %d, want 5", len(lines)) + } + for _, idx := range []int{1, 2, 3} { + if got := strings.Count(lines[idx], "│"); got != 2 { + t.Fatalf("line %d has %d vertical borders, want outer frame only", idx, got) + } + } +} diff --git a/netwalk/print_adapter.go b/netwalk/print_adapter.go new file mode 100644 index 0000000..e8e130e --- /dev/null +++ b/netwalk/print_adapter.go @@ -0,0 +1,122 @@ +package netwalk + +import ( + "math" + "strings" + + "codeberg.org/go-pdf/fpdf" + "github.com/FelineStateMachine/puzzletea/pdfexport" +) + +type printAdapter struct{} + +var PDFPrintAdapter = printAdapter{} + +func (printAdapter) CanonicalGameType() string { return "Netwalk" } +func (printAdapter) Aliases() []string { return []string{"netwalk", "network"} } + +func (printAdapter) BuildPDFPayload(save []byte) (any, error) { + return pdfexport.ParseNetwalkPrintData(save) +} + +func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error { + switch data := payload.(type) { + case *pdfexport.NetwalkData: + renderNetwalkPage(pdf, data) + } + return nil +} + +func renderNetwalkPage(pdf *fpdf.Fpdf, data *pdfexport.NetwalkData) { + if data == nil || data.Size <= 0 { + return + } + + pageW, pageH := pdf.GetPageSize() + pageNo := pdf.PageNo() + area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1) + cellSize := pdfexport.FitCompactCellSize(data.Size, data.Size, area) + if cellSize <= 0 { + return + } + + blockW := float64(data.Size) * cellSize + blockH := float64(data.Size) * cellSize + startX, startY := pdfexport.CenteredOrigin(area, data.Size, data.Size, cellSize) + + pdf.SetDrawColor(60, 60, 60) + pdf.SetLineWidth(pdfexport.ThinGridLineMM) + for y := range data.Size { + for x := range data.Size { + cellX := startX + float64(x)*cellSize + cellY := startY + float64(y)*cellSize + pdf.Rect(cellX, cellY, cellSize, cellSize, "D") + drawNetwalkTile(pdf, data, x, y, cellX, cellY, cellSize) + } + } + + pdf.SetDrawColor(35, 35, 35) + pdf.SetLineWidth(pdfexport.OuterBorderLineMM) + pdf.Rect(startX, startY, blockW, blockH, "D") + + ruleY := pdfexport.InstructionY(startY+blockH, pageH, 1) + pdfexport.SetInstructionStyle(pdf) + pdf.SetXY(area.X, ruleY) + pdf.CellFormat( + area.W, + pdfexport.InstructionLineHMM, + "Rotate tiles so every connector matches and the full network reaches the server without loops.", + "", + 0, + "C", + false, + 0, + "", + ) +} + +func drawNetwalkTile(pdf *fpdf.Fpdf, data *pdfexport.NetwalkData, x, y int, cellX, cellY, cellSize float64) { + mask := directionMask(data.Masks[y][x]) + if mask == 0 { + return + } + mask = rotateMask(mask, data.Rotations[y][x]) + + centerX := cellX + cellSize/2 + centerY := cellY + cellSize/2 + pad := cellSize * 0.16 + pdf.SetLineWidth(math.Max(cellSize*0.08, 0.35)) + pdf.SetDrawColor(50, 50, 50) + + if mask&north != 0 { + pdf.Line(centerX, centerY, centerX, cellY+pad) + } + if mask&east != 0 { + pdf.Line(centerX, centerY, cellX+cellSize-pad, centerY) + } + if mask&south != 0 { + pdf.Line(centerX, centerY, centerX, cellY+cellSize-pad) + } + if mask&west != 0 { + pdf.Line(centerX, centerY, cellX+pad, centerY) + } + + if x == data.RootX && y == data.RootY { + drawNetwalkCenteredText(pdf, centerX, centerY, cellSize, "★", 0.62) + return + } + + if degree(mask) == 1 { + drawNetwalkCenteredText(pdf, centerX, centerY, cellSize, "•", 0.68) + } +} + +func drawNetwalkCenteredText(pdf *fpdf.Fpdf, centerX, centerY, cellSize float64, text string, scale float64) { + fontSize := pdfexport.ClampStandardCellFontSize(pdfexport.StandardCellFontSize(cellSize, scale)) + pdf.SetTextColor(50, 50, 50) + pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize) + lineH := fontSize * 0.9 + width := pdf.GetStringWidth(text) + pdf.SetXY(centerX-width/2, centerY-lineH/2) + pdf.CellFormat(width, lineH, strings.TrimSpace(text), "", 0, "C", false, 0, "") +} diff --git a/netwalk/style.go b/netwalk/style.go new file mode 100644 index 0000000..6453eb6 --- /dev/null +++ b/netwalk/style.go @@ -0,0 +1,195 @@ +package netwalk + +import ( + "image/color" + "strconv" + + "charm.land/lipgloss/v2" + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/theme" +) + +const cellWidth = 5 + +func gridView(m Model) string { + return game.RenderDynamicGrid(game.DynamicGridSpec{ + Width: m.puzzle.Size, + Height: m.puzzle.Size, + CellWidth: cellWidth, + Solved: m.state.solved, + Cell: func(x, y int) string { + return cellView(m, x, y) + }, + ZoneAt: func(x, y int) int { + return 0 + }, + BridgeFill: func(bridge game.DynamicGridBridge) color.Color { + return bridgeFill(m, bridge) + }, + BridgeForeground: func(bridge game.DynamicGridBridge) color.Color { + return bridgeForeground(m, bridge) + }, + VerticalBridgeText: func(x, y int) string { + return verticalBridgeText(m, x, y) + }, + HorizontalBridgeText: func(x, y int) string { + return horizontalBridgeText(m, x, y) + }, + }) +} + +func cellView(m Model, x, y int) string { + t := m.puzzle.Tiles[y][x] + bg := cellBackground(m, x, y) + style := lipgloss.NewStyle(). + Width(cellWidth). + AlignHorizontal(lipgloss.Center). + Background(bg). + Foreground(cellForeground(m, x, y)) + + if x == m.cursor.X && y == m.cursor.Y && isActive(t) { + style = style.Bold(true) + } + if t.Kind == serverCell || m.state.tileHasDangling[y][x] { + style = style.Bold(true) + } + + return style.Render(cellText(m, x, y)) +} + +func cellText(m Model, x, y int) string { + t := m.puzzle.Tiles[y][x] + if !isActive(t) { + return " " + } + mask := m.state.rotatedMasks[y][x] + if t.Kind == serverCell { + return directionalSymbolText(mask, '★') + } + if degree(mask) == 1 { + return directionalSymbolText(mask, '•') + } + + left := " " + if mask&west != 0 { + left = "──" + } + right := " " + if mask&east != 0 { + right = "──" + } + return left + maskGlyph(mask) + right +} + +func directionalSymbolText(mask directionMask, symbol rune) string { + switch { + case mask&west != 0 && mask&east != 0: + return "──" + string(symbol) + "──" + case mask&west != 0: + return "──" + string(symbol) + " " + case mask&east != 0: + return " " + string(symbol) + "──" + default: + return " " + string(symbol) + " " + } +} + +func cellBackground(m Model, x, y int) color.Color { + t := m.puzzle.Tiles[y][x] + if !isActive(t) { + if m.state.solved { + return theme.Current().SuccessBG + } + return theme.Current().BG + } + if m.state.solved { + return theme.Current().SuccessBG + } + if x == m.cursor.X && y == m.cursor.Y { + return theme.Blend(theme.Current().BG, theme.Current().Accent, 0.18) + } + if t.Locked { + return theme.Blend(theme.Current().BG, theme.Current().Surface, 0.60) + } + return theme.Current().BG +} + +func cellForeground(m Model, x, y int) color.Color { + return pipeForeground(m, point{X: x, Y: y}) +} + +func pipeForeground(m Model, cells ...point) color.Color { + if m.state.solved { + return theme.Current().SolvedFG + } + + hasConnected := false + for _, cell := range cells { + if !m.puzzle.activeAt(cell.X, cell.Y) { + continue + } + tile := m.puzzle.Tiles[cell.Y][cell.X] + if tile.Kind == serverCell { + return theme.Current().AccentSoft + } + if m.state.tileHasDangling[cell.Y][cell.X] { + return theme.Current().Error + } + if m.state.connectedToRoot[cell.Y][cell.X] { + hasConnected = true + } + } + if hasConnected { + return theme.Current().Secondary + } + return theme.Current().FG +} + +func bridgeFill(m Model, _ game.DynamicGridBridge) color.Color { + if m.state.solved { + return theme.Current().SuccessBG + } + return nil +} + +func bridgeForeground(m Model, bridge game.DynamicGridBridge) color.Color { + cells := make([]point, 0, bridge.Count) + for i := 0; i < bridge.Count; i++ { + cells = append(cells, point{X: bridge.Cells[i].X, Y: bridge.Cells[i].Y}) + } + return pipeForeground(m, cells...) +} + +func verticalBridgeText(m Model, x, y int) string { + if x <= 0 || x >= m.puzzle.Size || y < 0 || y >= m.puzzle.Size { + return "" + } + leftMask := m.state.rotatedMasks[y][x-1] + rightMask := m.state.rotatedMasks[y][x] + if leftMask&east == 0 || rightMask&west == 0 { + return "" + } + return "─" +} + +func horizontalBridgeText(m Model, x, y int) string { + if x < 0 || x >= m.puzzle.Size || y <= 0 || y >= m.puzzle.Size { + return "" + } + topMask := m.state.rotatedMasks[y-1][x] + bottomMask := m.state.rotatedMasks[y][x] + if topMask&south == 0 || bottomMask&north == 0 { + return "" + } + return " │ " +} + +func statusBarView(m Model, full bool) string { + info := "connected " + strconv.Itoa(m.state.connected) + "/" + strconv.Itoa(m.state.nonEmpty) + + " dangling " + strconv.Itoa(m.state.dangling) + + " locks " + strconv.Itoa(m.state.locked) + if !full { + return game.StatusBarStyle().Render(info + " space rotate l lock") + } + return game.StatusBarStyle().Render(info + " enter/space rotate backspace reverse l toggle lock ctrl+r reset") +} diff --git a/netwalk/testdata/visual_states.jsonl b/netwalk/testdata/visual_states.jsonl new file mode 100644 index 0000000..cb50dcf --- /dev/null +++ b/netwalk/testdata/visual_states.jsonl @@ -0,0 +1,10 @@ +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-14T00:00:00Z","version":"visual-fixture","category":"Netwalk","mode_selection":"Visual Fixture","count":10,"seed":""},"puzzle":{"index":1,"name":"cursor-root-horizontal","game":"Netwalk","mode":"Visual Fixture","save":{"size":4,"masks":"2800\n0000\n0004\n0001","rotations":"0000\n0000\n0000\n0000","initial_rotations":"0000\n0000\n0000\n0000","kinds":"S#..\n....\n...#\n...#","locks":"....\n....\n....\n....","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-14T00:00:00Z","version":"visual-fixture","category":"Netwalk","mode_selection":"Visual Fixture","count":10,"seed":""},"puzzle":{"index":2,"name":"leaf-gallery","game":"Netwalk","mode":"Visual Fixture","save":{"size":5,"masks":"28008\n00001\n00000\n40000\n20021","rotations":"00000\n00000\n00000\n00000\n00000","initial_rotations":"00000\n00000\n00000\n00000\n00000","kinds":"S#..#\n....#\n.....\n#....\n#..##","locks":".....\n.....\n.....\n.....\n.....","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-14T00:00:00Z","version":"visual-fixture","category":"Netwalk","mode_selection":"Visual Fixture","count":10,"seed":""},"puzzle":{"index":3,"name":"straight-and-corner-gallery","game":"Netwalk","mode":"Visual Fixture","save":{"size":5,"masks":"28ac0\n00050\n0063c\n00109\n00000","rotations":"00000\n00000\n00000\n00000\n00000","initial_rotations":"00000\n00000\n00000\n00000\n00000","kinds":"S###.\n...#.\n..###\n..#.#\n.....","locks":".....\n.....\n.....\n.....\n.....","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-14T00:00:00Z","version":"visual-fixture","category":"Netwalk","mode_selection":"Visual Fixture","count":10,"seed":""},"puzzle":{"index":4,"name":"tee-and-cross-gallery","game":"Netwalk","mode":"Visual Fixture","save":{"size":5,"masks":"28040\n00078\n002f8\n00010\n00000","rotations":"00000\n00000\n00000\n00000\n00000","initial_rotations":"00000\n00000\n00000\n00000\n00000","kinds":"S#.#.\n...##\n..###\n...#.\n.....","locks":".....\n.....\n.....\n.....\n.....","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-14T00:00:00Z","version":"visual-fixture","category":"Netwalk","mode_selection":"Visual Fixture","count":10,"seed":""},"puzzle":{"index":5,"name":"connected-horizontal-bridge","game":"Netwalk","mode":"Visual Fixture","save":{"size":5,"masks":"2a800\n00000\n00000\n00004\n00001","rotations":"00000\n00000\n00000\n00000\n00000","initial_rotations":"00000\n00000\n00000\n00000\n00000","kinds":"S##..\n.....\n.....\n....#\n....#","locks":".....\n.....\n.....\n.....\n.....","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-14T00:00:00Z","version":"visual-fixture","category":"Netwalk","mode_selection":"Visual Fixture","count":10,"seed":""},"puzzle":{"index":6,"name":"connected-vertical-bridge","game":"Netwalk","mode":"Visual Fixture","save":{"size":5,"masks":"40000\n50000\n10000\n00004\n00001","rotations":"00000\n00000\n00000\n00000\n00000","initial_rotations":"00000\n00000\n00000\n00000\n00000","kinds":"S....\n#....\n#....\n....#\n....#","locks":".....\n.....\n.....\n.....\n.....","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-14T00:00:00Z","version":"visual-fixture","category":"Netwalk","mode_selection":"Visual Fixture","count":10,"seed":""},"puzzle":{"index":7,"name":"disconnected-default-foreground","game":"Netwalk","mode":"Visual Fixture","save":{"size":5,"masks":"28000\n0006c\n00039\n00000\n00000","rotations":"00000\n00000\n00000\n00000\n00000","initial_rotations":"00000\n00000\n00000\n00000\n00000","kinds":"S#...\n...##\n...##\n.....\n.....","locks":".....\n.....\n.....\n.....\n.....","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-14T00:00:00Z","version":"visual-fixture","category":"Netwalk","mode_selection":"Visual Fixture","count":10,"seed":""},"puzzle":{"index":8,"name":"dangling-error-state","game":"Netwalk","mode":"Visual Fixture","save":{"size":4,"masks":"2800\n0000\n0020\n0000","rotations":"0000\n0000\n0000\n0000","initial_rotations":"0000\n0000\n0000\n0000","kinds":"S#..\n....\n..#.\n....","locks":"....\n....\n....\n....","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-14T00:00:00Z","version":"visual-fixture","category":"Netwalk","mode_selection":"Visual Fixture","count":10,"seed":""},"puzzle":{"index":9,"name":"locked-root-cursor","game":"Netwalk","mode":"Visual Fixture","save":{"size":4,"masks":"2800\n0000\n0004\n0001","rotations":"0000\n0000\n0000\n0000","initial_rotations":"0000\n0000\n0000\n0000","kinds":"S#..\n....\n...#\n...#","locks":"#...\n....\n....\n....","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-14T00:00:00Z","version":"visual-fixture","category":"Netwalk","mode_selection":"Visual Fixture","count":10,"seed":""},"puzzle":{"index":10,"name":"solved-with-empty-cells","game":"Netwalk","mode":"Visual Fixture","save":{"size":4,"masks":"0000\n0680\n0100\n0000","rotations":"0000\n0000\n0000\n0000","initial_rotations":"0000\n0000\n0000\n0000","kinds":"....\n.S#.\n.#..\n....","locks":"....\n....\n....\n....","mode_title":"Visual Fixture"}}} diff --git a/netwalk/visual_fixture.go b/netwalk/visual_fixture.go new file mode 100644 index 0000000..04dd3cb --- /dev/null +++ b/netwalk/visual_fixture.go @@ -0,0 +1,229 @@ +package netwalk + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/pdfexport" +) + +const visualFixtureModeTitle = "Visual Fixture" + +type visualFixtureCell struct { + x int + y int + mask directionMask + kind cellKind + lock bool +} + +type visualFixtureCase struct { + name string + puzzle Puzzle +} + +var visualFixtureCases = []visualFixtureCase{ + { + name: "cursor-root-horizontal", + puzzle: buildVisualFixturePuzzle(4, point{X: 0, Y: 0}, + serverTile(0, 0, east), + nodeTile(1, 0, west), + nodeTile(3, 2, south), + nodeTile(3, 3, north), + ), + }, + { + name: "leaf-gallery", + puzzle: buildVisualFixturePuzzle(5, point{X: 0, Y: 0}, + serverTile(0, 0, east), + nodeTile(1, 0, west), + nodeTile(4, 0, west), + nodeTile(4, 1, north), + nodeTile(3, 4, east), + nodeTile(4, 4, north), + nodeTile(0, 3, south), + nodeTile(0, 4, east), + ), + }, + { + name: "straight-and-corner-gallery", + puzzle: buildVisualFixturePuzzle(5, point{X: 0, Y: 0}, + serverTile(0, 0, east), + nodeTile(1, 0, west), + nodeTile(2, 0, east|west), + nodeTile(3, 0, west|south), + nodeTile(3, 1, north|south), + nodeTile(3, 2, north|east), + nodeTile(4, 2, west|south), + nodeTile(4, 3, north|west), + nodeTile(2, 2, east|south), + nodeTile(2, 3, north), + ), + }, + { + name: "tee-and-cross-gallery", + puzzle: buildVisualFixturePuzzle(5, point{X: 0, Y: 0}, + serverTile(0, 0, east), + nodeTile(1, 0, west), + nodeTile(3, 1, north|east|south), + nodeTile(3, 0, south), + nodeTile(4, 1, west), + nodeTile(3, 2, north|east|south|west), + nodeTile(2, 2, east), + nodeTile(4, 2, west), + nodeTile(3, 3, north), + ), + }, + { + name: "connected-horizontal-bridge", + puzzle: buildVisualFixturePuzzle(5, point{X: 0, Y: 0}, + serverTile(0, 0, east), + nodeTile(1, 0, east|west), + nodeTile(2, 0, west), + nodeTile(4, 3, south), + nodeTile(4, 4, north), + ), + }, + { + name: "connected-vertical-bridge", + puzzle: buildVisualFixturePuzzle(5, point{X: 0, Y: 0}, + serverTile(0, 0, south), + nodeTile(0, 1, north|south), + nodeTile(0, 2, north), + nodeTile(4, 3, south), + nodeTile(4, 4, north), + ), + }, + { + name: "disconnected-default-foreground", + puzzle: buildVisualFixturePuzzle(5, point{X: 0, Y: 0}, + serverTile(0, 0, east), + nodeTile(1, 0, west), + nodeTile(3, 1, east|south), + nodeTile(4, 1, south|west), + nodeTile(3, 2, north|east), + nodeTile(4, 2, north|west), + ), + }, + { + name: "dangling-error-state", + puzzle: buildVisualFixturePuzzle(4, point{X: 0, Y: 0}, + serverTile(0, 0, east), + nodeTile(1, 0, west), + nodeTile(2, 2, east), + ), + }, + { + name: "locked-root-cursor", + puzzle: buildVisualFixturePuzzle(4, point{X: 0, Y: 0}, + serverLockedTile(0, 0, east), + nodeTile(1, 0, west), + nodeTile(3, 2, south), + nodeTile(3, 3, north), + ), + }, + { + name: "solved-with-empty-cells", + puzzle: buildVisualFixturePuzzle(4, point{X: 1, Y: 1}, + serverTile(1, 1, east|south), + nodeTile(2, 1, west), + nodeTile(1, 2, north), + ), + }, +} + +func VisualFixtureJSONL() ([]byte, error) { + records, err := visualFixtureRecords() + if err != nil { + return nil, err + } + + var b strings.Builder + for _, record := range records { + data, err := json.Marshal(record) + if err != nil { + return nil, fmt.Errorf("marshal visual fixture record: %w", err) + } + b.Write(data) + b.WriteByte('\n') + } + + return []byte(b.String()), nil +} + +func visualFixtureRecords() ([]pdfexport.JSONLRecord, error) { + records := make([]pdfexport.JSONLRecord, 0, len(visualFixtureCases)) + for i, fixture := range visualFixtureCases { + save, err := visualFixtureSave(fixture.puzzle) + if err != nil { + return nil, fmt.Errorf("fixture %q: %w", fixture.name, err) + } + + records = append(records, pdfexport.JSONLRecord{ + Schema: pdfexport.ExportSchemaV1, + Pack: pdfexport.JSONLPackMeta{ + Generated: "2026-03-14T00:00:00Z", + Version: "visual-fixture", + Category: "Netwalk", + ModeSelection: visualFixtureModeTitle, + Count: len(visualFixtureCases), + }, + Puzzle: pdfexport.JSONLPuzzle{ + Index: i + 1, + Name: fixture.name, + Game: "Netwalk", + Mode: visualFixtureModeTitle, + Save: save, + }, + }) + } + + return records, nil +} + +func visualFixtureSave(puzzle Puzzle) (json.RawMessage, error) { + cursor := puzzle.firstActive() + m := Model{ + puzzle: puzzle, + keys: DefaultKeyMap, + modeTitle: visualFixtureModeTitle, + cursor: game.Cursor{X: cursor.X, Y: cursor.Y}, + } + m.recompute() + + save, err := m.GetSave() + if err != nil { + return nil, fmt.Errorf("encode save: %w", err) + } + + return json.RawMessage(save), nil +} + +func buildVisualFixturePuzzle(size int, root point, cells ...visualFixtureCell) Puzzle { + puzzle := newPuzzle(size) + puzzle.Root = root + for _, cell := range cells { + puzzle.Tiles[cell.y][cell.x] = tile{ + BaseMask: cell.mask, + Rotation: 0, + InitialRotation: 0, + Locked: cell.lock, + Kind: cell.kind, + } + } + return puzzle +} + +func serverTile(x, y int, mask directionMask) visualFixtureCell { + return visualFixtureCell{x: x, y: y, mask: mask, kind: serverCell} +} + +func serverLockedTile(x, y int, mask directionMask) visualFixtureCell { + return visualFixtureCell{x: x, y: y, mask: mask, kind: serverCell, lock: true} +} + +func nodeTile(x, y int, mask directionMask) visualFixtureCell { + return visualFixtureCell{x: x, y: y, mask: mask, kind: nodeCell} +} diff --git a/netwalk/visual_fixture_test.go b/netwalk/visual_fixture_test.go new file mode 100644 index 0000000..3486f66 --- /dev/null +++ b/netwalk/visual_fixture_test.go @@ -0,0 +1,98 @@ +package netwalk + +import ( + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" +) + +func TestVisualFixtureJSONLMatchesCommittedFile(t *testing.T) { + want, err := VisualFixtureJSONL() + if err != nil { + t.Fatalf("VisualFixtureJSONL() error = %v", err) + } + + path := filepath.Join("testdata", "visual_states.jsonl") + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read fixture file: %v", err) + } + + if string(got) != string(want) { + t.Fatalf("committed fixture is out of sync; regenerate %s", path) + } +} + +func TestVisualFixtureCasesCoverExpectedViews(t *testing.T) { + wantNames := []string{ + "cursor-root-horizontal", + "leaf-gallery", + "straight-and-corner-gallery", + "tee-and-cross-gallery", + "connected-horizontal-bridge", + "connected-vertical-bridge", + "disconnected-default-foreground", + "dangling-error-state", + "locked-root-cursor", + "solved-with-empty-cells", + } + if len(visualFixtureCases) != len(wantNames) { + t.Fatalf("fixture case count = %d, want %d", len(visualFixtureCases), len(wantNames)) + } + for i, want := range wantNames { + if got := visualFixtureCases[i].name; got != want { + t.Fatalf("fixture case %d = %q, want %q", i, got, want) + } + } +} + +func TestVisualFixtureRepresentativeViews(t *testing.T) { + t.Run("dangling case shows error status", func(t *testing.T) { + view := fixtureView(t, "dangling-error-state") + if !strings.Contains(view, "dangling 1") { + t.Fatalf("expected dangling status in view:\n%s", view) + } + }) + + t.Run("locked root case reports lock count", func(t *testing.T) { + view := fixtureView(t, "locked-root-cursor") + if !strings.Contains(view, "locks 1") { + t.Fatalf("expected lock count in view:\n%s", view) + } + }) + + t.Run("solved case shows solved badge", func(t *testing.T) { + view := fixtureView(t, "solved-with-empty-cells") + if !strings.Contains(view, "SOLVED") { + t.Fatalf("expected solved badge in view:\n%s", view) + } + }) +} + +func fixtureView(t *testing.T, name string) string { + t.Helper() + + for _, fixture := range visualFixtureCases { + if fixture.name != name { + continue + } + + save, err := visualFixtureSave(fixture.puzzle) + if err != nil { + t.Fatalf("visualFixtureSave(%q) error = %v", name, err) + } + model, err := ImportModel(save) + if err != nil { + t.Fatalf("ImportModel(%q) error = %v", name, err) + } + g, _ := model.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + return ansi.Strip(g.View()) + } + + t.Fatalf("fixture %q not found", name) + return "" +} diff --git a/pdfexport/parse_netwalk.go b/pdfexport/parse_netwalk.go new file mode 100644 index 0000000..dd3dcb0 --- /dev/null +++ b/pdfexport/parse_netwalk.go @@ -0,0 +1,104 @@ +package pdfexport + +import ( + "encoding/json" + "fmt" + "strings" +) + +type netwalkSave struct { + Size int `json:"size"` + Masks string `json:"masks"` + InitialRotations string `json:"initial_rotations"` + Kinds string `json:"kinds"` +} + +func ParseNetwalkPrintData(saveData []byte) (*NetwalkData, error) { + if len(strings.TrimSpace(string(saveData))) == 0 { + return nil, nil + } + + var save netwalkSave + if err := json.Unmarshal(saveData, &save); err != nil { + return nil, fmt.Errorf("decode netwalk save: %w", err) + } + if save.Size <= 0 { + return nil, nil + } + + maskRows, err := parseNetwalkRows(save.Size, save.Masks) + if err != nil { + return nil, fmt.Errorf("parse netwalk masks: %w", err) + } + rotationRows, err := parseNetwalkRows(save.Size, save.InitialRotations) + if err != nil { + return nil, fmt.Errorf("parse netwalk rotations: %w", err) + } + kindRows, err := parseNetwalkRows(save.Size, save.Kinds) + if err != nil { + return nil, fmt.Errorf("parse netwalk kinds: %w", err) + } + + data := &NetwalkData{ + Size: save.Size, + Masks: make([][]uint8, save.Size), + Rotations: make([][]uint8, save.Size), + RootX: -1, + RootY: -1, + } + + for y := 0; y < save.Size; y++ { + data.Masks[y] = make([]uint8, save.Size) + data.Rotations[y] = make([]uint8, save.Size) + for x := 0; x < save.Size; x++ { + mask, ok := parseHexNibble(maskRows[y][x]) + if !ok { + return nil, fmt.Errorf("invalid mask value %q at (%d,%d)", maskRows[y][x], x, y) + } + if rotationRows[y][x] < '0' || rotationRows[y][x] > '3' { + return nil, fmt.Errorf("invalid rotation value %q at (%d,%d)", rotationRows[y][x], x, y) + } + data.Masks[y][x] = mask + data.Rotations[y][x] = rotationRows[y][x] - '0' + if kindRows[y][x] == 'S' { + data.RootX = x + data.RootY = y + } + } + } + + if data.RootX < 0 || data.RootY < 0 { + return nil, fmt.Errorf("netwalk print data missing root") + } + + return data, nil +} + +func parseNetwalkRows(size int, raw string) ([][]byte, error) { + rows := splitNormalizedLines(raw) + if len(rows) != size { + return nil, fmt.Errorf("row count = %d, want %d", len(rows), size) + } + + result := make([][]byte, size) + for y, row := range rows { + if len(row) != size { + return nil, fmt.Errorf("row %d width = %d, want %d", y, len(row), size) + } + result[y] = []byte(row) + } + return result, nil +} + +func parseHexNibble(value byte) (uint8, bool) { + switch { + case value >= '0' && value <= '9': + return value - '0', true + case value >= 'a' && value <= 'f': + return 10 + value - 'a', true + case value >= 'A' && value <= 'F': + return 10 + value - 'A', true + default: + return 0, false + } +} diff --git a/pdfexport/types.go b/pdfexport/types.go index 4ee67ad..3179b14 100644 --- a/pdfexport/types.go +++ b/pdfexport/types.go @@ -97,6 +97,14 @@ type FillominoData struct { Givens [][]int } +type NetwalkData struct { + Size int + Masks [][]uint8 + Rotations [][]uint8 + RootX int + RootY int +} + type RippleEffectCell struct { X int `json:"x"` Y int `json:"y"` diff --git a/registry/registry.go b/registry/registry.go index f54713b..b8b3198 100644 --- a/registry/registry.go +++ b/registry/registry.go @@ -12,6 +12,7 @@ import ( "github.com/FelineStateMachine/puzzletea/hashiwokakero" "github.com/FelineStateMachine/puzzletea/hitori" "github.com/FelineStateMachine/puzzletea/lightsout" + "github.com/FelineStateMachine/puzzletea/netwalk" "github.com/FelineStateMachine/puzzletea/nonogram" "github.com/FelineStateMachine/puzzletea/nurikabe" "github.com/FelineStateMachine/puzzletea/puzzle" @@ -43,6 +44,7 @@ var all = []Entry{ hashiwokakero.Entry, hitori.Entry, lightsout.Entry, + netwalk.Entry, nonogram.Entry, nurikabe.Entry, rippleeffect.Entry, diff --git a/registry/registry_test.go b/registry/registry_test.go index 9a6743f..cb034b3 100644 --- a/registry/registry_test.go +++ b/registry/registry_test.go @@ -19,6 +19,7 @@ func TestResolveNormalizesSpacingAndAliases(t *testing.T) { }{ {name: "word search", want: "Word Search"}, {name: "hashi", want: "Hashiwokakero"}, + {name: "network", want: "Netwalk"}, {name: "polyomino", want: "Fillomino"}, } From f1e564c42533a428ff9a0c221dfd741dc81e9ee1 Mon Sep 17 00:00:00 2001 From: Dami Date: Sat, 14 Mar 2026 16:19:06 -0600 Subject: [PATCH 02/10] netwalk polishing. --- netwalk/README.md | 4 +- netwalk/gamemode.go | 71 +++++++++--- netwalk/generator.go | 198 ++++++++++++++++++++++++++----- netwalk/help.md | 4 +- netwalk/keys.go | 8 +- netwalk/model.go | 24 ++-- netwalk/netwalk_test.go | 243 +++++++++++++++++++++++++++++++++------ netwalk/print_adapter.go | 72 +++++++++--- netwalk/style.go | 179 +++++++++++++--------------- 9 files changed, 585 insertions(+), 218 deletions(-) diff --git a/netwalk/README.md b/netwalk/README.md index 0b904a5..3794974 100644 --- a/netwalk/README.md +++ b/netwalk/README.md @@ -22,9 +22,9 @@ puzzletea new network medium | Key | Action | |-----|--------| | `Arrows` / `wasd` / `hjkl` | Move cursor | -| `Enter` / `Space` | Rotate clockwise | +| `Space` | Rotate clockwise | | `Backspace` | Rotate counter-clockwise | -| `l` | Toggle lock | +| `Enter` | Toggle lock | ## Modes diff --git a/netwalk/gamemode.go b/netwalk/gamemode.go index e091a1d..0370259 100644 --- a/netwalk/gamemode.go +++ b/netwalk/gamemode.go @@ -14,8 +14,9 @@ var HelpContent string type NetwalkMode struct { game.BaseMode - Size int - TargetActive int + Size int + FillRatio float64 + Profile generateProfile } var ( @@ -24,16 +25,22 @@ var ( _ game.SeededSpawner = NetwalkMode{} ) -func NewMode(title, desc string, size, targetActive int) NetwalkMode { +func NewMode(title, desc string, size int, fillRatio float64, profile generateProfile) NetwalkMode { return NetwalkMode{ - BaseMode: game.NewBaseMode(title, desc), - Size: size, - TargetActive: targetActive, + BaseMode: game.NewBaseMode(title, desc), + Size: size, + FillRatio: fillRatio, + Profile: profile, } } func (m NetwalkMode) Spawn() (game.Gamer, error) { - p, err := Generate(m.Size, m.TargetActive) + p, err := GenerateSeededWithDensity( + m.Size, + m.FillRatio, + m.Profile, + rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())), + ) if err != nil { return nil, err } @@ -41,19 +48,57 @@ func (m NetwalkMode) Spawn() (game.Gamer, error) { } func (m NetwalkMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) { - p, err := GenerateSeeded(m.Size, m.TargetActive, rng) + p, err := GenerateSeededWithDensity(m.Size, m.FillRatio, m.Profile, rng) if err != nil { return nil, err } return New(m, p) } +var ( + miniProfile = generateProfile{ + ParentDegreeWeights: [5]int{16, 16, 6, -8, -12}, + OrthogonalPackedWeight: 2, + DiagonalPackedWeight: 1, + SpanGrowthWeight: 12, + MinSpanRatio: 0.55, + } + easyProfile = generateProfile{ + ParentDegreeWeights: [5]int{14, 14, 8, -4, -8}, + OrthogonalPackedWeight: 4, + DiagonalPackedWeight: 2, + SpanGrowthWeight: 14, + MinSpanRatio: 0.62, + } + mediumProfile = generateProfile{ + ParentDegreeWeights: [5]int{10, 10, 12, 2, -4}, + OrthogonalPackedWeight: 6, + DiagonalPackedWeight: 3, + SpanGrowthWeight: 14, + MinSpanRatio: 0.70, + } + hardProfile = generateProfile{ + ParentDegreeWeights: [5]int{6, 6, 16, 8, 0}, + OrthogonalPackedWeight: 8, + DiagonalPackedWeight: 4, + SpanGrowthWeight: 16, + MinSpanRatio: 0.78, + } + expertProfile = generateProfile{ + ParentDegreeWeights: [5]int{4, 4, 18, 10, 2}, + OrthogonalPackedWeight: 10, + DiagonalPackedWeight: 5, + SpanGrowthWeight: 18, + MinSpanRatio: 0.84, + } +) + var Modes = []game.Mode{ - NewMode("Mini 5x5", "Compact 5×5 network. Good first deduction pass.", 5, 8), - NewMode("Easy 7x7", "7×7 board with a modest tree and clear local constraints.", 7, 14), - NewMode("Medium 9x9", "Larger network with more branches and ambiguous elbows.", 9, 22), - NewMode("Hard 11x11", "Dense mid-size network that rewards global checking.", 11, 30), - NewMode("Expert 13x13", "Longer branch interactions and more disconnected-looking scrambles.", 13, 40), + NewMode("Mini 5x5", "Compact 5×5 network with a denser starter tree.", 5, 0.40, miniProfile), + NewMode("Easy 7x7", "7×7 board with fuller coverage and gentle local tangles.", 7, 0.47, easyProfile), + NewMode("Medium 9x9", "Balanced 9×9 network with tighter clusters and more branching.", 9, 0.54, mediumProfile), + NewMode("Hard 11x11", "Dense 11×11 board that packs branches into close local interactions.", 11, 0.62, hardProfile), + NewMode("Expert 13x13", "Large, crowded network with heavy branching and frequent near-miss tangles.", 13, 0.68, expertProfile), } var ModeDefinitions = gameentry.BuildModeDefs(Modes) diff --git a/netwalk/generator.go b/netwalk/generator.go index ef667f3..2af6609 100644 --- a/netwalk/generator.go +++ b/netwalk/generator.go @@ -2,16 +2,37 @@ package netwalk import ( "errors" + "math" "math/rand/v2" ) const maxGenerateAttempts = 64 +type generateProfile struct { + ParentDegreeWeights [5]int + OrthogonalPackedWeight int + DiagonalPackedWeight int + SpanGrowthWeight int + MinSpanRatio float64 +} + +var legacyGenerateProfile = generateProfile{ + ParentDegreeWeights: [5]int{16, 16, 8, -2, -4}, +} + func Generate(size, targetActive int) (Puzzle, error) { return GenerateSeeded(size, targetActive, rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))) } func GenerateSeeded(size, targetActive int, rng *rand.Rand) (Puzzle, error) { + return generateSeededWithProfile(size, targetActive, legacyGenerateProfile, rng) +} + +func GenerateSeededWithDensity(size int, fillRatio float64, profile generateProfile, rng *rand.Rand) (Puzzle, error) { + return generateSeededWithProfile(size, targetActiveFromFillRatio(size, fillRatio), profile, rng) +} + +func generateSeededWithProfile(size, targetActive int, profile generateProfile, rng *rand.Rand) (Puzzle, error) { if size <= 1 { return Puzzle{}, errors.New("netwalk size must be at least 2") } @@ -23,7 +44,7 @@ func GenerateSeeded(size, targetActive int, rng *rand.Rand) (Puzzle, error) { } for attempt := 0; attempt < maxGenerateAttempts; attempt++ { - puzzle := buildTreePuzzle(size, targetActive, rng) + puzzle := buildTreePuzzle(size, targetActive, profile, rng) scramblePuzzle(&puzzle, rng) if !analyzePuzzle(puzzle).solved { return puzzle, nil @@ -38,7 +59,14 @@ type frontierEdge struct { to point } -func buildTreePuzzle(size, targetActive int, rng *rand.Rand) Puzzle { +type activeBounds struct { + minX int + maxX int + minY int + maxY int +} + +func buildTreePuzzle(size, targetActive int, profile generateProfile, rng *rand.Rand) Puzzle { puzzle := newPuzzle(size) root := point{X: size / 2, Y: size / 2} puzzle.Root = root @@ -47,12 +75,13 @@ func buildTreePuzzle(size, targetActive int, rng *rand.Rand) Puzzle { adjacency := map[point]directionMask{root: 0} for len(active) < targetActive { + bounds := measureActiveBounds(active) frontier := collectFrontier(size, active) if len(frontier) == 0 { break } - edge := frontier[weightedFrontierIndex(frontier, adjacency, root, rng)] + edge := frontier[weightedFrontierIndex(size, frontier, active, adjacency, bounds, profile, rng)] active[edge.to] = struct{}{} adjacency[edge.to] = 0 @@ -98,10 +127,60 @@ func collectFrontier(size int, active map[point]struct{}) []frontierEdge { return frontier } +func targetActiveFromFillRatio(size int, fillRatio float64) int { + if size <= 1 { + return 2 + } + target := int(math.Round(float64(size*size) * fillRatio)) + if target < 2 { + return 2 + } + if target > size*size { + return size * size + } + return target +} + +func measureActiveBounds(active map[point]struct{}) activeBounds { + first := true + bounds := activeBounds{} + for p := range active { + if first { + bounds = activeBounds{minX: p.X, maxX: p.X, minY: p.Y, maxY: p.Y} + first = false + continue + } + if p.X < bounds.minX { + bounds.minX = p.X + } + if p.X > bounds.maxX { + bounds.maxX = p.X + } + if p.Y < bounds.minY { + bounds.minY = p.Y + } + if p.Y > bounds.maxY { + bounds.maxY = p.Y + } + } + return bounds +} + +func (b activeBounds) spanX() int { + return b.maxX - b.minX + 1 +} + +func (b activeBounds) spanY() int { + return b.maxY - b.minY + 1 +} + func weightedFrontierIndex( + size int, frontier []frontierEdge, + active map[point]struct{}, adjacency map[point]directionMask, - root point, + bounds activeBounds, + profile generateProfile, rng *rand.Rand, ) int { if len(frontier) == 1 { @@ -111,29 +190,7 @@ func weightedFrontierIndex( weights := make([]int, len(frontier)) total := 0 for i, edge := range frontier { - weight := 10 - deg := degree(adjacency[edge.from]) - switch { - case deg <= 1: - weight += 6 - case deg == 2: - weight += 3 - case deg >= 3: - weight -= 2 - } - - dx := edge.to.X - root.X - if dx < 0 { - dx = -dx - } - dy := edge.to.Y - root.Y - if dy < 0 { - dy = -dy - } - weight += max(0, 6-(dx+dy)) - if weight < 1 { - weight = 1 - } + weight := frontierWeight(size, edge, active, adjacency, bounds, profile) weights[i] = weight total += weight } @@ -149,6 +206,93 @@ func weightedFrontierIndex( return len(frontier) - 1 } +func frontierWeight( + size int, + edge frontierEdge, + active map[point]struct{}, + adjacency map[point]directionMask, + bounds activeBounds, + profile generateProfile, +) int { + deg := degree(adjacency[edge.from]) + if deg >= len(profile.ParentDegreeWeights) { + deg = len(profile.ParentDegreeWeights) - 1 + } + + weight := 64 + profile.ParentDegreeWeights[deg] + orthogonal, diagonal := packedNeighborCounts(size, edge, active) + weight += orthogonal * profile.OrthogonalPackedWeight + weight += diagonal * profile.DiagonalPackedWeight + weight += spanGrowthScore(size, edge.to, bounds, profile) + if weight < 1 { + return 1 + } + return weight +} + +func packedNeighborCounts( + size int, + edge frontierEdge, + active map[point]struct{}, +) (int, int) { + orthogonal := 0 + diagonal := 0 + for _, dir := range directions { + next := point{X: edge.to.X + dir.dx, Y: edge.to.Y + dir.dy} + if next == edge.from || !inBounds(size, next) { + continue + } + if _, ok := active[next]; ok { + orthogonal++ + } + } + + for _, delta := range [][2]int{{-1, -1}, {1, -1}, {1, 1}, {-1, 1}} { + next := point{X: edge.to.X + delta[0], Y: edge.to.Y + delta[1]} + if !inBounds(size, next) { + continue + } + if _, ok := active[next]; ok { + diagonal++ + } + } + return orthogonal, diagonal +} + +func spanGrowthScore(size int, candidate point, bounds activeBounds, profile generateProfile) int { + target := minSpanTarget(size, profile.MinSpanRatio) + if target == 0 || profile.SpanGrowthWeight == 0 { + return 0 + } + + score := 0 + if bounds.spanX() < target && (candidate.X < bounds.minX || candidate.X > bounds.maxX) { + score += profile.SpanGrowthWeight + } + if bounds.spanY() < target && (candidate.Y < bounds.minY || candidate.Y > bounds.maxY) { + score += profile.SpanGrowthWeight + } + return score +} + +func minSpanTarget(size int, ratio float64) int { + if size <= 0 || ratio <= 0 { + return 0 + } + target := int(math.Ceil(float64(size) * ratio)) + if target < 1 { + return 1 + } + if target > size { + return size + } + return target +} + +func inBounds(size int, p point) bool { + return p.X >= 0 && p.X < size && p.Y >= 0 && p.Y < size +} + func scramblePuzzle(puzzle *Puzzle, rng *rand.Rand) { if puzzle == nil { return diff --git a/netwalk/help.md b/netwalk/help.md index ef2470c..0d5b74f 100644 --- a/netwalk/help.md +++ b/netwalk/help.md @@ -14,9 +14,9 @@ Rotate network tiles until every active tile connects back to the server in one | Key | Action | |-----|--------| | `Arrows` / `wasd` / `hjkl` | Move cursor | -| `Enter` / `Space` | Rotate current tile clockwise | +| `Space` | Rotate current tile clockwise | | `Backspace` | Rotate current tile counter-clockwise | -| `l` | Toggle lock on current tile | +| `Enter` | Toggle lock on current tile | | `Mouse left-click` | Rotate clicked tile | | `Mouse right-click` | Toggle lock on clicked tile | | `Ctrl+R` | Reset puzzle | diff --git a/netwalk/keys.go b/netwalk/keys.go index 7177623..d7e63bb 100644 --- a/netwalk/keys.go +++ b/netwalk/keys.go @@ -15,16 +15,16 @@ type KeyMap struct { var DefaultKeyMap = KeyMap{ CursorKeyMap: game.DefaultCursorKeyMap, Rotate: key.NewBinding( - key.WithKeys("enter", "space"), - key.WithHelp("enter/space", "Rotate"), + key.WithKeys("space"), + key.WithHelp("space", "Rotate"), ), RotateBack: key.NewBinding( key.WithKeys("backspace", "shift+space"), key.WithHelp("bkspc", "Rotate back"), ), Lock: key.NewBinding( - key.WithKeys("l"), - key.WithHelp("l", "Toggle lock"), + key.WithKeys("enter"), + key.WithHelp("enter", "Toggle lock"), ), } diff --git a/netwalk/model.go b/netwalk/model.go index c0b61d8..1439fa3 100644 --- a/netwalk/model.go +++ b/netwalk/model.go @@ -169,18 +169,18 @@ func (m Model) handleMouseClick(msg tea.MouseClickMsg) Model { func (m *Model) screenToGrid(screenX, screenY int) (col, row int, ok bool) { ox, oy := m.cachedGridOrigin() - return game.DynamicGridScreenToCell( - game.DynamicGridMetrics{ - Width: m.puzzle.Size, - Height: m.puzzle.Size, - CellWidth: cellWidth, - }, - ox, - oy, - screenX, - screenY, - false, - ) + lx := screenX - ox + ly := screenY - oy + if lx < 0 || ly < 0 { + return 0, 0, false + } + + col = lx / cellWidth + row = ly / cellHeight + if col < 0 || col >= m.puzzle.Size || row < 0 || row >= m.puzzle.Size { + return 0, 0, false + } + return col, row, true } func (m *Model) cachedGridOrigin() (x, y int) { diff --git a/netwalk/netwalk_test.go b/netwalk/netwalk_test.go index a2dd002..873733d 100644 --- a/netwalk/netwalk_test.go +++ b/netwalk/netwalk_test.go @@ -5,6 +5,9 @@ import ( "strings" "testing" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/FelineStateMachine/puzzletea/game" "github.com/charmbracelet/x/ansi" ) @@ -94,70 +97,234 @@ func TestGenerateSeededDeterministic(t *testing.T) { } } -func TestCellTextUsesStarAndDot(t *testing.T) { - m := Model{} +func TestDefaultKeyMapUsesEnterForLockAndSpaceForRotate(t *testing.T) { + if !key.Matches( + keyPress("space"), + DefaultKeyMap.Rotate, + ) { + t.Fatal("space should match rotate binding") + } + if key.Matches( + keyPress("enter"), + DefaultKeyMap.Rotate, + ) { + t.Fatal("enter should not match rotate binding") + } + if !key.Matches( + keyPress("enter"), + DefaultKeyMap.Lock, + ) { + t.Fatal("enter should match lock binding") + } + if key.Matches( + keyPress("l"), + DefaultKeyMap.Lock, + ) { + t.Fatal("l should not match lock binding") + } +} - rootTile := tile{Kind: serverCell} - leafTile := tile{Kind: nodeCell} - junctionTile := tile{Kind: nodeCell} +func TestFrontierWeightPrefersPackedCandidatesOnHardProfiles(t *testing.T) { + active := map[point]struct{}{ + {X: 2, Y: 2}: {}, + {X: 1, Y: 1}: {}, + {X: 3, Y: 1}: {}, + } + adjacency := map[point]directionMask{ + {X: 2, Y: 2}: 0, + {X: 1, Y: 1}: 0, + {X: 3, Y: 1}: 0, + } + bounds := activeBounds{minX: 0, maxX: 4, minY: 0, maxY: 4} - m.puzzle = newPuzzle(3) - m.puzzle.Tiles[1][1] = rootTile - m.puzzle.Tiles[1][2] = leafTile - m.puzzle.Tiles[0][1] = junctionTile - m.state.rotatedMasks = make([][]directionMask, 3) - for y := range 3 { - m.state.rotatedMasks[y] = make([]directionMask, 3) + packed := frontierWeight( + 5, + frontierEdge{from: point{X: 2, Y: 2}, to: point{X: 2, Y: 1}}, + active, + adjacency, + bounds, + hardProfile, + ) + isolated := frontierWeight( + 5, + frontierEdge{from: point{X: 2, Y: 2}, to: point{X: 2, Y: 3}}, + active, + adjacency, + bounds, + hardProfile, + ) + if packed <= isolated { + t.Fatalf("packed frontier weight = %d, want > isolated %d", packed, isolated) + } +} + +func TestSpanGrowthScoreRewardsExpansionBeforeTarget(t *testing.T) { + bounds := activeBounds{minX: 2, maxX: 4, minY: 2, maxY: 4} + + growing := spanGrowthScore(9, point{X: 1, Y: 4}, bounds, mediumProfile) + stable := spanGrowthScore(9, point{X: 3, Y: 3}, bounds, mediumProfile) + if growing <= stable { + t.Fatalf("expanding span score = %d, want > stable %d", growing, stable) + } +} + +func TestNetwalkModeDensityProgression(t *testing.T) { + modes := netwalkModesFromRegistry(t) + fill := make([]float64, len(modes)) + junctions := make([]float64, len(modes)) + + for i, mode := range modes { + fill[i], junctions[i] = sampleModeMetrics(t, mode, 12) } - m.state.rotatedMasks[1][1] = east | west - m.state.rotatedMasks[1][2] = west - m.state.rotatedMasks[0][1] = east | south | west - if got := cellText(m, 1, 1); got != "──★──" { - t.Fatalf("root cellText = %q, want %q", got, "──★──") + for i := 1; i < len(fill); i++ { + if fill[i] <= fill[i-1] { + t.Fatalf("fill ratio[%d] = %.3f, want > %.3f", i, fill[i], fill[i-1]) + } } - if got := cellText(m, 2, 1); got != "──• " { - t.Fatalf("leaf cellText = %q, want %q", got, "──• ") + for i := 2; i < len(junctions); i++ { + if junctions[i] <= junctions[i-1] { + t.Fatalf("junction avg[%d] = %.3f, want > %.3f", i, junctions[i], junctions[i-1]) + } } - if got := cellText(m, 1, 0); got != "──┬──" { - t.Fatalf("junction cellText = %q, want %q", got, "──┬──") + + if fill[3] < 0.57 || fill[3] > 0.67 { + t.Fatalf("hard fill ratio = %.3f, want within [0.57, 0.67]", fill[3]) + } + if fill[4] < 0.63 || fill[4] > 0.73 { + t.Fatalf("expert fill ratio = %.3f, want within [0.63, 0.73]", fill[4]) } } -func TestBridgeTextSpansSeparators(t *testing.T) { +func TestCellRowsShowDirectionalRootsAndLeaves(t *testing.T) { m := Model{} - m.puzzle = newPuzzle(2) - m.state.rotatedMasks = [][]directionMask{ - {east, west}, - {south, north}, - } - if got := verticalBridgeText(m, 1, 0); got != "─" { - t.Fatalf("verticalBridgeText = %q, want %q", got, "─") + m.puzzle = newPuzzle(3) + m.puzzle.Tiles[1][1] = tile{Kind: serverCell} + m.puzzle.Tiles[1][2] = tile{Kind: nodeCell} + m.puzzle.Tiles[0][1] = tile{Kind: nodeCell} + m.state.rotatedMasks = make([][]directionMask, 3) + for y := range 3 { + m.state.rotatedMasks[y] = make([]directionMask, 3) } + m.state.rotatedMasks[1][1] = south + m.state.rotatedMasks[1][2] = north + m.state.rotatedMasks[0][1] = north | east | south - m.state.rotatedMasks = [][]directionMask{ - {south, 0}, - {north, 0}, + if got := cellRows(m, 1, 1); got != [cellHeight]string{" ", " ◆ ", " │ "} { + t.Fatalf("south root rows = %#v", got) + } + if got := cellRows(m, 2, 1); got != [cellHeight]string{" │ ", " ● ", " "} { + t.Fatalf("north leaf rows = %#v", got) } - if got := horizontalBridgeText(m, 0, 1); got != " │ " { - t.Fatalf("horizontalBridgeText = %q, want %q", got, " │ ") + if got := cellRows(m, 1, 0); got != [cellHeight]string{" │ ", " ├──", " │ "} { + t.Fatalf("tee rows = %#v", got) } } -func TestGridViewUsesMinimalFrameWithoutInteriorBoxes(t *testing.T) { +func TestGridViewUsesTallerFrameWithoutInteriorBoxes(t *testing.T) { m := Model{ puzzle: newPuzzle(2), } m.recompute() lines := strings.Split(ansi.Strip(gridView(m)), "\n") - if len(lines) != 5 { - t.Fatalf("rendered line count = %d, want 5", len(lines)) + if len(lines) != 8 { + t.Fatalf("rendered line count = %d, want 8", len(lines)) } - for _, idx := range []int{1, 2, 3} { + for _, idx := range []int{1, 2, 3, 4, 5, 6} { if got := strings.Count(lines[idx], "│"); got != 2 { t.Fatalf("line %d has %d vertical borders, want outer frame only", idx, got) } } } + +func TestGridViewShowsCursorGlyphsOnBlankCells(t *testing.T) { + m := Model{ + puzzle: newPuzzle(2), + cursor: game.Cursor{X: 1, Y: 1}, + } + m.recompute() + + view := ansi.Strip(gridView(m)) + if !strings.Contains(view, "▸ ◂") { + t.Fatalf("blank cursor markers missing from view:\n%s", view) + } +} + +func netwalkModesFromRegistry(t *testing.T) []NetwalkMode { + t.Helper() + + modes := make([]NetwalkMode, 0, len(Modes)) + for i, mode := range Modes { + netwalkMode, ok := mode.(NetwalkMode) + if !ok { + t.Fatalf("mode %d has type %T, want NetwalkMode", i, mode) + } + modes = append(modes, netwalkMode) + } + return modes +} + +func sampleModeMetrics( + t *testing.T, + mode NetwalkMode, + samples int, +) (float64, float64) { + t.Helper() + + var totalFill float64 + var totalJunctions float64 + for i := range samples { + rng := rand.New(rand.NewPCG(uint64(1000+i), uint64(7000+i))) + puzzle, err := GenerateSeededWithDensity(mode.Size, mode.FillRatio, mode.Profile, rng) + if err != nil { + t.Fatalf("GenerateSeededWithDensity(%q) error = %v", mode.Title(), err) + } + totalFill += puzzleFillRatio(puzzle) + totalJunctions += float64(puzzleJunctionCount(puzzle)) + } + return totalFill / float64(samples), totalJunctions / float64(samples) +} + +func puzzleFillRatio(p Puzzle) float64 { + if p.Size <= 0 { + return 0 + } + active := 0 + for y := range p.Size { + for x := range p.Size { + if isActive(p.Tiles[y][x]) { + active++ + } + } + } + return float64(active) / float64(p.Size*p.Size) +} + +func puzzleJunctionCount(p Puzzle) int { + count := 0 + for y := range p.Size { + for x := range p.Size { + if !isActive(p.Tiles[y][x]) { + continue + } + if degree(p.Tiles[y][x].BaseMask) >= 3 { + count++ + } + } + } + return count +} + +func keyPress(value string) tea.KeyPressMsg { + switch value { + case "enter": + return tea.KeyPressMsg{Code: tea.KeyEnter} + case "space": + return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "} + default: + r := []rune(value) + return tea.KeyPressMsg{Code: r[0], Text: value} + } +} diff --git a/netwalk/print_adapter.go b/netwalk/print_adapter.go index e8e130e..b1c692f 100644 --- a/netwalk/print_adapter.go +++ b/netwalk/print_adapter.go @@ -2,7 +2,6 @@ package netwalk import ( "math" - "strings" "codeberg.org/go-pdf/fpdf" "github.com/FelineStateMachine/puzzletea/pdfexport" @@ -44,18 +43,17 @@ func renderNetwalkPage(pdf *fpdf.Fpdf, data *pdfexport.NetwalkData) { blockH := float64(data.Size) * cellSize startX, startY := pdfexport.CenteredOrigin(area, data.Size, data.Size, cellSize) - pdf.SetDrawColor(60, 60, 60) - pdf.SetLineWidth(pdfexport.ThinGridLineMM) + drawNetwalkGrid(pdf, startX, startY, blockW, blockH, data.Size, cellSize) + for y := range data.Size { for x := range data.Size { cellX := startX + float64(x)*cellSize cellY := startY + float64(y)*cellSize - pdf.Rect(cellX, cellY, cellSize, cellSize, "D") drawNetwalkTile(pdf, data, x, y, cellX, cellY, cellSize) } } - pdf.SetDrawColor(35, 35, 35) + pdf.SetDrawColor(55, 55, 55) pdf.SetLineWidth(pdfexport.OuterBorderLineMM) pdf.Rect(startX, startY, blockW, blockH, "D") @@ -75,6 +73,21 @@ func renderNetwalkPage(pdf *fpdf.Fpdf, data *pdfexport.NetwalkData) { ) } +func drawNetwalkGrid(pdf *fpdf.Fpdf, startX, startY, blockW, blockH float64, size int, cellSize float64) { + if size <= 1 { + return + } + + pdf.SetDrawColor(115, 115, 115) + pdf.SetLineWidth(math.Max(pdfexport.ThinGridLineMM*0.72, 0.14)) + + for i := 1; i < size; i++ { + offset := float64(i) * cellSize + pdf.Line(startX+offset, startY, startX+offset, startY+blockH) + pdf.Line(startX, startY+offset, startX+blockW, startY+offset) + } +} + func drawNetwalkTile(pdf *fpdf.Fpdf, data *pdfexport.NetwalkData, x, y int, cellX, cellY, cellSize float64) { mask := directionMask(data.Masks[y][x]) if mask == 0 { @@ -84,9 +97,14 @@ func drawNetwalkTile(pdf *fpdf.Fpdf, data *pdfexport.NetwalkData, x, y int, cell centerX := cellX + cellSize/2 centerY := cellY + cellSize/2 + isRoot := x == data.RootX && y == data.RootY + isLeaf := degree(mask) == 1 pad := cellSize * 0.16 - pdf.SetLineWidth(math.Max(cellSize*0.08, 0.35)) - pdf.SetDrawColor(50, 50, 50) + if isRoot || isLeaf { + pad = math.Max(pad, netwalkMarkerRadius(cellSize, isRoot)) + } + pdf.SetLineWidth(math.Max(cellSize*0.055, 0.26)) + pdf.SetDrawColor(65, 65, 65) if mask&north != 0 { pdf.Line(centerX, centerY, centerX, cellY+pad) @@ -101,22 +119,38 @@ func drawNetwalkTile(pdf *fpdf.Fpdf, data *pdfexport.NetwalkData, x, y int, cell pdf.Line(centerX, centerY, cellX+pad, centerY) } - if x == data.RootX && y == data.RootY { - drawNetwalkCenteredText(pdf, centerX, centerY, cellSize, "★", 0.62) + switch { + case isRoot: + drawNetwalkSourceMarker(pdf, centerX, centerY, cellSize) return + case isLeaf: + drawNetwalkSinkMarker(pdf, centerX, centerY, cellSize) } +} - if degree(mask) == 1 { - drawNetwalkCenteredText(pdf, centerX, centerY, cellSize, "•", 0.68) +func netwalkMarkerRadius(cellSize float64, root bool) float64 { + scale := 0.16 + if root { + scale = 0.20 } + return math.Max(1.2, math.Min(2.6, cellSize*scale)) } -func drawNetwalkCenteredText(pdf *fpdf.Fpdf, centerX, centerY, cellSize float64, text string, scale float64) { - fontSize := pdfexport.ClampStandardCellFontSize(pdfexport.StandardCellFontSize(cellSize, scale)) - pdf.SetTextColor(50, 50, 50) - pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize) - lineH := fontSize * 0.9 - width := pdf.GetStringWidth(text) - pdf.SetXY(centerX-width/2, centerY-lineH/2) - pdf.CellFormat(width, lineH, strings.TrimSpace(text), "", 0, "C", false, 0, "") +func drawNetwalkSinkMarker(pdf *fpdf.Fpdf, centerX, centerY, cellSize float64) { + radius := netwalkMarkerRadius(cellSize, false) + pdf.SetDrawColor(65, 65, 65) + pdf.SetFillColor(255, 255, 255) + pdf.SetLineWidth(math.Max(cellSize*0.045, 0.22)) + pdf.Circle(centerX, centerY, radius, "DF") +} + +func drawNetwalkSourceMarker(pdf *fpdf.Fpdf, centerX, centerY, cellSize float64) { + radius := netwalkMarkerRadius(cellSize, true) + pdf.SetDrawColor(65, 65, 65) + pdf.SetFillColor(255, 255, 255) + pdf.SetLineWidth(math.Max(cellSize*0.05, 0.24)) + pdf.Circle(centerX, centerY, radius, "DF") + + pdf.SetFillColor(65, 65, 65) + pdf.Circle(centerX, centerY, radius*0.36, "F") } diff --git a/netwalk/style.go b/netwalk/style.go index 6453eb6..8595560 100644 --- a/netwalk/style.go +++ b/netwalk/style.go @@ -3,111 +3,127 @@ package netwalk import ( "image/color" "strconv" + "strings" "charm.land/lipgloss/v2" "github.com/FelineStateMachine/puzzletea/game" "github.com/FelineStateMachine/puzzletea/theme" ) -const cellWidth = 5 +const ( + cellWidth = 5 + cellHeight = 3 +) func gridView(m Model) string { - return game.RenderDynamicGrid(game.DynamicGridSpec{ - Width: m.puzzle.Size, - Height: m.puzzle.Size, - CellWidth: cellWidth, - Solved: m.state.solved, - Cell: func(x, y int) string { - return cellView(m, x, y) - }, - ZoneAt: func(x, y int) int { - return 0 - }, - BridgeFill: func(bridge game.DynamicGridBridge) color.Color { - return bridgeFill(m, bridge) - }, - BridgeForeground: func(bridge game.DynamicGridBridge) color.Color { - return bridgeForeground(m, bridge) - }, - VerticalBridgeText: func(x, y int) string { - return verticalBridgeText(m, x, y) - }, - HorizontalBridgeText: func(x, y int) string { - return horizontalBridgeText(m, x, y) - }, - }) + colors := game.DefaultBorderColors() + rows := make([]string, 0, m.puzzle.Size*cellHeight+2) + rows = append(rows, game.HBorderRow(m.puzzle.Size, -1, cellWidth, "┌", "┐", colors, m.state.solved)) + for y := range m.puzzle.Size { + for inner := range cellHeight { + rows = append(rows, gridContentRow(m, y, inner, colors)) + } + } + rows = append(rows, game.HBorderRow(m.puzzle.Size, -1, cellWidth, "└", "┘", colors, m.state.solved)) + return strings.Join(rows, "\n") +} + +func gridContentRow(m Model, y, inner int, colors game.GridBorderColors) string { + var b strings.Builder + b.WriteString(game.BorderChar("│", colors, m.state.solved, false)) + for x := range m.puzzle.Size { + b.WriteString(cellRowView(m, x, y, inner)) + } + b.WriteString(game.BorderChar("│", colors, m.state.solved, false)) + return b.String() } -func cellView(m Model, x, y int) string { +func cellRowView(m Model, x, y, inner int) string { + rows := cellRows(m, x, y) t := m.puzzle.Tiles[y][x] - bg := cellBackground(m, x, y) + if x == m.cursor.X && y == m.cursor.Y && !isActive(t) { + rows = blankCursorRows() + } style := lipgloss.NewStyle(). Width(cellWidth). - AlignHorizontal(lipgloss.Center). - Background(bg). + Background(cellBackground(m, x, y)). Foreground(cellForeground(m, x, y)) if x == m.cursor.X && y == m.cursor.Y && isActive(t) { style = style.Bold(true) } - if t.Kind == serverCell || m.state.tileHasDangling[y][x] { + if t.Kind == serverCell || degree(m.state.rotatedMasks[y][x]) == 1 || m.state.tileHasDangling[y][x] { style = style.Bold(true) } - return style.Render(cellText(m, x, y)) + return style.Render(rows[inner]) } -func cellText(m Model, x, y int) string { +func cellRows(m Model, x, y int) [cellHeight]string { t := m.puzzle.Tiles[y][x] if !isActive(t) { - return " " + return [cellHeight]string{" ", " ", " "} } + mask := m.state.rotatedMasks[y][x] - if t.Kind == serverCell { - return directionalSymbolText(mask, '★') + center := centerGlyph(t.Kind, mask) + + return [cellHeight]string{ + verticalCellRow(mask&north != 0), + horizontalCellRow(mask&west != 0, center, mask&east != 0), + verticalCellRow(mask&south != 0), } - if degree(mask) == 1 { - return directionalSymbolText(mask, '•') +} + +func centerGlyph(kind cellKind, mask directionMask) string { + switch { + case kind == serverCell: + return "◆" + case degree(mask) == 1: + return "●" + default: + return maskGlyph(mask) } +} - left := " " - if mask&west != 0 { - left = "──" +func blankCursorRows() [cellHeight]string { + return [cellHeight]string{ + " ", + game.CursorLeft + " " + game.CursorRight, + " ", } - right := " " - if mask&east != 0 { - right = "──" +} + +func verticalCellRow(on bool) string { + if !on { + return " " } - return left + maskGlyph(mask) + right + return " │ " } -func directionalSymbolText(mask directionMask, symbol rune) string { - switch { - case mask&west != 0 && mask&east != 0: - return "──" + string(symbol) + "──" - case mask&west != 0: - return "──" + string(symbol) + " " - case mask&east != 0: - return " " + string(symbol) + "──" - default: - return " " + string(symbol) + " " +func horizontalCellRow(left bool, center string, right bool) string { + leftArm := " " + if left { + leftArm = "──" } + rightArm := " " + if right { + rightArm = "──" + } + return leftArm + center + rightArm } func cellBackground(m Model, x, y int) color.Color { t := m.puzzle.Tiles[y][x] - if !isActive(t) { - if m.state.solved { - return theme.Current().SuccessBG - } - return theme.Current().BG - } if m.state.solved { return theme.Current().SuccessBG } if x == m.cursor.X && y == m.cursor.Y { return theme.Blend(theme.Current().BG, theme.Current().Accent, 0.18) } + if !isActive(t) { + return theme.Current().BG + } if t.Locked { return theme.Blend(theme.Current().BG, theme.Current().Surface, 0.60) } @@ -145,51 +161,12 @@ func pipeForeground(m Model, cells ...point) color.Color { return theme.Current().FG } -func bridgeFill(m Model, _ game.DynamicGridBridge) color.Color { - if m.state.solved { - return theme.Current().SuccessBG - } - return nil -} - -func bridgeForeground(m Model, bridge game.DynamicGridBridge) color.Color { - cells := make([]point, 0, bridge.Count) - for i := 0; i < bridge.Count; i++ { - cells = append(cells, point{X: bridge.Cells[i].X, Y: bridge.Cells[i].Y}) - } - return pipeForeground(m, cells...) -} - -func verticalBridgeText(m Model, x, y int) string { - if x <= 0 || x >= m.puzzle.Size || y < 0 || y >= m.puzzle.Size { - return "" - } - leftMask := m.state.rotatedMasks[y][x-1] - rightMask := m.state.rotatedMasks[y][x] - if leftMask&east == 0 || rightMask&west == 0 { - return "" - } - return "─" -} - -func horizontalBridgeText(m Model, x, y int) string { - if x < 0 || x >= m.puzzle.Size || y <= 0 || y >= m.puzzle.Size { - return "" - } - topMask := m.state.rotatedMasks[y-1][x] - bottomMask := m.state.rotatedMasks[y][x] - if topMask&south == 0 || bottomMask&north == 0 { - return "" - } - return " │ " -} - func statusBarView(m Model, full bool) string { info := "connected " + strconv.Itoa(m.state.connected) + "/" + strconv.Itoa(m.state.nonEmpty) + " dangling " + strconv.Itoa(m.state.dangling) + " locks " + strconv.Itoa(m.state.locked) if !full { - return game.StatusBarStyle().Render(info + " space rotate l lock") + return game.StatusBarStyle().Render(info + " space rotate enter lock") } - return game.StatusBarStyle().Render(info + " enter/space rotate backspace reverse l toggle lock ctrl+r reset") + return game.StatusBarStyle().Render(info + " space rotate backspace reverse enter toggle lock ctrl+r reset") } From 8279193d5184e60d0aa95c800105ac876373f523 Mon Sep 17 00:00:00 2001 From: Dami Date: Sun, 15 Mar 2026 09:08:36 -0600 Subject: [PATCH 03/10] increase difficulty across netwalk --- netwalk/gamemode.go | 10 +++++----- netwalk/netwalk_test.go | 11 ++++++----- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/netwalk/gamemode.go b/netwalk/gamemode.go index 0370259..7665da1 100644 --- a/netwalk/gamemode.go +++ b/netwalk/gamemode.go @@ -94,11 +94,11 @@ var ( ) var Modes = []game.Mode{ - NewMode("Mini 5x5", "Compact 5×5 network with a denser starter tree.", 5, 0.40, miniProfile), - NewMode("Easy 7x7", "7×7 board with fuller coverage and gentle local tangles.", 7, 0.47, easyProfile), - NewMode("Medium 9x9", "Balanced 9×9 network with tighter clusters and more branching.", 9, 0.54, mediumProfile), - NewMode("Hard 11x11", "Dense 11×11 board that packs branches into close local interactions.", 11, 0.62, hardProfile), - NewMode("Expert 13x13", "Large, crowded network with heavy branching and frequent near-miss tangles.", 13, 0.68, expertProfile), + NewMode("Mini 5x5", "Compact 5×5 network with a denser starter tree.", 5, 0.50, miniProfile), + NewMode("Easy 7x7", "7×7 board with fuller coverage and gentle local tangles.", 7, 0.57, easyProfile), + NewMode("Medium 9x9", "Balanced 9×9 network with tighter clusters and more branching.", 9, 0.64, mediumProfile), + NewMode("Hard 11x11", "Dense 11×11 board that packs branches into close local interactions.", 11, 0.72, hardProfile), + NewMode("Expert 13x13", "Large, crowded network with heavy branching and frequent near-miss tangles.", 13, 0.78, expertProfile), } var ModeDefinitions = gameentry.BuildModeDefs(Modes) diff --git a/netwalk/netwalk_test.go b/netwalk/netwalk_test.go index 873733d..9edefca 100644 --- a/netwalk/netwalk_test.go +++ b/netwalk/netwalk_test.go @@ -1,6 +1,7 @@ package netwalk import ( + "math" "math/rand/v2" "strings" "testing" @@ -188,11 +189,11 @@ func TestNetwalkModeDensityProgression(t *testing.T) { } } - if fill[3] < 0.57 || fill[3] > 0.67 { - t.Fatalf("hard fill ratio = %.3f, want within [0.57, 0.67]", fill[3]) - } - if fill[4] < 0.63 || fill[4] > 0.73 { - t.Fatalf("expert fill ratio = %.3f, want within [0.63, 0.73]", fill[4]) + for i, mode := range modes { + target := float64(targetActiveFromFillRatio(mode.Size, mode.FillRatio)) / float64(mode.Size*mode.Size) + if math.Abs(fill[i]-target) > 1e-9 { + t.Fatalf("%s fill ratio = %.3f, want %.3f", mode.Title(), fill[i], target) + } } } From 2095486c2b4cd9c849cd21eec3854a8fb77e54b1 Mon Sep 17 00:00:00 2001 From: Dami Date: Sun, 15 Mar 2026 09:20:35 -0600 Subject: [PATCH 04/10] removed configured theme from render tests --- cmd/root.go | 6 ++++- cmd/test.go | 2 +- cmd/test_test.go | 69 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 625ecac..0058a1d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -105,8 +105,12 @@ func loadConfig(configPath string) *config.Config { if flagTheme != "" { themeName = flagTheme } + applyTheme(themeName) + return cfg +} + +func applyTheme(themeName string) { if err := theme.Apply(themeName); err != nil { log.Printf("warning: %v (using default theme)", err) } - return cfg } diff --git a/cmd/test.go b/cmd/test.go index e89bccd..372e83e 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -40,7 +40,7 @@ func init() { } func runTest(cmd *cobra.Command, args []string) error { - loadActiveConfig() + applyTheme(flagTheme) records, err := loadTestRecords(args[0]) if err != nil { diff --git a/cmd/test_test.go b/cmd/test_test.go index f1061b2..ce85669 100644 --- a/cmd/test_test.go +++ b/cmd/test_test.go @@ -3,6 +3,7 @@ package cmd import ( "bytes" "encoding/json" + "fmt" "math/rand/v2" "os" "path/filepath" @@ -13,6 +14,7 @@ import ( "github.com/FelineStateMachine/puzzletea/netwalk" "github.com/FelineStateMachine/puzzletea/pdfexport" "github.com/FelineStateMachine/puzzletea/sudoku" + "github.com/FelineStateMachine/puzzletea/theme" "github.com/spf13/cobra" ) @@ -125,11 +127,78 @@ func TestRunTestRendersMixedGamesInInputOrder(t *testing.T) { } } +func TestRunTestIgnoresPersistedThemeWithoutFlag(t *testing.T) { + reset := snapshotTestFlags() + defer reset() + t.Cleanup(func() { _ = theme.Apply("") }) + + configPath, _ := writeCommandConfig(t) + flagConfigPath = configPath + if err := theme.Apply(""); err != nil { + t.Fatalf("theme.Apply() error = %v", err) + } + defaultFG := fmt.Sprint(theme.Current().FG) + if err := theme.Apply("Dracula"); err != nil { + t.Fatalf("theme.Apply() error = %v", err) + } + + input := filepath.Join(t.TempDir(), "visual.jsonl") + data, err := netwalk.VisualFixtureJSONL() + if err != nil { + t.Fatalf("VisualFixtureJSONL() error = %v", err) + } + if err := os.WriteFile(input, data, 0o644); err != nil { + t.Fatal(err) + } + + cmd, _ := newTestCmd() + if err := runTest(cmd, []string{input}); err != nil { + t.Fatalf("runTest() error = %v", err) + } + + if got, want := fmt.Sprint(theme.Current().FG), defaultFG; got != want { + t.Fatalf("test command theme FG = %v, want default %v", got, want) + } +} + +func TestRunTestUsesExplicitThemeFlag(t *testing.T) { + reset := snapshotTestFlags() + defer reset() + t.Cleanup(func() { _ = theme.Apply("") }) + + configPath, _ := writeCommandConfig(t) + flagConfigPath = configPath + flagTheme = "Dracula" + + input := filepath.Join(t.TempDir(), "visual.jsonl") + data, err := netwalk.VisualFixtureJSONL() + if err != nil { + t.Fatalf("VisualFixtureJSONL() error = %v", err) + } + if err := os.WriteFile(input, data, 0o644); err != nil { + t.Fatal(err) + } + + cmd, _ := newTestCmd() + if err := runTest(cmd, []string{input}); err != nil { + t.Fatalf("runTest() error = %v", err) + } + + want := theme.LookupTheme(flagTheme).Palette() + if got := fmt.Sprint(theme.Current().FG); got != fmt.Sprint(want.FG) { + t.Fatalf("theme override FG = %v, want %v", theme.Current().FG, want.FG) + } +} + func snapshotTestFlags() func() { prevOutput := testOutput + prevTheme := flagTheme + prevConfigPath := flagConfigPath testOutput = "" return func() { testOutput = prevOutput + flagTheme = prevTheme + flagConfigPath = prevConfigPath } } From 402f01910c8643c95540fde69fde0452ba80bd91 Mon Sep 17 00:00:00 2001 From: Dami Date: Sun, 15 Mar 2026 18:39:49 -0600 Subject: [PATCH 05/10] visual testing proto --- fillomino/testdata/visual_states.jsonl | 3 + hashiwokakero/testdata/visual_states.jsonl | 3 + hitori/testdata/visual_states.jsonl | 4 + internal/testjsonl/records.go | 55 +++++ lightsout/testdata/visual_states.jsonl | 3 + netwalk/visual_fixture.go | 229 --------------------- nonogram/testdata/visual_states.jsonl | 2 + nurikabe/testdata/visual_states.jsonl | 2 + rippleeffect/testdata/visual_states.jsonl | 2 + shikaku/testdata/visual_states.jsonl | 2 + sudoku/testdata/visual_states.jsonl | 3 + sudokurgb/testdata/visual_states.jsonl | 3 + takuzu/testdata/visual_states.jsonl | 3 + takuzuplus/testdata/visual_states.jsonl | 3 + 14 files changed, 88 insertions(+), 229 deletions(-) create mode 100644 fillomino/testdata/visual_states.jsonl create mode 100644 hashiwokakero/testdata/visual_states.jsonl create mode 100644 hitori/testdata/visual_states.jsonl create mode 100644 internal/testjsonl/records.go create mode 100644 lightsout/testdata/visual_states.jsonl delete mode 100644 netwalk/visual_fixture.go create mode 100644 nonogram/testdata/visual_states.jsonl create mode 100644 nurikabe/testdata/visual_states.jsonl create mode 100644 rippleeffect/testdata/visual_states.jsonl create mode 100644 shikaku/testdata/visual_states.jsonl create mode 100644 sudoku/testdata/visual_states.jsonl create mode 100644 sudokurgb/testdata/visual_states.jsonl create mode 100644 takuzu/testdata/visual_states.jsonl create mode 100644 takuzuplus/testdata/visual_states.jsonl diff --git a/fillomino/testdata/visual_states.jsonl b/fillomino/testdata/visual_states.jsonl new file mode 100644 index 0000000..970789e --- /dev/null +++ b/fillomino/testdata/visual_states.jsonl @@ -0,0 +1,3 @@ +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Fillomino","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":1,"name":"provided-and-editable","game":"Fillomino","mode":"Visual Fixture","save":{"width":3,"height":3,"state":"2 2 1\n2 . 1\n. . .","provided":"#.#\n#.#\n...","mode_title":"Visual Fixture","max_cell_value":3}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Fillomino","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":2,"name":"mixed-regions","game":"Fillomino","mode":"Visual Fixture","save":{"width":3,"height":3,"state":"2 2 1\n3 . .\n. . 1","provided":"###\n#..\n..#","mode_title":"Visual Fixture","max_cell_value":3}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Fillomino","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":3,"name":"solved-complete-grid","game":"Fillomino","mode":"Visual Fixture","save":{"width":3,"height":3,"state":"2 2 1\n3 3 3\n1 2 2","provided":"#.#\n...\n#..","mode_title":"Visual Fixture","max_cell_value":3}}} diff --git a/hashiwokakero/testdata/visual_states.jsonl b/hashiwokakero/testdata/visual_states.jsonl new file mode 100644 index 0000000..d314bb1 --- /dev/null +++ b/hashiwokakero/testdata/visual_states.jsonl @@ -0,0 +1,3 @@ +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hashiwokakero","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":1,"name":"disconnected-corners","game":"Hashiwokakero","mode":"Visual Fixture","save":{"width":4,"height":4,"islands":[{"ID":0,"X":0,"Y":0,"Required":2},{"ID":1,"X":3,"Y":0,"Required":2},{"ID":2,"X":0,"Y":3,"Required":2},{"ID":3,"X":3,"Y":3,"Required":2}],"bridges":null,"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hashiwokakero","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":2,"name":"single-bridge-pair","game":"Hashiwokakero","mode":"Visual Fixture","save":{"width":3,"height":1,"islands":[{"ID":0,"X":0,"Y":0,"Required":1},{"ID":1,"X":2,"Y":0,"Required":1}],"bridges":[{"Island1":0,"Island2":1,"Count":1}],"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hashiwokakero","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":3,"name":"double-bridge-pair","game":"Hashiwokakero","mode":"Visual Fixture","save":{"width":3,"height":1,"islands":[{"ID":0,"X":0,"Y":0,"Required":2},{"ID":1,"X":2,"Y":0,"Required":2}],"bridges":[{"Island1":0,"Island2":1,"Count":2}],"mode_title":"Visual Fixture"}}} diff --git a/hitori/testdata/visual_states.jsonl b/hitori/testdata/visual_states.jsonl new file mode 100644 index 0000000..11365a1 --- /dev/null +++ b/hitori/testdata/visual_states.jsonl @@ -0,0 +1,4 @@ +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hitori","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":1,"name":"unmarked-duplicates","game":"Hitori","mode":"Visual Fixture","save":{"size":3,"numbers":"113\n231\n312","marks":"...\n...\n...","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hitori","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":2,"name":"shaded-and-circled","game":"Hitori","mode":"Visual Fixture","save":{"size":4,"numbers":"1213\n2341\n3124\n1432","marks":"X...\n....\n....\n.O..","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hitori","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":3,"name":"duplicate-circled-conflict","game":"Hitori","mode":"Visual Fixture","save":{"size":3,"numbers":"113\n231\n312","marks":"O..\n...\n...","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hitori","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":4,"name":"solved-valid-layout","game":"Hitori","mode":"Visual Fixture","save":{"size":4,"numbers":"1213\n2341\n3124\n1432","marks":"X...\n....\n....\n..X.","mode_title":"Visual Fixture"}}} diff --git a/internal/testjsonl/records.go b/internal/testjsonl/records.go new file mode 100644 index 0000000..60aa9ca --- /dev/null +++ b/internal/testjsonl/records.go @@ -0,0 +1,55 @@ +package testjsonl + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/FelineStateMachine/puzzletea/pdfexport" +) + +func LoadRecords(path string) ([]pdfexport.JSONLRecord, error) { + if !strings.EqualFold(filepath.Ext(path), ".jsonl") { + return nil, fmt.Errorf("%s: expected .jsonl input", path) + } + + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open input jsonl: %w", err) + } + defer f.Close() + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + + records := make([]pdfexport.JSONLRecord, 0, 16) + lineNo := 0 + for scanner.Scan() { + lineNo++ + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + var record pdfexport.JSONLRecord + if err := json.Unmarshal([]byte(line), &record); err != nil { + return nil, fmt.Errorf("%s:%d: decode jsonl record: %w", path, lineNo, err) + } + if record.Schema != pdfexport.ExportSchemaV1 { + return nil, fmt.Errorf("%s:%d: unsupported schema %q", path, lineNo, record.Schema) + } + + records = append(records, record) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read input jsonl: %w", err) + } + if len(records) == 0 { + return nil, fmt.Errorf("%s: input jsonl is empty", path) + } + + return records, nil +} diff --git a/lightsout/testdata/visual_states.jsonl b/lightsout/testdata/visual_states.jsonl new file mode 100644 index 0000000..a23898d --- /dev/null +++ b/lightsout/testdata/visual_states.jsonl @@ -0,0 +1,3 @@ +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Lights Out","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":1,"name":"cursor-lit-center","game":"Lights Out","mode":"Visual Fixture","save":{"grid":[[false,false,false],[false,true,false],[false,false,false]],"initial_grid":[[false,false,false],[false,true,false],[false,false,false]],"cx":1,"cy":1,"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Lights Out","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":2,"name":"cursor-dark-corner","game":"Lights Out","mode":"Visual Fixture","save":{"grid":[[false,true,false],[false,false,false],[true,false,true]],"initial_grid":[[false,true,false],[false,false,false],[true,false,true]],"cx":0,"cy":0,"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Lights Out","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":3,"name":"solved-all-off","game":"Lights Out","mode":"Visual Fixture","save":{"grid":[[false,false,false],[false,false,false],[false,false,false]],"initial_grid":[[false,false,false],[false,false,false],[false,false,false]],"cx":1,"cy":1,"mode_title":"Visual Fixture"}}} diff --git a/netwalk/visual_fixture.go b/netwalk/visual_fixture.go deleted file mode 100644 index 04dd3cb..0000000 --- a/netwalk/visual_fixture.go +++ /dev/null @@ -1,229 +0,0 @@ -package netwalk - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/FelineStateMachine/puzzletea/game" - "github.com/FelineStateMachine/puzzletea/pdfexport" -) - -const visualFixtureModeTitle = "Visual Fixture" - -type visualFixtureCell struct { - x int - y int - mask directionMask - kind cellKind - lock bool -} - -type visualFixtureCase struct { - name string - puzzle Puzzle -} - -var visualFixtureCases = []visualFixtureCase{ - { - name: "cursor-root-horizontal", - puzzle: buildVisualFixturePuzzle(4, point{X: 0, Y: 0}, - serverTile(0, 0, east), - nodeTile(1, 0, west), - nodeTile(3, 2, south), - nodeTile(3, 3, north), - ), - }, - { - name: "leaf-gallery", - puzzle: buildVisualFixturePuzzle(5, point{X: 0, Y: 0}, - serverTile(0, 0, east), - nodeTile(1, 0, west), - nodeTile(4, 0, west), - nodeTile(4, 1, north), - nodeTile(3, 4, east), - nodeTile(4, 4, north), - nodeTile(0, 3, south), - nodeTile(0, 4, east), - ), - }, - { - name: "straight-and-corner-gallery", - puzzle: buildVisualFixturePuzzle(5, point{X: 0, Y: 0}, - serverTile(0, 0, east), - nodeTile(1, 0, west), - nodeTile(2, 0, east|west), - nodeTile(3, 0, west|south), - nodeTile(3, 1, north|south), - nodeTile(3, 2, north|east), - nodeTile(4, 2, west|south), - nodeTile(4, 3, north|west), - nodeTile(2, 2, east|south), - nodeTile(2, 3, north), - ), - }, - { - name: "tee-and-cross-gallery", - puzzle: buildVisualFixturePuzzle(5, point{X: 0, Y: 0}, - serverTile(0, 0, east), - nodeTile(1, 0, west), - nodeTile(3, 1, north|east|south), - nodeTile(3, 0, south), - nodeTile(4, 1, west), - nodeTile(3, 2, north|east|south|west), - nodeTile(2, 2, east), - nodeTile(4, 2, west), - nodeTile(3, 3, north), - ), - }, - { - name: "connected-horizontal-bridge", - puzzle: buildVisualFixturePuzzle(5, point{X: 0, Y: 0}, - serverTile(0, 0, east), - nodeTile(1, 0, east|west), - nodeTile(2, 0, west), - nodeTile(4, 3, south), - nodeTile(4, 4, north), - ), - }, - { - name: "connected-vertical-bridge", - puzzle: buildVisualFixturePuzzle(5, point{X: 0, Y: 0}, - serverTile(0, 0, south), - nodeTile(0, 1, north|south), - nodeTile(0, 2, north), - nodeTile(4, 3, south), - nodeTile(4, 4, north), - ), - }, - { - name: "disconnected-default-foreground", - puzzle: buildVisualFixturePuzzle(5, point{X: 0, Y: 0}, - serverTile(0, 0, east), - nodeTile(1, 0, west), - nodeTile(3, 1, east|south), - nodeTile(4, 1, south|west), - nodeTile(3, 2, north|east), - nodeTile(4, 2, north|west), - ), - }, - { - name: "dangling-error-state", - puzzle: buildVisualFixturePuzzle(4, point{X: 0, Y: 0}, - serverTile(0, 0, east), - nodeTile(1, 0, west), - nodeTile(2, 2, east), - ), - }, - { - name: "locked-root-cursor", - puzzle: buildVisualFixturePuzzle(4, point{X: 0, Y: 0}, - serverLockedTile(0, 0, east), - nodeTile(1, 0, west), - nodeTile(3, 2, south), - nodeTile(3, 3, north), - ), - }, - { - name: "solved-with-empty-cells", - puzzle: buildVisualFixturePuzzle(4, point{X: 1, Y: 1}, - serverTile(1, 1, east|south), - nodeTile(2, 1, west), - nodeTile(1, 2, north), - ), - }, -} - -func VisualFixtureJSONL() ([]byte, error) { - records, err := visualFixtureRecords() - if err != nil { - return nil, err - } - - var b strings.Builder - for _, record := range records { - data, err := json.Marshal(record) - if err != nil { - return nil, fmt.Errorf("marshal visual fixture record: %w", err) - } - b.Write(data) - b.WriteByte('\n') - } - - return []byte(b.String()), nil -} - -func visualFixtureRecords() ([]pdfexport.JSONLRecord, error) { - records := make([]pdfexport.JSONLRecord, 0, len(visualFixtureCases)) - for i, fixture := range visualFixtureCases { - save, err := visualFixtureSave(fixture.puzzle) - if err != nil { - return nil, fmt.Errorf("fixture %q: %w", fixture.name, err) - } - - records = append(records, pdfexport.JSONLRecord{ - Schema: pdfexport.ExportSchemaV1, - Pack: pdfexport.JSONLPackMeta{ - Generated: "2026-03-14T00:00:00Z", - Version: "visual-fixture", - Category: "Netwalk", - ModeSelection: visualFixtureModeTitle, - Count: len(visualFixtureCases), - }, - Puzzle: pdfexport.JSONLPuzzle{ - Index: i + 1, - Name: fixture.name, - Game: "Netwalk", - Mode: visualFixtureModeTitle, - Save: save, - }, - }) - } - - return records, nil -} - -func visualFixtureSave(puzzle Puzzle) (json.RawMessage, error) { - cursor := puzzle.firstActive() - m := Model{ - puzzle: puzzle, - keys: DefaultKeyMap, - modeTitle: visualFixtureModeTitle, - cursor: game.Cursor{X: cursor.X, Y: cursor.Y}, - } - m.recompute() - - save, err := m.GetSave() - if err != nil { - return nil, fmt.Errorf("encode save: %w", err) - } - - return json.RawMessage(save), nil -} - -func buildVisualFixturePuzzle(size int, root point, cells ...visualFixtureCell) Puzzle { - puzzle := newPuzzle(size) - puzzle.Root = root - for _, cell := range cells { - puzzle.Tiles[cell.y][cell.x] = tile{ - BaseMask: cell.mask, - Rotation: 0, - InitialRotation: 0, - Locked: cell.lock, - Kind: cell.kind, - } - } - return puzzle -} - -func serverTile(x, y int, mask directionMask) visualFixtureCell { - return visualFixtureCell{x: x, y: y, mask: mask, kind: serverCell} -} - -func serverLockedTile(x, y int, mask directionMask) visualFixtureCell { - return visualFixtureCell{x: x, y: y, mask: mask, kind: serverCell, lock: true} -} - -func nodeTile(x, y int, mask directionMask) visualFixtureCell { - return visualFixtureCell{x: x, y: y, mask: mask, kind: nodeCell} -} diff --git a/nonogram/testdata/visual_states.jsonl b/nonogram/testdata/visual_states.jsonl new file mode 100644 index 0000000..ca11ea6 --- /dev/null +++ b/nonogram/testdata/visual_states.jsonl @@ -0,0 +1,2 @@ +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Nonogram","mode_selection":"Visual Fixture","count":2,"seed":""},"puzzle":{"index":1,"name":"filled-marked-mix","game":"Nonogram","mode":"Visual Fixture","save":{"state":". -. \n .. .\n.. .\n .. \n. . .","width":5,"height":5,"row-hints":[[1,1,1],[1,1],[5],[0],[1,1]],"col-hints":[[1,1,1],[2],[1,1],[2],[1,1,1]]}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Nonogram","mode_selection":"Visual Fixture","count":2,"seed":""},"puzzle":{"index":2,"name":"solved-diagonal","game":"Nonogram","mode":"Visual Fixture","save":{"state":". \n .","width":2,"height":2,"row-hints":[[1],[1]],"col-hints":[[1],[1]]}}} diff --git a/nurikabe/testdata/visual_states.jsonl b/nurikabe/testdata/visual_states.jsonl new file mode 100644 index 0000000..381392a --- /dev/null +++ b/nurikabe/testdata/visual_states.jsonl @@ -0,0 +1,2 @@ +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Nurikabe","mode_selection":"Visual Fixture","count":2,"seed":""},"puzzle":{"index":1,"name":"sea-and-island-marks","game":"Nurikabe","mode":"Visual Fixture","save":{"width":3,"height":3,"clues":"1,0,0\n0,0,0\n0,0,1","marks":"o??\n~?~\n??o","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Nurikabe","mode_selection":"Visual Fixture","count":2,"seed":""},"puzzle":{"index":2,"name":"solved-clue-and-sea","game":"Nurikabe","mode":"Visual Fixture","save":{"width":2,"height":1,"clues":"1,0","marks":"o~","mode_title":"Visual Fixture"}}} diff --git a/rippleeffect/testdata/visual_states.jsonl b/rippleeffect/testdata/visual_states.jsonl new file mode 100644 index 0000000..9126a03 --- /dev/null +++ b/rippleeffect/testdata/visual_states.jsonl @@ -0,0 +1,2 @@ +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Ripple Effect","mode_selection":"Visual Fixture","count":2,"seed":""},"puzzle":{"index":1,"name":"incomplete-cages","game":"Ripple Effect","mode":"Visual Fixture","save":{"width":3,"height":3,"state":"1 2 .\n2 . 1\n. 1 2","givens":". . .\n. . .\n. . .","cages":[{"id":0,"size":3,"cells":[{"x":0,"y":0},{"x":1,"y":0},{"x":2,"y":0}]},{"id":1,"size":3,"cells":[{"x":0,"y":1},{"x":1,"y":1},{"x":2,"y":1}]},{"id":2,"size":3,"cells":[{"x":0,"y":2},{"x":1,"y":2},{"x":2,"y":2}]}],"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Ripple Effect","mode_selection":"Visual Fixture","count":2,"seed":""},"puzzle":{"index":2,"name":"solved-sample-grid","game":"Ripple Effect","mode":"Visual Fixture","save":{"width":3,"height":3,"state":"1 2 3\n2 3 1\n3 1 2","givens":". . .\n. . .\n. . .","cages":[{"id":0,"size":3,"cells":[{"x":0,"y":0},{"x":1,"y":0},{"x":2,"y":0}]},{"id":1,"size":3,"cells":[{"x":0,"y":1},{"x":1,"y":1},{"x":2,"y":1}]},{"id":2,"size":3,"cells":[{"x":0,"y":2},{"x":1,"y":2},{"x":2,"y":2}]}],"mode_title":"Visual Fixture"}}} diff --git a/shikaku/testdata/visual_states.jsonl b/shikaku/testdata/visual_states.jsonl new file mode 100644 index 0000000..94dd2a2 --- /dev/null +++ b/shikaku/testdata/visual_states.jsonl @@ -0,0 +1,2 @@ +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Shikaku","mode_selection":"Visual Fixture","count":2,"seed":""},"puzzle":{"index":1,"name":"empty-rectangles","game":"Shikaku","mode":"Visual Fixture","save":{"width":2,"height":2,"clues":[{"id":0,"x":0,"y":0,"value":2},{"id":1,"x":1,"y":1,"value":2}],"rectangles":null,"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Shikaku","mode_selection":"Visual Fixture","count":2,"seed":""},"puzzle":{"index":2,"name":"solved-vertical-split","game":"Shikaku","mode":"Visual Fixture","save":{"width":2,"height":2,"clues":[{"id":0,"x":0,"y":0,"value":2},{"id":1,"x":1,"y":1,"value":2}],"rectangles":[{"clue_id":0,"x":0,"y":0,"w":1,"h":2},{"clue_id":1,"x":1,"y":0,"w":1,"h":2}],"mode_title":"Visual Fixture"}}} diff --git a/sudoku/testdata/visual_states.jsonl b/sudoku/testdata/visual_states.jsonl new file mode 100644 index 0000000..d59f6a2 --- /dev/null +++ b/sudoku/testdata/visual_states.jsonl @@ -0,0 +1,3 @@ +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Sudoku","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":1,"name":"provided-top-left","game":"Sudoku","mode":"Visual Fixture","save":{"grid":"500000000\n030000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000","provided":[{"x":0,"y":0,"v":5},{"x":1,"y":1,"v":3}],"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Sudoku","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":2,"name":"row-conflict","game":"Sudoku","mode":"Visual Fixture","save":{"grid":"550000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000","provided":null,"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Sudoku","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":3,"name":"solved-complete-grid","game":"Sudoku","mode":"Visual Fixture","save":{"grid":"534678912\n672195348\n198342567\n859761423\n426853791\n713924856\n961537284\n287419635\n345286179","provided":null,"mode_title":"Visual Fixture"}}} diff --git a/sudokurgb/testdata/visual_states.jsonl b/sudokurgb/testdata/visual_states.jsonl new file mode 100644 index 0000000..fa2fad6 --- /dev/null +++ b/sudokurgb/testdata/visual_states.jsonl @@ -0,0 +1,3 @@ +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Sudoku RGB","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":1,"name":"provided-top-left","game":"Sudoku RGB","mode":"Visual Fixture","save":{"grid":"100000000\n020000000\n003000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000","provided":[{"x":0,"y":0,"v":1},{"x":1,"y":1,"v":2},{"x":2,"y":2,"v":3}],"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Sudoku RGB","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":2,"name":"row-overquota","game":"Sudoku RGB","mode":"Visual Fixture","save":{"grid":"111100000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000","provided":null,"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Sudoku RGB","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":3,"name":"solved-complete-grid","game":"Sudoku RGB","mode":"Visual Fixture","save":{"grid":"123123123\n231231231\n312312312\n123123123\n231231231\n312312312\n123123123\n231231231\n312312312","provided":null,"mode_title":"Visual Fixture"}}} diff --git a/takuzu/testdata/visual_states.jsonl b/takuzu/testdata/visual_states.jsonl new file mode 100644 index 0000000..9cdfccd --- /dev/null +++ b/takuzu/testdata/visual_states.jsonl @@ -0,0 +1,3 @@ +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Takuzu","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":1,"name":"provided-balance-preview","game":"Takuzu","mode":"Visual Fixture","save":{"size":4,"state":"0.11\n1.0.\n.0.1\n0101","provided":"#.##\n#.#.\n.#.#\n####","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Takuzu","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":2,"name":"overfull-row-context","game":"Takuzu","mode":"Visual Fixture","save":{"size":4,"state":"0001\n....\n....\n....","provided":"....\n....\n....\n....","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Takuzu","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":3,"name":"solved-valid-grid","game":"Takuzu","mode":"Visual Fixture","save":{"size":4,"state":"0011\n1100\n0110\n1001","provided":"....\n....\n....\n....","mode_title":"Visual Fixture"}}} diff --git a/takuzuplus/testdata/visual_states.jsonl b/takuzuplus/testdata/visual_states.jsonl new file mode 100644 index 0000000..863c58d --- /dev/null +++ b/takuzuplus/testdata/visual_states.jsonl @@ -0,0 +1,3 @@ +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Takuzu+","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":1,"name":"relation-clues","game":"Takuzu+","mode":"Visual Fixture","save":{"size":4,"state":"0.11\n1.0.\n.0.1\n0101","provided":"#.##\n#.#.\n.#.#\n####","mode_title":"Visual Fixture","horizontal_relations":"=\n...\n...\n...","vertical_relations":"....\n..x.\n...."}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Takuzu+","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":2,"name":"overfull-with-relations","game":"Takuzu+","mode":"Visual Fixture","save":{"size":4,"state":"0001\n....\n....\n....","provided":"....\n....\n....\n....","mode_title":"Visual Fixture","horizontal_relations":"...\n...\n...\n...","vertical_relations":"....\n....\n...."}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Takuzu+","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":3,"name":"solved-valid-grid","game":"Takuzu+","mode":"Visual Fixture","save":{"size":4,"state":"0011\n1100\n0110\n1001","provided":"....\n....\n....\n....","mode_title":"Visual Fixture","horizontal_relations":"...\n...\n...\n...","vertical_relations":"....\n....\n...."}}} From a43d915d6a17a8d0ca7bc348bf28eb551bcae7b5 Mon Sep 17 00:00:00 2001 From: Dami Date: Sat, 21 Mar 2026 13:50:36 -0600 Subject: [PATCH 06/10] debug rendering, pdf changes --- README.md | 18 +- cmd/export_pdf.go | 68 +- cmd/export_pdf_test.go | 53 +- cmd/test_test.go | 269 ----- fillomino/print_adapter.go | 21 +- fillomino/testdata/visual_states.jsonl | 7 +- go.mod | 2 + go.sum | 5 + hashiwokakero/print_adapter.go | 23 +- hashiwokakero/testdata/visual_states.jsonl | 7 +- hitori/print_adapter.go | 21 +- hitori/testdata/visual_states.jsonl | 4 +- justfile | 12 + lightsout/testdata/visual_states.jsonl | 7 +- netwalk/print_adapter.go | 21 +- netwalk/visual_fixture_test.go | 98 -- nonogram/export.go | 23 +- nonogram/print_adapter.go | 25 +- nonogram/print_adapter_test.go | 1 + nonogram/testdata/visual_states.jsonl | 5 +- nurikabe/print_adapter.go | 21 +- nurikabe/testdata/visual_states.jsonl | 5 +- pdfexport/font.go | 7 +- pdfexport/font_test.go | 12 +- pdfexport/render.go | 251 +++- pdfexport/render_cover.go | 1273 +++++++++++++++++++- pdfexport/render_cover_test.go | 184 +++ pdfexport/render_difficulty.go | 165 +++ pdfexport/render_difficulty_test.go | 143 +++ pdfexport/render_instructions.go | 64 + pdfexport/render_instructions_test.go | 86 ++ pdfexport/render_kit.go | 3 +- pdfexport/render_layout_test.go | 155 ++- pdfexport/render_metadata.go | 10 +- pdfexport/render_plan.go | 164 +++ pdfexport/render_title.go | 19 +- pdfexport/render_tokens.go | 35 +- pdfexport/types.go | 11 +- release-volume-1.sh | 304 +++++ rippleeffect/print_adapter.go | 21 +- rippleeffect/testdata/visual_states.jsonl | 5 +- shikaku/print_adapter.go | 21 +- shikaku/testdata/visual_states.jsonl | 5 +- spellpuzzle/print_adapter.go | 21 +- sudoku/print_adapter.go | 21 +- sudoku/testdata/visual_states.jsonl | 7 +- sudokurgb/print_adapter.go | 21 +- sudokurgb/testdata/visual_states.jsonl | 7 +- takuzu/print_adapter.go | 78 +- takuzu/print_adapter_test.go | 87 +- takuzu/style.go | 59 +- takuzu/takuzu_test.go | 8 +- takuzu/testdata/visual_states.jsonl | 7 +- takuzuplus/print_adapter.go | 4 +- takuzuplus/style.go | 83 +- takuzuplus/takuzuplus_test.go | 8 +- takuzuplus/testdata/visual_states.jsonl | 7 +- wordsearch/print_adapter.go | 11 +- 58 files changed, 3183 insertions(+), 900 deletions(-) delete mode 100644 cmd/test_test.go delete mode 100644 netwalk/visual_fixture_test.go create mode 100644 pdfexport/render_difficulty.go create mode 100644 pdfexport/render_difficulty_test.go create mode 100644 pdfexport/render_instructions.go create mode 100644 pdfexport/render_instructions_test.go create mode 100644 pdfexport/render_plan.go create mode 100755 release-volume-1.sh diff --git a/README.md b/README.md index 8167cd4..c11c765 100644 --- a/README.md +++ b/README.md @@ -146,15 +146,25 @@ puzzletea new nonogram mini -e 6 -o nonogram-mini-set.jsonl puzzletea new sudoku --export 10 -o sudoku-mixed.jsonl --with-seed zine-issue-01 ``` -Render one or more JSONL packs into a half-letter print PDF: +Render one or more JSONL packs into a print PDF: ```bash puzzletea export-pdf nonogram-mini-set.jsonl -o issue-01.pdf --shuffle-seed issue-01 --volume 1 --title "Catacombs & Pines" ``` -`--title` sets the pack subtitle (title page, and cover pages when enabled), and `--volume` sets the volume number. -By default, covers are not included. Use `--cover-color` to include front/back cover pages. -Page count is always auto-padded to a multiple of 4 for half-letter booklet printing. +`--title` sets the pack subtitle (title page, and outside cover when enabled), and `--volume` sets the volume number. +`half-letter` renders a plain booklet interior with no cover block. +`duplex-booklet` automatically includes the 4-page black-ink cover block with blank inside covers, and the actual cover color comes from the physical stock you print on. +When `duplex-booklet` is enabled, the title page shifts to logical page 3 so the first two and last two pages stay on cover stock. +Page count is always auto-padded to a multiple of 4 for booklet printing. + +Use `--sheet-layout duplex-booklet` to emit a landscape US Letter PDF with two portrait half-letter booklet pages per sheet side: + +```bash +puzzletea export-pdf nonogram-mini-set.jsonl -o issue-01-duplex.pdf --shuffle-seed issue-01 --volume 1 --title "Catacombs & Pines" --sheet-layout duplex-booklet +``` + +`duplex-booklet` is meant for duplex printing without printer-side booklet mode. Print landscape on short edge. Font license note (Atkinson Hyperlegible Next): diff --git a/cmd/export_pdf.go b/cmd/export_pdf.go index 17fa8bd..ade3d1e 100644 --- a/cmd/export_pdf.go +++ b/cmd/export_pdf.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "path/filepath" - "strconv" "strings" "time" @@ -22,25 +21,25 @@ var ( flagPDFVolume int flagPDFAdvert string flagPDFShuffleSeed string - flagPDFCoverColor string + flagPDFSheetLayout string ) var exportPDFCmd = &cobra.Command{ Use: "export-pdf [more.jsonl ...]", - Short: "Convert one or more PuzzleTea JSONL exports into a half-letter printable PDF", - Long: "Parse one or more JSONL export files, order puzzles by progressive difficulty with seeded mixing, and render a half-letter PDF with a title page, one puzzle per page, optional covers (when --cover-color is set), and automatic page-count padding to a multiple of 4 for booklet printing.", + Short: "Convert one or more PuzzleTea JSONL exports into a printable PDF", + Long: "Parse one or more JSONL export files, order puzzles by progressive difficulty with seeded mixing, and render either a half-letter PDF or an imposed duplex-booklet PDF with a title page, one puzzle per logical half-letter page, and automatic page-count padding to a multiple of 4 for booklet printing. The duplex-booklet layout includes the 4-page black-ink cover block automatically.", Args: cobra.MinimumNArgs(1), RunE: runExportPDF, } func init() { exportPDFCmd.Flags().StringVarP(&flagPDFOutput, "output", "o", "", "write output PDF path (defaults to -print.pdf)") - exportPDFCmd.Flags().StringVar(&flagPDFTitle, "title", "", "subtitle shown on the title page (and on covers when enabled)") + exportPDFCmd.Flags().StringVar(&flagPDFTitle, "title", "", "subtitle shown on the title page (and on the outside cover when enabled)") exportPDFCmd.Flags().StringVar(&flagPDFHeader, "header", "", "optional intro paragraph shown on the title page under 'PuzzleTea Puzzle Pack'") - exportPDFCmd.Flags().IntVar(&flagPDFVolume, "volume", 1, "volume number shown on the title page (and on covers when enabled) (must be >= 1)") + exportPDFCmd.Flags().IntVar(&flagPDFVolume, "volume", 1, "volume number shown on the title page (and on the outside cover when enabled) (must be >= 1)") exportPDFCmd.Flags().StringVar(&flagPDFAdvert, "advert", "Find more puzzles at github.com/FelineStateMachine/puzzletea", "advert text shown on the title page") exportPDFCmd.Flags().StringVar(&flagPDFShuffleSeed, "shuffle-seed", "", "seed for deterministic within-band difficulty mixing") - exportPDFCmd.Flags().StringVar(&flagPDFCoverColor, "cover-color", "", `accent color for optional front/back covers: hex "#RRGGBB" or decimal "R,G,B" (omit for no covers)`) + exportPDFCmd.Flags().StringVar(&flagPDFSheetLayout, "sheet-layout", "half-letter", "physical PDF layout: half-letter or duplex-booklet (landscape US Letter with two half-letter pages per sheet side; print duplex on short edge). duplex-booklet automatically includes the 4-page cover block") } func runExportPDF(cmd *cobra.Command, args []string) error { @@ -115,17 +114,16 @@ func buildRenderConfigForPDF(docs []pdfexport.PackDocument, shuffleSeed string, if err := validatePDFVolume(flagPDFVolume); err != nil { return pdfexport.RenderConfig{}, err } + sheetLayout, err := parsePDFSheetLayout(flagPDFSheetLayout) + if err != nil { + return pdfexport.RenderConfig{}, err + } subtitle := strings.TrimSpace(flagPDFTitle) if subtitle == "" { subtitle = defaultPDFTitle(docs) } - coverColor, err := parseCoverColor(flagPDFCoverColor) - if err != nil { - return pdfexport.RenderConfig{}, fmt.Errorf("--cover-color: %w", err) - } - cfg := pdfexport.RenderConfig{ CoverSubtitle: subtitle, HeaderText: strings.TrimSpace(flagPDFHeader), @@ -133,11 +131,22 @@ func buildRenderConfigForPDF(docs []pdfexport.PackDocument, shuffleSeed string, AdvertText: flagPDFAdvert, GeneratedAt: now, ShuffleSeed: shuffleSeed, - CoverColor: coverColor, + SheetLayout: sheetLayout, } return cfg, nil } +func parsePDFSheetLayout(raw string) (pdfexport.SheetLayout, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "half-letter": + return pdfexport.SheetLayoutHalfLetter, nil + case "duplex-booklet": + return pdfexport.SheetLayoutDuplexBooklet, nil + default: + return pdfexport.SheetLayoutHalfLetter, fmt.Errorf("--sheet-layout must be half-letter or duplex-booklet") + } +} + func buildModeDifficultyLookup(definitions []puzzle.Definition) map[string]map[string]float64 { lookup := make(map[string]map[string]float64, len(definitions)) @@ -206,36 +215,3 @@ func annotateDifficulty(puzzles []pdfexport.Puzzle, lookup map[string]map[string func normalizeDifficultyToken(s string) string { return puzzle.NormalizeName(s) } - -// parseCoverColor parses a cover color string in hex ("#RRGGBB") or -// decimal ("R,G,B") format. Returns nil if s is empty (no cover pages). -func parseCoverColor(s string) (*pdfexport.RGB, error) { - s = strings.TrimSpace(s) - if s == "" { - return nil, nil - } - - // Hex format: #RRGGBB or RRGGBB - hex := strings.TrimPrefix(s, "#") - if len(hex) == 6 { - r, errR := strconv.ParseUint(hex[0:2], 16, 8) - g, errG := strconv.ParseUint(hex[2:4], 16, 8) - b, errB := strconv.ParseUint(hex[4:6], 16, 8) - if errR == nil && errG == nil && errB == nil { - return &pdfexport.RGB{R: uint8(r), G: uint8(g), B: uint8(b)}, nil - } - } - - // Decimal format: R,G,B - parts := strings.Split(s, ",") - if len(parts) == 3 { - r, errR := strconv.ParseUint(strings.TrimSpace(parts[0]), 10, 8) - g, errG := strconv.ParseUint(strings.TrimSpace(parts[1]), 10, 8) - b, errB := strconv.ParseUint(strings.TrimSpace(parts[2]), 10, 8) - if errR == nil && errG == nil && errB == nil { - return &pdfexport.RGB{R: uint8(r), G: uint8(g), B: uint8(b)}, nil - } - } - - return nil, fmt.Errorf("invalid color %q — use hex \"#RRGGBB\" or decimal \"R,G,B\"", s) -} diff --git a/cmd/export_pdf_test.go b/cmd/export_pdf_test.go index e649305..39c551e 100644 --- a/cmd/export_pdf_test.go +++ b/cmd/export_pdf_test.go @@ -62,7 +62,7 @@ func TestBuildRenderConfigForPDFUsesTitleAsCoverSubtitle(t *testing.T) { flagPDFHeader = "Custom heading paragraph" flagPDFVolume = 7 flagPDFAdvert = "Custom advert" - flagPDFCoverColor = "" + flagPDFSheetLayout = "half-letter" now := time.Date(2026, 2, 22, 11, 0, 0, 0, time.UTC) docs := []pdfexport.PackDocument{{Metadata: pdfexport.PackMetadata{Category: "Nonogram"}}} @@ -94,7 +94,7 @@ func TestBuildRenderConfigForPDFDefaultsSubtitleFromDocs(t *testing.T) { flagPDFTitle = "" flagPDFVolume = 1 flagPDFAdvert = "Find more puzzles" - flagPDFCoverColor = "" + flagPDFSheetLayout = "half-letter" docs := []pdfexport.PackDocument{{Metadata: pdfexport.PackMetadata{Category: "Sudoku"}}} cfg, err := buildRenderConfigForPDF(docs, "seed-2", time.Now()) @@ -106,34 +106,47 @@ func TestBuildRenderConfigForPDFDefaultsSubtitleFromDocs(t *testing.T) { } } -func TestBuildRenderConfigForPDFCoverColorControlsCoverPages(t *testing.T) { +func TestSheetLayoutControlsCoverPages(t *testing.T) { reset := snapshotExportPDFFlags() defer reset() flagPDFTitle = "Issue 01" flagPDFVolume = 1 flagPDFAdvert = "Find more puzzles" - docs := []pdfexport.PackDocument{{Metadata: pdfexport.PackMetadata{Category: "Sudoku"}}} + if pdfexport.SheetLayoutHalfLetter == pdfexport.SheetLayoutDuplexBooklet { + t.Fatal("expected distinct sheet layout values") + } +} - flagPDFCoverColor = "" - cfgNoCover, err := buildRenderConfigForPDF(docs, "seed-3", time.Now()) +func TestBuildRenderConfigForPDFParsesSheetLayout(t *testing.T) { + reset := snapshotExportPDFFlags() + defer reset() + + flagPDFVolume = 1 + flagPDFSheetLayout = "duplex-booklet" + + cfg, err := buildRenderConfigForPDF(nil, "seed-5", time.Now()) if err != nil { - t.Fatalf("buildRenderConfigForPDF (no cover color) error = %v", err) + t.Fatalf("buildRenderConfigForPDF error = %v", err) } - if cfgNoCover.CoverColor != nil { - t.Fatalf("CoverColor = %+v, want nil when --cover-color is omitted", cfgNoCover.CoverColor) + if cfg.SheetLayout != pdfexport.SheetLayoutDuplexBooklet { + t.Fatalf("SheetLayout = %d, want duplex booklet", cfg.SheetLayout) } +} - flagPDFCoverColor = "#112233" - cfgWithCover, err := buildRenderConfigForPDF(docs, "seed-4", time.Now()) - if err != nil { - t.Fatalf("buildRenderConfigForPDF (with cover color) error = %v", err) - } - if cfgWithCover.CoverColor == nil { - t.Fatal("CoverColor = nil, want parsed color when --cover-color is set") +func TestBuildRenderConfigForPDFRejectsInvalidSheetLayout(t *testing.T) { + reset := snapshotExportPDFFlags() + defer reset() + + flagPDFVolume = 1 + flagPDFSheetLayout = "brochure" + + _, err := buildRenderConfigForPDF(nil, "seed-6", time.Now()) + if err == nil { + t.Fatal("expected invalid --sheet-layout error") } - if *cfgWithCover.CoverColor != (pdfexport.RGB{R: 0x11, G: 0x22, B: 0x33}) { - t.Fatalf("CoverColor = %+v, want {R:17 G:34 B:51}", *cfgWithCover.CoverColor) + if !strings.Contains(err.Error(), "--sheet-layout") { + t.Fatalf("error = %q, want mention of --sheet-layout", err.Error()) } } @@ -272,17 +285,17 @@ func snapshotExportPDFFlags() func() { oldHeader := flagPDFHeader oldVolume := flagPDFVolume oldAdvert := flagPDFAdvert - oldCoverColor := flagPDFCoverColor oldOutput := flagPDFOutput oldShuffle := flagPDFShuffleSeed + oldSheetLayout := flagPDFSheetLayout return func() { flagPDFTitle = oldTitle flagPDFHeader = oldHeader flagPDFVolume = oldVolume flagPDFAdvert = oldAdvert - flagPDFCoverColor = oldCoverColor flagPDFOutput = oldOutput flagPDFShuffleSeed = oldShuffle + flagPDFSheetLayout = oldSheetLayout } } diff --git a/cmd/test_test.go b/cmd/test_test.go deleted file mode 100644 index ce85669..0000000 --- a/cmd/test_test.go +++ /dev/null @@ -1,269 +0,0 @@ -package cmd - -import ( - "bytes" - "encoding/json" - "fmt" - "math/rand/v2" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/FelineStateMachine/puzzletea/game" - "github.com/FelineStateMachine/puzzletea/netwalk" - "github.com/FelineStateMachine/puzzletea/pdfexport" - "github.com/FelineStateMachine/puzzletea/sudoku" - "github.com/FelineStateMachine/puzzletea/theme" - "github.com/spf13/cobra" -) - -func TestRunTestRendersANSIToStdout(t *testing.T) { - reset := snapshotTestFlags() - defer reset() - - input := filepath.Join(t.TempDir(), "visual.jsonl") - data, err := netwalk.VisualFixtureJSONL() - if err != nil { - t.Fatalf("VisualFixtureJSONL() error = %v", err) - } - if err := os.WriteFile(input, data, 0o644); err != nil { - t.Fatal(err) - } - - cmd, out := newTestCmd() - if err := runTest(cmd, []string{input}); err != nil { - t.Fatalf("runTest() error = %v", err) - } - - rendered := out.String() - if !strings.Contains(rendered, "=== Netwalk | Visual Fixture | cursor-root-horizontal | #1 ===") { - t.Fatalf("expected first section header, got:\n%s", rendered) - } - if !strings.Contains(rendered, "\x1b[") { - t.Fatal("expected ANSI escape sequences in review output") - } -} - -func TestRunTestWritesOutputFile(t *testing.T) { - reset := snapshotTestFlags() - defer reset() - - input := filepath.Join(t.TempDir(), "visual.jsonl") - data, err := netwalk.VisualFixtureJSONL() - if err != nil { - t.Fatalf("VisualFixtureJSONL() error = %v", err) - } - if err := os.WriteFile(input, data, 0o644); err != nil { - t.Fatal(err) - } - - testOutput = filepath.Join(t.TempDir(), "review.txt") - cmd, out := newTestCmd() - if err := runTest(cmd, []string{input}); err != nil { - t.Fatalf("runTest() error = %v", err) - } - if out.Len() != 0 { - t.Fatalf("expected stdout to stay empty when --output is set, got %q", out.String()) - } - - written, err := os.ReadFile(testOutput) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(written), "solved-with-empty-cells") { - t.Fatalf("expected written output to contain fixture case names, got:\n%s", string(written)) - } -} - -func TestRunTestRejectsBadSchema(t *testing.T) { - reset := snapshotTestFlags() - defer reset() - - input := filepath.Join(t.TempDir(), "bad.jsonl") - record := pdfexport.JSONLRecord{Schema: "bad.schema"} - line, err := json.Marshal(record) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(input, append(line, '\n'), 0o644); err != nil { - t.Fatal(err) - } - - cmd, _ := newTestCmd() - err = runTest(cmd, []string{input}) - if err == nil { - t.Fatal("expected unsupported schema error") - } - if !strings.Contains(err.Error(), "unsupported schema") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestRunTestRendersMixedGamesInInputOrder(t *testing.T) { - reset := snapshotTestFlags() - defer reset() - - input := filepath.Join(t.TempDir(), "mixed.jsonl") - records := []pdfexport.JSONLRecord{ - testJSONLRecord(t, "Netwalk", "Visual Fixture", "netwalk-first", 1, spawnSeededSave(t, netwalk.Modes[0].(game.SeededSpawner))), - testJSONLRecord(t, "Sudoku", "Easy", "sudoku-second", 2, spawnSeededSave(t, sudoku.Modes[0].(game.SeededSpawner))), - } - writeTestJSONL(t, input, records) - - cmd, out := newTestCmd() - if err := runTest(cmd, []string{input}); err != nil { - t.Fatalf("runTest() error = %v", err) - } - - rendered := out.String() - first := strings.Index(rendered, "netwalk-first") - second := strings.Index(rendered, "sudoku-second") - if first == -1 || second == -1 { - t.Fatalf("expected both records in output, got:\n%s", rendered) - } - if first > second { - t.Fatalf("expected input order to be preserved, got:\n%s", rendered) - } -} - -func TestRunTestIgnoresPersistedThemeWithoutFlag(t *testing.T) { - reset := snapshotTestFlags() - defer reset() - t.Cleanup(func() { _ = theme.Apply("") }) - - configPath, _ := writeCommandConfig(t) - flagConfigPath = configPath - if err := theme.Apply(""); err != nil { - t.Fatalf("theme.Apply() error = %v", err) - } - defaultFG := fmt.Sprint(theme.Current().FG) - if err := theme.Apply("Dracula"); err != nil { - t.Fatalf("theme.Apply() error = %v", err) - } - - input := filepath.Join(t.TempDir(), "visual.jsonl") - data, err := netwalk.VisualFixtureJSONL() - if err != nil { - t.Fatalf("VisualFixtureJSONL() error = %v", err) - } - if err := os.WriteFile(input, data, 0o644); err != nil { - t.Fatal(err) - } - - cmd, _ := newTestCmd() - if err := runTest(cmd, []string{input}); err != nil { - t.Fatalf("runTest() error = %v", err) - } - - if got, want := fmt.Sprint(theme.Current().FG), defaultFG; got != want { - t.Fatalf("test command theme FG = %v, want default %v", got, want) - } -} - -func TestRunTestUsesExplicitThemeFlag(t *testing.T) { - reset := snapshotTestFlags() - defer reset() - t.Cleanup(func() { _ = theme.Apply("") }) - - configPath, _ := writeCommandConfig(t) - flagConfigPath = configPath - flagTheme = "Dracula" - - input := filepath.Join(t.TempDir(), "visual.jsonl") - data, err := netwalk.VisualFixtureJSONL() - if err != nil { - t.Fatalf("VisualFixtureJSONL() error = %v", err) - } - if err := os.WriteFile(input, data, 0o644); err != nil { - t.Fatal(err) - } - - cmd, _ := newTestCmd() - if err := runTest(cmd, []string{input}); err != nil { - t.Fatalf("runTest() error = %v", err) - } - - want := theme.LookupTheme(flagTheme).Palette() - if got := fmt.Sprint(theme.Current().FG); got != fmt.Sprint(want.FG) { - t.Fatalf("theme override FG = %v, want %v", theme.Current().FG, want.FG) - } -} - -func snapshotTestFlags() func() { - prevOutput := testOutput - prevTheme := flagTheme - prevConfigPath := flagConfigPath - testOutput = "" - return func() { - testOutput = prevOutput - flagTheme = prevTheme - flagConfigPath = prevConfigPath - } -} - -func newTestCmd() (*cobra.Command, *bytes.Buffer) { - cmd := &cobra.Command{} - var out bytes.Buffer - cmd.SetOut(&out) - return cmd, &out -} - -func writeTestJSONL(t *testing.T, path string, records []pdfexport.JSONLRecord) { - t.Helper() - - var data []byte - for _, record := range records { - line, err := json.Marshal(record) - if err != nil { - t.Fatal(err) - } - data = append(data, line...) - data = append(data, '\n') - } - if err := os.WriteFile(path, data, 0o644); err != nil { - t.Fatal(err) - } -} - -func testJSONLRecord( - t *testing.T, - gameName, modeName, name string, - index int, - save []byte, -) pdfexport.JSONLRecord { - t.Helper() - - return pdfexport.JSONLRecord{ - Schema: pdfexport.ExportSchemaV1, - Pack: pdfexport.JSONLPackMeta{ - Generated: "2026-03-14T00:00:00Z", - Version: "test", - Category: gameName, - ModeSelection: modeName, - Count: 2, - }, - Puzzle: pdfexport.JSONLPuzzle{ - Index: index, - Name: name, - Game: gameName, - Mode: modeName, - Save: json.RawMessage(save), - }, - } -} - -func spawnSeededSave(t *testing.T, spawner game.SeededSpawner) []byte { - t.Helper() - - rng := rand.New(rand.NewPCG(1, 2)) - g, err := spawner.SpawnSeeded(rng) - if err != nil { - t.Fatalf("SpawnSeeded() error = %v", err) - } - save, err := g.GetSave() - if err != nil { - t.Fatalf("GetSave() error = %v", err) - } - return save -} diff --git a/fillomino/print_adapter.go b/fillomino/print_adapter.go index c51243b..fc846be 100644 --- a/fillomino/print_adapter.go +++ b/fillomino/print_adapter.go @@ -33,7 +33,10 @@ func renderFillominoPage(pdf *fpdf.Fpdf, data *pdfexport.FillominoData) { pageW, pageH := pdf.GetPageSize() pageNo := pdf.PageNo() - area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1) + body := pdfexport.PuzzleBodyRect(pageW, pageH, pageNo) + rules := []string{"Each connected region must contain exactly the number shown in its cells."} + ruleLines := pdfexport.InstructionLineCount(pdf, body.W, rules) + area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, ruleLines) cellSize := pdfexport.FitCompactCellSize(data.Width, data.Height, area) if cellSize <= 0 { return @@ -61,20 +64,8 @@ func renderFillominoPage(pdf *fpdf.Fpdf, data *pdfexport.FillominoData) { pdf.SetLineWidth(pdfexport.OuterBorderLineMM) pdf.Rect(startX, startY, blockW, blockH, "D") - ruleY := pdfexport.InstructionY(startY+blockH, pageH, 1) - pdfexport.SetInstructionStyle(pdf) - pdf.SetXY(area.X, ruleY) - pdf.CellFormat( - area.W, - pdfexport.InstructionLineHMM, - "Each connected region must contain exactly the number shown in its cells.", - "", - 0, - "C", - false, - 0, - "", - ) + ruleY := pdfexport.InstructionY(startY+blockH, pageH, ruleLines) + pdfexport.RenderInstructions(pdf, body.X, ruleY, body.W, rules) } func drawFillominoGiven(pdf *fpdf.Fpdf, x, y, cellSize float64, text string) { diff --git a/fillomino/testdata/visual_states.jsonl b/fillomino/testdata/visual_states.jsonl index 970789e..52b8e96 100644 --- a/fillomino/testdata/visual_states.jsonl +++ b/fillomino/testdata/visual_states.jsonl @@ -1,3 +1,4 @@ -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Fillomino","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":1,"name":"provided-and-editable","game":"Fillomino","mode":"Visual Fixture","save":{"width":3,"height":3,"state":"2 2 1\n2 . 1\n. . .","provided":"#.#\n#.#\n...","mode_title":"Visual Fixture","max_cell_value":3}}} -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Fillomino","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":2,"name":"mixed-regions","game":"Fillomino","mode":"Visual Fixture","save":{"width":3,"height":3,"state":"2 2 1\n3 . .\n. . 1","provided":"###\n#..\n..#","mode_title":"Visual Fixture","max_cell_value":3}}} -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Fillomino","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":3,"name":"solved-complete-grid","game":"Fillomino","mode":"Visual Fixture","save":{"width":3,"height":3,"state":"2 2 1\n3 3 3\n1 2 2","provided":"#.#\n...\n#..","mode_title":"Visual Fixture","max_cell_value":3}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Fillomino","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":1,"name":"provided-and-editable","game":"Fillomino","mode":"Visual Fixture","save":{"width":3,"height":3,"state":"2 2 1\n2 . 1\n. . .","provided":"#.#\n#.#\n...","mode_title":"Visual Fixture","max_cell_value":3}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Fillomino","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":2,"name":"mixed-regions","game":"Fillomino","mode":"Visual Fixture","save":{"width":3,"height":3,"state":"2 2 1\n3 . .\n. . 1","provided":"###\n#..\n..#","mode_title":"Visual Fixture","max_cell_value":3}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Fillomino","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":3,"name":"overfull-region-feedback","game":"Fillomino","mode":"Visual Fixture","save":{"width":3,"height":3,"state":"2 2 2\n3 . .\n. . 1","provided":"...\n#..\n..#","mode_title":"Visual Fixture","max_cell_value":3}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Fillomino","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":4,"name":"solved-complete-grid","game":"Fillomino","mode":"Visual Fixture","save":{"width":3,"height":3,"state":"2 2 1\n3 3 3\n1 2 2","provided":"#.#\n...\n#..","mode_title":"Visual Fixture","max_cell_value":3}}} diff --git a/go.mod b/go.mod index b8107f7..c89d2b5 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,8 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/phpdave11/gofpdi v1.0.13 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sahilm/fuzzy v0.1.1 // indirect github.com/spf13/pflag v1.0.9 // indirect diff --git a/go.sum b/go.sum index fce7be7..9475160 100644 --- a/go.sum +++ b/go.sum @@ -84,6 +84,11 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/phpdave11/gofpdi v1.0.13 h1:o61duiW8M9sMlkVXWlvP92sZJtGKENvW3VExs6dZukQ= +github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= diff --git a/hashiwokakero/print_adapter.go b/hashiwokakero/print_adapter.go index 6dff097..d95194e 100644 --- a/hashiwokakero/print_adapter.go +++ b/hashiwokakero/print_adapter.go @@ -40,7 +40,10 @@ func renderHashiPage(pdf *fpdf.Fpdf, data *pdfexport.HashiData) { spanX := max(data.Width-1, 1) spanY := max(data.Height-1, 1) - area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1) + body := pdfexport.PuzzleBodyRect(pageW, pageH, pageNo) + rules := []string{"Connect islands horizontally/vertically with up to two bridges and no crossings."} + ruleLines := pdfexport.InstructionLineCount(pdf, body.W, rules) + area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, ruleLines) step := pdfexport.FitHashiCellSize(spanX, spanY, area) if step <= 0 { return @@ -55,24 +58,12 @@ func renderHashiPage(pdf *fpdf.Fpdf, data *pdfexport.HashiData) { drawHashiBoardBorder(pdf, originX, originY, boardW, boardH, islandRadius) drawHashiIslands(pdf, originX, originY, step, islandRadius, data.Islands) - ruleY := pdfexport.InstructionY(originY+boardH+pdfexport.InstructionLineHMM, pageH, 1) - pdfexport.SetInstructionStyle(pdf) - pdf.SetXY(area.X, ruleY) - pdf.CellFormat( - area.W, - pdfexport.InstructionLineHMM, - "Connect islands horizontally/vertically with up to two bridges and no crossings.", - "", - 0, - "C", - false, - 0, - "", - ) + ruleY := pdfexport.InstructionY(originY+boardH+pdfexport.InstructionLineHMM, pageH, ruleLines) + pdfexport.RenderInstructions(pdf, body.X, ruleY, body.W, rules) } func drawHashiGuideDots(pdf *fpdf.Fpdf, originX, originY float64, width, height int, step float64) { - pdf.SetFillColor(230, 230, 230) + pdf.SetFillColor(180, 180, 180) r := math.Max(0.20, math.Min(0.55, step*0.035)) for y := range height { for x := range width { diff --git a/hashiwokakero/testdata/visual_states.jsonl b/hashiwokakero/testdata/visual_states.jsonl index d314bb1..f0c2896 100644 --- a/hashiwokakero/testdata/visual_states.jsonl +++ b/hashiwokakero/testdata/visual_states.jsonl @@ -1,3 +1,4 @@ -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hashiwokakero","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":1,"name":"disconnected-corners","game":"Hashiwokakero","mode":"Visual Fixture","save":{"width":4,"height":4,"islands":[{"ID":0,"X":0,"Y":0,"Required":2},{"ID":1,"X":3,"Y":0,"Required":2},{"ID":2,"X":0,"Y":3,"Required":2},{"ID":3,"X":3,"Y":3,"Required":2}],"bridges":null,"mode_title":"Visual Fixture"}}} -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hashiwokakero","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":2,"name":"single-bridge-pair","game":"Hashiwokakero","mode":"Visual Fixture","save":{"width":3,"height":1,"islands":[{"ID":0,"X":0,"Y":0,"Required":1},{"ID":1,"X":2,"Y":0,"Required":1}],"bridges":[{"Island1":0,"Island2":1,"Count":1}],"mode_title":"Visual Fixture"}}} -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hashiwokakero","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":3,"name":"double-bridge-pair","game":"Hashiwokakero","mode":"Visual Fixture","save":{"width":3,"height":1,"islands":[{"ID":0,"X":0,"Y":0,"Required":2},{"ID":1,"X":2,"Y":0,"Required":2}],"bridges":[{"Island1":0,"Island2":1,"Count":2}],"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hashiwokakero","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":1,"name":"disconnected-corners","game":"Hashiwokakero","mode":"Visual Fixture","save":{"width":4,"height":4,"islands":[{"ID":0,"X":0,"Y":0,"Required":2},{"ID":1,"X":3,"Y":0,"Required":2},{"ID":2,"X":0,"Y":3,"Required":2},{"ID":3,"X":3,"Y":3,"Required":2}],"bridges":null,"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hashiwokakero","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":2,"name":"partial-single-bridge","game":"Hashiwokakero","mode":"Visual Fixture","save":{"width":3,"height":1,"islands":[{"ID":0,"X":0,"Y":0,"Required":2},{"ID":1,"X":2,"Y":0,"Required":1}],"bridges":[{"Island1":0,"Island2":1,"Count":1}],"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hashiwokakero","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":3,"name":"corner-network-choice","game":"Hashiwokakero","mode":"Visual Fixture","save":{"width":3,"height":3,"islands":[{"ID":0,"X":0,"Y":0,"Required":2},{"ID":1,"X":2,"Y":0,"Required":1},{"ID":2,"X":0,"Y":2,"Required":1}],"bridges":[{"Island1":0,"Island2":1,"Count":1}],"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hashiwokakero","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":4,"name":"double-bridge-pair","game":"Hashiwokakero","mode":"Visual Fixture","save":{"width":3,"height":1,"islands":[{"ID":0,"X":0,"Y":0,"Required":2},{"ID":1,"X":2,"Y":0,"Required":2}],"bridges":[{"Island1":0,"Island2":1,"Count":2}],"mode_title":"Visual Fixture"}}} diff --git a/hitori/print_adapter.go b/hitori/print_adapter.go index 758168f..54f41e8 100644 --- a/hitori/print_adapter.go +++ b/hitori/print_adapter.go @@ -35,7 +35,10 @@ func renderHitoriPage(pdf *fpdf.Fpdf, data *pdfexport.HitoriData) { size := data.Size pageW, pageH := pdf.GetPageSize() pageNo := pdf.PageNo() - area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1) + body := pdfexport.PuzzleBodyRect(pageW, pageH, pageNo) + rules := []string{"Shade duplicates; shaded cells cannot touch orthogonally; unshaded cells stay connected."} + ruleLines := pdfexport.InstructionLineCount(pdf, body.W, rules) + area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, ruleLines) cellSize := pdfexport.FitCompactCellSize(size, size, area) if cellSize <= 0 { return @@ -68,20 +71,8 @@ func renderHitoriPage(pdf *fpdf.Fpdf, data *pdfexport.HitoriData) { pdf.SetLineWidth(pdfexport.OuterBorderLineMM) pdf.Rect(startX, startY, blockW, blockH, "D") - ruleY := pdfexport.InstructionY(startY+blockH, pageH, 1) - pdfexport.SetInstructionStyle(pdf) - pdf.SetXY(area.X, ruleY) - pdf.CellFormat( - area.W, - pdfexport.InstructionLineHMM, - "Shade duplicates; shaded cells cannot touch orthogonally; unshaded cells stay connected.", - "", - 0, - "C", - false, - 0, - "", - ) + ruleY := pdfexport.InstructionY(startY+blockH, pageH, ruleLines) + pdfexport.RenderInstructions(pdf, body.X, ruleY, body.W, rules) } func drawHitoriCellNumber(pdf *fpdf.Fpdf, x, y, cellSize float64, text string) { diff --git a/hitori/testdata/visual_states.jsonl b/hitori/testdata/visual_states.jsonl index 11365a1..a1df179 100644 --- a/hitori/testdata/visual_states.jsonl +++ b/hitori/testdata/visual_states.jsonl @@ -1,4 +1,4 @@ {"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hitori","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":1,"name":"unmarked-duplicates","game":"Hitori","mode":"Visual Fixture","save":{"size":3,"numbers":"113\n231\n312","marks":"...\n...\n...","mode_title":"Visual Fixture"}}} -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hitori","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":2,"name":"shaded-and-circled","game":"Hitori","mode":"Visual Fixture","save":{"size":4,"numbers":"1213\n2341\n3124\n1432","marks":"X...\n....\n....\n.O..","mode_title":"Visual Fixture"}}} -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hitori","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":3,"name":"duplicate-circled-conflict","game":"Hitori","mode":"Visual Fixture","save":{"size":3,"numbers":"113\n231\n312","marks":"O..\n...\n...","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hitori","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":2,"name":"adjacent-shaded-conflict","game":"Hitori","mode":"Visual Fixture","save":{"size":4,"numbers":"1213\n2341\n3124\n1432","marks":"XX..\n....\n....\n....","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hitori","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":3,"name":"circled-duplicate-conflict","game":"Hitori","mode":"Visual Fixture","save":{"size":3,"numbers":"113\n231\n312","marks":"OO.\n...\n...","mode_title":"Visual Fixture"}}} {"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Hitori","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":4,"name":"solved-valid-layout","game":"Hitori","mode":"Visual Fixture","save":{"size":4,"numbers":"1213\n2341\n3124\n1432","marks":"X...\n....\n....\n..X.","mode_title":"Visual Fixture"}}} diff --git a/justfile b/justfile index 607e13e..5a84f3d 100644 --- a/justfile +++ b/justfile @@ -32,6 +32,18 @@ diagnostic: fmt: gofumpt -w . +# Render visual fixture output for one game or the whole suite. +render game="all": + @if [ "{{game}}" = "all" ]; then \ + find . -path '*/testdata/visual_states.jsonl' | sort | while read -r file; do \ + printf '== %s ==\n' "$file"; \ + go run . test "$file"; \ + printf '\n'; \ + done; \ + else \ + go run . test "{{game}}/testdata/visual_states.jsonl"; \ + fi + # Tidy module dependencies. tidy: go mod tidy diff --git a/lightsout/testdata/visual_states.jsonl b/lightsout/testdata/visual_states.jsonl index a23898d..f22d9ad 100644 --- a/lightsout/testdata/visual_states.jsonl +++ b/lightsout/testdata/visual_states.jsonl @@ -1,3 +1,4 @@ -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Lights Out","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":1,"name":"cursor-lit-center","game":"Lights Out","mode":"Visual Fixture","save":{"grid":[[false,false,false],[false,true,false],[false,false,false]],"initial_grid":[[false,false,false],[false,true,false],[false,false,false]],"cx":1,"cy":1,"mode_title":"Visual Fixture"}}} -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Lights Out","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":2,"name":"cursor-dark-corner","game":"Lights Out","mode":"Visual Fixture","save":{"grid":[[false,true,false],[false,false,false],[true,false,true]],"initial_grid":[[false,true,false],[false,false,false],[true,false,true]],"cx":0,"cy":0,"mode_title":"Visual Fixture"}}} -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Lights Out","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":3,"name":"solved-all-off","game":"Lights Out","mode":"Visual Fixture","save":{"grid":[[false,false,false],[false,false,false],[false,false,false]],"initial_grid":[[false,false,false],[false,false,false],[false,false,false]],"cx":1,"cy":1,"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Lights Out","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":1,"name":"cursor-lit-center","game":"Lights Out","mode":"Visual Fixture","save":{"grid":[[false,false,false],[false,true,false],[false,false,false]],"initial_grid":[[false,false,false],[false,true,false],[false,false,false]],"cx":1,"cy":1,"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Lights Out","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":2,"name":"cursor-dark-corner","game":"Lights Out","mode":"Visual Fixture","save":{"grid":[[false,true,false],[false,false,false],[true,false,true]],"initial_grid":[[false,true,false],[false,false,false],[true,false,true]],"cx":0,"cy":0,"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Lights Out","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":3,"name":"mixed-hotspots","game":"Lights Out","mode":"Visual Fixture","save":{"grid":[[true,false,true],[false,true,false],[true,false,true]],"initial_grid":[[true,false,true],[false,true,false],[true,false,true]],"cx":2,"cy":0,"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Lights Out","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":4,"name":"solved-all-off","game":"Lights Out","mode":"Visual Fixture","save":{"grid":[[false,false,false],[false,false,false],[false,false,false]],"initial_grid":[[false,false,false],[false,false,false],[false,false,false]],"cx":1,"cy":1,"mode_title":"Visual Fixture"}}} diff --git a/netwalk/print_adapter.go b/netwalk/print_adapter.go index b1c692f..d9d6390 100644 --- a/netwalk/print_adapter.go +++ b/netwalk/print_adapter.go @@ -33,7 +33,10 @@ func renderNetwalkPage(pdf *fpdf.Fpdf, data *pdfexport.NetwalkData) { pageW, pageH := pdf.GetPageSize() pageNo := pdf.PageNo() - area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1) + body := pdfexport.PuzzleBodyRect(pageW, pageH, pageNo) + rules := []string{"Rotate tiles so every connector matches and the full network reaches the server without loops."} + ruleLines := pdfexport.InstructionLineCount(pdf, body.W, rules) + area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, ruleLines) cellSize := pdfexport.FitCompactCellSize(data.Size, data.Size, area) if cellSize <= 0 { return @@ -57,20 +60,8 @@ func renderNetwalkPage(pdf *fpdf.Fpdf, data *pdfexport.NetwalkData) { pdf.SetLineWidth(pdfexport.OuterBorderLineMM) pdf.Rect(startX, startY, blockW, blockH, "D") - ruleY := pdfexport.InstructionY(startY+blockH, pageH, 1) - pdfexport.SetInstructionStyle(pdf) - pdf.SetXY(area.X, ruleY) - pdf.CellFormat( - area.W, - pdfexport.InstructionLineHMM, - "Rotate tiles so every connector matches and the full network reaches the server without loops.", - "", - 0, - "C", - false, - 0, - "", - ) + ruleY := pdfexport.InstructionY(startY+blockH, pageH, ruleLines) + pdfexport.RenderInstructions(pdf, body.X, ruleY, body.W, rules) } func drawNetwalkGrid(pdf *fpdf.Fpdf, startX, startY, blockW, blockH float64, size int, cellSize float64) { diff --git a/netwalk/visual_fixture_test.go b/netwalk/visual_fixture_test.go deleted file mode 100644 index 3486f66..0000000 --- a/netwalk/visual_fixture_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package netwalk - -import ( - "os" - "path/filepath" - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/x/ansi" -) - -func TestVisualFixtureJSONLMatchesCommittedFile(t *testing.T) { - want, err := VisualFixtureJSONL() - if err != nil { - t.Fatalf("VisualFixtureJSONL() error = %v", err) - } - - path := filepath.Join("testdata", "visual_states.jsonl") - got, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read fixture file: %v", err) - } - - if string(got) != string(want) { - t.Fatalf("committed fixture is out of sync; regenerate %s", path) - } -} - -func TestVisualFixtureCasesCoverExpectedViews(t *testing.T) { - wantNames := []string{ - "cursor-root-horizontal", - "leaf-gallery", - "straight-and-corner-gallery", - "tee-and-cross-gallery", - "connected-horizontal-bridge", - "connected-vertical-bridge", - "disconnected-default-foreground", - "dangling-error-state", - "locked-root-cursor", - "solved-with-empty-cells", - } - if len(visualFixtureCases) != len(wantNames) { - t.Fatalf("fixture case count = %d, want %d", len(visualFixtureCases), len(wantNames)) - } - for i, want := range wantNames { - if got := visualFixtureCases[i].name; got != want { - t.Fatalf("fixture case %d = %q, want %q", i, got, want) - } - } -} - -func TestVisualFixtureRepresentativeViews(t *testing.T) { - t.Run("dangling case shows error status", func(t *testing.T) { - view := fixtureView(t, "dangling-error-state") - if !strings.Contains(view, "dangling 1") { - t.Fatalf("expected dangling status in view:\n%s", view) - } - }) - - t.Run("locked root case reports lock count", func(t *testing.T) { - view := fixtureView(t, "locked-root-cursor") - if !strings.Contains(view, "locks 1") { - t.Fatalf("expected lock count in view:\n%s", view) - } - }) - - t.Run("solved case shows solved badge", func(t *testing.T) { - view := fixtureView(t, "solved-with-empty-cells") - if !strings.Contains(view, "SOLVED") { - t.Fatalf("expected solved badge in view:\n%s", view) - } - }) -} - -func fixtureView(t *testing.T, name string) string { - t.Helper() - - for _, fixture := range visualFixtureCases { - if fixture.name != name { - continue - } - - save, err := visualFixtureSave(fixture.puzzle) - if err != nil { - t.Fatalf("visualFixtureSave(%q) error = %v", name, err) - } - model, err := ImportModel(save) - if err != nil { - t.Fatalf("ImportModel(%q) error = %v", name, err) - } - g, _ := model.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) - return ansi.Strip(g.View()) - } - - t.Fatalf("fixture %q not found", name) - return "" -} diff --git a/nonogram/export.go b/nonogram/export.go index 02b67e6..41ebc2e 100644 --- a/nonogram/export.go +++ b/nonogram/export.go @@ -6,20 +6,22 @@ import ( ) type Save struct { - State string `json:"state"` - Width int `json:"width"` - Height int `json:"height"` - RowHints TomographyDefinition `json:"row-hints"` - ColHints TomographyDefinition `json:"col-hints"` + State string `json:"state"` + Width int `json:"width"` + Height int `json:"height"` + RowHints TomographyDefinition `json:"row-hints"` + ColHints TomographyDefinition `json:"col-hints"` + ModeTitle string `json:"mode_title,omitempty"` } func (m Model) GetSave() ([]byte, error) { save := Save{ - RowHints: m.rowHints, - ColHints: m.colHints, - State: m.grid.String(), - Width: m.width, - Height: m.height, + RowHints: m.rowHints, + ColHints: m.colHints, + State: m.grid.String(), + Width: m.width, + Height: m.height, + ModeTitle: m.modeTitle, } jsonData, err := json.Marshal(save) if err != nil { @@ -42,6 +44,7 @@ func ImportModel(data []byte) (*Model, error) { colHints: save.ColHints, grid: g, keys: DefaultKeyMap, + modeTitle: save.ModeTitle, currentHints: curr, solved: curr.rows.equal(save.RowHints) && curr.cols.equal(save.ColHints), }, nil diff --git a/nonogram/print_adapter.go b/nonogram/print_adapter.go index b876952..697d1a1 100644 --- a/nonogram/print_adapter.go +++ b/nonogram/print_adapter.go @@ -47,10 +47,14 @@ func renderNonogramPage(pdf *fpdf.Fpdf, data *pdfexport.NonogramData) { colHintRows = 1 } + body := pdfexport.PuzzleBodyRect(pageW, pageH, pageNo) + rules := []string{"Use row/column hints to fill blocks in order; groups are separated by at least one blank cell."} + ruleLines := pdfexport.InstructionLineCount(pdf, body.W, rules) layout := layoutNonogram( pageW, pageH, pageNo, + ruleLines, data.Width, data.Height, rowHintCols, @@ -94,22 +98,8 @@ func renderNonogramPage(pdf *fpdf.Fpdf, data *pdfexport.NonogramData) { pdf.SetLineWidth(pdfexport.OuterBorderLineMM) pdf.Rect(xSep, ySep, gridW, gridH, "D") - ruleY := ySep + gridH + 3.5 - ruleY = pdfexport.InstructionY(ruleY-3.5, pageH, 1) - body := pdfexport.PuzzleBodyRect(pageW, pageH, pageNo) - pdfexport.SetInstructionStyle(pdf) - pdf.SetXY(body.X, ruleY) - pdf.CellFormat( - body.W, - pdfexport.InstructionLineHMM, - "Use row/column hints to fill blocks in order; groups are separated by at least one blank cell.", - "", - 0, - "C", - false, - 0, - "", - ) + ruleY := pdfexport.InstructionY(ySep+gridH, pageH, ruleLines) + pdfexport.RenderInstructions(pdf, body.X, ruleY, body.W, rules) } func drawNonogramPuzzleGrid( @@ -164,6 +154,7 @@ func layoutNonogram( pageW, pageH float64, pageNo, + ruleLines, gridCols, gridRows, rowHintCols, @@ -171,7 +162,7 @@ func layoutNonogram( ) nonogramLayout { totalCols := rowHintCols + gridCols totalRows := colHintRows + gridRows - area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1) + area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, ruleLines) cellSize := pdfexport.FitNonogramCellSize(totalCols, totalRows, area) if cellSize <= 0 { return nonogramLayout{} diff --git a/nonogram/print_adapter_test.go b/nonogram/print_adapter_test.go index 4888889..cf126d1 100644 --- a/nonogram/print_adapter_test.go +++ b/nonogram/print_adapter_test.go @@ -97,6 +97,7 @@ func TestLayoutNonogramCentersGrid(t *testing.T) { pageW, pageH, pageNo, + 1, 10, 10, tt.rowHintCol, diff --git a/nonogram/testdata/visual_states.jsonl b/nonogram/testdata/visual_states.jsonl index ca11ea6..9490b7a 100644 --- a/nonogram/testdata/visual_states.jsonl +++ b/nonogram/testdata/visual_states.jsonl @@ -1,2 +1,3 @@ -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Nonogram","mode_selection":"Visual Fixture","count":2,"seed":""},"puzzle":{"index":1,"name":"filled-marked-mix","game":"Nonogram","mode":"Visual Fixture","save":{"state":". -. \n .. .\n.. .\n .. \n. . .","width":5,"height":5,"row-hints":[[1,1,1],[1,1],[5],[0],[1,1]],"col-hints":[[1,1,1],[2],[1,1],[2],[1,1,1]]}}} -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Nonogram","mode_selection":"Visual Fixture","count":2,"seed":""},"puzzle":{"index":2,"name":"solved-diagonal","game":"Nonogram","mode":"Visual Fixture","save":{"state":". \n .","width":2,"height":2,"row-hints":[[1],[1]],"col-hints":[[1],[1]]}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Nonogram","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":1,"name":"filled-marked-mix","game":"Nonogram","mode":"Visual Fixture","save":{"state":". -. \n .. .\n.. .\n .. \n. . .","width":5,"height":5,"row-hints":[[1,1,1],[1,1],[5],[0],[1,1]],"col-hints":[[1,1,1],[2],[1,1],[2],[1,1,1]],"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Nonogram","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":2,"name":"line-completion-pressure","game":"Nonogram","mode":"Visual Fixture","save":{"state":".- \n ..\n- .","width":3,"height":3,"row-hints":[[2],[1],[1,1]],"col-hints":[[1,1],[2],[1]],"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Nonogram","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":3,"name":"solved-diagonal","game":"Nonogram","mode":"Visual Fixture","save":{"state":". \n .","width":2,"height":2,"row-hints":[[1],[1]],"col-hints":[[1],[1]],"mode_title":"Visual Fixture"}}} diff --git a/nurikabe/print_adapter.go b/nurikabe/print_adapter.go index 9e638a2..acce768 100644 --- a/nurikabe/print_adapter.go +++ b/nurikabe/print_adapter.go @@ -34,7 +34,10 @@ func renderNurikabePage(pdf *fpdf.Fpdf, data *pdfexport.NurikabeData) { pageW, pageH := pdf.GetPageSize() pageNo := pdf.PageNo() - area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1) + body := pdfexport.PuzzleBodyRect(pageW, pageH, pageNo) + rules := []string{"Expand each numbered island to its size; connect all sea cells into one wall."} + ruleLines := pdfexport.InstructionLineCount(pdf, body.W, rules) + area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, ruleLines) cellSize := pdfexport.FitCompactCellSize(data.Width, data.Height, area) if cellSize <= 0 { return @@ -63,20 +66,8 @@ func renderNurikabePage(pdf *fpdf.Fpdf, data *pdfexport.NurikabeData) { pdf.SetLineWidth(pdfexport.OuterBorderLineMM) pdf.Rect(startX, startY, blockW, blockH, "D") - ruleY := pdfexport.InstructionY(startY+blockH, pageH, 1) - pdfexport.SetInstructionStyle(pdf) - pdf.SetXY(area.X, ruleY) - pdf.CellFormat( - area.W, - pdfexport.InstructionLineHMM, - "Expand each numbered island to its size; connect all sea cells into one wall.", - "", - 0, - "C", - false, - 0, - "", - ) + ruleY := pdfexport.InstructionY(startY+blockH, pageH, ruleLines) + pdfexport.RenderInstructions(pdf, body.X, ruleY, body.W, rules) } func drawNurikabeClue(pdf *fpdf.Fpdf, x, y, cellSize float64, value int) { diff --git a/nurikabe/testdata/visual_states.jsonl b/nurikabe/testdata/visual_states.jsonl index 381392a..fa4f3c7 100644 --- a/nurikabe/testdata/visual_states.jsonl +++ b/nurikabe/testdata/visual_states.jsonl @@ -1,2 +1,3 @@ -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Nurikabe","mode_selection":"Visual Fixture","count":2,"seed":""},"puzzle":{"index":1,"name":"sea-and-island-marks","game":"Nurikabe","mode":"Visual Fixture","save":{"width":3,"height":3,"clues":"1,0,0\n0,0,0\n0,0,1","marks":"o??\n~?~\n??o","mode_title":"Visual Fixture"}}} -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Nurikabe","mode_selection":"Visual Fixture","count":2,"seed":""},"puzzle":{"index":2,"name":"solved-clue-and-sea","game":"Nurikabe","mode":"Visual Fixture","save":{"width":2,"height":1,"clues":"1,0","marks":"o~","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Nurikabe","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":1,"name":"sea-and-island-marks","game":"Nurikabe","mode":"Visual Fixture","save":{"width":3,"height":3,"clues":"1,0,0\n0,0,0\n0,0,1","marks":"o??\n~?~\n??o","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Nurikabe","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":2,"name":"two-by-two-sea-conflict","game":"Nurikabe","mode":"Visual Fixture","save":{"width":3,"height":3,"clues":"1,0,0\n0,0,0\n0,0,1","marks":"o~~\n?~~\n??o","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Nurikabe","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":3,"name":"solved-clue-and-sea","game":"Nurikabe","mode":"Visual Fixture","save":{"width":2,"height":1,"clues":"1,0","marks":"o~","mode_title":"Visual Fixture"}}} diff --git a/pdfexport/font.go b/pdfexport/font.go index 654e8ad..65f3c72 100644 --- a/pdfexport/font.go +++ b/pdfexport/font.go @@ -1,12 +1,13 @@ package pdfexport const ( - standardCellFontMin = 5.2 - standardCellFontMax = 8.2 + pdfFontSizeDelta = 3.0 + standardCellFontMin = 5.2 + pdfFontSizeDelta + standardCellFontMax = 8.2 + pdfFontSizeDelta ) func standardCellFontSize(cellSize, scale float64) float64 { - return clampStandardCellFontSize(cellSize * scale) + return clampStandardCellFontSize(cellSize*scale + pdfFontSizeDelta) } func clampStandardCellFontSize(fontSize float64) float64 { diff --git a/pdfexport/font_test.go b/pdfexport/font_test.go index dd4aa69..945ef21 100644 --- a/pdfexport/font_test.go +++ b/pdfexport/font_test.go @@ -13,19 +13,19 @@ func TestStandardCellFontSizeBounds(t *testing.T) { name: "clamps low", cellSize: 3.0, scale: 0.6, - want: 5.2, + want: 8.2, }, { name: "keeps in range", cellSize: 10.0, scale: 0.6, - want: 6.0, + want: 9.0, }, { name: "clamps high", cellSize: 20.0, scale: 0.7, - want: 8.2, + want: 11.2, }, } @@ -48,17 +48,17 @@ func TestClampStandardCellFontSizeBounds(t *testing.T) { { name: "below min", in: 3.9, - want: 5.2, + want: 8.2, }, { name: "in range", in: 6.5, - want: 6.5, + want: 8.2, }, { name: "above max", in: 9.1, - want: 8.2, + want: 9.1, }, } diff --git a/pdfexport/render.go b/pdfexport/render.go index 71a45a4..54baa1e 100644 --- a/pdfexport/render.go +++ b/pdfexport/render.go @@ -4,11 +4,11 @@ import ( "fmt" "os" "path/filepath" - "strconv" "strings" "time" "codeberg.org/go-pdf/fpdf" + "codeberg.org/go-pdf/fpdf/contrib/gofpdi" ) func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg RenderConfig) error { @@ -37,73 +37,74 @@ func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg Rend cfg.AdvertText = "Find more puzzles at github.com/FelineStateMachine/puzzletea" } - pdf := fpdf.NewCustom(&fpdf.InitType{ + logicalPages := buildLogicalPages(len(printablePuzzles), sheetLayoutIncludesCover(cfg.SheetLayout)) + + dir := filepath.Dir(outputPath) + if dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create output directory: %w", err) + } + } + + switch cfg.SheetLayout { + case SheetLayoutHalfLetter: + pdf, err := newConfiguredPDF(SheetLayoutHalfLetter, cfg.Title, true) + if err != nil { + return err + } + if err := renderLogicalPages(pdf, logicalPages, docs, printablePuzzles, cfg); err != nil { + return err + } + if got, want := pdf.PageNo(), len(logicalPages); got != want { + return fmt.Errorf("rendered %d physical pages, want %d", got, want) + } + if err := pdf.OutputFileAndClose(outputPath); err != nil { + return fmt.Errorf("write pdf file: %w", err) + } + case SheetLayoutDuplexBooklet: + if err := writeDuplexBookletPDF(outputPath, logicalPages, docs, printablePuzzles, cfg); err != nil { + return err + } + default: + return fmt.Errorf("unsupported sheet layout %d", cfg.SheetLayout) + } + return nil +} + +func newRenderPDF(layout SheetLayout) *fpdf.Fpdf { + init := &fpdf.InitType{ OrientationStr: "P", UnitStr: "mm", Size: fpdf.SizeType{ Wd: halfLetterWidthMM, Ht: halfLetterHeightMM, }, - }) - if err := registerPDFFonts(pdf); err != nil { - return err } - pdf.SetAutoPageBreak(false, 0) - pdf.SetCreator("PuzzleTea", true) - pdf.SetAuthor("PuzzleTea", true) - pdf.SetTitle(cfg.Title, true) - footerExcludedPages := map[int]struct{}{} - pdf.SetFooterFunc(func() { - pageNo := pdf.PageNo() - if _, skip := footerExcludedPages[pageNo]; skip { - return - } - pdf.SetY(-8) - pdf.SetFont(sansFontFamily, "", 8) - pdf.SetTextColor(footerTextGray, footerTextGray, footerTextGray) - pdf.CellFormat(0, 4, strconv.Itoa(pageNo), "", 0, "C", false, 0, "") - }) - - includeCover := cfg.CoverColor != nil - if includeCover { - renderCoverPage(pdf, printablePuzzles, cfg, *cfg.CoverColor) - footerExcludedPages[pdf.PageNo()] = struct{}{} - } - - renderTitlePage(pdf, docs, printablePuzzles, cfg) - footerExcludedPages[pdf.PageNo()] = struct{}{} - - for _, puzzle := range printablePuzzles { - if err := renderPuzzlePage(pdf, puzzle); err != nil { - return fmt.Errorf("render puzzle %q (%s #%d): %w", puzzle.Name, puzzle.Category, puzzle.Index, err) + if layout == SheetLayoutDuplexBooklet { + init.Size = fpdf.SizeType{ + Wd: letterWidthMM, + Ht: letterHeightMM, } } + return fpdf.NewCustom(init) +} - totalPagesWithoutPadding := pdf.PageNo() - if includeCover { - totalPagesWithoutPadding++ // include upcoming back cover - } - for range saddleStitchPadCount(totalPagesWithoutPadding) { - renderPadPage(pdf) - footerExcludedPages[pdf.PageNo()] = struct{}{} - } - - if includeCover { - renderBackCoverPage(pdf, cfg, *cfg.CoverColor) - footerExcludedPages[pdf.PageNo()] = struct{}{} - } - - dir := filepath.Dir(outputPath) - if dir != "" && dir != "." { - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("create output directory: %w", err) +func newConfiguredPDF(layout SheetLayout, title string, registerFonts bool) (*fpdf.Fpdf, error) { + pdf := newRenderPDF(layout) + if registerFonts { + if err := registerPDFFonts(pdf); err != nil { + return nil, err } } + pdf.SetAutoPageBreak(false, 0) + pdf.SetCreator("PuzzleTea", true) + pdf.SetAuthor("PuzzleTea", true) + pdf.SetTitle(title, true) + return pdf, nil +} - if err := pdf.OutputFileAndClose(outputPath); err != nil { - return fmt.Errorf("write pdf file: %w", err) - } - return nil +func sheetLayoutIncludesCover(layout SheetLayout) bool { + return layout == SheetLayoutDuplexBooklet } func saddleStitchPadCount(totalPages int) int { @@ -132,8 +133,130 @@ func filterPrintablePuzzles(puzzles []Puzzle) []Puzzle { return printable } -func renderPadPage(pdf *fpdf.Fpdf) { - pdf.AddPage() +func renderLogicalPages( + pdf *fpdf.Fpdf, + pages []logicalPage, + docs []PackDocument, + puzzles []Puzzle, + cfg RenderConfig, +) error { + for _, page := range pages { + pdf.AddPage() + if err := drawLogicalPage(pdf, page, docs, puzzles, cfg); err != nil { + return err + } + } + return nil +} + +func drawLogicalPage( + pdf *fpdf.Fpdf, + page logicalPage, + docs []PackDocument, + puzzles []Puzzle, + cfg RenderConfig, +) error { + return withLogicalPageNumber(page.Number, func() error { + switch page.Kind { + case logicalPageCoverOutside: + renderOutsideCoverPage(pdf, cfg, page.OutsideSlice) + case logicalPageCoverBlank: + renderCoverBlankPage(pdf) + case logicalPageTitle: + renderTitlePage(pdf, docs, puzzles, cfg) + case logicalPagePuzzle: + if page.PuzzleIndex < 0 || page.PuzzleIndex >= len(puzzles) { + return fmt.Errorf("logical page %d references puzzle index %d", page.Number, page.PuzzleIndex) + } + puzzle := puzzles[page.PuzzleIndex] + if err := renderPuzzlePage(pdf, puzzle); err != nil { + return fmt.Errorf("render puzzle %q (%s #%d): %w", puzzle.Name, puzzle.Category, puzzle.Index, err) + } + case logicalPagePad: + renderPadPage(pdf) + default: + return fmt.Errorf("unsupported logical page kind %d", page.Kind) + } + + if page.ShowFooter { + drawPageFooter(pdf, page.Number) + } + return nil + }) +} + +func renderPadPage(_ *fpdf.Fpdf) {} + +func writeDuplexBookletPDF( + outputPath string, + logicalPages []logicalPage, + docs []PackDocument, + puzzles []Puzzle, + cfg RenderConfig, +) error { + tempFile, err := os.CreateTemp("", "puzzletea-booklet-*.pdf") + if err != nil { + return fmt.Errorf("create temporary booklet pdf: %w", err) + } + tempPath := tempFile.Name() + if err := tempFile.Close(); err != nil { + return fmt.Errorf("close temporary booklet pdf handle: %w", err) + } + defer os.Remove(tempPath) + + sourcePDF, err := newConfiguredPDF(SheetLayoutHalfLetter, cfg.Title, true) + if err != nil { + return err + } + if err := renderLogicalPages(sourcePDF, logicalPages, docs, puzzles, cfg); err != nil { + return err + } + if got, want := sourcePDF.PageNo(), len(logicalPages); got != want { + return fmt.Errorf("rendered %d logical pages, want %d", got, want) + } + if err := sourcePDF.OutputFileAndClose(tempPath); err != nil { + return fmt.Errorf("write temporary booklet pdf: %w", err) + } + + imposedPDF, err := newConfiguredPDF(SheetLayoutDuplexBooklet, cfg.Title, false) + if err != nil { + return err + } + importer := gofpdi.NewImporter() + for _, sheet := range duplexBookletSheets(len(logicalPages)) { + imposedPDF.AddPage() + drawImportedPage(imposedPDF, importer, tempPath, sheet.Front.LeftPage, 0, 0) + drawImportedPage(imposedPDF, importer, tempPath, sheet.Front.RightPage, halfLetterWidthMM, 0) + + imposedPDF.AddPage() + drawImportedPage(imposedPDF, importer, tempPath, sheet.Back.LeftPage, 0, 0) + drawImportedPage(imposedPDF, importer, tempPath, sheet.Back.RightPage, halfLetterWidthMM, 0) + } + if got, want := imposedPDF.PageNo(), len(logicalPages)/2; got != want { + return fmt.Errorf("rendered %d physical pages, want %d", got, want) + } + if err := imposedPDF.OutputFileAndClose(outputPath); err != nil { + return fmt.Errorf("write pdf file: %w", err) + } + return nil +} + +func drawImportedPage( + pdf *fpdf.Fpdf, + importer *gofpdi.Importer, + sourcePath string, + pageNumber int, + x, y float64, +) { + templateID := importer.ImportPage(pdf, sourcePath, pageNumber, "/MediaBox") + importer.UseImportedTemplate(pdf, templateID, x, y, halfLetterWidthMM, halfLetterHeightMM) +} + +func drawPageFooter(pdf *fpdf.Fpdf, pageNo int) { + pdf.SetY(-8) + pdf.SetFont(sansFontFamily, "", 8+pdfFontSizeDelta) + pdf.SetTextColor(footerTextGray, footerTextGray, footerTextGray) + pdf.CellFormat(0, 4, fmt.Sprintf("%d", pageNo), "", 0, "C", false, 0, "") } func renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) error { @@ -145,22 +268,18 @@ func renderPuzzlePage(pdf *fpdf.Fpdf, puzzle Puzzle) error { return nil } - pdf.AddPage() pageW, _ := pdf.GetPageSize() setPuzzleTitleStyle(pdf) pdf.SetXY(0, 10) - title := fmt.Sprintf("%s %d: %s", puzzle.Category, puzzle.Index, puzzle.Name) + title := strings.TrimSpace(puzzle.Category) + if name := strings.TrimSpace(puzzle.Name); name != "" { + title = fmt.Sprintf("%s: %s", title, name) + } pdf.CellFormat(pageW, 7, title, "", 0, "C", false, 0, "") setPuzzleSubtitleStyle(pdf) - pdf.SetXY(0, 17) - subtitleParts := []string{fmt.Sprintf("Difficulty Score: %d/10", difficultyScoreOutOfTen(puzzle.DifficultyScore))} - if !isMixedModes(puzzle.ModeSelection) { - subtitleParts = append([]string{fmt.Sprintf("Mode: %s", puzzle.ModeSelection)}, subtitleParts...) - } - subtitle := strings.Join(subtitleParts, " | ") - pdf.CellFormat(pageW, 5, subtitle, "", 0, "C", false, 0, "") + renderPuzzleDifficultySubtitle(pdf, pageW, 17, puzzle) if err := adapter.RenderPDFBody(pdf, puzzle.PrintPayload); err != nil { return err } diff --git a/pdfexport/render_cover.go b/pdfexport/render_cover.go index 5facd29..1e82f5c 100644 --- a/pdfexport/render_cover.go +++ b/pdfexport/render_cover.go @@ -2,11 +2,131 @@ package pdfexport import ( "fmt" + "math" + "reflect" + "sort" "strings" "codeberg.org/go-pdf/fpdf" ) +type coverOutsideSlice int + +const ( + coverOutsideBack coverOutsideSlice = iota + coverOutsideFront +) + +type coverCompositionFamily int + +const ( + coverFamilyFrame coverCompositionFamily = iota + coverFamilyStack + coverFamilyHinge + coverFamilyIsland +) + +type coverDirection int + +const ( + coverDirectionVertical coverDirection = iota + coverDirectionHorizontal + coverDirectionDiagonal + coverDirectionClustered +) + +type coverAspectBucket int + +const ( + coverAspectTall coverAspectBucket = iota + coverAspectWide + coverAspectCompact + coverAspectOffset +) + +type coverTextureKind int + +const ( + coverTextureNone coverTextureKind = iota + coverTextureStripes + coverTextureHatch + coverTextureDots + coverTextureChecker + coverTextureLattice +) + +type coverFillMode int + +const ( + coverFillSolid coverFillMode = iota + coverFillSlits + coverFillOpenGrid + coverFillPunctured + coverFillBanded +) + +type coverCutoutKind int + +const ( + coverCutoutRect coverCutoutKind = iota + coverCutoutCircle +) + +type coverShapeKind int + +const ( + coverShapeRect coverShapeKind = iota + coverShapeCircle + coverShapeCroppedCircle + coverShapeStepped +) + +type coverShapeEdge int + +const ( + coverEdgeLeft coverShapeEdge = iota + coverEdgeRight + coverEdgeTop + coverEdgeBottom +) + +type coverCutout struct { + Kind coverCutoutKind + X float64 + Y float64 + W float64 + H float64 + R float64 +} + +type coverShape struct { + Kind coverShapeKind + Edge coverShapeEdge + Bounds rectMM + PivotX float64 + PivotY float64 + Rotate float64 + Locked bool + Fill coverFillMode + Texture coverTextureKind + Step float64 + Weight float64 + Cutouts []coverCutout +} + +type coverArtLayout struct { + ArtArea rectMM + LockupExclusion rectMM + Family coverCompositionFamily + Direction coverDirection + Shapes []coverShape +} + +type coverRand interface { + Float64() float64 + IntN(int) int +} + func splitCoverTextLines(pdf *fpdf.Fpdf, text string, maxW float64) []string { trimmed := strings.TrimSpace(text) if trimmed == "" { @@ -43,91 +163,1124 @@ func splitCoverSubtitleLines(pdf *fpdf.Fpdf, subtitle string, maxW float64, maxL return splitClampedTextLines(pdf, subtitle, maxW, maxLines) } -func renderCoverPage(pdf *fpdf.Fpdf, _ []Puzzle, cfg RenderConfig, coverColor RGB) { - ink := RGB{R: 8, G: 8, B: 8} +func renderOutsideCoverPage(pdf *fpdf.Fpdf, cfg RenderConfig, slice coverOutsideSlice) { + const frameInset = 7.5 - pdf.AddPage() pageW, pageH := pdf.GetPageSize() + drawOutsideSpreadFrameSlice(pdf, slice, frameInset, pageW, pageH) + + if slice == coverOutsideFront { + layout := buildCoverArtLayout(cfg, pageW, pageH) + drawCoverArtLayout(pdf, layout) + renderFrontCoverLockup(pdf, cfg, pageW, frameInset) + return + } - pdf.SetFillColor(int(coverColor.R), int(coverColor.G), int(coverColor.B)) - pdf.Rect(0, 0, pageW, pageH, "F") + renderBackCoverImprint(pdf, cfg, pageW, pageH, frameInset) +} + +func renderCoverBlankPage(_ *fpdf.Fpdf) {} + +func drawOutsideSpreadFrameSlice(pdf *fpdf.Fpdf, slice coverOutsideSlice, inset, pageW, pageH float64) { + drawCoverSpreadBorder(pdf, slice, inset, 1.1, pageW, pageH) + drawCoverSpreadBorder(pdf, slice, inset+1.7, 0.28, pageW, pageH) +} + +func drawCoverSpreadBorder(pdf *fpdf.Fpdf, slice coverOutsideSlice, inset, lineW, pageW, pageH float64) { + pdf.SetDrawColor(8, 8, 8) + pdf.SetLineWidth(lineW) - frameInset := 7.5 - drawCoverFrame(pdf, frameInset, pageW, pageH, ink) + leftX, rightX := coverSliceHorizontalSpan(slice, inset, pageW) + pdf.Line(leftX, inset, rightX, inset) + pdf.Line(leftX, pageH-inset, rightX, pageH-inset) + + if slice == coverOutsideBack { + pdf.Line(inset, inset, inset, pageH-inset) + return + } + pdf.Line(pageW-inset, inset, pageW-inset, pageH-inset) +} + +func coverSliceHorizontalSpan(slice coverOutsideSlice, inset, pageW float64) (left, right float64) { + if slice == coverOutsideBack { + return inset, pageW + } + return 0, pageW - inset +} + +func renderFrontCoverLockup(pdf *fpdf.Fpdf, cfg RenderConfig, pageW, frameInset float64) { + labelW := min(pageW*0.54, 74.0) + labelX := pageW - frameInset - 7.0 - labelW subtitle := strings.TrimSpace(cfg.CoverSubtitle) if subtitle == "" { subtitle = "PuzzleTea Collection" } - labelW := pageW - 2*(frameInset+6.0) - fontSize := 20.0 - for fontSize >= 13.0 { + fontSize := 18.0 + pdfFontSizeDelta + for fontSize >= 13.5+pdfFontSizeDelta { pdf.SetFont(coverFontFamily, "", fontSize) - if len(splitCoverTextLines(pdf, subtitle, labelW)) <= 2 { + if len(splitCoverSubtitleLines(pdf, subtitle, labelW, 3)) <= 3 { break } - fontSize -= 1.0 + fontSize -= 0.8 } - pdf.SetFont(sansFontFamily, "B", 9.8) - pdf.SetTextColor(int(ink.R), int(ink.G), int(ink.B)) - pdf.SetXY(frameInset+6.0, frameInset+2.8) - pdf.CellFormat(labelW, 5.0, fmt.Sprintf("VOL. %02d", cfg.VolumeNumber), "", 0, "L", false, 0, "") + pdf.SetTextColor(8, 8, 8) + pdf.SetFont(sansFontFamily, "B", 9.6+pdfFontSizeDelta) + pdf.SetXY(labelX, frameInset+5.0) + pdf.CellFormat(labelW, 4.5, fmt.Sprintf("VOL. %02d", cfg.VolumeNumber), "", 0, "R", false, 0, "") pdf.SetFont(coverFontFamily, "", fontSize) - titleLines := splitCoverSubtitleLines(pdf, subtitle, labelW, 2) - lineH := fontSize * 0.45 - y := frameInset + 12.0 - for _, line := range titleLines { - pdf.SetXY(frameInset+6.0, y) - pdf.CellFormat(labelW, lineH, line, "", 0, "L", false, 0, "") + lineH := fontSize * 0.44 + y := frameInset + 13.2 + for _, line := range splitCoverSubtitleLines(pdf, subtitle, labelW, 3) { + pdf.SetXY(labelX, y) + pdf.CellFormat(labelW, lineH, line, "", 0, "R", false, 0, "") y += lineH } +} - pdf.SetFont(sansFontFamily, "B", 7.8) - pdf.SetXY(frameInset+6.0, pageH-frameInset-7.0) +func renderBackCoverImprint(pdf *fpdf.Fpdf, cfg RenderConfig, pageW, pageH, frameInset float64) { + labelX := frameInset + 6.0 + labelW := min(pageW*0.42, 52.0) + + pdf.SetTextColor(8, 8, 8) + pdf.SetFont(sansFontFamily, "B", 8.2+pdfFontSizeDelta) + pdf.SetXY(labelX, pageH-frameInset-13.4) pdf.CellFormat(labelW, 4.0, "PuzzleTea", "", 0, "L", false, 0, "") + + pdf.SetFont(sansFontFamily, "", 7.3+pdfFontSizeDelta) + pdf.SetXY(labelX, pageH-frameInset-8.5) + pdf.CellFormat(labelW, 3.8, fmt.Sprintf("Vol. %02d", cfg.VolumeNumber), "", 0, "L", false, 0, "") } -func drawCoverFrame(pdf *fpdf.Fpdf, inset, pageW, pageH float64, ink RGB) { - pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B)) - pdf.SetLineWidth(1.1) - pdf.Rect(inset, inset, pageW-2*inset, pageH-2*inset, "D") +func buildCoverArtLayout(cfg RenderConfig, pageW, pageH float64) coverArtLayout { + rng := seededRand(fmt.Sprintf("cover:%s:%d", cfg.ShuffleSeed, cfg.VolumeNumber)) + layout := coverArtLayout{ + ArtArea: coverArtArea(pageW, pageH), + LockupExclusion: coverLockupExclusion(pageW), + Family: coverCompositionFamily(rng.IntN(4)), + } + + switch layout.Family { + case coverFamilyFrame: + layout.Direction = buildFrameFamily(&layout, rng) + case coverFamilyStack: + layout.Direction = buildStackFamily(&layout, rng) + case coverFamilyHinge: + layout.Direction = buildHingeFamily(&layout, rng) + default: + layout.Direction = buildIslandFamily(&layout, rng) + } - pdf.SetLineWidth(0.28) - inner := inset + 1.7 - pdf.Rect(inner, inner, pageW-2*inner, pageH-2*inner, "D") + return layout } -func renderBackCoverPage(pdf *fpdf.Fpdf, cfg RenderConfig, coverColor RGB) { - ink := RGB{R: 8, G: 8, B: 8} +func coverArtArea(pageW, pageH float64) rectMM { + return rectMM{ + x: 12.5, + y: 37.0, + w: pageW - 25.0, + h: pageH - 56.0, + } +} - pdf.AddPage() - pageW, pageH := pdf.GetPageSize() - pdf.SetFillColor(int(coverColor.R), int(coverColor.G), int(coverColor.B)) - pdf.Rect(0, 0, pageW, pageH, "F") - - frameInset := 7.5 - drawCoverFrame(pdf, frameInset, pageW, pageH, ink) - - labelW := pageW - 2*(frameInset+6.0) - pdf.SetTextColor(int(ink.R), int(ink.G), int(ink.B)) - pdf.SetFont(sansFontFamily, "B", 8.4) - pdf.SetXY(frameInset+6.0, pageH-frameInset-23.0) - pdf.CellFormat(labelW, 4.2, "PuzzleTea", "", 0, "L", false, 0, "") - - pdf.SetFont(sansFontFamily, "", 8.0) - advertLines := splitCoverTextLines(pdf, cfg.AdvertText, labelW) - advertLineH := 4.2 - advertY := pageH - frameInset - 17.0 - float64(len(advertLines)-1)*advertLineH - for _, line := range advertLines { - pdf.SetXY(frameInset+6.0, advertY) - pdf.CellFormat(labelW, advertLineH, line, "", 0, "L", false, 0, "") - advertY += advertLineH - } - - pdf.SetFont(sansFontFamily, "B", 8.2) - pdf.SetXY(frameInset+6.0, pageH-frameInset-10.5) - pdf.CellFormat(labelW, 4.2, fmt.Sprintf("VOL. %02d", cfg.VolumeNumber), "", 0, "L", false, 0, "") +func coverLockupExclusion(pageW float64) rectMM { + return rectMM{ + x: pageW - 89.0, + y: 8.0, + w: 78.0, + h: 32.0, + } +} + +func buildFrameFamily(layout *coverArtLayout, rng coverRand) coverDirection { + outer := rectMM{ + x: layout.ArtArea.x + layout.ArtArea.w*(0.06+rng.Float64()*0.14), + y: layout.ArtArea.y + layout.ArtArea.h*(0.08+rng.Float64()*0.10), + w: layout.ArtArea.w * (0.42 + rng.Float64()*0.20), + h: layout.ArtArea.h * (0.48 + rng.Float64()*0.20), + } + thickness := min(outer.w, outer.h) * (0.16 + rng.Float64()*0.07) + openSide := coverShapeEdge(rng.IntN(4)) + direction := coverDirectionVertical + if outer.w > outer.h { + direction = coverDirectionHorizontal + } + + add := func(kind coverShapeKind, edge coverShapeEdge, bounds rectMM) { + layout.Shapes = append(layout.Shapes, decoratedCoverShape(layout, kind, edge, bounds, coverFamilyFrame, true, rng)) + } + + if openSide != coverEdgeTop { + add(shapeFamilyKind(rng, coverShapeRect, coverShapeStepped), coverEdgeTop, rectMM{ + x: outer.x, + y: outer.y, + w: outer.w, + h: thickness, + }) + } + if openSide != coverEdgeLeft { + add(shapeFamilyKind(rng, coverShapeRect, coverShapeStepped), coverEdgeLeft, rectMM{ + x: outer.x, + y: outer.y + thickness*0.55, + w: thickness * (0.9 + rng.Float64()*0.35), + h: outer.h - thickness*0.55, + }) + } + if openSide != coverEdgeRight { + add(shapeFamilyKind(rng, coverShapeRect, coverShapeStepped), coverEdgeRight, rectMM{ + x: outer.x + outer.w - thickness*(0.9+rng.Float64()*0.35), + y: outer.y + thickness*0.35, + w: thickness * (0.9 + rng.Float64()*0.35), + h: outer.h - thickness*0.35, + }) + } + if openSide != coverEdgeBottom { + add(shapeFamilyKind(rng, coverShapeRect, coverShapeStepped), coverEdgeBottom, rectMM{ + x: outer.x + outer.w*(0.04+rng.Float64()*0.10), + y: outer.y + outer.h - thickness*(0.95+rng.Float64()*0.25), + w: outer.w * (0.56 + rng.Float64()*0.20), + h: thickness * (0.95 + rng.Float64()*0.25), + }) + } + + if rng.IntN(2) == 0 { + layout.Shapes = append(layout.Shapes, decoratedCoverShape(layout, coverShapeCroppedCircle, oppositeEdge(openSide), rectMM{ + x: outer.x + outer.w*(0.52+rng.Float64()*0.16), + y: outer.y + outer.h*(0.48+rng.Float64()*0.16), + w: outer.w * (0.16 + rng.Float64()*0.12), + h: outer.w * (0.16 + rng.Float64()*0.12), + }, coverFamilyFrame, false, rng)) + } + + return direction +} + +func buildStackFamily(layout *coverArtLayout, rng coverRand) coverDirection { + vertical := rng.IntN(2) == 0 + if vertical { + x := layout.ArtArea.x + layout.ArtArea.w*(0.06+rng.Float64()*0.18) + y := layout.ArtArea.y + layout.ArtArea.h*(0.08+rng.Float64()*0.10) + widths := []float64{ + layout.ArtArea.w * (0.26 + rng.Float64()*0.10), + layout.ArtArea.w * (0.48 + rng.Float64()*0.16), + layout.ArtArea.w * (0.34 + rng.Float64()*0.16), + } + heights := []float64{ + layout.ArtArea.h * (0.18 + rng.Float64()*0.08), + layout.ArtArea.h * (0.22 + rng.Float64()*0.10), + layout.ArtArea.h * (0.16 + rng.Float64()*0.08), + } + gap := 4.0 + rng.Float64()*4.0 + curY := y + for i := range widths { + bounds := rectMM{x: x, y: curY, w: widths[i], h: heights[i]} + kind := coverShapeRect + if i == 0 && rng.IntN(2) == 0 { + kind = coverShapeStepped + } + layout.Shapes = append(layout.Shapes, decoratedCoverShape(layout, kind, coverEdgeLeft, bounds, coverFamilyStack, false, rng)) + curY += heights[i] + gap + } + layout.Shapes = append(layout.Shapes, decoratedCoverShape(layout, coverShapeCircle, coverEdgeRight, rectMM{ + x: x + layout.ArtArea.w*(0.44+rng.Float64()*0.12), + y: y + layout.ArtArea.h*(0.18+rng.Float64()*0.18), + w: layout.ArtArea.w * (0.16 + rng.Float64()*0.10), + h: layout.ArtArea.w * (0.16 + rng.Float64()*0.10), + }, coverFamilyStack, false, rng)) + return coverDirectionVertical + } + + x := layout.ArtArea.x + layout.ArtArea.w*(0.06+rng.Float64()*0.10) + y := layout.ArtArea.y + layout.ArtArea.h*(0.14+rng.Float64()*0.16) + widths := []float64{ + layout.ArtArea.w * (0.18 + rng.Float64()*0.10), + layout.ArtArea.w * (0.30 + rng.Float64()*0.12), + layout.ArtArea.w * (0.20 + rng.Float64()*0.08), + } + heights := []float64{ + layout.ArtArea.h * (0.28 + rng.Float64()*0.12), + layout.ArtArea.h * (0.18 + rng.Float64()*0.08), + layout.ArtArea.h * (0.32 + rng.Float64()*0.10), + } + gap := 4.0 + rng.Float64()*4.0 + curX := x + for i := range widths { + bounds := rectMM{x: curX, y: y, w: widths[i], h: heights[i]} + kind := coverShapeRect + if i == 1 && rng.IntN(2) == 0 { + kind = coverShapeStepped + } + layout.Shapes = append(layout.Shapes, decoratedCoverShape(layout, kind, coverEdgeBottom, bounds, coverFamilyStack, false, rng)) + curX += widths[i] + gap + } + layout.Shapes = append(layout.Shapes, decoratedCoverShape(layout, coverShapeCroppedCircle, coverEdgeBottom, rectMM{ + x: x + layout.ArtArea.w*(0.30+rng.Float64()*0.18), + y: y + layout.ArtArea.h*(0.42+rng.Float64()*0.10), + w: layout.ArtArea.w * (0.18 + rng.Float64()*0.09), + h: layout.ArtArea.w * (0.18 + rng.Float64()*0.09), + }, coverFamilyStack, false, rng)) + return coverDirectionHorizontal +} + +func buildHingeFamily(layout *coverArtLayout, rng coverRand) coverDirection { + baseX := layout.ArtArea.x + layout.ArtArea.w*(0.06+rng.Float64()*0.08) + baseY := layout.ArtArea.y + layout.ArtArea.h*(0.42+rng.Float64()*0.12) + upperX := layout.ArtArea.x + layout.ArtArea.w*(0.48+rng.Float64()*0.10) + upperY := layout.ArtArea.y + layout.ArtArea.h*(0.08+rng.Float64()*0.10) + + layout.Shapes = append(layout.Shapes, + decoratedCoverShape(layout, shapeFamilyKind(rng, coverShapeRect, coverShapeStepped), coverEdgeBottom, rectMM{ + x: baseX, + y: baseY, + w: layout.ArtArea.w * (0.30 + rng.Float64()*0.10), + h: layout.ArtArea.h * (0.26 + rng.Float64()*0.10), + }, coverFamilyHinge, false, rng), + ) + layout.Shapes = append(layout.Shapes, + decoratedCoverShape(layout, shapeFamilyKind(rng, coverShapeRect, coverShapeCroppedCircle), coverEdgeTop, rectMM{ + x: upperX, + y: upperY, + w: layout.ArtArea.w * (0.24 + rng.Float64()*0.12), + h: layout.ArtArea.h * (0.26 + rng.Float64()*0.10), + }, coverFamilyHinge, false, rng), + ) + layout.Shapes = append(layout.Shapes, + decoratedCoverShape(layout, coverShapeRect, coverEdgeRight, rectMM{ + x: layout.ArtArea.x + layout.ArtArea.w*(0.38+rng.Float64()*0.08), + y: layout.ArtArea.y + layout.ArtArea.h*(0.28+rng.Float64()*0.10), + w: layout.ArtArea.w * (0.09 + rng.Float64()*0.04), + h: layout.ArtArea.h * (0.34 + rng.Float64()*0.12), + }, coverFamilyHinge, true, rng), + ) + layout.Shapes = append(layout.Shapes, + decoratedCoverShape(layout, coverShapeCircle, coverEdgeRight, rectMM{ + x: layout.ArtArea.x + layout.ArtArea.w*(0.34+rng.Float64()*0.10), + y: layout.ArtArea.y + layout.ArtArea.h*(0.22+rng.Float64()*0.12), + w: layout.ArtArea.w * (0.12 + rng.Float64()*0.08), + h: layout.ArtArea.w * (0.12 + rng.Float64()*0.08), + }, coverFamilyHinge, false, rng), + ) + return coverDirectionDiagonal +} + +func buildIslandFamily(layout *coverArtLayout, rng coverRand) coverDirection { + clustered := rng.IntN(2) == 0 + layout.Shapes = append(layout.Shapes, + decoratedCoverShape(layout, shapeFamilyKind(rng, coverShapeStepped, coverShapeCircle), coverEdgeLeft, rectMM{ + x: layout.ArtArea.x + layout.ArtArea.w*(0.06+rng.Float64()*0.14), + y: layout.ArtArea.y + layout.ArtArea.h*(0.34+rng.Float64()*0.16), + w: layout.ArtArea.w * (0.30 + rng.Float64()*0.14), + h: layout.ArtArea.h * (0.24 + rng.Float64()*0.14), + }, coverFamilyIsland, false, rng), + ) + + if clustered { + layout.Shapes = append(layout.Shapes, + decoratedCoverShape(layout, coverShapeCircle, coverEdgeTop, rectMM{ + x: layout.ArtArea.x + layout.ArtArea.w*(0.40+rng.Float64()*0.14), + y: layout.ArtArea.y + layout.ArtArea.h*(0.18+rng.Float64()*0.14), + w: layout.ArtArea.w * (0.14 + rng.Float64()*0.10), + h: layout.ArtArea.w * (0.14 + rng.Float64()*0.10), + }, coverFamilyIsland, false, rng), + decoratedCoverShape(layout, coverShapeRect, coverEdgeBottom, rectMM{ + x: layout.ArtArea.x + layout.ArtArea.w*(0.46+rng.Float64()*0.14), + y: layout.ArtArea.y + layout.ArtArea.h*(0.48+rng.Float64()*0.12), + w: layout.ArtArea.w * (0.18 + rng.Float64()*0.10), + h: layout.ArtArea.h * (0.18 + rng.Float64()*0.10), + }, coverFamilyIsland, false, rng), + decoratedCoverShape(layout, coverShapeCroppedCircle, coverEdgeRight, rectMM{ + x: layout.ArtArea.x + layout.ArtArea.w*(0.28+rng.Float64()*0.18), + y: layout.ArtArea.y + layout.ArtArea.h*(0.06+rng.Float64()*0.10), + w: layout.ArtArea.w * (0.12 + rng.Float64()*0.08), + h: layout.ArtArea.w * (0.12 + rng.Float64()*0.08), + }, coverFamilyIsland, false, rng), + ) + return coverDirectionClustered + } + + layout.Shapes = append(layout.Shapes, + decoratedCoverShape(layout, coverShapeCircle, coverEdgeTop, rectMM{ + x: layout.ArtArea.x + layout.ArtArea.w*(0.34+rng.Float64()*0.10), + y: layout.ArtArea.y + layout.ArtArea.h*(0.18+rng.Float64()*0.08), + w: layout.ArtArea.w * (0.16 + rng.Float64()*0.10), + h: layout.ArtArea.w * (0.16 + rng.Float64()*0.10), + }, coverFamilyIsland, false, rng), + decoratedCoverShape(layout, coverShapeRect, coverEdgeBottom, rectMM{ + x: layout.ArtArea.x + layout.ArtArea.w*(0.56+rng.Float64()*0.10), + y: layout.ArtArea.y + layout.ArtArea.h*(0.48+rng.Float64()*0.08), + w: layout.ArtArea.w * (0.18 + rng.Float64()*0.10), + h: layout.ArtArea.h * (0.20 + rng.Float64()*0.10), + }, coverFamilyIsland, false, rng), + ) + return coverDirectionDiagonal +} + +func shapeFamilyKind(rng coverRand, a, b coverShapeKind) coverShapeKind { + if rng.IntN(2) == 0 { + return a + } + return b +} + +func oppositeEdge(edge coverShapeEdge) coverShapeEdge { + switch edge { + case coverEdgeLeft: + return coverEdgeRight + case coverEdgeRight: + return coverEdgeLeft + case coverEdgeTop: + return coverEdgeBottom + default: + return coverEdgeTop + } +} + +func decoratedCoverShape( + layout *coverArtLayout, + kind coverShapeKind, + edge coverShapeEdge, + bounds rectMM, + family coverCompositionFamily, + locked bool, + rng coverRand, +) coverShape { + shape := coverShape{ + Kind: kind, + Edge: edge, + Bounds: bounds, + Locked: locked, + Fill: pickCoverFillMode(family, rng), + Texture: pickCoverTexture(family, rng), + Step: 4.0 + rng.Float64()*3.8, + Weight: 0.7 + rng.Float64()*0.8, + } + shape.Rotate = pickCoverRotationDeg(locked, rng) + shape = sanitizeCoverShape(shape, layout.ArtArea, layout.LockupExclusion) + shape.Cutouts = buildFamilyCutouts(family, shape, rng) + return shape +} + +func pickCoverRotationDeg(locked bool, rng coverRand) float64 { + minDeg := 8.0 + maxDeg := 20.0 + if locked { + minDeg = 4.0 + maxDeg = 10.0 + } + angle := minDeg + rng.Float64()*(maxDeg-minDeg) + if rng.IntN(2) == 0 { + return -angle + } + return angle +} + +func sanitizeCoverShape(shape coverShape, artArea, exclusion rectMM) coverShape { + if shape.Bounds.w < 8 { + shape.Bounds.w = 8 + } + if shape.Bounds.h < 8 { + shape.Bounds.h = 8 + } + if shape.Bounds.w > artArea.w { + shape.Bounds.w = artArea.w + } + if shape.Bounds.h > artArea.h { + shape.Bounds.h = artArea.h + } + + if shape.Bounds.x < artArea.x { + shape.Bounds.x = artArea.x + } + if shape.Bounds.y < artArea.y { + shape.Bounds.y = artArea.y + } + if shape.Bounds.x+shape.Bounds.w > artArea.x+artArea.w { + shape.Bounds.x = artArea.x + artArea.w - shape.Bounds.w + } + if shape.Bounds.y+shape.Bounds.h > artArea.y+artArea.h { + shape.Bounds.y = artArea.y + artArea.h - shape.Bounds.h + } + + shape = withCoverShapePivot(shape) + + for range 4 { + env := coverShapeEnvelope(shape) + dx := 0.0 + dy := 0.0 + + if env.x < artArea.x { + dx += artArea.x - env.x + } + if env.x+env.w > artArea.x+artArea.w { + dx -= env.x + env.w - (artArea.x + artArea.w) + } + if env.y < artArea.y { + dy += artArea.y - env.y + } + if env.y+env.h > artArea.y+artArea.h { + dy -= env.y + env.h - (artArea.y + artArea.h) + } + + if dx != 0 || dy != 0 { + shape.Bounds.x += dx + shape.Bounds.y += dy + shape = withCoverShapePivot(shape) + continue + } + + if rectsIntersect(env, exclusion) { + clearBelow := exclusion.y + exclusion.h + 5.0 + if shape.Bounds.y < clearBelow { + shape.Bounds.y += clearBelow - shape.Bounds.y + } + shape = withCoverShapePivot(shape) + env = coverShapeEnvelope(shape) + if rectsIntersect(env, exclusion) { + targetRight := exclusion.x - 5.0 + shape.Bounds.x -= env.x + env.w - targetRight + shape = withCoverShapePivot(shape) + } + continue + } + + break + } + + return shape +} + +func withCoverShapePivot(shape coverShape) coverShape { + shape.PivotX = shape.Bounds.x + shape.Bounds.w*0.5 + shape.PivotY = shape.Bounds.y + shape.Bounds.h*0.5 + return shape +} + +func pickCoverFillMode(family coverCompositionFamily, rng coverRand) coverFillMode { + switch family { + case coverFamilyFrame: + return []coverFillMode{coverFillSolid, coverFillSlits, coverFillOpenGrid, coverFillBanded}[rng.IntN(4)] + case coverFamilyStack: + return []coverFillMode{coverFillSolid, coverFillBanded, coverFillSlits, coverFillOpenGrid}[rng.IntN(4)] + case coverFamilyHinge: + return []coverFillMode{coverFillSolid, coverFillBanded, coverFillPunctured, coverFillSlits}[rng.IntN(4)] + default: + return []coverFillMode{coverFillPunctured, coverFillOpenGrid, coverFillSolid, coverFillBanded}[rng.IntN(4)] + } +} + +func pickCoverTexture(family coverCompositionFamily, rng coverRand) coverTextureKind { + switch family { + case coverFamilyFrame: + return []coverTextureKind{coverTextureNone, coverTextureStripes, coverTextureChecker, coverTextureDots, coverTextureLattice}[rng.IntN(5)] + case coverFamilyStack: + return []coverTextureKind{coverTextureNone, coverTextureStripes, coverTextureHatch, coverTextureChecker, coverTextureLattice}[rng.IntN(5)] + case coverFamilyHinge: + return []coverTextureKind{coverTextureNone, coverTextureHatch, coverTextureStripes, coverTextureDots, coverTextureLattice}[rng.IntN(5)] + default: + return []coverTextureKind{coverTextureNone, coverTextureDots, coverTextureChecker, coverTextureHatch, coverTextureStripes}[rng.IntN(5)] + } +} + +func buildFamilyCutouts(family coverCompositionFamily, shape coverShape, rng coverRand) []coverCutout { + switch family { + case coverFamilyFrame: + return buildFrameCutouts(shape, rng) + case coverFamilyStack: + return buildStackCutouts(shape, rng) + case coverFamilyHinge: + return buildHingeCutouts(shape, rng) + default: + return buildIslandCutouts(shape, rng) + } +} + +func buildFrameCutouts(shape coverShape, rng coverRand) []coverCutout { + cutouts := []coverCutout{ + { + Kind: coverCutoutRect, + X: shape.Bounds.x + shape.Bounds.w*(0.14+rng.Float64()*0.32), + Y: shape.Bounds.y + shape.Bounds.h*(0.18+rng.Float64()*0.40), + W: shape.Bounds.w * (0.12 + rng.Float64()*0.18), + H: shape.Bounds.h * (0.12 + rng.Float64()*0.22), + }, + } + if rng.IntN(2) == 0 { + cutouts = append(cutouts, coverCutout{ + Kind: coverCutoutCircle, + X: shape.Bounds.x + shape.Bounds.w*(0.26+rng.Float64()*0.48), + Y: shape.Bounds.y + shape.Bounds.h*(0.22+rng.Float64()*0.48), + R: min(shape.Bounds.w, shape.Bounds.h) * (0.08 + rng.Float64()*0.10), + }) + } + return cutouts +} + +func buildStackCutouts(shape coverShape, rng coverRand) []coverCutout { + if rng.IntN(2) == 0 { + return []coverCutout{ + { + Kind: coverCutoutRect, + X: shape.Bounds.x + shape.Bounds.w*(0.10+rng.Float64()*0.12), + Y: shape.Bounds.y + shape.Bounds.h*(0.36+rng.Float64()*0.18), + W: shape.Bounds.w * (0.42 + rng.Float64()*0.22), + H: shape.Bounds.h * (0.10 + rng.Float64()*0.10), + }, + } + } + return []coverCutout{ + { + Kind: coverCutoutRect, + X: shape.Bounds.x + shape.Bounds.w*(0.36+rng.Float64()*0.18), + Y: shape.Bounds.y + shape.Bounds.h*(0.08+rng.Float64()*0.12), + W: shape.Bounds.w * (0.12 + rng.Float64()*0.12), + H: shape.Bounds.h * (0.34 + rng.Float64()*0.20), + }, + } +} + +func buildHingeCutouts(shape coverShape, rng coverRand) []coverCutout { + cutouts := []coverCutout{ + { + Kind: coverCutoutCircle, + X: shape.Bounds.x + shape.Bounds.w*(0.34+rng.Float64()*0.24), + Y: shape.Bounds.y + shape.Bounds.h*(0.30+rng.Float64()*0.26), + R: min(shape.Bounds.w, shape.Bounds.h) * (0.08 + rng.Float64()*0.08), + }, + } + if rng.IntN(2) == 0 { + cutouts = append(cutouts, coverCutout{ + Kind: coverCutoutRect, + X: shape.Bounds.x + shape.Bounds.w*(0.58+rng.Float64()*0.10), + Y: shape.Bounds.y + shape.Bounds.h*(0.56+rng.Float64()*0.10), + W: shape.Bounds.w * (0.18 + rng.Float64()*0.12), + H: shape.Bounds.h * (0.14 + rng.Float64()*0.10), + }) + } + return cutouts +} + +func buildIslandCutouts(shape coverShape, rng coverRand) []coverCutout { + return []coverCutout{ + { + Kind: coverCutoutCircle, + X: shape.Bounds.x + shape.Bounds.w*(0.18+rng.Float64()*0.54), + Y: shape.Bounds.y + shape.Bounds.h*(0.18+rng.Float64()*0.54), + R: min(shape.Bounds.w, shape.Bounds.h) * (0.10 + rng.Float64()*0.10), + }, + } +} + +func drawCoverArtLayout(pdf *fpdf.Fpdf, layout coverArtLayout) { + for _, shape := range layout.Shapes { + drawCoverShape(pdf, shape) + } +} + +func drawCoverShape(pdf *fpdf.Fpdf, shape coverShape) { + pdf.TransformBegin() + pdf.TransformRotate(shape.Rotate, shape.PivotX, shape.PivotY) + switch shape.Kind { + case coverShapeCircle: + drawCircleCoverShape(pdf, shape) + case coverShapeCroppedCircle: + drawCroppedCircleCoverShape(pdf, shape) + case coverShapeStepped: + drawSteppedCoverShape(pdf, shape) + default: + drawRectCoverShape(pdf, shape) + } + pdf.TransformEnd() +} + +func drawRectCoverShape(pdf *fpdf.Fpdf, shape coverShape) { + pdf.SetFillColor(8, 8, 8) + pdf.Rect(shape.Bounds.x, shape.Bounds.y, shape.Bounds.w, shape.Bounds.h, "F") + pdf.ClipRect(shape.Bounds.x, shape.Bounds.y, shape.Bounds.w, shape.Bounds.h, false) + drawCoverFill(pdf, shape) + drawCoverTexture(pdf, shape) + pdf.ClipEnd() + drawCoverCutouts(pdf, shape.Cutouts) +} + +func drawCircleCoverShape(pdf *fpdf.Fpdf, shape coverShape) { + cx, cy, r := coverCircleMetrics(shape.Bounds) + pdf.SetFillColor(8, 8, 8) + pdf.Circle(cx, cy, r, "F") + pdf.ClipCircle(cx, cy, r, false) + drawCoverFill(pdf, shape) + drawCoverTexture(pdf, shape) + pdf.ClipEnd() + drawCoverCutouts(pdf, shape.Cutouts) +} + +func drawCroppedCircleCoverShape(pdf *fpdf.Fpdf, shape coverShape) { + cx, cy, r := croppedCircleMetrics(shape) + pdf.SetFillColor(8, 8, 8) + pdf.ClipRect(shape.Bounds.x, shape.Bounds.y, shape.Bounds.w, shape.Bounds.h, false) + pdf.Circle(cx, cy, r, "F") + pdf.ClipCircle(cx, cy, r, false) + drawCoverFill(pdf, shape) + drawCoverTexture(pdf, shape) + pdf.ClipEnd() + pdf.ClipEnd() + drawCoverCutouts(pdf, shape.Cutouts) +} + +func drawSteppedCoverShape(pdf *fpdf.Fpdf, shape coverShape) { + points := steppedShapePoints(shape) + pdf.SetFillColor(8, 8, 8) + pdf.Polygon(points, "F") + pdf.ClipPolygon(points, false) + drawCoverFill(pdf, shape) + drawCoverTexture(pdf, shape) + pdf.ClipEnd() + drawCoverCutouts(pdf, shape.Cutouts) +} + +func coverCircleMetrics(bounds rectMM) (cx, cy, r float64) { + r = min(bounds.w, bounds.h) * 0.5 + cx = bounds.x + bounds.w*0.5 + cy = bounds.y + bounds.h*0.5 + return cx, cy, r +} + +func croppedCircleMetrics(shape coverShape) (cx, cy, r float64) { + cx, cy, _ = coverCircleMetrics(shape.Bounds) + r = max(shape.Bounds.w, shape.Bounds.h) * 0.56 + switch shape.Edge { + case coverEdgeLeft: + cx -= shape.Bounds.w * 0.18 + case coverEdgeRight: + cx += shape.Bounds.w * 0.18 + case coverEdgeTop: + cy -= shape.Bounds.h * 0.18 + default: + cy += shape.Bounds.h * 0.18 + } + return cx, cy, r +} + +func steppedShapePoints(shape coverShape) []fpdf.PointType { + b := shape.Bounds + notch := min(b.w, b.h) * 0.18 + step1 := min(b.w, b.h) * 0.10 + step2 := min(b.w, b.h) * 0.20 + + switch shape.Edge { + case coverEdgeLeft: + return []fpdf.PointType{ + {X: b.x + notch, Y: b.y}, + {X: b.x + b.w, Y: b.y}, + {X: b.x + b.w, Y: b.y + b.h}, + {X: b.x + notch, Y: b.y + b.h}, + {X: b.x + notch, Y: b.y + b.h*0.66}, + {X: b.x, Y: b.y + b.h*0.66 - step2}, + {X: b.x, Y: b.y + b.h*0.34 + step2}, + {X: b.x + notch, Y: b.y + b.h*0.34}, + } + case coverEdgeRight: + return []fpdf.PointType{ + {X: b.x, Y: b.y}, + {X: b.x + b.w - notch, Y: b.y}, + {X: b.x + b.w - notch, Y: b.y + b.h*0.34}, + {X: b.x + b.w, Y: b.y + b.h*0.34 + step1}, + {X: b.x + b.w, Y: b.y + b.h*0.66 - step1}, + {X: b.x + b.w - notch, Y: b.y + b.h*0.66}, + {X: b.x + b.w - notch, Y: b.y + b.h}, + {X: b.x, Y: b.y + b.h}, + } + case coverEdgeTop: + return []fpdf.PointType{ + {X: b.x, Y: b.y + notch}, + {X: b.x + b.w*0.34, Y: b.y + notch}, + {X: b.x + b.w*0.34 + step1, Y: b.y}, + {X: b.x + b.w*0.66 - step1, Y: b.y}, + {X: b.x + b.w*0.66, Y: b.y + notch}, + {X: b.x + b.w, Y: b.y + notch}, + {X: b.x + b.w, Y: b.y + b.h}, + {X: b.x, Y: b.y + b.h}, + } + default: + return []fpdf.PointType{ + {X: b.x, Y: b.y}, + {X: b.x + b.w, Y: b.y}, + {X: b.x + b.w, Y: b.y + b.h - notch}, + {X: b.x + b.w*0.66, Y: b.y + b.h - notch}, + {X: b.x + b.w*0.66 - step1, Y: b.y + b.h}, + {X: b.x + b.w*0.34 + step1, Y: b.y + b.h}, + {X: b.x + b.w*0.34, Y: b.y + b.h - notch}, + {X: b.x, Y: b.y + b.h - notch}, + } + } +} + +func drawCoverFill(pdf *fpdf.Fpdf, shape coverShape) { + pdf.SetFillColor(255, 255, 255) + + switch shape.Fill { + case coverFillSlits: + drawCoverSlits(pdf, shape.Bounds) + case coverFillOpenGrid: + drawCoverOpenGrid(pdf, shape.Bounds) + case coverFillPunctured: + drawCoverPunctures(pdf, shape.Bounds) + case coverFillBanded: + drawCoverBands(pdf, shape.Bounds) + default: + return + } +} + +func drawCoverSlits(pdf *fpdf.Fpdf, bounds rectMM) { + if bounds.w >= bounds.h { + slitW := max(1.6, bounds.w*0.05) + for x := bounds.x + bounds.w*0.12; x < bounds.x+bounds.w*0.88; x += slitW * 1.9 { + pdf.Rect(x, bounds.y-1, slitW, bounds.h+2, "F") + } + return + } + + slitH := max(1.6, bounds.h*0.05) + for y := bounds.y + bounds.h*0.12; y < bounds.y+bounds.h*0.88; y += slitH * 1.9 { + pdf.Rect(bounds.x-1, y, bounds.w+2, slitH, "F") + } +} + +func drawCoverOpenGrid(pdf *fpdf.Fpdf, bounds rectMM) { + cell := max(3.2, min(bounds.w, bounds.h)*0.12) + for row, y := 0, bounds.y; y < bounds.y+bounds.h; row, y = row+1, y+cell { + for col, x := 0, bounds.x; x < bounds.x+bounds.w; col, x = col+1, x+cell { + if (row+col)%2 == 0 { + pdf.Rect(x, y, cell*0.78, cell*0.78, "F") + } + } + } +} + +func drawCoverPunctures(pdf *fpdf.Fpdf, bounds rectMM) { + step := max(4.4, min(bounds.w, bounds.h)*0.16) + radius := max(1.0, step*0.18) + for row, y := 0, bounds.y+radius; y < bounds.y+bounds.h-radius; row, y = row+1, y+step { + shift := 0.0 + if row%2 == 1 { + shift = step * 0.45 + } + for x := bounds.x + radius + shift; x < bounds.x+bounds.w-radius; x += step { + pdf.Circle(x, y, radius, "F") + } + } +} + +func drawCoverBands(pdf *fpdf.Fpdf, bounds rectMM) { + if bounds.w >= bounds.h { + bandH := max(3.4, bounds.h*0.16) + for i := 0; i < 2; i++ { + y := bounds.y + bounds.h*(0.18+float64(i)*0.34) + pdf.Rect(bounds.x-1, y, bounds.w+2, bandH, "F") + } + return + } + + bandW := max(3.4, bounds.w*0.16) + for i := 0; i < 2; i++ { + x := bounds.x + bounds.w*(0.18+float64(i)*0.34) + pdf.Rect(x, bounds.y-1, bandW, bounds.h+2, "F") + } +} + +func drawCoverTexture(pdf *fpdf.Fpdf, shape coverShape) { + setCoverRemovalStroke(pdf, shape.Weight) + + switch shape.Texture { + case coverTextureStripes: + barW := max(1.2, shape.Step*0.34) + for x := shape.Bounds.x - shape.Step; x <= shape.Bounds.x+shape.Bounds.w+shape.Step; x += shape.Step { + pdf.SetFillColor(255, 255, 255) + pdf.Rect(x, shape.Bounds.y-1, barW, shape.Bounds.h+2, "F") + } + case coverTextureHatch: + for offset := -shape.Bounds.h; offset <= shape.Bounds.w+shape.Bounds.h; offset += shape.Step { + pdf.Line(shape.Bounds.x+offset, shape.Bounds.y+shape.Bounds.h, shape.Bounds.x+offset+shape.Bounds.h, shape.Bounds.y) + } + case coverTextureDots: + radius := max(0.9, shape.Step*0.16) + rowStep := shape.Step * 0.94 + for row, y := 0, shape.Bounds.y+radius; y <= shape.Bounds.y+shape.Bounds.h-radius; row, y = row+1, y+rowStep { + shift := 0.0 + if row%2 == 1 { + shift = shape.Step * 0.42 + } + for x := shape.Bounds.x + radius + shift; x <= shape.Bounds.x+shape.Bounds.w-radius; x += shape.Step { + pdf.SetFillColor(255, 255, 255) + pdf.Circle(x, y, radius, "F") + } + } + case coverTextureChecker: + cell := max(2.6, shape.Step*0.68) + for row, y := 0, shape.Bounds.y; y <= shape.Bounds.y+shape.Bounds.h; row, y = row+1, y+cell { + for col, x := 0, shape.Bounds.x; x <= shape.Bounds.x+shape.Bounds.w; col, x = col+1, x+cell { + if (row+col)%2 == 0 { + pdf.SetFillColor(255, 255, 255) + pdf.Rect(x, y, cell, cell, "F") + } + } + } + case coverTextureLattice: + barW := max(0.95, shape.Step*0.16) + for x := shape.Bounds.x; x <= shape.Bounds.x+shape.Bounds.w; x += shape.Step { + pdf.SetFillColor(255, 255, 255) + pdf.Rect(x, shape.Bounds.y-1, barW, shape.Bounds.h+2, "F") + } + for y := shape.Bounds.y; y <= shape.Bounds.y+shape.Bounds.h; y += shape.Step * 0.84 { + pdf.SetFillColor(255, 255, 255) + pdf.Rect(shape.Bounds.x-1, y, shape.Bounds.w+2, barW, "F") + } + default: + return + } +} + +func setCoverRemovalStroke(pdf *fpdf.Fpdf, weight float64) { + pdf.SetDrawColor(255, 255, 255) + pdf.SetLineWidth(weight) +} + +func drawCoverCutouts(pdf *fpdf.Fpdf, cutouts []coverCutout) { + pdf.SetFillColor(255, 255, 255) + for _, cutout := range cutouts { + switch cutout.Kind { + case coverCutoutCircle: + pdf.Circle(cutout.X, cutout.Y, cutout.R, "F") + default: + pdf.Rect(cutout.X, cutout.Y, cutout.W, cutout.H, "F") + } + } +} + +func rectsIntersect(a, b rectMM) bool { + return a.x < b.x+b.w && a.x+a.w > b.x && a.y < b.y+b.h && a.y+a.h > b.y +} + +func rotatePoint(x, y, pivotX, pivotY, angleDeg float64) (float64, float64) { + rad := angleDeg * math.Pi / 180.0 + sinA, cosA := math.Sincos(rad) + dx := x - pivotX + dy := y - pivotY + return pivotX + dx*cosA - dy*sinA, pivotY + dx*sinA + dy*cosA +} + +func rotatedRectAABB(bounds rectMM, pivotX, pivotY, angleDeg float64) rectMM { + if angleDeg == 0 { + return bounds + } + points := [4][2]float64{ + {bounds.x, bounds.y}, + {bounds.x + bounds.w, bounds.y}, + {bounds.x + bounds.w, bounds.y + bounds.h}, + {bounds.x, bounds.y + bounds.h}, + } + minX := math.MaxFloat64 + minY := math.MaxFloat64 + maxX := -math.MaxFloat64 + maxY := -math.MaxFloat64 + for _, pt := range points { + x, y := rotatePoint(pt[0], pt[1], pivotX, pivotY, angleDeg) + minX = min(minX, x) + minY = min(minY, y) + maxX = max(maxX, x) + maxY = max(maxY, y) + } + return rectMM{x: minX, y: minY, w: maxX - minX, h: maxY - minY} +} + +func coverShapeEnvelope(shape coverShape) rectMM { + return rotatedRectAABB(shape.Bounds, shape.PivotX, shape.PivotY, shape.Rotate) +} + +func coverArtLayoutEqual(a, b coverArtLayout) bool { + return reflect.DeepEqual(a, b) +} + +func coverArtStaysWithinArea(layout coverArtLayout) bool { + for _, shape := range layout.Shapes { + env := coverShapeEnvelope(shape) + if env.x < layout.ArtArea.x || env.y < layout.ArtArea.y { + return false + } + if env.x+env.w > layout.ArtArea.x+layout.ArtArea.w { + return false + } + if env.y+env.h > layout.ArtArea.y+layout.ArtArea.h { + return false + } + } + return true +} + +func coverArtRespectsLockup(layout coverArtLayout) bool { + for _, shape := range layout.Shapes { + if rectsIntersect(coverShapeEnvelope(shape), layout.LockupExclusion) { + return false + } + } + return true +} + +func coverArtDiffers(a, b coverArtLayout) bool { + return !reflect.DeepEqual(a, b) +} + +func coverFamilyName(family coverCompositionFamily) string { + switch family { + case coverFamilyFrame: + return "frame" + case coverFamilyStack: + return "stack" + case coverFamilyHinge: + return "hinge" + default: + return "island" + } +} + +func coverDirectionName(direction coverDirection) string { + switch direction { + case coverDirectionVertical: + return "vertical" + case coverDirectionHorizontal: + return "horizontal" + case coverDirectionDiagonal: + return "diagonal" + default: + return "clustered" + } +} + +func coverShapeKindName(kind coverShapeKind) string { + switch kind { + case coverShapeRect: + return "rect" + case coverShapeCircle: + return "circle" + case coverShapeCroppedCircle: + return "cropped-circle" + default: + return "stepped" + } +} + +func coverTextureCount(layout coverArtLayout) int { + seen := map[coverTextureKind]struct{}{} + for _, shape := range layout.Shapes { + if shape.Texture == coverTextureNone { + continue + } + seen[shape.Texture] = struct{}{} + } + return len(seen) +} + +func coverPrimitiveMixSignature(layout coverArtLayout) string { + parts := map[string]struct{}{} + for _, shape := range layout.Shapes { + parts[coverShapeKindName(shape.Kind)] = struct{}{} + } + + keys := make([]string, 0, len(parts)) + for key := range parts { + keys = append(keys, key) + } + sort.Strings(keys) + return strings.Join(keys, "+") +} + +func coverRotationSignature(layout coverArtLayout) string { + parts := make([]string, 0, len(layout.Shapes)) + for _, shape := range layout.Shapes { + parts = append(parts, fmt.Sprintf("%.1f", shape.Rotate)) + } + sort.Strings(parts) + return strings.Join(parts, ",") +} + +func coverRotationPolaritySignature(layout coverArtLayout) string { + hasPos := false + hasNeg := false + for _, shape := range layout.Shapes { + if shape.Rotate > 0 { + hasPos = true + } + if shape.Rotate < 0 { + hasNeg = true + } + } + switch { + case hasPos && hasNeg: + return "mixed" + case hasPos: + return "positive" + case hasNeg: + return "negative" + default: + return "flat" + } +} + +func coverArtInkBounds(layout coverArtLayout) rectMM { + if len(layout.Shapes) == 0 { + return rectMM{} + } + + minX := math.MaxFloat64 + minY := math.MaxFloat64 + maxX := 0.0 + maxY := 0.0 + for _, shape := range layout.Shapes { + env := coverShapeEnvelope(shape) + minX = min(minX, env.x) + minY = min(minY, env.y) + maxX = max(maxX, env.x+env.w) + maxY = max(maxY, env.y+env.h) + } + return rectMM{x: minX, y: minY, w: maxX - minX, h: maxY - minY} +} + +func coverInkAspectBucket(layout coverArtLayout) coverAspectBucket { + bounds := coverArtInkBounds(layout) + if bounds.w <= 0 || bounds.h <= 0 { + return coverAspectCompact + } + + if bounds.h/bounds.w >= 1.28 { + return coverAspectTall + } + if bounds.w/bounds.h >= 1.28 { + return coverAspectWide + } + + centerX := bounds.x + bounds.w*0.5 + centerY := bounds.y + bounds.h*0.5 + artCenterX := layout.ArtArea.x + layout.ArtArea.w*0.5 + artCenterY := layout.ArtArea.y + layout.ArtArea.h*0.5 + offset := math.Hypot(centerX-artCenterX, centerY-artCenterY) + if offset > min(layout.ArtArea.w, layout.ArtArea.h)*0.12 { + return coverAspectOffset + } + return coverAspectCompact +} + +func coverAspectBucketName(bucket coverAspectBucket) string { + switch bucket { + case coverAspectTall: + return "tall" + case coverAspectWide: + return "wide" + case coverAspectOffset: + return "offset" + default: + return "compact" + } } diff --git a/pdfexport/render_cover_test.go b/pdfexport/render_cover_test.go index 7e44b8c..9c9b23c 100644 --- a/pdfexport/render_cover_test.go +++ b/pdfexport/render_cover_test.go @@ -1,6 +1,7 @@ package pdfexport import ( + "math" "reflect" "testing" @@ -48,3 +49,186 @@ func TestSplitClampedTextLinesClampsToMaxLines(t *testing.T) { t.Fatalf("splitClampedTextLines not stable: %v vs %v", got, gotAgain) } } + +func TestBuildCoverArtLayoutIsDeterministicForSameSeed(t *testing.T) { + cfg := RenderConfig{ + ShuffleSeed: "zine-seed-01", + VolumeNumber: 4, + } + + layoutA := buildCoverArtLayout(cfg, halfLetterWidthMM, halfLetterHeightMM) + layoutB := buildCoverArtLayout(cfg, halfLetterWidthMM, halfLetterHeightMM) + if !coverArtLayoutEqual(layoutA, layoutB) { + t.Fatalf("cover layout should be stable for identical input: %#v vs %#v", layoutA, layoutB) + } + if !coverArtStaysWithinArea(layoutA) { + t.Fatalf("cover layout escapes art area: %#v", layoutA) + } + if !coverArtRespectsLockup(layoutA) { + t.Fatalf("cover layout overlaps lockup exclusion: %#v", layoutA) + } + if coverRotationSignature(layoutA) != coverRotationSignature(layoutB) { + t.Fatalf("rotation signature changed: %q vs %q", coverRotationSignature(layoutA), coverRotationSignature(layoutB)) + } +} + +func TestBuildCoverArtLayoutChangesWhenSeedChanges(t *testing.T) { + base := RenderConfig{ + ShuffleSeed: "zine-seed-01", + VolumeNumber: 4, + } + other := RenderConfig{ + ShuffleSeed: "zine-seed-02", + VolumeNumber: 4, + } + + layoutA := buildCoverArtLayout(base, halfLetterWidthMM, halfLetterHeightMM) + layoutB := buildCoverArtLayout(other, halfLetterWidthMM, halfLetterHeightMM) + if !coverArtDiffers(layoutA, layoutB) { + t.Fatalf("cover layout should differ when seed changes: %#v", layoutA) + } + if coverRotationSignature(layoutA) == coverRotationSignature(layoutB) { + t.Fatalf("rotation signature should differ when seed changes: %q", coverRotationSignature(layoutA)) + } +} + +func TestBuildCoverArtLayoutUsesMultipleTextureFamilies(t *testing.T) { + cfg := RenderConfig{ + ShuffleSeed: "texture-seed", + VolumeNumber: 2, + } + + layout := buildCoverArtLayout(cfg, halfLetterWidthMM, halfLetterHeightMM) + if got := coverTextureCount(layout); got < 1 { + t.Fatalf("texture count = %d, want at least 1", got) + } + + bounds := coverArtInkBounds(layout) + if bounds.w <= 0 || bounds.h <= 0 { + t.Fatalf("ink bounds = %#v, want positive extents", bounds) + } +} + +func TestCoverSeedCorpusProducesMultipleSilhouetteFamilies(t *testing.T) { + seeds := []string{ + "near-seed-00", + "near-seed-01", + "near-seed-02", + "near-seed-03", + "near-seed-04", + "near-seed-05", + "near-seed-06", + "near-seed-07", + "near-seed-08", + "near-seed-09", + } + + families := map[string]struct{}{} + aspectBuckets := map[string]struct{}{} + directions := map[string]struct{}{} + mixes := map[string]struct{}{} + rotations := map[string]struct{}{} + polarities := map[string]struct{}{} + + for _, seed := range seeds { + layout := buildCoverArtLayout(RenderConfig{ + ShuffleSeed: seed, + VolumeNumber: 1, + }, halfLetterWidthMM, halfLetterHeightMM) + + if !coverArtStaysWithinArea(layout) { + t.Fatalf("layout for %q escapes art area: %#v", seed, layout) + } + if !coverArtRespectsLockup(layout) { + t.Fatalf("layout for %q overlaps lockup exclusion: %#v", seed, layout) + } + + families[coverFamilyName(layout.Family)] = struct{}{} + aspectBuckets[coverAspectBucketName(coverInkAspectBucket(layout))] = struct{}{} + directions[coverDirectionName(layout.Direction)] = struct{}{} + mixes[coverPrimitiveMixSignature(layout)] = struct{}{} + rotations[coverRotationSignature(layout)] = struct{}{} + polarities[coverRotationPolaritySignature(layout)] = struct{}{} + } + + if got := len(families); got < 3 { + t.Fatalf("family count = %d, want at least 3", got) + } + if got := len(aspectBuckets); got < 2 { + t.Fatalf("aspect bucket count = %d, want at least 2", got) + } + if got := len(directions); got < 3 { + t.Fatalf("direction count = %d, want at least 3", got) + } + if got := len(mixes); got < 3 { + t.Fatalf("primitive mix count = %d, want at least 3", got) + } + if got := len(rotations); got < 4 { + t.Fatalf("rotation signature count = %d, want at least 4", got) + } + if got := len(polarities); got < 2 { + t.Fatalf("rotation polarity count = %d, want at least 2", got) + } +} + +func TestRotatedRectAABBNinetyDegreesSwapsExtents(t *testing.T) { + bounds := rectMM{x: 10, y: 20, w: 20, h: 10} + aabb := rotatedRectAABB(bounds, 20, 25, 90) + if math.Abs(aabb.w-10) > 0.001 { + t.Fatalf("aabb.w = %.3f, want 10", aabb.w) + } + if math.Abs(aabb.h-20) > 0.001 { + t.Fatalf("aabb.h = %.3f, want 20", aabb.h) + } + if math.Abs(aabb.x-15) > 0.001 || math.Abs(aabb.y-15) > 0.001 { + t.Fatalf("aabb origin = (%.3f, %.3f), want (15, 15)", aabb.x, aabb.y) + } +} + +func TestCoverRotationAngleBandRespectsStructuralFlag(t *testing.T) { + seeds := []string{ + "rotation-band-00", + "rotation-band-01", + "rotation-band-02", + "rotation-band-03", + "rotation-band-04", + "rotation-band-05", + } + + seenPositive := false + seenNegative := false + seenStructural := false + for _, seed := range seeds { + layout := buildCoverArtLayout(RenderConfig{ + ShuffleSeed: seed, + VolumeNumber: 1, + }, halfLetterWidthMM, halfLetterHeightMM) + + for _, shape := range layout.Shapes { + absAngle := math.Abs(shape.Rotate) + if shape.Rotate > 0 { + seenPositive = true + } + if shape.Rotate < 0 { + seenNegative = true + } + if shape.Locked { + seenStructural = true + if absAngle < 4 || absAngle > 10 { + t.Fatalf("structural angle = %.2f, want within [4,10]", absAngle) + } + continue + } + if absAngle < 8 || absAngle > 20 { + t.Fatalf("standard angle = %.2f, want within [8,20]", absAngle) + } + } + } + + if !seenStructural { + t.Fatal("expected at least one structural shape across seed corpus") + } + if !seenPositive || !seenNegative { + t.Fatalf("expected both positive and negative rotations, got positive=%t negative=%t", seenPositive, seenNegative) + } +} diff --git a/pdfexport/render_difficulty.go b/pdfexport/render_difficulty.go new file mode 100644 index 0000000..04364ec --- /dev/null +++ b/pdfexport/render_difficulty.go @@ -0,0 +1,165 @@ +package pdfexport + +import ( + "math" + + "codeberg.org/go-pdf/fpdf" +) + +type difficultyStarState uint8 + +const ( + difficultyStarEmpty difficultyStarState = iota + difficultyStarHalf + difficultyStarFull +) + +type puzzleSubtitleLayout struct { + labelText string + labelWidth float64 + starStates []difficultyStarState + starsWidth float64 + totalWidth float64 +} + +func scoreToDifficultyStarStates(score float64) []difficultyStarState { + score = clampDifficultyScore(score) + + units := int(math.Round(score * 10)) + states := make([]difficultyStarState, 5) + for i := range states { + switch { + case units >= 2: + states[i] = difficultyStarFull + units -= 2 + case units == 1: + states[i] = difficultyStarHalf + units = 0 + default: + states[i] = difficultyStarEmpty + } + } + return states +} + +func puzzleDifficultySubtitleLayout(pdf *fpdf.Fpdf, puzzle Puzzle) puzzleSubtitleLayout { + label := "Difficulty:" + if !isMixedModes(puzzle.ModeSelection) { + label = "Mode: " + puzzle.ModeSelection + " | " + label + } + + layout := puzzleSubtitleLayout{ + labelText: label, + starStates: scoreToDifficultyStarStates(puzzle.DifficultyScore), + } + if pdf == nil { + return layout + } + + setPuzzleSubtitleStyle(pdf) + layout.labelWidth = pdf.GetStringWidth(label) + layout.starsWidth = difficultyStarsWidth() + layout.totalWidth = layout.labelWidth + difficultyTextToStarsGapMM + layout.starsWidth + return layout +} + +func renderPuzzleDifficultySubtitle(pdf *fpdf.Fpdf, pageW, y float64, puzzle Puzzle) { + if pdf == nil { + return + } + + layout := puzzleDifficultySubtitleLayout(pdf, puzzle) + rowHeight := 5.0 + startX := (pageW - layout.totalWidth) / 2 + if startX < 0 { + startX = 0 + } + + setPuzzleSubtitleStyle(pdf) + pdf.SetXY(startX, y) + pdf.CellFormat(layout.labelWidth, rowHeight, layout.labelText, "", 0, "L", false, 0, "") + + starX := startX + layout.labelWidth + difficultyTextToStarsGapMM + starCenterY := y + rowHeight/2 + renderDifficultyStars(pdf, starX, starCenterY, layout.starStates) +} + +func renderDifficultyStars(pdf *fpdf.Fpdf, x, centerY float64, states []difficultyStarState) { + if pdf == nil { + return + } + + textR, textG, textB := pdf.GetTextColor() + emptyOutline := 132 + pdf.SetLineWidth(difficultyStarOutlineMM) + + for i, state := range states { + starX := x + float64(i)*(difficultyStarSizeMM+difficultyStarGapMM) + points := difficultyStarPoints(starX, centerY, difficultyStarSizeMM) + top := centerY - difficultyStarSizeMM/2 + + switch state { + case difficultyStarFull: + pdf.SetDrawColor(textR, textG, textB) + fillDifficultyStar(pdf, points, starX, top, difficultyStarSizeMM, difficultyStarSizeMM, textR, textG, textB) + pdf.Polygon(points, "D") + case difficultyStarHalf: + pdf.SetDrawColor(emptyOutline, emptyOutline, emptyOutline) + pdf.Polygon(points, "D") + fillDifficultyStar(pdf, points, starX, top, difficultyStarSizeMM/2, difficultyStarSizeMM, textR, textG, textB) + default: + pdf.SetDrawColor(emptyOutline, emptyOutline, emptyOutline) + pdf.Polygon(points, "D") + } + } +} + +func fillDifficultyStar( + pdf *fpdf.Fpdf, + points []fpdf.PointType, + x, + y, + w, + h float64, + r, + g, + b int, +) { + if pdf == nil || len(points) == 0 || w <= 0 || h <= 0 { + return + } + + pdf.ClipPolygon(points, false) + pdf.ClipRect(x, y, w, h, false) + pdf.SetFillColor(r, g, b) + pdf.Rect(x, y, w, h, "F") + pdf.ClipEnd() + pdf.ClipEnd() +} + +func difficultyStarsWidth() float64 { + return float64(5)*difficultyStarSizeMM + float64(4)*difficultyStarGapMM +} + +func difficultyStarPoints(x, centerY, size float64) []fpdf.PointType { + cx := x + size/2 + cy := centerY + outer := size / 2 + inner := outer * 0.48 + points := make([]fpdf.PointType, 0, 10) + + for i := range 10 { + radius := outer + if i%2 == 1 { + radius = inner + } + + angle := -math.Pi/2 + float64(i)*math.Pi/5 + points = append(points, fpdf.PointType{ + X: cx + math.Cos(angle)*radius, + Y: cy + math.Sin(angle)*radius, + }) + } + + return points +} diff --git a/pdfexport/render_difficulty_test.go b/pdfexport/render_difficulty_test.go new file mode 100644 index 0000000..cecec6d --- /dev/null +++ b/pdfexport/render_difficulty_test.go @@ -0,0 +1,143 @@ +package pdfexport + +import ( + "reflect" + "testing" + + "codeberg.org/go-pdf/fpdf" +) + +func newDifficultyTestPDF(t *testing.T) *fpdf.Fpdf { + t.Helper() + + pdf := fpdf.NewCustom(&fpdf.InitType{ + OrientationStr: "P", + UnitStr: "mm", + Size: fpdf.SizeType{ + Wd: halfLetterWidthMM, + Ht: halfLetterHeightMM, + }, + }) + if err := registerPDFFonts(pdf); err != nil { + t.Fatalf("registerPDFFonts error: %v", err) + } + pdf.AddPage() + return pdf +} + +func TestScoreToDifficultyStarStates(t *testing.T) { + tests := []struct { + name string + score float64 + want []difficultyStarState + }{ + { + name: "zero score is empty", + score: 0.0, + want: []difficultyStarState{difficultyStarEmpty, difficultyStarEmpty, difficultyStarEmpty, difficultyStarEmpty, difficultyStarEmpty}, + }, + { + name: "one tenth is half then empty", + score: 0.1, + want: []difficultyStarState{difficultyStarHalf, difficultyStarEmpty, difficultyStarEmpty, difficultyStarEmpty, difficultyStarEmpty}, + }, + { + name: "three tenths is full half then empty", + score: 0.3, + want: []difficultyStarState{difficultyStarFull, difficultyStarHalf, difficultyStarEmpty, difficultyStarEmpty, difficultyStarEmpty}, + }, + { + name: "five tenths is two and a half stars", + score: 0.5, + want: []difficultyStarState{difficultyStarFull, difficultyStarFull, difficultyStarHalf, difficultyStarEmpty, difficultyStarEmpty}, + }, + { + name: "eight tenths is four full stars", + score: 0.8, + want: []difficultyStarState{difficultyStarFull, difficultyStarFull, difficultyStarFull, difficultyStarFull, difficultyStarEmpty}, + }, + { + name: "one is five full stars", + score: 1.0, + want: []difficultyStarState{difficultyStarFull, difficultyStarFull, difficultyStarFull, difficultyStarFull, difficultyStarFull}, + }, + { + name: "negative clamps low", + score: -1.0, + want: []difficultyStarState{difficultyStarEmpty, difficultyStarEmpty, difficultyStarEmpty, difficultyStarEmpty, difficultyStarEmpty}, + }, + { + name: "above one clamps high", + score: 2.0, + want: []difficultyStarState{difficultyStarFull, difficultyStarFull, difficultyStarFull, difficultyStarFull, difficultyStarFull}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := scoreToDifficultyStarStates(tt.score) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("states = %v, want %v", got, tt.want) + } + }) + } +} + +func TestPuzzleDifficultySubtitleLayoutWidthNormalMode(t *testing.T) { + pdf := newDifficultyTestPDF(t) + layout := puzzleDifficultySubtitleLayout(pdf, Puzzle{ + ModeSelection: "Expert", + DifficultyScore: 0.5, + }) + if layout.labelText != "Mode: Expert | Difficulty:" { + t.Fatalf("label = %q", layout.labelText) + } + if layout.totalWidth <= 0 { + t.Fatalf("total width = %.3f, want positive", layout.totalWidth) + } + if layout.totalWidth > halfLetterWidthMM { + t.Fatalf("total width = %.3f, want <= %.3f", layout.totalWidth, halfLetterWidthMM) + } +} + +func TestPuzzleDifficultySubtitleLayoutWidthMixedMode(t *testing.T) { + pdf := newDifficultyTestPDF(t) + layout := puzzleDifficultySubtitleLayout(pdf, Puzzle{ + ModeSelection: "Mixed Modes", + DifficultyScore: 0.3, + }) + if layout.labelText != "Difficulty:" { + t.Fatalf("label = %q", layout.labelText) + } + if layout.totalWidth <= 0 { + t.Fatalf("total width = %.3f, want positive", layout.totalWidth) + } + if layout.totalWidth > halfLetterWidthMM { + t.Fatalf("total width = %.3f, want <= %.3f", layout.totalWidth, halfLetterWidthMM) + } +} + +func TestPuzzleDifficultySubtitleLayoutStatesSequence(t *testing.T) { + pdf := newDifficultyTestPDF(t) + layout := puzzleDifficultySubtitleLayout(pdf, Puzzle{ + ModeSelection: "Tricky", + DifficultyScore: 0.3, + }) + want := []difficultyStarState{ + difficultyStarFull, + difficultyStarHalf, + difficultyStarEmpty, + difficultyStarEmpty, + difficultyStarEmpty, + } + if !reflect.DeepEqual(layout.starStates, want) { + t.Fatalf("states = %v, want %v", layout.starStates, want) + } +} + +func TestDifficultyStarPointsReturnsTenVertices(t *testing.T) { + points := difficultyStarPoints(10, 20, difficultyStarSizeMM) + if len(points) != 10 { + t.Fatalf("vertex count = %d, want 10", len(points)) + } +} diff --git a/pdfexport/render_instructions.go b/pdfexport/render_instructions.go new file mode 100644 index 0000000..bc4d973 --- /dev/null +++ b/pdfexport/render_instructions.go @@ -0,0 +1,64 @@ +package pdfexport + +import ( + "strings" + + "codeberg.org/go-pdf/fpdf" +) + +const instructionWrapInsetMM = 0.0 + +func wrapInstructionLines(pdf *fpdf.Fpdf, width float64, rules []string) []string { + if pdf == nil || width <= 0 { + return nil + } + + setInstructionStyle(pdf) + + wrapWidth := width - instructionWrapInsetMM*2 + if wrapWidth <= 0 { + wrapWidth = width + } + + lines := make([]string, 0, len(rules)) + for _, rule := range rules { + rule = strings.TrimSpace(rule) + if rule == "" { + continue + } + + chunks := pdf.SplitLines([]byte(rule), wrapWidth) + if len(chunks) == 0 { + lines = append(lines, rule) + continue + } + + for _, chunk := range chunks { + line := strings.TrimSpace(string(chunk)) + if line != "" { + lines = append(lines, line) + } + } + } + + return lines +} + +func InstructionLineCount(pdf *fpdf.Fpdf, width float64, rules []string) int { + return len(wrapInstructionLines(pdf, width, rules)) +} + +func RenderInstructions(pdf *fpdf.Fpdf, x, y, width float64, rules []string) int { + lines := wrapInstructionLines(pdf, width, rules) + if len(lines) == 0 { + return 0 + } + + setInstructionStyle(pdf) + for i, line := range lines { + pdf.SetXY(x, y+float64(i)*InstructionLineHMM) + pdf.CellFormat(width, InstructionLineHMM, line, "", 0, "C", false, 0, "") + } + + return len(lines) +} diff --git a/pdfexport/render_instructions_test.go b/pdfexport/render_instructions_test.go new file mode 100644 index 0000000..4164e7e --- /dev/null +++ b/pdfexport/render_instructions_test.go @@ -0,0 +1,86 @@ +package pdfexport + +import ( + "testing" + + "codeberg.org/go-pdf/fpdf" +) + +func TestWrapInstructionLinesWrapsToSafeWidth(t *testing.T) { + pdf := fpdf.NewCustom(&fpdf.InitType{ + OrientationStr: "P", + UnitStr: "mm", + Size: fpdf.SizeType{ + Wd: halfLetterWidthMM, + Ht: halfLetterHeightMM, + }, + }) + if err := registerPDFFonts(pdf); err != nil { + t.Fatalf("registerPDFFonts error: %v", err) + } + pdf.AddPage() + + body := PuzzleBodyRect(halfLetterWidthMM, halfLetterHeightMM, 2) + lines := wrapInstructionLines( + pdf, + body.W, + []string{"Shade duplicates; shaded cells cannot touch orthogonally; unshaded cells stay connected."}, + ) + if len(lines) < 2 { + t.Fatalf("line count = %d, want at least 2 (%v)", len(lines), lines) + } + + setInstructionStyle(pdf) + maxWidth := body.W - instructionWrapInsetMM*2 + for _, line := range lines { + if got := pdf.GetStringWidth(line); got > maxWidth+0.01 { + t.Fatalf("line %q width = %.2f, want <= %.2f", line, got, maxWidth) + } + } +} + +func TestWrapInstructionLinesKeepsShortRulesSingleLine(t *testing.T) { + pdf := fpdf.NewCustom(&fpdf.InitType{ + OrientationStr: "P", + UnitStr: "mm", + Size: fpdf.SizeType{ + Wd: halfLetterWidthMM, + Ht: halfLetterHeightMM, + }, + }) + if err := registerPDFFonts(pdf); err != nil { + t.Fatalf("registerPDFFonts error: %v", err) + } + pdf.AddPage() + + body := PuzzleBodyRect(halfLetterWidthMM, halfLetterHeightMM, 2) + lines := wrapInstructionLines(pdf, body.W, []string{"Fill rows, columns, and 3x3 boxes with 1-9"}) + if len(lines) != 1 { + t.Fatalf("line count = %d, want 1 (%v)", len(lines), lines) + } +} + +func TestTakuzuPlusRuleCopyWrapsToThreePhysicalLines(t *testing.T) { + pdf := fpdf.NewCustom(&fpdf.InitType{ + OrientationStr: "P", + UnitStr: "mm", + Size: fpdf.SizeType{ + Wd: halfLetterWidthMM, + Ht: halfLetterHeightMM, + }, + }) + if err := registerPDFFonts(pdf); err != nil { + t.Fatalf("registerPDFFonts error: %v", err) + } + pdf.AddPage() + + body := PuzzleBodyRect(halfLetterWidthMM, halfLetterHeightMM, 2) + lines := wrapInstructionLines(pdf, body.W, []string{ + "No three equal adjacent in any row or column.", + "Rows/columns have equal 0s and 1s, and all rows/columns are unique.", + "= means same; x means different.", + }) + if len(lines) != 3 { + t.Fatalf("line count = %d, want 3 (%v)", len(lines), lines) + } +} diff --git a/pdfexport/render_kit.go b/pdfexport/render_kit.go index 782d62c..c81b260 100644 --- a/pdfexport/render_kit.go +++ b/pdfexport/render_kit.go @@ -10,7 +10,8 @@ type Rect struct { } const ( - SansFontFamily = sansFontFamily + SansFontFamily = sansFontFamily + PDFFontSizeDelta = pdfFontSizeDelta PrimaryTextGray = primaryTextGray SecondaryTextGray = secondaryTextGray diff --git a/pdfexport/render_layout_test.go b/pdfexport/render_layout_test.go index 9a4d51c..c194fc9 100644 --- a/pdfexport/render_layout_test.go +++ b/pdfexport/render_layout_test.go @@ -98,14 +98,14 @@ func TestSaddleStitchPadCountForStandardPackLayout(t *testing.T) { puzzleRows int wantPad int }{ - {name: "single puzzle", puzzleRows: 1, wantPad: 0}, - {name: "two puzzles", puzzleRows: 2, wantPad: 3}, - {name: "thirty-two puzzles", puzzleRows: 32, wantPad: 1}, + {name: "single puzzle", puzzleRows: 1, wantPad: 2}, + {name: "two puzzles", puzzleRows: 2, wantPad: 1}, + {name: "thirty-two puzzles", puzzleRows: 32, wantPad: 3}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - totalWithoutPad := tt.puzzleRows + 3 // cover + title + back cover + totalWithoutPad := tt.puzzleRows + 5 // outside front + inside front + title + inside back + outside back got := saddleStitchPadCount(totalWithoutPad) if got != tt.wantPad { t.Fatalf("pad pages = %d, want %d", got, tt.wantPad) @@ -117,6 +117,153 @@ func TestSaddleStitchPadCountForStandardPackLayout(t *testing.T) { } } +func TestBookletRenderPlanWithCoverUsesFirstTwoAndLastTwoPages(t *testing.T) { + plan := newBookletRenderPlan(32, true) + + if got, want := plan.titlePageNumber(), 3; got != want { + t.Fatalf("title page = %d, want %d", got, want) + } + if got, want := plan.totalPages(), 40; got != want { + t.Fatalf("total pages = %d, want %d", got, want) + } + if got, want := plan.padPages, 3; got != want { + t.Fatalf("pad pages = %d, want %d", got, want) + } + + frontOutside, frontInside, backInside, backOutside, ok := plan.coverPageNumbers() + if !ok { + t.Fatal("expected cover page numbers") + } + if frontOutside != 1 || frontInside != 2 || backInside != 39 || backOutside != 40 { + t.Fatalf("cover pages = (%d,%d,%d,%d), want (1,2,39,40)", frontOutside, frontInside, backInside, backOutside) + } + + excluded := plan.footerExcludedPages() + for _, page := range []int{1, 2, 3, 36, 37, 38, 39, 40} { + if _, ok := excluded[page]; !ok { + t.Fatalf("expected page %d to be footer-excluded", page) + } + } + for _, page := range []int{4, 20, 35} { + if _, ok := excluded[page]; ok { + t.Fatalf("did not expect puzzle page %d to be footer-excluded", page) + } + } +} + +func TestBookletRenderPlanWithoutCoverKeepsTitleFirst(t *testing.T) { + plan := newBookletRenderPlan(2, false) + + if got, want := plan.titlePageNumber(), 1; got != want { + t.Fatalf("title page = %d, want %d", got, want) + } + if got, want := plan.totalPages(), 4; got != want { + t.Fatalf("total pages = %d, want %d", got, want) + } + + excluded := plan.footerExcludedPages() + for _, page := range []int{1, 4} { + if _, ok := excluded[page]; !ok { + t.Fatalf("expected page %d to be footer-excluded", page) + } + } + if _, ok := excluded[2]; ok { + t.Fatal("did not expect first puzzle page to be footer-excluded") + } +} + +func TestBuildLogicalPagesWithCoverUsesExpectedSequence(t *testing.T) { + pages := buildLogicalPages(32, true) + if got, want := len(pages), 40; got != want { + t.Fatalf("logical pages = %d, want %d", got, want) + } + + if pages[0].Kind != logicalPageCoverOutside || pages[0].OutsideSlice != coverOutsideFront { + t.Fatal("page 1 should be front outside cover") + } + if pages[1].Kind != logicalPageCoverBlank { + t.Fatal("page 2 should be inside front blank") + } + if pages[2].Kind != logicalPageTitle { + t.Fatal("page 3 should be title") + } + if pages[len(pages)-2].Kind != logicalPageCoverBlank { + t.Fatal("second-last page should be inside back blank") + } + if pages[len(pages)-1].Kind != logicalPageCoverOutside || pages[len(pages)-1].OutsideSlice != coverOutsideBack { + t.Fatal("last page should be back outside cover") + } +} + +func TestDuplexBookletSheetsForFourPageBooklet(t *testing.T) { + sheets := duplexBookletSheets(4) + if got, want := len(sheets), 1; got != want { + t.Fatalf("sheet count = %d, want %d", got, want) + } + if sheets[0].Front.LeftPage != 4 || sheets[0].Front.RightPage != 1 { + t.Fatalf("front pair = (%d,%d), want (4,1)", sheets[0].Front.LeftPage, sheets[0].Front.RightPage) + } + if sheets[0].Back.LeftPage != 2 || sheets[0].Back.RightPage != 3 { + t.Fatalf("back pair = (%d,%d), want (2,3)", sheets[0].Back.LeftPage, sheets[0].Back.RightPage) + } +} + +func TestDuplexBookletSheetsForEightPageBooklet(t *testing.T) { + sheets := duplexBookletSheets(8) + if got, want := len(sheets), 2; got != want { + t.Fatalf("sheet count = %d, want %d", got, want) + } + if sheets[0].Front.LeftPage != 8 || sheets[0].Front.RightPage != 1 { + t.Fatalf("outer front pair = (%d,%d), want (8,1)", sheets[0].Front.LeftPage, sheets[0].Front.RightPage) + } + if sheets[1].Back.LeftPage != 4 || sheets[1].Back.RightPage != 5 { + t.Fatalf("inner back pair = (%d,%d), want (4,5)", sheets[1].Back.LeftPage, sheets[1].Back.RightPage) + } +} + +func TestDuplexBookletSheetsWithCoverOuterSheetPairs(t *testing.T) { + pages := buildLogicalPages(32, true) + sheets := duplexBookletSheets(len(pages)) + if got, want := len(sheets), 10; got != want { + t.Fatalf("sheet count = %d, want %d", got, want) + } + if sheets[0].Front.LeftPage != 40 || sheets[0].Front.RightPage != 1 { + t.Fatalf("outer front pair = (%d,%d), want (40,1)", sheets[0].Front.LeftPage, sheets[0].Front.RightPage) + } + if sheets[0].Back.LeftPage != 2 || sheets[0].Back.RightPage != 39 { + t.Fatalf("outer back pair = (%d,%d), want (2,39)", sheets[0].Back.LeftPage, sheets[0].Back.RightPage) + } +} + +func TestDuplexBookletPhysicalPageCountIsHalfLogicalPages(t *testing.T) { + for _, total := range []int{4, 8, 40} { + sheets := duplexBookletSheets(total) + got := len(sheets) * 2 + want := total / 2 + if got != want { + t.Fatalf("physical pages for %d logical pages = %d, want %d", total, got, want) + } + } +} + +func TestNewRenderPDFDuplexBookletUsesLandscapeLetterCanvas(t *testing.T) { + pdf := newRenderPDF(SheetLayoutDuplexBooklet) + pageW, pageH := pdf.GetPageSize() + + if pageW <= pageH { + t.Fatalf("duplex-booklet canvas = %.1fx%.1f, want landscape", pageW, pageH) + } + if math.Abs(pageW-letterWidthMM) > 0.01 || math.Abs(pageH-letterHeightMM) > 0.01 { + t.Fatalf( + "duplex-booklet canvas = %.1fx%.1f, want %.1fx%.1f", + pageW, + pageH, + letterWidthMM, + letterHeightMM, + ) + } +} + func TestSaddleStitchPadCountForTitleOnlyPackLayout(t *testing.T) { tests := []struct { name string diff --git a/pdfexport/render_metadata.go b/pdfexport/render_metadata.go index 335b55e..405aea2 100644 --- a/pdfexport/render_metadata.go +++ b/pdfexport/render_metadata.go @@ -1,15 +1,13 @@ package pdfexport -import "math" - -func difficultyScoreOutOfTen(score float64) int { +func clampDifficultyScore(score float64) float64 { if score < 0 { - score = 0 + return 0 } if score > 1 { - score = 1 + return 1 } - return int(math.Round(score * 10)) + return score } func isMixedModes(mode string) bool { diff --git a/pdfexport/render_plan.go b/pdfexport/render_plan.go new file mode 100644 index 0000000..7f3ee45 --- /dev/null +++ b/pdfexport/render_plan.go @@ -0,0 +1,164 @@ +package pdfexport + +type bookletRenderPlan struct { + puzzlePages int + includeCover bool + padPages int +} + +type logicalPageKind int + +const ( + logicalPageCoverOutside logicalPageKind = iota + logicalPageCoverBlank + logicalPageTitle + logicalPagePuzzle + logicalPagePad +) + +type logicalPage struct { + Number int + Kind logicalPageKind + PuzzleIndex int + OutsideSlice coverOutsideSlice + ShowFooter bool +} + +type duplexBookletSpread struct { + LeftPage int + RightPage int +} + +type duplexBookletSheet struct { + Front duplexBookletSpread + Back duplexBookletSpread +} + +func newBookletRenderPlan(puzzlePages int, includeCover bool) bookletRenderPlan { + extraPages := 1 // title page + if includeCover { + extraPages += 4 + } + + return bookletRenderPlan{ + puzzlePages: puzzlePages, + includeCover: includeCover, + padPages: saddleStitchPadCount(puzzlePages + extraPages), + } +} + +func (p bookletRenderPlan) totalPagesWithoutPadding() int { + total := p.puzzlePages + 1 // title page + if p.includeCover { + total += 4 + } + return total +} + +func (p bookletRenderPlan) totalPages() int { + return p.totalPagesWithoutPadding() + p.padPages +} + +func (p bookletRenderPlan) titlePageNumber() int { + if p.includeCover { + return 3 + } + return 1 +} + +func (p bookletRenderPlan) puzzleStartPage() int { + return p.titlePageNumber() + 1 +} + +func (p bookletRenderPlan) firstPadPage() int { + return p.puzzleStartPage() + p.puzzlePages +} + +func (p bookletRenderPlan) coverPageNumbers() (frontOutside, frontInside, backInside, backOutside int, ok bool) { + if !p.includeCover { + return 0, 0, 0, 0, false + } + + total := p.totalPages() + return 1, 2, total - 1, total, true +} + +func (p bookletRenderPlan) footerExcludedPages() map[int]struct{} { + excluded := map[int]struct{}{ + p.titlePageNumber(): {}, + } + + for page := p.firstPadPage(); page < p.firstPadPage()+p.padPages; page++ { + excluded[page] = struct{}{} + } + + frontOutside, frontInside, backInside, backOutside, ok := p.coverPageNumbers() + if ok { + excluded[frontOutside] = struct{}{} + excluded[frontInside] = struct{}{} + excluded[backInside] = struct{}{} + excluded[backOutside] = struct{}{} + } + + return excluded +} + +func buildLogicalPages(puzzlePages int, includeCover bool) []logicalPage { + plan := newBookletRenderPlan(puzzlePages, includeCover) + excluded := plan.footerExcludedPages() + pages := make([]logicalPage, 0, plan.totalPages()) + + appendPage := func(kind logicalPageKind, puzzleIndex int, slice coverOutsideSlice) { + pageNo := len(pages) + 1 + _, skipFooter := excluded[pageNo] + pages = append(pages, logicalPage{ + Number: pageNo, + Kind: kind, + PuzzleIndex: puzzleIndex, + OutsideSlice: slice, + ShowFooter: !skipFooter, + }) + } + + if includeCover { + appendPage(logicalPageCoverOutside, -1, coverOutsideFront) + appendPage(logicalPageCoverBlank, -1, coverOutsideFront) + } + + appendPage(logicalPageTitle, -1, coverOutsideFront) + + for i := range puzzlePages { + appendPage(logicalPagePuzzle, i, coverOutsideFront) + } + for range plan.padPages { + appendPage(logicalPagePad, -1, coverOutsideFront) + } + + if includeCover { + appendPage(logicalPageCoverBlank, -1, coverOutsideBack) + appendPage(logicalPageCoverOutside, -1, coverOutsideBack) + } + + return pages +} + +func duplexBookletSheets(totalPages int) []duplexBookletSheet { + if totalPages <= 0 || totalPages%4 != 0 { + return nil + } + + sheets := make([]duplexBookletSheet, 0, totalPages/4) + for sheetIndex := 0; sheetIndex < totalPages/4; sheetIndex++ { + sheets = append(sheets, duplexBookletSheet{ + Front: duplexBookletSpread{ + LeftPage: totalPages - 2*sheetIndex, + RightPage: 1 + 2*sheetIndex, + }, + Back: duplexBookletSpread{ + LeftPage: 2 + 2*sheetIndex, + RightPage: totalPages - 1 - 2*sheetIndex, + }, + }) + } + return sheets +} diff --git a/pdfexport/render_title.go b/pdfexport/render_title.go index f8ff0d1..c3d5917 100644 --- a/pdfexport/render_title.go +++ b/pdfexport/render_title.go @@ -10,25 +10,24 @@ import ( ) func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg RenderConfig) { - pdf.AddPage() pageW, pageH := pdf.GetPageSize() margin := 12.0 contentWidth := pageW - 2*margin categoryTotals := summarizeCategoryTotals(puzzles) pdf.SetTextColor(20, 20, 20) - pdf.SetFont(sansFontFamily, "B", 22) + pdf.SetFont(sansFontFamily, "B", 22+pdfFontSizeDelta) pdf.SetXY(0, 24) pdf.CellFormat(pageW, 10, fmt.Sprintf("PuzzleTea Volume %02d", cfg.VolumeNumber), "", 0, "C", false, 0, "") pdf.SetTextColor(50, 50, 50) - pdf.SetFont(coverFontFamily, "", 16) + pdf.SetFont(coverFontFamily, "", 16+pdfFontSizeDelta) pdf.SetXY(0, 35) pdf.CellFormat(pageW, 8, cfg.CoverSubtitle, "", 0, "C", false, 0, "") bodyY := 49.0 if header := strings.TrimSpace(cfg.HeaderText); header != "" { - pdf.SetFont(sansFontFamily, "", 9.2) + pdf.SetFont(sansFontFamily, "", 9.2+pdfFontSizeDelta) pdf.SetTextColor(74, 74, 74) wrappedHeader := pdf.SplitLines([]byte(header), contentWidth-20) if len(wrappedHeader) == 0 { @@ -45,7 +44,7 @@ func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg introLines := splitCoverTextLines(pdf, cfg.AdvertText, contentWidth) if len(introLines) > 0 { pdf.SetTextColor(58, 58, 58) - pdf.SetFont(sansFontFamily, "", 9.3) + pdf.SetFont(sansFontFamily, "", 9.3+pdfFontSizeDelta) for _, line := range introLines { pdf.SetXY(margin, bodyY) pdf.CellFormat(contentWidth, 4.8, line, "", 0, "C", false, 0, "") @@ -55,12 +54,12 @@ func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg } pdf.SetTextColor(40, 40, 40) - pdf.SetFont(sansFontFamily, "B", 9.6) + pdf.SetFont(sansFontFamily, "B", 9.6+pdfFontSizeDelta) pdf.SetXY(margin, bodyY) pdf.CellFormat(contentWidth, 5.4, fmt.Sprintf("%d puzzles across %d categories", len(puzzles), len(categoryTotals)), "", 0, "C", false, 0, "") bodyY += 7.6 - pdf.SetFont(sansFontFamily, "B", 10) + pdf.SetFont(sansFontFamily, "B", 10+pdfFontSizeDelta) pdf.SetTextColor(45, 45, 45) pdf.SetXY(margin, bodyY) pdf.CellFormat(contentWidth, 6, "Inside This Volume", "", 0, "C", false, 0, "") @@ -69,14 +68,14 @@ func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg renderCategoryOverview(pdf, categoryTotals, margin, bodyY, contentWidth, pageH-32) pdf.SetTextColor(50, 50, 50) - pdf.SetFont(sansFontFamily, "B", 12) + pdf.SetFont(sansFontFamily, "B", 12+pdfFontSizeDelta) footerTitleY := pageH - 31.0 pdf.SetXY(margin, footerTitleY) pdf.CellFormat(contentWidth, 7, "Made with PuzzleTea", "", 0, "C", false, 0, "") colophon := titlePageColophon(docs, cfg.GeneratedAt) if colophon != "" { - pdf.SetFont(sansFontFamily, "", 7.8) + pdf.SetFont(sansFontFamily, "", 7.8+pdfFontSizeDelta) pdf.SetTextColor(112, 112, 112) pdf.SetXY(margin, footerTitleY+8.0) pdf.CellFormat(contentWidth, 4.0, colophon, "", 0, "C", false, 0, "") @@ -165,7 +164,7 @@ func renderCategoryOverview( } pdf.SetTextColor(ruleTextGray, ruleTextGray, ruleTextGray) - pdf.SetFont(sansFontFamily, "", 9.0) + pdf.SetFont(sansFontFamily, "", 9.0+pdfFontSizeDelta) containerWidth := min(width, min(maxWidth, width*widthScale)) containerX := x + (width-containerWidth)/2 diff --git a/pdfexport/render_tokens.go b/pdfexport/render_tokens.go index 704432d..733d8fc 100644 --- a/pdfexport/render_tokens.go +++ b/pdfexport/render_tokens.go @@ -9,6 +9,8 @@ import ( const ( halfLetterWidthMM = 139.7 halfLetterHeightMM = 215.9 + letterWidthMM = 279.4 + letterHeightMM = 215.9 footerTextGray = 78 secondaryTextGray = 60 @@ -29,12 +31,18 @@ const ( outerBorderLineMM = 0.62 ) +var logicalPageNumberOverride int + const ( - puzzleTitleFontSize = 13.0 - puzzleSubtitleFontSize = 9.0 - puzzleInstructionFontSize = 8.2 - puzzleWordBankFontSize = 8.8 - puzzleWordBankHeadSize = 9.2 + puzzleTitleFontSize = 13.0 + pdfFontSizeDelta + puzzleSubtitleFontSize = 9.0 + pdfFontSizeDelta + puzzleInstructionFontSize = 7.0 + pdfFontSizeDelta + puzzleWordBankFontSize = 8.8 + pdfFontSizeDelta + puzzleWordBankHeadSize = 9.2 + pdfFontSizeDelta + difficultyStarSizeMM = 3.5 + difficultyStarGapMM = 0.9 + difficultyTextToStarsGapMM = 2.1 + difficultyStarOutlineMM = 0.26 ) type ( @@ -84,6 +92,7 @@ func puzzleBoardRect(pageW, pageH float64, pageNo, ruleLines int) rectMM { } func puzzleHorizontalMargins(pageNo int) (left, right float64) { + pageNo = effectiveLogicalPageNumber(pageNo) left = pageMarginXMM right = pageMarginXMM if pageNo <= 1 { @@ -99,6 +108,22 @@ func puzzleHorizontalMargins(pageNo int) (left, right float64) { return left, right } +func effectiveLogicalPageNumber(pageNo int) int { + if logicalPageNumberOverride > 0 { + return logicalPageNumberOverride + } + return pageNo +} + +func withLogicalPageNumber(pageNo int, fn func() error) error { + previous := logicalPageNumberOverride + logicalPageNumberOverride = pageNo + defer func() { + logicalPageNumberOverride = previous + }() + return fn() +} + func centeredOrigin(area rectMM, cols, rows int, cellSize float64) (float64, float64) { boardW := float64(cols) * cellSize boardH := float64(rows) * cellSize diff --git a/pdfexport/types.go b/pdfexport/types.go index 3179b14..40d99ac 100644 --- a/pdfexport/types.go +++ b/pdfexport/types.go @@ -2,6 +2,13 @@ package pdfexport import "time" +type SheetLayout int + +const ( + SheetLayoutHalfLetter SheetLayout = iota + SheetLayoutDuplexBooklet +) + type PackMetadata struct { GeneratedRaw string GeneratedAt time.Time @@ -135,8 +142,6 @@ type GridTable struct { HasHeaderCol bool } -type RGB struct{ R, G, B uint8 } - type RenderConfig struct { Title string CoverSubtitle string @@ -145,5 +150,5 @@ type RenderConfig struct { AdvertText string GeneratedAt time.Time ShuffleSeed string - CoverColor *RGB // nil = omit front/back covers + SheetLayout SheetLayout } diff --git a/release-volume-1.sh b/release-volume-1.sh new file mode 100755 index 0000000..88bac12 --- /dev/null +++ b/release-volume-1.sh @@ -0,0 +1,304 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PUZZLETEA_BIN="${PUZZLETEA_BIN:-$SCRIPT_DIR/puzzletea}" +OUT_DIR="$SCRIPT_DIR/out" +PARTS_DIR="$OUT_DIR/parts" +MERGED_JSONL="$OUT_DIR/puzzletea-volume-1.jsonl" +PDF_OUTPUT="$OUT_DIR/puzzletea-volume-1.0.pdf" +BASE_SEED="${BASE_SEED:-tff 2026}" +PDF_TITLE="${PDF_TITLE:-TFF 2026 Preview}" +PDF_ADVERT="${PDF_ADVERT:-Generated via a custom-coded puzzle engine, this collection is a modern tribute to the legacy of Nikoli. Created by Dami Etoile.}" +PDF_SHEET_LAYOUT="${PDF_SHEET_LAYOUT:-duplex-booklet}" + +EXPECTED_CATEGORY_COUNT=13 +MAX_PUZZLE_PAGES=8 +EXPECTED_TOTAL_COUNT=$(((MAX_PUZZLE_PAGES * 4) + 2)) + +if [[ ! -x "$PUZZLETEA_BIN" ]]; then + echo "expected built executable at $PUZZLETEA_BIN" >&2 + echo "build it first, for example: go build -o \"$SCRIPT_DIR/puzzletea\"" >&2 + exit 1 +fi + +mkdir -p "$OUT_DIR" "$PARTS_DIR" + +slugify() { + printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed \ + -e 's/+/-plus-/g' \ + -e 's/[^a-z0-9]/-/g' \ + -e 's/--*/-/g' \ + -e 's/^-//' \ + -e 's/-$//' +} + +set_modes_for_game() { + case "$1" in + "Fillomino") + MODES=("Mini 5x5" "Easy 6x6" "Medium 8x8" "Hard 10x10" "Expert 12x12") + ;; + "Hashiwokakero") + MODES=( + "Easy 7x7" "Medium 7x7" "Hard 7x7" + "Easy 9x9" "Medium 9x9" "Hard 9x9" + "Easy 11x11" "Medium 11x11" "Hard 11x11" + "Easy 13x13" "Medium 13x13" "Hard 13x13" + ) + ;; + "Hitori") + MODES=("Mini" "Easy" "Medium" "Tricky" "Hard" "Expert") + ;; + "Nonogram") + MODES=("Mini" "Pocket" "Teaser" "Standard" "Classic" "Tricky" "Large" "Grand" "Epic" "Massive") + ;; + "Nurikabe") + MODES=("Mini" "Easy" "Medium" "Hard" "Expert") + ;; + "Ripple Effect") + MODES=("Mini 5x5" "Easy 6x6" "Medium 7x7" "Hard 8x8" "Expert 9x9") + ;; + "Shikaku") + MODES=("Mini 5x5" "Easy 7x7" "Medium 8x8" "Hard 10x10" "Expert 12x12") + ;; + "Sudoku") + MODES=("Beginner" "Easy" "Medium" "Hard" "Expert" "Diabolical") + ;; + "Sudoku RGB") + MODES=("Beginner" "Easy" "Medium" "Hard" "Expert" "Diabolical") + ;; + "Takuzu") + MODES=("Beginner" "Easy" "Medium" "Tricky" "Hard" "Very Hard" "Extreme") + ;; + "Takuzu+") + MODES=("Beginner" "Easy" "Medium" "Tricky" "Hard" "Very Hard" "Extreme") + ;; + "Word Search") + MODES=("Easy 10x10" "Medium 15x15" "Hard 20x20") + ;; + *) + echo "unknown game manifest entry: $1" >&2 + exit 1 + ;; + esac +} + +allocate_counts() { + local total="$1" + local mode_count="$2" + local i + local base + local remainder + local count + + ALLOC_COUNTS=() + if (( mode_count <= 0 )); then + return + fi + + base=$((total / mode_count)) + remainder=$((total % mode_count)) + for ((i = 0; i < mode_count; i++)); do + count="$base" + if (( i < remainder )); then + count=$((count + 1)) + fi + ALLOC_COUNTS+=("$count") + done +} + +allocate_category_targets() { + local total="$1" + local category_count="$2" + + CATEGORY_TARGETS=() + if (( category_count <= 0 )); then + return + fi + + allocate_counts "$total" "$category_count" + CATEGORY_TARGETS=("${ALLOC_COUNTS[@]}") +} + +bucket_targets_for_total() { + local total="$1" + + case "$total" in + 6) + BUCKET_TARGETS=(3 2 1) + ;; + 5) + BUCKET_TARGETS=(3 1 1) + ;; + 4) + BUCKET_TARGETS=(2 1 1) + ;; + 3) + BUCKET_TARGETS=(1 1 1) + ;; + 2) + BUCKET_TARGETS=(0 1 1) + ;; + *) + echo "unsupported per-category target ${total}" >&2 + exit 1 + ;; + esac +} + +append_bucket_exports() { + local game="$1" + local game_slug="$2" + local bucket="$3" + local target="$4" + local category_file="$5" + shift 5 + + local bucket_modes=("$@") + local mode_count="${#bucket_modes[@]}" + local i + local count + local mode + local mode_slug + local seed + local temp_file + + if (( mode_count == 0 || target == 0 )); then + return + fi + + allocate_counts "$target" "$mode_count" + for ((i = 0; i < mode_count; i++)); do + count="${ALLOC_COUNTS[$i]}" + if (( count == 0 )); then + continue + fi + + mode="${bucket_modes[$i]}" + mode_slug="$(slugify "$mode")" + seed="${BASE_SEED}:${game_slug}:${bucket}:${mode_slug}" + temp_file="$(mktemp "${TMPDIR:-/tmp}/${game_slug}-${bucket}-${mode_slug}.XXXXXX.jsonl")" + + echo "Generating ${count} ${bucket} puzzle(s) for ${game} / ${mode}" + "$PUZZLETEA_BIN" new "$game" "$mode" -e "$count" -o "$temp_file" --with-seed "$seed" + cat "$temp_file" >> "$category_file" + rm -f "$temp_file" + done +} + +generate_category_pack() { + local game="$1" + local target_total="$2" + local game_slug + local category_file + local total_modes + local i + local bucket_index + + local easy_modes=() + local medium_modes=() + local hard_modes=() + + set_modes_for_game "$game" + game_slug="$(slugify "$game")" + category_file="$PARTS_DIR/${game_slug}.jsonl" + : > "$category_file" + + total_modes="${#MODES[@]}" + for ((i = 0; i < total_modes; i++)); do + bucket_index=$((i * 3 / total_modes)) + case "$bucket_index" in + 0) + easy_modes+=("${MODES[$i]}") + ;; + 1) + medium_modes+=("${MODES[$i]}") + ;; + 2) + hard_modes+=("${MODES[$i]}") + ;; + *) + echo "invalid bucket index ${bucket_index} for ${game}" >&2 + exit 1 + ;; + esac + done + + bucket_targets_for_total "$target_total" + + append_bucket_exports "$game" "$game_slug" "easy" "${BUCKET_TARGETS[0]}" "$category_file" "${easy_modes[@]}" + append_bucket_exports "$game" "$game_slug" "medium" "${BUCKET_TARGETS[1]}" "$category_file" "${medium_modes[@]}" + append_bucket_exports "$game" "$game_slug" "hard" "${BUCKET_TARGETS[2]}" "$category_file" "${hard_modes[@]}" + + local line_count + line_count="$(wc -l < "$category_file" | tr -d '[:space:]')" + if [[ "$line_count" != "$target_total" ]]; then + echo "expected ${target_total} puzzles for ${game}, got ${line_count}" >&2 + exit 1 + fi +} + +render_pdf() { + local cmd=( + "$PUZZLETEA_BIN" + export-pdf + -o "$PDF_OUTPUT" + --volume 1 + --title "$PDF_TITLE" + --shuffle-seed "$BASE_SEED" + --sheet-layout "$PDF_SHEET_LAYOUT" + ) + local game + + for game in "${GAMES[@]}"; do + cmd+=("$PARTS_DIR/$(slugify "$game").jsonl") + done + + if [[ -n "${PDF_HEADER:-}" ]]; then + cmd+=(--header "$PDF_HEADER") + fi + if [[ -n "${PDF_ADVERT:-}" ]]; then + cmd+=(--advert "$PDF_ADVERT") + fi + + "${cmd[@]}" +} + +GAMES=( + "Fillomino" + "Hashiwokakero" + "Hitori" + "Nonogram" + "Nurikabe" + "Ripple Effect" + "Shikaku" + "Sudoku" + "Sudoku RGB" + "Takuzu" + "Takuzu+" + "Word Search" +) + +: > "$MERGED_JSONL" + +allocate_category_targets "$EXPECTED_TOTAL_COUNT" "${#GAMES[@]}" + +for i in "${!GAMES[@]}"; do + generate_category_pack "${GAMES[$i]}" "${CATEGORY_TARGETS[$i]}" +done + +for game in "${GAMES[@]}"; do + cat "$PARTS_DIR/$(slugify "$game").jsonl" >> "$MERGED_JSONL" +done + +line_count="$(wc -l < "$MERGED_JSONL" | tr -d '[:space:]')" +if [[ "$line_count" != "$EXPECTED_TOTAL_COUNT" ]]; then + echo "expected ${EXPECTED_TOTAL_COUNT} puzzles in merged jsonl, got ${line_count}" >&2 + exit 1 +fi + +render_pdf + +echo "Wrote $MERGED_JSONL" +echo "Wrote $PDF_OUTPUT" diff --git a/rippleeffect/print_adapter.go b/rippleeffect/print_adapter.go index e266d04..063b138 100644 --- a/rippleeffect/print_adapter.go +++ b/rippleeffect/print_adapter.go @@ -33,7 +33,10 @@ func renderRippleEffectPage(pdf *fpdf.Fpdf, data *pdfexport.RippleEffectData) { pageW, pageH := pdf.GetPageSize() pageNo := pdf.PageNo() - area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 2) + body := pdfexport.PuzzleBodyRect(pageW, pageH, pageNo) + rules := []string{"Each cage uses 1..n once; equal digits must be at least their value apart in rows and columns."} + ruleLines := pdfexport.InstructionLineCount(pdf, body.W, rules) + area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, ruleLines) cellSize := pdfexport.FitCompactCellSize(data.Width, data.Height, area) if cellSize <= 0 { return @@ -97,20 +100,8 @@ func renderRippleEffectPage(pdf *fpdf.Fpdf, data *pdfexport.RippleEffectData) { } } - ruleY := pdfexport.InstructionY(startY+blockH, pageH, 2) - pdfexport.SetInstructionStyle(pdf) - pdf.SetXY(area.X, ruleY) - pdf.CellFormat( - area.W, - pdfexport.InstructionLineHMM, - "Each cage uses 1..n once; equal digits must be at least their value apart in rows and columns.", - "", - 0, - "C", - false, - 0, - "", - ) + ruleY := pdfexport.InstructionY(startY+blockH, pageH, ruleLines) + pdfexport.RenderInstructions(pdf, body.X, ruleY, body.W, rules) } func drawRippleEffectGiven(pdf *fpdf.Fpdf, x, y, cellSize float64, text string) { diff --git a/rippleeffect/testdata/visual_states.jsonl b/rippleeffect/testdata/visual_states.jsonl index 9126a03..d22f6dd 100644 --- a/rippleeffect/testdata/visual_states.jsonl +++ b/rippleeffect/testdata/visual_states.jsonl @@ -1,2 +1,3 @@ -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Ripple Effect","mode_selection":"Visual Fixture","count":2,"seed":""},"puzzle":{"index":1,"name":"incomplete-cages","game":"Ripple Effect","mode":"Visual Fixture","save":{"width":3,"height":3,"state":"1 2 .\n2 . 1\n. 1 2","givens":". . .\n. . .\n. . .","cages":[{"id":0,"size":3,"cells":[{"x":0,"y":0},{"x":1,"y":0},{"x":2,"y":0}]},{"id":1,"size":3,"cells":[{"x":0,"y":1},{"x":1,"y":1},{"x":2,"y":1}]},{"id":2,"size":3,"cells":[{"x":0,"y":2},{"x":1,"y":2},{"x":2,"y":2}]}],"mode_title":"Visual Fixture"}}} -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Ripple Effect","mode_selection":"Visual Fixture","count":2,"seed":""},"puzzle":{"index":2,"name":"solved-sample-grid","game":"Ripple Effect","mode":"Visual Fixture","save":{"width":3,"height":3,"state":"1 2 3\n2 3 1\n3 1 2","givens":". . .\n. . .\n. . .","cages":[{"id":0,"size":3,"cells":[{"x":0,"y":0},{"x":1,"y":0},{"x":2,"y":0}]},{"id":1,"size":3,"cells":[{"x":0,"y":1},{"x":1,"y":1},{"x":2,"y":1}]},{"id":2,"size":3,"cells":[{"x":0,"y":2},{"x":1,"y":2},{"x":2,"y":2}]}],"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Ripple Effect","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":1,"name":"incomplete-cages","game":"Ripple Effect","mode":"Visual Fixture","save":{"width":3,"height":3,"state":"1 2 .\n2 . 1\n. 1 2","givens":". . .\n. . .\n. . .","cages":[{"id":0,"size":3,"cells":[{"x":0,"y":0},{"x":1,"y":0},{"x":2,"y":0}]},{"id":1,"size":3,"cells":[{"x":0,"y":1},{"x":1,"y":1},{"x":2,"y":1}]},{"id":2,"size":3,"cells":[{"x":0,"y":2},{"x":1,"y":2},{"x":2,"y":2}]}],"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Ripple Effect","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":2,"name":"spacing-conflict","game":"Ripple Effect","mode":"Visual Fixture","save":{"width":3,"height":3,"state":"1 1 .\n. . .\n. . .","givens":". . .\n. . .\n. . .","cages":[{"id":0,"size":3,"cells":[{"x":0,"y":0},{"x":1,"y":0},{"x":2,"y":0}]},{"id":1,"size":3,"cells":[{"x":0,"y":1},{"x":1,"y":1},{"x":2,"y":1}]},{"id":2,"size":3,"cells":[{"x":0,"y":2},{"x":1,"y":2},{"x":2,"y":2}]}],"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Ripple Effect","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":3,"name":"solved-sample-grid","game":"Ripple Effect","mode":"Visual Fixture","save":{"width":3,"height":3,"state":"1 2 3\n2 3 1\n3 1 2","givens":". . .\n. . .\n. . .","cages":[{"id":0,"size":3,"cells":[{"x":0,"y":0},{"x":1,"y":0},{"x":2,"y":0}]},{"id":1,"size":3,"cells":[{"x":0,"y":1},{"x":1,"y":1},{"x":2,"y":1}]},{"id":2,"size":3,"cells":[{"x":0,"y":2},{"x":1,"y":2},{"x":2,"y":2}]}],"mode_title":"Visual Fixture"}}} diff --git a/shikaku/print_adapter.go b/shikaku/print_adapter.go index fc5546c..3af0cee 100644 --- a/shikaku/print_adapter.go +++ b/shikaku/print_adapter.go @@ -34,7 +34,10 @@ func renderShikakuPage(pdf *fpdf.Fpdf, data *pdfexport.ShikakuData) { pageW, pageH := pdf.GetPageSize() pageNo := pdf.PageNo() - area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1) + body := pdfexport.PuzzleBodyRect(pageW, pageH, pageNo) + rules := []string{"Partition into rectangles where each clue equals its rectangle area."} + ruleLines := pdfexport.InstructionLineCount(pdf, body.W, rules) + area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, ruleLines) cellSize := pdfexport.FitCompactCellSize(data.Width, data.Height, area) if cellSize <= 0 { return @@ -63,20 +66,8 @@ func renderShikakuPage(pdf *fpdf.Fpdf, data *pdfexport.ShikakuData) { pdf.SetLineWidth(pdfexport.OuterBorderLineMM) pdf.Rect(startX, startY, blockW, blockH, "D") - ruleY := pdfexport.InstructionY(startY+blockH, pageH, 1) - pdfexport.SetInstructionStyle(pdf) - pdf.SetXY(area.X, ruleY) - pdf.CellFormat( - area.W, - pdfexport.InstructionLineHMM, - "Partition into rectangles where each clue equals its rectangle area.", - "", - 0, - "C", - false, - 0, - "", - ) + ruleY := pdfexport.InstructionY(startY+blockH, pageH, ruleLines) + pdfexport.RenderInstructions(pdf, body.X, ruleY, body.W, rules) } func drawShikakuClue(pdf *fpdf.Fpdf, x, y, cellSize float64, value int) { diff --git a/shikaku/testdata/visual_states.jsonl b/shikaku/testdata/visual_states.jsonl index 94dd2a2..5baa384 100644 --- a/shikaku/testdata/visual_states.jsonl +++ b/shikaku/testdata/visual_states.jsonl @@ -1,2 +1,3 @@ -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Shikaku","mode_selection":"Visual Fixture","count":2,"seed":""},"puzzle":{"index":1,"name":"empty-rectangles","game":"Shikaku","mode":"Visual Fixture","save":{"width":2,"height":2,"clues":[{"id":0,"x":0,"y":0,"value":2},{"id":1,"x":1,"y":1,"value":2}],"rectangles":null,"mode_title":"Visual Fixture"}}} -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Shikaku","mode_selection":"Visual Fixture","count":2,"seed":""},"puzzle":{"index":2,"name":"solved-vertical-split","game":"Shikaku","mode":"Visual Fixture","save":{"width":2,"height":2,"clues":[{"id":0,"x":0,"y":0,"value":2},{"id":1,"x":1,"y":1,"value":2}],"rectangles":[{"clue_id":0,"x":0,"y":0,"w":1,"h":2},{"clue_id":1,"x":1,"y":0,"w":1,"h":2}],"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Shikaku","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":1,"name":"empty-rectangles","game":"Shikaku","mode":"Visual Fixture","save":{"width":2,"height":2,"clues":[{"id":0,"x":0,"y":0,"value":2},{"id":1,"x":1,"y":1,"value":2}],"rectangles":null,"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Shikaku","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":2,"name":"partially-placed-rectangles","game":"Shikaku","mode":"Visual Fixture","save":{"width":2,"height":2,"clues":[{"id":0,"x":0,"y":0,"value":2},{"id":1,"x":1,"y":1,"value":2}],"rectangles":[{"clue_id":0,"x":0,"y":0,"w":1,"h":2}],"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Shikaku","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":3,"name":"solved-vertical-split","game":"Shikaku","mode":"Visual Fixture","save":{"width":2,"height":2,"clues":[{"id":0,"x":0,"y":0,"value":2},{"id":1,"x":1,"y":1,"value":2}],"rectangles":[{"clue_id":0,"x":0,"y":0,"w":1,"h":2},{"clue_id":1,"x":1,"y":0,"w":1,"h":2}],"mode_title":"Visual Fixture"}}} diff --git a/spellpuzzle/print_adapter.go b/spellpuzzle/print_adapter.go index 0231f93..87159ea 100644 --- a/spellpuzzle/print_adapter.go +++ b/spellpuzzle/print_adapter.go @@ -80,7 +80,10 @@ func renderSpellPuzzlePage(pdf *fpdf.Fpdf, data *printPayload) { pageW, pageH := pdf.GetPageSize() pageNo := pdf.PageNo() - area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1) + body := pdfexport.PuzzleBodyRect(pageW, pageH, pageNo) + rules := []string{"Form words from the letter bank to fill the crossword"} + ruleLines := pdfexport.InstructionLineCount(pdf, body.W, rules) + area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, ruleLines) layout, ok := computePrintLayout(area, data) if !ok { return @@ -90,20 +93,8 @@ func renderSpellPuzzlePage(pdf *fpdf.Fpdf, data *printPayload) { drawSpellPuzzleBank(pdf, data.Bank, layout) contentBottom := layout.bankY + layout.tileSize - ruleY := pdfexport.InstructionY(contentBottom, pageH, 1) - pdfexport.SetInstructionStyle(pdf) - pdf.SetXY(area.X, ruleY) - pdf.CellFormat( - area.W, - pdfexport.InstructionLineHMM, - "Form words from the letter bank to fill the crossword", - "", - 0, - "C", - false, - 0, - "", - ) + ruleY := pdfexport.InstructionY(contentBottom, pageH, ruleLines) + pdfexport.RenderInstructions(pdf, body.X, ruleY, body.W, rules) } func computePrintLayout(area pdfexport.Rect, data *printPayload) (printLayout, bool) { diff --git a/sudoku/print_adapter.go b/sudoku/print_adapter.go index 7d54657..ccdc06a 100644 --- a/sudoku/print_adapter.go +++ b/sudoku/print_adapter.go @@ -33,7 +33,10 @@ func renderSudokuPage(pdf *fpdf.Fpdf, data *pdfexport.SudokuData) { pageW, pageH := pdf.GetPageSize() pageNo := pdf.PageNo() - area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1) + body := pdfexport.PuzzleBodyRect(pageW, pageH, pageNo) + rules := []string{"Fill rows, columns, and 3x3 boxes with 1-9"} + ruleLines := pdfexport.InstructionLineCount(pdf, body.W, rules) + area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, ruleLines) cellSize := pdfexport.FitSudokuCellSize(9, 9, area) if cellSize <= 0 { return @@ -45,20 +48,8 @@ func renderSudokuPage(pdf *fpdf.Fpdf, data *pdfexport.SudokuData) { drawSudokuGridLines(pdf, startX, startY, cellSize) drawSudokuGivens(pdf, startX, startY, cellSize, data.Givens) - ruleY := pdfexport.InstructionY(startY+boardH, pageH, 1) - pdfexport.SetInstructionStyle(pdf) - pdf.SetXY(area.X, ruleY) - pdf.CellFormat( - area.W, - pdfexport.InstructionLineHMM, - "Fill rows, columns, and 3x3 boxes with 1-9", - "", - 0, - "C", - false, - 0, - "", - ) + ruleY := pdfexport.InstructionY(startY+boardH, pageH, ruleLines) + pdfexport.RenderInstructions(pdf, body.X, ruleY, body.W, rules) } func drawSudokuGridLines(pdf *fpdf.Fpdf, startX, startY, cellSize float64) { diff --git a/sudoku/testdata/visual_states.jsonl b/sudoku/testdata/visual_states.jsonl index d59f6a2..c8b15db 100644 --- a/sudoku/testdata/visual_states.jsonl +++ b/sudoku/testdata/visual_states.jsonl @@ -1,3 +1,4 @@ -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Sudoku","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":1,"name":"provided-top-left","game":"Sudoku","mode":"Visual Fixture","save":{"grid":"500000000\n030000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000","provided":[{"x":0,"y":0,"v":5},{"x":1,"y":1,"v":3}],"mode_title":"Visual Fixture"}}} -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Sudoku","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":2,"name":"row-conflict","game":"Sudoku","mode":"Visual Fixture","save":{"grid":"550000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000","provided":null,"mode_title":"Visual Fixture"}}} -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Sudoku","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":3,"name":"solved-complete-grid","game":"Sudoku","mode":"Visual Fixture","save":{"grid":"534678912\n672195348\n198342567\n859761423\n426853791\n713924856\n961537284\n287419635\n345286179","provided":null,"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Sudoku","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":1,"name":"provided-top-left","game":"Sudoku","mode":"Visual Fixture","save":{"grid":"500000000\n030000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000","provided":[{"x":0,"y":0,"v":5},{"x":1,"y":1,"v":3}],"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Sudoku","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":2,"name":"row-conflict","game":"Sudoku","mode":"Visual Fixture","save":{"grid":"550000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000","provided":null,"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Sudoku","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":3,"name":"box-conflict-with-provided","game":"Sudoku","mode":"Visual Fixture","save":{"grid":"500000000\n050000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000","provided":[{"x":0,"y":0,"v":5}],"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Sudoku","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":4,"name":"solved-complete-grid","game":"Sudoku","mode":"Visual Fixture","save":{"grid":"534678912\n672195348\n198342567\n859761423\n426853791\n713924856\n961537284\n287419635\n345286179","provided":null,"mode_title":"Visual Fixture"}}} diff --git a/sudokurgb/print_adapter.go b/sudokurgb/print_adapter.go index 3dafc36..849f25b 100644 --- a/sudokurgb/print_adapter.go +++ b/sudokurgb/print_adapter.go @@ -35,7 +35,10 @@ func renderSudokuRGBPage(pdf *fpdf.Fpdf, data *pdfexport.SudokuData) { pageW, pageH := pdf.GetPageSize() pageNo := pdf.PageNo() - area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1) + body := pdfexport.PuzzleBodyRect(pageW, pageH, pageNo) + rules := []string{"Fill rows, columns, and 3x3 boxes with three 1s, three 2s, and three 3s"} + ruleLines := pdfexport.InstructionLineCount(pdf, body.W, rules) + area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, ruleLines) cellSize := pdfexport.FitSudokuCellSize(9, 9, area) if cellSize <= 0 { return @@ -47,20 +50,8 @@ func renderSudokuRGBPage(pdf *fpdf.Fpdf, data *pdfexport.SudokuData) { drawSudokuGridLines(pdf, startX, startY, cellSize) drawSudokuRGBGivens(pdf, startX, startY, cellSize, data.Givens) - ruleY := pdfexport.InstructionY(startY+boardH, pageH, 1) - pdfexport.SetInstructionStyle(pdf) - pdf.SetXY(area.X, ruleY) - pdf.CellFormat( - area.W, - pdfexport.InstructionLineHMM, - "Fill rows, columns, and 3x3 boxes with three 1s, three 2s, and three 3s", - "", - 0, - "C", - false, - 0, - "", - ) + ruleY := pdfexport.InstructionY(startY+boardH, pageH, ruleLines) + pdfexport.RenderInstructions(pdf, body.X, ruleY, body.W, rules) } func drawSudokuGridLines(pdf *fpdf.Fpdf, startX, startY, cellSize float64) { diff --git a/sudokurgb/testdata/visual_states.jsonl b/sudokurgb/testdata/visual_states.jsonl index fa2fad6..1d751cf 100644 --- a/sudokurgb/testdata/visual_states.jsonl +++ b/sudokurgb/testdata/visual_states.jsonl @@ -1,3 +1,4 @@ -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Sudoku RGB","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":1,"name":"provided-top-left","game":"Sudoku RGB","mode":"Visual Fixture","save":{"grid":"100000000\n020000000\n003000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000","provided":[{"x":0,"y":0,"v":1},{"x":1,"y":1,"v":2},{"x":2,"y":2,"v":3}],"mode_title":"Visual Fixture"}}} -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Sudoku RGB","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":2,"name":"row-overquota","game":"Sudoku RGB","mode":"Visual Fixture","save":{"grid":"111100000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000","provided":null,"mode_title":"Visual Fixture"}}} -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Sudoku RGB","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":3,"name":"solved-complete-grid","game":"Sudoku RGB","mode":"Visual Fixture","save":{"grid":"123123123\n231231231\n312312312\n123123123\n231231231\n312312312\n123123123\n231231231\n312312312","provided":null,"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Sudoku RGB","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":1,"name":"provided-top-left","game":"Sudoku RGB","mode":"Visual Fixture","save":{"grid":"100000000\n020000000\n003000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000","provided":[{"x":0,"y":0,"v":1},{"x":1,"y":1,"v":2},{"x":2,"y":2,"v":3}],"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Sudoku RGB","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":2,"name":"row-overquota","game":"Sudoku RGB","mode":"Visual Fixture","save":{"grid":"111100000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000","provided":null,"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Sudoku RGB","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":3,"name":"box-overquota-with-provided","game":"Sudoku RGB","mode":"Visual Fixture","save":{"grid":"100000000\n110000000\n001000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000","provided":[{"x":0,"y":0,"v":1}],"mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Sudoku RGB","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":4,"name":"solved-complete-grid","game":"Sudoku RGB","mode":"Visual Fixture","save":{"grid":"123123123\n231231231\n312312312\n123123123\n231231231\n312312312\n123123123\n231231231\n312312312","provided":null,"mode_title":"Visual Fixture"}}} diff --git a/takuzu/print_adapter.go b/takuzu/print_adapter.go index 4635abe..478d932 100644 --- a/takuzu/print_adapter.go +++ b/takuzu/print_adapter.go @@ -16,6 +16,11 @@ var defaultTakuzuRules = []string{ "Each row/column has equal 0 and 1 counts, and rows/columns are unique.", } +type takuzuRelationSizing struct { + fontSize float64 + backdropSize float64 +} + func (printAdapter) CanonicalGameType() string { return "Takuzu" } func (printAdapter) Aliases() []string { return []string{"takuzu"} } @@ -46,7 +51,9 @@ func RenderTakuzuPDFBodyWithRules(pdf *fpdf.Fpdf, data *pdfexport.TakuzuData, ru size := data.Size pageW, pageH := pdf.GetPageSize() pageNo := pdf.PageNo() - area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, len(rules)) + body := pdfexport.PuzzleBodyRect(pageW, pageH, pageNo) + ruleLines := pdfexport.InstructionLineCount(pdf, body.W, rules) + area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, ruleLines) cellSize := pdfexport.FitCompactCellSize(size, size, area) if cellSize <= 0 { return @@ -99,22 +106,8 @@ func RenderTakuzuPDFBodyWithRules(pdf *fpdf.Fpdf, data *pdfexport.TakuzuData, ru pdf.SetLineWidth(pdfexport.OuterBorderLineMM) pdf.Rect(startX, startY, blockW, blockH, "D") - ruleY := pdfexport.InstructionY(startY+blockH, pageH, len(rules)) - pdfexport.SetInstructionStyle(pdf) - for i, rule := range rules { - pdf.SetXY(area.X, ruleY+float64(i)*pdfexport.InstructionLineHMM) - pdf.CellFormat( - area.W, - pdfexport.InstructionLineHMM, - rule, - "", - 0, - "C", - false, - 0, - "", - ) - } + ruleY := pdfexport.InstructionY(startY+blockH, pageH, ruleLines) + pdfexport.RenderInstructions(pdf, body.X, ruleY, body.W, rules) } func drawTakuzuRelations(pdf *fpdf.Fpdf, data *pdfexport.TakuzuData, startX, startY, cellSize float64) { @@ -123,8 +116,8 @@ func drawTakuzuRelations(pdf *fpdf.Fpdf, data *pdfexport.TakuzuData, startX, sta } pdf.SetTextColor(95, 95, 95) - fontSize := takuzuRelationFontSize(cellSize, data.Size) - pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize) + sizing := takuzuRelationSizingFor(cellSize, data.Size) + pdf.SetFont(pdfexport.SansFontFamily, "B", sizing.fontSize) pdf.SetFillColor(255, 255, 255) for y, row := range data.HorizontalRelations { @@ -135,7 +128,7 @@ func drawTakuzuRelations(pdf *fpdf.Fpdf, data *pdfexport.TakuzuData, startX, sta centerX := startX + float64(x+1)*cellSize centerY := startY + float64(y)*cellSize + cellSize/2 - drawTakuzuRelation(pdf, centerX, centerY, cellSize, fontSize, value) + drawTakuzuRelation(pdf, centerX, centerY, sizing, value) } } @@ -147,20 +140,19 @@ func drawTakuzuRelations(pdf *fpdf.Fpdf, data *pdfexport.TakuzuData, startX, sta centerX := startX + float64(x)*cellSize + cellSize/2 centerY := startY + float64(y+1)*cellSize - drawTakuzuRelation(pdf, centerX, centerY, cellSize, fontSize, value) + drawTakuzuRelation(pdf, centerX, centerY, sizing, value) } } } -func drawTakuzuRelation(pdf *fpdf.Fpdf, centerX, centerY, cellSize, fontSize float64, value string) { - boxSize := takuzuRelationBackdropSize(cellSize, fontSize) - left := centerX - boxSize/2 - top := centerY - boxSize/2 - lineH := fontSize * 0.9 +func drawTakuzuRelation(pdf *fpdf.Fpdf, centerX, centerY float64, sizing takuzuRelationSizing, value string) { + left := centerX - sizing.backdropSize/2 + top := centerY - sizing.backdropSize/2 + lineH := sizing.fontSize * 0.9 - pdf.Rect(left, top, boxSize, boxSize, "F") + pdf.Rect(left, top, sizing.backdropSize, sizing.backdropSize, "F") pdf.SetXY(left, centerY-lineH/2) - pdf.CellFormat(boxSize, lineH, value, "", 0, "C", false, 0, "") + pdf.CellFormat(sizing.backdropSize, lineH, value, "", 0, "C", false, 0, "") } func drawTakuzuGiven(pdf *fpdf.Fpdf, x, y, cellSize float64, size int, text string) { @@ -184,17 +176,29 @@ func takuzuGivenFontSize(cellSize float64, size int) float64 { return pdfexport.ClampStandardCellFontSize(fontSize) } -func takuzuRelationFontSize(cellSize float64, size int) float64 { - fontSize := cellSize * 0.58 +func takuzuRelationSizingFor(cellSize float64, size int) takuzuRelationSizing { + fontSize := cellSize * 0.48 if size >= 12 { - fontSize *= 0.97 + fontSize *= 0.94 } - if fontSize < 6.0 { - return 6.0 + if size >= 14 { + fontSize *= 0.90 + } + + if fontSize < 6.2 { + fontSize = 6.2 + } + if fontSize > 8.4 { + fontSize = 8.4 } - return pdfexport.ClampStandardCellFontSize(fontSize) -} -func takuzuRelationBackdropSize(cellSize, fontSize float64) float64 { - return fontSize + cellSize*0.12 + backdropSize := fontSize + max(0.25, cellSize*0.04) + if maxSize := cellSize * 0.58; backdropSize > maxSize { + backdropSize = maxSize + } + + return takuzuRelationSizing{ + fontSize: fontSize, + backdropSize: backdropSize, + } } diff --git a/takuzu/print_adapter_test.go b/takuzu/print_adapter_test.go index cce0569..a1f5659 100644 --- a/takuzu/print_adapter_test.go +++ b/takuzu/print_adapter_test.go @@ -1,6 +1,10 @@ package takuzu -import "testing" +import ( + "testing" + + "github.com/FelineStateMachine/puzzletea/pdfexport" +) func TestTakuzuGivenFontSize(t *testing.T) { tests := []struct { @@ -14,22 +18,22 @@ func TestTakuzuGivenFontSize(t *testing.T) { name: "small cell keeps readable minimum", cellSize: 3.0, size: 14, - wantMin: 5.2, - wantMax: 5.2, + wantMin: 8.2, + wantMax: 8.2, }, { name: "12x12 remains comfortably readable", cellSize: 10.0, size: 12, - wantMin: 6.3, - wantMax: 6.7, + wantMin: 9.5, + wantMax: 9.6, }, { name: "14x14 remains comfortably readable", cellSize: 9.0, size: 14, - wantMin: 5.7, - wantMax: 6.0, + wantMin: 8.5, + wantMax: 8.6, }, } @@ -43,7 +47,7 @@ func TestTakuzuGivenFontSize(t *testing.T) { } } -func TestTakuzuRelationFontSize(t *testing.T) { +func TestTakuzuRelationSizing_FontSize(t *testing.T) { tests := []struct { name string cellSize float64 @@ -52,68 +56,81 @@ func TestTakuzuRelationFontSize(t *testing.T) { wantMax float64 }{ { - name: "small cells keep a larger minimum for relation clues", + name: "14x14 scales below the old 9 point floor", cellSize: 8.0, size: 14, - wantMin: 6.0, - wantMax: 6.0, + wantMin: 6.2, + wantMax: 6.2, }, { - name: "10x10 clues scale above the old minimum", + name: "12x12 scales below the old 9 point floor", cellSize: 11.0, - size: 10, - wantMin: 6.3, - wantMax: 6.5, + size: 12, + wantMin: 6.2, + wantMax: 6.2, }, { - name: "large cells still respect the shared cap", + name: "larger cells still respect the new cap", cellSize: 16.0, size: 6, - wantMin: 8.2, - wantMax: 8.2, + wantMin: 7.6, + wantMax: 7.7, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := takuzuRelationFontSize(tt.cellSize, tt.size) - if got < tt.wantMin || got > tt.wantMax { - t.Fatalf("font size = %.3f, want %.3f..%.3f", got, tt.wantMin, tt.wantMax) + got := takuzuRelationSizingFor(tt.cellSize, tt.size) + if got.fontSize < tt.wantMin || got.fontSize > tt.wantMax { + t.Fatalf("font size = %.3f, want %.3f..%.3f", got.fontSize, tt.wantMin, tt.wantMax) } }) } } -func TestTakuzuRelationBackdropSize(t *testing.T) { +func TestTakuzuRelationSizing_BackdropSize(t *testing.T) { tests := []struct { name string cellSize float64 - fontSize float64 + size int wantMin float64 wantMax float64 }{ { - name: "adds padding beyond the glyph size", + name: "caps the knockout on denser 12x12 boards", cellSize: 11.0, - fontSize: 6.4, - wantMin: 7.7, - wantMax: 7.8, + size: 12, + wantMin: 6.3, + wantMax: 6.4, }, { - name: "still provides a readable knockout on tighter boards", - cellSize: 8.0, - fontSize: 6.0, - wantMin: 6.9, - wantMax: 7.0, + name: "allows slightly larger knockouts when space is available", + cellSize: 16.0, + size: 6, + wantMin: 8.2, + wantMax: 8.4, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := takuzuRelationBackdropSize(tt.cellSize, tt.fontSize) - if got < tt.wantMin || got > tt.wantMax { - t.Fatalf("backdrop size = %.3f, want %.3f..%.3f", got, tt.wantMin, tt.wantMax) + got := takuzuRelationSizingFor(tt.cellSize, tt.size) + if got.backdropSize < tt.wantMin || got.backdropSize > tt.wantMax { + t.Fatalf("backdrop size = %.3f, want %.3f..%.3f", got.backdropSize, tt.wantMin, tt.wantMax) } }) } } + +func TestTakuzuRelationSizing_BackdropStaysWithin12x12Cap(t *testing.T) { + area := pdfexport.PuzzleBoardRect(139.7, 215.9, 2, 3) + cellSize := pdfexport.FitCompactCellSize(12, 12, area) + if cellSize <= 0 { + t.Fatal("expected positive cell size") + } + + got := takuzuRelationSizingFor(cellSize, 12) + if got.backdropSize > cellSize*0.58+0.001 { + t.Fatalf("backdrop size = %.3f, want <= %.3f", got.backdropSize, cellSize*0.58) + } +} diff --git a/takuzu/style.go b/takuzu/style.go index 724d0b1..8ca963f 100644 --- a/takuzu/style.go +++ b/takuzu/style.go @@ -52,7 +52,7 @@ type countContext struct { target int } -func cellView(val rune, isProvided, isCursor, inCursorRow, inCursorCol, solved bool) string { +func cellView(val rune, isProvided, isCursor, inCursorRow, inCursorCol, solved, inDuplicateRow, inDuplicateCol bool) string { p := theme.Current() styles := renderStyleMap() style, ok := styles[val] @@ -73,6 +73,9 @@ func cellView(val rune, isProvided, isCursor, inCursorRow, inCursorCol, solved b if isProvided && val != emptyCell && !solved { style = style.Bold(true).Background(theme.GivenTint(p.BG)) } + if (inDuplicateRow || inDuplicateCol) && !solved { + style = style.Background(game.ConflictBG()) + } if isCursor { text = cursorText(text) } @@ -98,7 +101,59 @@ func cursorText(text string) string { } } +func lineComplete(row []rune) bool { + for _, r := range row { + if r == emptyCell { + return false + } + } + return true +} + +func colComplete(g grid, size, col int) bool { + for y := range size { + if col >= len(g[y]) || g[y][col] == emptyCell { + return false + } + } + return true +} + +func duplicateRowSet(g grid, size int) map[int]bool { + dup := map[int]bool{} + for i := range size { + if !lineComplete(g[i]) { + continue + } + for j := i + 1; j < size; j++ { + if lineComplete(g[j]) && rowEqual(g[i], g[j]) { + dup[i] = true + dup[j] = true + } + } + } + return dup +} + +func duplicateColSet(g grid, size int) map[int]bool { + dup := map[int]bool{} + for i := range size { + if !colComplete(g, size, i) { + continue + } + for j := i + 1; j < size; j++ { + if colComplete(g, size, j) && colEqual(g, size, i, j) { + dup[i] = true + dup[j] = true + } + } + } + return dup +} + func gridView(m Model) string { + dupRows := duplicateRowSet(m.grid, m.size) + dupCols := duplicateColSet(m.grid, m.size) return game.RenderDynamicGrid(game.DynamicGridSpec{ Width: m.size, Height: m.size, @@ -111,6 +166,8 @@ func gridView(m Model) string { y == m.cursor.Y, x == m.cursor.X, m.solved, + dupRows[y], + dupCols[x], ) }, ZoneAt: func(_, _ int) int { diff --git a/takuzu/takuzu_test.go b/takuzu/takuzu_test.go index e14ab2c..da44e6f 100644 --- a/takuzu/takuzu_test.go +++ b/takuzu/takuzu_test.go @@ -1005,7 +1005,7 @@ func TestMouseClickSameCellDoesNotCycleProvidedCell(t *testing.T) { func TestCellViewUsesGivenTintForProvidedCells(t *testing.T) { p := theme.Current() - gotZero := cellView(zeroCell, true, false, false, false, false) + gotZero := cellView(zeroCell, true, false, false, false, false, false, false) wantZero := lipgloss.NewStyle(). Bold(true). Foreground(p.Accent). @@ -1017,7 +1017,7 @@ func TestCellViewUsesGivenTintForProvidedCells(t *testing.T) { t.Fatalf("provided zero cellView() = %q, want %q", gotZero, wantZero) } - gotOne := cellView(oneCell, true, false, false, false, false) + gotOne := cellView(oneCell, true, false, false, false, false, false, false) wantOne := lipgloss.NewStyle(). Bold(true). Foreground(p.Secondary). @@ -1033,7 +1033,7 @@ func TestCellViewUsesGivenTintForProvidedCells(t *testing.T) { func TestCellViewCursorUsesGlyphsWithoutChangingEmptyCellColors(t *testing.T) { p := theme.Current() - got := cellView(emptyCell, false, true, false, false, false) + got := cellView(emptyCell, false, true, false, false, false, false, false) want := lipgloss.NewStyle(). Foreground(p.TextDim). Background(p.BG). @@ -1049,7 +1049,7 @@ func TestCellViewCursorUsesGlyphsWithoutChangingEmptyCellColors(t *testing.T) { func TestCellViewCursorPreservesProvidedCellColors(t *testing.T) { p := theme.Current() - got := cellView(zeroCell, true, true, true, false, false) + got := cellView(zeroCell, true, true, true, false, false, false, false) want := lipgloss.NewStyle(). Bold(true). Foreground(p.Accent). diff --git a/takuzu/testdata/visual_states.jsonl b/takuzu/testdata/visual_states.jsonl index 9cdfccd..65c3f3e 100644 --- a/takuzu/testdata/visual_states.jsonl +++ b/takuzu/testdata/visual_states.jsonl @@ -1,3 +1,4 @@ -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Takuzu","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":1,"name":"provided-balance-preview","game":"Takuzu","mode":"Visual Fixture","save":{"size":4,"state":"0.11\n1.0.\n.0.1\n0101","provided":"#.##\n#.#.\n.#.#\n####","mode_title":"Visual Fixture"}}} -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Takuzu","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":2,"name":"overfull-row-context","game":"Takuzu","mode":"Visual Fixture","save":{"size":4,"state":"0001\n....\n....\n....","provided":"....\n....\n....\n....","mode_title":"Visual Fixture"}}} -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Takuzu","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":3,"name":"solved-valid-grid","game":"Takuzu","mode":"Visual Fixture","save":{"size":4,"state":"0011\n1100\n0110\n1001","provided":"....\n....\n....\n....","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Takuzu","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":1,"name":"provided-balance-preview","game":"Takuzu","mode":"Visual Fixture","save":{"size":4,"state":"0.11\n1.0.\n.0.1\n0101","provided":"#.##\n#.#.\n.#.#\n####","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Takuzu","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":2,"name":"overfull-row-context","game":"Takuzu","mode":"Visual Fixture","save":{"size":4,"state":"0001\n....\n....\n....","provided":"....\n....\n....\n....","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Takuzu","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":3,"name":"duplicate-row-conflict","game":"Takuzu","mode":"Visual Fixture","save":{"size":4,"state":"0011\n0011\n....\n....","provided":"....\n....\n....\n....","mode_title":"Visual Fixture"}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Takuzu","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":4,"name":"solved-valid-grid","game":"Takuzu","mode":"Visual Fixture","save":{"size":4,"state":"0011\n1100\n0110\n1001","provided":"....\n....\n....\n....","mode_title":"Visual Fixture"}}} diff --git a/takuzuplus/print_adapter.go b/takuzuplus/print_adapter.go index ea8178f..d38af11 100644 --- a/takuzuplus/print_adapter.go +++ b/takuzuplus/print_adapter.go @@ -12,8 +12,8 @@ var PDFPrintAdapter = printAdapter{} var takuzuPlusRules = []string{ "No three equal adjacent in any row or column.", - "Each row/column has equal 0 and 1 counts, and rows/columns are unique.", - "= means equal neighbors; x means opposite neighbors.", + "Rows/columns have equal 0s and 1s, and all rows/columns are unique.", + "= means same; x means different.", } func (printAdapter) CanonicalGameType() string { return "Takuzu+" } diff --git a/takuzuplus/style.go b/takuzuplus/style.go index cb7cdc4..58f6d8a 100644 --- a/takuzuplus/style.go +++ b/takuzuplus/style.go @@ -52,7 +52,7 @@ type countContext struct { target int } -func cellView(val rune, isProvided, isCursor, inCursorRow, inCursorCol, solved bool) string { +func cellView(val rune, isProvided, isCursor, inCursorRow, inCursorCol, solved, inDuplicateRow, inDuplicateCol bool) string { p := theme.Current() styles := renderStyleMap() style, ok := styles[val] @@ -73,6 +73,9 @@ func cellView(val rune, isProvided, isCursor, inCursorRow, inCursorCol, solved b if isProvided && val != emptyCell && !solved { style = style.Bold(true).Background(theme.GivenTint(p.BG)) } + if (inDuplicateRow || inDuplicateCol) && !solved { + style = style.Background(game.ConflictBG()) + } if isCursor { text = cursorText(text) } @@ -98,7 +101,80 @@ func cursorText(text string) string { } } +func rowComplete(row []rune) bool { + for _, r := range row { + if r == emptyCell { + return false + } + } + return true +} + +func colComplete(g grid, size, col int) bool { + for y := range size { + if col >= len(g[y]) || g[y][col] == emptyCell { + return false + } + } + return true +} + +func rowsEqual(a, b []rune) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func colsEqual(g grid, size, c1, c2 int) bool { + for r := range size { + if g[r][c1] != g[r][c2] { + return false + } + } + return true +} + +func duplicateRowSet(g grid, size int) map[int]bool { + dup := map[int]bool{} + for i := range size { + if !rowComplete(g[i]) { + continue + } + for j := i + 1; j < size; j++ { + if rowComplete(g[j]) && rowsEqual(g[i], g[j]) { + dup[i] = true + dup[j] = true + } + } + } + return dup +} + +func duplicateColSet(g grid, size int) map[int]bool { + dup := map[int]bool{} + for i := range size { + if !colComplete(g, size, i) { + continue + } + for j := i + 1; j < size; j++ { + if colComplete(g, size, j) && colsEqual(g, size, i, j) { + dup[i] = true + dup[j] = true + } + } + } + return dup +} + func gridView(m Model) string { + dupRows := duplicateRowSet(m.grid, m.size) + dupCols := duplicateColSet(m.grid, m.size) return game.RenderDynamicGrid(game.DynamicGridSpec{ Width: m.size, Height: m.size, @@ -111,6 +187,8 @@ func gridView(m Model) string { y == m.cursor.Y, x == m.cursor.X, m.solved, + dupRows[y], + dupCols[x], ) }, ZoneAt: func(_, _ int) int { @@ -143,9 +221,6 @@ func gridView(m Model) string { } func bridgeFill(m Model, bridge game.DynamicGridBridge) color.Color { - if m.solved { - return theme.Current().SuccessBG - } if bg := relationBridgeBackground(m, bridge); bg != nil { return bg } diff --git a/takuzuplus/takuzuplus_test.go b/takuzuplus/takuzuplus_test.go index 420b840..bc90732 100644 --- a/takuzuplus/takuzuplus_test.go +++ b/takuzuplus/takuzuplus_test.go @@ -494,7 +494,7 @@ func TestRelationBridgeBackgroundIncompleteIsNeutral(t *testing.T) { func TestCellViewUsesGivenTintForProvidedCells(t *testing.T) { p := theme.Current() - gotZero := cellView(zeroCell, true, false, false, false, false) + gotZero := cellView(zeroCell, true, false, false, false, false, false, false) wantZero := lipgloss.NewStyle(). Bold(true). Foreground(p.Accent). @@ -506,7 +506,7 @@ func TestCellViewUsesGivenTintForProvidedCells(t *testing.T) { t.Fatalf("provided zero cellView() = %q, want %q", gotZero, wantZero) } - gotOne := cellView(oneCell, true, false, false, false, false) + gotOne := cellView(oneCell, true, false, false, false, false, false, false) wantOne := lipgloss.NewStyle(). Bold(true). Foreground(p.Secondary). @@ -522,7 +522,7 @@ func TestCellViewUsesGivenTintForProvidedCells(t *testing.T) { func TestCellViewCursorUsesGlyphsWithoutChangingEmptyCellColors(t *testing.T) { p := theme.Current() - got := cellView(emptyCell, false, true, false, false, false) + got := cellView(emptyCell, false, true, false, false, false, false, false) want := lipgloss.NewStyle(). Foreground(p.TextDim). Background(p.BG). @@ -538,7 +538,7 @@ func TestCellViewCursorUsesGlyphsWithoutChangingEmptyCellColors(t *testing.T) { func TestCellViewCursorPreservesProvidedCellColors(t *testing.T) { p := theme.Current() - got := cellView(zeroCell, true, true, true, false, false) + got := cellView(zeroCell, true, true, true, false, false, false, false) want := lipgloss.NewStyle(). Bold(true). Foreground(p.Accent). diff --git a/takuzuplus/testdata/visual_states.jsonl b/takuzuplus/testdata/visual_states.jsonl index 863c58d..5c04ede 100644 --- a/takuzuplus/testdata/visual_states.jsonl +++ b/takuzuplus/testdata/visual_states.jsonl @@ -1,3 +1,4 @@ -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Takuzu+","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":1,"name":"relation-clues","game":"Takuzu+","mode":"Visual Fixture","save":{"size":4,"state":"0.11\n1.0.\n.0.1\n0101","provided":"#.##\n#.#.\n.#.#\n####","mode_title":"Visual Fixture","horizontal_relations":"=\n...\n...\n...","vertical_relations":"....\n..x.\n...."}}} -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Takuzu+","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":2,"name":"overfull-with-relations","game":"Takuzu+","mode":"Visual Fixture","save":{"size":4,"state":"0001\n....\n....\n....","provided":"....\n....\n....\n....","mode_title":"Visual Fixture","horizontal_relations":"...\n...\n...\n...","vertical_relations":"....\n....\n...."}}} -{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Takuzu+","mode_selection":"Visual Fixture","count":3,"seed":""},"puzzle":{"index":3,"name":"solved-valid-grid","game":"Takuzu+","mode":"Visual Fixture","save":{"size":4,"state":"0011\n1100\n0110\n1001","provided":"....\n....\n....\n....","mode_title":"Visual Fixture","horizontal_relations":"...\n...\n...\n...","vertical_relations":"....\n....\n...."}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Takuzu+","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":1,"name":"relation-clues","game":"Takuzu+","mode":"Visual Fixture","save":{"size":4,"state":"0.11\n1.0.\n.0.1\n0101","provided":"#.##\n#.#.\n.#.#\n####","mode_title":"Visual Fixture","horizontal_relations":"=\n...\n...\n...","vertical_relations":"....\n..x.\n...."}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Takuzu+","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":2,"name":"overfull-with-relations","game":"Takuzu+","mode":"Visual Fixture","save":{"size":4,"state":"0001\n....\n....\n....","provided":"....\n....\n....\n....","mode_title":"Visual Fixture","horizontal_relations":"...\n...\n...\n...","vertical_relations":"....\n....\n...."}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Takuzu+","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":3,"name":"relation-mismatch","game":"Takuzu+","mode":"Visual Fixture","save":{"size":4,"state":"01..\n....\n....\n....","provided":"....\n....\n....\n....","mode_title":"Visual Fixture","horizontal_relations":"=\n...\n...\n...","vertical_relations":"....\n....\n...."}}} +{"schema":"puzzletea.export.v1","pack":{"generated":"2026-03-15T00:00:00Z","version":"visual-fixture","category":"Takuzu+","mode_selection":"Visual Fixture","count":4,"seed":""},"puzzle":{"index":4,"name":"solved-valid-grid","game":"Takuzu+","mode":"Visual Fixture","save":{"size":4,"state":"0011\n1100\n0110\n1001","provided":"....\n....\n....\n....","mode_title":"Visual Fixture","horizontal_relations":"...\n...\n...\n...","vertical_relations":"....\n....\n...."}}} diff --git a/wordsearch/print_adapter.go b/wordsearch/print_adapter.go index de118b5..3d57f04 100644 --- a/wordsearch/print_adapter.go +++ b/wordsearch/print_adapter.go @@ -11,6 +11,11 @@ type printAdapter struct{} var PDFPrintAdapter = printAdapter{} +const ( + wordBankHeaderHeight = 9.0 + wordBankListTopGap = 2.2 +) + func (printAdapter) CanonicalGameType() string { return "Word Search" } func (printAdapter) Aliases() []string { return []string{"word search", "wordsearch"} @@ -45,7 +50,7 @@ func renderWordSearchPage(pdf *fpdf.Fpdf, data *pdfexport.WordSearchData) { gridListGap := pdfexport.WordSearchGridGapMM estimatedWordLines := estimateWordBankLineCount(pdf, data.Words, columnCount, availW, wordFontSize) - wordBankHeight := 7.0 + float64(estimatedWordLines)*wordLineHeight + wordBankHeight := wordBankHeaderHeight + wordBankListTopGap + float64(estimatedWordLines)*wordLineHeight maxWordBankHeight := availH * 0.42 if wordBankHeight > maxWordBankHeight { wordBankHeight = maxWordBankHeight @@ -127,7 +132,7 @@ func drawWordBank(pdf *fpdf.Fpdf, words []string, x, y, width, height float64, c pdf.SetXY(x, y+4.8) pdf.CellFormat(width, 4.2, "Words may run in all 8 directions", "", 0, "L", false, 0, "") - listY := y + 9.0 + listY := y + wordBankHeaderHeight + wordBankListTopGap if len(words) == 0 { pdf.SetFont(pdfexport.SansFontFamily, "", pdfexport.PuzzleWordBankHeadSize) pdf.SetTextColor(pdfexport.SecondaryTextGray, pdfexport.SecondaryTextGray, pdfexport.SecondaryTextGray) @@ -148,7 +153,7 @@ func drawWordBank(pdf *fpdf.Fpdf, words []string, x, y, width, height float64, c colLines := layoutWordBankColumns(pdf, words, columns, colWidth) lineHeight := 4.1 - maxLines := int(height / lineHeight) + maxLines := int((height - (listY - y)) / lineHeight) if maxLines <= 0 { return } From b225176fed999e94ef8892a5414ae88bbfc6adc2 Mon Sep 17 00:00:00 2001 From: Dami Date: Sat, 21 Mar 2026 14:11:06 -0600 Subject: [PATCH 07/10] style fixes --- fillomino/style.go | 11 ++++++++++ hashiwokakero/style.go | 12 ++++++++--- hitori/style.go | 13 ++++++++++- lightsout/style.go | 17 +++++++++++---- netwalk/style.go | 20 +++++++++++------ nonogram/style.go | 2 +- nurikabe/style.go | 2 +- shikaku/style.go | 4 ++-- sudoku/model.go | 2 +- sudoku/style.go | 7 ++++-- takuzu/style.go | 11 +++++++++- takuzuplus/style.go | 49 +++++++++++++++++++++++++++++++++++++++--- 12 files changed, 125 insertions(+), 25 deletions(-) diff --git a/fillomino/style.go b/fillomino/style.go index 4149d1a..3eeff42 100644 --- a/fillomino/style.go +++ b/fillomino/style.go @@ -44,6 +44,9 @@ func cellView( } else { text = lipgloss.NewStyle().Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(strconv.Itoa(value)) } + if conflict && !cursor { + text = conflictText(text) + } if provided && value != 0 { style = style.Bold(true) @@ -95,6 +98,14 @@ func conflictedCursorStyle() lipgloss.Style { Background(game.ConflictBG()) } +func conflictText(text string) string { + runes := []rune(text) + if len(runes) != cellWidth { + return text + } + return "!" + string(runes[1]) + "!" +} + func gridView(m Model) string { renderState := buildRenderGridState(m) diff --git a/hashiwokakero/style.go b/hashiwokakero/style.go index 469957e..797bade 100644 --- a/hashiwokakero/style.go +++ b/hashiwokakero/style.go @@ -95,9 +95,13 @@ func resolveCellVisual(m Model, x, y int, solved bool) cellVisual { if solved { cellBG = solvedBoardBackground() } + text := " " + if !solved { + text = " · " + } visual := cellVisual{ - text: " ", - fg: theme.TextOnBG(cellBG), + text: text, + fg: theme.Current().TextDim, bg: cellBG, outerBG: cellBG, } @@ -392,6 +396,8 @@ func infoView(p *Puzzle) string { var sb strings.Builder sb.WriteString(infoStyle.Render("Islands: ")) sb.WriteString(satisfiedStyle.Render(fmt.Sprintf("%d", satisfied))) - sb.WriteString(infoStyle.Render(fmt.Sprintf("/%d satisfied Bridges: %d", total, len(p.Bridges)))) + sb.WriteString(infoStyle.Render(fmt.Sprintf("/%d satisfied", total))) + sb.WriteString("\n") + sb.WriteString(infoStyle.Render(fmt.Sprintf("Bridges: %d", len(p.Bridges)))) return sb.String() } diff --git a/hitori/style.go b/hitori/style.go index 5b13bfa..b66f488 100644 --- a/hitori/style.go +++ b/hitori/style.go @@ -54,6 +54,7 @@ func resolveCellVisual( visual.bridgeBG = p.SuccessBG visual.state = cellVisualStateSolved case conflict: + visual.text = conflictDisplay(mark, num) visual.fg = game.ConflictFG() visual.bg = game.ConflictBG() visual.bridgeBG = game.ConflictBG() @@ -81,7 +82,7 @@ func hitoriBaseVisual(mark cellMark, num rune) cellVisual { } case circled: return cellVisual{ - text: fmt.Sprintf(" %c ", num), + text: fmt.Sprintf("(%c)", num), fg: p.Info, bg: p.BG, bridgeBG: p.BG, @@ -121,6 +122,7 @@ func hitoriCursorVisual(num rune, mark cellMark, solved, conflict bool) cellVisu visual.fg = p.SolvedFG } case conflict: + visual.text = conflictDisplay(mark, num) visual.bg = game.ConflictBG() visual.bridgeBG = game.ConflictBG() visual.state = cellVisualStateConflict @@ -133,6 +135,15 @@ func hitoriCursorVisual(num rune, mark cellMark, solved, conflict bool) cellVisu return visual } +func conflictDisplay(mark cellMark, num rune) string { + switch mark { + case shaded: + return "!█!" + default: + return fmt.Sprintf("!%c!", num) + } +} + func renderCellVisual(visual cellVisual) string { style := lipgloss.NewStyle(). Width(cellWidth). diff --git a/lightsout/style.go b/lightsout/style.go index a825050..211f68c 100644 --- a/lightsout/style.go +++ b/lightsout/style.go @@ -116,8 +116,17 @@ func cellView(isOn, isCursor, solved bool) string { s = cursorOffStyle() } - content := strings.Repeat(" ", cellWidth) - return s.Width(cellWidth).Height(cellHeight).Render(content) + content := " · " + if isOn { + content = " ● " + } + if isCursor && isOn { + content = "▸●◂" + } else if isCursor { + content = "▸·◂" + } + + return s.Width(cellWidth).Height(cellHeight).AlignHorizontal(lipgloss.Center).Render(content) } func gridView(g [][]bool, c game.Cursor, solved bool) string { @@ -140,7 +149,7 @@ func gridView(g [][]bool, c game.Cursor, solved bool) string { func statusBarView(showFullHelp bool) string { if showFullHelp { - return game.StatusBarStyle().Render("arrows/wasd: move enter/space/click: toggle esc: menu ctrl+r: reset ctrl+h: help") + return game.StatusBarStyle().Render("arrows/wasd: move click/enter: toggle esc: menu ctrl+r: reset ctrl+h: help") } - return game.StatusBarStyle().Render("enter/space/click: toggle") + return game.StatusBarStyle().Render("click/enter: toggle") } diff --git a/netwalk/style.go b/netwalk/style.go index 8595560..eb81fb6 100644 --- a/netwalk/style.go +++ b/netwalk/style.go @@ -66,7 +66,7 @@ func cellRows(m Model, x, y int) [cellHeight]string { } mask := m.state.rotatedMasks[y][x] - center := centerGlyph(t.Kind, mask) + center := centerGlyph(m, x, y, t.Kind, mask) return [cellHeight]string{ verticalCellRow(mask&north != 0), @@ -75,7 +75,7 @@ func cellRows(m Model, x, y int) [cellHeight]string { } } -func centerGlyph(kind cellKind, mask directionMask) string { +func centerGlyph(m Model, x, y int, kind cellKind, mask directionMask) string { switch { case kind == serverCell: return "◆" @@ -148,10 +148,10 @@ func pipeForeground(m Model, cells ...point) color.Color { if tile.Kind == serverCell { return theme.Current().AccentSoft } - if m.state.tileHasDangling[cell.Y][cell.X] { + if stateBoolAt(m.state.tileHasDangling, cell.X, cell.Y) { return theme.Current().Error } - if m.state.connectedToRoot[cell.Y][cell.X] { + if stateBoolAt(m.state.connectedToRoot, cell.X, cell.Y) { hasConnected = true } } @@ -161,12 +161,20 @@ func pipeForeground(m Model, cells ...point) color.Color { return theme.Current().FG } +func hasStateCell(cells [][]bool, x, y int) bool { + return y >= 0 && y < len(cells) && x >= 0 && x < len(cells[y]) +} + +func stateBoolAt(cells [][]bool, x, y int) bool { + return hasStateCell(cells, x, y) && cells[y][x] +} + func statusBarView(m Model, full bool) string { info := "connected " + strconv.Itoa(m.state.connected) + "/" + strconv.Itoa(m.state.nonEmpty) + " dangling " + strconv.Itoa(m.state.dangling) + " locks " + strconv.Itoa(m.state.locked) if !full { - return game.StatusBarStyle().Render(info + " space rotate enter lock") + return game.StatusBarStyle().Render(info + "\nspace: rotate enter: lock") } - return game.StatusBarStyle().Render(info + " space rotate backspace reverse enter toggle lock ctrl+r reset") + return game.StatusBarStyle().Render(info + "\nspace: rotate backspace: reverse\nenter: toggle lock ctrl+r: reset") } diff --git a/nonogram/style.go b/nonogram/style.go index c9a9fc7..7fe3b6e 100644 --- a/nonogram/style.go +++ b/nonogram/style.go @@ -311,7 +311,7 @@ func statusBarView(showFullHelp bool) string { if showFullHelp { return game.StatusBarStyle().Render("arrows/wasd: move z: fill (hold+move) x: mark (hold+move) bkspc: clear LMB: fill RMB: mark esc: menu ctrl+r: reset ctrl+h: help") } - return game.StatusBarStyle().Render("z: fill x: mark bkspc: clear mouse: click/drag") + return game.StatusBarStyle().Render("z: fill x: mark mouse: drag bkspc clear") } func intSliceEqual(a, b []int) bool { diff --git a/nurikabe/style.go b/nurikabe/style.go index 0a16459..d8df8ab 100644 --- a/nurikabe/style.go +++ b/nurikabe/style.go @@ -121,7 +121,7 @@ func resolveCellVisualWithState(m Model, renderState renderGridState, x, y int) case c == seaCell: visual.text = " ~ " if inSeaSquare { - visual.text = " @ " + visual.text = " ! " } visual.bg = seaBg visual.bridgeBG = seaBg diff --git a/shikaku/style.go b/shikaku/style.go index 7208428..2b0acee 100644 --- a/shikaku/style.go +++ b/shikaku/style.go @@ -188,9 +188,9 @@ func statusBarView(selected, showFullHelp bool) string { return game.StatusBarStyle().Render("arrows: expand shift+arrows: shrink enter: confirm bkspc: cancel mouse: drag") } if showFullHelp { - return game.StatusBarStyle().Render("arrows/wasd: move enter/space: select clue bkspc: cancel/delete mouse: click clue & drag esc: menu ctrl+r: reset ctrl+h: help") + return game.StatusBarStyle().Render("arrows/wasd: move enter/space: select clue bkspc: cancel/delete mouse: click+drag clue esc: menu ctrl+r: reset ctrl+h: help") } - return game.StatusBarStyle().Render("enter/space: select clue bkspc: cancel/delete mouse: click & drag") + return game.StatusBarStyle().Render("enter/space: select clue bkspc: cancel/delete mouse: click+drag clue") } func statusBarVariants() []string { diff --git a/sudoku/model.go b/sudoku/model.go index e40e855..175ae93 100644 --- a/sudoku/model.go +++ b/sudoku/model.go @@ -155,7 +155,7 @@ func (m Model) GetDebugInfo() string { s := game.DebugHeader("Sudoku", [][2]string{ {"Status", status}, {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)}, - {"Cell Value", cellContent(cursorCell)}, + {"Cell Value", cellContent(cursorCell, conflict)}, {"Is Provided", fmt.Sprintf("%v", isProvided)}, {"Has Conflict", fmt.Sprintf("%v", conflict)}, {"Cells Filled", fmt.Sprintf("%d / 81", filledCount)}, diff --git a/sudoku/style.go b/sudoku/style.go index 40d286a..7afda7e 100644 --- a/sudoku/style.go +++ b/sudoku/style.go @@ -76,7 +76,7 @@ func renderGrid(m Model, solved bool) string { func cellView(m Model, x, y int, solved bool) string { c := m.grid[y][x] style := cellStyle(m, c, x, y, m.conflicts[y][x], solved) - text := cellContent(c) + text := cellContent(c, m.conflicts[y][x]) if x == m.cursor.X && y == m.cursor.Y { if c.v == 0 { @@ -180,10 +180,13 @@ func digitColor(value int) color.Color { return colors[(value-1)%len(colors)] } -func cellContent(c cell) string { +func cellContent(c cell, conflict bool) string { if c.v == 0 { return "·" } + if conflict { + return "!" + strconv.Itoa(c.v) + "!" + } return strconv.Itoa(c.v) } diff --git a/takuzu/style.go b/takuzu/style.go index 8ca963f..73d26ca 100644 --- a/takuzu/style.go +++ b/takuzu/style.go @@ -74,7 +74,8 @@ func cellView(val rune, isProvided, isCursor, inCursorRow, inCursorCol, solved, style = style.Bold(true).Background(theme.GivenTint(p.BG)) } if (inDuplicateRow || inDuplicateCol) && !solved { - style = style.Background(game.ConflictBG()) + style = style.Foreground(game.ConflictFG()).Background(game.ConflictBG()) + text = conflictText(text) } if isCursor { text = cursorText(text) @@ -101,6 +102,14 @@ func cursorText(text string) string { } } +func conflictText(text string) string { + runes := []rune(text) + if len(runes) != cellWidth { + return text + } + return "!" + string(runes[1]) + "!" +} + func lineComplete(row []rune) bool { for _, r := range row { if r == emptyCell { diff --git a/takuzuplus/style.go b/takuzuplus/style.go index 58f6d8a..e4c9161 100644 --- a/takuzuplus/style.go +++ b/takuzuplus/style.go @@ -74,7 +74,8 @@ func cellView(val rune, isProvided, isCursor, inCursorRow, inCursorCol, solved, style = style.Bold(true).Background(theme.GivenTint(p.BG)) } if (inDuplicateRow || inDuplicateCol) && !solved { - style = style.Background(game.ConflictBG()) + style = style.Foreground(game.ConflictFG()).Background(game.ConflictBG()) + text = conflictText(text) } if isCursor { text = cursorText(text) @@ -101,6 +102,14 @@ func cursorText(text string) string { } } +func conflictText(text string) string { + runes := []rune(text) + if len(runes) != cellWidth { + return text + } + return "!" + string(runes[1]) + "!" +} + func rowComplete(row []rune) bool { for _, r := range row { if r == emptyCell { @@ -197,6 +206,9 @@ func gridView(m Model) string { BridgeFill: func(bridge game.DynamicGridBridge) color.Color { return bridgeFill(m, bridge) }, + BridgeBold: func(bridge game.DynamicGridBridge) bool { + return relationBridgeState(m, bridge) != 0 + }, VerticalBridgeText: func(x, y int) string { if x <= 0 || x >= m.size { return "" @@ -205,7 +217,7 @@ func gridView(m Model) string { if rel == relationNone { return "" } - return string(rel) + return relationBridgeText(rel, m.grid[y][x-1], m.grid[y][x]) }, HorizontalBridgeText: func(x, y int) string { if y <= 0 || y >= m.size { @@ -215,7 +227,7 @@ func gridView(m Model) string { if rel == relationNone { return "" } - return string(rel) + return relationBridgeText(rel, m.grid[y-1][x], m.grid[y][x]) }, }) } @@ -265,6 +277,37 @@ func relationStateBackground(state int) color.Color { } } +func relationBridgeState(m Model, bridge game.DynamicGridBridge) int { + switch bridge.Kind { + case game.DynamicGridBridgeVertical: + if bridge.X <= 0 || bridge.X >= m.size || bridge.Y < 0 || bridge.Y >= m.size { + return 0 + } + return relationState( + m.relations.horizontal[bridge.Y][bridge.X-1], + m.grid[bridge.Y][bridge.X-1], + m.grid[bridge.Y][bridge.X], + ) + case game.DynamicGridBridgeHorizontal: + if bridge.Y <= 0 || bridge.Y >= m.size || bridge.X < 0 || bridge.X >= m.size { + return 0 + } + return relationState( + m.relations.vertical[bridge.Y-1][bridge.X], + m.grid[bridge.Y-1][bridge.X], + m.grid[bridge.Y][bridge.X], + ) + default: + return 0 + } +} + +func relationBridgeText(rel, left, right rune) string { + _ = left + _ = right + return string(rel) +} + func countContextView(m Model) string { ctx := buildCountContext(m.grid, m.cursor, m.size) width := countValueWidth(ctx.target) From df2e9aa7199e9eb9771378986cbbb529368d08fc Mon Sep 17 00:00:00 2001 From: Dami Date: Sat, 21 Mar 2026 14:12:28 -0600 Subject: [PATCH 08/10] remove tmp file --- release-volume-1.sh | 304 -------------------------------------------- 1 file changed, 304 deletions(-) delete mode 100755 release-volume-1.sh diff --git a/release-volume-1.sh b/release-volume-1.sh deleted file mode 100755 index 88bac12..0000000 --- a/release-volume-1.sh +++ /dev/null @@ -1,304 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PUZZLETEA_BIN="${PUZZLETEA_BIN:-$SCRIPT_DIR/puzzletea}" -OUT_DIR="$SCRIPT_DIR/out" -PARTS_DIR="$OUT_DIR/parts" -MERGED_JSONL="$OUT_DIR/puzzletea-volume-1.jsonl" -PDF_OUTPUT="$OUT_DIR/puzzletea-volume-1.0.pdf" -BASE_SEED="${BASE_SEED:-tff 2026}" -PDF_TITLE="${PDF_TITLE:-TFF 2026 Preview}" -PDF_ADVERT="${PDF_ADVERT:-Generated via a custom-coded puzzle engine, this collection is a modern tribute to the legacy of Nikoli. Created by Dami Etoile.}" -PDF_SHEET_LAYOUT="${PDF_SHEET_LAYOUT:-duplex-booklet}" - -EXPECTED_CATEGORY_COUNT=13 -MAX_PUZZLE_PAGES=8 -EXPECTED_TOTAL_COUNT=$(((MAX_PUZZLE_PAGES * 4) + 2)) - -if [[ ! -x "$PUZZLETEA_BIN" ]]; then - echo "expected built executable at $PUZZLETEA_BIN" >&2 - echo "build it first, for example: go build -o \"$SCRIPT_DIR/puzzletea\"" >&2 - exit 1 -fi - -mkdir -p "$OUT_DIR" "$PARTS_DIR" - -slugify() { - printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed \ - -e 's/+/-plus-/g' \ - -e 's/[^a-z0-9]/-/g' \ - -e 's/--*/-/g' \ - -e 's/^-//' \ - -e 's/-$//' -} - -set_modes_for_game() { - case "$1" in - "Fillomino") - MODES=("Mini 5x5" "Easy 6x6" "Medium 8x8" "Hard 10x10" "Expert 12x12") - ;; - "Hashiwokakero") - MODES=( - "Easy 7x7" "Medium 7x7" "Hard 7x7" - "Easy 9x9" "Medium 9x9" "Hard 9x9" - "Easy 11x11" "Medium 11x11" "Hard 11x11" - "Easy 13x13" "Medium 13x13" "Hard 13x13" - ) - ;; - "Hitori") - MODES=("Mini" "Easy" "Medium" "Tricky" "Hard" "Expert") - ;; - "Nonogram") - MODES=("Mini" "Pocket" "Teaser" "Standard" "Classic" "Tricky" "Large" "Grand" "Epic" "Massive") - ;; - "Nurikabe") - MODES=("Mini" "Easy" "Medium" "Hard" "Expert") - ;; - "Ripple Effect") - MODES=("Mini 5x5" "Easy 6x6" "Medium 7x7" "Hard 8x8" "Expert 9x9") - ;; - "Shikaku") - MODES=("Mini 5x5" "Easy 7x7" "Medium 8x8" "Hard 10x10" "Expert 12x12") - ;; - "Sudoku") - MODES=("Beginner" "Easy" "Medium" "Hard" "Expert" "Diabolical") - ;; - "Sudoku RGB") - MODES=("Beginner" "Easy" "Medium" "Hard" "Expert" "Diabolical") - ;; - "Takuzu") - MODES=("Beginner" "Easy" "Medium" "Tricky" "Hard" "Very Hard" "Extreme") - ;; - "Takuzu+") - MODES=("Beginner" "Easy" "Medium" "Tricky" "Hard" "Very Hard" "Extreme") - ;; - "Word Search") - MODES=("Easy 10x10" "Medium 15x15" "Hard 20x20") - ;; - *) - echo "unknown game manifest entry: $1" >&2 - exit 1 - ;; - esac -} - -allocate_counts() { - local total="$1" - local mode_count="$2" - local i - local base - local remainder - local count - - ALLOC_COUNTS=() - if (( mode_count <= 0 )); then - return - fi - - base=$((total / mode_count)) - remainder=$((total % mode_count)) - for ((i = 0; i < mode_count; i++)); do - count="$base" - if (( i < remainder )); then - count=$((count + 1)) - fi - ALLOC_COUNTS+=("$count") - done -} - -allocate_category_targets() { - local total="$1" - local category_count="$2" - - CATEGORY_TARGETS=() - if (( category_count <= 0 )); then - return - fi - - allocate_counts "$total" "$category_count" - CATEGORY_TARGETS=("${ALLOC_COUNTS[@]}") -} - -bucket_targets_for_total() { - local total="$1" - - case "$total" in - 6) - BUCKET_TARGETS=(3 2 1) - ;; - 5) - BUCKET_TARGETS=(3 1 1) - ;; - 4) - BUCKET_TARGETS=(2 1 1) - ;; - 3) - BUCKET_TARGETS=(1 1 1) - ;; - 2) - BUCKET_TARGETS=(0 1 1) - ;; - *) - echo "unsupported per-category target ${total}" >&2 - exit 1 - ;; - esac -} - -append_bucket_exports() { - local game="$1" - local game_slug="$2" - local bucket="$3" - local target="$4" - local category_file="$5" - shift 5 - - local bucket_modes=("$@") - local mode_count="${#bucket_modes[@]}" - local i - local count - local mode - local mode_slug - local seed - local temp_file - - if (( mode_count == 0 || target == 0 )); then - return - fi - - allocate_counts "$target" "$mode_count" - for ((i = 0; i < mode_count; i++)); do - count="${ALLOC_COUNTS[$i]}" - if (( count == 0 )); then - continue - fi - - mode="${bucket_modes[$i]}" - mode_slug="$(slugify "$mode")" - seed="${BASE_SEED}:${game_slug}:${bucket}:${mode_slug}" - temp_file="$(mktemp "${TMPDIR:-/tmp}/${game_slug}-${bucket}-${mode_slug}.XXXXXX.jsonl")" - - echo "Generating ${count} ${bucket} puzzle(s) for ${game} / ${mode}" - "$PUZZLETEA_BIN" new "$game" "$mode" -e "$count" -o "$temp_file" --with-seed "$seed" - cat "$temp_file" >> "$category_file" - rm -f "$temp_file" - done -} - -generate_category_pack() { - local game="$1" - local target_total="$2" - local game_slug - local category_file - local total_modes - local i - local bucket_index - - local easy_modes=() - local medium_modes=() - local hard_modes=() - - set_modes_for_game "$game" - game_slug="$(slugify "$game")" - category_file="$PARTS_DIR/${game_slug}.jsonl" - : > "$category_file" - - total_modes="${#MODES[@]}" - for ((i = 0; i < total_modes; i++)); do - bucket_index=$((i * 3 / total_modes)) - case "$bucket_index" in - 0) - easy_modes+=("${MODES[$i]}") - ;; - 1) - medium_modes+=("${MODES[$i]}") - ;; - 2) - hard_modes+=("${MODES[$i]}") - ;; - *) - echo "invalid bucket index ${bucket_index} for ${game}" >&2 - exit 1 - ;; - esac - done - - bucket_targets_for_total "$target_total" - - append_bucket_exports "$game" "$game_slug" "easy" "${BUCKET_TARGETS[0]}" "$category_file" "${easy_modes[@]}" - append_bucket_exports "$game" "$game_slug" "medium" "${BUCKET_TARGETS[1]}" "$category_file" "${medium_modes[@]}" - append_bucket_exports "$game" "$game_slug" "hard" "${BUCKET_TARGETS[2]}" "$category_file" "${hard_modes[@]}" - - local line_count - line_count="$(wc -l < "$category_file" | tr -d '[:space:]')" - if [[ "$line_count" != "$target_total" ]]; then - echo "expected ${target_total} puzzles for ${game}, got ${line_count}" >&2 - exit 1 - fi -} - -render_pdf() { - local cmd=( - "$PUZZLETEA_BIN" - export-pdf - -o "$PDF_OUTPUT" - --volume 1 - --title "$PDF_TITLE" - --shuffle-seed "$BASE_SEED" - --sheet-layout "$PDF_SHEET_LAYOUT" - ) - local game - - for game in "${GAMES[@]}"; do - cmd+=("$PARTS_DIR/$(slugify "$game").jsonl") - done - - if [[ -n "${PDF_HEADER:-}" ]]; then - cmd+=(--header "$PDF_HEADER") - fi - if [[ -n "${PDF_ADVERT:-}" ]]; then - cmd+=(--advert "$PDF_ADVERT") - fi - - "${cmd[@]}" -} - -GAMES=( - "Fillomino" - "Hashiwokakero" - "Hitori" - "Nonogram" - "Nurikabe" - "Ripple Effect" - "Shikaku" - "Sudoku" - "Sudoku RGB" - "Takuzu" - "Takuzu+" - "Word Search" -) - -: > "$MERGED_JSONL" - -allocate_category_targets "$EXPECTED_TOTAL_COUNT" "${#GAMES[@]}" - -for i in "${!GAMES[@]}"; do - generate_category_pack "${GAMES[$i]}" "${CATEGORY_TARGETS[$i]}" -done - -for game in "${GAMES[@]}"; do - cat "$PARTS_DIR/$(slugify "$game").jsonl" >> "$MERGED_JSONL" -done - -line_count="$(wc -l < "$MERGED_JSONL" | tr -d '[:space:]')" -if [[ "$line_count" != "$EXPECTED_TOTAL_COUNT" ]]; then - echo "expected ${EXPECTED_TOTAL_COUNT} puzzles in merged jsonl, got ${line_count}" >&2 - exit 1 -fi - -render_pdf - -echo "Wrote $MERGED_JSONL" -echo "Wrote $PDF_OUTPUT" From a6a5f1942a82da51dea58db90bfd2e9ec573796a Mon Sep 17 00:00:00 2001 From: Dami Date: Sat, 21 Mar 2026 14:26:53 -0600 Subject: [PATCH 09/10] Invalidate Netwalk cached mouse origin after recompute --- netwalk/model.go | 1 + netwalk/netwalk_test.go | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/netwalk/model.go b/netwalk/model.go index 1439fa3..ad82f66 100644 --- a/netwalk/model.go +++ b/netwalk/model.go @@ -148,6 +148,7 @@ func (m *Model) toggleCurrentLock() { func (m *Model) recompute() { m.state = analyzePuzzle(m.puzzle) + m.originValid = false } func (m Model) handleMouseClick(msg tea.MouseClickMsg) Model { diff --git a/netwalk/netwalk_test.go b/netwalk/netwalk_test.go index 9edefca..c7e7097 100644 --- a/netwalk/netwalk_test.go +++ b/netwalk/netwalk_test.go @@ -253,6 +253,25 @@ func TestGridViewShowsCursorGlyphsOnBlankCells(t *testing.T) { } } +func TestRecomputeInvalidatesCachedGridOrigin(t *testing.T) { + m := Model{ + puzzle: newPuzzle(2), + modeTitle: "Origin Check", + termWidth: 120, + termHeight: 40, + originX: 17, + originY: 9, + originValid: true, + showFullHelp: true, + } + + m.recompute() + + if m.originValid { + t.Fatal("expected recompute to invalidate cached grid origin") + } +} + func netwalkModesFromRegistry(t *testing.T) []NetwalkMode { t.Helper() From e50ca69646af9af70b9bc522125cc1c11c610fea Mon Sep 17 00:00:00 2001 From: Dami Date: Sat, 21 Mar 2026 23:46:16 -0600 Subject: [PATCH 10/10] add netwalk vhs --- README.md | 7 +++++ justfile | 1 + netwalk/README.md | 2 ++ vhs/netwalk.gif | Bin 0 -> 79123 bytes vhs/netwalk.tape | 66 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 76 insertions(+) create mode 100644 vhs/netwalk.gif create mode 100644 vhs/netwalk.tape diff --git a/README.md b/README.md index c11c765..a6ac922 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,13 @@ Toggle lights to turn all off. [Game details and controls](lightsout/README.md) +### Netwalk +Rotate network tiles until every computer reaches the server in one loop-free tree. + +![Netwalk](vhs/netwalk.gif) + +[Game details and controls](netwalk/README.md) + ### Takuzu Fill the grid with two symbols following three simple rules. diff --git a/justfile b/justfile index 5a84f3d..78f2e67 100644 --- a/justfile +++ b/justfile @@ -74,4 +74,5 @@ vhs: build vhs vhs/help.tape vhs vhs/hitori.tape vhs vhs/lightsout.tape + vhs vhs/netwalk.tape vhs vhs/stats.tape diff --git a/netwalk/README.md b/netwalk/README.md index 3794974..ba99912 100644 --- a/netwalk/README.md +++ b/netwalk/README.md @@ -2,6 +2,8 @@ Rotate network tiles until every active connection reaches the server in one loop-free tree. +![Netwalk gameplay](../vhs/netwalk.gif) + ## Quick Start ```bash diff --git a/vhs/netwalk.gif b/vhs/netwalk.gif new file mode 100644 index 0000000000000000000000000000000000000000..32f613d49587332b15a0b9349be24be8939b058a GIT binary patch literal 79123 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

r1l`%a!uSH$+ILzE4T*%NFjof1@w#%%Tom0KV~ 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 Lo{}}h5`#4W!Dli; literal 0 HcmV?d00001 diff --git a/vhs/netwalk.tape b/vhs/netwalk.tape new file mode 100644 index 0000000..f66efb2 --- /dev/null +++ b/vhs/netwalk.tape @@ -0,0 +1,66 @@ +Output vhs/netwalk.gif + +Set Theme "catppuccin-mocha" +Set FontSize 14 +Set Width 1200 +Set Height 800 +Set Margin 60 +Set MarginFill "#6A5B4A" +Set WindowBar Colorful +Set BorderRadius 10 + +Env COLORTERM "truecolor" +Env CLICOLOR_FORCE "1" +Env CI "" + +Hide +Type "go build -o puzzletea" +Enter +Sleep 2s +Ctrl+L +Show + +Type './puzzletea --theme "catppuccin-mocha" new netwalk "Easy 7x7" --with-seed "vhs-netwalk-easy-7x7"' +Sleep 500ms +Enter +Sleep 2s + +# Rotate the starting tile and lock it in place. +Type " " +Sleep 500ms +Enter +Sleep 500ms + +# Move to a branch and show reverse rotation. +Right +Sleep 350ms +Right +Sleep 350ms +Down +Sleep 350ms +Backspace +Sleep 500ms + +# Unlock and rotate a different tile. +Left +Sleep 350ms +Down +Sleep 350ms +Type " " +Sleep 500ms +Enter +Sleep 500ms + +# Sweep across the board to show the network changing color/state. +Right +Sleep 300ms +Down +Sleep 300ms +Right +Sleep 300ms +Up +Sleep 300ms +Type " " +Sleep 500ms + +Sleep 3s