clone

fifteen puzzle games for the terminal, with daily puzzles, weekly ladders and xp, built on bubble tea

made cover optional

Dami committed d5dce28
commit
d5dce2873385e4a3fbecbe1d35945f08d2085e40
parent
439fd4e24aa82924d2be6ad138f2fe3ae32cd46c
author
Dami <dami.etoile@gmail.com>
9 files changed, +82 -308
DAGENTS.md251------------------------------
MREADME.md4+-
Mcmd/export_pdf.go10+-
Mcmd/export_pdf_test.go31++++
Mpdfexport/render.go24++-
Mpdfexport/render_cover.go32+----
Mpdfexport/render_cover_test.go11-
Mpdfexport/render_layout_test.go25+++
Mpdfexport/types.go2+-
D AGENTS.md +0 -251
@@ -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
M README.md +3 -1
M cmd/export_pdf.go +5 -5
@@ -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 == "" {
M cmd/export_pdf_test.go +31 -0
M pdfexport/render.go +16 -8
@@ -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 != "." {
M pdfexport/render_cover.go +1 -31
M pdfexport/render_cover_test.go +0 -11
M pdfexport/render_layout_test.go +25 -0
M pdfexport/types.go +1 -1