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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,9 @@
- Prefer `just test-short` for test runs.
- Use the -short flag when testing non-generator code.
- Use full-length tests only when validating long-running generator behavior.
- Package map:
- `catalog` is the pure metadata index.
- `registry` is the concrete built-in runtime registry.
- `gameentry` builds validated runtime entries from definitions plus modes.
- `pdfexport` owns the printable export pipeline.
- `builtinprint` only bootstraps built-in print adapters.
54 changes: 34 additions & 20 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).

Twelve puzzle types, multiple difficulty modes, daily and weekly deterministic challenges, XP progression, 365 color themes, and an explicit game catalog for adding new games.
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.

![PuzzleTea menu](vhs/menu.gif)

## Features

- **13 puzzle games** -- Fillomino, Nonogram, Nurikabe, Ripple Effect, Sudoku, Shikaku, Spell Puzzle, Word Search, Hashiwokakero, Hitori, Lights Out, Takuzu, Takuzu+
- **14 puzzle games** -- Fillomino, 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** -- Click and drag in Nonogram, Nurikabe, Shikaku, Spell Puzzle, and Word Search. Lights Out supports click-to-toggle.
- **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.
- **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 @@ -23,13 +23,14 @@ Twelve puzzle types, multiple difficulty modes, daily and weekly deterministic c
| Game | Description | Modes |
|------|-------------|-------|
| **Fillomino** | Grow numbered regions to their exact sizes | Mini 5x5 through Expert 12x12 |
| **Nonogram** | Fill cells to match row and column hints | Easy/Medium/Hard across 5x5, 10x10, 15x15, 20x20 |
| **Nonogram** | Fill cells to match row and column hints | 10 named modes from 5x5 `Mini` to 20x20 `Massive` |
| **Nurikabe** | Build islands while keeping one connected sea | 5 modes from 5x5 to 12x12 |
| **Ripple Effect** | Place digits in cages without violating ripple distance | Mini 5x5 through Expert 9x9 |
| **Shikaku** | Divide grid into rectangles matching cell counts | 5 modes from 7x7 to 11x11 |
| **Shikaku** | Divide grid into rectangles matching cell counts | Mini 5x5 through Expert 12x12 |
| **Sudoku** | Classic 9x9 grid | Beginner, Easy, Medium, Hard, Expert, Diabolical |
| **Sudoku RGB** | Fill a 9x9 grid with three symbols so each row, column, and box contains three of each | Beginner, Easy, Medium, Hard, Expert, Diabolical |
| **Spell Puzzle** | Connect letters to reveal crossword words and bonus anagrams | Beginner, Easy, Medium, Hard |
| **Word Search** | Find hidden words in a letter grid | Easy, Medium, Hard (3-8 directions) |
| **Word Search** | Find hidden words in a letter grid | Easy 10x10, Medium 15x15, Hard 20x20 |
| **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) |
Expand Down Expand Up @@ -99,15 +100,19 @@ that week. Current-week puzzles unlock one at a time; older weeks are review-onl
Start a new game directly:

```bash
puzzletea new nonogram medium
puzzletea new nonogram classic
puzzletea new fillomino "Hard 10x10"
puzzletea new ripple-effect "Medium 7x7"
puzzletea new sudoku hard
puzzletea new ripeto expert
puzzletea new spell beginner
puzzletea new lights-out
puzzletea new hashi easy
puzzletea new hashi "Easy 7x7"
```

Mode names are matched case-insensitively after normalizing spaces, hyphens,
and underscores. Multi-word mode titles usually need quotes.

Resume and manage saved games:

```bash
Expand Down Expand Up @@ -167,13 +172,13 @@ puzzletea --theme "Catppuccin Mocha"
Flag aliases on the root command also work:

```bash
puzzletea --new nonogram:medium
puzzletea --new nonogram:classic
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, `binairo`/`binary` for Takuzu, `binario+` for Takuzu+, `words`/`ws` for Word Search, `rectangles` for Shikaku.
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.

## Controls

Expand All @@ -185,7 +190,6 @@ Several shorthand names are accepted for games: `polyomino`/`regions` for Fillom
| `Escape` | Return to the menu or go back |
| `Ctrl+R` | Reset puzzle |
| `Ctrl+H` | Toggle full help |
| `Ctrl+E` | Toggle debug overlay |
| `Ctrl+C` | Quit |

### Navigation
Expand All @@ -194,7 +198,7 @@ Arrow keys, WASD, and Vim bindings (`hjkl`) are supported for grid movement acro

### Mouse

Nonogram, Nurikabe, Shikaku, Spell Puzzle, and Word Search support click and drag. 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. Lights Out supports click to toggle. See each game's help for details.

