- commit
- d5dce2873385e4a3fbecbe1d35945f08d2085e40
- parent
- 439fd4e24aa82924d2be6ad138f2fe3ae32cd46c
- author
- Dami <dami.etoile@gmail.com>
| D | AGENTS.md | 251 | ------------------------------ |
| M | README.md | 4 | +- |
| M | cmd/ | 10 | +- |
| M | cmd/ | 31 | ++++ |
| M | pdfexport/ | 24 | ++- |
| M | pdfexport/ | 32 | +---- |
| M | pdfexport/ | 11 | - |
| M | pdfexport/ | 25 | +++ |
| M | pdfexport/ | 2 | +- |
@@ -1,251 +0,0 @@
1-# AGENTS.md - PuzzleTea Development Guide2-3-## Commands4-5-### Build & Run6-```bash7-just # default: build with version from git tags8-just build # explicit build recipe9-just run # build and run10-just install # install to $GOPATH/bin11-just clean # remove binary and dist/12-just vhs # regenerate all GIF previews under vhs/13-```14-Without `just`: `go build -ldflags "-X github.com/FelineStateMachine/puzzletea/cmd.Version=$(git describe --tags --always --dirty)" -o puzzletea`15-16-### CLI Play Workflows17-```bash18-puzzletea # launch interactive menu19-puzzletea new nonogram medium # start game directly20-puzzletea continue amber-falcon # resume by save name21-puzzletea list # list non-abandoned saves22-puzzletea list --all # include abandoned saves23-```24-25-Root flag shortcuts are supported:26-```bash27-puzzletea --new nonogram:medium28-puzzletea --continue amber-falcon29-puzzletea --set-seed issue-0130-puzzletea --theme "Catppuccin Mocha"31-```32-33-### CLI Seed & Export Workflows34-```bash35-puzzletea new --set-seed myseed # deterministic game/mode/puzzle selection36-puzzletea new nonogram epic --with-seed s1 # deterministic puzzle in selected game/mode37-puzzletea new nonogram mini --export 6 -o nonogram-mini.jsonl38-puzzletea new sudoku --export 10 --with-seed z1 -o sudoku-pack.jsonl39-puzzletea export-pdf nonogram-mini.jsonl -o issue-01.pdf --shuffle-seed issue-0140-```41-- `--set-seed` cannot be combined with positional game/mode arguments.42-- `--set-seed` cannot be combined with root `--new`/`--continue`.43-- `--set-seed` cannot be combined with export flags (`--export` / `--output`).44-- `--with-seed` is used with explicit game/mode arguments for mode-local reproducibility.45-- `new --export` requires a game arg; `--output` must end with `.jsonl` (stdout if omitted).46-- `export-pdf` accepts one or more JSONL files; default output is `<first-input>-print.pdf`.47-- `export-pdf --output` must end with `.pdf`; `--volume` must be `>= 1`.48-- `export-pdf` supports `--title`, `--header`, `--advert`, `--shuffle-seed`, and `--cover-color`.49-- `lightsout` has no print adapter, so `new lightsout --export ...` currently produces no records.50-51-### Testing52-```bash53-just test # go test ./...54-just test-short # go test -short ./... (skips slow generator tests)55-go test ./nonogram/ # single package56-go test ./sudoku/ -run TestGenerateGrid # specific test57-go test ./resolve/ -run TestCategory -v # specific test, verbose58-```59-60-### Linting & Formatting61-```bash62-just lint # golangci-lint run ./...63-just fmt # gofumpt -w .64-just tidy # go mod tidy65-```66-67-Always run `just fmt` and `just lint` before committing.68-69-Enabled linters (`.golangci.yml`): errcheck, gofumpt (extra-rules), gosimple, govet, ineffassign, misspell (US locale), staticcheck, unused.70-71----72-73-## Architecture74-75-PuzzleTea is a terminal puzzle game collection built with Go using the **Bubble Tea TUI framework** (Elm architecture: Model-Update-View).76-77-### Technology Stack78-- **TUI**: Bubble Tea v2 (`charm.land/bubbletea/v2`, always aliased as `tea`) + Bubbles + Lip Gloss79-- **CLI**: Cobra80-- **PDF generation**: go-pdf/fpdf (half-letter size: 139.7mm × 215.9mm)81-- **Persistence**: SQLite (`~/.puzzletea/history.db`)82-83-### Control Flow84-```85-main() → cmd.RootCmd (Cobra)86- ├─ Default: Launch TUI (app.InitialModel → Elm loop)87- ├─ --new / --continue / --set-seed: direct game launch88- └─ Subcommands: new, continue, list, export-pdf89-```90-91-### Key Packages92-93-| Package | Role |94-|---------|------|95-| `app/` | Root TUI model; 9 puzzle categories wired at startup |96-| `cmd/` | Cobra CLI commands including `export-pdf` |97-| `game/` | Plugin interfaces (`Gamer`, `Mode`, `Spawner`, `PrintAdapter`), registry |98-| `pdfexport/` | PDF pipeline: JSONL parsing → per-game rendering → cover art |99-| `store/` | SQLite persistence |100-| `theme/` | 365 WCAG-compliant color themes |101-| `stats/` | XP/level/streak system |102-| `config/` | Persistent JSON config (`~/.puzzletea/config.json`) |103-| `resolve/` | CLI argument matching for game/mode names |104-| `daily/` | Deterministic daily puzzle seeding |105-| `ui/` | Shared TUI components (menus, tables, panels) |106-107-### Puzzle Packages108-Eight printable games: `nonogram`, `sudoku`, `nurikabe`, `shikaku`, `wordsearch`, `hashiwokakero`, `hitori`, `takuzu`. One game without PDF export: `lightsout`.109-110-Each puzzle package exposes: `Modes`, `DailyModes`, `HelpContent`, `NewMode(...)`, `New(...)`, `ImportModel([]byte)`, `DefaultKeyMap`, and registers itself via `init()` in `Gamemode.go`.111-112-### Plugin Registration113-```go114-// In Gamemode.go init():115-game.Register("Nonogram", func(data []byte) (game.Gamer, error) {116- return ImportModel(data)117-})118-// Optional PDF export registration:119-game.RegisterPrintAdapter(adapter)120-```121-122-### PDF Export Pipeline123-```124-export-pdf command125- → ParseJSONLFiles() # parse schema puzzletea.export.v1126- → adapter.BuildPDFPayload() # game-specific save → typed struct127- → pdfexport.OrderPuzzlesForPrint() # difficulty-based ordering128- → pdfexport.WritePDF() # cover + title pages + puzzle bodies + back129-```130-131-### File Layout per Puzzle Package132-- **Capitalized**: `Model.go`, `Gamemode.go`, `Export.go`133-- **Lowercase**: `grid.go`, `keys.go`, `style.go`, `generator.go`, `mouse.go`, `<game>_test.go`134-- **Docs**: `help.md` (embedded via `//go:embed`), `README.md`135-136----137-138-## Code Style139-140-### Formatting141-- Use `gofumpt` (stricter than gofmt, extra-rules enabled) — run `just fmt`142-- Keep lines under ~100 characters143-- US English spelling enforced by misspell linter144-145-### Imports146-Two groups: stdlib, then everything else (internal + external sorted together). Three groups acceptable when there are many internal imports.147-```go148-import (149- "errors"150- "fmt"151-152- "github.com/FelineStateMachine/puzzletea/game"153- "charm.land/bubbles/v2/key"154- tea "charm.land/bubbletea/v2"155- "charm.land/lipgloss/v2"156-)157-```158-159-### Naming160-- **Types**: PascalCase (`Model`, `NonogramMode`, `Entry`)161-- **Unexported types**: camelCase or lowercase (`grid`, `state`, `menuItem`)162-- **Variables/Fields**: camelCase (`rowHints`, `currentHints`)163-- **Constants**: camelCase (`mainMenuView`, `gameSelectView`)164-- **Interfaces**: PascalCase (`Gamer`, `Spawner`, `Mode`)165-166-### Type Declarations167-Prefer grouped type blocks: `type ( grid [][]rune; state string )`168-169-### Interface Compliance (compile-time checks)170-```go171-var (172- _ game.Mode = NonogramMode{}173- _ game.Spawner = NonogramMode{}174- _ game.SeededSpawner = NonogramMode{}175-)176-```177-178-### Styling179-Use `theme.Current().Accent` etc. from the `theme` package, and `game/style.go` shared accessors (`CursorFG()`, `CursorBG()`, `ConflictFG()`). Use `compat.AdaptiveColor` from `charm.land/lipgloss/v2/compat` for adaptive colors.180-181-### Error Handling182-Return descriptive errors; use `fmt.Errorf` with `%w` only when wrapping adds context. No assertion libraries in tests — use `t.Errorf`/`t.Fatalf` only.183-184----185-186-## Testing Conventions187-188-### Section Comments with Priority189-```go190-// --- generateTomography (P0) --- // P0 = critical191-// --- Grid serialization (P1) --- // P1 = important192-// --- generateRandomState (P2) --- // P2 = generators/slow193-// --- TitleBarView (P3) --- // P3 = low-priority UI194-```195-196-### Table-Driven Tests with Subtests197-```go198-func TestGenerateTomography(t *testing.T) {199- tests := []struct {200- name string201- grid grid202- wantRows TomographyDefinition203- }{204- {name: "all filled row", grid: grid{...}, wantRows: ...},205- }206- for _, tt := range tests {207- t.Run(tt.name, func(t *testing.T) {208- // test logic using t.Errorf / t.Fatalf only209- })210- }211-}212-```213-214-### Slow Test Gating215-```go216-if testing.Short() {217- t.Skip("skipping slow generator test in short mode")218-}219-```220-221-### Save/Load Round-Trip Pattern222-```go223-data, err := m.GetSave()224-if err != nil { t.Fatal(err) }225-loaded, err := ImportModel(data)226-if err != nil { t.Fatal(err) }227-// verify state preserved228-```229-230----231-232-## Gamer Interface233-Every puzzle `Model` must implement (from `game/gamer.go`):234-```go235-type Gamer interface {236- Init() tea.Cmd237- Update(msg tea.Msg) (Gamer, tea.Cmd)238- View() string239- GetDebugInfo() string240- GetFullHelp() [][]key.Binding241- GetSave() ([]byte, error)242- IsSolved() bool243- Reset() Gamer244- SetTitle(string) Gamer245-}246-```247-248-Every mode type embeds `game.BaseMode` via `game.NewBaseMode(title, description)`.249-250-## Global Keybindings251-`Ctrl+N` Main menu | `Ctrl+C` Quit | `Ctrl+E` Debug overlay | `Ctrl+H` Full help | `Ctrl+R` Reset puzzle | `Enter` Select | `Escape` Back@@ -125,7 +125,9 @@ Render one or more JSONL packs into a half-letter print PDF:
125125 puzzletea export-pdf nonogram-mini-set.jsonl -o issue-01.pdf --shuffle-seed issue-01 --volume 1 --title "Catacombs & Pines"126126 ```127127 128-`--title` sets the cover subtitle, and `--volume` sets the cover volume number.128+`--title` sets the pack subtitle (title page, and cover pages when enabled), and `--volume` sets the volume number.129+By default, covers are not included. Use `--cover-color` to include front/back cover pages.130+Page count is always auto-padded to a multiple of 4 for half-letter booklet printing.129131 130132 Font license note (Atkinson Hyperlegible Next):131133 @@ -27,19 +27,19 @@ var (
2727 var exportPDFCmd = &cobra.Command{2828 Use: "export-pdf <input.jsonl> [more.jsonl ...]",2929 Short: "Convert one or more PuzzleTea JSONL exports into a half-letter printable PDF",30- 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 and one puzzle per page.",30+ 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.",3131 Args: cobra.MinimumNArgs(1),3232 RunE: runExportPDF,3333 }3434 3535 func init() {3636 exportPDFCmd.Flags().StringVarP(&flagPDFOutput, "output", "o", "", "write output PDF path (defaults to <first-input>-print.pdf)")37- exportPDFCmd.Flags().StringVar(&flagPDFTitle, "title", "", "subtitle shown on the cover")37+ exportPDFCmd.Flags().StringVar(&flagPDFTitle, "title", "", "subtitle shown on the title page (and on covers when enabled)")3838 exportPDFCmd.Flags().StringVar(&flagPDFHeader, "header", "", "optional intro paragraph shown on the title page under 'PuzzleTea Puzzle Pack'")39- exportPDFCmd.Flags().IntVar(&flagPDFVolume, "volume", 1, "volume number shown on the cover (must be >= 1)")39+ exportPDFCmd.Flags().IntVar(&flagPDFVolume, "volume", 1, "volume number shown on the title page (and on covers when enabled) (must be >= 1)")4040 exportPDFCmd.Flags().StringVar(&flagPDFAdvert, "advert", "Find more puzzles at github.com/FelineStateMachine/puzzletea", "advert text shown on the title page")4141 exportPDFCmd.Flags().StringVar(&flagPDFShuffleSeed, "shuffle-seed", "", "seed for deterministic within-band difficulty mixing")42- exportPDFCmd.Flags().StringVar(&flagPDFCoverColor, "cover-color", "", `accent color for cover page: hex "#RRGGBB", decimal "R,G,B", or omit for random vibrant nature tone`)42+ exportPDFCmd.Flags().StringVar(&flagPDFCoverColor, "cover-color", "", `accent color for optional front/back covers: hex "#RRGGBB" or decimal "R,G,B" (omit for no covers)`)4343 }4444 4545 func runExportPDF(cmd *cobra.Command, args []string) error {@@ -212,7 +212,7 @@ func normalizeDifficultyToken(s string) string {
212212 }213213 214214 // parseCoverColor parses a cover color string in hex ("#RRGGBB") or215-// decimal ("R,G,B") format. Returns nil if s is empty (random vibrant nature tone).215+// decimal ("R,G,B") format. Returns nil if s is empty (no cover pages).216216 func parseCoverColor(s string) (*pdfexport.RGB, error) {217217 s = strings.TrimSpace(s)218218 if s == "" {@@ -106,6 +106,37 @@ func TestBuildRenderConfigForPDFDefaultsSubtitleFromDocs(t *testing.T) {
106106 }107107 }108108 109+func TestBuildRenderConfigForPDFCoverColorControlsCoverPages(t *testing.T) {110+ reset := snapshotExportPDFFlags()111+ defer reset()112+113+ flagPDFTitle = "Issue 01"114+ flagPDFVolume = 1115+ flagPDFAdvert = "Find more puzzles"116+ docs := []pdfexport.PackDocument{{Metadata: pdfexport.PackMetadata{Category: "Sudoku"}}}117+118+ flagPDFCoverColor = ""119+ cfgNoCover, err := buildRenderConfigForPDF(docs, "seed-3", time.Now())120+ if err != nil {121+ t.Fatalf("buildRenderConfigForPDF (no cover color) error = %v", err)122+ }123+ if cfgNoCover.CoverColor != nil {124+ t.Fatalf("CoverColor = %+v, want nil when --cover-color is omitted", cfgNoCover.CoverColor)125+ }126+127+ flagPDFCoverColor = "#112233"128+ cfgWithCover, err := buildRenderConfigForPDF(docs, "seed-4", time.Now())129+ if err != nil {130+ t.Fatalf("buildRenderConfigForPDF (with cover color) error = %v", err)131+ }132+ if cfgWithCover.CoverColor == nil {133+ t.Fatal("CoverColor = nil, want parsed color when --cover-color is set")134+ }135+ if *cfgWithCover.CoverColor != (pdfexport.RGB{R: 0x11, G: 0x22, B: 0x33}) {136+ t.Fatalf("CoverColor = %+v, want {R:17 G:34 B:51}", *cfgWithCover.CoverColor)137+ }138+}139+109140 func TestRunExportPDFSilentlyNoOpsWhenAllPuzzlesUnsupported(t *testing.T) {110141 reset := snapshotExportPDFFlags()111142 defer reset()@@ -56,9 +56,6 @@ func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg Rend
5656 footerExcludedPages := map[int]struct{}{}5757 pdf.SetFooterFunc(func() {5858 pageNo := pdf.PageNo()59- if pageNo <= 2 {60- return61- }6259 if _, skip := footerExcludedPages[pageNo]; skip {6360 return6461 }@@ -68,23 +65,34 @@ func WritePDF(outputPath string, docs []PackDocument, puzzles []Puzzle, cfg Rend
6865 pdf.CellFormat(0, 4, strconv.Itoa(pageNo), "", 0, "C", false, 0, "")6966 })7067 71- coverColor := resolveCoverColor(cfg)72- renderCoverPage(pdf, printablePuzzles, cfg)68+ includeCover := cfg.CoverColor != nil69+ if includeCover {70+ renderCoverPage(pdf, printablePuzzles, cfg, *cfg.CoverColor)71+ footerExcludedPages[pdf.PageNo()] = struct{}{}72+ }73+7374 renderTitlePage(pdf, docs, printablePuzzles, cfg)75+ footerExcludedPages[pdf.PageNo()] = struct{}{}76+7477 for _, puzzle := range printablePuzzles {7578 if err := renderPuzzlePage(pdf, puzzle); err != nil {7679 return fmt.Errorf("render puzzle %q (%s #%d): %w", puzzle.Name, puzzle.Category, puzzle.Index, err)7780 }7881 }7982 80- totalPagesWithoutPadding := pdf.PageNo() + 1 // include upcoming back cover83+ totalPagesWithoutPadding := pdf.PageNo()84+ if includeCover {85+ totalPagesWithoutPadding++ // include upcoming back cover86+ }8187 for range saddleStitchPadCount(totalPagesWithoutPadding) {8288 renderPadPage(pdf)8389 footerExcludedPages[pdf.PageNo()] = struct{}{}8490 }8591 86- renderBackCoverPage(pdf, cfg, coverColor)87- footerExcludedPages[pdf.PageNo()] = struct{}{}92+ if includeCover {93+ renderBackCoverPage(pdf, cfg, *cfg.CoverColor)94+ footerExcludedPages[pdf.PageNo()] = struct{}{}95+ }8896 8997 dir := filepath.Dir(outputPath)9098 if dir != "" && dir != "." {@@ -2,40 +2,11 @@ package pdfexport
22 33 import (44 "fmt"5- "hash/fnv"6- "math/rand"75 "strings"86 97 "codeberg.org/go-pdf/fpdf"108 )119 12-var natureTonePalette = []RGB{13- {250, 76, 56}, // riso red14- {255, 112, 0}, // fluorescent orange15- {255, 183, 3}, // sunflower16- {53, 169, 255}, // sky blue17- {0, 204, 160}, // seafoam18- {152, 226, 68}, // neon moss19- {255, 82, 133}, // hot pink20- {104, 83, 255}, // ultramarine21-}22-23-func resolveCoverColor(cfg RenderConfig) RGB {24- if cfg.CoverColor != nil {25- return *cfg.CoverColor26- }27- seed := strings.TrimSpace(cfg.ShuffleSeed)28- var idx int29- if seed != "" {30- h := fnv.New64a()31- h.Write([]byte(seed))32- idx = int(h.Sum64() % uint64(len(natureTonePalette)))33- } else {34- idx = rand.Intn(len(natureTonePalette))35- }36- return natureTonePalette[idx]37-}38-3910 func splitCoverTextLines(pdf *fpdf.Fpdf, text string, maxW float64) []string {4011 trimmed := strings.TrimSpace(text)4112 if trimmed == "" {@@ -67,8 +38,7 @@ func splitCoverSubtitleLines(pdf *fpdf.Fpdf, subtitle string, maxW float64, maxL
6738 return out6839 }6940 70-func renderCoverPage(pdf *fpdf.Fpdf, _ []Puzzle, cfg RenderConfig) {71- coverColor := resolveCoverColor(cfg)41+func renderCoverPage(pdf *fpdf.Fpdf, _ []Puzzle, cfg RenderConfig, coverColor RGB) {7242 ink := RGB{R: 8, G: 8, B: 8}7343 7444 pdf.AddPage()@@ -7,17 +7,6 @@ import (
77 "codeberg.org/go-pdf/fpdf"88 )99 10-func TestResolveCoverColorDeterministicWithSeed(t *testing.T) {11- cfgA := RenderConfig{ShuffleSeed: "zine-seed"}12- cfgB := RenderConfig{ShuffleSeed: "zine-seed"}13-14- colorA := resolveCoverColor(cfgA)15- colorB := resolveCoverColor(cfgB)16- if colorA != colorB {17- t.Fatalf("resolveCoverColor mismatch for identical seed: %+v vs %+v", colorA, colorB)18- }19-}20-2110 func TestSplitCoverSubtitleLinesClampsToMaxLines(t *testing.T) {2211 pdf := fpdf.New("P", "mm", "A4", "")2312 if err := registerPDFFonts(pdf); err != nil {@@ -116,3 +116,28 @@ func TestSaddleStitchPadCountForStandardPackLayout(t *testing.T) {
116116 })117117 }118118 }119+120+func TestSaddleStitchPadCountForTitleOnlyPackLayout(t *testing.T) {121+ tests := []struct {122+ name string123+ puzzleRows int124+ wantPad int125+ }{126+ {name: "single puzzle", puzzleRows: 1, wantPad: 2},127+ {name: "two puzzles", puzzleRows: 2, wantPad: 1},128+ {name: "thirty-two puzzles", puzzleRows: 32, wantPad: 3},129+ }130+131+ for _, tt := range tests {132+ t.Run(tt.name, func(t *testing.T) {133+ totalWithoutPad := tt.puzzleRows + 1 // title page + puzzle pages134+ got := saddleStitchPadCount(totalWithoutPad)135+ if got != tt.wantPad {136+ t.Fatalf("pad pages = %d, want %d", got, tt.wantPad)137+ }138+ if (totalWithoutPad+got)%4 != 0 {139+ t.Fatalf("total pages = %d, want multiple of 4", totalWithoutPad+got)140+ }141+ })142+ }143+}