clone

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

Merge pull request #51 from FelineStateMachine/chore/tech-debt

FelineStateMachine committed dfd7d55
Refactor application screens and export parsing
commit
dfd7d55c8fb2a75fac2414495c61908553bd7119
parent
df406aaeeecdbeb12576827cd4baeb79eb2d62be
parent
7d01c023feb3e18f995757261aecb2c90689e2db
author
FelineStateMachine <dami.etoile@gmail.com>
177 files changed, +5124 -5031
MAGENTS.md6+
MREADME.md54+-
Mapp/game_session.go36+-
Aapp/handle_daily.go48+
Aapp/handle_game.go56+
Aapp/handle_help.go16+
Aapp/handle_menu.go41+
Aapp/handle_seed.go66++
Aapp/handle_stats.go68++
Aapp/handle_theme.go66++
Mapp/model.go74+-
Aapp/notice.go66++
Aapp/screen_core.go190+++++
Aapp/screen_layout.go177++++
Aapp/screen_menu.go152++++
Aapp/screen_navigation.go309++++++++
Aapp/screen_support.go256++++++
Aapp/screens_test.go74++
Mapp/seed_input.go52+-
Aapp/session_controller.go204+++++
Mapp/spawn.go103+--
Mapp/spawn_test.go27+
Aapp/state_view.go52+
Mapp/update.go872+++------------------
Mapp/update_test.go65+-
Mapp/view.go142+---
Mapp/view_test.go44+
Mapp/weekly.go53+-
Marchitecture_test.go132+++-
Abuiltinprint/register.go23+
Abuiltinprint/register_test.go22+
Mcatalog/catalog.go2+
Mcmd/config_test.go1+
Mcmd/export_pdf.go8+-
Mcmd/export_pdf_test.go10+-
Mcmd/new.go4+-
Mcmd/new_export.go5+-
Mcmd/new_export_test.go7+-
Mdaily/daily.go38+-
Mdaily/daily_test.go49-
Mfillomino/README.md27+-
Rfillomino/Export.go → fillomino/export.go0
Rfillomino/Gamemode.go → fillomino/gamemode.go35+-
Mfillomino/help.md3+-
Rfillomino/Model.go → fillomino/model.go0
Rfillomino/PrintAdapter.go → fillomino/print_adapter.go6+-
Mgame/game_test.go37-
Mgame/gamer.go40+-
Dgame/print_adapter.go21-
Dgame/print_adapter_test.go55-
Agameentry/gameentry.go105+++
Agameentry/gameentry_test.go75++
Mhashiwokakero/README.md2+-
Rhashiwokakero/Export.go → hashiwokakero/export.go0
Rhashiwokakero/Gamemode.go → hashiwokakero/gamemode.go34+-
Mhashiwokakero/help.md3+-
Rhashiwokakero/Model.go → hashiwokakero/model.go0
Rhashiwokakero/PrintAdapter.go → hashiwokakero/print_adapter.go6+-
Mhitori/README.md2+-
Rhitori/Export.go → hitori/export.go0
Rhitori/Gamemode.go → hitori/gamemode.go32+-
Mhitori/help.md3+-
Mhitori/hitori_test.go2+-
Rhitori/Model.go → hitori/model.go0
Rhitori/PrintAdapter.go → hitori/print_adapter.go6+-
Mlightsout/README.md2+-
Rlightsout/Export.go → lightsout/export.go0
Rlightsout/Gamemode.go → lightsout/gamemode.go33+-
Mlightsout/help.md2+-
Rlightsout/Model.go → lightsout/model.go0
Mnonogram/README.md1-
Rnonogram/Export.go → nonogram/export.go0
Rnonogram/Gamemode.go → nonogram/gamemode.go32+-
Mnonogram/help.md2+-
Rnonogram/Model.go → nonogram/model.go0
Rnonogram/PrintAdapter.go → nonogram/print_adapter.go6+-
Mnurikabe/README.md1-
Rnurikabe/Export.go → nurikabe/export.go0
Rnurikabe/Gamemode.go → nurikabe/gamemode.go34+-
Mnurikabe/help.md2+-
Rnurikabe/Model.go → nurikabe/model.go0
Rnurikabe/PrintAdapter.go → nurikabe/print_adapter.go6+-
Dpdfexport/cover_art.go1236------------------------------
Dpdfexport/cover_art_test.go204-----
Mpdfexport/jsonl.go15+
Mpdfexport/jsonl_test.go63+-
Apdfexport/normalize.go7+
Mpdfexport/order.go15+-
Dpdfexport/parse.go496------------
Apdfexport/parse_fillomino.go50+
Apdfexport/parse_hashi.go50+
Apdfexport/parse_helpers.go68++
Apdfexport/parse_hitori.go103+++
Apdfexport/parse_nonogram.go129+++
Apdfexport/parse_nurikabe.go74++
Apdfexport/parse_rippleeffect.go40+
Apdfexport/parse_shikaku.go54+
Apdfexport/parse_sudoku.go69++
Apdfexport/parse_takuzu.go71++
Apdfexport/parse_takuzuplus.go94++
Dpdfexport/parse_test.go134---
Apdfexport/parse_wordsearch.go84++
Mpdfexport/print_adapter.go9+-
Dpdfexport/printdata.go809--------------------
Mpdfexport/render_cover.go47+-
Mpdfexport/render_cover_test.go21+
Mpdfexport/render_title.go288+++----
Apdfexport/render_title_test.go51+
Mpuzzle/puzzle.go55+
Apuzzle/puzzle_test.go84++
Mregistry/registry.go109+--
Aregistry/registry_test.go189+++++
Mrippleeffect/README.md24+-
Rrippleeffect/Export.go → rippleeffect/export.go0
Rrippleeffect/Gamemode.go → rippleeffect/gamemode.go35+-
Mrippleeffect/help.md2+-
Rrippleeffect/Model.go → rippleeffect/model.go0
Rrippleeffect/PrintAdapter.go → rippleeffect/print_adapter.go6+-
Aschedule/select.go47+
Aschedule/select_test.go69++
Msession/session.go18+-
Msession/session_test.go27+-
Mshikaku/README.md1-
Rshikaku/Export.go → shikaku/export.go0
Rshikaku/Gamemode.go → shikaku/gamemode.go34+-
Mshikaku/help.md2+-
Rshikaku/Model.go → shikaku/model.go0
Rshikaku/PrintAdapter.go → shikaku/print_adapter.go6+-
Rspellpuzzle/Export.go → spellpuzzle/export.go0
Rspellpuzzle/Gamemode.go → spellpuzzle/gamemode.go33+-
Mspellpuzzle/help.md2+-
Rspellpuzzle/Model.go → spellpuzzle/model.go0
Rspellpuzzle/PrintAdapter.go → spellpuzzle/print_adapter.go6+-
Mspellpuzzle/print_adapter_test.go9+-
Mstore/game_record.go9+
Mstore/metadata.go34+-
Mstore/migrate.go165++++
Mstore/stats_test.go72+-
Mstore/store.go45+-
Mstore/store_test.go151++++-
Msudoku/README.md1-
Rsudoku/Export.go → sudoku/export.go0
Rsudoku/Gamemode.go → sudoku/gamemode.go32+-
Msudoku/help.md2+-
Rsudoku/Model.go → sudoku/model.go0
Rsudoku/PrintAdapter.go → sudoku/print_adapter.go6+-
Msudokurgb/README.md1-
Rsudokurgb/Export.go → sudokurgb/export.go0
Rsudokurgb/Gamemode.go → sudokurgb/gamemode.go34+-
Msudokurgb/help.md2+-
Rsudokurgb/Model.go → sudokurgb/model.go0
Rsudokurgb/PrintAdapter.go → sudokurgb/print_adapter.go6+-
Mtakuzu/README.md1-
Rtakuzu/Export.go → takuzu/export.go0
Rtakuzu/Gamemode.go → takuzu/gamemode.go34+-
Mtakuzu/help.md2+-
Rtakuzu/Model.go → takuzu/model.go0
Rtakuzu/PrintAdapter.go → takuzu/print_adapter.go6+-
Mtakuzuplus/README.md3-
Rtakuzuplus/Export.go → takuzuplus/export.go0
Rtakuzuplus/Gamemode.go → takuzuplus/gamemode.go34+-
Mtakuzuplus/help.md2+-
Rtakuzuplus/Model.go → takuzuplus/model.go0
Rtakuzuplus/PrintAdapter.go → takuzuplus/print_adapter.go6+-
Mui/mainmenu.go4+
Mui/menuitem.go8+
Mui/style.go9+
Mui/table.go32+-
Mui/ui_test.go16+
Mweekly/weekly.go37+-
Mweekly/weekly_test.go35-
Mwordsearch/README.md1-
Rwordsearch/Export.go → wordsearch/export.go0
Rwordsearch/Gamemode.go → wordsearch/gamemode.go33+-
Mwordsearch/help.md2+-
Rwordsearch/Model.go → wordsearch/model.go0
Rwordsearch/PrintAdapter.go → wordsearch/print_adapter.go6+-
M AGENTS.md +6 -0
M README.md +34 -20
@@ -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 ## Features1010 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 ```bash102-puzzletea new nonogram medium103+puzzletea new nonogram classic103104 puzzletea new fillomino "Hard 10x10"104105 puzzletea new ripple-effect "Medium 7x7"105106 puzzletea new sudoku hard107+puzzletea new ripeto expert106108 puzzletea new spell beginner107109 puzzletea new lights-out108-puzzletea new hashi easy110+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 ```bash170-puzzletea --new nonogram:medium175+puzzletea --new nonogram:classic171176 puzzletea --continue amber-falcon172177 ```173178 174179 ### CLI Aliases175180 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 ## Controls179184
@@ -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 ### Mouse196200 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 Persistence200204
@@ -321,7 +325,7 @@ All code must pass `gofumpt` and `golangci-lint` before committing. CI runs both
321325 322326 ## Adding a New Puzzle323327 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 package327331
@@ -329,21 +333,26 @@ 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 catalog348+Use `gameentry.BuildModeDefs(Modes)` and `gameentry.NewEntry(...)` so the349+package exposes both metadata and its validated runtime wiring.350+351+### 2. Wire it into the built-in registry343352 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 content356365 - save/import function357366 367+The `catalog` package is built from the registered entries and remains the pure368+metadata index for names, aliases, and daily-mode lookup.369+358370 ### 3. Add a VHS preview359371 360372 - Create `vhs/<game>.tape` following the format in existing tapes.
@@ -363,7 +375,9 @@ The game package's `Definition` owns:
363375 ### 4. Verify364376 365377 ```bash366-just fmt && just lint && just test378+just fmt379+just lint380+just test-short367381 ```368382 369383 See any existing game package (e.g., `nonogram/`) for the full pattern, and `AGENTS.md` for detailed conventions.
M app/game_session.go +2 -34
@@ -1,14 +1,8 @@
11 package app22 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.Info1812 }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 = g26- m.session.activeGameID = activeGameID27- m.session.completionSaved = completionSaved28- m.session.returnState = options.returnState29- m.session.weeklyAdvance = options.weeklyInfo30- m.state = gameView31- return m32-}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, false43- }44-45- activeGameID := rec.ID46- completionSaved := rec.Status == store.StatusCompleted47- if options.readOnly {48- activeGameID = 049- completionSaved = true50- }51-52- return m.activateGame(g, activeGameID, completionSaved, options), true19+ ok := newSessionController(&m).importRecord(rec, options)20+ return m, ok5321 }
A app/handle_daily.go +48 -0
A app/handle_game.go +56 -0
@@ -0,0 +1,56 @@
1+package app2+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, nil16+ }17+ m.nav.selectedCategory = entry18+ 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 = modeSelectView21+ return m, nil22+}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, nil29+ }30+ m.nav.selectedModeTitle = mode.Definition.Title31+ 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, cmd41+}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, nil47+ }48+ rec := m.cont.games[idx]49+ m, _ = m.importAndActivateRecord(rec)50+ return m, nil51+}52+53+func (m model) persistCompletionIfSolved() model {54+ newSessionController(&m).persistCompletionIfSolved()55+ return m56+}
A app/handle_help.go +16 -0
A app/handle_menu.go +41 -0
A app/handle_seed.go +66 -0
@@ -0,0 +1,66 @@
1+package app2+3+import (4+ "github.com/FelineStateMachine/puzzletea/game"5+ "github.com/FelineStateMachine/puzzletea/registry"6+ "github.com/FelineStateMachine/puzzletea/resolve"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) handleSeedConfirm() (tea.Model, tea.Cmd) {14+ seed := sessionflow.NormalizeSeed(m.seed.input.Value())15+ if seed == "" {16+ return m, nil17+ }18+19+ selectedMode := m.currentSeedMode()20+ name := sessionflow.SeededName(seed)21+ if selectedMode.key != "" {22+ name = sessionflow.SeededNameForGame(seed, selectedMode.gameType)23+ }24+25+ rec, err := m.store.GetDailyGame(name)26+ if err != nil {27+ return m.setErrorf("Could not check saved seeded puzzle: %v", err), nil28+ }29+ if rec != nil {30+ var resumed bool31+ m, resumed = m.importAndActivateRecord(*rec)32+ if resumed {33+ if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, rec); err != nil {34+ m = m.setErrorf("%v", err)35+ }36+ }37+ return m, nil38+ }39+40+ var spawner game.SeededSpawner41+ var gameType string42+ modeTitle := ""43+ if selectedMode.key == "" {44+ spawner, gameType, modeTitle, err = resolve.SeededMode(seed, registry.Entries())45+ if err != nil {46+ return m.setErrorf("Could not choose a seeded mode: %v", err), nil47+ }48+ } else {49+ spawner, gameType, modeTitle, err = resolve.SeededModeForGame(seed, selectedMode.gameType, registry.Entries())50+ if err != nil {51+ return m.setErrorf("Could not choose a seeded mode for %s: %v", selectedMode.gameType, err), nil52+ }53+ }54+55+ rng := resolve.RNGFromString(seed)56+ cmd := newSessionController(&m).startSeededSpawn(spawner, rng, spawnRequest{57+ source: spawnSourceSeed,58+ name: name,59+ gameType: gameType,60+ modeTitle: modeTitle,61+ run: store.SeededRunMetadata(seed),62+ returnState: playMenuView,63+ exitState: mainMenuView,64+ })65+ return m, cmd66+}
A app/handle_stats.go +68 -0
@@ -0,0 +1,68 @@
1+package app2+3+import (4+ "time"5+6+ "github.com/FelineStateMachine/puzzletea/daily"7+ "github.com/FelineStateMachine/puzzletea/registry"8+ "github.com/FelineStateMachine/puzzletea/stats"9+ "github.com/FelineStateMachine/puzzletea/ui"10+11+ "charm.land/bubbles/v2/viewport"12+ tea "charm.land/bubbletea/v2"13+)14+15+func (m model) handleStatsEnter() (tea.Model, tea.Cmd) {16+ m = m.clearNotice()17+18+ catStats, err := m.store.GetCategoryStats()19+ if err != nil {20+ return m.setErrorf("Could not load category stats: %v", err), nil21+ }22+ modeStats, err := m.store.GetModeStats()23+ if err != nil {24+ return m.setErrorf("Could not load mode stats: %v", err), nil25+ }26+ streakDates, err := m.store.GetDailyStreakDates()27+ if err != nil {28+ return m.setErrorf("Could not load streak data: %v", err), nil29+ }30+ weekliesCompleted, err := m.store.GetCompletedWeeklyGauntlets()31+ if err != nil {32+ return m.setErrorf("Could not load weekly progress: %v", err), nil33+ }34+ now := time.Now()35+ currentYear, currentWeek := now.ISOWeek()36+ thisWeekHighestIndex, err := m.store.GetCurrentWeeklyHighestCompletedIndex(currentYear, currentWeek)37+ if err != nil {38+ return m.setErrorf("Could not load this week’s progress: %v", err), nil39+ }40+ currentDaily := false41+ rec, err := m.store.GetDailyGame(daily.Name(now))42+ if err != nil {43+ m = m.setErrorf("Could not check today’s daily puzzle: %v", err)44+ } else {45+ currentDaily = rec != nil46+ }47+48+ weights := stats.WeightsFromDefinitions(registry.Definitions())49+ m.stats.cards = stats.BuildCards(weights, catStats, modeStats)50+ m.stats.profile = stats.BuildProfileBanner(51+ catStats,52+ modeStats,53+ weights,54+ streakDates,55+ currentDaily,56+ weekliesCompleted,57+ thisWeekHighestIndex,58+ )59+60+ statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.stats.cards)61+ m.stats.viewport = viewport.New(62+ viewport.WithWidth(statsWidth),63+ viewport.WithHeight(statsHeight),64+ )65+ m.stats.viewport.SetContent(ui.RenderStatsCardGrid(m.stats.cards, statsWidth))66+ m.state = statsView67+ return m, nil68+}
A app/handle_theme.go +66 -0
@@ -0,0 +1,66 @@
1+package app2+3+import (4+ "github.com/FelineStateMachine/puzzletea/config"5+ "github.com/FelineStateMachine/puzzletea/theme"6+ "github.com/FelineStateMachine/puzzletea/ui"7+8+ "charm.land/bubbles/v2/list"9+ tea "charm.land/bubbletea/v2"10+)11+12+func (m model) handleThemeEnter() (tea.Model, tea.Cmd) {13+ m.theme.previous = m.cfg.Theme14+15+ names := theme.ThemeNames()16+ items := make([]list.Item, len(names))17+ for i, n := range names {18+ desc := "dark theme"19+ if n == theme.DefaultThemeName {20+ desc = "built-in earth-tone palette"21+ } else if t := theme.LookupTheme(n); t != nil && !t.Meta.IsDark {22+ desc = "light theme"23+ }24+ items[i] = ui.MenuItem{ItemTitle: n, Desc: desc}25+ }26+27+ const maxVisibleItems = 828+ listH := min(m.height, maxVisibleItems*3)29+ listW := min(m.width, theme.MaxNameLen+4)30+31+ m.theme.list = ui.InitThemeList(items, listW, listH)32+ for i, item := range items {33+ if mi, ok := item.(ui.MenuItem); ok && mi.ItemTitle == m.theme.previous {34+ m.theme.list.Select(i)35+ break36+ }37+ }38+ if m.theme.previous == "" {39+ m.theme.list.Select(0)40+ }41+42+ m.state = themeSelectView43+ m = m.clearNotice()44+ return m, nil45+}46+47+func (m model) handleThemeConfirm() (tea.Model, tea.Cmd) {48+ item, ok := m.theme.list.SelectedItem().(ui.MenuItem)49+ if !ok {50+ return m, nil51+ }52+53+ themeName := item.ItemTitle54+ if themeName == theme.DefaultThemeName {55+ themeName = ""56+ }57+58+ _ = theme.Apply(item.ItemTitle)59+ m.cfg.Theme = themeName60+ if err := m.cfg.Save(config.DefaultPath()); err != nil {61+ m = m.setErrorf("Theme changed, but saving config failed: %v", err)62+ }63+64+ m.state = mainMenuView65+ return m, nil66+}
M app/model.go +52 -22
@@ -33,16 +33,32 @@ var (
3333 3434 type viewState int3535 36+const (37+ mainMenuActionPlay = "play"38+ mainMenuActionStats = "stats"39+ mainMenuActionOptions = "options"40+ mainMenuActionQuit = "quit"41+42+ playMenuActionCreate = "create"43+ playMenuActionContinue = "continue"44+ playMenuActionDaily = "daily"45+ playMenuActionWeekly = "weekly"46+ playMenuActionSeeded = "seeded"47+48+ optionsMenuActionTheme = "theme"49+ optionsMenuActionGuides = "guides"50+)51+3652 var mainMenuItems = []ui.MenuItem{37- {ItemTitle: "Play", Desc: "start or continue a puzzle"},38- {ItemTitle: "Stats", Desc: "your progress"},39- {ItemTitle: "Options", Desc: "configure and learn"},40- {ItemTitle: "Quit", Desc: "exit puzzletea"},53+ {Action: mainMenuActionPlay, ItemTitle: "Play", Desc: "start or continue a puzzle"},54+ {Action: mainMenuActionStats, ItemTitle: "Stats", Desc: "your progress"},55+ {Action: mainMenuActionOptions, ItemTitle: "Options", Desc: "configure and learn"},56+ {Action: mainMenuActionQuit, ItemTitle: "Quit", Desc: "exit puzzletea"},4157 }4258 4359 var optionsMenuItems = []ui.MenuItem{44- {ItemTitle: "Theme", Desc: "change colors"},45- {ItemTitle: "Guides", Desc: "learn the rules"},60+ {Action: optionsMenuActionTheme, ItemTitle: "Theme", Desc: "change colors"},61+ {Action: optionsMenuActionGuides, ItemTitle: "Guides", Desc: "learn the rules"},4662 }4763 4864 const (
@@ -71,17 +87,25 @@ type navigationState struct {
7187 modeSelectList list.Model7288 selectedCategory registry.Entry7389 selectedModeTitle string74- continueTable table.Model75- continueGames []store.GameRecord76- weeklyTable table.Model77- weeklyRows []weeklyRow78- weeklyCursor time.Time79- seedInput textinput.Model80- seedModeOptions []seedModeOption81- seedModeIndex int82- seedFocus seedInputFocus83- lastSeedModeKey string84- helpSelectList list.Model90+}91+92+type continueState struct {93+ table table.Model94+ games []store.GameRecord95+}96+97+type weeklyState struct {98+ table table.Model99+ rows []weeklyRow100+ cursor time.Time101+}102+103+type seedState struct {104+ input textinput.Model105+ modeOptions []seedModeOption106+ modeIndex int107+ focus seedInputFocus108+ lastModeKey string85109 }86110 87111 type sessionState struct {
@@ -97,6 +121,7 @@ type sessionState struct {
97121 }98122 99123 type helpState struct {124+ selectList list.Model100125 category registry.Entry101126 viewport viewport.Model102127 renderer *glamour.TermRenderer
@@ -136,6 +161,7 @@ type spawnRequest struct {
136161 name string137162 gameType string138163 modeTitle string164+ run store.RunMetadata139165 returnState viewState140166 exitState viewState141167 weeklyInfo *weekly.Info
@@ -158,11 +184,15 @@ type model struct {
158184 state viewState159185 160186 nav navigationState187+ cont continueState161188 session sessionState189+ seed seedState190+ weekly weeklyState162191 help helpState163192 stats statsState164193 theme themeState165194 debug debugState195+ notice noticeState166196 167197 spinner spinner.Model168198
@@ -227,10 +257,10 @@ func (m model) Init() tea.Cmd {
227257 func buildPlayMenuItems(now time.Time, currentWeeklyIndex int) []ui.MenuItem {228258 year, week := now.ISOWeek()229259 return []ui.MenuItem{230- {ItemTitle: "Create", Desc: "a new puzzle"},231- {ItemTitle: "Continue", Desc: "a previously played puzzle"},232- {ItemTitle: "Daily", Desc: now.Format("Jan _2 06")},233- {ItemTitle: "Weekly", Desc: "Week " + formatTwoDigits(week) + "-" + strconv.Itoa(year) + " #" + formatWeeklyMenuIndex(currentWeeklyIndex)},234- {ItemTitle: "Seeded", Desc: "enter a specific seed"},260+ {Action: playMenuActionCreate, ItemTitle: "Create", Desc: "a new puzzle"},261+ {Action: playMenuActionContinue, ItemTitle: "Continue", Desc: "a previously played puzzle"},262+ {Action: playMenuActionDaily, ItemTitle: "Daily", Desc: now.Format("Jan _2 06")},263+ {Action: playMenuActionWeekly, ItemTitle: "Weekly", Desc: "Week " + formatTwoDigits(week) + "-" + strconv.Itoa(year) + " #" + formatWeeklyMenuIndex(currentWeeklyIndex)},264+ {Action: playMenuActionSeeded, ItemTitle: "Seeded", Desc: "enter a specific seed"},235265 }236266 }
A app/notice.go +66 -0
@@ -0,0 +1,66 @@
1+package app2+3+import (4+ "fmt"5+ "log"6+7+ "github.com/FelineStateMachine/puzzletea/ui"8+9+ "charm.land/lipgloss/v2"10+)11+12+type noticeLevel string13+14+const noticeLevelError noticeLevel = "error"15+16+type noticeState struct {17+ level noticeLevel18+ message string19+}20+21+func (m model) clearNotice() model {22+ m.notice = noticeState{}23+ return m24+}25+26+func (m model) setErrorf(format string, args ...any) model {27+ message := fmt.Sprintf(format, args...)28+ log.Printf("%s", message)29+ m.notice = noticeState{30+ level: noticeLevelError,31+ message: message,32+ }33+ return m34+}35+36+func (m model) appendNotice(content string) string {37+ return appendNoticeContent(m.width, m.notice, content)38+}39+40+func appendNoticeContent(width int, notice noticeState, content string) string {41+ if notice.message == "" {42+ return content43+ }44+45+ style := ui.ErrorNoticeStyle()46+ block := style.47+ MaxWidth(max(width-8, 24)).48+ Render(notice.message)49+ if content == "" {50+ return block51+ }52+ return lipgloss.JoinVertical(lipgloss.Left, content, "", block)53+}54+55+func (m model) renderPanel(title, content, footer string) string {56+ return renderPanelView(m.width, m.height, m.notice, title, content, footer)57+}58+59+func centerContentWithNotice(width, height int, notice noticeState, content string) string {60+ return ui.CenterView(width, height, appendNoticeContent(width, notice, content))61+}62+63+func renderPanelView(width, height int, notice noticeState, title, content, footer string) string {64+ panel := ui.Panel(title, appendNoticeContent(width, notice, content), footer)65+ return ui.CenterView(width, height, panel)66+}
A app/screen_core.go +190 -0
@@ -0,0 +1,190 @@
1+package app2+3+import tea "charm.land/bubbletea/v2"4+5+type screenAction interface{ isScreenAction() }6+7+type openPlayMenuAction struct{}8+9+func (openPlayMenuAction) isScreenAction() {}10+11+type openStatsAction struct{}12+13+func (openStatsAction) isScreenAction() {}14+15+type openOptionsMenuAction struct{}16+17+func (openOptionsMenuAction) isScreenAction() {}18+19+type quitAction struct{}20+21+func (quitAction) isScreenAction() {}22+23+type openGameSelectAction struct{}24+25+func (openGameSelectAction) isScreenAction() {}26+27+type openContinueAction struct{}28+29+func (openContinueAction) isScreenAction() {}30+31+type openDailyAction struct{}32+33+func (openDailyAction) isScreenAction() {}34+35+type openWeeklyAction struct{}36+37+func (openWeeklyAction) isScreenAction() {}38+39+type openSeedInputAction struct{}40+41+func (openSeedInputAction) isScreenAction() {}42+43+type backAction struct {44+ target viewState45+}46+47+func (backAction) isScreenAction() {}48+49+type gameSelectEnterAction struct{}50+51+func (gameSelectEnterAction) isScreenAction() {}52+53+type modeSelectEnterAction struct{}54+55+func (modeSelectEnterAction) isScreenAction() {}56+57+type continueEnterAction struct{}58+59+func (continueEnterAction) isScreenAction() {}60+61+type weeklyShiftAction struct {62+ delta int63+}64+65+func (weeklyShiftAction) isScreenAction() {}66+67+type weeklyEnterAction struct{}68+69+func (weeklyEnterAction) isScreenAction() {}70+71+type helpSelectEnterAction struct{}72+73+func (helpSelectEnterAction) isScreenAction() {}74+75+type openThemeSelectAction struct{}76+77+func (openThemeSelectAction) isScreenAction() {}78+79+type openHelpSelectAction struct{}80+81+func (openHelpSelectAction) isScreenAction() {}82+83+type previewThemeAction struct {84+ name string85+}86+87+func (previewThemeAction) isScreenAction() {}88+89+type confirmThemeAction struct{}90+91+func (confirmThemeAction) isScreenAction() {}92+93+type seedConfirmAction struct{}94+95+func (seedConfirmAction) isScreenAction() {}96+97+type screenModel interface {98+ State() viewState99+ Resize(width, height int) screenModel100+ Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction)101+ View(notice noticeState) string102+ Apply(m model) model103+}104+105+func (m model) activeScreen() screenModel {106+ switch m.state {107+ case mainMenuView:108+ return mainMenuScreen{109+ width: m.width,110+ height: m.height,111+ menu: m.nav.mainMenu,112+ }113+ case playMenuView:114+ return playMenuScreen{115+ width: m.width,116+ height: m.height,117+ menu: m.nav.playMenu,118+ }119+ case optionsMenuView:120+ return optionsMenuScreen{121+ width: m.width,122+ height: m.height,123+ menu: m.nav.optionsMenu,124+ }125+ case seedInputView:126+ return seedInputScreen{127+ width: m.width,128+ height: m.height,129+ seed: m.seed,130+ }131+ case gameSelectView:132+ return gameSelectScreen{133+ width: m.width,134+ height: m.height,135+ list: m.nav.gameSelectList,136+ detail: m.nav.categoryDetail,137+ }138+ case modeSelectView:139+ return modeSelectScreen{140+ width: m.width,141+ height: m.height,142+ entry: m.nav.selectedCategory,143+ list: m.nav.modeSelectList,144+ }145+ case continueView:146+ return continueScreen{147+ width: m.width,148+ height: m.height,149+ cont: m.cont,150+ }151+ case weeklyView:152+ return weeklyScreen{153+ width: m.width,154+ height: m.height,155+ weekly: m.weekly,156+ }157+ case helpSelectView:158+ return helpSelectScreen{159+ width: m.width,160+ height: m.height,161+ help: m.help,162+ }163+ case helpDetailView:164+ return helpDetailScreen{165+ width: m.width,166+ height: m.height,167+ help: m.help,168+ }169+ case statsView:170+ return statsScreen{171+ width: m.width,172+ height: m.height,173+ stats: m.stats,174+ }175+ case themeSelectView:176+ return themeSelectScreen{177+ width: m.width,178+ height: m.height,179+ theme: m.theme,180+ }181+ case generatingView:182+ return generatingScreen{183+ width: m.width,184+ height: m.height,185+ spinner: m.spinner,186+ }187+ default:188+ return nil189+ }190+}
A app/screen_layout.go +177 -0
@@ -0,0 +1,177 @@
1+package app2+3+import (4+ "log"5+6+ "github.com/FelineStateMachine/puzzletea/stats"7+ "github.com/FelineStateMachine/puzzletea/theme"8+ "github.com/FelineStateMachine/puzzletea/ui"9+10+ "charm.land/bubbles/v2/list"11+ "charm.land/bubbles/v2/viewport"12+ "github.com/charmbracelet/glamour"13+)14+15+const (16+ helpPanelInsetX = 217+ helpPanelInsetY = 118+ helpPanelHorizontalTrim = 619+ helpPanelVerticalTrim = 820+ categoryPanelChrome = 821+ categoryBodyMaxWidth = 8622+ categoryBodyMaxHeight = 1623+ categoryMinListWidth = 2424+ categoryMaxListWidth = 3025+ categoryGapWidth = 226+ categoryDetailTrimX = 627+ categoryDetailTrimY = 428+ categoryStackGapHeight = 129+ categoryMinSideBySideW = 7230+)31+32+func helpViewportSize(width, height int) (int, int) {33+ panelWidth := max(width-(helpPanelInsetX*2), 1)34+ panelHeight := max(height-(helpPanelInsetY*2), 1)35+ contentWidth := max(panelWidth-helpPanelHorizontalTrim, 1)36+ contentHeight := max(panelHeight-helpPanelVerticalTrim, 1)37+ return contentWidth, contentHeight38+}39+40+func helpSelectListSize(width, height int, l list.Model) (int, int) {41+ contentWidth, contentHeight := helpViewportSize(width, height)42+ listWidth := min(contentWidth, 64)43+ listHeight := min(contentHeight, ui.ListHeight(l))44+ return listWidth, listHeight45+}46+47+func statsViewportSize(width, height int, cards []stats.Card) (int, int) {48+ contentWidth, _ := helpViewportSize(width, height)49+ panelHeight := max(height-(helpPanelInsetY*2), 1)50+ contentHeight := max(panelHeight-stats.StaticHeight(cards), 1)51+ return contentWidth, contentHeight52+}53+54+type categoryPickerMetrics struct {55+ bodyWidth int56+ bodyHeight int57+ listWidth int58+ listHeight int59+ detailWidth int60+ detailHeight int61+ stacked bool62+}63+64+func categoryPickerSize(width, height int) categoryPickerMetrics {65+ bodyWidth := min(width, categoryBodyMaxWidth)66+ bodyHeight := min(max(height-categoryPanelChrome, 1), categoryBodyMaxHeight)67+68+ if bodyWidth < categoryMinSideBySideW {69+ listHeight := min(bodyHeight, categoryPickerListHeight())70+ detailHeight := max(bodyHeight-listHeight-categoryStackGapHeight, 1)71+ if detailHeight == 1 && bodyHeight > 1 {72+ listHeight = max(bodyHeight-categoryStackGapHeight-detailHeight, 1)73+ }74+ return categoryPickerMetrics{75+ bodyWidth: bodyWidth,76+ bodyHeight: bodyHeight,77+ listWidth: bodyWidth,78+ listHeight: listHeight,79+ detailWidth: bodyWidth,80+ detailHeight: detailHeight,81+ stacked: true,82+ }83+ }84+85+ listWidth := min(categoryMaxListWidth, max(categoryMinListWidth, bodyWidth/3))86+ detailWidth := max(bodyWidth-listWidth-categoryGapWidth, 1)87+ return categoryPickerMetrics{88+ bodyWidth: bodyWidth,89+ bodyHeight: bodyHeight,90+ listWidth: listWidth,91+ listHeight: bodyHeight,92+ detailWidth: detailWidth,93+ detailHeight: bodyHeight,94+ }95+}96+97+func selectedCategoryName(item list.Item) string {98+ entry, ok := selectedCategoryEntry(item)99+ if !ok {100+ return ""101+ }102+ return entry.Definition.Name103+}104+105+func (m model) updateCategoryDetailViewport() model {106+ metrics := categoryPickerSize(m.width, m.height)107+ contentWidth := max(metrics.detailWidth-categoryDetailTrimX, 1)108+ contentHeight := max(metrics.detailHeight-categoryDetailTrimY, 1)109+110+ if m.nav.categoryDetail.Width() == 0 || m.nav.categoryDetail.Height() == 0 {111+ m.nav.categoryDetail = viewport.New(112+ viewport.WithWidth(contentWidth),113+ viewport.WithHeight(contentHeight),114+ )115+ }116+ m.nav.categoryDetail.SetWidth(contentWidth)117+ m.nav.categoryDetail.SetHeight(contentHeight)118+ m.nav.categoryDetail.FillHeight = true119+120+ entry, ok := selectedCategoryEntry(m.nav.gameSelectList.SelectedItem())121+ if !ok {122+ m.nav.categoryDetail.SetContent("")123+ return m124+ }125+126+ m.nav.categoryDetail.SetContent(renderCategoryDetailContent(entry, contentWidth))127+ m.nav.categoryDetail.GotoTop()128+ return m129+}130+131+func (m model) updateHelpDetailViewport() model {132+ helpWidth, helpHeight := helpViewportSize(m.width, m.height)133+ palette := theme.Current()134+ themeKey := helpMarkdownThemeKey(palette)135+ if m.help.renderer == nil || m.help.rendererWidth != helpWidth || m.help.rendererTheme != themeKey {136+ renderer, err := glamour.NewTermRenderer(137+ glamour.WithStyles(helpMarkdownStyle(palette)),138+ glamour.WithWordWrap(helpWidth),139+ glamour.WithChromaFormatter("terminal16m"),140+ )141+ if err != nil {142+ log.Printf("failed to create help renderer: %v", err)143+ m.help.renderer = nil144+ m.help.rendererWidth = 0145+ m.help.rendererTheme = ""146+ } else {147+ m.help.renderer = renderer148+ m.help.rendererWidth = helpWidth149+ m.help.rendererTheme = themeKey150+ }151+ }152+153+ rendered := m.help.category.Help154+ if m.help.renderer != nil {155+ out, err := m.help.renderer.Render(m.help.category.Help)156+ if err != nil {157+ log.Printf("failed to render help: %v", err)158+ } else {159+ rendered = out160+ }161+ }162+163+ m.help.viewport = viewport.New(164+ viewport.WithWidth(helpWidth),165+ viewport.WithHeight(helpHeight),166+ )167+ m.help.viewport.SetContent(rendered)168+ return m169+}170+171+func (m model) updateStatsViewport() model {172+ statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.stats.cards)173+ m.stats.viewport.SetWidth(statsWidth)174+ m.stats.viewport.SetHeight(statsHeight)175+ m.stats.viewport.SetContent(ui.RenderStatsCardGrid(m.stats.cards, statsWidth))176+ return m177+}
A app/screen_menu.go +152 -0
@@ -0,0 +1,152 @@
1+package app2+3+import (4+ "github.com/FelineStateMachine/puzzletea/ui"5+6+ "charm.land/bubbles/v2/key"7+ tea "charm.land/bubbletea/v2"8+)9+10+type mainMenuScreen struct {11+ width int12+ height int13+ menu ui.MainMenu14+}15+16+func (s mainMenuScreen) State() viewState { return mainMenuView }17+18+func (s mainMenuScreen) Resize(width, height int) screenModel {19+ s.width = width20+ s.height = height21+ return s22+}23+24+func (s mainMenuScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {25+ updateMainMenuCursor(msg, &s.menu)26+ keyMsg, ok := msg.(tea.KeyPressMsg)27+ if !ok || !key.Matches(keyMsg, rootKeys.Enter) {28+ return s, nil, nil29+ }30+31+ switch s.menu.SelectedAction() {32+ case mainMenuActionPlay:33+ return s, nil, openPlayMenuAction{}34+ case mainMenuActionStats:35+ return s, nil, openStatsAction{}36+ case mainMenuActionOptions:37+ return s, nil, openOptionsMenuAction{}38+ case mainMenuActionQuit:39+ return s, nil, quitAction{}40+ default:41+ return s, nil, nil42+ }43+}44+45+func (s mainMenuScreen) View(notice noticeState) string {46+ return centerContentWithNotice(s.width, s.height, notice, s.menu.View())47+}48+49+func (s mainMenuScreen) Apply(m model) model {50+ m.state = mainMenuView51+ m.nav.mainMenu = s.menu52+ return m53+}54+55+type playMenuScreen struct {56+ width int57+ height int58+ menu ui.MainMenu59+}60+61+func (s playMenuScreen) State() viewState { return playMenuView }62+63+func (s playMenuScreen) Resize(width, height int) screenModel {64+ s.width = width65+ s.height = height66+ return s67+}68+69+func (s playMenuScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {70+ updateMainMenuCursor(msg, &s.menu)71+ keyMsg, ok := msg.(tea.KeyPressMsg)72+ if !ok {73+ return s, nil, nil74+ }75+76+ switch {77+ case key.Matches(keyMsg, rootKeys.Enter):78+ switch s.menu.SelectedAction() {79+ case playMenuActionCreate:80+ return s, nil, openGameSelectAction{}81+ case playMenuActionContinue:82+ return s, nil, openContinueAction{}83+ case playMenuActionDaily:84+ return s, nil, openDailyAction{}85+ case playMenuActionWeekly:86+ return s, nil, openWeeklyAction{}87+ case playMenuActionSeeded:88+ return s, nil, openSeedInputAction{}89+ }90+ case key.Matches(keyMsg, rootKeys.Escape):91+ return s, nil, backAction{target: mainMenuView}92+ }93+94+ return s, nil, nil95+}96+97+func (s playMenuScreen) View(notice noticeState) string {98+ return centerContentWithNotice(s.width, s.height, notice, s.menu.ViewAsPanel("Play"))99+}100+101+func (s playMenuScreen) Apply(m model) model {102+ m.state = playMenuView103+ m.nav.playMenu = s.menu104+ return m105+}106+107+type optionsMenuScreen struct {108+ width int109+ height int110+ menu ui.MainMenu111+}112+113+func (s optionsMenuScreen) State() viewState { return optionsMenuView }114+115+func (s optionsMenuScreen) Resize(width, height int) screenModel {116+ s.width = width117+ s.height = height118+ return s119+}120+121+func (s optionsMenuScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {122+ updateMainMenuCursor(msg, &s.menu)123+ keyMsg, ok := msg.(tea.KeyPressMsg)124+ if !ok {125+ return s, nil, nil126+ }127+128+ switch {129+ case key.Matches(keyMsg, rootKeys.Enter):130+ switch s.menu.SelectedAction() {131+ case optionsMenuActionTheme:132+ return s, nil, openThemeSelectAction{}133+ case optionsMenuActionGuides:134+ return s, nil, openHelpSelectAction{}135+ }136+ case key.Matches(keyMsg, rootKeys.Escape):137+ return s, nil, backAction{target: mainMenuView}138+ }139+140+ return s, nil, nil141+}142+143+func (s optionsMenuScreen) View(notice noticeState) string {144+ items := s.menu.RenderItems() + "\n\n" + ui.DimItemStyle().Render("- Dami")145+ return renderPanelView(s.width, s.height, notice, "Options", items, "↑/↓ navigate • enter select • esc back")146+}147+148+func (s optionsMenuScreen) Apply(m model) model {149+ m.state = optionsMenuView150+ m.nav.optionsMenu = s.menu151+ return m152+}
A app/screen_navigation.go +309 -0
@@ -0,0 +1,309 @@
1+package app2+3+import (4+ "github.com/FelineStateMachine/puzzletea/registry"5+ sessionflow "github.com/FelineStateMachine/puzzletea/session"6+ "github.com/FelineStateMachine/puzzletea/ui"7+8+ "charm.land/bubbles/v2/key"9+ "charm.land/bubbles/v2/list"10+ "charm.land/bubbles/v2/viewport"11+ tea "charm.land/bubbletea/v2"12+)13+14+type seedInputScreen struct {15+ width int16+ height int17+ seed seedState18+}19+20+func (s seedInputScreen) State() viewState { return seedInputView }21+22+func (s seedInputScreen) Resize(width, height int) screenModel {23+ s.width = width24+ s.height = height25+ s.seed.input.SetWidth(min(width, 48))26+ return s27+}28+29+func (s seedInputScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {30+ keyMsg, ok := msg.(tea.KeyPressMsg)31+ if ok {32+ switch {33+ case key.Matches(keyMsg, rootKeys.Enter):34+ if sessionflow.NormalizeSeed(s.seed.input.Value()) != "" {35+ return s, nil, seedConfirmAction{}36+ }37+ return s, nil, nil38+ case key.Matches(keyMsg, rootKeys.Escape):39+ return s, nil, backAction{target: playMenuView}40+ }41+ }42+43+ m := model{seed: s.seed}44+ next, cmd := m.handleSeedInputUpdate(msg)45+ s.seed = next.seed46+ return s, cmd, nil47+}48+49+func (s seedInputScreen) View(notice noticeState) string {50+ m := model{51+ width: s.width,52+ height: s.height,53+ seed: s.seed,54+ notice: notice,55+ }56+ return m.renderPanel(57+ "Enter Seed",58+ m.seedInputBody(),59+ "↑/↓ change field • ←/→ game • enter confirm • esc back",60+ )61+}62+63+func (s seedInputScreen) Apply(m model) model {64+ m.state = seedInputView65+ m.seed = s.seed66+ return m67+}68+69+type gameSelectScreen struct {70+ width int71+ height int72+ list list.Model73+ detail viewport.Model74+}75+76+func (s gameSelectScreen) State() viewState { return gameSelectView }77+78+func (s gameSelectScreen) Resize(width, height int) screenModel {79+ m := model{80+ width: width,81+ height: height,82+ nav: navigationState{83+ gameSelectList: s.list,84+ categoryDetail: s.detail,85+ },86+ }87+ metrics := categoryPickerSize(width, height)88+ m.nav.gameSelectList.SetSize(metrics.listWidth, metrics.listHeight)89+ m = m.updateCategoryDetailViewport()90+ s.width = width91+ s.height = height92+ s.list = m.nav.gameSelectList93+ s.detail = m.nav.categoryDetail94+ return s95+}96+97+func (s gameSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {98+ keyMsg, ok := msg.(tea.KeyPressMsg)99+ if ok {100+ switch {101+ case s.list.SettingFilter() && s.list.FilterValue() == "" && key.Matches(keyMsg, rootKeys.Enter):102+ s.list.ResetFilter()103+ s = s.resizeSelf()104+ return s, nil, nil105+ case s.list.SettingFilter() && key.Matches(keyMsg, rootKeys.Enter):106+ case s.list.FilterState() != list.Unfiltered && key.Matches(keyMsg, rootKeys.Escape):107+ case key.Matches(keyMsg, rootKeys.Enter):108+ return s, nil, gameSelectEnterAction{}109+ case key.Matches(keyMsg, rootKeys.Escape):110+ return s, nil, backAction{target: playMenuView}111+ case keyMsg.String() == "pgup":112+ s.detail.PageUp()113+ return s, nil, nil114+ case keyMsg.String() == "pgdown":115+ s.detail.PageDown()116+ return s, nil, nil117+ }118+ }119+120+ prev := selectedCategoryName(s.list.SelectedItem())121+ var cmd tea.Cmd122+ s.list, cmd = s.list.Update(msg)123+ if selectedCategoryName(s.list.SelectedItem()) != prev {124+ s = s.resizeSelf()125+ }126+ return s, cmd, nil127+}128+129+func (s gameSelectScreen) View(notice noticeState) string {130+ m := model{131+ width: s.width,132+ height: s.height,133+ notice: notice,134+ nav: navigationState{135+ gameSelectList: s.list,136+ categoryDetail: s.detail,137+ },138+ }139+ return m.gameSelectViewContent()140+}141+142+func (s gameSelectScreen) Apply(m model) model {143+ m.state = gameSelectView144+ m.nav.gameSelectList = s.list145+ m.nav.categoryDetail = s.detail146+ return m147+}148+149+func (s gameSelectScreen) resizeSelf() gameSelectScreen {150+ return s.Resize(s.width, s.height).(gameSelectScreen)151+}152+153+type modeSelectScreen struct {154+ width int155+ height int156+ entry registry.Entry157+ list list.Model158+}159+160+func (s modeSelectScreen) State() viewState { return modeSelectView }161+162+func (s modeSelectScreen) Resize(width, height int) screenModel {163+ s.width = width164+ s.height = height165+ s.list.SetSize(min(width, 64), min(height, ui.ListHeight(s.list)))166+ return s167+}168+169+func (s modeSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {170+ keyMsg, ok := msg.(tea.KeyPressMsg)171+ if ok {172+ switch {173+ case key.Matches(keyMsg, rootKeys.Enter):174+ return s, nil, modeSelectEnterAction{}175+ case key.Matches(keyMsg, rootKeys.Escape):176+ return s, nil, backAction{target: gameSelectView}177+ }178+ }179+180+ var cmd tea.Cmd181+ s.list, cmd = s.list.Update(msg)182+ return s, cmd, nil183+}184+185+func (s modeSelectScreen) View(notice noticeState) string {186+ m := model{187+ width: s.width,188+ height: s.height,189+ notice: notice,190+ nav: navigationState{191+ selectedCategory: s.entry,192+ modeSelectList: s.list,193+ },194+ }195+ return m.renderPanel(196+ m.nav.selectedCategory.Definition.Name+" — Select Mode",197+ m.nav.modeSelectList.View(),198+ "↑/↓ navigate • enter select • esc back",199+ )200+}201+202+func (s modeSelectScreen) Apply(m model) model {203+ m.state = modeSelectView204+ m.nav.selectedCategory = s.entry205+ m.nav.modeSelectList = s.list206+ return m207+}208+209+type continueScreen struct {210+ width int211+ height int212+ cont continueState213+}214+215+func (s continueScreen) State() viewState { return continueView }216+217+func (s continueScreen) Resize(width, height int) screenModel {218+ s.width = width219+ s.height = height220+ s.cont.table.SetWidth(min(width, ui.ContinueTableWidth()))221+ visibleRows := min(len(s.cont.games), ui.MaxTableRows)222+ s.cont.table.SetHeight(min(height, visibleRows))223+ return s224+}225+226+func (s continueScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {227+ keyMsg, ok := msg.(tea.KeyPressMsg)228+ if ok {229+ switch {230+ case key.Matches(keyMsg, rootKeys.Enter):231+ return s, nil, continueEnterAction{}232+ case key.Matches(keyMsg, rootKeys.Escape):233+ return s, nil, backAction{target: playMenuView}234+ }235+ }236+237+ var cmd tea.Cmd238+ s.cont.table, cmd = s.cont.table.Update(msg)239+ return s, cmd, nil240+}241+242+func (s continueScreen) View(notice noticeState) string {243+ m := model{244+ width: s.width,245+ height: s.height,246+ notice: notice,247+ cont: s.cont,248+ }249+ return m.renderContinueView()250+}251+252+func (s continueScreen) Apply(m model) model {253+ m.state = continueView254+ m.cont = s.cont255+ return m256+}257+258+type weeklyScreen struct {259+ width int260+ height int261+ weekly weeklyState262+}263+264+func (s weeklyScreen) State() viewState { return weeklyView }265+266+func (s weeklyScreen) Resize(width, height int) screenModel {267+ s.width = width268+ s.height = height269+ s.weekly.table.SetWidth(min(width, ui.WeeklyTableWidth()))270+ visibleRows := min(len(s.weekly.rows), ui.MaxTableRows)271+ s.weekly.table.SetHeight(min(height, visibleRows))272+ return s273+}274+275+func (s weeklyScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {276+ keyMsg, ok := msg.(tea.KeyPressMsg)277+ if ok {278+ switch {279+ case key.Matches(keyMsg, rootKeys.Escape):280+ return s, nil, backAction{target: playMenuView}281+ case key.Matches(keyMsg, rootKeys.Enter):282+ return s, nil, weeklyEnterAction{}283+ case keyMsg.String() == "left" || keyMsg.String() == "h":284+ return s, nil, weeklyShiftAction{delta: -1}285+ case keyMsg.String() == "right" || keyMsg.String() == "l":286+ return s, nil, weeklyShiftAction{delta: 1}287+ }288+ }289+290+ var cmd tea.Cmd291+ s.weekly.table, cmd = s.weekly.table.Update(msg)292+ return s, cmd, nil293+}294+295+func (s weeklyScreen) View(notice noticeState) string {296+ m := model{297+ width: s.width,298+ height: s.height,299+ notice: notice,300+ weekly: s.weekly,301+ }302+ return centerContentWithNotice(s.width, s.height, notice, m.weeklyViewContent())303+}304+305+func (s weeklyScreen) Apply(m model) model {306+ m.state = weeklyView307+ m.weekly = s.weekly308+ return m309+}
A app/screen_support.go +256 -0
@@ -0,0 +1,256 @@
1+package app2+3+import (4+ "github.com/FelineStateMachine/puzzletea/theme"5+ "github.com/FelineStateMachine/puzzletea/ui"6+7+ "charm.land/bubbles/v2/key"8+ "charm.land/bubbles/v2/list"9+ "charm.land/bubbles/v2/spinner"10+ tea "charm.land/bubbletea/v2"11+)12+13+type helpSelectScreen struct {14+ width int15+ height int16+ help helpState17+}18+19+func (s helpSelectScreen) State() viewState { return helpSelectView }20+21+func (s helpSelectScreen) Resize(width, height int) screenModel {22+ s.width = width23+ s.height = height24+ listWidth, listHeight := helpSelectListSize(width, height, s.help.selectList)25+ s.help.selectList.SetSize(listWidth, listHeight)26+ return s27+}28+29+func (s helpSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {30+ keyMsg, ok := msg.(tea.KeyPressMsg)31+ if ok {32+ switch {33+ case key.Matches(keyMsg, rootKeys.Enter):34+ return s, nil, helpSelectEnterAction{}35+ case key.Matches(keyMsg, rootKeys.Escape):36+ return s, nil, backAction{target: optionsMenuView}37+ }38+ }39+40+ var cmd tea.Cmd41+ s.help.selectList, cmd = s.help.selectList.Update(msg)42+ return s, cmd, nil43+}44+45+func (s helpSelectScreen) View(notice noticeState) string {46+ m := model{47+ width: s.width,48+ height: s.height,49+ notice: notice,50+ help: s.help,51+ }52+ return m.renderPanel(53+ "How to Play",54+ m.help.selectList.View(),55+ "↑/↓ navigate • enter select • esc back",56+ )57+}58+59+func (s helpSelectScreen) Apply(m model) model {60+ m.state = helpSelectView61+ m.help.selectList = s.help.selectList62+ return m63+}64+65+type helpDetailScreen struct {66+ width int67+ height int68+ help helpState69+}70+71+func (s helpDetailScreen) State() viewState { return helpDetailView }72+73+func (s helpDetailScreen) Resize(width, height int) screenModel {74+ m := model{75+ width: width,76+ height: height,77+ help: s.help,78+ }79+ m = m.updateHelpDetailViewport()80+ s.width = width81+ s.height = height82+ s.help = m.help83+ return s84+}85+86+func (s helpDetailScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {87+ keyMsg, ok := msg.(tea.KeyPressMsg)88+ if ok && key.Matches(keyMsg, rootKeys.Escape) {89+ return s, nil, backAction{target: helpSelectView}90+ }91+92+ var cmd tea.Cmd93+ s.help.viewport, cmd = s.help.viewport.Update(msg)94+ return s, cmd, nil95+}96+97+func (s helpDetailScreen) View(notice noticeState) string {98+ m := model{99+ width: s.width,100+ height: s.height,101+ notice: notice,102+ help: s.help,103+ }104+ return m.renderPanel(105+ m.help.category.Definition.Name+" — Guide",106+ m.help.viewport.View(),107+ "↑/↓ scroll • esc back",108+ )109+}110+111+func (s helpDetailScreen) Apply(m model) model {112+ m.state = helpDetailView113+ m.help = s.help114+ return m115+}116+117+type statsScreen struct {118+ width int119+ height int120+ stats statsState121+}122+123+func (s statsScreen) State() viewState { return statsView }124+125+func (s statsScreen) Resize(width, height int) screenModel {126+ m := model{127+ width: width,128+ height: height,129+ stats: s.stats,130+ }131+ m = m.updateStatsViewport()132+ s.width = width133+ s.height = height134+ s.stats = m.stats135+ return s136+}137+138+func (s statsScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {139+ keyMsg, ok := msg.(tea.KeyPressMsg)140+ if ok && key.Matches(keyMsg, rootKeys.Escape) {141+ return s, nil, backAction{target: mainMenuView}142+ }143+144+ var cmd tea.Cmd145+ s.stats.viewport, cmd = s.stats.viewport.Update(msg)146+ return s, cmd, nil147+}148+149+func (s statsScreen) View(notice noticeState) string {150+ m := model{151+ width: s.width,152+ height: s.height,153+ notice: notice,154+ stats: s.stats,155+ }156+ return m.renderStatsView()157+}158+159+func (s statsScreen) Apply(m model) model {160+ m.state = statsView161+ m.stats = s.stats162+ return m163+}164+165+type themeSelectScreen struct {166+ width int167+ height int168+ theme themeState169+}170+171+func (s themeSelectScreen) State() viewState { return themeSelectView }172+173+func (s themeSelectScreen) Resize(width, height int) screenModel {174+ const maxVisibleItems = 8175+ menuW := min(width, 64)176+ listW := min(menuW, theme.MaxNameLen+4)177+ s.width = width178+ s.height = height179+ s.theme.list.SetSize(listW, min(height, maxVisibleItems*3))180+ return s181+}182+183+func (s themeSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {184+ keyMsg, ok := msg.(tea.KeyPressMsg)185+ if ok {186+ switch {187+ case s.theme.list.SettingFilter() && s.theme.list.FilterValue() == "" && key.Matches(keyMsg, rootKeys.Enter):188+ s.theme.list.ResetFilter()189+ return s, nil, nil190+ case s.theme.list.SettingFilter() && key.Matches(keyMsg, rootKeys.Enter):191+ case s.theme.list.FilterState() != list.Unfiltered && key.Matches(keyMsg, rootKeys.Escape):192+ case key.Matches(keyMsg, rootKeys.Enter):193+ return s, nil, confirmThemeAction{}194+ case key.Matches(keyMsg, rootKeys.Escape):195+ return s, nil, backAction{target: optionsMenuView}196+ }197+ }198+199+ prev := s.theme.list.Index()200+ var cmd tea.Cmd201+ s.theme.list, cmd = s.theme.list.Update(msg)202+ if s.theme.list.Index() != prev {203+ if item, ok := s.theme.list.SelectedItem().(ui.MenuItem); ok {204+ return s, cmd, previewThemeAction{name: item.ItemTitle}205+ }206+ }207+ return s, cmd, nil208+}209+210+func (s themeSelectScreen) View(notice noticeState) string {211+ m := model{212+ width: s.width,213+ height: s.height,214+ notice: notice,215+ theme: s.theme,216+ }217+ return m.themeSelectViewContent()218+}219+220+func (s themeSelectScreen) Apply(m model) model {221+ m.state = themeSelectView222+ m.theme = s.theme223+ return m224+}225+226+type generatingScreen struct {227+ width int228+ height int229+ spinner spinner.Model230+}231+232+func (s generatingScreen) State() viewState { return generatingView }233+234+func (s generatingScreen) Resize(width, height int) screenModel {235+ s.width = width236+ s.height = height237+ return s238+}239+240+func (s generatingScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {241+ var cmd tea.Cmd242+ s.spinner, cmd = s.spinner.Update(msg)243+ return s, cmd, nil244+}245+246+func (s generatingScreen) View(notice noticeState) string {247+ content := s.spinner.View() + " Generating puzzle..."248+ box := ui.GeneratingFrame().Render(appendNoticeContent(s.width, notice, content))249+ return ui.CenterView(s.width, s.height, box)250+}251+252+func (s generatingScreen) Apply(m model) model {253+ m.state = generatingView254+ m.spinner = s.spinner255+ return m256+}
A app/screens_test.go +74 -0
@@ -0,0 +1,74 @@
1+package app2+3+import (4+ "testing"5+6+ tea "charm.land/bubbletea/v2"7+ "github.com/FelineStateMachine/puzzletea/ui"8+)9+10+func TestMainMenuScreenRoutesByStableAction(t *testing.T) {11+ screen := mainMenuScreen{12+ menu: ui.NewMainMenu([]ui.MenuItem{13+ {Action: mainMenuActionPlay, ItemTitle: "Launch", Desc: "custom label"},14+ }),15+ }16+17+ _, _, action := screen.Update(tea.KeyPressMsg{Code: tea.KeyEnter})18+ if _, ok := action.(openPlayMenuAction); !ok {19+ t.Fatalf("action = %T, want %T", action, openPlayMenuAction{})20+ }21+}22+23+func TestPlayMenuScreenRoutesByStableAction(t *testing.T) {24+ tests := []struct {25+ name string26+ item ui.MenuItem27+ assert func(t *testing.T, action screenAction)28+ }{29+ {30+ name: "daily",31+ item: ui.MenuItem{Action: playMenuActionDaily, ItemTitle: "Today", Desc: "custom label"},32+ assert: func(t *testing.T, action screenAction) {33+ t.Helper()34+ if _, ok := action.(openDailyAction); !ok {35+ t.Fatalf("action = %T, want %T", action, openDailyAction{})36+ }37+ },38+ },39+ {40+ name: "seeded",41+ item: ui.MenuItem{Action: playMenuActionSeeded, ItemTitle: "Named Seed", Desc: "custom label"},42+ assert: func(t *testing.T, action screenAction) {43+ t.Helper()44+ if _, ok := action.(openSeedInputAction); !ok {45+ t.Fatalf("action = %T, want %T", action, openSeedInputAction{})46+ }47+ },48+ },49+ }50+51+ for _, tt := range tests {52+ t.Run(tt.name, func(t *testing.T) {53+ screen := playMenuScreen{54+ menu: ui.NewMainMenu([]ui.MenuItem{tt.item}),55+ }56+57+ _, _, action := screen.Update(tea.KeyPressMsg{Code: tea.KeyEnter})58+ tt.assert(t, action)59+ })60+ }61+}62+63+func TestOptionsMenuScreenRoutesByStableAction(t *testing.T) {64+ screen := optionsMenuScreen{65+ menu: ui.NewMainMenu([]ui.MenuItem{66+ {Action: optionsMenuActionGuides, ItemTitle: "Help Docs", Desc: "custom label"},67+ }),68+ }69+70+ _, _, action := screen.Update(tea.KeyPressMsg{Code: tea.KeyEnter})71+ if _, ok := action.(openHelpSelectAction); !ok {72+ t.Fatalf("action = %T, want %T", action, openHelpSelectAction{})73+ }74+}
M app/seed_input.go +26 -26
@@ -75,46 +75,46 @@ func (m model) enterSeedInputView() (model, tea.Cmd) {
7575 ti.SetWidth(min(m.width, 48))7676 7777 options := buildSeedModeOptions(registry.Definitions())78- index := findSeedModeIndex(options, m.nav.lastSeedModeKey)78+ index := findSeedModeIndex(options, m.seed.lastModeKey)7979 80- m.nav.seedInput = ti81- m.nav.seedModeOptions = options82- m.nav.seedModeIndex = index83- m.nav.seedFocus = seedFocusText80+ m.seed.input = ti81+ m.seed.modeOptions = options82+ m.seed.modeIndex = index83+ m.seed.focus = seedFocusText8484 m.state = seedInputView85- return m, m.nav.seedInput.Focus()85+ return m, m.seed.input.Focus()8686 }8787 8888 func (m model) currentSeedMode() seedModeOption {89- if len(m.nav.seedModeOptions) == 0 {89+ if len(m.seed.modeOptions) == 0 {9090 return seedModeOption{label: randomSeedModeLabel}9191 }92- if m.nav.seedModeIndex < 0 || m.nav.seedModeIndex >= len(m.nav.seedModeOptions) {93- return m.nav.seedModeOptions[0]92+ if m.seed.modeIndex < 0 || m.seed.modeIndex >= len(m.seed.modeOptions) {93+ return m.seed.modeOptions[0]9494 }95- return m.nav.seedModeOptions[m.nav.seedModeIndex]95+ return m.seed.modeOptions[m.seed.modeIndex]9696 }9797 9898 func (m model) moveSeedMode(step int) model {99- if len(m.nav.seedModeOptions) == 0 {99+ if len(m.seed.modeOptions) == 0 {100100 return m101101 }102102 103- index := m.nav.seedModeIndex + step103+ index := m.seed.modeIndex + step104104 for index < 0 {105- index += len(m.nav.seedModeOptions)105+ index += len(m.seed.modeOptions)106106 }107- m.nav.seedModeIndex = index % len(m.nav.seedModeOptions)108- m.nav.lastSeedModeKey = m.currentSeedMode().key107+ m.seed.modeIndex = index % len(m.seed.modeOptions)108+ m.seed.lastModeKey = m.currentSeedMode().key109109 return m110110 }111111 112112 func (m model) setSeedFocus(focus seedInputFocus) (model, tea.Cmd) {113- m.nav.seedFocus = focus113+ m.seed.focus = focus114114 if focus == seedFocusText {115- return m, m.nav.seedInput.Focus()115+ return m, m.seed.input.Focus()116116 }117- m.nav.seedInput.Blur()117+ m.seed.input.Blur()118118 return m, nil119119 }120120
@@ -127,34 +127,34 @@ func (m model) handleSeedInputUpdate(msg tea.Msg) (model, tea.Cmd) {
127127 case "down", "j":128128 return m.setSeedFocus(seedFocusMode)129129 case "left", "h":130- if m.nav.seedFocus == seedFocusMode {130+ if m.seed.focus == seedFocusMode {131131 return m.moveSeedMode(-1), nil132132 }133133 case "right", "l":134- if m.nav.seedFocus == seedFocusMode {134+ if m.seed.focus == seedFocusMode {135135 return m.moveSeedMode(1), nil136136 }137137 }138138 }139139 140- if m.nav.seedFocus != seedFocusText {140+ if m.seed.focus != seedFocusText {141141 return m, nil142142 }143143 144144 var cmd tea.Cmd145- m.nav.seedInput, cmd = m.nav.seedInput.Update(msg)145+ m.seed.input, cmd = m.seed.input.Update(msg)146146 return m, cmd147147 }148148 149149 func (m model) seedInputBody() string {150- selector := renderSeedModeTitle(m.currentSeedMode().label, m.nav.seedModeIndex)150+ selector := renderSeedModeTitle(m.currentSeedMode().label, m.seed.modeIndex)151151 modePrefix := " "152- if m.nav.seedFocus == seedFocusMode {152+ if m.seed.focus == seedFocusMode {153153 modePrefix = ui.CursorStyle().Render("> ")154154 }155155 156- seedInputView := m.nav.seedInput.View()157- if m.nav.seedFocus != seedFocusText {156+ seedInputView := m.seed.input.View()157+ if m.seed.focus != seedFocusText {158158 seedInputView = strings.Replace(seedInputView, ">", " ", 1)159159 }160160
A app/session_controller.go +204 -0
@@ -0,0 +1,204 @@
1+package app2+3+import (4+ "context"5+ "math/rand/v2"6+7+ "github.com/FelineStateMachine/puzzletea/game"8+ sessionflow "github.com/FelineStateMachine/puzzletea/session"9+ "github.com/FelineStateMachine/puzzletea/store"10+11+ tea "charm.land/bubbletea/v2"12+)13+14+type sessionController struct {15+ model *model16+}17+18+func newSessionController(m *model) sessionController {19+ return sessionController{model: m}20+}21+22+func (c sessionController) beginSpawnContext() (context.Context, int64) {23+ c.cancelActiveSpawn()24+ c.model.session.spawnJobID++25+ jobID := c.model.session.spawnJobID26+ ctx, cancel := context.WithTimeout(context.Background(), spawnTimeout)27+ c.model.session.spawnCancel = cancel28+ c.model.session.generating = true29+ return ctx, jobID30+}31+32+func (c sessionController) cancelActiveSpawn() {33+ if c.model.session.spawnCancel != nil {34+ c.model.session.spawnCancel()35+ c.model.session.spawnCancel = nil36+ }37+ if c.model.session.generating {38+ c.model.session.spawnJobID++39+ }40+ c.model.session.generating = false41+ c.model.session.spawn = nil42+}43+44+func (c sessionController) startSpawn(spawner game.Spawner, request spawnRequest) tea.Cmd {45+ ctx, jobID := c.beginSpawnContext()46+ c.model.session.spawn = &request47+ c.model.state = generatingView48+ *c.model = c.model.clearNotice()49+ return tea.Batch(c.model.spinner.Tick, spawnCmd(spawner, ctx, jobID))50+}51+52+func (c sessionController) startSeededSpawn(spawner game.SeededSpawner, rng *rand.Rand, request spawnRequest) tea.Cmd {53+ ctx, jobID := c.beginSpawnContext()54+ c.model.session.spawn = &request55+ c.model.state = generatingView56+ *c.model = c.model.clearNotice()57+ return tea.Batch(c.model.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))58+}59+60+func (c sessionController) handleSpawnComplete(jobID int64, msg game.SpawnCompleteMsg) tea.Cmd {61+ if jobID != c.model.session.spawnJobID {62+ return nil63+ }64+65+ c.model.session.generating = false66+ if c.model.session.spawnCancel != nil {67+ c.model.session.spawnCancel()68+ c.model.session.spawnCancel = nil69+ }70+ request := c.model.session.spawn71+ c.model.session.spawn = nil72+73+ if c.model.state != generatingView {74+ return nil75+ }76+77+ if msg.Err != nil {78+ *c.model = c.model.setErrorf("Could not generate puzzle: %v", msg.Err)79+ if request != nil {80+ c.model.state = request.returnState81+ }82+ return nil83+ }84+85+ if request == nil {86+ *c.model = c.model.setErrorf("Internal error: missing spawn request metadata")87+ return nil88+ }89+90+ c.activateGame(msg.Game.SetTitle(request.name), 0, false, gameOpenOptions{91+ returnState: request.exitState,92+ weeklyInfo: request.weeklyInfo,93+ })94+ *c.model = c.model.clearNotice()95+96+ rec, err := sessionflow.CreateRecord(97+ c.model.store,98+ c.model.session.game,99+ request.name,100+ request.gameType,101+ request.modeTitle,102+ request.run,103+ )104+ if err != nil {105+ *c.model = c.model.setErrorf("Started puzzle, but could not create a save record: %v", err)106+ } else {107+ c.model.session.activeGameID = rec.ID108+ }109+ return nil110+}111+112+func (c sessionController) activateGame(g game.Gamer, activeGameID int64, completionSaved bool, options gameOpenOptions) {113+ g, _ = g.Update(game.HelpToggleMsg{Show: c.model.help.showFull})114+ g, _ = g.Update(tea.WindowSizeMsg{Width: c.model.width, Height: c.model.height})115+116+ c.model.session.game = g117+ c.model.session.activeGameID = activeGameID118+ c.model.session.completionSaved = completionSaved119+ c.model.session.returnState = options.returnState120+ c.model.session.weeklyAdvance = options.weeklyInfo121+ c.model.state = gameView122+}123+124+func (c sessionController) importRecord(rec store.GameRecord, options gameOpenOptions) bool {125+ g, err := sessionflow.ImportRecord(&rec)126+ if err != nil {127+ *c.model = c.model.setErrorf("Could not load saved puzzle %q: %v", rec.Name, err)128+ return false129+ }130+131+ activeGameID := rec.ID132+ completionSaved := rec.Status == store.StatusCompleted133+ if options.readOnly {134+ activeGameID = 0135+ completionSaved = true136+ }137+138+ c.activateGame(g, activeGameID, completionSaved, options)139+ *c.model = c.model.clearNotice()140+ return true141+}142+143+func (c sessionController) updateActiveGame(msg tea.Msg) tea.Cmd {144+ var cmd tea.Cmd145+ c.model.session.game, cmd = c.model.session.game.Update(msg)146+ if c.model.debug.enabled {147+ c.model.debug.info = c.model.renderDebugInfo()148+ }149+ c.persistCompletionIfSolved()150+ return cmd151+}152+153+func (c sessionController) persistCompletionIfSolved() {154+ if c.model.session.game == nil || c.model.session.activeGameID == 0 ||155+ c.model.session.completionSaved || !c.model.session.game.IsSolved() {156+ return157+ }158+159+ c.model.session.completionSaved = true160+ saveData, err := c.model.session.game.GetSave()161+ if err == nil {162+ if err := c.model.store.UpdateSaveState(c.model.session.activeGameID, string(saveData)); err != nil {163+ *c.model = c.model.setErrorf("Puzzle completed, but saving the final state failed: %v", err)164+ }165+ } else {166+ *c.model = c.model.setErrorf("Puzzle completed, but reading the final state failed: %v", err)167+ }168+ if err := c.model.store.UpdateStatus(c.model.session.activeGameID, store.StatusCompleted); err != nil {169+ *c.model = c.model.setErrorf("Puzzle completed, but updating completion status failed: %v", err)170+ }171+}172+173+func (c sessionController) saveCurrentGame(status store.GameStatus) {174+ if c.model.session.game == nil {175+ return176+ }177+ if c.model.session.activeGameID == 0 {178+ c.clearActiveGame()179+ return180+ }181+182+ saveData, err := c.model.session.game.GetSave()183+ if err != nil {184+ *c.model = c.model.setErrorf("Could not save puzzle progress: %v", err)185+ return186+ }187+ if err := c.model.store.UpdateSaveState(c.model.session.activeGameID, string(saveData)); err != nil {188+ *c.model = c.model.setErrorf("Could not save puzzle progress: %v", err)189+ }190+ if !(c.model.session.completionSaved && status != store.StatusCompleted) {191+ if err := c.model.store.UpdateStatus(c.model.session.activeGameID, status); err != nil {192+ *c.model = c.model.setErrorf("Could not update puzzle status: %v", err)193+ }194+ }195+ c.clearActiveGame()196+}197+198+func (c sessionController) clearActiveGame() {199+ c.model.session.activeGameID = 0200+ c.model.session.game = nil201+ c.model.session.completionSaved = false202+ c.model.session.returnState = mainMenuView203+ c.model.session.weeklyAdvance = nil204+}
M app/spawn.go +4 -99
@@ -2,12 +2,10 @@ package app
22 33 import (44 "context"5- "log"65 "math/rand/v2"76 "time"87 98 "github.com/FelineStateMachine/puzzletea/game"10- sessionflow "github.com/FelineStateMachine/puzzletea/session"119 "github.com/FelineStateMachine/puzzletea/store"1210 1311 tea "charm.land/bubbletea/v2"
@@ -60,110 +58,17 @@ func spawnSeededCmd(spawner game.SeededSpawner, rng *rand.Rand, ctx context.Cont
6058 }6159 }6260 63-func (m *model) beginSpawnContext() (context.Context, int64) {64- m.cancelActiveSpawn()65- m.session.spawnJobID++66- jobID := m.session.spawnJobID67- ctx, cancel := context.WithTimeout(context.Background(), spawnTimeout)68- m.session.spawnCancel = cancel69- m.session.generating = true70- return ctx, jobID71-}72-7361 func (m *model) cancelActiveSpawn() {74- if m.session.spawnCancel != nil {75- m.session.spawnCancel()76- m.session.spawnCancel = nil77- }78- if m.session.generating {79- // Invalidate late completion messages from a canceled job.80- m.session.spawnJobID++81- }82- m.session.generating = false83- m.session.spawn = nil62+ newSessionController(m).cancelActiveSpawn()8463 }8564 8665 func (m model) handleSpawnComplete(jobID int64, msg game.SpawnCompleteMsg) (tea.Model, tea.Cmd) {87- if jobID != m.session.spawnJobID {88- return m, nil89- }90-91- m.session.generating = false92- if m.session.spawnCancel != nil {93- m.session.spawnCancel()94- m.session.spawnCancel = nil95- }96- request := m.session.spawn97- m.session.spawn = nil98-99- // If the user navigated away while generating, discard the result.100- if m.state != generatingView {101- return m, nil102- }103-104- if msg.Err != nil {105- log.Printf("failed to spawn game: %v", msg.Err)106- if request != nil {107- m.state = request.returnState108- }109- return m, nil110- }111-112- if request == nil {113- log.Printf("missing spawn request metadata")114- return m, nil115- }116- m = m.activateGame(msg.Game.SetTitle(request.name), 0, false, gameOpenOptions{117- returnState: request.exitState,118- weeklyInfo: request.weeklyInfo,119- })120-121- // Capture initial state and create DB record.122- rec, err := sessionflow.CreateRecord(123- m.store,124- m.session.game,125- request.name,126- request.gameType,127- request.modeTitle,128- )129- if err != nil {130- log.Printf("failed to create game record: %v", err)131- } else {132- m.session.activeGameID = rec.ID133- }134- return m, nil66+ cmd := newSessionController(&m).handleSpawnComplete(jobID, msg)67+ return m, cmd13568 }13669 13770 // saveCurrentGame saves the current game state to the DB if a game is active.13871 func saveCurrentGame(m model, status store.GameStatus) model {139- if m.session.game == nil {140- return m141- }142- if m.session.activeGameID == 0 {143- return clearActiveGame(m)144- }145- saveData, err := m.session.game.GetSave()146- if err != nil {147- log.Printf("failed to get save data: %v", err)148- return m149- }150- if err := m.store.UpdateSaveState(m.session.activeGameID, string(saveData)); err != nil {151- log.Printf("failed to update save state: %v", err)152- }153- // Don't overwrite a completed status when navigating away.154- if !(m.session.completionSaved && status != store.StatusCompleted) {155- if err := m.store.UpdateStatus(m.session.activeGameID, status); err != nil {156- log.Printf("failed to update game status: %v", err)157- }158- }159- return clearActiveGame(m)160-}161-162-func clearActiveGame(m model) model {163- m.session.activeGameID = 0164- m.session.game = nil165- m.session.completionSaved = false166- m.session.returnState = mainMenuView167- m.session.weeklyAdvance = nil72+ newSessionController(&m).saveCurrentGame(status)16873 return m16974 }
M app/spawn_test.go +27 -0
A app/state_view.go +52 -0
@@ -0,0 +1,52 @@
1+package app2+3+import (4+ "github.com/FelineStateMachine/puzzletea/ui"5+6+ "charm.land/lipgloss/v2"7+)8+9+func (m model) renderContinueView() string {10+ if len(m.cont.games) == 0 {11+ return m.renderPanel("Saved Games", "No saved games yet.", "esc back")12+ }13+14+ footer := "↑/↓ navigate • enter resume • esc back"15+ if pg := ui.TablePagination(m.cont.table); pg != "" {16+ footer = pg + " " + footer17+ }18+ return m.renderPanel("Saved Games", m.cont.table.View(), footer)19+}20+21+func (m model) renderGameView() string {22+ if m.session.game == nil {23+ return ""24+ }25+26+ gameView := lipgloss.NewStyle().MaxWidth(m.width).Render(m.session.game.View())27+ centered := gameView28+ if m.debug.enabled {29+ debugInfo := lipgloss.NewStyle().MaxWidth(m.width).Render(30+ ui.DebugStyle().Render(m.debug.info),31+ )32+ centered = lipgloss.JoinVertical(lipgloss.Center, gameView, debugInfo)33+ }34+ return ui.CenterView(m.width, m.height, m.appendNotice(centered))35+}36+37+func (m model) renderStatsView() string {38+ statsWidth, _ := statsViewportSize(m.width, m.height, m.stats.cards)39+ var statsBody string40+ if len(m.stats.cards) == 0 {41+ statsBody = m.stats.viewport.View()42+ } else {43+ banner := ui.RenderStatsBanner(m.stats.profile, statsWidth)44+ statsBody = lipgloss.JoinVertical(lipgloss.Left,45+ banner,46+ "",47+ m.stats.viewport.View(),48+ )49+ }50+ statsBody = lipgloss.NewStyle().Width(statsWidth).Render(statsBody)51+ return m.renderPanel("Stats", statsBody, "↑/↓ scroll • esc back")52+}
M app/update.go +142 -730
@@ -1,392 +1,143 @@
11 package app22 33 import (4- "log"54 "time"65 7- "github.com/FelineStateMachine/puzzletea/config"8- "github.com/FelineStateMachine/puzzletea/daily"96 "github.com/FelineStateMachine/puzzletea/game"10- "github.com/FelineStateMachine/puzzletea/registry"11- "github.com/FelineStateMachine/puzzletea/resolve"12- sessionflow "github.com/FelineStateMachine/puzzletea/session"13- "github.com/FelineStateMachine/puzzletea/stats"147 "github.com/FelineStateMachine/puzzletea/store"158 "github.com/FelineStateMachine/puzzletea/theme"169 "github.com/FelineStateMachine/puzzletea/ui"1710 1811 "charm.land/bubbles/v2/key"19- "charm.land/bubbles/v2/list"20- "charm.land/bubbles/v2/viewport"2112 tea "charm.land/bubbletea/v2"22- "github.com/charmbracelet/glamour"2313 )2414 25-const (26- helpPanelInsetX = 227- helpPanelInsetY = 128- helpPanelHorizontalTrim = 629- helpPanelVerticalTrim = 830- categoryPanelChrome = 831- categoryBodyMaxWidth = 8632- categoryBodyMaxHeight = 1633- categoryMinListWidth = 2434- categoryMaxListWidth = 3035- categoryGapWidth = 236- categoryDetailTrimX = 637- categoryDetailTrimY = 438- categoryStackGapHeight = 139- categoryMinSideBySideW = 7240-)41-42-func helpViewportSize(width, height int) (int, int) {43- panelWidth := max(width-(helpPanelInsetX*2), 1)44- panelHeight := max(height-(helpPanelInsetY*2), 1)45- contentWidth := max(panelWidth-helpPanelHorizontalTrim, 1)46- contentHeight := max(panelHeight-helpPanelVerticalTrim, 1)47- return contentWidth, contentHeight48-}49-50-func helpSelectListSize(width, height int, l list.Model) (int, int) {51- contentWidth, contentHeight := helpViewportSize(width, height)52- listWidth := min(contentWidth, 64)53- listHeight := min(contentHeight, ui.ListHeight(l))54- return listWidth, listHeight55-}56-57-func statsViewportSize(width, height int, cards []stats.Card) (int, int) {58- contentWidth, _ := helpViewportSize(width, height)59- panelHeight := max(height-(helpPanelInsetY*2), 1)60- contentHeight := max(panelHeight-stats.StaticHeight(cards), 1)61- return contentWidth, contentHeight62-}63-64-type categoryPickerMetrics struct {65- bodyWidth int66- bodyHeight int67- listWidth int68- listHeight int69- detailWidth int70- detailHeight int71- stacked bool72-}73-74-func categoryPickerSize(width, height int) categoryPickerMetrics {75- bodyWidth := min(width, categoryBodyMaxWidth)76- bodyHeight := min(max(height-categoryPanelChrome, 1), categoryBodyMaxHeight)77-78- if bodyWidth < categoryMinSideBySideW {79- listHeight := min(bodyHeight, categoryPickerListHeight())80- detailHeight := max(bodyHeight-listHeight-categoryStackGapHeight, 1)81- if detailHeight == 1 && bodyHeight > 1 {82- listHeight = max(bodyHeight-categoryStackGapHeight-detailHeight, 1)83- }84- return categoryPickerMetrics{85- bodyWidth: bodyWidth,86- bodyHeight: bodyHeight,87- listWidth: bodyWidth,88- listHeight: listHeight,89- detailWidth: bodyWidth,90- detailHeight: detailHeight,91- stacked: true,15+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {16+ switch msg := msg.(type) {17+ case spawnCompleteMsg:18+ next, cmd := m.handleSpawnComplete(msg.jobID, msg.result)19+ return next, cmd20+ case game.SpawnCompleteMsg:21+ next, cmd := m.handleSpawnComplete(m.session.spawnJobID, msg)22+ return next, cmd23+ case tea.WindowSizeMsg:24+ m = m.handleWindowSize(msg)25+ if m.state != gameView {26+ return m, nil9227 }9328 }9429 95- listWidth := min(categoryMaxListWidth, max(categoryMinListWidth, bodyWidth/3))96- detailWidth := max(bodyWidth-listWidth-categoryGapWidth, 1)97- return categoryPickerMetrics{98- bodyWidth: bodyWidth,99- bodyHeight: bodyHeight,100- listWidth: listWidth,101- listHeight: bodyHeight,102- detailWidth: detailWidth,103- detailHeight: bodyHeight,104- }105-}106-107-func selectedCategoryName(item list.Item) string {108- entry, ok := selectedCategoryEntry(item)109- if !ok {110- return ""111- }112- return entry.Definition.Name113-}114-115-func activeFilterList(m model) *list.Model {116- switch m.state {117- case gameSelectView:118- return &m.nav.gameSelectList119- case themeSelectView:120- return &m.theme.list121- default:122- return nil123- }124-}125-126-func (m model) updateCategoryDetailViewport() model {127- metrics := categoryPickerSize(m.width, m.height)128- contentWidth := max(metrics.detailWidth-categoryDetailTrimX, 1)129- contentHeight := max(metrics.detailHeight-categoryDetailTrimY, 1)130-131- if m.nav.categoryDetail.Width() == 0 || m.nav.categoryDetail.Height() == 0 {132- m.nav.categoryDetail = viewport.New(133- viewport.WithWidth(contentWidth),134- viewport.WithHeight(contentHeight),135- )30+ next, cmd, handled := m.handleGlobalKey(msg)31+ if handled {32+ return next, cmd13633 }137- m.nav.categoryDetail.SetWidth(contentWidth)138- m.nav.categoryDetail.SetHeight(contentHeight)139- m.nav.categoryDetail.FillHeight = true34+ m = next14035 141- entry, ok := selectedCategoryEntry(m.nav.gameSelectList.SelectedItem())142- if !ok {143- m.nav.categoryDetail.SetContent("")144- return m36+ if m.state == gameView {37+ gameCmd := newSessionController(&m).updateActiveGame(msg)38+ return m, gameCmd14539 }14640 147- m.nav.categoryDetail.SetContent(renderCategoryDetailContent(entry, contentWidth))148- m.nav.categoryDetail.GotoTop()149- return m150-}151-152-func (m model) updateHelpDetailViewport() model {153- helpWidth, helpHeight := helpViewportSize(m.width, m.height)154- palette := theme.Current()155- themeKey := helpMarkdownThemeKey(palette)156- if m.help.renderer == nil || m.help.rendererWidth != helpWidth || m.help.rendererTheme != themeKey {157- renderer, err := glamour.NewTermRenderer(158- glamour.WithStyles(helpMarkdownStyle(palette)),159- glamour.WithWordWrap(helpWidth),160- glamour.WithChromaFormatter("terminal16m"),161- )162- if err != nil {163- log.Printf("failed to create help renderer: %v", err)164- m.help.renderer = nil165- m.help.rendererWidth = 0166- m.help.rendererTheme = ""167- } else {168- m.help.renderer = renderer169- m.help.rendererWidth = helpWidth170- m.help.rendererTheme = themeKey171- }41+ screen := m.activeScreen()42+ if screen == nil {43+ return m, nil17244 }17345 174- rendered := m.help.category.Help175- if m.help.renderer != nil {176- out, err := m.help.renderer.Render(m.help.category.Help)177- if err != nil {178- log.Printf("failed to render help: %v", err)179- } else {180- rendered = out181- }46+ nextScreen, screenCmd, action := screen.Update(msg)47+ m = nextScreen.Apply(m)48+ if action == nil {49+ return m, screenCmd18250 }18351 184- m.help.viewport = viewport.New(185- viewport.WithWidth(helpWidth),186- viewport.WithHeight(helpHeight),187- )188- m.help.viewport.SetContent(rendered)189- return m52+ next, actionCmd := m.handleScreenAction(action)53+ return next, tea.Batch(screenCmd, actionCmd)19054 }19155 192-func (m model) updateStatsViewport() model {193- statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.stats.cards)194- m.stats.viewport.SetWidth(statsWidth)195- m.stats.viewport.SetHeight(statsHeight)196- m.stats.viewport.SetContent(ui.RenderStatsCardGrid(m.stats.cards, statsWidth))197- return m198-}199-200-func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {201- var cmd tea.Cmd202-203- switch msg := msg.(type) {204- case spawnCompleteMsg:205- return m.handleSpawnComplete(msg.jobID, msg.result)206- case game.SpawnCompleteMsg:207- // Backward compatibility for callers still using the old message type.208- return m.handleSpawnComplete(m.session.spawnJobID, msg)209- case tea.WindowSizeMsg:210- m = m.handleWindowSize(msg)211- case tea.KeyPressMsg:212- next, keyCmd, done := m.handleGlobalKey(msg)213- if done {214- return next, keyCmd215- }216- m = next217- }218-219- switch m.state {220- case mainMenuView:221- updateMainMenuCursor(msg, &m.nav.mainMenu)222- case playMenuView:223- updateMainMenuCursor(msg, &m.nav.playMenu)224- case optionsMenuView:225- updateMainMenuCursor(msg, &m.nav.optionsMenu)226- case seedInputView:227- m, cmd = m.handleSeedInputUpdate(msg)228- case generatingView:229- m.spinner, cmd = m.spinner.Update(msg)230- case gameView:231- m.session.game, cmd = m.session.game.Update(msg)232- if m.debug.enabled {233- m.debug.info = m.renderDebugInfo()234- }235- m = m.persistCompletionIfSolved()236- case gameSelectView:237- prev := selectedCategoryName(m.nav.gameSelectList.SelectedItem())238- m.nav.gameSelectList, cmd = m.nav.gameSelectList.Update(msg)239- if selectedCategoryName(m.nav.gameSelectList.SelectedItem()) != prev {240- m = m.updateCategoryDetailViewport()241- }242- case modeSelectView:243- m.nav.modeSelectList, cmd = m.nav.modeSelectList.Update(msg)244- case continueView:245- m.nav.continueTable, cmd = m.nav.continueTable.Update(msg)246- case weeklyView:247- m.nav.weeklyTable, cmd = m.nav.weeklyTable.Update(msg)248- case helpSelectView:249- m.nav.helpSelectList, cmd = m.nav.helpSelectList.Update(msg)250- case helpDetailView:251- m.help.viewport, cmd = m.help.viewport.Update(msg)252- case statsView:253- m.stats.viewport, cmd = m.stats.viewport.Update(msg)254- case themeSelectView:255- prev := m.theme.list.Index()256- m.theme.list, cmd = m.theme.list.Update(msg)257- if m.theme.list.Index() != prev {258- if item, ok := m.theme.list.SelectedItem().(ui.MenuItem); ok {259- _ = theme.Apply(item.ItemTitle)260- ui.UpdateThemeListStyles(&m.theme.list)261- }262- }56+func (m model) resizeActiveScreen() model {57+ screen := m.activeScreen()58+ if screen == nil {59+ return m26360 }264-265- return m, cmd61+ return screen.Resize(m.width, m.height).Apply(m)26662 }26763 26864 func (m model) handleWindowSize(msg tea.WindowSizeMsg) model {26965 m.width = msg.Width27066 m.height = msg.Height271- menuW := min(m.width, 64)272- metrics := categoryPickerSize(m.width, m.height)273- m.nav.gameSelectList.SetSize(metrics.listWidth, metrics.listHeight)274- m = m.updateCategoryDetailViewport()275- if m.state == seedInputView {276- m.nav.seedInput.SetWidth(min(m.width, 48))277- }278- if m.state == modeSelectView {279- m.nav.modeSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.nav.modeSelectList)))280- }281- if m.state == continueView {282- m.nav.continueTable.SetWidth(m.width)283- visibleRows := min(len(m.nav.continueGames), ui.MaxTableRows)284- m.nav.continueTable.SetHeight(min(m.height, visibleRows))285- }286- if m.state == weeklyView {287- m = m.refreshWeeklyBrowser()288- }289- if m.state == helpSelectView {290- listWidth, listHeight := helpSelectListSize(m.width, m.height, m.nav.helpSelectList)291- m.nav.helpSelectList.SetSize(listWidth, listHeight)292- }293- if m.state == helpDetailView {294- m = m.updateHelpDetailViewport()295- }296- if m.state == themeSelectView {297- const maxVisibleItems = 8298- listW := min(menuW, theme.MaxNameLen+4)299- m.theme.list.SetSize(listW, min(m.height, maxVisibleItems*3))300- }301- if m.state == statsView {302- m = m.updateStatsViewport()67+ if m.state == gameView {68+ return m30369 }304- return m70+ return m.resizeActiveScreen()30571 }30672 307-func (m model) handleGlobalKey(msg tea.KeyPressMsg) (model, tea.Cmd, bool) {73+func (m model) handleGlobalKey(msg tea.Msg) (model, tea.Cmd, bool) {74+ keyMsg, ok := msg.(tea.KeyPressMsg)75+ if !ok {76+ return m, nil, false77+ }78+30879 if m.state == generatingView {30980 switch {310- case key.Matches(msg, rootKeys.Escape):81+ case key.Matches(keyMsg, rootKeys.Escape):31182 returnState := m.activeSpawnReturnState()31283 m.cancelActiveSpawn()31384 m.state = returnState314- return m, nil, true315- case key.Matches(msg, rootKeys.Quit):85+ return m.resizeActiveScreen(), nil, true86+ case key.Matches(keyMsg, rootKeys.Quit):31687 return m, tea.Quit, true31788 }31889 return m, nil, true31990 }32091 321- if l := activeFilterList(m); l != nil {92+ if m.state == gameView {32293 switch {323- case l.SettingFilter() && l.FilterValue() == "" && key.Matches(msg, rootKeys.Enter):324- l.ResetFilter()325- if m.state == gameSelectView {326- m = m.updateCategoryDetailViewport()94+ case key.Matches(keyMsg, rootKeys.Enter):95+ next, cmd, handled := m.advanceSolvedWeekly()96+ if handled {97+ return next, cmd, true98+ }99+ case key.Matches(keyMsg, rootKeys.Escape):100+ returnState := m.session.returnState101+ m = saveCurrentGame(m, store.StatusInProgress)102+ m.state = returnState103+ if returnState == weeklyView {104+ m = m.refreshWeeklyBrowser()105+ }106+ m.debug.enabled = false107+ return m, nil, true108+ case key.Matches(keyMsg, rootKeys.Quit):109+ m = saveCurrentGame(m, store.StatusAbandoned)110+ return m, tea.Quit, true111+ case key.Matches(keyMsg, rootKeys.Debug):112+ m.debug.enabled = !m.debug.enabled113+ return m, nil, true114+ case key.Matches(keyMsg, rootKeys.FullHelp):115+ m.help.showFull = !m.help.showFull116+ if m.session.game != nil {117+ m.session.game, _ = m.session.game.Update(game.HelpToggleMsg{Show: m.help.showFull})118+ }119+ return m, nil, true120+ case key.Matches(keyMsg, rootKeys.ResetGame):121+ if m.session.game != nil {122+ m.session.game = m.session.game.Reset()327123 }328124 return m, nil, true329- case l.SettingFilter() && key.Matches(msg, rootKeys.Enter):330- return m, nil, false331- case l.FilterState() != list.Unfiltered && key.Matches(msg, rootKeys.Escape):332- return m, nil, false333125 }126+ return m, nil, false334127 }335128 336129 switch {337- case m.state == weeklyView && (msg.String() == "left" || msg.String() == "h"):338- m = m.moveWeeklyWeek(-1)339- return m, nil, true340- case m.state == weeklyView && (msg.String() == "right" || msg.String() == "l"):341- m = m.moveWeeklyWeek(1)342- return m, nil, true343- case m.state == gameView && key.Matches(msg, rootKeys.Enter):344- next, cmd, handled := m.advanceSolvedWeekly()345- if handled {346- return next, cmd, true347- }348- case m.state == gameSelectView && msg.String() == "pgup":349- m.nav.categoryDetail.PageUp()350- return m, nil, true351- case m.state == gameSelectView && msg.String() == "pgdown":352- m.nav.categoryDetail.PageDown()353- return m, nil, true354- case m.state == gameView && key.Matches(msg, rootKeys.Escape):355- returnState := m.session.returnState356- m = saveCurrentGame(m, store.StatusInProgress)357- m.state = returnState358- if returnState == weeklyView {359- m = m.refreshWeeklyBrowser()360- }361- m.debug.enabled = false362- return m, nil, true363- case key.Matches(msg, rootKeys.Enter):364- if m.state != gameView {365- next, cmd := m.handleEnter()366- return next.(model), cmd, true367- }368- case key.Matches(msg, rootKeys.Escape):369- if m.state != gameView {370- next, cmd := m.handleEscape()371- return next.(model), cmd, true372- }373- case key.Matches(msg, rootKeys.Quit):374- m = saveCurrentGame(m, store.StatusAbandoned)130+ case key.Matches(keyMsg, rootKeys.Quit):375131 return m, tea.Quit, true376- case key.Matches(msg, rootKeys.Debug):132+ case key.Matches(keyMsg, rootKeys.Debug):377133 m.debug.enabled = !m.debug.enabled378- case key.Matches(msg, rootKeys.FullHelp):134+ return m, nil, true135+ case key.Matches(keyMsg, rootKeys.FullHelp):379136 m.help.showFull = !m.help.showFull380- if m.state == gameView && m.session.game != nil {381- m.session.game, _ = m.session.game.Update(game.HelpToggleMsg{Show: m.help.showFull})382- }383- case key.Matches(msg, rootKeys.ResetGame):384- if m.state == gameView && m.session.game != nil {385- m.session.game = m.session.game.Reset()386- }137+ return m, nil, true138+ default:139+ return m, nil, false387140 }388-389- return m, nil, false390141 }391142 392143 func (m model) activeSpawnReturnState() viewState {
@@ -396,418 +147,79 @@ func (m model) activeSpawnReturnState() viewState {
396147 return m.session.spawn.returnState397148 }398149 399-func (m model) persistCompletionIfSolved() model {400- if m.session.game == nil || m.session.activeGameID == 0 ||401- m.session.completionSaved || !m.session.game.IsSolved() {402- return m403- }404-405- m.session.completionSaved = true406- saveData, err := m.session.game.GetSave()407- if err == nil {408- if err := m.store.UpdateSaveState(m.session.activeGameID, string(saveData)); err != nil {409- log.Printf("failed to save completion state: %v", err)410- }411- }412- if err := m.store.UpdateStatus(m.session.activeGameID, store.StatusCompleted); err != nil {413- log.Printf("failed to mark game completed: %v", err)414- }415- return m416-}417-418-func updateMainMenuCursor(msg tea.Msg, menu *ui.MainMenu) {419- keyMsg, ok := msg.(tea.KeyPressMsg)420- if !ok {421- return422- }423- switch keyMsg.String() {424- case "up", "k":425- menu.CursorUp()426- case "down", "j":427- menu.CursorDown()428- }429-}430-431-func (m model) handleEnter() (tea.Model, tea.Cmd) {432- switch m.state {433- case mainMenuView:434- return m.handleMainMenuEnter()435- case playMenuView:436- return m.handlePlayMenuEnter()437- case optionsMenuView:438- return m.handleOptionsMenuEnter()439- case seedInputView:440- return m.handleSeedConfirm()441- case gameSelectView:442- return m.handleGameSelectEnter()443- case modeSelectView:444- return m.handleModeSelectEnter()445- case continueView:446- return m.handleContinueEnter()447- case weeklyView:448- return m.handleWeeklyEnter()449- case helpSelectView:450- return m.handleHelpSelectEnter()451- case themeSelectView:452- return m.handleThemeConfirm()453- }454- return m, nil455-}456-457-func (m model) handleMainMenuEnter() (tea.Model, tea.Cmd) {458- item := m.nav.mainMenu.Selected()459- switch item.Title() {460- case "Play":150+func (m model) handleScreenAction(action screenAction) (model, tea.Cmd) {151+ switch action := action.(type) {152+ case openPlayMenuAction:461153 m.nav.playMenu = ui.NewMainMenu(buildPlayMenuItems(time.Now(), m.currentWeeklyMenuIndex()))462154 m.state = playMenuView463- case "Stats":464- return m.handleStatsEnter()465- case "Options":155+ m = m.clearNotice()156+ return m.resizeActiveScreen(), nil157+ case openStatsAction:158+ return asModel(m.handleStatsEnter())159+ case openOptionsMenuAction:466160 m.nav.optionsMenu = ui.NewMainMenu(optionsMenuItems)467161 m.state = optionsMenuView468- case "Quit":162+ m = m.clearNotice()163+ return m.resizeActiveScreen(), nil164+ case quitAction:469165 return m, tea.Quit470- }471- return m, nil472-}473-474-func (m model) currentWeeklyMenuIndex() int {475- if m.store == nil {476- return 1477- }478-479- year, week := time.Now().ISOWeek()480- highestCompleted, err := m.store.GetCurrentWeeklyHighestCompletedIndex(year, week)481- if err != nil {482- return 1483- }484- if highestCompleted >= weeklyEntryCount {485- return weeklyEntryCount486- }487- if highestCompleted < 1 {488- return 1489- }490- return highestCompleted + 1491-}492-493-func (m model) handlePlayMenuEnter() (tea.Model, tea.Cmd) {494- item := m.nav.playMenu.Selected()495- switch item.Title() {496- case "Create":166+ case openGameSelectAction:497167 m.state = gameSelectView498168 m = m.updateCategoryDetailViewport()499- case "Continue":500- m.nav.continueTable, m.nav.continueGames = ui.InitContinueTable(m.store, m.height)169+ m = m.clearNotice()170+ return m.resizeActiveScreen(), nil171+ case openContinueAction:172+ m.cont.table, m.cont.games = ui.InitContinueTable(m.store, m.height)501173 m.state = continueView502- case "Daily":503- return m.handleDailyPuzzle()504- case "Weekly":505- return m.enterWeeklyView()506- case "Seeded":174+ m = m.clearNotice()175+ return m.resizeActiveScreen(), nil176+ case openDailyAction:177+ return asModel(m.handleDailyPuzzle())178+ case openWeeklyAction:179+ return asModel(m.enterWeeklyView())180+ case openSeedInputAction:507181 return m.enterSeedInputView()508- }509- return m, nil510-}511-512-func (m model) handleOptionsMenuEnter() (tea.Model, tea.Cmd) {513- item := m.nav.optionsMenu.Selected()514- switch item.Title() {515- case "Theme":516- return m.handleThemeEnter()517- case "Guides":518- m.nav.helpSelectList = ui.InitList(gameCategoryItems, "How to Play")519- listWidth, listHeight := helpSelectListSize(m.width, m.height, m.nav.helpSelectList)520- m.nav.helpSelectList.SetSize(listWidth, listHeight)521- m.state = helpSelectView522- }523- return m, nil524-}525-526-func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {527- seed := sessionflow.NormalizeSeed(m.nav.seedInput.Value())528- if seed == "" {529- return m, nil530- }531-532- selectedMode := m.currentSeedMode()533- name := sessionflow.SeededName(seed)534- if selectedMode.key != "" {535- name = sessionflow.SeededNameForGame(seed, selectedMode.gameType)536- }537-538- rec, err := m.store.GetDailyGame(name)539- if err != nil {540- log.Printf("failed to check seeded game: %v", err)541- return m, nil542- }543- if rec != nil {544- var resumed bool545- m, resumed = m.importAndActivateRecord(*rec)546- if resumed {547- if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, rec); err != nil {548- log.Printf("%v", err)549- }550- }551- return m, nil552- }553-554- var spawner game.SeededSpawner555- var gameType string556- modeTitle := ""557- if selectedMode.key == "" {558- spawner, gameType, modeTitle, err = resolve.SeededMode(seed, registry.Entries())559- if err != nil {560- log.Printf("failed to select seeded mode: %v", err)561- return m, nil562- }563- } else {564- spawner, gameType, modeTitle, err = resolve.SeededModeForGame(seed, selectedMode.gameType, registry.Entries())565- if err != nil {566- log.Printf("failed to select seeded mode for %s: %v", selectedMode.gameType, err)567- return m, nil568- }569- }570-571- rng := resolve.RNGFromString(seed)572- ctx, jobID := m.beginSpawnContext()573- m.session.spawn = &spawnRequest{574- source: spawnSourceSeed,575- name: name,576- gameType: gameType,577- modeTitle: modeTitle,578- returnState: playMenuView,579- exitState: mainMenuView,580- }581- m.state = generatingView582- return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))583-}584-585-func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {586- today := time.Now()587- name := daily.Name(today)588-589- rec, err := m.store.GetDailyGame(name)590- if err != nil {591- log.Printf("failed to check daily game: %v", err)592- return m, nil593- }594- if rec != nil {595- var resumed bool596- m, resumed = m.importAndActivateRecord(*rec)597- if resumed {598- if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, rec); err != nil {599- log.Printf("%v", err)600- }601- }602- return m, nil603- }604-605- spawner, gameType, modeTitle := daily.Mode(today)606- if spawner == nil {607- log.Printf("no daily mode available for %s", today.Format("2006-01-02"))608- return m, nil609- }610-611- rng := daily.RNG(today)612- ctx, jobID := m.beginSpawnContext()613- m.session.spawn = &spawnRequest{614- source: spawnSourceDaily,615- name: name,616- gameType: gameType,617- modeTitle: modeTitle,618- returnState: playMenuView,619- exitState: mainMenuView,620- }621- m.state = generatingView622- return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))623-}624-625-func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) {626- entry, ok := selectedCategoryEntry(m.nav.gameSelectList.SelectedItem())627- if !ok {628- return m, nil629- }630- m.nav.selectedCategory = entry631- m.nav.modeSelectList = ui.InitList(buildModeDisplayItems(entry), entry.Definition.Name+" - Select Mode")632- m.nav.modeSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.nav.modeSelectList)))633- m.state = modeSelectView634- return m, nil635-}636-637-func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {638- item := unwrapModeDisplayItem(m.nav.modeSelectList.SelectedItem())639- mode, ok := item.(registry.ModeEntry)640- if !ok {641- return m, nil642- }643- m.nav.selectedModeTitle = mode.Definition.Title644- ctx, jobID := m.beginSpawnContext()645- m.session.spawn = &spawnRequest{646- source: spawnSourceNormal,647- name: sessionflow.GenerateUniqueName(m.store),648- gameType: m.nav.selectedCategory.Definition.Name,649- modeTitle: m.nav.selectedModeTitle,650- returnState: modeSelectView,651- exitState: mainMenuView,652- }653- m.state = generatingView654- return m, tea.Batch(m.spinner.Tick, spawnCmd(mode.Spawner, ctx, jobID))655-}656-657-func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {658- idx := m.nav.continueTable.Cursor()659- if idx < 0 || idx >= len(m.nav.continueGames) {660- return m, nil661- }662- rec := m.nav.continueGames[idx]663- m, _ = m.importAndActivateRecord(rec)664- return m, nil665-}666-667-func (m model) handleHelpSelectEnter() (tea.Model, tea.Cmd) {668- entry, ok := selectedCategoryEntry(m.nav.helpSelectList.SelectedItem())669- if !ok {670- return m, nil671- }672- m.help.category = entry673- m = m.updateHelpDetailViewport()674- m.state = helpDetailView675- return m, nil676-}677-678-func (m model) handleEscape() (tea.Model, tea.Cmd) {679- switch m.state {680- case playMenuView, optionsMenuView, statsView:681- m.state = mainMenuView682- case seedInputView, gameSelectView, continueView, weeklyView:683- m.state = playMenuView684- case generatingView:685- returnState := m.activeSpawnReturnState()686- m.cancelActiveSpawn()687- m.state = returnState688- case modeSelectView:689- m.state = gameSelectView690- m = m.updateCategoryDetailViewport()691- case helpDetailView:692- m.state = helpSelectView693- case helpSelectView, themeSelectView:182+ case backAction:694183 if m.state == themeSelectView {695184 _ = theme.Apply(m.theme.previous)696185 }697- m.state = optionsMenuView698- }699- return m, nil700-}701-702-func (m model) handleThemeEnter() (tea.Model, tea.Cmd) {703- m.theme.previous = m.cfg.Theme704-705- names := theme.ThemeNames()706- items := make([]list.Item, len(names))707- for i, n := range names {708- desc := "dark theme"709- if n == theme.DefaultThemeName {710- desc = "built-in earth-tone palette"711- } else if t := theme.LookupTheme(n); t != nil && !t.Meta.IsDark {712- desc = "light theme"713- }714- items[i] = ui.MenuItem{ItemTitle: n, Desc: desc}715- }716-717- const maxVisibleItems = 8718- listH := min(m.height, maxVisibleItems*3)719- listW := min(m.width, theme.MaxNameLen+4)720-721- m.theme.list = ui.InitThemeList(items, listW, listH)722- for i, item := range items {723- if mi, ok := item.(ui.MenuItem); ok && mi.ItemTitle == m.theme.previous {724- m.theme.list.Select(i)725- break726- }727- }728- if m.theme.previous == "" {729- m.theme.list.Select(0)730- }731-732- m.state = themeSelectView733- return m, nil734-}735-736-func (m model) handleThemeConfirm() (tea.Model, tea.Cmd) {737- item, ok := m.theme.list.SelectedItem().(ui.MenuItem)738- if !ok {186+ m.state = action.target187+ return m.resizeActiveScreen(), nil188+ case gameSelectEnterAction:189+ return asModel(m.handleGameSelectEnter())190+ case modeSelectEnterAction:191+ return asModel(m.handleModeSelectEnter())192+ case continueEnterAction:193+ return asModel(m.handleContinueEnter())194+ case weeklyShiftAction:195+ m = m.moveWeeklyWeek(action.delta)196+ return m, nil197+ case weeklyEnterAction:198+ return asModel(m.handleWeeklyEnter())199+ case helpSelectEnterAction:200+ return asModel(m.handleHelpSelectEnter())201+ case openThemeSelectAction:202+ return asModel(m.handleThemeEnter())203+ case openHelpSelectAction:204+ m.help.selectList = ui.InitList(gameCategoryItems, "How to Play")205+ listWidth, listHeight := helpSelectListSize(m.width, m.height, m.help.selectList)206+ m.help.selectList.SetSize(listWidth, listHeight)207+ m.state = helpSelectView208+ m = m.clearNotice()209+ return m.resizeActiveScreen(), nil210+ case previewThemeAction:211+ _ = theme.Apply(action.name)212+ ui.UpdateThemeListStyles(&m.theme.list)213+ return m, nil214+ case confirmThemeAction:215+ return asModel(m.handleThemeConfirm())216+ case seedConfirmAction:217+ return asModel(m.handleSeedConfirm())218+ default:739219 return m, nil740220 }741-742- themeName := item.ItemTitle743- if themeName == theme.DefaultThemeName {744- themeName = ""745- }746-747- _ = theme.Apply(item.ItemTitle)748- m.cfg.Theme = themeName749- if err := m.cfg.Save(config.DefaultPath()); err != nil {750- log.Printf("failed to save config: %v", err)751- }752-753- m.state = mainMenuView754- return m, nil755221 }756222 757-func (m model) handleStatsEnter() (tea.Model, tea.Cmd) {758- catStats, err := m.store.GetCategoryStats()759- if err != nil {760- log.Printf("failed to get category stats: %v", err)761- return m, nil762- }763- modeStats, err := m.store.GetModeStats()764- if err != nil {765- log.Printf("failed to get mode stats: %v", err)766- return m, nil767- }768- streakDates, err := m.store.GetDailyStreakDates()769- if err != nil {770- log.Printf("failed to get daily streak dates: %v", err)771- return m, nil772- }773- weekliesCompleted, err := m.store.GetCompletedWeeklyGauntlets()774- if err != nil {775- log.Printf("failed to get weekly gauntlet completions: %v", err)776- return m, nil777- }778- now := time.Now()779- currentYear, currentWeek := now.ISOWeek()780- thisWeekHighestIndex, err := m.store.GetCurrentWeeklyHighestCompletedIndex(currentYear, currentWeek)781- if err != nil {782- log.Printf("failed to get current weekly progress: %v", err)783- return m, nil784- }785- currentDaily := false786- rec, err := m.store.GetDailyGame(daily.Name(now))787- if err != nil {788- log.Printf("failed to check current daily: %v", err)789- } else {790- currentDaily = rec != nil791- }792-793- weights := stats.WeightsFromDefinitions(registry.Definitions())794- m.stats.cards = stats.BuildCards(weights, catStats, modeStats)795- m.stats.profile = stats.BuildProfileBanner(796- catStats,797- modeStats,798- weights,799- streakDates,800- currentDaily,801- weekliesCompleted,802- thisWeekHighestIndex,803- )804-805- statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.stats.cards)806- m.stats.viewport = viewport.New(807- viewport.WithWidth(statsWidth),808- viewport.WithHeight(statsHeight),809- )810- m.stats.viewport.SetContent(ui.RenderStatsCardGrid(m.stats.cards, statsWidth))811- m.state = statsView812- return m, nil223+func asModel(next tea.Model, cmd tea.Cmd) (model, tea.Cmd) {224+ return next.(model), cmd813225 }
M app/update_test.go +43 -22
@@ -128,9 +128,16 @@ func TestGameViewEscapeReturnsToMainMenu(t *testing.T) {
128128 129129 func TestImportAndActivateRecordSuccessFlag(t *testing.T) {130130 unknown := model{state: playMenuView}131- if _, ok := unknown.importAndActivateRecord(store.GameRecord{GameType: "NoSuchGameType"}); ok {131+ nextUnknown, ok := unknown.importAndActivateRecord(store.GameRecord{132+ Name: "broken-save",133+ GameType: "NoSuchGameType",134+ })135+ if ok {132136 t.Fatal("expected unknown game type import to fail")133137 }138+ if nextUnknown.notice.message == "" {139+ t.Fatal("expected failed import to surface a user-visible notice")140+ }134141 135142 loadedGame, err := lightsout.New(3, 3)136143 if err != nil {
@@ -170,6 +177,9 @@ func TestImportAndActivateRecordSuccessFlag(t *testing.T) {
170177 if next.session.game == nil {171178 t.Fatal("expected game to be activated")172179 }180+ if next.notice.message != "" {181+ t.Fatalf("notice = %q, want empty after successful import", next.notice.message)182+ }173183 }174184 175185 func TestHandleSeedConfirmDoesNotResumeStatusWhenImportFails(t *testing.T) {
@@ -183,6 +193,8 @@ func TestHandleSeedConfirmDoesNotResumeStatusWhenImportFails(t *testing.T) {
183193 InitialState: "{}",184194 SaveState: "{}",185195 Status: store.StatusAbandoned,196+ RunKind: store.RunKindSeeded,197+ SeedText: seed,186198 }187199 if err := s.CreateGame(rec); err != nil {188200 t.Fatal(err)
@@ -193,7 +205,7 @@ func TestHandleSeedConfirmDoesNotResumeStatusWhenImportFails(t *testing.T) {
193205 m := model{194206 state: seedInputView,195207 store: s,196- nav: navigationState{seedInput: ti},208+ seed: seedState{input: ti},197209 }198210 199211 next, _ := m.handleSeedConfirm()
@@ -222,27 +234,25 @@ func TestSeedInputSelectorCyclesAndPersistsDefault(t *testing.T) {
222234 223235 m := model{224236 state: seedInputView,225- nav: navigationState{226- seedModeOptions: options,227- seedFocus: seedFocusMode,237+ seed: seedState{238+ modeOptions: options,239+ focus: seedFocusMode,228240 },229241 }230242 231243 next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight})232244 got := next.(model)233245 234- if got.nav.seedModeIndex != 1 {235- t.Fatalf("seedModeIndex = %d, want 1", got.nav.seedModeIndex)246+ if got.seed.modeIndex != 1 {247+ t.Fatalf("seedModeIndex = %d, want 1", got.seed.modeIndex)236248 }237- if got.nav.lastSeedModeKey != options[1].key {238- t.Fatalf("lastSeedModeKey = %q, want %q", got.nav.lastSeedModeKey, options[1].key)249+ if got.seed.lastModeKey != options[1].key {250+ t.Fatalf("lastSeedModeKey = %q, want %q", got.seed.lastModeKey, options[1].key)239251 }240252 241253 reopenedModel, _ := (model{242254 state: playMenuView,243- nav: navigationState{244- lastSeedModeKey: got.nav.lastSeedModeKey,245- },255+ seed: seedState{lastModeKey: got.seed.lastModeKey},246256 }).enterSeedInputView()247257 248258 if reopenedModel.state != seedInputView {
@@ -266,10 +276,10 @@ func TestHandleSeedConfirmUsesSelectedSpecificMode(t *testing.T) {
266276 m := model{267277 state: seedInputView,268278 store: s,269- nav: navigationState{270- seedInput: ti,271- seedModeOptions: options,272- seedModeIndex: 1,279+ seed: seedState{280+ input: ti,281+ modeOptions: options,282+ modeIndex: 1,273283 },274284 }275285
@@ -309,6 +319,7 @@ func TestHandleDailyPuzzleDoesNotResumeStatusWhenImportFails(t *testing.T) {
309319 continue310320 }311321 seen[name] = true322+ day := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location())312323 rec := &store.GameRecord{313324 Name: name,314325 GameType: "NoSuchGameType",
@@ -316,6 +327,8 @@ func TestHandleDailyPuzzleDoesNotResumeStatusWhenImportFails(t *testing.T) {
316327 InitialState: "{}",317328 SaveState: "{}",318329 Status: store.StatusAbandoned,330+ RunKind: store.RunKindDaily,331+ RunDate: &day,319332 }320333 if err := s.CreateGame(rec); err != nil {321334 t.Fatal(err)
@@ -465,6 +478,10 @@ func TestEnterOnSolvedLatestWeeklyCompletesAndQueuesNext(t *testing.T) {
465478 InitialState: "{}",466479 SaveState: "{}",467480 Status: store.StatusInProgress,481+ RunKind: store.RunKindWeekly,482+ WeekYear: year,483+ WeekNumber: weekNumber,484+ WeekIndex: 1,468485 }469486 if err := s.CreateGame(rec); err != nil {470487 t.Fatal(err)
@@ -481,8 +498,8 @@ func TestEnterOnSolvedLatestWeeklyCompletesAndQueuesNext(t *testing.T) {
481498 weeklyAdvance: &weekly.Info{Year: year, Week: weekNumber, Index: 1},482499 completionSaved: false,483500 },484- nav: navigationState{485- weeklyCursor: weekly.StartOfWeek(year, weekNumber, time.Local),501+ weekly: weeklyState{502+ cursor: weekly.StartOfWeek(year, weekNumber, time.Local),486503 },487504 }488505
@@ -518,14 +535,14 @@ func TestMoveWeeklyWeekDoesNotAdvancePastCurrentWeek(t *testing.T) {
518535 519536 m := model{520537 store: s,521- nav: navigationState{522- weeklyCursor: currentCursor,538+ weekly: weeklyState{539+ cursor: currentCursor,523540 },524541 }525542 526543 got := m.moveWeeklyWeek(1)527- if !got.nav.weeklyCursor.Equal(currentCursor) {528- t.Fatalf("weeklyCursor = %v, want %v", got.nav.weeklyCursor, currentCursor)544+ if !got.weekly.cursor.Equal(currentCursor) {545+ t.Fatalf("weeklyCursor = %v, want %v", got.weekly.cursor, currentCursor)529546 }530547 }531548
@@ -541,6 +558,10 @@ func TestCurrentWeeklyMenuIndexTracksNextPlayableSlot(t *testing.T) {
541558 InitialState: "{}",542559 SaveState: "{}",543560 Status: store.StatusCompleted,561+ RunKind: store.RunKindWeekly,562+ WeekYear: year,563+ WeekNumber: weekNumber,564+ WeekIndex: index,544565 }545566 if err := s.CreateGame(rec); err != nil {546567 t.Fatal(err)
M app/view.go +19 -123
@@ -13,114 +13,7 @@ import (
1313 )1414 1515 func (m model) View() tea.View {16- var content string17-18- switch m.state {19- case mainMenuView:20- content = ui.CenterView(m.width, m.height, m.nav.mainMenu.View())21- case playMenuView:22- content = ui.CenterView(m.width, m.height, m.nav.playMenu.ViewAsPanel("Play"))23- case optionsMenuView:24- items := m.nav.optionsMenu.RenderItems() + "\n\n" + ui.DimItemStyle().Render("- Dami")25- panel := ui.Panel("Options", items, "↑/↓ navigate • enter select • esc back")26- content = ui.CenterView(m.width, m.height, panel)27- case seedInputView:28- panel := ui.Panel(29- "Enter Seed",30- m.seedInputBody(),31- "↑/↓ change field • ←/→ game • enter confirm • esc back",32- )33- content = ui.CenterView(m.width, m.height, panel)34- case gameSelectView:35- content = m.gameSelectViewContent()36- case modeSelectView:37- panel := ui.Panel(38- m.nav.selectedCategory.Definition.Name+" — Select Mode",39- m.nav.modeSelectList.View(),40- "↑/↓ navigate • enter select • esc back",41- )42- content = ui.CenterView(m.width, m.height, panel)43- case generatingView:44- s := m.spinner.View() + " Generating puzzle..."45- box := ui.GeneratingFrame().Render(s)46- content = ui.CenterView(m.width, m.height, box)47- case continueView:48- var s string49- if len(m.nav.continueGames) == 0 {50- s = ui.Panel(51- "Saved Games",52- "No saved games yet.",53- "esc back",54- )55- } else {56- footer := "↑/↓ navigate • enter resume • esc back"57- if pg := ui.TablePagination(m.nav.continueTable); pg != "" {58- footer = pg + " " + footer59- }60- s = ui.Panel(61- "Saved Games",62- m.nav.continueTable.View(),63- footer,64- )65- }66- content = ui.CenterView(m.width, m.height, s)67- case weeklyView:68- content = ui.CenterView(m.width, m.height, m.weeklyViewContent())69- case gameView:70- if m.session.game == nil {71- content = ""72- } else {73- gameView := lipgloss.NewStyle().MaxWidth(m.width).Render(m.session.game.View())74- centered := gameView75- if m.debug.enabled {76- debugInfo := lipgloss.NewStyle().MaxWidth(m.width).Render(77- ui.DebugStyle().Render(m.debug.info),78- )79- centered = lipgloss.JoinVertical(lipgloss.Center, gameView, debugInfo)80- }81- content = ui.CenterView(m.width, m.height, centered)82- }83- case helpSelectView:84- panel := ui.Panel(85- "How to Play",86- m.nav.helpSelectList.View(),87- "↑/↓ navigate • enter select • esc back",88- )89- content = ui.CenterView(m.width, m.height, panel)90- case helpDetailView:91- panel := ui.Panel(92- m.help.category.Definition.Name+" — Guide",93- m.help.viewport.View(),94- "↑/↓ scroll • esc back",95- )96- content = ui.CenterView(m.width, m.height, panel)97- case themeSelectView:98- content = m.themeSelectViewContent()99- case statsView:100- statsWidth, _ := statsViewportSize(m.width, m.height, m.stats.cards)101- var statsBody string102- if len(m.stats.cards) == 0 {103- statsBody = m.stats.viewport.View()104- } else {105- banner := ui.RenderStatsBanner(m.stats.profile, statsWidth)106- statsBody = lipgloss.JoinVertical(lipgloss.Left,107- banner,108- "",109- m.stats.viewport.View(),110- )111- }112- statsBody = lipgloss.NewStyle().Width(statsWidth).Render(statsBody)113- panel := ui.Panel(114- "Stats",115- statsBody,116- "↑/↓ scroll • esc back",117- )118- content = ui.CenterView(m.width, m.height, panel)119- default:120- content = fmt.Sprintf("unknown state: %d", m.state)121- }122-123- v := tea.NewView(content)16+ v := tea.NewView(m.viewContent())12417 v.AltScreen = true12518 if m.state == gameView {12619 v.MouseMode = tea.MouseModeCellMotion
@@ -129,6 +22,18 @@ func (m model) View() tea.View {
12922 return v13023 }13124 25+func (m model) viewContent() string {26+ if m.state == gameView {27+ return m.renderGameView()28+ }29+30+ screen := m.activeScreen()31+ if screen == nil {32+ return fmt.Sprintf("unknown state: %d", m.state)33+ }34+ return screen.View(m.notice)35+}36+13237 func (m model) gameSelectViewContent() string {13338 metrics := categoryPickerSize(m.width, m.height)13439
@@ -156,7 +61,7 @@ func (m model) gameSelectViewContent() string {
15661 15762 panel := ui.Panel(15863 "Select Category",159- body,64+ m.appendNotice(body),16065 "↑/↓ navigate • / filter • enter select • esc back",16166 )16267 return ui.CenterView(m.width, m.height, panel)
@@ -164,7 +69,7 @@ func (m model) gameSelectViewContent() string {
16469 16570 func (m model) weeklyViewContent() string {16671 title := "Weekly Gauntlet — " + m.weeklyPanelTitle()167- if len(m.nav.weeklyRows) == 0 {72+ if len(m.weekly.rows) == 0 {16873 body := "No completed puzzles for this week yet."16974 if m.isCurrentWeeklySelection() {17075 body = "No weekly puzzles are available."
@@ -180,11 +85,11 @@ func (m model) weeklyViewContent() string {
18085 if !m.isCurrentWeeklySelection() {18186 footer = "←/→ week • enter review • esc back"18287 }183- if pg := ui.TablePagination(m.nav.weeklyTable); pg != "" {88+ if pg := ui.TablePagination(m.weekly.table); pg != "" {18489 footer = pg + " " + footer18590 }18691 187- description := m.nav.weeklyTable.View()92+ description := m.weekly.table.View()18893 if !m.isCurrentWeeklySelection() {18994 description = lipgloss.JoinVertical(19095 lipgloss.Left,
@@ -274,27 +179,20 @@ func renderModeList(entry registry.Entry, width int) string {
274179 return strings.Join(lines, "\n")275180 }276181 277-// themeSelectViewContent renders the theme picker as a side-by-side layout:278-// theme list on the left, color preview panel on the right.279182 func (m model) themeSelectViewContent() string {280183 p := theme.Current()281184 282- // Determine selected theme name for the preview.283185 themeName := theme.DefaultThemeName284186 if item, ok := m.theme.list.SelectedItem().(ui.MenuItem); ok {285187 themeName = item.ItemTitle286188 }287189 288- // Compute available inner height for the panel content.289- // Panel chrome: border (2) + padding (2) + title (1) + blank (1) + footer (1) + blank (1) = 8290190 const panelChrome = 8291191 innerH := m.height - panelChrome292192 innerH = max(innerH, 10)293193 294- // Left side: theme list.295194 listView := m.theme.list.View()296195 297- // Right side: color preview.298196 previewBorder := lipgloss.NewStyle().299197 Border(lipgloss.RoundedBorder()).300198 BorderForeground(p.Border).
@@ -303,13 +201,11 @@ func (m model) themeSelectViewContent() string {
303201 preview := theme.PreviewPanel(themeName, innerH-4)304202 previewBox := previewBorder.Render(preview)305203 306- // Join side by side with a gap.307- spacer := " "308- body := lipgloss.JoinHorizontal(lipgloss.Top, listView, spacer, previewBox)204+ body := lipgloss.JoinHorizontal(lipgloss.Top, listView, " ", previewBox)309205 310206 panel := ui.Panel(311207 "Select Theme",312- body,208+ m.appendNotice(body),313209 "↑/↓ navigate • / filter • enter select • esc back",314210 )315211 return ui.CenterView(m.width, m.height, panel)
M app/view_test.go +44 -0
@@ -1,9 +1,12 @@
11 package app22 33 import (4+ "strings"45 "testing"56 67 tea "charm.land/bubbletea/v2"8+ "github.com/FelineStateMachine/puzzletea/store"9+ "github.com/FelineStateMachine/puzzletea/ui"710 )811 912 func TestGameViewRequestsMouseCellMotion(t *testing.T) {
@@ -20,3 +23,44 @@ func TestGameViewRequestsMouseCellMotion(t *testing.T) {
2023 t.Fatal("expected keyboard event type reporting to remain enabled")2124 }2225 }26+27+func TestPlayMenuViewIncludesNotice(t *testing.T) {28+ m := model{29+ state: playMenuView,30+ width: 80,31+ height: 24,32+ nav: navigationState{33+ playMenu: ui.NewMainMenu(nil),34+ },35+ notice: noticeState{level: noticeLevelError, message: "Could not load puzzle"},36+ }37+38+ if got := m.viewContent(); !strings.Contains(got, "Could not load puzzle") {39+ t.Fatalf("viewContent() missing notice, got %q", got)40+ }41+}42+43+func TestContinueViewStaysAtNaturalTableWidth(t *testing.T) {44+ s := openAppTestStore(t)45+ if err := s.CreateGame(&store.GameRecord{46+ Name: "shadow-trail",47+ GameType: "Sudoku",48+ Mode: "Medium",49+ Status: store.StatusInProgress,50+ RunKind: store.RunKindNormal,51+ }); err != nil {52+ t.Fatal(err)53+ }54+ table, games := ui.InitContinueTable(s, 30)55+ m := model{56+ state: continueView,57+ width: 140,58+ height: 30,59+ cont: continueState{table: table, games: games},60+ }61+ m = m.resizeActiveScreen()62+63+ if got, want := m.cont.table.Width(), ui.ContinueTableWidth(); got != want {64+ t.Fatalf("continue table width = %d, want %d", got, want)65+ }66+}
M app/weekly.go +26 -27
@@ -1,7 +1,6 @@
11 package app22 33 import (4- "log"54 "sort"65 "strconv"76 "time"
@@ -40,7 +39,7 @@ func (r weeklyRow) tableRow() table.Row {
4039 4140 func (m model) enterWeeklyView() (tea.Model, tea.Cmd) {4241 current := weekly.Current(time.Now())43- m.nav.weeklyCursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)42+ m.weekly.cursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)4443 m = m.refreshWeeklyBrowser()4544 m.state = weeklyView4645 return m, nil
@@ -50,21 +49,23 @@ func (m model) refreshWeeklyBrowser() model {
5049 year, weekNumber := m.selectedWeek()5150 games, err := m.store.ListWeeklyGames(year, weekNumber)5251 if err != nil {53- log.Printf("failed to list weekly games: %v", err)52+ m = m.setErrorf("Could not load weekly puzzles: %v", err)5453 games = nil54+ } else {55+ m = m.clearNotice()5556 }5657 5758 if m.isCurrentWeeklySelection() {58- m.nav.weeklyRows = buildCurrentWeeklyRows(year, weekNumber, games)59+ m.weekly.rows = buildCurrentWeeklyRows(year, weekNumber, games)5960 } else {60- m.nav.weeklyRows = buildReviewWeeklyRows(games)61+ m.weekly.rows = buildReviewWeeklyRows(games)6162 }6263 63- rows := make([]table.Row, 0, len(m.nav.weeklyRows))64- for _, row := range m.nav.weeklyRows {64+ rows := make([]table.Row, 0, len(m.weekly.rows))65+ for _, row := range m.weekly.rows {6566 rows = append(rows, row.tableRow())6667 }67- m.nav.weeklyTable = ui.InitWeeklyTable(rows, m.height)68+ m.weekly.table = ui.InitWeeklyTable(rows, m.height)6869 return m6970 }7071
@@ -75,7 +76,7 @@ func (m model) isCurrentWeeklySelection() bool {
7576 }7677 7778 func (m model) selectedWeek() (int, int) {78- cursor := m.nav.weeklyCursor79+ cursor := m.weekly.cursor7980 if cursor.IsZero() {8081 current := weekly.Current(time.Now())8182 cursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)
@@ -89,29 +90,29 @@ func (m model) weeklyPanelTitle() string {
8990 }9091 9192 func (m model) moveWeeklyWeek(delta int) model {92- if m.nav.weeklyCursor.IsZero() {93+ if m.weekly.cursor.IsZero() {9394 current := weekly.Current(time.Now())94- m.nav.weeklyCursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)95+ m.weekly.cursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)9596 }9697 97- nextCursor := weekly.AddWeeks(m.nav.weeklyCursor, delta)98+ nextCursor := weekly.AddWeeks(m.weekly.cursor, delta)9899 current := weekly.Current(time.Now())99100 currentCursor := weekly.StartOfWeek(current.Year, current.Week, time.Local)100101 if nextCursor.After(currentCursor) {101102 nextCursor = currentCursor102103 }103104 104- m.nav.weeklyCursor = nextCursor105+ m.weekly.cursor = nextCursor105106 return m.refreshWeeklyBrowser()106107 }107108 108109 func (m model) handleWeeklyEnter() (tea.Model, tea.Cmd) {109- idx := m.nav.weeklyTable.Cursor()110- if idx < 0 || idx >= len(m.nav.weeklyRows) {110+ idx := m.weekly.table.Cursor()111+ if idx < 0 || idx >= len(m.weekly.rows) {111112 return m, nil112113 }113114 114- return m.openWeeklyRow(m.nav.weeklyRows[idx])115+ return m.openWeeklyRow(m.weekly.rows[idx])115116 }116117 117118 func (m model) openWeeklyRow(row weeklyRow) (tea.Model, tea.Cmd) {
@@ -141,7 +142,7 @@ func (m model) openWeeklyRow(row weeklyRow) (tea.Model, tea.Cmd) {
141142 m, resumed = m.importAndActivateRecordWithOptions(*row.Record, options)142143 if resumed {143144 if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, row.Record); err != nil {144- log.Printf("%v", err)145+ m = m.setErrorf("%v", err)145146 }146147 }147148 return m, nil
@@ -153,24 +154,22 @@ func (m model) openWeeklyRow(row weeklyRow) (tea.Model, tea.Cmd) {
153154 154155 spawner, gameType, modeTitle := weekly.Mode(info.Year, info.Week, info.Index)155156 if spawner == nil {156- log.Printf("no weekly mode available for %s", row.Name)157- return m, nil157+ return m.setErrorf("No weekly puzzle is configured for %s", row.Name), nil158158 }159159 160160 rng := weekly.RNG(info.Year, info.Week, info.Index)161- ctx, jobID := m.beginSpawnContext()162161 infoCopy := info163- m.session.spawn = &spawnRequest{162+ cmd := newSessionController(&m).startSeededSpawn(spawner, rng, spawnRequest{164163 source: spawnSourceWeekly,165164 name: row.Name,166165 gameType: gameType,167166 modeTitle: modeTitle,167+ run: store.WeeklyRunMetadata(info.Year, info.Week, info.Index),168168 returnState: weeklyView,169169 exitState: weeklyView,170170 weeklyInfo: &infoCopy,171- }172- m.state = generatingView173- return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))171+ })172+ return m, cmd174173 }175174 176175 func (m model) advanceSolvedWeekly() (model, tea.Cmd, bool) {
@@ -180,14 +179,14 @@ func (m model) advanceSolvedWeekly() (model, tea.Cmd, bool) {
180179 181180 info := *m.session.weeklyAdvance182181 m = m.persistCompletionIfSolved()183- m.nav.weeklyCursor = weekly.StartOfWeek(info.Year, info.Week, time.Local)182+ m.weekly.cursor = weekly.StartOfWeek(info.Year, info.Week, time.Local)184183 m = m.refreshWeeklyBrowser()185184 m.state = weeklyView186- if len(m.nav.weeklyRows) == 0 || !m.nav.weeklyRows[0].Playable {185+ if len(m.weekly.rows) == 0 || !m.weekly.rows[0].Playable {187186 return m, nil, true188187 }189188 190- next, cmd := m.openWeeklyRow(m.nav.weeklyRows[0])189+ next, cmd := m.openWeeklyRow(m.weekly.rows[0])191190 return next.(model), cmd, true192191 }193192
M architecture_test.go +117 -15
@@ -3,7 +3,10 @@ package main_test
33 import (44 "go/parser"55 "go/token"6+ "os"67 "path/filepath"8+ "runtime"9+ "slices"710 "strings"811 "testing"912 )
@@ -35,25 +38,55 @@ func TestStorePackageDoesNotImportSchedulePackages(t *testing.T) {
3538 })3639 }3740 41+func TestGamePackageDoesNotImportPDFExport(t *testing.T) {42+ assertPackageDoesNotImport(t, "game", []string{43+ "github.com/FelineStateMachine/puzzletea/pdfexport",44+ })45+}46+3847 func TestCatalogPackageDoesNotImportConcreteGames(t *testing.T) {39- assertPackageDoesNotImport(t, "catalog", []string{40- "github.com/FelineStateMachine/puzzletea/fillomino",41- "github.com/FelineStateMachine/puzzletea/hashiwokakero",42- "github.com/FelineStateMachine/puzzletea/hitori",43- "github.com/FelineStateMachine/puzzletea/lightsout",44- "github.com/FelineStateMachine/puzzletea/nonogram",45- "github.com/FelineStateMachine/puzzletea/nurikabe",46- "github.com/FelineStateMachine/puzzletea/rippleeffect",47- "github.com/FelineStateMachine/puzzletea/shikaku",48- "github.com/FelineStateMachine/puzzletea/spellpuzzle",49- "github.com/FelineStateMachine/puzzletea/sudoku",50- "github.com/FelineStateMachine/puzzletea/sudokurgb",51- "github.com/FelineStateMachine/puzzletea/takuzu",52- "github.com/FelineStateMachine/puzzletea/takuzuplus",53- "github.com/FelineStateMachine/puzzletea/wordsearch",48+ assertPackageDoesNotImport(t, "catalog", concreteGameImportPaths(t))49+}50+51+func TestBuiltinPrintDoesNotImportConcreteGames(t *testing.T) {52+ assertPackageDoesNotImport(t, "builtinprint", concreteGameImportPaths(t))53+}54+55+func TestSessionPackageDoesNotUseNameDerivedRunMetadata(t *testing.T) {56+ assertFilesDoNotContain(t, "session", []string{57+ "RunKindForName(",58+ "RunDateForName(",59+ "SeedTextForName(",60+ "WeeklyIdentityForName(",5461 })5562 }5663 64+func TestStoreCreateGameDoesNotUseNameDerivedRunMetadata(t *testing.T) {65+ assertFileDoesNotContain(t, filepath.Join("store", "store.go"), []string{66+ "RunKindForName(",67+ "RunDateForName(",68+ "SeedTextForName(",69+ "WeeklyIdentityForName(",70+ })71+}72+73+func TestPDFExportPackageDoesNotContainMarkdownParseEntrypoints(t *testing.T) {74+ assertFilesDoNotContain(t, "pdfexport", []string{75+ "func ParseMarkdown(",76+ "func ParseFile(",77+ "func ParseFiles(",78+ "func lookupMarkdownBodyParser(",79+ })80+}81+82+func TestGamePackageDoesNotExposeLegacyPrintFacade(t *testing.T) {83+ if _, err := os.Stat(filepath.Join("game", "print_adapter.go")); err == nil {84+ t.Fatal("game/print_adapter.go should not exist")85+ } else if !os.IsNotExist(err) {86+ t.Fatalf("stat game/print_adapter.go: %v", err)87+ }88+}89+5790 func assertPackageDoesNotImport(t *testing.T, dir string, forbidden []string) {5891 t.Helper()5992
@@ -81,3 +114,72 @@ func assertPackageDoesNotImport(t *testing.T, dir string, forbidden []string) {
81114 }82115 }83116 }117+118+func concreteGameImportPaths(t testing.TB) []string {119+ t.Helper()120+121+ pattern := filepath.Join(repoRoot(t), "*", "gamemode.go")122+ matches, err := filepath.Glob(pattern)123+ if err != nil {124+ t.Fatalf("glob concrete game packages: %v", err)125+ }126+127+ importPaths := make([]string, 0, len(matches))128+ for _, match := range matches {129+ dir := filepath.Base(filepath.Dir(match))130+ importPaths = append(importPaths, "github.com/FelineStateMachine/puzzletea/"+dir)131+ }132+ slices.Sort(importPaths)133+ return importPaths134+}135+136+func repoRoot(t testing.TB) string {137+ t.Helper()138+139+ _, path, _, ok := runtime.Caller(0)140+ if !ok {141+ t.Fatal("runtime.Caller failed")142+ }143+ return filepath.Clean(filepath.Dir(path))144+}145+146+func assertFileDoesNotContain(t *testing.T, path string, forbidden []string) {147+ t.Helper()148+149+ data, err := os.ReadFile(path)150+ if err != nil {151+ t.Fatalf("read %s: %v", path, err)152+ }153+ content := string(data)154+ for _, pattern := range forbidden {155+ if strings.Contains(content, pattern) {156+ t.Fatalf("%s contains forbidden pattern %q", path, pattern)157+ }158+ }159+}160+161+func assertFilesDoNotContain(t *testing.T, dir string, forbidden []string) {162+ t.Helper()163+164+ files, err := filepath.Glob(filepath.Join(dir, "*.go"))165+ if err != nil {166+ t.Fatalf("glob %s: %v", dir, err)167+ }168+169+ for _, path := range files {170+ if strings.HasSuffix(path, "_test.go") {171+ continue172+ }173+174+ data, err := os.ReadFile(path)175+ if err != nil {176+ t.Fatalf("read %s: %v", path, err)177+ }178+ content := string(data)179+ for _, pattern := range forbidden {180+ if strings.Contains(content, pattern) {181+ t.Fatalf("%s contains forbidden pattern %q", path, pattern)182+ }183+ }184+ }185+}
A builtinprint/register.go +23 -0
A builtinprint/register_test.go +22 -0
M catalog/catalog.go +2 -0
M cmd/config_test.go +1 -0
M cmd/export_pdf.go +4 -4
M cmd/export_pdf_test.go +7 -3
M cmd/new.go +2 -2
M cmd/new_export.go +4 -1
M cmd/new_export_test.go +5 -2
M daily/daily.go +4 -34
@@ -8,29 +8,16 @@ import (
88 "github.com/FelineStateMachine/puzzletea/game"99 "github.com/FelineStateMachine/puzzletea/namegen"1010 "github.com/FelineStateMachine/puzzletea/registry"11+ "github.com/FelineStateMachine/puzzletea/schedule"1112 )1213 13-// Entry pairs a SeededSpawner with metadata for the eligible daily pool.14-type Entry struct {15- Spawner game.SeededSpawner16- GameType string17- Mode string18-}14+type Entry = schedule.Entry1915 2016 // eligibleModes is the flattened pool built from each package's DailyModes.2117 var eligibleModes = buildEligibleModes()2218 2319 func buildEligibleModes() []Entry {24- registryEntries := registry.DailyEntries()25- entries := make([]Entry, 0, len(registryEntries))26- for _, entry := range registryEntries {27- entries = append(entries, Entry{28- Spawner: entry.Spawner,29- GameType: entry.GameType,30- Mode: entry.Mode,31- })32- }33- return entries20+ return schedule.BuildEligibleModes(registry.DailyEntries())3421 }3522 3623 // Seed returns a deterministic uint64 seed derived from the date.
@@ -73,24 +60,7 @@ func Mode(date time.Time) (game.SeededSpawner, string, string) {
7360 }7461 7562 dateStr := date.Format("2006-01-02")76-77- var best Entry78- var bestHash uint6479- found := false80- for _, entry := range eligibleModes {81- h := fnv.New64a()82- h.Write([]byte(dateStr))83- h.Write([]byte{0})84- h.Write([]byte(entry.GameType))85- h.Write([]byte{0})86- h.Write([]byte(entry.Mode))87- score := h.Sum64()88- if !found || score > bestHash {89- bestHash = score90- best = entry91- found = true92- }93- }63+ best, found := schedule.SelectBySeed(dateStr, eligibleModes)9464 if !found {9565 return nil, "", ""9666 }
M daily/daily_test.go +0 -49
@@ -132,55 +132,6 @@ func TestModeDeterministic(t *testing.T) {
132132 }133133 }134134 135-func TestModeStableOnPoolChange(t *testing.T) {136- // Verify the core property of rendezvous hashing: for a given date,137- // the selected entry depends only on the (date, gameType, mode) triple,138- // not on the total number of entries in the pool.139- //140- // We test this by recording the mode for a set of dates, then adding141- // a synthetic entry to eligibleModes and confirming that dates which142- // did NOT select the new entry still return the same mode as before.143- dates := make([]time.Time, 30)144- for i := range dates {145- dates[i] = time.Date(2026, 1, 1+i, 0, 0, 0, 0, time.UTC)146- }147-148- // Record original selections.149- type selection struct {150- gameType string151- mode string152- }153- original := make([]selection, len(dates))154- for i, d := range dates {155- _, gt, m := Mode(d)156- original[i] = selection{gt, m}157- }158-159- // Temporarily add a synthetic entry.160- synth := Entry{161- Spawner: eligibleModes[0].Spawner,162- GameType: "SyntheticGame",163- Mode: "SyntheticMode",164- }165- eligibleModes = append(eligibleModes, synth)166- defer func() {167- eligibleModes = eligibleModes[:len(eligibleModes)-1]168- }()169-170- for i, d := range dates {171- _, gt, m := Mode(d)172- if gt == "SyntheticGame" && m == "SyntheticMode" {173- // This date was "stolen" by the new entry — expected for some dates.174- continue175- }176- if gt != original[i].gameType || m != original[i].mode {177- t.Errorf("date %s: selection changed from (%q,%q) to (%q,%q) after adding unrelated entry",178- d.Format("2006-01-02"),179- original[i].gameType, original[i].mode, gt, m)180- }181- }182-}183-184135 // --- EligibleModes pool (P1) ---185136 186137 func TestEligibleModesNotEmpty(t *testing.T) {
M fillomino/README.md +26 -1
R fillomino/Export.go → fillomino/export.go +0 -0

No content changes.

R fillomino/Gamemode.go → fillomino/gamemode.go +19 -16
@@ -4,8 +4,9 @@ import (
44 _ "embed"55 "math/rand/v2"66 7- "charm.land/bubbles/v2/list"87 "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/FelineStateMachine/puzzletea/gameentry"9+ "github.com/FelineStateMachine/puzzletea/puzzle"910 )1011 1112 //go:embed help.md
@@ -49,7 +50,7 @@ func (m Mode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {
4950 return New(m, puzzle)5051 }5152 52-var Modes = []list.Item{53+var Modes = []game.Mode{5354 NewMode("Mini 5x5", "Small board with generous clues.", 5, 5, 0.70),5455 NewMode("Easy 6x6", "Compact board with simple regions.", 6, 6, 0.66),5556 NewMode("Medium 8x8", "Balanced deduction and region growth.", 8, 7, 0.60),
@@ -57,18 +58,20 @@ var Modes = []list.Item{
5758 NewMode("Expert 12x12", "Wide board with long deduction chains.", 12, 9, 0.52),5859 }5960 60-var DailyModes = []list.Item{61- Modes[1],62- Modes[2],63- Modes[3],64-}61+var ModeDefinitions = gameentry.BuildModeDefs(Modes)6562 66-var Definition = game.Definition{67- Name: "Fillomino",68- Description: "Grow the numbered regions to their exact sizes.",69- Aliases: []string{"polyomino", "regions"},70- Modes: Modes,71- DailyModes: DailyModes,72- Help: HelpContent,73- Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },74-}63+var Definition = puzzle.NewDefinition(puzzle.DefinitionSpec{64+ Name: "Fillomino",65+ Description: "Grow the numbered regions to their exact sizes.",66+ Aliases: []string{"polyomino", "regions"},67+ Modes: ModeDefinitions,68+ DailyModeIDs: puzzle.SelectModeIDsByIndex(ModeDefinitions, 1, 2, 3),69+})70+71+var Entry = gameentry.NewEntry(gameentry.EntrySpec{72+ Definition: Definition,73+ Help: HelpContent,74+ Import: game.AdaptImport(ImportModel),75+ Modes: Modes,76+ Print: PDFPrintAdapter,77+})
M fillomino/help.md +2 -1
R fillomino/Model.go → fillomino/model.go +0 -0

No content changes.

R fillomino/PrintAdapter.go → fillomino/print_adapter.go +2 -4
M game/game_test.go +0 -37
M game/gamer.go +5 -35
@@ -4,11 +4,10 @@ package game
44 import (55 "context"66 "math/rand/v2"7- "strings"87 98 "charm.land/bubbles/v2/key"10- "charm.land/bubbles/v2/list"119 tea "charm.land/bubbletea/v2"10+ "github.com/FelineStateMachine/puzzletea/puzzle"1211 )1312 1413 // Gamer is the interface that an active game instance must implement.
@@ -76,45 +75,16 @@ func (b BaseMode) Title() string { return b.title }
7675 func (b BaseMode) Description() string { return b.description }7776 func (b BaseMode) FilterValue() string { return b.title + " " + b.description }7877 79-// Category groups related game modes under a heading in the menu.80-type Category struct {81- Name string82- Desc string83- Modes []list.Item84- Help string // embedded help.md content rendered in "How to Play"85-}86-87-func (c Category) Title() string { return c.Name }88-func (c Category) Description() string { return c.Desc }89-func (c Category) FilterValue() string { return c.Name }90-91-// Definition is the package-level metadata for a puzzle game.92-type Definition struct {93- Name string94- Description string95- Aliases []string96- Modes []list.Item97- DailyModes []list.Item98- Help string99- Import func([]byte) (Gamer, error)100-}101-102-func (d Definition) Category() Category {103- return Category{104- Name: d.Name,105- Desc: d.Description,106- Modes: d.Modes,107- Help: d.Help,78+func AdaptImport[T Gamer](fn func([]byte) (T, error)) func([]byte) (Gamer, error) {79+ return func(data []byte) (Gamer, error) {80+ return fn(data)10881 }10982 }11083 11184 // NormalizeName lowercases and replaces hyphens/underscores with spaces for11285 // fuzzy matching of CLI arguments to game names and aliases.11386 func NormalizeName(s string) string {114- s = strings.ToLower(s)115- s = strings.ReplaceAll(s, "-", " ")116- s = strings.ReplaceAll(s, "_", " ")117- return strings.TrimSpace(s)87+ return puzzle.NormalizeName(s)11888 }11989 12090 // SpawnCompleteMsg is sent when an async Spawn() call finishes.
D game/print_adapter.go +0 -21
D game/print_adapter_test.go +0 -55
@@ -1,55 +0,0 @@
1-package game2-3-import (4- "testing"5-6- "codeberg.org/go-pdf/fpdf"7-)8-9-type testPrintAdapter struct {10- canonical string11- aliases []string12-}13-14-func (a testPrintAdapter) CanonicalGameType() string { return a.canonical }15-func (a testPrintAdapter) Aliases() []string { return a.aliases }16-func (a testPrintAdapter) BuildPDFPayload([]byte) (any, error) { return nil, nil }17-func (a testPrintAdapter) RenderPDFBody(*fpdf.Fpdf, any) error { return nil }18-19-func TestPrintAdapterRegistryLookupAndAliases(t *testing.T) {20- adapter := testPrintAdapter{21- canonical: "Test Word Search",22- aliases: []string{"testwordsearch", "test-word-search"},23- }24- RegisterPrintAdapter(adapter)25-26- if !HasPrintAdapter("Test Word Search") {27- t.Fatal("expected canonical lookup to be supported")28- }29- if !HasPrintAdapter("test_word_search") {30- t.Fatal("expected underscore alias lookup to be supported")31- }32- if !HasPrintAdapter("testwordsearch") {33- t.Fatal("expected compact alias lookup to be supported")34- }35- if HasPrintAdapter("lights out") {36- t.Fatal("expected unknown type to be unsupported")37- }38-}39-40-func TestRegisterPrintAdapterSkipsBlankCanonical(t *testing.T) {41- RegisterPrintAdapter(testPrintAdapter{canonical: " "})42- if HasPrintAdapter("should-not-exist") {43- t.Fatal("blank canonical adapter should not create lookups")44- }45-}46-47-func TestIsNilPrintPayload(t *testing.T) {48- var ptr *int49- if !IsNilPrintPayload(ptr) {50- t.Fatal("expected typed nil pointer to be treated as nil payload")51- }52- if IsNilPrintPayload(5) {53- t.Fatal("expected concrete payload to be non-nil")54- }55-}
A gameentry/gameentry.go +105 -0
@@ -0,0 +1,105 @@
1+// Package gameentry builds concrete runtime game entries from puzzle definitions2+// and runtime mode implementations.3+package gameentry4+5+import (6+ "fmt"7+8+ "github.com/FelineStateMachine/puzzletea/game"9+ "github.com/FelineStateMachine/puzzletea/pdfexport"10+ "github.com/FelineStateMachine/puzzletea/puzzle"11+)12+13+type ModeEntry struct {14+ Definition puzzle.ModeDef15+ Spawner game.Spawner16+ Seeded game.SeededSpawner17+}18+19+type Entry struct {20+ Definition puzzle.Definition21+ Help string22+ Import func([]byte) (game.Gamer, error)23+ Modes []ModeEntry24+ Print pdfexport.PrintAdapter25+}26+27+type EntrySpec struct {28+ Definition puzzle.Definition29+ Help string30+ Import func([]byte) (game.Gamer, error)31+ Modes []game.Mode32+ Print pdfexport.PrintAdapter33+}34+35+func BuildModeDefs(modes []game.Mode) []puzzle.ModeDef {36+ defs := make([]puzzle.ModeDef, 0, len(modes))37+ for _, mode := range modes {38+ def := puzzle.NewModeDef(puzzle.ModeSpec{39+ Title: mode.Title(),40+ Description: mode.Description(),41+ })42+ if _, ok := mode.(game.SeededSpawner); ok {43+ def.Seeded = true44+ }45+ defs = append(defs, def)46+ }47+ return defs48+}49+50+func NewEntry(spec EntrySpec) Entry {51+ if len(spec.Definition.Modes) != len(spec.Modes) {52+ panic(fmt.Sprintf(53+ "gameentry: definition %q has %d mode definitions but %d runtime modes",54+ spec.Definition.Name,55+ len(spec.Definition.Modes),56+ len(spec.Modes),57+ ))58+ }59+60+ entries := make([]ModeEntry, 0, len(spec.Modes))61+ for i, mode := range spec.Modes {62+ spawner, ok := mode.(game.Spawner)63+ if !ok {64+ panic(fmt.Sprintf(65+ "gameentry: definition %q mode %q does not implement game.Spawner",66+ spec.Definition.Name,67+ mode.Title(),68+ ))69+ }70+71+ modeDef := spec.Definition.Modes[i]72+ if modeDef.Title != mode.Title() || modeDef.Description != mode.Description() {73+ panic(fmt.Sprintf(74+ "gameentry: definition %q mode %d metadata does not match runtime mode %q",75+ spec.Definition.Name,76+ i,77+ mode.Title(),78+ ))79+ }80+81+ entry := ModeEntry{82+ Definition: modeDef,83+ Spawner: spawner,84+ }85+ if seeded, ok := mode.(game.SeededSpawner); ok {86+ entry.Seeded = seeded87+ }88+ if entry.Definition.Seeded != (entry.Seeded != nil) {89+ panic(fmt.Sprintf(90+ "gameentry: definition %q mode %q seeded flag does not match runtime mode",91+ spec.Definition.Name,92+ modeDef.Title,93+ ))94+ }95+ entries = append(entries, entry)96+ }97+98+ return Entry{99+ Definition: spec.Definition,100+ Help: spec.Help,101+ Import: spec.Import,102+ Modes: entries,103+ Print: spec.Print,104+ }105+}
A gameentry/gameentry_test.go +75 -0
@@ -0,0 +1,75 @@
1+package gameentry2+3+import (4+ "errors"5+ "math/rand/v2"6+ "testing"7+8+ "github.com/FelineStateMachine/puzzletea/game"9+ "github.com/FelineStateMachine/puzzletea/puzzle"10+)11+12+type stubMode struct {13+ game.BaseMode14+}15+16+func (m stubMode) Spawn() (game.Gamer, error) {17+ return nil, errors.New("not implemented")18+}19+20+type seededStubMode struct {21+ stubMode22+}23+24+func (m seededStubMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {25+ return nil, errors.New("not implemented")26+}27+28+func TestBuildModeDefsMarksSeededModes(t *testing.T) {29+ modes := []game.Mode{30+ stubMode{BaseMode: game.NewBaseMode("Beginner", "Easy board")},31+ seededStubMode{stubMode{BaseMode: game.NewBaseMode("Expert", "Hard board")}},32+ }33+34+ defs := BuildModeDefs(modes)35+ if len(defs) != 2 {36+ t.Fatalf("len(BuildModeDefs) = %d, want 2", len(defs))37+ }38+ if defs[0].Seeded {39+ t.Fatal("defs[0].Seeded = true, want false")40+ }41+ if !defs[1].Seeded {42+ t.Fatal("defs[1].Seeded = false, want true")43+ }44+}45+46+func TestNewEntryKeepsMetadataAndRuntimeModesAligned(t *testing.T) {47+ modes := []game.Mode{48+ seededStubMode{stubMode{BaseMode: game.NewBaseMode("Medium", "Balanced board")}},49+ }50+ definition := puzzle.NewDefinition(puzzle.DefinitionSpec{51+ Name: "Sudoku",52+ Modes: BuildModeDefs(modes),53+ DailyModeIDs: []puzzle.ModeID{"medium"},54+ })55+56+ entry := NewEntry(EntrySpec{57+ Definition: definition,58+ Help: "Rules",59+ Import: func([]byte) (game.Gamer, error) { return nil, nil },60+ Modes: modes,61+ })62+63+ if got, want := entry.Definition.Name, "Sudoku"; got != want {64+ t.Fatalf("Definition.Name = %q, want %q", got, want)65+ }66+ if got, want := len(entry.Modes), 1; got != want {67+ t.Fatalf("len(Modes) = %d, want %d", got, want)68+ }69+ if entry.Modes[0].Seeded == nil {70+ t.Fatal("Modes[0].Seeded = nil, want seeded spawner")71+ }72+ if entry.Print != nil {73+ t.Fatal("Print = non-nil, want nil")74+ }75+}
M hashiwokakero/README.md +1 -1
R hashiwokakero/Export.go → hashiwokakero/export.go +0 -0

No content changes.

R hashiwokakero/Gamemode.go → hashiwokakero/gamemode.go +19 -15
@@ -4,8 +4,9 @@ import (
44 _ "embed"55 "math/rand/v2"66 7- "charm.land/bubbles/v2/list"87 "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/FelineStateMachine/puzzletea/gameentry"9+ "github.com/FelineStateMachine/puzzletea/puzzle"910 )1011 1112 //go:embed help.md
@@ -51,7 +52,7 @@ func (h HashiMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {
5152 return New(h, puzzle), nil5253 }5354 54-var Modes = []list.Item{55+var Modes = []game.Mode{5556 NewMode("Easy 7x7", "7x7 grid with 8-10 islands.", 7, 7, 8, 10),5657 NewMode("Medium 7x7", "7x7 grid with 12-15 islands.", 7, 7, 12, 15),5758 NewMode("Hard 7x7", "7x7 grid with 17-20 islands.", 7, 7, 17, 20),
@@ -66,17 +67,20 @@ var Modes = []list.Item{
6667 NewMode("Hard 13x13", "13x13 grid with 59-68 islands.", 13, 13, 59, 68),6768 }6869 69-var DailyModes = []list.Item{70- Modes[3], // Easy 9x971- Modes[1], // Medium 7x772-}70+var ModeDefinitions = gameentry.BuildModeDefs(Modes)7371 74-var Definition = game.Definition{75- Name: "Hashiwokakero",76- Description: "Connect the islands with bridges.",77- Aliases: []string{"hashi", "bridges"},78- Modes: Modes,79- DailyModes: DailyModes,80- Help: HelpContent,81- Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },82-}72+var Definition = puzzle.NewDefinition(puzzle.DefinitionSpec{73+ Name: "Hashiwokakero",74+ Description: "Connect the islands with bridges.",75+ Aliases: []string{"hashi", "bridges"},76+ Modes: ModeDefinitions,77+ DailyModeIDs: puzzle.SelectModeIDsByIndex(ModeDefinitions, 3, 1),78+})79+80+var Entry = gameentry.NewEntry(gameentry.EntrySpec{81+ Definition: Definition,82+ Help: HelpContent,83+ Import: game.AdaptImport(ImportModel),84+ Modes: Modes,85+ Print: PDFPrintAdapter,86+})
M hashiwokakero/help.md +2 -1
R hashiwokakero/Model.go → hashiwokakero/model.go +0 -0

No content changes.

R hashiwokakero/PrintAdapter.go → hashiwokakero/print_adapter.go +2 -4
M hitori/README.md +1 -1
R hitori/Export.go → hitori/export.go +0 -0

No content changes.

R hitori/Gamemode.go → hitori/gamemode.go +18 -14
@@ -4,8 +4,9 @@ import (
44 _ "embed"55 "math/rand/v2"66 7- "charm.land/bubbles/v2/list"87 "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/FelineStateMachine/puzzletea/gameentry"9+ "github.com/FelineStateMachine/puzzletea/puzzle"910 )1011 1112 //go:embed help.md
@@ -47,7 +48,7 @@ func (h HitoriMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {
4748 return New(h, puzzle)4849 }4950 50-var Modes = []list.Item{51+var Modes = []game.Mode{5152 NewMode("Mini", "5\u00d75 grid, gentle introduction.", 5, 0.32),5253 NewMode("Easy", "6\u00d76 grid, straightforward logic.", 6, 0.32),5354 NewMode("Medium", "8\u00d78 grid, moderate challenge.", 8, 0.30),
@@ -56,16 +57,19 @@ var Modes = []list.Item{
5657 NewMode("Expert", "12\u00d712 grid, maximum challenge.", 12, 0.28),5758 }5859 59-var DailyModes = []list.Item{60- Modes[1], // Easy 6x661- Modes[2], // Medium 8x862-}60+var ModeDefinitions = gameentry.BuildModeDefs(Modes)6361 64-var Definition = game.Definition{65- Name: "Hitori",66- Description: "Shade the cells to eliminate duplicates.",67- Modes: Modes,68- DailyModes: DailyModes,69- Help: HelpContent,70- Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },71-}62+var Definition = puzzle.NewDefinition(puzzle.DefinitionSpec{63+ Name: "Hitori",64+ Description: "Shade the cells to eliminate duplicates.",65+ Modes: ModeDefinitions,66+ DailyModeIDs: puzzle.SelectModeIDsByIndex(ModeDefinitions, 1, 2),67+})68+69+var Entry = gameentry.NewEntry(gameentry.EntrySpec{70+ Definition: Definition,71+ Help: HelpContent,72+ Import: game.AdaptImport(ImportModel),73+ Modes: Modes,74+ Print: PDFPrintAdapter,75+})
M hitori/help.md +2 -1
M hitori/hitori_test.go +1 -1
R hitori/Model.go → hitori/model.go +0 -0

No content changes.

R hitori/PrintAdapter.go → hitori/print_adapter.go +2 -4
M lightsout/README.md +1 -1
R lightsout/Export.go → lightsout/export.go +0 -0

No content changes.

R lightsout/Gamemode.go → lightsout/gamemode.go +18 -15
M lightsout/help.md +1 -1
R lightsout/Model.go → lightsout/model.go +0 -0

No content changes.

M nonogram/README.md +0 -1
R nonogram/Export.go → nonogram/export.go +0 -0

No content changes.

R nonogram/Gamemode.go → nonogram/gamemode.go +18 -14
@@ -4,8 +4,9 @@ import (
44 _ "embed"55 "math/rand/v2"66 7- "charm.land/bubbles/v2/list"87 "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/FelineStateMachine/puzzletea/gameentry"9+ "github.com/FelineStateMachine/puzzletea/puzzle"910 )1011 1112 //go:embed help.md
@@ -42,7 +43,7 @@ func (n NonogramMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {
4243 return New(n, hints)4344 }4445 45-var Modes = []list.Item{46+var Modes = []game.Mode{4647 // 5x54748 NewMode("Mini", "5x5 grid, ~65% filled. Quick puzzle, straightforward hints.", 5, 5, 0.65),4849 NewMode("Pocket", "5x5 grid, ~50% filled. Compact but balanced.", 5, 5, 0.50),
@@ -59,16 +60,19 @@ var Modes = []list.Item{
5960 NewMode("Massive", "20x20 grid, ~56% filled. Truly massive puzzle.", 20, 20, 0.56),6061 }6162 62-var DailyModes = []list.Item{63- Modes[3], // Standard 10x1064- Modes[4], // Classic 10x1065-}63+var ModeDefinitions = gameentry.BuildModeDefs(Modes)6664 67-var Definition = game.Definition{68- Name: "Nonogram",69- Description: "Fill the cells to match tomographic hints.",70- Modes: Modes,71- DailyModes: DailyModes,72- Help: HelpContent,73- Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },74-}65+var Definition = puzzle.NewDefinition(puzzle.DefinitionSpec{66+ Name: "Nonogram",67+ Description: "Fill the cells to match tomographic hints.",68+ Modes: ModeDefinitions,69+ DailyModeIDs: puzzle.SelectModeIDsByIndex(ModeDefinitions, 3, 4),70+})71+72+var Entry = gameentry.NewEntry(gameentry.EntrySpec{73+ Definition: Definition,74+ Help: HelpContent,75+ Import: game.AdaptImport(ImportModel),76+ Modes: Modes,77+ Print: PDFPrintAdapter,78+})
M nonogram/help.md +1 -1
R nonogram/Model.go → nonogram/model.go +0 -0

No content changes.

R nonogram/PrintAdapter.go → nonogram/print_adapter.go +2 -4
M nurikabe/README.md +0 -1
R nurikabe/Export.go → nurikabe/export.go +0 -0

No content changes.

R nurikabe/Gamemode.go → nurikabe/gamemode.go +19 -15
@@ -6,8 +6,9 @@ import (
66 "fmt"77 "math/rand/v2"88 9- "charm.land/bubbles/v2/list"109 "github.com/FelineStateMachine/puzzletea/game"10+ "github.com/FelineStateMachine/puzzletea/gameentry"11+ "github.com/FelineStateMachine/puzzletea/puzzle"1112 )1213 1314 //go:embed help.md
@@ -66,7 +67,7 @@ func (n NurikabeMode) SpawnSeededContext(ctx context.Context, rng *rand.Rand) (g
6667 return New(n, p)6768 }6869 69-var Modes = []list.Item{70+var Modes = []game.Mode{7071 NewMode("Mini", "5x5 grid, gentle introduction.", 5, 5, 0.28, 5),7172 NewMode("Easy", "7x7 grid, balanced logic.", 7, 7, 0.24, 7),7273 NewMode("Medium", "9x9 grid, moderate deduction.", 9, 9, 0.20, 9),
@@ -74,17 +75,20 @@ var Modes = []list.Item{
7475 NewMode("Expert", "12x12 grid, sparse clues and long chains.", 12, 12, 0.14, 12),7576 }7677 77-var DailyModes = []list.Item{78- Modes[1], // Easy79- Modes[2], // Medium80-}78+var ModeDefinitions = gameentry.BuildModeDefs(Modes)8179 82-var Definition = game.Definition{83- Name: "Nurikabe",84- Description: "Split the land while keeping one connected sea.",85- Aliases: []string{"islands", "sea"},86- Modes: Modes,87- DailyModes: DailyModes,88- Help: HelpContent,89- Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },90-}80+var Definition = puzzle.NewDefinition(puzzle.DefinitionSpec{81+ Name: "Nurikabe",82+ Description: "Split the land while keeping one connected sea.",83+ Aliases: []string{"islands", "sea"},84+ Modes: ModeDefinitions,85+ DailyModeIDs: puzzle.SelectModeIDsByIndex(ModeDefinitions, 1, 2),86+})87+88+var Entry = gameentry.NewEntry(gameentry.EntrySpec{89+ Definition: Definition,90+ Help: HelpContent,91+ Import: game.AdaptImport(ImportModel),92+ Modes: Modes,93+ Print: PDFPrintAdapter,94+})
M nurikabe/help.md +1 -1
R nurikabe/Model.go → nurikabe/model.go +0 -0

No content changes.

R nurikabe/PrintAdapter.go → nurikabe/print_adapter.go +2 -4
D pdfexport/cover_art.go +0 -1236
@@ -1,1236 +0,0 @@
1-package pdfexport2-3-import (4- "bytes"5- "encoding/binary"6- "fmt"7- "hash/fnv"8- "image"9- "image/color"10- "image/png"11- "math"12- "math/rand"13- "strings"14- "time"15-16- "codeberg.org/go-pdf/fpdf"17-)18-19-type coverVec2 struct {20- x float6421- y float6422-}23-24-type coverGlow struct {25- center coverVec226- spread float6427- strength float6428- col color.RGBA29-}30-31-type coverFieldProfile struct {32- freqX float6433- freqY float6434- freqMixX float6435- freqMixY float6436- freqCurlX float6437- freqCurlY float6438- ampX float6439- ampY float6440- ampMix float6441- ampCurl float6442- phaseX float6443- phaseY float6444- phaseMix float6445- phaseCurl float6446- swirl float6447- shear float6448- pinch float6449- pivot coverVec250-}51-52-type coverFlowLayer struct {53- count int54- steps int55- step float6456- alpha uint857- radius float6458- drift float6459- phase float6460- waveXFreq float6461- waveYFreq float6462- waveXAmp float6463- waveYAmp float6464- a color.RGBA65- b color.RGBA66-}67-68-type coverLatticeProfile struct {69- enabled bool70- layout int71- cols int72- rows int73- neighbors int74- jitterX float6475- jitterY float6476- center coverVec277- radialWarp float6478- edge color.RGBA79- nodeOuter color.RGBA80- nodeInner color.RGBA81- nodeSize float6482- coreSize float6483-}84-85-type coverOrbitProfile struct {86- enabled bool87- center coverVec288- count int89- radiusStart float6490- radiusStep float6491- arcCoverage float6492- segmentsMin int93- segmentsJitter int94- eccentricity float6495- wobble float6496- dotSize float6497- colorA color.RGBA98- colorB color.RGBA99- alphaBase uint8100- alphaStep uint8101-}102-103-type coverMarkProfile struct {104- enabled bool105- count int106- gridW int107- gridH int108- jitter float64109- sizeMin float64110- sizeMax float64111- alphaBase uint8112- alphaRange uint8113- colorA color.RGBA114- colorB color.RGBA115- cutout color.RGBA116-}117-118-type coverPalette struct {119- bgTop color.RGBA120- bgMid color.RGBA121- bgBottom color.RGBA122- accentA color.RGBA123- accentB color.RGBA124- ink color.RGBA125- nodeOuter color.RGBA126- nodeInner color.RGBA127- markCutout color.RGBA128- grainWarm color.RGBA129- grainCool color.RGBA130-}131-132-type coverPaletteFamily struct {133- name string134- bgShift [3]float64135- accentShift [2]float64136- satBias float64137- valueBias float64138-}139-140-type coverArchetype uint8141-142-const (143- coverArchetypeConstellation coverArchetype = iota144- coverArchetypeVortex145- coverArchetypeBands146- coverArchetypeDriftField147- coverArchetypeRadialMesh148- coverArchetypeSparseGlyph149- coverArchetypeCount150-)151-152-type coverModifier uint8153-154-const (155- coverModifierDenseFlow coverModifier = iota156- coverModifierHighOrbit157- coverModifierQuietLattice158- coverModifierMicroMarks159- coverModifierGrainHeavy160- coverModifierNegativeSpace161-)162-163-var coverPaletteFamilies = []coverPaletteFamily{164- {165- name: "tropical",166- bgShift: [3]float64{0.10, 0.02, -0.14},167- accentShift: [2]float64{0.33, 0.52},168- satBias: 0.08,169- valueBias: 0.03,170- },171- {172- name: "sunset",173- bgShift: [3]float64{0.05, -0.05, -0.17},174- accentShift: [2]float64{0.19, 0.38},175- satBias: 0.10,176- valueBias: 0.04,177- },178- {179- name: "electric-mineral",180- bgShift: [3]float64{-0.16, -0.08, 0.01},181- accentShift: [2]float64{0.36, -0.28},182- satBias: 0.13,183- valueBias: -0.02,184- },185- {186- name: "aurora",187- bgShift: [3]float64{0.22, 0.10, -0.09},188- accentShift: [2]float64{0.45, 0.62},189- satBias: 0.11,190- valueBias: 0.02,191- },192- {193- name: "ember",194- bgShift: [3]float64{0.00, -0.11, -0.22},195- accentShift: [2]float64{-0.36, 0.11},196- satBias: 0.09,197- valueBias: -0.03,198- },199- {200- name: "citrus-marine",201- bgShift: [3]float64{0.14, 0.03, -0.13},202- accentShift: [2]float64{0.30, -0.22},203- satBias: 0.12,204- valueBias: 0.01,205- },206-}207-208-type coverArtDirection struct {209- motif int210- top color.RGBA211- mid color.RGBA212- bottom color.RGBA213- verticalCurve float64214- glows []coverGlow215- field coverFieldProfile216- flowLayers []coverFlowLayer217- lattice coverLatticeProfile218- orbit coverOrbitProfile219- marks coverMarkProfile220- grainCount int221- grainWarm color.RGBA222- grainCool color.RGBA223-}224-225-func drawCoverArtworkImage(226- pdf *fpdf.Fpdf,227- scene rectMM,228- seedText string,229- variant string,230- base RGB,231-) {232- seedText = strings.TrimSpace(seedText)233- if seedText == "" {234- seedText = "puzzletea" + time.Now().String()235- }236- if strings.TrimSpace(variant) == "" {237- variant = "front"238- }239- imageName := fmt.Sprintf(240- "puzzletea-cover-artwork-%016x",241- coverSeedHash(seedText+"|"+variant),242- )243- artPNG := renderCoverArtworkPNG(seedText, variant, base)244- options := fpdf.ImageOptions{245- ImageType: "PNG",246- ReadDpi: true,247- }248- pdf.RegisterImageOptionsReader(imageName, options, bytes.NewReader(artPNG))249- pdf.ImageOptions(imageName, scene.x, scene.y, scene.w, scene.h, false, options, 0, "")250-}251-252-func renderCoverArtworkPNG(seedText, variant string, base RGB) []byte {253- const (254- width = 1200255- height = 1400256- )257- seed := coverSeedHash(seedText + "|" + variant)258- direction := newCoverArtDirection(seed, base)259- img := image.NewRGBA(image.Rect(0, 0, width, height))260-261- paintCoverBackground(img, width, height, direction)262- drawCoverFlowTrails(img, width, height, direction, coverStreamRNG(seed, "flow"))263- drawCoverPuzzleLattice(img, width, height, direction, coverStreamRNG(seed, "lattice"))264- drawCoverOrbitBands(img, width, height, direction, coverStreamRNG(seed, "orbit"))265- drawCoverSeedMarks(img, width, height, direction, coverStreamRNG(seed, "marks"))266- drawCoverFilmGrain(img, width, height, direction, coverStreamRNG(seed, "grain"))267-268- var out bytes.Buffer269- if err := png.Encode(&out, img); err != nil {270- return []byte{}271- }272- return out.Bytes()273-}274-275-func newCoverArtDirection(seed uint64, base RGB) coverArtDirection {276- paletteRNG := coverStreamRNG(seed, "palette")277- structureRNG := coverStreamRNG(seed, "structure")278- palette := newCoverPalette(base, paletteRNG)279- primary, modifiers := pickCoverComposition(structureRNG)280-281- glows := make([]coverGlow, 0, 5)282- anchorColor := palette.accentA283- if structureRNG.Intn(2) == 0 {284- anchorColor = palette.accentB285- }286- glows = append(glows, coverGlow{287- center: coverVec2{288- x: 0.12 + structureRNG.Float64()*0.76,289- y: 0.10 + structureRNG.Float64()*0.78,290- },291- spread: 0.24 + structureRNG.Float64()*0.30,292- strength: 0.46 + structureRNG.Float64()*0.28,293- col: jitterRGBA(anchorColor, structureRNG, 26),294- })295-296- glowCount := 2 + structureRNG.Intn(3)297- for i := range glowCount {298- col := palette.accentA299- if i%2 == 1 {300- col = palette.accentB301- }302- col = jitterRGBA(col, structureRNG, 28)303- glows = append(glows, coverGlow{304- center: coverVec2{305- x: 0.06 + structureRNG.Float64()*0.88,306- y: 0.08 + structureRNG.Float64()*0.84,307- },308- spread: 0.42 + structureRNG.Float64()*0.70,309- strength: 0.12 + structureRNG.Float64()*0.28,310- col: col,311- })312- }313-314- field := coverFieldProfile{315- freqX: 3.8 + structureRNG.Float64()*6.8,316- freqY: 3.6 + structureRNG.Float64()*6.5,317- freqMixX: 0.5 + structureRNG.Float64()*1.8,318- freqMixY: 0.5 + structureRNG.Float64()*1.8,319- freqCurlX: 0.4 + structureRNG.Float64()*2.0,320- freqCurlY: 0.4 + structureRNG.Float64()*2.0,321- ampX: 0.60 + structureRNG.Float64()*1.00,322- ampY: 0.60 + structureRNG.Float64()*1.00,323- ampMix: 0.26 + structureRNG.Float64()*0.92,324- ampCurl: 0.24 + structureRNG.Float64()*0.88,325- phaseX: 0.22 + structureRNG.Float64()*1.45,326- phaseY: 0.22 + structureRNG.Float64()*1.45,327- phaseMix: 0.20 + structureRNG.Float64()*1.20,328- phaseCurl: 0.20 + structureRNG.Float64()*1.20,329- swirl: (structureRNG.Float64()*2 - 1) * 0.40,330- shear: (structureRNG.Float64()*2 - 1) * 0.36,331- pinch: (structureRNG.Float64()*2 - 1) * 0.56,332- pivot: coverVec2{333- x: 0.20 + structureRNG.Float64()*0.60,334- y: 0.18 + structureRNG.Float64()*0.64,335- },336- }337-338- flowLayers := make([]coverFlowLayer, 0, 4)339- layerCount := 2 + structureRNG.Intn(3)340- for i := range layerCount {341- t := float64(i) / float64(maxInt(1, layerCount-1))342- a := jitterRGBA(lerpRGB(palette.accentA, palette.bgMid, 0.28+t*0.42), structureRNG, 24)343- b := jitterRGBA(lerpRGB(palette.accentB, palette.bgTop, 0.16+t*0.38), structureRNG, 24)344- flowLayers = append(flowLayers, coverFlowLayer{345- count: 220 + structureRNG.Intn(360),346- steps: 96 + structureRNG.Intn(132),347- step: 1.10 + structureRNG.Float64()*1.80,348- alpha: uint8(22 + structureRNG.Intn(42)),349- radius: 0.78 + structureRNG.Float64()*0.98,350- drift: 0.40 + structureRNG.Float64()*1.72,351- phase: structureRNG.Float64() * math.Pi * 2,352- waveXFreq: 4.6 + structureRNG.Float64()*7.8,353- waveYFreq: 4.6 + structureRNG.Float64()*7.8,354- waveXAmp: 0.06 + structureRNG.Float64()*0.34,355- waveYAmp: 0.06 + structureRNG.Float64()*0.34,356- a: a,357- b: b,358- })359- }360-361- lattice := coverLatticeProfile{362- enabled: true,363- layout: structureRNG.Intn(3),364- cols: 7 + structureRNG.Intn(6),365- rows: 6 + structureRNG.Intn(7),366- neighbors: 2 + structureRNG.Intn(3),367- jitterX: 24 + structureRNG.Float64()*48,368- jitterY: 28 + structureRNG.Float64()*56,369- center: coverVec2{x: 0.28 + structureRNG.Float64()*0.44, y: 0.24 + structureRNG.Float64()*0.50},370- radialWarp: (structureRNG.Float64()*2 - 1) * 0.40,371- edge: color.RGBA{R: palette.ink.R, G: palette.ink.G, B: palette.ink.B, A: uint8(52 + structureRNG.Intn(76))},372- nodeOuter: palette.nodeOuter,373- nodeInner: palette.nodeInner,374- nodeSize: 3.3 + structureRNG.Float64()*3.8,375- coreSize: 1.4 + structureRNG.Float64()*1.7,376- }377-378- orbit := coverOrbitProfile{379- enabled: true,380- center: coverVec2{x: 0.30 + structureRNG.Float64()*0.38, y: 0.26 + structureRNG.Float64()*0.38},381- count: 10 + structureRNG.Intn(18),382- radiusStart: 36 + structureRNG.Float64()*52,383- radiusStep: 13 + structureRNG.Float64()*20,384- arcCoverage: 0.48 + structureRNG.Float64()*0.48,385- segmentsMin: 34 + structureRNG.Intn(40),386- segmentsJitter: 42 + structureRNG.Intn(56),387- eccentricity: 0.66 + structureRNG.Float64()*0.56,388- wobble: 2.6 + structureRNG.Float64()*8.8,389- dotSize: 0.84 + structureRNG.Float64()*0.86,390- colorA: jitterRGBA(lerpRGB(palette.accentA, palette.bgTop, 0.22), structureRNG, 18),391- colorB: jitterRGBA(lerpRGB(palette.accentB, palette.bgMid, 0.34), structureRNG, 18),392- alphaBase: uint8(18 + structureRNG.Intn(28)),393- alphaStep: uint8(1 + structureRNG.Intn(3)),394- }395-396- marks := coverMarkProfile{397- enabled: true,398- count: 28 + structureRNG.Intn(58),399- gridW: 8 + structureRNG.Intn(9),400- gridH: 10 + structureRNG.Intn(10),401- jitter: 8 + structureRNG.Float64()*30,402- sizeMin: 1.7 + structureRNG.Float64()*1.5,403- sizeMax: 3.4 + structureRNG.Float64()*3.0,404- alphaBase: uint8(40 + structureRNG.Intn(40)),405- alphaRange: uint8(20 + structureRNG.Intn(30)),406- colorA: jitterRGBA(lerpRGB(palette.accentA, palette.bgTop, 0.18), structureRNG, 22),407- colorB: jitterRGBA(lerpRGB(palette.accentB, palette.bgMid, 0.30), structureRNG, 22),408- cutout: palette.markCutout,409- }410-411- direction := coverArtDirection{412- motif: int(primary),413- top: palette.bgTop,414- mid: palette.bgMid,415- bottom: palette.bgBottom,416- verticalCurve: 1.18 + structureRNG.Float64()*1.14,417- glows: glows,418- field: field,419- flowLayers: flowLayers,420- lattice: lattice,421- orbit: orbit,422- marks: marks,423- grainCount: 34000 + structureRNG.Intn(32000),424- grainWarm: palette.grainWarm,425- grainCool: palette.grainCool,426- }427-428- applyCoverPrimaryArchetype(&direction, primary, structureRNG)429- for _, modifier := range modifiers {430- applyCoverModifier(&direction, modifier, structureRNG)431- }432-433- return direction434-}435-436-func coverCompositionForSeed(seed uint64) (coverArchetype, []coverModifier) {437- structureRNG := coverStreamRNG(seed, "structure")438- return pickCoverComposition(structureRNG)439-}440-441-func pickCoverComposition(rng *rand.Rand) (coverArchetype, []coverModifier) {442- primary := pickWeightedCoverArchetype(rng)443- modifierCount := 0444- roll := rng.Float64()445- switch {446- case roll < 0.58:447- modifierCount = 1448- default:449- modifierCount = 2450- }451-452- modifierPool := []coverModifier{453- coverModifierDenseFlow,454- coverModifierHighOrbit,455- coverModifierQuietLattice,456- coverModifierMicroMarks,457- coverModifierGrainHeavy,458- coverModifierNegativeSpace,459- }460- rng.Shuffle(len(modifierPool), func(i, j int) {461- modifierPool[i], modifierPool[j] = modifierPool[j], modifierPool[i]462- })463-464- modifiers := make([]coverModifier, 0, modifierCount)465- for _, modifier := range modifierPool {466- if len(modifiers) >= modifierCount {467- break468- }469- if primary == coverArchetypeSparseGlyph && modifier == coverModifierNegativeSpace {470- continue471- }472- if primary == coverArchetypeBands && modifier == coverModifierHighOrbit {473- continue474- }475- modifiers = append(modifiers, modifier)476- }477- return primary, modifiers478-}479-480-func pickWeightedCoverArchetype(rng *rand.Rand) coverArchetype {481- weights := [...]int{18, 16, 14, 18, 17, 17}482- total := 0483- for _, weight := range weights {484- total += weight485- }486- pick := rng.Intn(total)487- for idx, weight := range weights {488- if pick < weight {489- return coverArchetype(idx)490- }491- pick -= weight492- }493- return coverArchetypeSparseGlyph494-}495-496-func newCoverPalette(base RGB, rng *rand.Rand) coverPalette {497- baseColor := rgbToColor(base)498- baseHue, baseSat, baseVal := rgbToHSV(baseColor)499- family := coverPaletteFamilies[rng.Intn(len(coverPaletteFamilies))]500- baseHue = wrapHue(baseHue + randCentered(rng, 0.32))501-502- baseSat = clamp(0.14, 0.50, baseSat*0.62+0.11+family.satBias*0.18+rng.Float64()*0.07)503- baseVal = clamp(0.34, 0.86, baseVal*0.84+0.12+family.valueBias*0.15+randCentered(rng, 0.05))504-505- top := hsvToRGB(506- wrapHue(baseHue+family.bgShift[0]+randCentered(rng, 0.10)),507- clamp(0.14, 0.34, baseSat*0.50+0.04+family.satBias*0.28+rng.Float64()*0.08),508- clamp(0.74, 0.98, baseVal+0.16+family.valueBias*0.24+rng.Float64()*0.14),509- )510- mid := hsvToRGB(511- wrapHue(baseHue+family.bgShift[1]+randCentered(rng, 0.14)),512- clamp(0.20, 0.50, baseSat*0.74+0.02+family.satBias*0.40+rng.Float64()*0.10),513- clamp(0.46, 0.88, baseVal-0.02+family.valueBias*0.12+randCentered(rng, 0.12)),514- )515- bottom := hsvToRGB(516- wrapHue(baseHue+family.bgShift[2]+randCentered(rng, 0.16)),517- clamp(0.18, 0.54, baseSat*0.82+0.01+family.satBias*0.35+rng.Float64()*0.10),518- clamp(0.08, 0.36, baseVal*0.32+0.02+family.valueBias*0.10+randCentered(rng, 0.10)),519- )520- accentA := hsvToRGB(521- wrapHue(baseHue+family.accentShift[0]+randCentered(rng, 0.18)),522- clamp(0.30, 0.62, baseSat+0.12+family.satBias*0.40+rng.Float64()*0.15),523- clamp(0.62, 0.96, baseVal+0.14+rng.Float64()*0.12),524- )525- accentB := hsvToRGB(526- wrapHue(baseHue+family.accentShift[1]+randCentered(rng, 0.20)),527- clamp(0.28, 0.60, baseSat+0.10+family.satBias*0.38+rng.Float64()*0.16),528- clamp(0.56, 0.92, baseVal+0.08+rng.Float64()*0.12),529- )530- ink := hsvToRGB(531- wrapHue(baseHue+0.50+family.bgShift[2]*0.35),532- clamp(0.22, 0.44, 0.24+baseSat*0.22),533- clamp(0.06, 0.22, 0.12+(1-baseVal)*0.10),534- )535-536- return coverPalette{537- bgTop: top,538- bgMid: mid,539- bgBottom: bottom,540- accentA: accentA,541- accentB: accentB,542- ink: ink,543- nodeOuter: blendRGB(top, color.RGBA{R: 255, G: 244, B: 220, A: 255}, 0.26),544- nodeInner: blendRGB(ink, bottom, 0.34),545- markCutout: blendRGB(bottom, color.RGBA{R: 6, G: 11, B: 20, A: 255}, 0.30),546- grainWarm: blendRGB(top, color.RGBA{R: 255, G: 248, B: 232, A: 255}, 0.36),547- grainCool: blendRGB(bottom, ink, 0.44),548- }549-}550-551-func applyCoverPrimaryArchetype(552- direction *coverArtDirection,553- primary coverArchetype,554- rng *rand.Rand,555-) {556- switch primary {557- case coverArchetypeConstellation:558- direction.lattice.layout = 0559- direction.lattice.neighbors = minInt(5, direction.lattice.neighbors+1)560- direction.lattice.edge.A = minUint8(160, direction.lattice.edge.A+22)561- for i := range direction.flowLayers {562- direction.flowLayers[i].count = maxInt(160, int(float64(direction.flowLayers[i].count)*0.76))563- direction.flowLayers[i].alpha = maxUint8(16, direction.flowLayers[i].alpha-6)564- }565- direction.orbit.enabled = false566- direction.orbit.count = maxInt(4, direction.orbit.count/2)567- direction.orbit.arcCoverage = clamp01(direction.orbit.arcCoverage*0.72 + 0.12)568- direction.marks.count += 12569- direction.field.swirl *= 0.88570- case coverArchetypeVortex:571- direction.lattice.enabled = false572- direction.orbit.count += 14573- direction.orbit.arcCoverage = clamp01(direction.orbit.arcCoverage*1.20 + 0.08)574- direction.orbit.radiusStep *= 0.84575- direction.field.swirl *= 1.42576- for i := range direction.flowLayers {577- direction.flowLayers[i].alpha = minUint8(86, direction.flowLayers[i].alpha+10)578- direction.flowLayers[i].drift *= 1.18579- }580- direction.marks.count = maxInt(8, direction.marks.count-16)581- case coverArchetypeBands:582- direction.lattice.layout = 2583- direction.lattice.enabled = false584- direction.orbit.enabled = false585- direction.orbit.count = maxInt(5, direction.orbit.count/2)586- direction.field.shear *= 0.44587- direction.field.pinch *= 0.74588- for i := range direction.flowLayers {589- direction.flowLayers[i].waveYFreq = 1.8 + rng.Float64()*2.3590- direction.flowLayers[i].waveYAmp += 0.14 + rng.Float64()*0.16591- direction.flowLayers[i].waveXAmp *= 0.66592- direction.flowLayers[i].step *= 0.94593- direction.flowLayers[i].count = maxInt(180, int(float64(direction.flowLayers[i].count)*0.84))594- }595- direction.marks.count = maxInt(8, direction.marks.count-14)596- case coverArchetypeDriftField:597- direction.lattice.enabled = false598- for i := range direction.flowLayers {599- direction.flowLayers[i].count += 130600- direction.flowLayers[i].steps += 18601- direction.flowLayers[i].alpha = minUint8(90, direction.flowLayers[i].alpha+12)602- direction.flowLayers[i].drift *= 1.24603- }604- direction.orbit.count = maxInt(8, direction.orbit.count-4)605- direction.lattice.neighbors = maxInt(2, direction.lattice.neighbors-1)606- direction.marks.count = maxInt(10, direction.marks.count-12)607- direction.grainCount += 5000608- case coverArchetypeRadialMesh:609- direction.lattice.layout = 1610- direction.lattice.rows += 2611- direction.lattice.cols += 1612- direction.lattice.neighbors = minInt(5, direction.lattice.neighbors+1)613- direction.lattice.center = coverVec2{614- x: 0.44 + rng.Float64()*0.12,615- y: 0.38 + rng.Float64()*0.14,616- }617- direction.orbit.enabled = true618- direction.orbit.count += 8619- direction.orbit.eccentricity = clamp(0.56, 1.46, direction.orbit.eccentricity*1.16)620- direction.field.pinch *= 1.24621- for i := range direction.flowLayers {622- direction.flowLayers[i].count = maxInt(110, int(float64(direction.flowLayers[i].count)*0.45))623- direction.flowLayers[i].alpha = maxUint8(14, direction.flowLayers[i].alpha-6)624- }625- direction.marks.count = maxInt(10, direction.marks.count-14)626- case coverArchetypeSparseGlyph:627- for i := range direction.flowLayers {628- direction.flowLayers[i].count = maxInt(120, int(float64(direction.flowLayers[i].count)*0.55))629- direction.flowLayers[i].steps = maxInt(72, int(float64(direction.flowLayers[i].steps)*0.70))630- direction.flowLayers[i].alpha = maxUint8(12, direction.flowLayers[i].alpha-8)631- }632- direction.lattice.enabled = rng.Float64() > 0.68633- if direction.lattice.enabled {634- direction.lattice.neighbors = 2635- direction.lattice.edge.A = maxUint8(14, direction.lattice.edge.A-26)636- }637- direction.orbit.enabled = rng.Float64() > 0.80638- direction.orbit.count = maxInt(5, direction.orbit.count/2)639- direction.orbit.arcCoverage *= 0.66640- direction.marks.count += 40641- direction.marks.sizeMax += 1.5642- direction.marks.jitter *= 0.76643- direction.field.swirl *= 0.72644- direction.grainCount += 9000645- }646-}647-648-func applyCoverModifier(649- direction *coverArtDirection,650- modifier coverModifier,651- rng *rand.Rand,652-) {653- switch modifier {654- case coverModifierDenseFlow:655- for i := range direction.flowLayers {656- direction.flowLayers[i].count += 96657- direction.flowLayers[i].steps += 16658- direction.flowLayers[i].alpha = minUint8(94, direction.flowLayers[i].alpha+10)659- }660- direction.grainCount += 3000661- case coverModifierHighOrbit:662- direction.orbit.enabled = true663- direction.orbit.count += 10664- direction.orbit.arcCoverage = clamp01(direction.orbit.arcCoverage*1.15 + 0.05)665- direction.orbit.dotSize += 0.12666- direction.orbit.alphaBase = minUint8(120, direction.orbit.alphaBase+6)667- case coverModifierQuietLattice:668- if direction.lattice.enabled {669- direction.lattice.edge.A = maxUint8(12, direction.lattice.edge.A-30)670- direction.lattice.neighbors = maxInt(2, direction.lattice.neighbors-1)671- }672- if rng.Float64() < 0.35 {673- direction.lattice.enabled = false674- }675- case coverModifierMicroMarks:676- direction.marks.count += 26677- direction.marks.sizeMin = clamp(0.8, 4.0, direction.marks.sizeMin*0.78)678- direction.marks.sizeMax = clamp(1.2, 5.2, direction.marks.sizeMax*0.74)679- direction.marks.jitter *= 0.86680- direction.marks.alphaBase = minUint8(108, direction.marks.alphaBase+8)681- case coverModifierGrainHeavy:682- direction.grainCount += 12000683- direction.grainWarm = blendRGB(direction.grainWarm, color.RGBA{R: 255, G: 250, B: 240, A: 255}, 0.12)684- case coverModifierNegativeSpace:685- for i := range direction.flowLayers {686- direction.flowLayers[i].count = maxInt(140, int(float64(direction.flowLayers[i].count)*0.62))687- direction.flowLayers[i].alpha = maxUint8(12, direction.flowLayers[i].alpha-8)688- }689- direction.orbit.count = maxInt(4, direction.orbit.count-6)690- direction.orbit.alphaBase = maxUint8(8, direction.orbit.alphaBase-4)691- direction.marks.count = maxInt(10, direction.marks.count-12)692- direction.verticalCurve = clamp(1.0, 2.8, direction.verticalCurve+0.26)693- for i := range direction.glows {694- direction.glows[i].strength *= 0.72695- }696- }697-}698-699-func randCentered(rng *rand.Rand, spread float64) float64 {700- return (rng.Float64()*2 - 1) * spread701-}702-703-func paintCoverBackground(img *image.RGBA, w, h int, direction coverArtDirection) {704- for y := range h {705- ty := float64(y) / float64(h-1)706- vertical := blendRGB(707- lerpRGB(direction.top, direction.mid, ty*1.12),708- direction.bottom,709- powClamp(ty, direction.verticalCurve),710- )711- for x := range w {712- tx := float64(x) / float64(w-1)713- col := vertical714- for _, glow := range direction.glows {715- amount := radialFalloff(tx, ty, glow.center.x, glow.center.y, glow.spread)716- col = blendRGB(col, glow.col, amount*glow.strength)717- }718- img.SetRGBA(x, y, col)719- }720- }721-}722-723-func drawCoverFlowTrails(724- img *image.RGBA,725- w, h int,726- direction coverArtDirection,727- rng *rand.Rand,728-) {729- for _, layer := range direction.flowLayers {730- for i := 0; i < layer.count; i++ {731- x := rng.Float64() * float64(w)732- y := rng.Float64() * float64(h)733- for step := 0; step < layer.steps; step++ {734- nx := x / float64(w)735- ny := y / float64(h)736- angle := coverFieldAngle(nx, ny, direction.field, layer.phase)737- angle += math.Sin((ny+layer.phase)*layer.waveYFreq) * layer.waveYAmp738- angle += math.Cos((nx-layer.phase)*layer.waveXFreq) * layer.waveXAmp739-740- x += math.Cos(angle) * layer.step741- y += math.Sin(angle) * layer.step742- x += math.Cos((ny-layer.phase)*math.Pi*2) * layer.drift * 0.04743- y += math.Sin((nx+layer.phase)*math.Pi*2) * layer.drift * 0.08744- if x < 1 || x >= float64(w-1) || y < 1 || y >= float64(h-1) {745- break746- }747- t := float64(step) / float64(layer.steps)748- c := lerpRGB(layer.a, layer.b, t)749- c.A = layer.alpha750- drawDisc(img, x, y, layer.radius, c)751- }752- }753- }754-}755-756-func drawCoverPuzzleLattice(757- img *image.RGBA,758- w, h int,759- direction coverArtDirection,760- rng *rand.Rand,761-) {762- if !direction.lattice.enabled {763- return764- }765- nodes := buildCoverLatticeNodes(w, h, direction.lattice, rng)766- if len(nodes) == 0 {767- return768- }769-770- for i := range nodes {771- for _, j := range nearestN(nodes, i, direction.lattice.neighbors) {772- if j > i {773- drawLine(img, nodes[i], nodes[j], direction.lattice.edge)774- }775- }776- }777- for _, n := range nodes {778- drawDisc(img, n.x, n.y, direction.lattice.nodeSize, direction.lattice.nodeOuter)779- drawDisc(img, n.x, n.y, direction.lattice.coreSize, direction.lattice.nodeInner)780- }781-}782-783-func buildCoverLatticeNodes(784- w, h int,785- profile coverLatticeProfile,786- rng *rand.Rand,787-) []coverVec2 {788- nodes := make([]coverVec2, 0, profile.cols*profile.rows)789- switch profile.layout {790- case 1:791- minDim := math.Min(float64(w), float64(h))792- rings := maxInt(4, profile.rows+1)793- spokes := maxInt(8, profile.cols+4)794- center := coverVec2{795- x: profile.center.x * float64(w),796- y: profile.center.y * float64(h),797- }798- for ring := 1; ring <= rings; ring++ {799- ringT := float64(ring) / float64(rings+1)800- radius := ringT * minDim * (0.16 + 0.62*ringT)801- offset := rng.Float64() * math.Pi * 2802- for spoke := range spokes {803- ang := offset + float64(spoke)/float64(spokes)*math.Pi*2804- ang += math.Sin(float64(ring)*0.9+float64(spoke)*0.45) * profile.radialWarp805- jitter := (rng.Float64() - 0.5) * profile.jitterX806- x := center.x + math.Cos(ang)*(radius+jitter)807- y := center.y + math.Sin(ang)*(radius+jitter*0.45)808- nodes = append(nodes, coverVec2{x: x, y: y})809- }810- }811- case 2:812- total := maxInt(38, profile.cols*profile.rows)813- for i := range total {814- tx := (rng.Float64()*0.84 + 0.08)815- ty := (rng.Float64()*0.84 + 0.08)816- warpX := math.Sin(ty*math.Pi*4+float64(i)*0.22) * profile.radialWarp * 0.12817- warpY := math.Cos(tx*math.Pi*3+float64(i)*0.18) * profile.radialWarp * 0.10818- x := (tx+warpX)*float64(w) + (rng.Float64()-0.5)*profile.jitterX819- y := (ty+warpY)*float64(h) + (rng.Float64()-0.5)*profile.jitterY820- nodes = append(nodes, coverVec2{x: x, y: y})821- }822- default:823- for row := 0; row < profile.rows; row++ {824- for col := 0; col < profile.cols; col++ {825- x := (float64(col) + 1) / (float64(profile.cols) + 1) * float64(w)826- y := (float64(row) + 1) / (float64(profile.rows) + 1) * float64(h)827- x += (rng.Float64() - 0.5) * profile.jitterX828- y += (rng.Float64() - 0.5) * profile.jitterY829- nodes = append(nodes, coverVec2{x: x, y: y})830- }831- }832- }833- return nodes834-}835-836-func drawCoverOrbitBands(837- img *image.RGBA,838- w, h int,839- direction coverArtDirection,840- rng *rand.Rand,841-) {842- if !direction.orbit.enabled {843- return844- }845- center := coverVec2{846- x: float64(w) * direction.orbit.center.x,847- y: float64(h) * direction.orbit.center.y,848- }849- for i := 0; i < direction.orbit.count; i++ {850- radius := direction.orbit.radiusStart + float64(i)*direction.orbit.radiusStep851- start := rng.Float64() * math.Pi * 2852- sweep := direction.orbit.arcCoverage * (0.74 + rng.Float64()*0.52) * math.Pi * 2853- sweep = math.Min(sweep, math.Pi*2)854- segments := direction.orbit.segmentsMin + rng.Intn(direction.orbit.segmentsJitter)855- t := float64(i) / float64(maxInt(1, direction.orbit.count-1))856- col := lerpRGB(direction.orbit.colorA, direction.orbit.colorB, t)857- col.A = minUint8(220, direction.orbit.alphaBase+uint8(i)*direction.orbit.alphaStep)858- for s := range segments {859- a := start + float64(s)/float64(segments)*sweep860- jx := math.Sin(float64(i)*0.43+a*2.9) * direction.orbit.wobble861- jy := math.Cos(float64(i)*0.37+a*2.3) * direction.orbit.wobble * 0.82862- x := center.x + math.Cos(a)*radius*direction.orbit.eccentricity + jx863- y := center.y + math.Sin(a)*radius + jy864- drawDisc(img, x, y, direction.orbit.dotSize, col)865- }866- }867-}868-869-func drawCoverSeedMarks(870- img *image.RGBA,871- w, h int,872- direction coverArtDirection,873- rng *rand.Rand,874-) {875- if !direction.marks.enabled {876- return877- }878- for i := 0; i < direction.marks.count; i++ {879- gx := 1 + rng.Intn(direction.marks.gridW)880- gy := 1 + rng.Intn(direction.marks.gridH)881- x := float64(gx) / float64(direction.marks.gridW+1) * float64(w)882- y := float64(gy) / float64(direction.marks.gridH+1) * float64(h)883- x += (rng.Float64() - 0.5) * direction.marks.jitter884- y += (rng.Float64() - 0.5) * direction.marks.jitter885-886- size := direction.marks.sizeMin887- if direction.marks.sizeMax > direction.marks.sizeMin {888- size += rng.Float64() * (direction.marks.sizeMax - direction.marks.sizeMin)889- }890- col := lerpRGB(direction.marks.colorA, direction.marks.colorB, rng.Float64())891- col.A = minUint8(220, direction.marks.alphaBase+uint8(rng.Intn(int(direction.marks.alphaRange)+1)))892- style := (i + rng.Intn(4) + direction.motif) % 4893- switch style {894- case 0:895- drawDisc(img, x, y, size*0.48, col)896- case 1:897- drawDisc(img, x, y, size, col)898- drawDisc(img, x, y, size*0.52, direction.marks.cutout)899- case 2:900- drawLine(img, coverVec2{x: x - size, y: y}, coverVec2{x: x + size, y: y}, col)901- drawLine(img, coverVec2{x: x, y: y - size}, coverVec2{x: x, y: y + size}, col)902- default:903- drawLine(img, coverVec2{x: x - size, y: y - size}, coverVec2{x: x + size, y: y + size}, col)904- drawLine(img, coverVec2{x: x - size, y: y + size}, coverVec2{x: x + size, y: y - size}, col)905- drawDisc(img, x, y, size*0.32, direction.marks.cutout)906- }907- }908-}909-910-func drawCoverFilmGrain(911- img *image.RGBA,912- w, h int,913- direction coverArtDirection,914- rng *rand.Rand,915-) {916- for i := 0; i < direction.grainCount; i++ {917- x := rng.Intn(w)918- y := rng.Intn(h)919- alpha := uint8(8 + rng.Intn(18))920- if rng.Intn(2) == 0 {921- blendPixel(img, x, y, color.RGBA{922- R: direction.grainWarm.R,923- G: direction.grainWarm.G,924- B: direction.grainWarm.B,925- A: alpha,926- })927- continue928- }929- blendPixel(img, x, y, color.RGBA{930- R: direction.grainCool.R,931- G: direction.grainCool.G,932- B: direction.grainCool.B,933- A: alpha,934- })935- }936-}937-938-func coverFieldAngle(x, y float64, field coverFieldProfile, phase float64) float64 {939- ax := math.Sin((x*field.freqX+phase*field.phaseX)*math.Pi*2) * field.ampX940- ay := math.Cos((y*field.freqY-phase*field.phaseY)*math.Pi*2) * field.ampY941- mix := math.Sin((x*field.freqMixX+y*field.freqMixY+phase*field.phaseMix)*math.Pi*2) * field.ampMix942- curl := math.Cos((x*field.freqCurlX-y*field.freqCurlY+phase*field.phaseCurl)*math.Pi*2) * field.ampCurl943- radial := math.Atan2(y-field.pivot.y, x-field.pivot.x)944- distance := math.Hypot(x-field.pivot.x, y-field.pivot.y)945- swirl := radial * field.swirl946- pinch := (0.5 - distance) * field.pinch947- shear := (x - y) * field.shear948- return ax + ay + mix + curl + swirl + pinch + shear949-}950-951-func nearestN(nodes []coverVec2, idx, n int) []int {952- n = maxInt(1, n)953- bestDistance := make([]float64, n)954- bestIndex := make([]int, n)955- for i := range bestDistance {956- bestDistance[i] = math.MaxFloat64957- bestIndex[i] = -1958- }959- for j := range nodes {960- if j == idx {961- continue962- }963- dx := nodes[idx].x - nodes[j].x964- dy := nodes[idx].y - nodes[j].y965- distance := dx*dx + dy*dy966- for k := range bestDistance {967- if distance >= bestDistance[k] {968- continue969- }970- for shift := len(bestDistance) - 1; shift > k; shift-- {971- bestDistance[shift] = bestDistance[shift-1]972- bestIndex[shift] = bestIndex[shift-1]973- }974- bestDistance[k] = distance975- bestIndex[k] = j976- break977- }978- }979- out := make([]int, 0, len(bestIndex))980- for _, best := range bestIndex {981- if best >= 0 {982- out = append(out, best)983- }984- }985- return out986-}987-988-func drawDisc(img *image.RGBA, cx, cy, radius float64, c color.RGBA) {989- minX := int(math.Floor(cx - radius))990- maxX := int(math.Ceil(cx + radius))991- minY := int(math.Floor(cy - radius))992- maxY := int(math.Ceil(cy + radius))993- r2 := radius * radius994- for y := minY; y <= maxY; y++ {995- for x := minX; x <= maxX; x++ {996- dx := float64(x) + 0.5 - cx997- dy := float64(y) + 0.5 - cy998- if dx*dx+dy*dy <= r2 {999- blendPixel(img, x, y, c)1000- }1001- }1002- }1003-}1004-1005-func drawLine(img *image.RGBA, a, b coverVec2, c color.RGBA) {1006- dx := b.x - a.x1007- dy := b.y - a.y1008- steps := int(math.Max(math.Abs(dx), math.Abs(dy)))1009- if steps <= 0 {1010- blendPixel(img, int(a.x), int(a.y), c)1011- return1012- }1013- for i := 0; i <= steps; i++ {1014- t := float64(i) / float64(steps)1015- drawDisc(img, a.x+dx*t, a.y+dy*t, 0.82, c)1016- }1017-}1018-1019-func blendPixel(img *image.RGBA, x, y int, src color.RGBA) {1020- if !image.Pt(x, y).In(img.Rect) {1021- return1022- }1023- dst := img.RGBAAt(x, y)1024- alpha := float64(src.A) / 2551025- inv := 1 - alpha1026- img.SetRGBA(x, y, color.RGBA{1027- R: uint8(float64(src.R)*alpha + float64(dst.R)*inv),1028- G: uint8(float64(src.G)*alpha + float64(dst.G)*inv),1029- B: uint8(float64(src.B)*alpha + float64(dst.B)*inv),1030- A: 255,1031- })1032-}1033-1034-func rgbToColor(c RGB) color.RGBA {1035- return color.RGBA{R: c.R, G: c.G, B: c.B, A: 255}1036-}1037-1038-func rgbToHSV(c color.RGBA) (h, s, v float64) {1039- r := float64(c.R) / 2551040- g := float64(c.G) / 2551041- b := float64(c.B) / 2551042- maxC := math.Max(r, math.Max(g, b))1043- minC := math.Min(r, math.Min(g, b))1044- chroma := maxC - minC1045-1046- v = maxC1047- if maxC == 0 {1048- s = 01049- } else {1050- s = chroma / maxC1051- }1052- if chroma == 0 {1053- return 0, s, v1054- }1055-1056- switch maxC {1057- case r:1058- h = math.Mod((g-b)/chroma, 6)1059- case g:1060- h = (b-r)/chroma + 21061- default:1062- h = (r-g)/chroma + 41063- }1064- h /= 61065- if h < 0 {1066- h += 11067- }1068- return h, s, v1069-}1070-1071-func hsvToRGB(h, s, v float64) color.RGBA {1072- h = wrapHue(h)1073- s = clamp01(s)1074- v = clamp01(v)1075- if s == 0 {1076- ch := uint8(math.Round(v * 255))1077- return color.RGBA{R: ch, G: ch, B: ch, A: 255}1078- }1079-1080- h6 := h * 61081- segment := math.Floor(h6)1082- f := h6 - segment1083- p := v * (1 - s)1084- q := v * (1 - s*f)1085- t := v * (1 - s*(1-f))1086-1087- var r, g, b float641088- switch int(segment) % 6 {1089- case 0:1090- r, g, b = v, t, p1091- case 1:1092- r, g, b = q, v, p1093- case 2:1094- r, g, b = p, v, t1095- case 3:1096- r, g, b = p, q, v1097- case 4:1098- r, g, b = t, p, v1099- default:1100- r, g, b = v, p, q1101- }1102-1103- return color.RGBA{1104- R: uint8(math.Round(clamp01(r) * 255)),1105- G: uint8(math.Round(clamp01(g) * 255)),1106- B: uint8(math.Round(clamp01(b) * 255)),1107- A: 255,1108- }1109-}1110-1111-func wrapHue(h float64) float64 {1112- if h >= 0 && h < 1 {1113- return h1114- }1115- h = math.Mod(h, 1)1116- if h < 0 {1117- h += 11118- }1119- return h1120-}1121-1122-func lerpRGB(a, b color.RGBA, t float64) color.RGBA {1123- t = clamp01(t)1124- return color.RGBA{1125- R: uint8(float64(a.R) + (float64(b.R)-float64(a.R))*t),1126- G: uint8(float64(a.G) + (float64(b.G)-float64(a.G))*t),1127- B: uint8(float64(a.B) + (float64(b.B)-float64(a.B))*t),1128- A: 255,1129- }1130-}1131-1132-func blendRGB(base, top color.RGBA, amount float64) color.RGBA {1133- return lerpRGB(base, top, clamp01(amount))1134-}1135-1136-func radialFalloff(x, y, cx, cy, spread float64) float64 {1137- dx := x - cx1138- dy := y - cy1139- distance := math.Sqrt(dx*dx + dy*dy)1140- if distance >= spread {1141- return 01142- }1143- q := 1 - distance/spread1144- return q * q1145-}1146-1147-func powClamp(v, p float64) float64 {1148- return math.Pow(clamp01(v), p)1149-}1150-1151-func clamp01(v float64) float64 {1152- if v < 0 {1153- return 01154- }1155- if v > 1 {1156- return 11157- }1158- return v1159-}1160-1161-func clamp(min, max, v float64) float64 {1162- if v < min {1163- return min1164- }1165- if v > max {1166- return max1167- }1168- return v1169-}1170-1171-func maxInt(a, b int) int {1172- if a > b {1173- return a1174- }1175- return b1176-}1177-1178-func minInt(a, b int) int {1179- if a < b {1180- return a1181- }1182- return b1183-}1184-1185-func maxUint8(a, b uint8) uint8 {1186- if a > b {1187- return a1188- }1189- return b1190-}1191-1192-func minUint8(a, b uint8) uint8 {1193- if a < b {1194- return a1195- }1196- return b1197-}1198-1199-func jitterRGBA(c color.RGBA, rng *rand.Rand, spread int) color.RGBA {1200- if spread <= 0 {1201- return c1202- }1203- half := spread / 21204- return color.RGBA{1205- R: shiftChannel(c.R, rng.Intn(spread)-half),1206- G: shiftChannel(c.G, rng.Intn(spread)-half),1207- B: shiftChannel(c.B, rng.Intn(spread)-half),1208- A: c.A,1209- }1210-}1211-1212-func shiftChannel(ch uint8, delta int) uint8 {1213- v := int(ch) + delta1214- if v < 0 {1215- return 01216- }1217- if v > 255 {1218- return 2551219- }1220- return uint8(v)1221-}1222-1223-func coverStreamRNG(seed uint64, stream string) *rand.Rand {1224- h := fnv.New64a()1225- var raw [8]byte1226- binary.LittleEndian.PutUint64(raw[:], seed)1227- _, _ = h.Write(raw[:])1228- _, _ = h.Write([]byte(stream))1229- return rand.New(rand.NewSource(int64(h.Sum64())))1230-}1231-1232-func coverSeedHash(text string) uint64 {1233- h := fnv.New64a()1234- h.Write([]byte(text))1235- return h.Sum64()1236-}
D pdfexport/cover_art_test.go +0 -204

Not shown: this page already shows 6000 lines of diff.

M pdfexport/jsonl.go +15 -0

Not shown: this page already shows 6000 lines of diff.

M pdfexport/jsonl_test.go +33 -30

Not shown: this page already shows 6000 lines of diff.

A pdfexport/normalize.go +7 -0

Not shown: this page already shows 6000 lines of diff.

M pdfexport/order.go +5 -10

Not shown: this page already shows 6000 lines of diff.

D pdfexport/parse.go +0 -496

Not shown: this page already shows 6000 lines of diff.

A pdfexport/parse_fillomino.go +50 -0

Not shown: this page already shows 6000 lines of diff.

A pdfexport/parse_hashi.go +50 -0

Not shown: this page already shows 6000 lines of diff.

A pdfexport/parse_helpers.go +68 -0

Not shown: this page already shows 6000 lines of diff.

A pdfexport/parse_hitori.go +103 -0

Not shown: this page already shows 6000 lines of diff.

A pdfexport/parse_nonogram.go +129 -0

Not shown: this page already shows 6000 lines of diff.

A pdfexport/parse_nurikabe.go +74 -0

Not shown: this page already shows 6000 lines of diff.

A pdfexport/parse_rippleeffect.go +40 -0

Not shown: this page already shows 6000 lines of diff.

A pdfexport/parse_shikaku.go +54 -0

Not shown: this page already shows 6000 lines of diff.

A pdfexport/parse_sudoku.go +69 -0

Not shown: this page already shows 6000 lines of diff.

A pdfexport/parse_takuzu.go +71 -0

Not shown: this page already shows 6000 lines of diff.

A pdfexport/parse_takuzuplus.go +94 -0

Not shown: this page already shows 6000 lines of diff.

D pdfexport/parse_test.go +0 -134

Not shown: this page already shows 6000 lines of diff.

A pdfexport/parse_wordsearch.go +84 -0

Not shown: this page already shows 6000 lines of diff.

M pdfexport/print_adapter.go +4 -5

Not shown: this page already shows 6000 lines of diff.

D pdfexport/printdata.go +0 -809

Not shown: this page already shows 6000 lines of diff.

M pdfexport/render_cover.go +16 -31

Not shown: this page already shows 6000 lines of diff.

M pdfexport/render_cover_test.go +21 -0

Not shown: this page already shows 6000 lines of diff.

M pdfexport/render_title.go +110 -178

Not shown: this page already shows 6000 lines of diff.

A pdfexport/render_title_test.go +51 -0

Not shown: this page already shows 6000 lines of diff.

M puzzle/puzzle.go +55 -0

Not shown: this page already shows 6000 lines of diff.

A puzzle/puzzle_test.go +84 -0

Not shown: this page already shows 6000 lines of diff.

M registry/registry.go +21 -88

Not shown: this page already shows 6000 lines of diff.

A registry/registry_test.go +189 -0

Not shown: this page already shows 6000 lines of diff.

M rippleeffect/README.md +23 -1

Not shown: this page already shows 6000 lines of diff.

R rippleeffect/Export.go → rippleeffect/export.go +0 -0

No content changes.

R rippleeffect/Gamemode.go → rippleeffect/gamemode.go +19 -16

Not shown: this page already shows 6000 lines of diff.

M rippleeffect/help.md +1 -1

Not shown: this page already shows 6000 lines of diff.

R rippleeffect/Model.go → rippleeffect/model.go +0 -0

No content changes.

R rippleeffect/PrintAdapter.go → rippleeffect/print_adapter.go +2 -4

Not shown: this page already shows 6000 lines of diff.

A schedule/select.go +47 -0

Not shown: this page already shows 6000 lines of diff.

A schedule/select_test.go +69 -0

Not shown: this page already shows 6000 lines of diff.

M session/session.go +9 -9

Not shown: this page already shows 6000 lines of diff.

M session/session_test.go +26 -1

Not shown: this page already shows 6000 lines of diff.

M shikaku/README.md +0 -1

Not shown: this page already shows 6000 lines of diff.

R shikaku/Export.go → shikaku/export.go +0 -0

No content changes.

R shikaku/Gamemode.go → shikaku/gamemode.go +19 -15

Not shown: this page already shows 6000 lines of diff.

M shikaku/help.md +1 -1

Not shown: this page already shows 6000 lines of diff.

R shikaku/Model.go → shikaku/model.go +0 -0

No content changes.

R shikaku/PrintAdapter.go → shikaku/print_adapter.go +2 -4

Not shown: this page already shows 6000 lines of diff.

R spellpuzzle/Export.go → spellpuzzle/export.go +0 -0

No content changes.

R spellpuzzle/Gamemode.go → spellpuzzle/gamemode.go +19 -14

Not shown: this page already shows 6000 lines of diff.

M spellpuzzle/help.md +1 -1

Not shown: this page already shows 6000 lines of diff.

R spellpuzzle/Model.go → spellpuzzle/model.go +0 -0

No content changes.

R spellpuzzle/PrintAdapter.go → spellpuzzle/print_adapter.go +2 -4

Not shown: this page already shows 6000 lines of diff.

M spellpuzzle/print_adapter_test.go +7 -2

Not shown: this page already shows 6000 lines of diff.

M store/game_record.go +9 -0

Not shown: this page already shows 6000 lines of diff.

M store/metadata.go +33 -1

Not shown: this page already shows 6000 lines of diff.

M store/migrate.go +165 -0

Not shown: this page already shows 6000 lines of diff.

M store/stats_test.go +55 -17

Not shown: this page already shows 6000 lines of diff.

M store/store.go +2 -43

Not shown: this page already shows 6000 lines of diff.

M store/store_test.go +147 -4

Not shown: this page already shows 6000 lines of diff.

M sudoku/README.md +0 -1

Not shown: this page already shows 6000 lines of diff.

R sudoku/Export.go → sudoku/export.go +0 -0

No content changes.

R sudoku/Gamemode.go → sudoku/gamemode.go +18 -14

Not shown: this page already shows 6000 lines of diff.

M sudoku/help.md +1 -1

Not shown: this page already shows 6000 lines of diff.

R sudoku/Model.go → sudoku/model.go +0 -0

No content changes.

R sudoku/PrintAdapter.go → sudoku/print_adapter.go +2 -4

Not shown: this page already shows 6000 lines of diff.

M sudokurgb/README.md +0 -1

Not shown: this page already shows 6000 lines of diff.

R sudokurgb/Export.go → sudokurgb/export.go +0 -0

No content changes.

R sudokurgb/Gamemode.go → sudokurgb/gamemode.go +19 -15

Not shown: this page already shows 6000 lines of diff.

M sudokurgb/help.md +1 -1

Not shown: this page already shows 6000 lines of diff.

R sudokurgb/Model.go → sudokurgb/model.go +0 -0

No content changes.

R sudokurgb/PrintAdapter.go → sudokurgb/print_adapter.go +2 -4

Not shown: this page already shows 6000 lines of diff.

M takuzu/README.md +0 -1

Not shown: this page already shows 6000 lines of diff.

R takuzu/Export.go → takuzu/export.go +0 -0

No content changes.

R takuzu/Gamemode.go → takuzu/gamemode.go +19 -15

Not shown: this page already shows 6000 lines of diff.

M takuzu/help.md +1 -1

Not shown: this page already shows 6000 lines of diff.

R takuzu/Model.go → takuzu/model.go +0 -0

No content changes.

R takuzu/PrintAdapter.go → takuzu/print_adapter.go +2 -4

Not shown: this page already shows 6000 lines of diff.

M takuzuplus/README.md +0 -3

Not shown: this page already shows 6000 lines of diff.

R takuzuplus/Export.go → takuzuplus/export.go +0 -0

No content changes.

R takuzuplus/Gamemode.go → takuzuplus/gamemode.go +19 -15

Not shown: this page already shows 6000 lines of diff.

M takuzuplus/help.md +1 -1

Not shown: this page already shows 6000 lines of diff.

R takuzuplus/Model.go → takuzuplus/model.go +0 -0

No content changes.

R takuzuplus/PrintAdapter.go → takuzuplus/print_adapter.go +2 -4

Not shown: this page already shows 6000 lines of diff.

M ui/mainmenu.go +4 -0

Not shown: this page already shows 6000 lines of diff.

M ui/menuitem.go +8 -0

Not shown: this page already shows 6000 lines of diff.

M ui/style.go +9 -0

Not shown: this page already shows 6000 lines of diff.

M ui/table.go +17 -15

Not shown: this page already shows 6000 lines of diff.

M ui/ui_test.go +16 -0

Not shown: this page already shows 6000 lines of diff.

M weekly/weekly.go +4 -33

Not shown: this page already shows 6000 lines of diff.

M weekly/weekly_test.go +0 -35

Not shown: this page already shows 6000 lines of diff.

M wordsearch/README.md +0 -1

Not shown: this page already shows 6000 lines of diff.

R wordsearch/Export.go → wordsearch/export.go +0 -0

No content changes.

R wordsearch/Gamemode.go → wordsearch/gamemode.go +19 -14

Not shown: this page already shows 6000 lines of diff.

M wordsearch/help.md +1 -1

Not shown: this page already shows 6000 lines of diff.

R wordsearch/Model.go → wordsearch/model.go +0 -0

No content changes.

R wordsearch/PrintAdapter.go → wordsearch/print_adapter.go +2 -4

Not shown: this page already shows 6000 lines of diff.