diff --git a/README.md b/README.md index 0b22c62..a6ac922 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" ``` @@ -144,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): @@ -178,7 +190,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 +210,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 @@ -290,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/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/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/root.go b/cmd/root.go index 5597b15..0058a1d 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 { @@ -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 new file mode 100644 index 0000000..372e83e --- /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 { + applyTheme(flagTheme) + + 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/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/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/fillomino/testdata/visual_states.jsonl b/fillomino/testdata/visual_states.jsonl new file mode 100644 index 0000000..52b8e96 --- /dev/null +++ b/fillomino/testdata/visual_states.jsonl @@ -0,0 +1,4 @@ +{"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/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/hashiwokakero/testdata/visual_states.jsonl b/hashiwokakero/testdata/visual_states.jsonl new file mode 100644 index 0000000..f0c2896 --- /dev/null +++ b/hashiwokakero/testdata/visual_states.jsonl @@ -0,0 +1,4 @@ +{"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/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/hitori/testdata/visual_states.jsonl b/hitori/testdata/visual_states.jsonl new file mode 100644 index 0000000..a1df179 --- /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":"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/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/justfile b/justfile index 607e13e..78f2e67 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 @@ -62,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/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/lightsout/testdata/visual_states.jsonl b/lightsout/testdata/visual_states.jsonl new file mode 100644 index 0000000..f22d9ad --- /dev/null +++ b/lightsout/testdata/visual_states.jsonl @@ -0,0 +1,4 @@ +{"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/README.md b/netwalk/README.md new file mode 100644 index 0000000..ba99912 --- /dev/null +++ b/netwalk/README.md @@ -0,0 +1,39 @@ +# Netwalk + +Rotate network tiles until every active connection reaches the server in one loop-free tree. + +![Netwalk gameplay](../vhs/netwalk.gif) + +## 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 | +| `Space` | Rotate clockwise | +| `Backspace` | Rotate counter-clockwise | +| `Enter` | 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..7665da1 --- /dev/null +++ b/netwalk/gamemode.go @@ -0,0 +1,120 @@ +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 + FillRatio float64 + Profile generateProfile +} + +var ( + _ game.Mode = NetwalkMode{} + _ game.Spawner = NetwalkMode{} + _ game.SeededSpawner = NetwalkMode{} +) + +func NewMode(title, desc string, size int, fillRatio float64, profile generateProfile) NetwalkMode { + return NetwalkMode{ + BaseMode: game.NewBaseMode(title, desc), + Size: size, + FillRatio: fillRatio, + Profile: profile, + } +} + +func (m NetwalkMode) Spawn() (game.Gamer, error) { + p, err := GenerateSeededWithDensity( + m.Size, + m.FillRatio, + m.Profile, + rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())), + ) + if err != nil { + return nil, err + } + return New(m, p) +} + +func (m NetwalkMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) { + 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 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) + +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..2af6609 --- /dev/null +++ b/netwalk/generator.go @@ -0,0 +1,372 @@ +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") + } + if targetActive < 2 { + targetActive = 2 + } + if targetActive > size*size { + targetActive = size * size + } + + for attempt := 0; attempt < maxGenerateAttempts; attempt++ { + puzzle := buildTreePuzzle(size, targetActive, profile, 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 +} + +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 + + active := map[point]struct{}{root: {}} + 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(size, frontier, active, adjacency, bounds, profile, 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 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, + bounds activeBounds, + profile generateProfile, + rng *rand.Rand, +) int { + if len(frontier) == 1 { + return 0 + } + + weights := make([]int, len(frontier)) + total := 0 + for i, edge := range frontier { + weight := frontierWeight(size, edge, active, adjacency, bounds, profile) + 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 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 + } + + 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..0d5b74f --- /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 | +| `Space` | Rotate current tile clockwise | +| `Backspace` | Rotate current tile counter-clockwise | +| `Enter` | 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..d7e63bb --- /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("space"), + key.WithHelp("space", "Rotate"), + ), + RotateBack: key.NewBinding( + key.WithKeys("backspace", "shift+space"), + key.WithHelp("bkspc", "Rotate back"), + ), + Lock: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "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..ad82f66 --- /dev/null +++ b/netwalk/model.go @@ -0,0 +1,201 @@ +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) + m.originValid = false +} + +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() + 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) { + 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..c7e7097 --- /dev/null +++ b/netwalk/netwalk_test.go @@ -0,0 +1,350 @@ +package netwalk + +import ( + "math" + "math/rand/v2" + "strings" + "testing" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "github.com/FelineStateMachine/puzzletea/game" + "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 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") + } +} + +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} + + 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) + } + + 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]) + } + } + 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]) + } + } + + 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) + } + } +} + +func TestCellRowsShowDirectionalRootsAndLeaves(t *testing.T) { + m := Model{} + + 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 + + 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 := cellRows(m, 1, 0); got != [cellHeight]string{" │ ", " ├──", " │ "} { + t.Fatalf("tee rows = %#v", got) + } +} + +func TestGridViewUsesTallerFrameWithoutInteriorBoxes(t *testing.T) { + m := Model{ + puzzle: newPuzzle(2), + } + m.recompute() + + lines := strings.Split(ansi.Strip(gridView(m)), "\n") + if len(lines) != 8 { + t.Fatalf("rendered line count = %d, want 8", len(lines)) + } + 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 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() + + 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 new file mode 100644 index 0000000..d9d6390 --- /dev/null +++ b/netwalk/print_adapter.go @@ -0,0 +1,147 @@ +package netwalk + +import ( + "math" + + "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() + 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 + } + + blockW := float64(data.Size) * cellSize + blockH := float64(data.Size) * cellSize + startX, startY := pdfexport.CenteredOrigin(area, data.Size, data.Size, cellSize) + + 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 + drawNetwalkTile(pdf, data, x, y, cellX, cellY, cellSize) + } + } + + pdf.SetDrawColor(55, 55, 55) + pdf.SetLineWidth(pdfexport.OuterBorderLineMM) + pdf.Rect(startX, startY, blockW, blockH, "D") + + 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) { + 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 { + return + } + mask = rotateMask(mask, data.Rotations[y][x]) + + centerX := cellX + cellSize/2 + centerY := cellY + cellSize/2 + isRoot := x == data.RootX && y == data.RootY + isLeaf := degree(mask) == 1 + pad := cellSize * 0.16 + 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) + } + 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) + } + + switch { + case isRoot: + drawNetwalkSourceMarker(pdf, centerX, centerY, cellSize) + return + case isLeaf: + drawNetwalkSinkMarker(pdf, centerX, centerY, cellSize) + } +} + +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 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 new file mode 100644 index 0000000..eb81fb6 --- /dev/null +++ b/netwalk/style.go @@ -0,0 +1,180 @@ +package netwalk + +import ( + "image/color" + "strconv" + "strings" + + "charm.land/lipgloss/v2" + "github.com/FelineStateMachine/puzzletea/game" + "github.com/FelineStateMachine/puzzletea/theme" +) + +const ( + cellWidth = 5 + cellHeight = 3 +) + +func gridView(m Model) string { + 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 cellRowView(m Model, x, y, inner int) string { + rows := cellRows(m, x, y) + t := m.puzzle.Tiles[y][x] + if x == m.cursor.X && y == m.cursor.Y && !isActive(t) { + rows = blankCursorRows() + } + style := lipgloss.NewStyle(). + Width(cellWidth). + 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 || degree(m.state.rotatedMasks[y][x]) == 1 || m.state.tileHasDangling[y][x] { + style = style.Bold(true) + } + + return style.Render(rows[inner]) +} + +func cellRows(m Model, x, y int) [cellHeight]string { + t := m.puzzle.Tiles[y][x] + if !isActive(t) { + return [cellHeight]string{" ", " ", " "} + } + + mask := m.state.rotatedMasks[y][x] + center := centerGlyph(m, x, y, t.Kind, mask) + + return [cellHeight]string{ + verticalCellRow(mask&north != 0), + horizontalCellRow(mask&west != 0, center, mask&east != 0), + verticalCellRow(mask&south != 0), + } +} + +func centerGlyph(m Model, x, y int, kind cellKind, mask directionMask) string { + switch { + case kind == serverCell: + return "◆" + case degree(mask) == 1: + return "●" + default: + return maskGlyph(mask) + } +} + +func blankCursorRows() [cellHeight]string { + return [cellHeight]string{ + " ", + game.CursorLeft + " " + game.CursorRight, + " ", + } +} + +func verticalCellRow(on bool) string { + if !on { + return " " + } + return " │ " +} + +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 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) + } + 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 stateBoolAt(m.state.tileHasDangling, cell.X, cell.Y) { + return theme.Current().Error + } + if stateBoolAt(m.state.connectedToRoot, cell.X, cell.Y) { + hasConnected = true + } + } + if hasConnected { + return theme.Current().Secondary + } + 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 + "\nspace: rotate enter: lock") + } + return game.StatusBarStyle().Render(info + "\nspace: rotate backspace: reverse\nenter: 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/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/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/nonogram/testdata/visual_states.jsonl b/nonogram/testdata/visual_states.jsonl new file mode 100644 index 0000000..9490b7a --- /dev/null +++ b/nonogram/testdata/visual_states.jsonl @@ -0,0 +1,3 @@ +{"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/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/nurikabe/testdata/visual_states.jsonl b/nurikabe/testdata/visual_states.jsonl new file mode 100644 index 0000000..fa4f3c7 --- /dev/null +++ b/nurikabe/testdata/visual_states.jsonl @@ -0,0 +1,3 @@ +{"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/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/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 4ee67ad..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 @@ -97,6 +104,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"` @@ -127,8 +142,6 @@ type GridTable struct { HasHeaderCol bool } -type RGB struct{ R, G, B uint8 } - type RenderConfig struct { Title string CoverSubtitle string @@ -137,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/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"}, } 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 new file mode 100644 index 0000000..d22f6dd --- /dev/null +++ b/rippleeffect/testdata/visual_states.jsonl @@ -0,0 +1,3 @@ +{"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/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/shikaku/testdata/visual_states.jsonl b/shikaku/testdata/visual_states.jsonl new file mode 100644 index 0000000..5baa384 --- /dev/null +++ b/shikaku/testdata/visual_states.jsonl @@ -0,0 +1,3 @@ +{"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/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/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/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/sudoku/testdata/visual_states.jsonl b/sudoku/testdata/visual_states.jsonl new file mode 100644 index 0000000..c8b15db --- /dev/null +++ b/sudoku/testdata/visual_states.jsonl @@ -0,0 +1,4 @@ +{"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 new file mode 100644 index 0000000..1d751cf --- /dev/null +++ b/sudokurgb/testdata/visual_states.jsonl @@ -0,0 +1,4 @@ +{"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..73d26ca 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,10 @@ 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.Foreground(game.ConflictFG()).Background(game.ConflictBG()) + text = conflictText(text) + } if isCursor { text = cursorText(text) } @@ -98,7 +102,67 @@ 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 { + 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 +175,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 new file mode 100644 index 0000000..65c3f3e --- /dev/null +++ b/takuzu/testdata/visual_states.jsonl @@ -0,0 +1,4 @@ +{"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..e4c9161 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,10 @@ 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.Foreground(game.ConflictFG()).Background(game.ConflictBG()) + text = conflictText(text) + } if isCursor { text = cursorText(text) } @@ -98,7 +102,88 @@ 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 { + 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 +196,8 @@ func gridView(m Model) string { y == m.cursor.Y, x == m.cursor.X, m.solved, + dupRows[y], + dupCols[x], ) }, ZoneAt: func(_, _ int) int { @@ -119,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 "" @@ -127,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 { @@ -137,15 +227,12 @@ func gridView(m Model) string { if rel == relationNone { return "" } - return string(rel) + return relationBridgeText(rel, m.grid[y-1][x], m.grid[y][x]) }, }) } 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 } @@ -190,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) 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 new file mode 100644 index 0000000..5c04ede --- /dev/null +++ b/takuzuplus/testdata/visual_states.jsonl @@ -0,0 +1,4 @@ +{"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/vhs/netwalk.gif b/vhs/netwalk.gif new file mode 100644 index 0000000..32f613d Binary files /dev/null and b/vhs/netwalk.gif differ 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 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 }