## Game Persistence

Expand Down Expand Up @@ -321,29 +325,34 @@ All code must pass `gofumpt` and `golangci-lint` before committing. CI runs both

## Adding a New Puzzle

PuzzleTea uses an explicit game catalog. To add a new puzzle type:
PuzzleTea uses an explicit built-in registry plus a metadata catalog. To add a new puzzle type:

### 1. Create the game package

Create a directory (e.g., `mypuzzle/`) with these files:

| File | Purpose |
|------|---------|
| `Gamemode.go` | Mode struct embedding `game.BaseMode`, `Spawn()`, `Modes`/`DailyModes`, and package-level `Definition` metadata |
| `Model.go` | `Model` struct implementing `game.Gamer` |
| `Export.go` | `Save` struct, `GetSave()`, `ImportModel()` for persistence |
| `gamemode.go` | Mode structs embedding `game.BaseMode`, `Modes`, `ModeDefinitions`, package-level `Definition`, and the built-in `Entry` |
| `model.go` | `Model` struct implementing `game.Gamer` |
| `export.go` | `Save` struct, `GetSave()`, `ImportModel()` for persistence |
| `keys.go` | Game-specific `KeyMap` struct |
| `style.go` | lipgloss styling and rendering helpers |
| `generator.go` | Puzzle generation logic (if applicable) |
| `grid.go` | Grid type and serialization (for grid-based games) |
| `help.md` | Embedded rules/help content wired into the runtime entry |
| `print_adapter.go` | Optional printable export adapter for JSONL/PDF support |
| `mypuzzle_test.go` | Tests (table-driven, save/load round-trip, generator validation) |
| `README.md` | Game docs: rules, controls table, modes table, quick start examples |

### 2. Wire it into the central catalog
Use `gameentry.BuildModeDefs(Modes)` and `gameentry.NewEntry(...)` so the
package exposes both metadata and its validated runtime wiring.

### 2. Wire it into the built-in registry

Edit the central catalog once:
Edit the built-in registry once:

- **`catalog/catalog.go`**: Import the package and add its exported `Definition` to `All` (maintain alphabetical order).
- **`registry/registry.go`**: Import the package and add its exported `Entry` to `all` (maintain alphabetical order).

The game package's `Definition` owns:

Expand All @@ -355,6 +364,9 @@ The game package's `Definition` owns:
- help content
- save/import function

The `catalog` package is built from the registered entries and remains the pure
metadata index for names, aliases, and daily-mode lookup.

### 3. Add a VHS preview

- Create `vhs/<game>.tape` following the format in existing tapes.
Expand All @@ -363,7 +375,9 @@ The game package's `Definition` owns:
### 4. Verify

```bash
just fmt && just lint && just test
just fmt
just lint
just test-short
```

See any existing game package (e.g., `nonogram/`) for the full pattern, and `AGENTS.md` for detailed conventions.
Expand Down
36 changes: 2 additions & 34 deletions app/game_session.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
package app

import (
"log"

"github.com/FelineStateMachine/puzzletea/game"
sessionflow "github.com/FelineStateMachine/puzzletea/session"
"github.com/FelineStateMachine/puzzletea/store"
"github.com/FelineStateMachine/puzzletea/weekly"

tea "charm.land/bubbletea/v2"
)

type gameOpenOptions struct {
Expand All @@ -17,37 +11,11 @@ type gameOpenOptions struct {
weeklyInfo *weekly.Info
}

// activateGame prepares the game with global UI state and moves to game view.
func (m model) activateGame(g game.Gamer, activeGameID int64, completionSaved bool, options gameOpenOptions) model {
g, _ = g.Update(game.HelpToggleMsg{Show: m.help.showFull})
g, _ = g.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})

m.session.game = g
m.session.activeGameID = activeGameID
m.session.completionSaved = completionSaved
m.session.returnState = options.returnState
m.session.weeklyAdvance = options.weeklyInfo
m.state = gameView
return m
}

func (m model) importAndActivateRecord(rec store.GameRecord) (model, bool) {
return m.importAndActivateRecordWithOptions(rec, gameOpenOptions{returnState: mainMenuView})
}

func (m model) importAndActivateRecordWithOptions(rec store.GameRecord, options gameOpenOptions) (model, bool) {
g, err := sessionflow.ImportRecord(&rec)
if err != nil {
log.Printf("failed to import game: %v", err)
return m, false
}

activeGameID := rec.ID
completionSaved := rec.Status == store.StatusCompleted
if options.readOnly {
activeGameID = 0
completionSaved = true
}

return m.activateGame(g, activeGameID, completionSaved, options), true
ok := newSessionController(&m).importRecord(rec, options)
return m, ok
}
48 changes: 48 additions & 0 deletions app/handle_daily.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package app

