Skip to content

Commit dfd7d55

Browse files
Merge pull request #51 from FelineStateMachine/chore/tech-debt
Refactor application screens and export parsing
2 parents df406aa + 7d01c02 commit dfd7d55

File tree

177 files changed

+5124
-5031
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

177 files changed

+5124
-5031
lines changed

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,9 @@
44
- Prefer `just test-short` for test runs.
55
- Use the -short flag when testing non-generator code.
66
- Use full-length tests only when validating long-running generator behavior.
7+
- Package map:
8+
- `catalog` is the pure metadata index.
9+
- `registry` is the concrete built-in runtime registry.
10+
- `gameentry` builds validated runtime entries from definitions plus modes.
11+
- `pdfexport` owns the printable export pipeline.
12+
- `builtinprint` only bootstraps built-in print adapters.

README.md

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,19 @@
22

33
A terminal-based puzzle game collection built with [Bubble Tea](https://github.com/charmbracelet/bubbletea).
44

5-
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.
5+
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.
66

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

99
## Features
1010

11-
- **13 puzzle games** -- Fillomino, Nonogram, Nurikabe, Ripple Effect, Sudoku, Shikaku, Spell Puzzle, Word Search, Hashiwokakero, Hitori, Lights Out, Takuzu, Takuzu+
11+
- **14 puzzle games** -- Fillomino, Nonogram, Nurikabe, Ripple Effect, Shikaku, Sudoku, Sudoku RGB, Spell Puzzle, Word Search, Hashiwokakero, Hitori, Lights Out, Takuzu, Takuzu+
1212
- **Daily puzzles** -- A unique puzzle generated each day using deterministic seeding. Same date, same puzzle for everyone. Streak tracking rewards consecutive daily completions.
1313
- **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.
1414
- **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.
1515
- **Stats dashboard** -- Profile level, daily streak status, weekly completion progress, victory counts, and XP progress bars per category.
1616
- **365 color themes** -- Live-preview theme picker with WCAG-compliant contrast enforcement. Dark and light themes included.
17-
- **Mouse support** -- Click and drag in Nonogram, Nurikabe, Shikaku, Spell Puzzle, and Word Search. Lights Out supports click-to-toggle.
17+
- **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.
1818
- **Seeded puzzles** -- Share a seed string to generate identical puzzles across sessions and machines.
1919
- **Save/load persistence** -- Games auto-save to SQLite. Resume any in-progress game by name.
2020

@@ -23,13 +23,14 @@ Twelve puzzle types, multiple difficulty modes, daily and weekly deterministic c
2323
| Game | Description | Modes |
2424
|------|-------------|-------|
2525
| **Fillomino** | Grow numbered regions to their exact sizes | Mini 5x5 through Expert 12x12 |
26-
| **Nonogram** | Fill cells to match row and column hints | Easy/Medium/Hard across 5x5, 10x10, 15x15, 20x20 |
26+
| **Nonogram** | Fill cells to match row and column hints | 10 named modes from 5x5 `Mini` to 20x20 `Massive` |
2727
| **Nurikabe** | Build islands while keeping one connected sea | 5 modes from 5x5 to 12x12 |
2828
| **Ripple Effect** | Place digits in cages without violating ripple distance | Mini 5x5 through Expert 9x9 |
29-
| **Shikaku** | Divide grid into rectangles matching cell counts | 5 modes from 7x7 to 11x11 |
29+
| **Shikaku** | Divide grid into rectangles matching cell counts | Mini 5x5 through Expert 12x12 |
3030
| **Sudoku** | Classic 9x9 grid | Beginner, Easy, Medium, Hard, Expert, Diabolical |
31+
| **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 |
3132
| **Spell Puzzle** | Connect letters to reveal crossword words and bonus anagrams | Beginner, Easy, Medium, Hard |
32-
| **Word Search** | Find hidden words in a letter grid | Easy, Medium, Hard (3-8 directions) |
33+
| **Word Search** | Find hidden words in a letter grid | Easy 10x10, Medium 15x15, Hard 20x20 |
3334
| **Hashiwokakero** | Connect islands with bridges | 12 modes across 7x7 to 13x13 grids |
3435
| **Hitori** | Shade cells to eliminate duplicates | 6 modes from 5x5 to 12x12 |
3536
| **Lights Out** | Toggle lights to turn all off | Easy (3x3) to Extreme (9x9) |
@@ -99,15 +100,19 @@ that week. Current-week puzzles unlock one at a time; older weeks are review-onl
99100
Start a new game directly:
100101

101102
```bash
102-
puzzletea new nonogram medium
103+
puzzletea new nonogram classic
103104
puzzletea new fillomino "Hard 10x10"
104105
puzzletea new ripple-effect "Medium 7x7"
105106
puzzletea new sudoku hard
107+
puzzletea new ripeto expert
106108
puzzletea new spell beginner
107109
puzzletea new lights-out
108-
puzzletea new hashi easy
110+
puzzletea new hashi "Easy 7x7"
109111
```
110112

113+
Mode names are matched case-insensitively after normalizing spaces, hyphens,
114+
and underscores. Multi-word mode titles usually need quotes.
115+
111116
Resume and manage saved games:
112117

113118
```bash
@@ -167,13 +172,13 @@ puzzletea --theme "Catppuccin Mocha"
167172
Flag aliases on the root command also work:
168173

169174
```bash
170-
puzzletea --new nonogram:medium
175+
puzzletea --new nonogram:classic
171176
puzzletea --continue amber-falcon
172177
```
173178

174179
### CLI Aliases
175180

176-
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.
181+
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.
177182

178183
## Controls
179184

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

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

195199
### Mouse
196200

197-
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.
201+
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.
198202

199203
## Game Persistence
200204

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

322326
## Adding a New Puzzle
323327

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

326330
### 1. Create the game package
327331

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

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

342-
### 2. Wire it into the central catalog
348+
Use `gameentry.BuildModeDefs(Modes)` and `gameentry.NewEntry(...)` so the
349+
package exposes both metadata and its validated runtime wiring.
350+
351+
### 2. Wire it into the built-in registry
343352

344-
Edit the central catalog once:
353+
Edit the built-in registry once:
345354

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

348357
The game package's `Definition` owns:
349358

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

367+
The `catalog` package is built from the registered entries and remains the pure
368+
metadata index for names, aliases, and daily-mode lookup.
369+
358370
### 3. Add a VHS preview
359371

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

365377
```bash
366-
just fmt && just lint && just test
378+
just fmt
379+
just lint
380+
just test-short
367381
```
368382

369383
See any existing game package (e.g., `nonogram/`) for the full pattern, and `AGENTS.md` for detailed conventions.

app/game_session.go

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,8 @@
11
package app
22

33
import (
4-
"log"
5-
6-
"github.com/FelineStateMachine/puzzletea/game"
7-
sessionflow "github.com/FelineStateMachine/puzzletea/session"
84
"github.com/FelineStateMachine/puzzletea/store"
95
"github.com/FelineStateMachine/puzzletea/weekly"
10-
11-
tea "charm.land/bubbletea/v2"
126
)
137

148
type gameOpenOptions struct {
@@ -17,37 +11,11 @@ type gameOpenOptions struct {
1711
weeklyInfo *weekly.Info
1812
}
1913

20-
// activateGame prepares the game with global UI state and moves to game view.
21-
func (m model) activateGame(g game.Gamer, activeGameID int64, completionSaved bool, options gameOpenOptions) model {
22-
g, _ = g.Update(game.HelpToggleMsg{Show: m.help.showFull})
23-
g, _ = g.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
24-
25-
m.session.game = g
26-
m.session.activeGameID = activeGameID
27-
m.session.completionSaved = completionSaved
28-
m.session.returnState = options.returnState
29-
m.session.weeklyAdvance = options.weeklyInfo
30-
m.state = gameView
31-
return m
32-
}
33-
3414
func (m model) importAndActivateRecord(rec store.GameRecord) (model, bool) {
3515
return m.importAndActivateRecordWithOptions(rec, gameOpenOptions{returnState: mainMenuView})
3616
}
3717

3818
func (m model) importAndActivateRecordWithOptions(rec store.GameRecord, options gameOpenOptions) (model, bool) {
39-
g, err := sessionflow.ImportRecord(&rec)
40-
if err != nil {
41-
log.Printf("failed to import game: %v", err)
42-
return m, false
43-
}
44-
45-
activeGameID := rec.ID
46-
completionSaved := rec.Status == store.StatusCompleted
47-
if options.readOnly {
48-
activeGameID = 0
49-
completionSaved = true
50-
}
51-
52-
return m.activateGame(g, activeGameID, completionSaved, options), true
19+
ok := newSessionController(&m).importRecord(rec, options)
20+
return m, ok
5321
}

app/handle_daily.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package app
2+
3+
import (
4+
"time"
5+
6+
"github.com/FelineStateMachine/puzzletea/daily"
7+
sessionflow "github.com/FelineStateMachine/puzzletea/session"
8+
"github.com/FelineStateMachine/puzzletea/store"
9+
10+
tea "charm.land/bubbletea/v2"
11+
)
12+
13+
func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {
14+
today := time.Now()
15+
name := daily.Name(today)
16+
17+
rec, err := m.store.GetDailyGame(name)
18+
if err != nil {
19+
return m.setErrorf("Could not check today’s daily puzzle: %v", err), nil
20+
}
21+
if rec != nil {
22+
var resumed bool
23+
m, resumed = m.importAndActivateRecord(*rec)
24+
if resumed {
25+
if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, rec); err != nil {
26+
m = m.setErrorf("%v", err)
27+
}
28+
}
29+
return m, nil
30+
}
31+
32+
spawner, gameType, modeTitle := daily.Mode(today)
33+
if spawner == nil {
34+
return m.setErrorf("No daily puzzle is configured for %s", today.Format("2006-01-02")), nil
35+
}
36+
37+
rng := daily.RNG(today)
38+
cmd := newSessionController(&m).startSeededSpawn(spawner, rng, spawnRequest{
39+
source: spawnSourceDaily,
40+
name: name,
41+
gameType: gameType,
42+
modeTitle: modeTitle,
43+
run: store.DailyRunMetadata(today),
44+
returnState: playMenuView,
45+
exitState: mainMenuView,
46+
})
47+
return m, cmd
48+
}

app/handle_game.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package app
2+
3+
import (
4+
"github.com/FelineStateMachine/puzzletea/registry"
5+
sessionflow "github.com/FelineStateMachine/puzzletea/session"
6+
"github.com/FelineStateMachine/puzzletea/store"
7+
"github.com/FelineStateMachine/puzzletea/ui"
8+
9+
tea "charm.land/bubbletea/v2"
10+
)
11+
12+
func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) {
13+
entry, ok := selectedCategoryEntry(m.nav.gameSelectList.SelectedItem())
14+
if !ok {
15+
return m, nil
16+
}
17+
m.nav.selectedCategory = entry
18+
m.nav.modeSelectList = ui.InitList(buildModeDisplayItems(entry), entry.Definition.Name+" - Select Mode")
19+
m.nav.modeSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.nav.modeSelectList)))
20+
m.state = modeSelectView
21+
return m, nil
22+
}
23+
24+
func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {
25+
item := unwrapModeDisplayItem(m.nav.modeSelectList.SelectedItem())
26+
mode, ok := item.(registry.ModeEntry)
27+
if !ok {
28+
return m, nil
29+
}
30+
m.nav.selectedModeTitle = mode.Definition.Title
31+
cmd := newSessionController(&m).startSpawn(mode.Spawner, spawnRequest{
32+
source: spawnSourceNormal,
33+
name: sessionflow.GenerateUniqueName(m.store),
34+
gameType: m.nav.selectedCategory.Definition.Name,
35+
modeTitle: m.nav.selectedModeTitle,
36+
run: store.NormalRunMetadata(),
37+
returnState: modeSelectView,
38+
exitState: mainMenuView,
39+
})
40+
return m, cmd
41+
}
42+
43+
func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {
44+
idx := m.cont.table.Cursor()
45+
if idx < 0 || idx >= len(m.cont.games) {
46+
return m, nil
47+
}
48+
rec := m.cont.games[idx]
49+
m, _ = m.importAndActivateRecord(rec)
50+
return m, nil
51+
}
52+
53+
func (m model) persistCompletionIfSolved() model {
54+
newSessionController(&m).persistCompletionIfSolved()
55+
return m
56+
}

app/handle_help.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package app
2+
3+
import (
4+
tea "charm.land/bubbletea/v2"
5+
)
6+
7+
func (m model) handleHelpSelectEnter() (tea.Model, tea.Cmd) {
8+
entry, ok := selectedCategoryEntry(m.help.selectList.SelectedItem())
9+
if !ok {
10+
return m, nil
11+
}
12+
m.help.category = entry
13+
m = m.updateHelpDetailViewport()
14+
m.state = helpDetailView
15+
return m, nil
16+
}

app/handle_menu.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package app
2+
3+
import (
4+
"time"
5+
6+
"github.com/FelineStateMachine/puzzletea/ui"
7+
8+
tea "charm.land/bubbletea/v2"
9+
)
10+
11+
func (m model) currentWeeklyMenuIndex() int {
12+
if m.store == nil {
13+
return 1
14+
}
15+
16+
year, week := time.Now().ISOWeek()
17+
highestCompleted, err := m.store.GetCurrentWeeklyHighestCompletedIndex(year, week)
18+
if err != nil {
19+
return 1
20+
}
21+
if highestCompleted >= weeklyEntryCount {
22+
return weeklyEntryCount
23+
}
24+
if highestCompleted < 1 {
25+
return 1
26+
}
27+
return highestCompleted + 1
28+
}
29+
30+
func updateMainMenuCursor(msg tea.Msg, menu *ui.MainMenu) {
31+
keyMsg, ok := msg.(tea.KeyPressMsg)
32+
if !ok {
33+
return
34+
}
35+
switch keyMsg.String() {
36+
case "up", "k":
37+
menu.CursorUp()
38+
case "down", "j":
39+
menu.CursorDown()
40+
}
41+
}

0 commit comments

Comments
 (0)