Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 28 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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 |

Expand Down Expand Up @@ -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"
```

Expand Down Expand Up @@ -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):

Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions catalog/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
68 changes: 22 additions & 46 deletions cmd/export_pdf.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package cmd
import (
"fmt"
"path/filepath"
"strconv"
"strings"
"time"

Expand All @@ -22,25 +21,25 @@ var (
flagPDFVolume int
flagPDFAdvert string
flagPDFShuffleSeed string
flagPDFCoverColor string
flagPDFSheetLayout string
)

var exportPDFCmd = &cobra.Command{
Use: "export-pdf <input.jsonl> [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 <first-input>-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 {
Expand Down Expand Up @@ -115,29 +114,39 @@ 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),
VolumeNumber: flagPDFVolume,
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))

Expand Down Expand Up @@ -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)
}
53 changes: 33 additions & 20 deletions cmd/export_pdf_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}}
Expand Down Expand Up @@ -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())
Expand All @@ -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())
}
}

Expand Down Expand Up @@ -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
}
}
8 changes: 6 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Loading
Loading