import (
"time"

"github.com/FelineStateMachine/puzzletea/daily"
sessionflow "github.com/FelineStateMachine/puzzletea/session"
"github.com/FelineStateMachine/puzzletea/store"

tea "charm.land/bubbletea/v2"
)

func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {
today := time.Now()
name := daily.Name(today)

rec, err := m.store.GetDailyGame(name)
if err != nil {
return m.setErrorf("Could not check today’s daily puzzle: %v", err), nil
}
if rec != nil {
var resumed bool
m, resumed = m.importAndActivateRecord(*rec)
if resumed {
if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, rec); err != nil {
m = m.setErrorf("%v", err)
}
}
return m, nil
}

spawner, gameType, modeTitle := daily.Mode(today)
if spawner == nil {
return m.setErrorf("No daily puzzle is configured for %s", today.Format("2006-01-02")), nil
}

rng := daily.RNG(today)
cmd := newSessionController(&m).startSeededSpawn(spawner, rng, spawnRequest{
source: spawnSourceDaily,
name: name,
gameType: gameType,
modeTitle: modeTitle,
run: store.DailyRunMetadata(today),
returnState: playMenuView,
exitState: mainMenuView,
})
return m, cmd
}
56 changes: 56 additions & 0 deletions app/handle_game.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package app

import (
"github.com/FelineStateMachine/puzzletea/registry"
sessionflow "github.com/FelineStateMachine/puzzletea/session"
"github.com/FelineStateMachine/puzzletea/store"
"github.com/FelineStateMachine/puzzletea/ui"

tea "charm.land/bubbletea/v2"
)

func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) {
entry, ok := selectedCategoryEntry(m.nav.gameSelectList.SelectedItem())
if !ok {
return m, nil
}
m.nav.selectedCategory = entry
m.nav.modeSelectList = ui.InitList(buildModeDisplayItems(entry), entry.Definition.Name+" - Select Mode")
m.nav.modeSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.nav.modeSelectList)))
m.state = modeSelectView
return m, nil
}

func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {
item := unwrapModeDisplayItem(m.nav.modeSelectList.SelectedItem())
mode, ok := item.(registry.ModeEntry)
if !ok {
return m, nil
}
m.nav.selectedModeTitle = mode.Definition.Title
cmd := newSessionController(&m).startSpawn(mode.Spawner, spawnRequest{
source: spawnSourceNormal,
name: sessionflow.GenerateUniqueName(m.store),
gameType: m.nav.selectedCategory.Definition.Name,
modeTitle: m.nav.selectedModeTitle,
run: store.NormalRunMetadata(),
returnState: modeSelectView,
exitState: mainMenuView,
})
return m, cmd
}

func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {
idx := m.cont.table.Cursor()
if idx < 0 || idx >= len(m.cont.games) {
return m, nil
}
rec := m.cont.games[idx]
m, _ = m.importAndActivateRecord(rec)
return m, nil
}

func (m model) persistCompletionIfSolved() model {
newSessionController(&m).persistCompletionIfSolved()
return m
}
16 changes: 16 additions & 0 deletions app/handle_help.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package app

import (
tea "charm.land/bubbletea/v2"
)

func (m model) handleHelpSelectEnter() (tea.Model, tea.Cmd) {
entry, ok := selectedCategoryEntry(m.help.selectList.SelectedItem())
if !ok {
return m, nil
}
m.help.category = entry
m = m.updateHelpDetailViewport()
m.state = helpDetailView
return m, nil
}
41 changes: 41 additions & 0 deletions app/handle_menu.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package app

import (
"time"

"github.com/FelineStateMachine/puzzletea/ui"

tea "charm.land/bubbletea/v2"
)

func (m model) currentWeeklyMenuIndex() int {
if m.store == nil {
return 1
}

year, week := time.Now().ISOWeek()
highestCompleted, err := m.store.GetCurrentWeeklyHighestCompletedIndex(year, week)
if err != nil {
return 1
}
if highestCompleted >= weeklyEntryCount {
return weeklyEntryCount
}
if highestCompleted < 1 {
return 1
}
return highestCompleted + 1
}

func updateMainMenuCursor(msg tea.Msg, menu *ui.MainMenu) {
keyMsg, ok := msg.(tea.KeyPressMsg)
if !ok {
return
}
switch keyMsg.String() {
case "up", "k":
menu.CursorUp()
case "down", "j":
menu.CursorDown()
}
}
Loading
Loading