clone

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

refactor

Dami committed f068e92
commit
f068e922edd005712c70181e0e3ef085dafd0a41
parent
c81e86e3ff5b50d8635993ce3a7df0cfaca2cbb7
author
Dami <dami.etoile@gmail.com>
37 files changed, +1411 -855
MREADME.md23+-
Mapp/debug.go8+-
Mapp/game_session.go19+-
Mapp/model.go190++++++-------
Mapp/spawn.go119+++-----
Mapp/spawn_test.go35+-
Mapp/update.go440+++++++++++++++---------------
Mapp/update_test.go57++--
Mapp/view.go48++--
Acatalog/catalog.go213+++++++++++++++
Acatalog/catalog_test.go88++++++
Acmd/config_test.go167+++++++++++
Mcmd/continue.go14+-
Mcmd/export_pdf.go4+-
Mcmd/list.go22+-
Mcmd/new.go58++--
Mcmd/new_export.go4+-
Mcmd/root.go24+-
Mcmd/session.go42+---
Mdaily/daily.go54+---
Mgame/game_test.go82++---
Mgame/gamer.go37++-
Mhashiwokakero/Gamemode.go16+-
Mhitori/Gamemode.go15+-
Mhitori/hitori_test.go11+-
Mlightsout/Gamemode.go16+-
Mnonogram/Gamemode.go15+-
Mnurikabe/Gamemode.go16+-
Mresolve/resolve.go90++----
Mresolve/resolve_test.go71+++--
Asession/session.go98+++++++
Asession/session_test.go101+++++++
Mshikaku/Gamemode.go16+-
Mstore/store.go6-
Msudoku/Gamemode.go15+-
Mtakuzu/Gamemode.go16+-
Mwordsearch/Gamemode.go16+-
M README.md +16 -7
@@ -2,7 +2,7 @@
22 33 A terminal-based puzzle game collection built with [Bubble Tea](https://github.com/charmbracelet/bubbletea).44 5-Nine puzzle types, multiple difficulty modes, daily challenges, XP progression, 365 color themes, and a plugin architecture for adding new games.5+Nine puzzle types, multiple difficulty modes, daily challenges, XP progression, 365 color themes, and an explicit game catalog for adding new games.66 77 ![PuzzleTea menu](vhs/menu.gif)88
@@ -274,7 +274,7 @@ All code must pass `gofumpt` and `golangci-lint` before committing. CI runs both
274274 275275 ## Adding a New Puzzle276276 277-PuzzleTea uses a plugin architecture. To add a new puzzle type:277+PuzzleTea uses an explicit game catalog. To add a new puzzle type:278278 279279 ### 1. Create the game package280280
@@ -282,7 +282,7 @@ Create a directory (e.g., `mypuzzle/`) with these files:
282282 283283 | File | Purpose |284284 |------|---------|285-| `Gamemode.go` | Mode struct embedding `game.BaseMode`, `Spawn()`, `Modes` var, `init()` with `game.Register()` |285+| `Gamemode.go` | Mode struct embedding `game.BaseMode`, `Spawn()`, `Modes`/`DailyModes`, and package-level `Definition` metadata |286286 | `Model.go` | `Model` struct implementing `game.Gamer` |287287 | `Export.go` | `Save` struct, `GetSave()`, `ImportModel()` for persistence |288288 | `keys.go` | Game-specific `KeyMap` struct |
@@ -292,12 +292,21 @@ Create a directory (e.g., `mypuzzle/`) with these files:
292292 | `mypuzzle_test.go` | Tests (table-driven, save/load round-trip, generator validation) |293293 | `README.md` | Game docs: rules, controls table, modes table, quick start examples |294294 295-### 2. Wire it into the main application295+### 2. Wire it into the central catalog296296 297-These files in the project root must be edited:297+Edit the central catalog once:298298 299-- **`model.go`**: Import the package and add a `game.Category` entry to `GameCategories` (maintain alphabetical order). The import triggers `init()`, which registers save/load -- no blank import needed.300-- **`resolve.go`**: Add the canonical name and any CLI aliases to `categoryAliases`.299+- **`catalog/catalog.go`**: Import the package and add its exported `Definition` to `All` (maintain alphabetical order).300+301+The game package's `Definition` owns:302+303+- canonical name304+- description305+- aliases306+- menu modes307+- daily-eligible modes308+- help content309+- save/import function301310 302311 ### 3. Add a VHS preview303312
M app/debug.go +4 -4
M app/game_session.go +7 -12
M app/model.go +93 -97
@@ -6,21 +6,13 @@ import (
66 "context"77 "time"88 9+ "github.com/FelineStateMachine/puzzletea/catalog"910 "github.com/FelineStateMachine/puzzletea/config"1011 "github.com/FelineStateMachine/puzzletea/game"11- "github.com/FelineStateMachine/puzzletea/hashiwokakero"12- "github.com/FelineStateMachine/puzzletea/hitori"13- "github.com/FelineStateMachine/puzzletea/lightsout"14- "github.com/FelineStateMachine/puzzletea/nonogram"15- "github.com/FelineStateMachine/puzzletea/nurikabe"16- "github.com/FelineStateMachine/puzzletea/shikaku"1712 "github.com/FelineStateMachine/puzzletea/stats"1813 "github.com/FelineStateMachine/puzzletea/store"19- "github.com/FelineStateMachine/puzzletea/sudoku"20- "github.com/FelineStateMachine/puzzletea/takuzu"2114 "github.com/FelineStateMachine/puzzletea/theme"2215 "github.com/FelineStateMachine/puzzletea/ui"23- "github.com/FelineStateMachine/puzzletea/wordsearch"2416 2517 "charm.land/bubbles/v2/list"2618 "charm.land/bubbles/v2/spinner"
@@ -32,29 +24,13 @@ import (
3224 "github.com/charmbracelet/glamour"3325 )3426 35-// Categories is the master typed registry of all puzzle types.36-var Categories = []game.Category{37- {Name: "Hashiwokakero", Desc: "Connect islands with bridges.", Modes: hashiwokakero.Modes, Help: hashiwokakero.HelpContent},38- {Name: "Hitori", Desc: "Shade cells to eliminate duplicates.", Modes: hitori.Modes, Help: hitori.HelpContent},39- {Name: "Lights Out", Desc: "Turn off all the lights.", Modes: lightsout.Modes, Help: lightsout.HelpContent},40- {Name: "Nonogram", Desc: "Fill cells to match row and column hints.", Modes: nonogram.Modes, Help: nonogram.HelpContent},41- {Name: "Nurikabe", Desc: "Build islands while keeping one connected sea.", Modes: nurikabe.Modes, Help: nurikabe.HelpContent},42- {Name: "Shikaku", Desc: "Divide the grid into rectangles.", Modes: shikaku.Modes, Help: shikaku.HelpContent},43- {Name: "Sudoku", Desc: "Fill the 9x9 grid following sudoku rules.", Modes: sudoku.Modes, Help: sudoku.HelpContent},44- {Name: "Takuzu", Desc: "Fill the grid with ● and ○.", Modes: takuzu.Modes, Help: takuzu.HelpContent},45- {Name: "Word Search", Desc: "Find hidden words in a letter grid.", Modes: wordsearch.Modes, Help: wordsearch.HelpContent},46-}27+// Categories is the typed catalog view used by stats and print-export helpers.28+var Categories = catalog.Categories()4729 48-// GameCategories is the list.Item view of Categories for bubbles lists.49-var GameCategories = categoriesAsItems(Categories)30+// GameCategories is the list.Item view of Categories for Bubble Tea lists.31+var GameCategories = catalog.CategoryItems()5032 51-func categoriesAsItems(categories []game.Category) []list.Item {52- items := make([]list.Item, len(categories))53- for i, cat := range categories {54- items[i] = cat55- }56- return items57-}33+type viewState int5834 5935 var mainMenuItems = []ui.MenuItem{6036 {ItemTitle: "Play", Desc: "start or continue a puzzle"},
@@ -76,7 +52,7 @@ var optionsMenuItems = []ui.MenuItem{
7652 }7753 7854 const (79- mainMenuView = iota55+ mainMenuView viewState = iota8056 playMenuView8157 optionsMenuView8258 seedInputView
@@ -91,67 +67,87 @@ const (
9167 themeSelectView9268 )9369 94-type model struct {95- state int70+type navigationState struct {71+ mainMenu ui.MainMenu72+ playMenu ui.MainMenu73+ optionsMenu ui.MainMenu74+ gameSelectList list.Model75+ modeSelectList list.Model76+ selectedCategory game.Category77+ selectedModeTitle string78+ continueTable table.Model79+ continueGames []store.GameRecord80+ seedInput textinput.Model81+ helpSelectList list.Model82+}9683 97- mainMenu ui.MainMenu98- playMenu ui.MainMenu99- optionsMenu ui.MainMenu100- gameSelectList list.Model101- modeSelectList list.Model102- selectedCategory game.Category84+type sessionState struct {85+ game game.Gamer86+ activeGameID int6487+ completionSaved bool88+ generating bool89+ spawnJobID int6490+ spawnCancel context.CancelFunc91+ spawn *spawnRequest92+}10393 104- continueTable table.Model105- continueGames []store.GameRecord94+type helpState struct {95+ category game.Category96+ viewport viewport.Model97+ renderer *glamour.TermRenderer98+ rendererWidth int99+ showFull bool100+}106101 107- selectedModeTitle string108- game game.Gamer102+type statsState struct {103+ cards []stats.Card104+ profile stats.ProfileBanner105+ viewport viewport.Model106+}109107 110- spinner spinner.Model111- generating bool // true while an async Spawn is in flight112- spawnJobID int64113- spawnCancel context.CancelFunc108+type themeState struct {109+ list list.Model110+ previous string111+}114112 115- width int // available content width (terminal - rootStyle frame)116- height int // available content height (terminal - rootStyle frame)113+type debugState struct {114+ enabled bool115+ renderer *glamour.TermRenderer116+ info string117+}117118 118- debug bool119- debugRenderer *glamour.TermRenderer120- debuginfo string121- showFullHelp bool119+type spawnSource string122120 123- store *store.Store124- activeGameID int64125- completionSaved bool121+const (122+ spawnSourceNormal spawnSource = "normal"123+ spawnSourceDaily spawnSource = "daily"124+ spawnSourceSeed spawnSource = "seed"125+)126126 127- // Daily puzzle state128- dailyPending bool // true while generating a daily puzzle129- dailyName string // pre-computed daily name130- dailyGameType string // e.g. "Nonogram"131- dailyModeTitle string // e.g. "Standard"127+type spawnRequest struct {128+ source spawnSource129+ name string130+ gameType string131+ modeTitle string132+ returnState viewState133+}132134 133- // Seed input state134- seedInput textinput.Model135- seedPending bool // true while generating a seeded puzzle136- seedName string // pre-computed seed-derived name137- seedGameType string // game type from rendezvous hashing138- seedModeTitle string // mode title from rendezvous hashing135+type model struct {136+ state viewState139137 140- // Help page state141- helpSelectList list.Model142- helpCategory game.Category143- helpViewport viewport.Model144- helpRenderer *glamour.TermRenderer145- helpRendererWidth int138+ nav navigationState139+ session sessionState140+ help helpState141+ stats statsState142+ theme themeState143+ debug debugState146144 147- // Stats page state148- statsCards []stats.Card149- statsProfile stats.ProfileBanner150- statsViewport viewport.Model145+ spinner spinner.Model146+147+ width int // available content width (terminal - rootStyle frame)148+ height int // available content height (terminal - rootStyle frame)151149 152- // Theme picker state153- themeList list.Model154- previousTheme string // for revert on Esc150+ store *store.Store155151 156152 // Config157153 cfg *config.Config
@@ -171,13 +167,12 @@ func InitialModel(s *store.Store, cfg *config.Config) model {
171167 l := ui.InitCategoryList(GameCategories, "Select Category")172168 mm := ui.NewMainMenu(mainMenuItems)173169 return model{174- state: mainMenuView,175- debugRenderer: r,176- gameSelectList: l,177- mainMenu: mm,178- spinner: newSpinner(),179- store: s,180- cfg: cfg,170+ state: mainMenuView,171+ debug: debugState{renderer: r},172+ nav: navigationState{gameSelectList: l, mainMenu: mm},173+ spinner: newSpinner(),174+ store: s,175+ cfg: cfg,181176 }182177 }183178
@@ -188,16 +183,17 @@ func InitialModelWithGame(s *store.Store, cfg *config.Config, g game.Gamer, acti
188183 l := ui.InitCategoryList(GameCategories, "Select Category")189184 mm := ui.NewMainMenu(mainMenuItems)190185 return model{191- state: gameView,192- debugRenderer: r,193- gameSelectList: l,194- mainMenu: mm,195- spinner: newSpinner(),196- store: s,197- cfg: cfg,198- game: g,199- activeGameID: activeGameID,200- completionSaved: completionSaved,186+ state: gameView,187+ debug: debugState{renderer: r},188+ nav: navigationState{gameSelectList: l, mainMenu: mm},189+ spinner: newSpinner(),190+ store: s,191+ cfg: cfg,192+ session: sessionState{193+ game: g,194+ activeGameID: activeGameID,195+ completionSaved: completionSaved,196+ },201197 }202198 }203199
M app/spawn.go +40 -79
@@ -4,11 +4,10 @@ import (
44 "context"55 "log"66 "math/rand/v2"7- "strconv"87 "time"98 109 "github.com/FelineStateMachine/puzzletea/game"11- "github.com/FelineStateMachine/puzzletea/namegen"10+ sessionflow "github.com/FelineStateMachine/puzzletea/session"1211 "github.com/FelineStateMachine/puzzletea/store"1312 1413 tea "charm.land/bubbletea/v2"
@@ -63,40 +62,39 @@ func spawnSeededCmd(spawner game.SeededSpawner, rng *rand.Rand, ctx context.Cont
6362 6463 func (m *model) beginSpawnContext() (context.Context, int64) {6564 m.cancelActiveSpawn()66- m.spawnJobID++67- jobID := m.spawnJobID65+ m.session.spawnJobID++66+ jobID := m.session.spawnJobID6867 ctx, cancel := context.WithTimeout(context.Background(), spawnTimeout)69- m.spawnCancel = cancel70- m.generating = true68+ m.session.spawnCancel = cancel69+ m.session.generating = true7170 return ctx, jobID7271 }7372 7473 func (m *model) cancelActiveSpawn() {75- if m.spawnCancel != nil {76- m.spawnCancel()77- m.spawnCancel = nil74+ if m.session.spawnCancel != nil {75+ m.session.spawnCancel()76+ m.session.spawnCancel = nil7877 }79- if m.generating {78+ if m.session.generating {8079 // Invalidate late completion messages from a canceled job.81- m.spawnJobID++80+ m.session.spawnJobID++8281 }83- m.generating = false82+ m.session.generating = false83+ m.session.spawn = nil8484 }8585 8686 func (m model) handleSpawnComplete(jobID int64, msg game.SpawnCompleteMsg) (tea.Model, tea.Cmd) {87- if jobID != m.spawnJobID {87+ if jobID != m.session.spawnJobID {8888 return m, nil8989 }9090 91- m.generating = false92- if m.spawnCancel != nil {93- m.spawnCancel()94- m.spawnCancel = nil91+ m.session.generating = false92+ if m.session.spawnCancel != nil {93+ m.session.spawnCancel()94+ m.session.spawnCancel = nil9595 }96- isDaily := m.dailyPending97- isSeed := m.seedPending98- m.dailyPending = false99- m.seedPending = false96+ request := m.session.spawn97+ m.session.spawn = nil10098 10199 // If the user navigated away while generating, discard the result.102100 if m.state != generatingView {
@@ -105,91 +103,54 @@ func (m model) handleSpawnComplete(jobID int64, msg game.SpawnCompleteMsg) (tea.
105103 106104 if msg.Err != nil {107105 log.Printf("failed to spawn game: %v", msg.Err)108- if isDaily || isSeed {109- m.state = playMenuView110- } else {111- m.state = modeSelectView106+ if request != nil {107+ m.state = request.returnState112108 }113109 return m, nil114110 }115111 116- var name, gameType, modeTitle string117- if isDaily {118- name = m.dailyName119- gameType = m.dailyGameType120- modeTitle = m.dailyModeTitle121- } else if isSeed {122- name = m.seedName123- gameType = m.seedGameType124- modeTitle = m.seedModeTitle125- } else {126- name = GenerateUniqueName(m.store)127- gameType = m.selectedCategory.Name128- modeTitle = m.selectedModeTitle112+ if request == nil {113+ log.Printf("missing spawn request metadata")114+ return m, nil129115 }130- m = m.activateGame(msg.Game.SetTitle(name), 0, false)116+ m = m.activateGame(msg.Game.SetTitle(request.name), 0, false)131117 132118 // Capture initial state and create DB record.133- initialState, err := m.game.GetSave()119+ rec, err := sessionflow.CreateRecord(120+ m.store,121+ m.session.game,122+ request.name,123+ request.gameType,124+ request.modeTitle,125+ )134126 if err != nil {135- log.Printf("failed to get initial save: %v", err)136- return m, nil137- }138- rec := &store.GameRecord{139- Name: name,140- GameType: gameType,141- Mode: modeTitle,142- InitialState: string(initialState),143- SaveState: string(initialState),144- Status: store.StatusNew,145- }146- if err := m.store.CreateGame(rec); err != nil {147127 log.Printf("failed to create game record: %v", err)148128 } else {149- m.activeGameID = rec.ID129+ m.session.activeGameID = rec.ID150130 }151131 return m, nil152132 }153133 154134 // saveCurrentGame saves the current game state to the DB if a game is active.155135 func saveCurrentGame(m model, status store.GameStatus) model {156- if m.game == nil || m.activeGameID == 0 {136+ if m.session.game == nil || m.session.activeGameID == 0 {157137 return m158138 }159- saveData, err := m.game.GetSave()139+ saveData, err := m.session.game.GetSave()160140 if err != nil {161141 log.Printf("failed to get save data: %v", err)162142 return m163143 }164- if err := m.store.UpdateSaveState(m.activeGameID, string(saveData)); err != nil {144+ if err := m.store.UpdateSaveState(m.session.activeGameID, string(saveData)); err != nil {165145 log.Printf("failed to update save state: %v", err)166146 }167147 // Don't overwrite a completed status when navigating away.168- if !(m.completionSaved && status != store.StatusCompleted) {169- if err := m.store.UpdateStatus(m.activeGameID, status); err != nil {148+ if !(m.session.completionSaved && status != store.StatusCompleted) {149+ if err := m.store.UpdateStatus(m.session.activeGameID, status); err != nil {170150 log.Printf("failed to update game status: %v", err)171151 }172152 }173- m.activeGameID = 0174- m.game = nil153+ m.session.activeGameID = 0154+ m.session.game = nil175155 return m176156 }177-178-// GenerateUniqueName generates a unique adjective-noun name, retrying if needed.179-func GenerateUniqueName(s *store.Store) string {180- for range 100 {181- name := namegen.Generate()182- exists, err := s.NameExists(name)183- if err != nil || !exists {184- return name185- }186- }187- // Fallback: append a number188- for i := 1; ; i++ {189- name := namegen.Generate() + "-" + strconv.Itoa(i)190- exists, err := s.NameExists(name)191- if err != nil || !exists {192- return name193- }194- }195-}
M app/spawn_test.go +21 -14
@@ -10,18 +10,20 @@ import (
1010 func TestHandleSpawnCompleteIgnoresStaleJob(t *testing.T) {1111 called := false1212 m := model{13- state: generatingView,14- generating: true,15- spawnJobID: 7,16- spawnCancel: func() {17- called = true13+ state: generatingView,14+ session: sessionState{15+ generating: true,16+ spawnJobID: 7,17+ spawnCancel: func() {18+ called = true19+ },1820 },1921 }2022 2123 next, _ := m.handleSpawnComplete(6, game.SpawnCompleteMsg{Err: nil})2224 got := next.(model)2325 24- if !got.generating {26+ if !got.session.generating {2527 t.Fatal("expected stale completion to be ignored")2628 }2729 if called {
@@ -32,12 +34,17 @@ func TestHandleSpawnCompleteIgnoresStaleJob(t *testing.T) {
3234 func TestGeneratingEscapeCancelsActiveSpawn(t *testing.T) {3335 called := false3436 m := model{35- state: generatingView,36- generating: true,37- seedPending: true,38- spawnJobID: 3,39- spawnCancel: func() {40- called = true37+ state: generatingView,38+ session: sessionState{39+ generating: true,40+ spawnJobID: 3,41+ spawnCancel: func() {42+ called = true43+ },44+ spawn: &spawnRequest{45+ source: spawnSourceSeed,46+ returnState: playMenuView,47+ },4148 },4249 }4350
@@ -47,13 +54,13 @@ func TestGeneratingEscapeCancelsActiveSpawn(t *testing.T) {
4754 if !called {4855 t.Fatal("expected spawn cancellation callback to run")4956 }50- if got.generating {57+ if got.session.generating {5158 t.Fatal("expected generating to be false after escape")5259 }5360 if got.state != playMenuView {5461 t.Fatalf("expected playMenuView after seeded generation escape, got %d", got.state)5562 }56- if got.spawnCancel != nil {63+ if got.session.spawnCancel != nil {5764 t.Fatal("expected spawnCancel to be cleared")5865 }5966 }
M app/update.go +219 -221
@@ -2,14 +2,14 @@ package app
22 33 import (44 "log"5- "strings"65 "time"76 7+ "github.com/FelineStateMachine/puzzletea/catalog"88 "github.com/FelineStateMachine/puzzletea/config"99 "github.com/FelineStateMachine/puzzletea/daily"1010 "github.com/FelineStateMachine/puzzletea/game"11- "github.com/FelineStateMachine/puzzletea/namegen"1211 "github.com/FelineStateMachine/puzzletea/resolve"12+ sessionflow "github.com/FelineStateMachine/puzzletea/session"1313 "github.com/FelineStateMachine/puzzletea/stats"1414 "github.com/FelineStateMachine/puzzletea/store"1515 "github.com/FelineStateMachine/puzzletea/theme"
@@ -47,24 +47,24 @@ func statsViewportSize(width, height int, cards []stats.Card) (int, int) {
4747 4848 func (m model) updateHelpDetailViewport() model {4949 helpWidth, helpHeight := helpViewportSize(m.width, m.height)50- if m.helpRenderer == nil || m.helpRendererWidth != helpWidth {50+ if m.help.renderer == nil || m.help.rendererWidth != helpWidth {5151 renderer, err := glamour.NewTermRenderer(5252 glamour.WithAutoStyle(),5353 glamour.WithWordWrap(helpWidth),5454 )5555 if err != nil {5656 log.Printf("failed to create help renderer: %v", err)57- m.helpRenderer = nil58- m.helpRendererWidth = 057+ m.help.renderer = nil58+ m.help.rendererWidth = 05959 } else {60- m.helpRenderer = renderer61- m.helpRendererWidth = helpWidth60+ m.help.renderer = renderer61+ m.help.rendererWidth = helpWidth6262 }6363 }6464 65- rendered := m.helpCategory.Help66- if m.helpRenderer != nil {67- out, err := m.helpRenderer.Render(m.helpCategory.Help)65+ rendered := m.help.category.Help66+ if m.help.renderer != nil {67+ out, err := m.help.renderer.Render(m.help.category.Help)6868 if err != nil {6969 log.Printf("failed to render help: %v", err)7070 } else {
@@ -72,19 +72,19 @@ func (m model) updateHelpDetailViewport() model {
7272 }7373 }7474 75- m.helpViewport = viewport.New(75+ m.help.viewport = viewport.New(7676 viewport.WithWidth(helpWidth),7777 viewport.WithHeight(helpHeight),7878 )79- m.helpViewport.SetContent(rendered)79+ m.help.viewport.SetContent(rendered)8080 return m8181 }8282 8383 func (m model) updateStatsViewport() model {84- statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.statsCards)85- m.statsViewport.SetWidth(statsWidth)86- m.statsViewport.SetHeight(statsHeight)87- m.statsViewport.SetContent(stats.RenderCardGrid(m.statsCards, statsWidth))84+ statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.stats.cards)85+ m.stats.viewport.SetWidth(statsWidth)86+ m.stats.viewport.SetHeight(statsHeight)87+ m.stats.viewport.SetContent(stats.RenderCardGrid(m.stats.cards, statsWidth))8888 return m8989 }9090
@@ -96,136 +96,53 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
9696 return m.handleSpawnComplete(msg.jobID, msg.result)9797 case game.SpawnCompleteMsg:9898 // Backward compatibility for callers still using the old message type.99- return m.handleSpawnComplete(m.spawnJobID, msg)100-99+ return m.handleSpawnComplete(m.session.spawnJobID, msg)101100 case tea.WindowSizeMsg:102- m.width = msg.Width103- m.height = msg.Height104- menuW := min(m.width, 64)105- m.gameSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.gameSelectList)))106- if m.state == seedInputView {107- m.seedInput.SetWidth(min(m.width, 48))108- }109- if m.state == modeSelectView {110- m.modeSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.modeSelectList)))111- }112- if m.state == continueView {113- m.continueTable.SetWidth(m.width)114- visibleRows := min(len(m.continueGames), ui.MaxTableRows)115- m.continueTable.SetHeight(min(m.height, visibleRows))116- }117- if m.state == helpSelectView {118- m.helpSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.helpSelectList)))119- }120- if m.state == helpDetailView {121- m = m.updateHelpDetailViewport()122- }123- if m.state == themeSelectView {124- const maxVisibleItems = 8125- listW := min(menuW, theme.MaxNameLen+4)126- m.themeList.SetSize(listW, min(m.height, maxVisibleItems*3))127- }128- if m.state == statsView {129- m = m.updateStatsViewport()130- }131-101+ m = m.handleWindowSize(msg)132102 case tea.KeyPressMsg:133- // During generation, only allow Escape (to cancel) and Ctrl+C (to quit).134- if m.state == generatingView {135- switch {136- case key.Matches(msg, rootKeys.Escape):137- m.cancelActiveSpawn()138- if m.dailyPending || m.seedPending {139- m.dailyPending = false140- m.seedPending = false141- m.state = playMenuView142- } else {143- m.state = modeSelectView144- }145- return m, nil146- case key.Matches(msg, rootKeys.Quit):147- return m, tea.Quit148- }149- return m, nil150- }151- switch {152- case key.Matches(msg, rootKeys.MainMenu):153- m = saveCurrentGame(m, store.StatusInProgress)154- m.state = mainMenuView155- m.debug = false156- case key.Matches(msg, rootKeys.Enter):157- if m.state != gameView {158- return m.handleEnter()159- }160- case key.Matches(msg, rootKeys.Escape):161- if m.state != gameView {162- return m.handleEscape()163- }164- case key.Matches(msg, rootKeys.Quit):165- m = saveCurrentGame(m, store.StatusAbandoned)166- return m, tea.Quit167- case key.Matches(msg, rootKeys.Debug):168- m.debug = !m.debug169- case key.Matches(msg, rootKeys.FullHelp):170- m.showFullHelp = !m.showFullHelp171- if m.state == gameView && m.game != nil {172- m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})173- }174- case key.Matches(msg, rootKeys.ResetGame):175- if m.state == gameView && m.game != nil {176- m.game = m.game.Reset()177- }103+ next, keyCmd, done := m.handleGlobalKey(msg)104+ if done {105+ return next, keyCmd178106 }107+ m = next179108 }180109 181110 switch m.state {182111 case mainMenuView:183- updateMainMenuCursor(msg, &m.mainMenu)112+ updateMainMenuCursor(msg, &m.nav.mainMenu)184113 case playMenuView:185- updateMainMenuCursor(msg, &m.playMenu)114+ updateMainMenuCursor(msg, &m.nav.playMenu)186115 case optionsMenuView:187- updateMainMenuCursor(msg, &m.optionsMenu)116+ updateMainMenuCursor(msg, &m.nav.optionsMenu)188117 case seedInputView:189- m.seedInput, cmd = m.seedInput.Update(msg)118+ m.nav.seedInput, cmd = m.nav.seedInput.Update(msg)190119 case generatingView:191120 m.spinner, cmd = m.spinner.Update(msg)192121 case gameView:193- m.game, cmd = m.game.Update(msg)194- if m.debug {195- m.debuginfo = m.renderDebugInfo()196- }197- if !m.completionSaved && m.game.IsSolved() {198- m.completionSaved = true199- saveData, err := m.game.GetSave()200- if err == nil {201- if err := m.store.UpdateSaveState(m.activeGameID, string(saveData)); err != nil {202- log.Printf("failed to save completion state: %v", err)203- }204- }205- if err := m.store.UpdateStatus(m.activeGameID, store.StatusCompleted); err != nil {206- log.Printf("failed to mark game completed: %v", err)207- }122+ m.session.game, cmd = m.session.game.Update(msg)123+ if m.debug.enabled {124+ m.debug.info = m.renderDebugInfo()208125 }126+ m = m.persistCompletionIfSolved()209127 case gameSelectView:210- m.gameSelectList, cmd = m.gameSelectList.Update(msg)128+ m.nav.gameSelectList, cmd = m.nav.gameSelectList.Update(msg)211129 case modeSelectView:212- m.modeSelectList, cmd = m.modeSelectList.Update(msg)130+ m.nav.modeSelectList, cmd = m.nav.modeSelectList.Update(msg)213131 case continueView:214- m.continueTable, cmd = m.continueTable.Update(msg)132+ m.nav.continueTable, cmd = m.nav.continueTable.Update(msg)215133 case helpSelectView:216- m.helpSelectList, cmd = m.helpSelectList.Update(msg)134+ m.nav.helpSelectList, cmd = m.nav.helpSelectList.Update(msg)217135 case helpDetailView:218- m.helpViewport, cmd = m.helpViewport.Update(msg)136+ m.help.viewport, cmd = m.help.viewport.Update(msg)219137 case statsView:220- m.statsViewport, cmd = m.statsViewport.Update(msg)138+ m.stats.viewport, cmd = m.stats.viewport.Update(msg)221139 case themeSelectView:222- prev := m.themeList.Index()223- m.themeList, cmd = m.themeList.Update(msg)224- // Live preview: apply theme when cursor moves.225- if m.themeList.Index() != prev {226- if item, ok := m.themeList.SelectedItem().(ui.MenuItem); ok {140+ prev := m.theme.list.Index()141+ m.theme.list, cmd = m.theme.list.Update(msg)142+ if m.theme.list.Index() != prev {143+ if item, ok := m.theme.list.SelectedItem().(ui.MenuItem); ok {227144 _ = theme.Apply(item.ItemTitle)228- ui.UpdateThemeListStyles(&m.themeList)145+ ui.UpdateThemeListStyles(&m.theme.list)229146 }230147 }231148 }
@@ -233,6 +150,112 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
233150 return m, cmd234151 }235152 153+func (m model) handleWindowSize(msg tea.WindowSizeMsg) model {154+ m.width = msg.Width155+ m.height = msg.Height156+ menuW := min(m.width, 64)157+ m.nav.gameSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.nav.gameSelectList)))158+ if m.state == seedInputView {159+ m.nav.seedInput.SetWidth(min(m.width, 48))160+ }161+ if m.state == modeSelectView {162+ m.nav.modeSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.nav.modeSelectList)))163+ }164+ if m.state == continueView {165+ m.nav.continueTable.SetWidth(m.width)166+ visibleRows := min(len(m.nav.continueGames), ui.MaxTableRows)167+ m.nav.continueTable.SetHeight(min(m.height, visibleRows))168+ }169+ if m.state == helpSelectView {170+ m.nav.helpSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.nav.helpSelectList)))171+ }172+ if m.state == helpDetailView {173+ m = m.updateHelpDetailViewport()174+ }175+ if m.state == themeSelectView {176+ const maxVisibleItems = 8177+ listW := min(menuW, theme.MaxNameLen+4)178+ m.theme.list.SetSize(listW, min(m.height, maxVisibleItems*3))179+ }180+ if m.state == statsView {181+ m = m.updateStatsViewport()182+ }183+ return m184+}185+186+func (m model) handleGlobalKey(msg tea.KeyPressMsg) (model, tea.Cmd, bool) {187+ if m.state == generatingView {188+ switch {189+ case key.Matches(msg, rootKeys.Escape):190+ returnState := m.activeSpawnReturnState()191+ m.cancelActiveSpawn()192+ m.state = returnState193+ return m, nil, true194+ case key.Matches(msg, rootKeys.Quit):195+ return m, tea.Quit, true196+ }197+ return m, nil, true198+ }199+200+ switch {201+ case key.Matches(msg, rootKeys.MainMenu):202+ m = saveCurrentGame(m, store.StatusInProgress)203+ m.state = mainMenuView204+ m.debug.enabled = false205+ case key.Matches(msg, rootKeys.Enter):206+ if m.state != gameView {207+ next, cmd := m.handleEnter()208+ return next.(model), cmd, true209+ }210+ case key.Matches(msg, rootKeys.Escape):211+ if m.state != gameView {212+ next, cmd := m.handleEscape()213+ return next.(model), cmd, true214+ }215+ case key.Matches(msg, rootKeys.Quit):216+ m = saveCurrentGame(m, store.StatusAbandoned)217+ return m, tea.Quit, true218+ case key.Matches(msg, rootKeys.Debug):219+ m.debug.enabled = !m.debug.enabled220+ case key.Matches(msg, rootKeys.FullHelp):221+ m.help.showFull = !m.help.showFull222+ if m.state == gameView && m.session.game != nil {223+ m.session.game, _ = m.session.game.Update(game.HelpToggleMsg{Show: m.help.showFull})224+ }225+ case key.Matches(msg, rootKeys.ResetGame):226+ if m.state == gameView && m.session.game != nil {227+ m.session.game = m.session.game.Reset()228+ }229+ }230+231+ return m, nil, false232+}233+234+func (m model) activeSpawnReturnState() viewState {235+ if m.session.spawn == nil {236+ return modeSelectView237+ }238+ return m.session.spawn.returnState239+}240+241+func (m model) persistCompletionIfSolved() model {242+ if m.session.game == nil || m.session.completionSaved || !m.session.game.IsSolved() {243+ return m244+ }245+246+ m.session.completionSaved = true247+ saveData, err := m.session.game.GetSave()248+ if err == nil {249+ if err := m.store.UpdateSaveState(m.session.activeGameID, string(saveData)); err != nil {250+ log.Printf("failed to save completion state: %v", err)251+ }252+ }253+ if err := m.store.UpdateStatus(m.session.activeGameID, store.StatusCompleted); err != nil {254+ log.Printf("failed to mark game completed: %v", err)255+ }256+ return m257+}258+236259 func updateMainMenuCursor(msg tea.Msg, menu *ui.MainMenu) {237260 keyMsg, ok := msg.(tea.KeyPressMsg)238261 if !ok {
@@ -271,15 +294,15 @@ func (m model) handleEnter() (tea.Model, tea.Cmd) {
271294 }272295 273296 func (m model) handleMainMenuEnter() (tea.Model, tea.Cmd) {274- item := m.mainMenu.Selected()297+ item := m.nav.mainMenu.Selected()275298 switch item.Title() {276299 case "Play":277- m.playMenu = ui.NewMainMenu(playMenuItems)300+ m.nav.playMenu = ui.NewMainMenu(playMenuItems)278301 m.state = playMenuView279302 case "Stats":280303 return m.handleStatsEnter()281304 case "Options":282- m.optionsMenu = ui.NewMainMenu(optionsMenuItems)305+ m.nav.optionsMenu = ui.NewMainMenu(optionsMenuItems)283306 m.state = optionsMenuView284307 case "Quit":285308 return m, tea.Quit
@@ -288,81 +311,64 @@ func (m model) handleMainMenuEnter() (tea.Model, tea.Cmd) {
288311 }289312 290313 func (m model) handlePlayMenuEnter() (tea.Model, tea.Cmd) {291- item := m.playMenu.Selected()314+ item := m.nav.playMenu.Selected()292315 switch item.Title() {293316 case "Daily Puzzle":294317 return m.handleDailyPuzzle()295318 case "Generate":296319 m.state = gameSelectView297320 case "Continue":298- m.continueTable, m.continueGames = ui.InitContinueTable(m.store, m.height)321+ m.nav.continueTable, m.nav.continueGames = ui.InitContinueTable(m.store, m.height)299322 m.state = continueView300323 case "Enter Seed":301324 ti := textinput.New()302325 ti.Placeholder = "any word or phrase"303326 ti.CharLimit = 64304327 ti.SetWidth(min(m.width, 48))305- m.seedInput = ti328+ m.nav.seedInput = ti306329 m.state = seedInputView307- return m, m.seedInput.Focus()330+ return m, m.nav.seedInput.Focus()308331 }309332 return m, nil310333 }311334 312335 func (m model) handleOptionsMenuEnter() (tea.Model, tea.Cmd) {313- item := m.optionsMenu.Selected()336+ item := m.nav.optionsMenu.Selected()314337 switch item.Title() {315338 case "Theme":316339 return m.handleThemeEnter()317340 case "Guides":318- m.helpSelectList = ui.InitList(GameCategories, "How to Play")319- m.helpSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.helpSelectList)))341+ m.nav.helpSelectList = ui.InitList(GameCategories, "How to Play")342+ m.nav.helpSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.nav.helpSelectList)))320343 m.state = helpSelectView321344 }322345 return m, nil323346 }324347 325348 func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {326- seed := strings.TrimSpace(m.seedInput.Value())349+ seed := sessionflow.NormalizeSeed(m.nav.seedInput.Value())327350 if seed == "" {328351 return m, nil329352 }330353 331- // Prevent seeded puzzles from mimicking daily puzzle names by332- // silently lowercasing a "Daily" prefix so titles never start333- // with the real daily prefix "Daily ".334- if strings.HasPrefix(strings.ToLower(seed), "daily") {335- seed = strings.ToLower(seed[:len("daily")]) + seed[len("daily"):]336- }337-338- // Deterministic name from seed, using an independent sub-RNG so339- // namegen changes don't affect mode selection or puzzle content.340- nameRNG := resolve.RNGFromString("name:" + seed)341- name := seed + " - " + namegen.GenerateSeeded(nameRNG)342-343- // Check if a game with this seed-derived name already exists.354+ name := sessionflow.SeededName(seed)344355 rec, err := m.store.GetDailyGame(name)345356 if err != nil {346357 log.Printf("failed to check seeded game: %v", err)347358 return m, nil348359 }349-350360 if rec != nil {351- // Seed game exists - resume it.352361 var resumed bool353362 m, resumed = m.importAndActivateRecord(*rec)354-355- // Resume abandoned seeded games by resetting their status.356- if resumed && rec.Status == store.StatusAbandoned {357- if err := m.store.UpdateStatus(rec.ID, store.StatusInProgress); err != nil {358- log.Printf("failed to mark seeded game in progress: %v", err)363+ if resumed {364+ if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, rec); err != nil {365+ log.Printf("%v", err)359366 }360367 }361368 return m, nil362369 }363370 364- // No existing game — generate a new seeded puzzle.365- spawner, gameType, modeTitle, err := resolve.SeededMode(seed, GameCategories)371+ spawner, gameType, modeTitle, err := resolve.SeededMode(seed, catalog.All)366372 if err != nil {367373 log.Printf("failed to select seeded mode: %v", err)368374 return m, nil
@@ -370,10 +376,13 @@ func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {
370376 371377 rng := resolve.RNGFromString(seed)372378 ctx, jobID := m.beginSpawnContext()373- m.seedPending = true374- m.seedName = name375- m.seedGameType = gameType376- m.seedModeTitle = modeTitle379+ m.session.spawn = &spawnRequest{380+ source: spawnSourceSeed,381+ name: name,382+ gameType: gameType,383+ modeTitle: modeTitle,384+ returnState: playMenuView,385+ }377386 m.state = generatingView378387 return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))379388 }
@@ -382,59 +391,55 @@ func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {
382391 today := time.Now()383392 name := daily.Name(today)384393 385- // Check if a daily game already exists for today.386394 rec, err := m.store.GetDailyGame(name)387395 if err != nil {388396 log.Printf("failed to check daily game: %v", err)389397 return m, nil390398 }391-392399 if rec != nil {393- // Daily exists - resume or review it.394400 var resumed bool395401 m, resumed = m.importAndActivateRecord(*rec)396-397- // Resume abandoned dailies by resetting their status.398- if resumed && rec.Status == store.StatusAbandoned {399- if err := m.store.UpdateStatus(rec.ID, store.StatusInProgress); err != nil {400- log.Printf("failed to mark daily game in progress: %v", err)402+ if resumed {403+ if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, rec); err != nil {404+ log.Printf("%v", err)401405 }402406 }403407 return m, nil404408 }405409 406- // No existing daily - generate a new one.407- // Mode selection uses rendezvous hashing (independent of RNG).408- // RNG is reserved purely for puzzle content generation.409410 spawner, gameType, modeTitle := daily.Mode(today)410411 if spawner == nil {411412 log.Printf("no daily mode available for %s", today.Format("2006-01-02"))412413 return m, nil413414 }415+414416 rng := daily.RNG(today)415417 ctx, jobID := m.beginSpawnContext()416- m.dailyPending = true417- m.dailyName = name418- m.dailyGameType = gameType419- m.dailyModeTitle = modeTitle418+ m.session.spawn = &spawnRequest{419+ source: spawnSourceDaily,420+ name: name,421+ gameType: gameType,422+ modeTitle: modeTitle,423+ returnState: playMenuView,424+ }420425 m.state = generatingView421426 return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))422427 }423428 424429 func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) {425- cat, ok := m.gameSelectList.SelectedItem().(game.Category)430+ cat, ok := m.nav.gameSelectList.SelectedItem().(game.Category)426431 if !ok {427432 return m, nil428433 }429- m.selectedCategory = cat430- m.modeSelectList = ui.InitList(cat.Modes, cat.Name+" - Select Mode")431- m.modeSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.modeSelectList)))434+ m.nav.selectedCategory = cat435+ m.nav.modeSelectList = ui.InitList(cat.Modes, cat.Name+" - Select Mode")436+ m.nav.modeSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.nav.modeSelectList)))432437 m.state = modeSelectView433438 return m, nil434439 }435440 436441 func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {437- item := m.modeSelectList.SelectedItem()442+ item := m.nav.modeSelectList.SelectedItem()438443 mode, ok := item.(game.Mode)439444 if !ok {440445 return m, nil
@@ -443,28 +448,35 @@ func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {
443448 if !ok {444449 return m, nil445450 }446- m.selectedModeTitle = mode.Title()451+ m.nav.selectedModeTitle = mode.Title()447452 ctx, jobID := m.beginSpawnContext()453+ m.session.spawn = &spawnRequest{454+ source: spawnSourceNormal,455+ name: sessionflow.GenerateUniqueName(m.store),456+ gameType: m.nav.selectedCategory.Name,457+ modeTitle: m.nav.selectedModeTitle,458+ returnState: modeSelectView,459+ }448460 m.state = generatingView449461 return m, tea.Batch(m.spinner.Tick, spawnCmd(spawner, ctx, jobID))450462 }451463 452464 func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {453- idx := m.continueTable.Cursor()454- if idx < 0 || idx >= len(m.continueGames) {465+ idx := m.nav.continueTable.Cursor()466+ if idx < 0 || idx >= len(m.nav.continueGames) {455467 return m, nil456468 }457- rec := m.continueGames[idx]469+ rec := m.nav.continueGames[idx]458470 m, _ = m.importAndActivateRecord(rec)459471 return m, nil460472 }461473 462474 func (m model) handleHelpSelectEnter() (tea.Model, tea.Cmd) {463- cat, ok := m.helpSelectList.SelectedItem().(game.Category)475+ cat, ok := m.nav.helpSelectList.SelectedItem().(game.Category)464476 if !ok {465477 return m, nil466478 }467- m.helpCategory = cat479+ m.help.category = cat468480 m = m.updateHelpDetailViewport()469481 m.state = helpDetailView470482 return m, nil
@@ -472,35 +484,29 @@ func (m model) handleHelpSelectEnter() (tea.Model, tea.Cmd) {
472484 473485 func (m model) handleEscape() (tea.Model, tea.Cmd) {474486 switch m.state {475- case playMenuView:487+ case playMenuView, optionsMenuView, statsView:476488 m.state = mainMenuView477- case optionsMenuView:478- m.state = mainMenuView479- case seedInputView:489+ case seedInputView, gameSelectView, continueView:480490 m.state = playMenuView481491 case generatingView:492+ returnState := m.activeSpawnReturnState()482493 m.cancelActiveSpawn()483- m.state = modeSelectView494+ m.state = returnState484495 case modeSelectView:485496 m.state = gameSelectView486- case gameSelectView, continueView:487- m.state = playMenuView488497 case helpDetailView:489498 m.state = helpSelectView490- case helpSelectView:491- m.state = optionsMenuView492- case statsView:493- m.state = mainMenuView494- case themeSelectView:495- // Revert to the previously saved theme.496- _ = theme.Apply(m.previousTheme)499+ case helpSelectView, themeSelectView:500+ if m.state == themeSelectView {501+ _ = theme.Apply(m.theme.previous)502+ }497503 m.state = optionsMenuView498504 }499505 return m, nil500506 }501507 502508 func (m model) handleThemeEnter() (tea.Model, tea.Cmd) {503- m.previousTheme = m.cfg.Theme509+ m.theme.previous = m.cfg.Theme504510 505511 names := theme.ThemeNames()506512 items := make([]list.Item, len(names))
@@ -514,24 +520,19 @@ func (m model) handleThemeEnter() (tea.Model, tea.Cmd) {
514520 items[i] = ui.MenuItem{ItemTitle: n, Desc: desc}515521 }516522 517- // Cap list height to show ~8 items (like the continue table).518- // Default delegate: height=2, spacing=1 → 3 lines per item.519523 const maxVisibleItems = 8520524 listH := min(m.height, maxVisibleItems*3)521525 listW := min(m.width, theme.MaxNameLen+4)522526 523- m.themeList = ui.InitThemeList(items, listW, listH)524-525- // Position cursor on the currently active theme.527+ m.theme.list = ui.InitThemeList(items, listW, listH)526528 for i, item := range items {527- if mi, ok := item.(ui.MenuItem); ok && mi.ItemTitle == m.previousTheme {528- m.themeList.Select(i)529+ if mi, ok := item.(ui.MenuItem); ok && mi.ItemTitle == m.theme.previous {530+ m.theme.list.Select(i)529531 break530532 }531533 }532- // If current theme is default (empty string), select index 0.533- if m.previousTheme == "" {534- m.themeList.Select(0)534+ if m.theme.previous == "" {535+ m.theme.list.Select(0)535536 }536537 537538 m.state = themeSelectView
@@ -539,7 +540,7 @@ func (m model) handleThemeEnter() (tea.Model, tea.Cmd) {
539540 }540541 541542 func (m model) handleThemeConfirm() (tea.Model, tea.Cmd) {542- item, ok := m.themeList.SelectedItem().(ui.MenuItem)543+ item, ok := m.theme.list.SelectedItem().(ui.MenuItem)543544 if !ok {544545 return m, nil545546 }
@@ -549,10 +550,7 @@ func (m model) handleThemeConfirm() (tea.Model, tea.Cmd) {
549550 themeName = ""550551 }551552 552- // Apply the theme (already live-previewed, but ensure it's set).553553 _ = theme.Apply(item.ItemTitle)554-555- // Persist the selection.556554 m.cfg.Theme = themeName557555 if err := m.cfg.Save(config.DefaultPath()); err != nil {558556 log.Printf("failed to save config: %v", err)
@@ -587,15 +585,15 @@ func (m model) handleStatsEnter() (tea.Model, tea.Cmd) {
587585 currentDaily = rec != nil588586 }589587 590- m.statsCards = stats.BuildCards(catStats, modeStats)591- m.statsProfile = stats.BuildProfileBanner(catStats, modeStats, streakDates, currentDaily)588+ m.stats.cards = stats.BuildCards(catStats, modeStats)589+ m.stats.profile = stats.BuildProfileBanner(catStats, modeStats, streakDates, currentDaily)592590 593- statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.statsCards)594- m.statsViewport = viewport.New(591+ statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.stats.cards)592+ m.stats.viewport = viewport.New(595593 viewport.WithWidth(statsWidth),596594 viewport.WithHeight(statsHeight),597595 )598- m.statsViewport.SetContent(stats.RenderCardGrid(m.statsCards, statsWidth))596+ m.stats.viewport.SetContent(stats.RenderCardGrid(m.stats.cards, statsWidth))599597 m.state = statsView600598 return m, nil601599 }
M app/update_test.go +26 -31
@@ -10,8 +10,8 @@ import (
1010 tea "charm.land/bubbletea/v2"1111 "github.com/FelineStateMachine/puzzletea/daily"1212 "github.com/FelineStateMachine/puzzletea/game"13- "github.com/FelineStateMachine/puzzletea/namegen"14- "github.com/FelineStateMachine/puzzletea/resolve"13+ "github.com/FelineStateMachine/puzzletea/lightsout"14+ sessionflow "github.com/FelineStateMachine/puzzletea/session"1515 "github.com/FelineStateMachine/puzzletea/store"1616 )1717
@@ -64,8 +64,8 @@ func (g *escapeTrackingGame) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
6464 func TestGameViewEscapePassesThroughToGame(t *testing.T) {6565 tracker := &escapeTrackingGame{}6666 m := model{67- state: gameView,68- game: tracker,67+ state: gameView,68+ session: sessionState{game: tracker},6969 }7070 7171 next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
@@ -88,32 +88,28 @@ func TestImportAndActivateRecordSuccessFlag(t *testing.T) {
8888 t.Fatal("expected unknown game type import to fail")8989 }9090 91- const gameType = "TestImportAndActivateRecordGame"92- prev, hadPrev := game.Registry[gameType]93- game.Register(gameType, func(_ []byte) (game.Gamer, error) {94- return &escapeTrackingGame{}, nil95- })96- t.Cleanup(func() {97- if hadPrev {98- game.Registry[gameType] = prev99- return100- }101- delete(game.Registry, gameType)102- })91+ loadedGame, err := lightsout.New(3, 3)92+ if err != nil {93+ t.Fatal(err)94+ }95+ save, err := loadedGame.GetSave()96+ if err != nil {97+ t.Fatal(err)98+ }10399 104100 rec := store.GameRecord{105101 ID: 42,106102 Name: "resume-me",107- GameType: gameType,108- SaveState: "{}",103+ GameType: "Lights Out",104+ SaveState: string(save),109105 Status: store.StatusCompleted,110106 }111107 112108 next, ok := (model{113- state: playMenuView,114- width: 80,115- height: 24,116- showFullHelp: true,109+ state: playMenuView,110+ width: 80,111+ height: 24,112+ help: helpState{showFull: true},117113 }).importAndActivateRecord(rec)118114 if !ok {119115 t.Fatal("expected known game type import to succeed")
@@ -121,13 +117,13 @@ func TestImportAndActivateRecordSuccessFlag(t *testing.T) {
121117 if next.state != gameView {122118 t.Fatalf("state = %d, want %d (gameView)", next.state, gameView)123119 }124- if next.activeGameID != rec.ID {125- t.Fatalf("activeGameID = %d, want %d", next.activeGameID, rec.ID)120+ if next.session.activeGameID != rec.ID {121+ t.Fatalf("activeGameID = %d, want %d", next.session.activeGameID, rec.ID)126122 }127- if !next.completionSaved {123+ if !next.session.completionSaved {128124 t.Fatal("expected completionSaved to be true for completed record")129125 }130- if next.game == nil {126+ if next.session.game == nil {131127 t.Fatal("expected game to be activated")132128 }133129 }
@@ -135,8 +131,7 @@ func TestImportAndActivateRecordSuccessFlag(t *testing.T) {
135131 func TestHandleSeedConfirmDoesNotResumeStatusWhenImportFails(t *testing.T) {136132 s := openAppTestStore(t)137133 seed := "import-failure-seed"138- nameRNG := resolve.RNGFromString("name:" + seed)139- name := seed + " - " + namegen.GenerateSeeded(nameRNG)134+ name := sessionflow.SeededName(seed)140135 rec := &store.GameRecord{141136 Name: name,142137 GameType: "NoSuchGameType",
@@ -152,9 +147,9 @@ func TestHandleSeedConfirmDoesNotResumeStatusWhenImportFails(t *testing.T) {
152147 ti := textinput.New()153148 ti.SetValue(seed)154149 m := model{155- state: seedInputView,156- store: s,157- seedInput: ti,150+ state: seedInputView,151+ store: s,152+ nav: navigationState{seedInput: ti},158153 }159154 160155 next, _ := m.handleSeedConfirm()
M app/view.go +24 -24
@@ -16,31 +16,31 @@ func (m model) View() tea.View {
1616 1717 switch m.state {1818 case mainMenuView:19- content = ui.CenterView(m.width, m.height, m.mainMenu.View())19+ content = ui.CenterView(m.width, m.height, m.nav.mainMenu.View())2020 case playMenuView:21- content = ui.CenterView(m.width, m.height, m.playMenu.ViewAsPanel("Play"))21+ content = ui.CenterView(m.width, m.height, m.nav.playMenu.ViewAsPanel("Play"))2222 case optionsMenuView:23- items := m.optionsMenu.RenderItems() + "\n\n" + ui.DimItemStyle().Render("- Dami")23+ items := m.nav.optionsMenu.RenderItems() + "\n\n" + ui.DimItemStyle().Render("- Dami")2424 panel := ui.Panel("Options", items, "↑/↓ navigate • enter select • esc back")2525 content = ui.CenterView(m.width, m.height, panel)2626 case seedInputView:2727 panel := ui.Panel(2828 "Enter Seed",29- m.seedInput.View(),29+ m.nav.seedInput.View(),3030 "enter confirm • esc back",3131 )3232 content = ui.CenterView(m.width, m.height, panel)3333 case gameSelectView:3434 panel := ui.Panel(3535 "Select Category",36- m.gameSelectList.View(),36+ m.nav.gameSelectList.View(),3737 "↑/↓ navigate • enter select • esc back",3838 )3939 content = ui.CenterView(m.width, m.height, panel)4040 case modeSelectView:4141 panel := ui.Panel(42- m.selectedCategory.Name+" — Select Mode",43- m.modeSelectList.View(),42+ m.nav.selectedCategory.Name+" — Select Mode",43+ m.nav.modeSelectList.View(),4444 "↑/↓ navigate • enter select • esc back",4545 )4646 content = ui.CenterView(m.width, m.height, panel)
@@ -50,7 +50,7 @@ func (m model) View() tea.View {
5050 content = ui.CenterView(m.width, m.height, box)5151 case continueView:5252 var s string53- if len(m.continueGames) == 0 {53+ if len(m.nav.continueGames) == 0 {5454 s = ui.Panel(5555 "Saved Games",5656 "No saved games yet.",
@@ -58,25 +58,25 @@ func (m model) View() tea.View {
5858 )5959 } else {6060 footer := "↑/↓ navigate • enter resume • esc back"61- if pg := ui.TablePagination(m.continueTable); pg != "" {61+ if pg := ui.TablePagination(m.nav.continueTable); pg != "" {6262 footer = pg + " " + footer6363 }6464 s = ui.Panel(6565 "Saved Games",66- m.continueTable.View(),66+ m.nav.continueTable.View(),6767 footer,6868 )6969 }7070 content = ui.CenterView(m.width, m.height, s)7171 case gameView:72- if m.game == nil {72+ if m.session.game == nil {7373 content = ""7474 } else {75- gameView := lipgloss.NewStyle().MaxWidth(m.width).Render(m.game.View())75+ gameView := lipgloss.NewStyle().MaxWidth(m.width).Render(m.session.game.View())7676 centered := gameView77- if m.debug {77+ if m.debug.enabled {7878 debugInfo := lipgloss.NewStyle().MaxWidth(m.width).Render(79- ui.DebugStyle().Render(m.debuginfo),79+ ui.DebugStyle().Render(m.debug.info),8080 )8181 centered = lipgloss.JoinVertical(lipgloss.Center, gameView, debugInfo)8282 }
@@ -85,30 +85,30 @@ func (m model) View() tea.View {
8585 case helpSelectView:8686 panel := ui.Panel(8787 "How to Play",88- m.helpSelectList.View(),88+ m.nav.helpSelectList.View(),8989 "↑/↓ navigate • enter select • esc back",9090 )9191 content = ui.CenterView(m.width, m.height, panel)9292 case helpDetailView:9393 panel := ui.Panel(94- m.helpCategory.Name+" — Guide",95- m.helpViewport.View(),94+ m.help.category.Name+" — Guide",95+ m.help.viewport.View(),9696 "↑/↓ scroll • esc back",9797 )9898 content = ui.CenterView(m.width, m.height, panel)9999 case themeSelectView:100100 content = m.themeSelectViewContent()101101 case statsView:102- statsWidth, _ := statsViewportSize(m.width, m.height, m.statsCards)102+ statsWidth, _ := statsViewportSize(m.width, m.height, m.stats.cards)103103 var statsBody string104- if len(m.statsCards) == 0 {105- statsBody = m.statsViewport.View()104+ if len(m.stats.cards) == 0 {105+ statsBody = m.stats.viewport.View()106106 } else {107- banner := stats.RenderBanner(m.statsProfile, statsWidth)107+ banner := stats.RenderBanner(m.stats.profile, statsWidth)108108 statsBody = lipgloss.JoinVertical(lipgloss.Left,109109 banner,110110 "",111- m.statsViewport.View(),111+ m.stats.viewport.View(),112112 )113113 }114114 statsBody = lipgloss.NewStyle().Width(statsWidth).Render(statsBody)
@@ -138,7 +138,7 @@ func (m model) themeSelectViewContent() string {
138138 139139 // Determine selected theme name for the preview.140140 themeName := theme.DefaultThemeName141- if item, ok := m.themeList.SelectedItem().(ui.MenuItem); ok {141+ if item, ok := m.theme.list.SelectedItem().(ui.MenuItem); ok {142142 themeName = item.ItemTitle143143 }144144
@@ -151,7 +151,7 @@ func (m model) themeSelectViewContent() string {
151151 }152152 153153 // Left side: theme list.154- listView := m.themeList.View()154+ listView := m.theme.list.View()155155 156156 // Right side: color preview.157157 previewBorder := lipgloss.NewStyle().
A catalog/catalog.go +213 -0
@@ -0,0 +1,213 @@
1+package catalog2+3+import (4+ "fmt"5+6+ "charm.land/bubbles/v2/list"7+ "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/FelineStateMachine/puzzletea/hashiwokakero"9+ "github.com/FelineStateMachine/puzzletea/hitori"10+ "github.com/FelineStateMachine/puzzletea/lightsout"11+ "github.com/FelineStateMachine/puzzletea/nonogram"12+ "github.com/FelineStateMachine/puzzletea/nurikabe"13+ "github.com/FelineStateMachine/puzzletea/shikaku"14+ "github.com/FelineStateMachine/puzzletea/sudoku"15+ "github.com/FelineStateMachine/puzzletea/takuzu"16+ "github.com/FelineStateMachine/puzzletea/wordsearch"17+)18+19+// All is the explicit ordered catalog of every puzzle game.20+var All = []game.Definition{21+ hashiwokakero.Definition,22+ hitori.Definition,23+ lightsout.Definition,24+ nonogram.Definition,25+ nurikabe.Definition,26+ shikaku.Definition,27+ sudoku.Definition,28+ takuzu.Definition,29+ wordsearch.Definition,30+}31+32+// DailyEntry is a flattened seeded daily candidate with metadata.33+type DailyEntry struct {34+ Spawner game.SeededSpawner35+ GameType string36+ Mode string37+}38+39+type index struct {40+ categories []game.Category41+ items []list.Item42+ names []string43+ dailies []DailyEntry44+ byName map[string]game.Definition45+ byAlias map[string]string46+}47+48+var built = mustBuildIndex(All)49+50+func mustBuildIndex(definitions []game.Definition) index {51+ idx, err := buildIndex(definitions)52+ if err != nil {53+ panic(err)54+ }55+ return idx56+}57+58+func buildIndex(definitions []game.Definition) (index, error) {59+ if err := Validate(definitions); err != nil {60+ return index{}, err61+ }62+63+ idx := index{64+ categories: make([]game.Category, 0, len(definitions)),65+ items: make([]list.Item, 0, len(definitions)),66+ names: make([]string, 0, len(definitions)),67+ dailies: make([]DailyEntry, 0, len(definitions)*2),68+ byName: make(map[string]game.Definition, len(definitions)),69+ byAlias: make(map[string]string),70+ }71+72+ for _, def := range definitions {73+ category := def.Category()74+ idx.categories = append(idx.categories, category)75+ idx.items = append(idx.items, category)76+ idx.names = append(idx.names, def.Name)77+ idx.byName[game.NormalizeName(def.Name)] = def78+ for _, alias := range def.Aliases {79+ idx.byAlias[game.NormalizeName(alias)] = def.Name80+ }81+ for _, item := range def.DailyModes {82+ mode, ok := item.(game.Mode)83+ if !ok {84+ continue85+ }86+ spawner, ok := item.(game.SeededSpawner)87+ if !ok {88+ continue89+ }90+ idx.dailies = append(idx.dailies, DailyEntry{91+ Spawner: spawner,92+ GameType: def.Name,93+ Mode: mode.Title(),94+ })95+ }96+ }97+98+ return idx, nil99+}100+101+// Validate checks that the catalog is internally consistent.102+func Validate(definitions []game.Definition) error {103+ names := make(map[string]string, len(definitions))104+ aliases := make(map[string]string)105+106+ for _, def := range definitions {107+ if def.Import == nil {108+ return fmt.Errorf("game %q has nil importer", def.Name)109+ }110+111+ normName := game.NormalizeName(def.Name)112+ if normName == "" {113+ return fmt.Errorf("game %q has empty canonical name", def.Name)114+ }115+ if prior, exists := names[normName]; exists {116+ return fmt.Errorf("duplicate game name %q conflicts with %q", def.Name, prior)117+ }118+ names[normName] = def.Name119+120+ modeTitles := make(map[string]struct{}, len(def.Modes))121+ for _, item := range def.Modes {122+ mode, ok := item.(game.Mode)123+ if !ok {124+ continue125+ }126+ modeTitles[mode.Title()] = struct{}{}127+ }128+129+ for _, item := range def.DailyModes {130+ mode, ok := item.(game.Mode)131+ if !ok {132+ return fmt.Errorf("game %q has daily mode without game.Mode implementation", def.Name)133+ }134+ if _, ok := item.(game.SeededSpawner); !ok {135+ return fmt.Errorf("game %q daily mode %q is not seeded", def.Name, mode.Title())136+ }137+ if _, ok := modeTitles[mode.Title()]; !ok {138+ return fmt.Errorf("game %q daily mode %q is not present in Modes", def.Name, mode.Title())139+ }140+ }141+142+ for _, alias := range def.Aliases {143+ normAlias := game.NormalizeName(alias)144+ if normAlias == "" {145+ return fmt.Errorf("game %q has empty alias", def.Name)146+ }147+ if normAlias == normName {148+ return fmt.Errorf("game %q alias %q duplicates the canonical name", def.Name, alias)149+ }150+ if prior, exists := names[normAlias]; exists {151+ return fmt.Errorf("game %q alias %q conflicts with canonical game %q", def.Name, alias, prior)152+ }153+ if prior, exists := aliases[normAlias]; exists {154+ return fmt.Errorf("game %q alias %q conflicts with alias owned by %q", def.Name, alias, prior)155+ }156+ aliases[normAlias] = def.Name157+ }158+ }159+160+ return nil161+}162+163+// Names returns the canonical game names in catalog order.164+func Names() []string {165+ return append([]string(nil), built.names...)166+}167+168+// Categories returns the category view of the catalog in menu order.169+func Categories() []game.Category {170+ return append([]game.Category(nil), built.categories...)171+}172+173+// CategoryItems returns the category view as list items for Bubble Tea lists.174+func CategoryItems() []list.Item {175+ return append([]list.Item(nil), built.items...)176+}177+178+// DailyEntries returns the flattened daily-eligible seeded modes.179+func DailyEntries() []DailyEntry {180+ return append([]DailyEntry(nil), built.dailies...)181+}182+183+// Lookup returns a definition by canonical name.184+func Lookup(name string) (game.Definition, bool) {185+ def, ok := built.byName[game.NormalizeName(name)]186+ return def, ok187+}188+189+// Resolve returns a definition by canonical name or alias.190+func Resolve(name string) (game.Definition, bool) {191+ norm := game.NormalizeName(name)192+ if def, ok := built.byName[norm]; ok {193+ return def, true194+ }195+ canonical, ok := built.byAlias[norm]196+ if !ok {197+ return game.Definition{}, false198+ }199+ return Lookup(canonical)200+}201+202+// Import reconstructs a saved game for the given canonical game type.203+func Import(gameType string, data []byte) (game.Gamer, error) {204+ def, ok := Lookup(gameType)205+ if !ok {206+ return nil, fmt.Errorf("unknown game type %q in save data", gameType)207+ }208+ g, err := def.Import(data)209+ if err != nil {210+ return nil, fmt.Errorf("failed to import game: %w", err)211+ }212+ return g, nil213+}
A catalog/catalog_test.go +88 -0
@@ -0,0 +1,88 @@
1+package catalog2+3+import (4+ "testing"5+6+ "github.com/FelineStateMachine/puzzletea/lightsout"7+)8+9+func TestValidateAll(t *testing.T) {10+ if err := Validate(All); err != nil {11+ t.Fatalf("Validate(All) error = %v", err)12+ }13+}14+15+func TestResolveSupportsCanonicalNamesAndAliases(t *testing.T) {16+ tests := []struct {17+ input string18+ want string19+ }{20+ {input: "Sudoku", want: "Sudoku"},21+ {input: "hashi", want: "Hashiwokakero"},22+ {input: "lights", want: "Lights Out"},23+ {input: "wordsearch", want: "Word Search"},24+ }25+26+ for _, tt := range tests {27+ def, ok := Resolve(tt.input)28+ if !ok {29+ t.Fatalf("Resolve(%q) returned !ok", tt.input)30+ }31+ if def.Name != tt.want {32+ t.Fatalf("Resolve(%q).Name = %q, want %q", tt.input, def.Name, tt.want)33+ }34+ }35+}36+37+func TestCategoriesAndNamesPreserveCatalogOrder(t *testing.T) {38+ names := Names()39+ categories := Categories()40+ if len(names) != len(All) {41+ t.Fatalf("len(Names()) = %d, want %d", len(names), len(All))42+ }43+ if len(categories) != len(All) {44+ t.Fatalf("len(Categories()) = %d, want %d", len(categories), len(All))45+ }46+ for i, def := range All {47+ if names[i] != def.Name {48+ t.Fatalf("Names()[%d] = %q, want %q", i, names[i], def.Name)49+ }50+ if categories[i].Name != def.Name {51+ t.Fatalf("Categories()[%d].Name = %q, want %q", i, categories[i].Name, def.Name)52+ }53+ }54+}55+56+func TestImportRoundTrip(t *testing.T) {57+ g, err := lightsout.New(3, 3)58+ if err != nil {59+ t.Fatal(err)60+ }61+ save, err := g.GetSave()62+ if err != nil {63+ t.Fatal(err)64+ }65+66+ loaded, err := Import("Lights Out", save)67+ if err != nil {68+ t.Fatalf("Import returned error: %v", err)69+ }70+ if loaded == nil {71+ t.Fatal("Import returned nil game")72+ }73+}74+75+func TestDailyEntriesHaveMetadata(t *testing.T) {76+ entries := DailyEntries()77+ if len(entries) == 0 {78+ t.Fatal("DailyEntries returned no entries")79+ }80+ for i, entry := range entries {81+ if entry.Spawner == nil {82+ t.Fatalf("DailyEntries()[%d].Spawner is nil", i)83+ }84+ if entry.GameType == "" || entry.Mode == "" {85+ t.Fatalf("DailyEntries()[%d] missing metadata", i)86+ }87+ }88+}
A cmd/config_test.go +167 -0
@@ -0,0 +1,167 @@
1+package cmd2+3+import (4+ "bytes"5+ "fmt"6+ "path/filepath"7+ "testing"8+9+ "github.com/FelineStateMachine/puzzletea/config"10+ "github.com/FelineStateMachine/puzzletea/store"11+ "github.com/FelineStateMachine/puzzletea/theme"12+ "github.com/spf13/cobra"13+)14+15+func TestNewContinueAndRootUseActiveConfig(t *testing.T) {16+ configPath, dbPath := writeCommandConfig(t)17+ restore := snapshotCommandGlobals(t)18+ defer restore()19+20+ flagConfigPath = configPath21+22+ var seen []string23+ launchNewGameFn = func(gameArg, modeArg, seed string, cfg *config.Config) error {24+ seen = append(seen, cfg.DBPath)25+ if gameArg == "" {26+ t.Fatal("expected game argument to be forwarded")27+ }28+ return nil29+ }30+ continueGameFn = func(name string, cfg *config.Config) error {31+ seen = append(seen, cfg.DBPath)32+ if name == "" {33+ t.Fatal("expected continue name to be forwarded")34+ }35+ return nil36+ }37+38+ if err := newCmd.RunE(newCmd, []string{"nonogram", "mini"}); err != nil {39+ t.Fatalf("newCmd.RunE returned error: %v", err)40+ }41+ if err := continueCmd.RunE(continueCmd, []string{"resume-me"}); err != nil {42+ t.Fatalf("continueCmd.RunE returned error: %v", err)43+ }44+45+ flagNew = "sudoku:easy"46+ if err := RootCmd.RunE(RootCmd, nil); err != nil {47+ t.Fatalf("RootCmd.RunE returned error: %v", err)48+ }49+ flagNew = ""50+51+ flagContinue = "resume-me"52+ if err := RootCmd.RunE(RootCmd, nil); err != nil {53+ t.Fatalf("RootCmd.RunE returned error: %v", err)54+ }55+56+ if got, want := len(seen), 4; got != want {57+ t.Fatalf("saw %d config loads, want %d", got, want)58+ }59+ for i, got := range seen {60+ if got != dbPath {61+ t.Fatalf("config load %d used DB path %q, want %q", i, got, dbPath)62+ }63+ }64+}65+66+func TestListCmdUsesActiveConfig(t *testing.T) {67+ configPath, dbPath := writeCommandConfig(t)68+ restore := snapshotCommandGlobals(t)69+ defer restore()70+71+ flagConfigPath = configPath72+73+ s, err := store.Open(dbPath)74+ if err != nil {75+ t.Fatal(err)76+ }77+ t.Cleanup(func() { _ = s.Close() })78+ if err := s.CreateGame(&store.GameRecord{79+ Name: "from-custom-config",80+ GameType: "Lights Out",81+ Mode: "Easy",82+ InitialState: "{}",83+ SaveState: "{}",84+ Status: store.StatusNew,85+ }); err != nil {86+ t.Fatal(err)87+ }88+89+ var out bytes.Buffer90+ cmd := &cobra.Command{}91+ cmd.SetOut(&out)92+ if err := listCmd.RunE(cmd, nil); err != nil {93+ t.Fatalf("listCmd.RunE returned error: %v", err)94+ }95+ if !bytes.Contains(out.Bytes(), []byte("from-custom-config")) {96+ t.Fatalf("list output %q did not include custom DB record", out.String())97+ }98+}99+100+func TestLoadConfigThemeFlagOverridesPersistedTheme(t *testing.T) {101+ configPath, _ := writeCommandConfig(t)102+ restore := snapshotCommandGlobals(t)103+ defer restore()104+ t.Cleanup(func() { _ = theme.Apply("") })105+106+ flagTheme = "Dracula"107+ if themeDoc := theme.LookupTheme(flagTheme); themeDoc == nil {108+ t.Fatalf("expected theme %q to exist", flagTheme)109+ }110+111+ _ = loadConfig(configPath)112+113+ want := theme.LookupTheme(flagTheme).Palette()114+ got := theme.Current()115+ if fmt.Sprint(got.FG) != fmt.Sprint(want.FG) {116+ t.Fatalf("theme override FG = %v, want %v", got.FG, want.FG)117+ }118+}119+120+func snapshotCommandGlobals(t *testing.T) func() {121+ t.Helper()122+123+ prevNew := flagNew124+ prevContinue := flagContinue125+ prevTheme := flagTheme126+ prevConfigPath := flagConfigPath127+ prevSetSeed := flagSetSeed128+ prevWithSeed := flagWithSeed129+ prevExport := flagExport130+ prevOutput := flagOutput131+ prevListAll := listAll132+ prevLaunchNew := launchNewGameFn133+ prevLaunchSeeded := launchSeededGameFn134+ prevContinueFn := continueGameFn135+ prevOpenStore := openStoreFn136+137+ return func() {138+ flagNew = prevNew139+ flagContinue = prevContinue140+ flagTheme = prevTheme141+ flagConfigPath = prevConfigPath142+ flagSetSeed = prevSetSeed143+ flagWithSeed = prevWithSeed144+ flagExport = prevExport145+ flagOutput = prevOutput146+ listAll = prevListAll147+ launchNewGameFn = prevLaunchNew148+ launchSeededGameFn = prevLaunchSeeded149+ continueGameFn = prevContinueFn150+ openStoreFn = prevOpenStore151+ }152+}153+154+func writeCommandConfig(t *testing.T) (string, string) {155+ t.Helper()156+157+ configPath := filepath.Join(t.TempDir(), "config.json")158+ dbPath := filepath.Join(t.TempDir(), "history.db")159+ cfg := &config.Config{160+ DBPath: dbPath,161+ Theme: "Nord",162+ }163+ if err := cfg.Save(configPath); err != nil {164+ t.Fatal(err)165+ }166+ return configPath, dbPath167+}
M cmd/continue.go +7 -7
@@ -3,8 +3,9 @@ package cmd
33 import (44 "fmt"55 6- "github.com/FelineStateMachine/puzzletea/app"6+ "github.com/FelineStateMachine/puzzletea/catalog"77 "github.com/FelineStateMachine/puzzletea/config"8+ sessionflow "github.com/FelineStateMachine/puzzletea/session"89 "github.com/FelineStateMachine/puzzletea/stats"910 "github.com/FelineStateMachine/puzzletea/store"1011
@@ -17,20 +18,19 @@ var continueCmd = &cobra.Command{
1718 Long: "Resume a previously saved game using its unique name.\nUse 'puzzletea list' to see available saved games.",1819 Args: cobra.ExactArgs(1),1920 RunE: func(cmd *cobra.Command, args []string) error {20- cfg := loadConfig("")21- return continueGame(args[0], cfg)21+ return continueGameFn(args[0], loadActiveConfig())2222 },2323 }2424 2525 // continueGame looks up a saved game by name and launches the TUI.2626 func continueGame(name string, cfg *config.Config) error {27- s, err := store.Open(cfg.DBPath)27+ s, err := openStoreFn(cfg.DBPath)2828 if err != nil {2929 return err3030 }3131 defer s.Close()3232 33- stats.InitModeXP(app.Categories)33+ stats.InitModeXP(catalog.Categories())3434 3535 rec, err := s.GetGameByName(name)3636 if err != nil {
@@ -40,11 +40,11 @@ func continueGame(name string, cfg *config.Config) error {
4040 return fmt.Errorf("no saved game found with name %q\nRun 'puzzletea list' to see available games.", name)4141 }4242 43- g, err := importSavedGame(rec)43+ g, err := sessionflow.ImportRecord(rec)4444 if err != nil {4545 return err4646 }4747 4848 completed := rec.Status == store.StatusCompleted49- return runGameProgram(s, cfg, g, rec.ID, completed)49+ return runGameProgramFn(s, cfg, g, rec.ID, completed)5050 }
M cmd/export_pdf.go +2 -2
M cmd/list.go +12 -10
M cmd/new.go +23 -35
@@ -4,11 +4,11 @@ import (
44 "fmt"55 "strings"66 7- "github.com/FelineStateMachine/puzzletea/app"7+ "github.com/FelineStateMachine/puzzletea/catalog"88 "github.com/FelineStateMachine/puzzletea/config"99 "github.com/FelineStateMachine/puzzletea/game"10- "github.com/FelineStateMachine/puzzletea/namegen"1110 "github.com/FelineStateMachine/puzzletea/resolve"11+ sessionflow "github.com/FelineStateMachine/puzzletea/session"1212 "github.com/FelineStateMachine/puzzletea/stats"1313 "github.com/FelineStateMachine/puzzletea/store"1414
@@ -27,20 +27,20 @@ var newCmd = &cobra.Command{
2727 Short: "Start a new puzzle game",2828 Long: fmt.Sprintf("Start a new puzzle game, optionally specifying the difficulty mode.\n"+2929 "Use --set-seed to generate a deterministic puzzle from a seed string.\n\nAvailable games:\n %s",30- strings.Join(resolve.CategoryNames(app.GameCategories), "\n ")),30+ strings.Join(resolve.CategoryNames(catalog.All), "\n ")),3131 Args: cobra.RangeArgs(0, 2),3232 RunE: func(cmd *cobra.Command, args []string) error {3333 if flagOutput != "" || cmd.Flags().Changed("export") {3434 return runNewExport(cmd, args)3535 }3636 37- cfg := loadConfig("")37+ cfg := loadActiveConfig()3838 3939 if flagSetSeed != "" {4040 if len(args) > 0 {4141 return fmt.Errorf("cannot specify game/mode arguments with --set-seed; the seed determines the puzzle type")4242 }43- return launchSeededGame(flagSetSeed, cfg)43+ return launchSeededGameFn(flagSetSeed, cfg)4444 }4545 if len(args) == 0 {4646 return fmt.Errorf("requires at least 1 arg(s), only received 0")
@@ -50,7 +50,7 @@ var newCmd = &cobra.Command{
5050 if len(args) > 1 {5151 modeArg = args[1]5252 }53- return launchNewGame(gameArg, modeArg, flagWithSeed, cfg)53+ return launchNewGameFn(gameArg, modeArg, flagWithSeed, cfg)5454 },5555 }5656
@@ -63,7 +63,7 @@ func init() {
6363 6464 // launchNewGame resolves the game/mode, spawns a new game, and launches the TUI.6565 func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {66- cat, err := resolve.Category(gameArg, app.GameCategories)66+ cat, err := resolve.Category(gameArg, catalog.All)6767 if err != nil {6868 return err6969 }
@@ -78,23 +78,23 @@ func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {
7878 return fmt.Errorf("failed to spawn game: %w", err)7979 }8080 81- s, err := store.Open(cfg.DBPath)81+ s, err := openStoreFn(cfg.DBPath)8282 if err != nil {8383 return err8484 }8585 defer s.Close()8686 87- stats.InitModeXP(app.Categories)87+ stats.InitModeXP(catalog.Categories())8888 89- name := app.GenerateUniqueName(s)89+ name := sessionflow.GenerateUniqueName(s)9090 g = g.SetTitle(name)9191 92- rec, err := createGameRecord(s, g, name, cat.Name, modeTitle)92+ rec, err := sessionflow.CreateRecord(s, g, name, cat.Name, modeTitle)9393 if err != nil {9494 return err9595 }9696 97- return runGameProgram(s, cfg, g, rec.ID, false)97+ return runGameProgramFn(s, cfg, g, rec.ID, false)9898 }9999 100100 func spawnFromMode(spawner game.Spawner, seed string) (game.Gamer, error) {
@@ -117,48 +117,36 @@ func spawnFromMode(spawner game.Spawner, seed string) (game.Gamer, error) {
117117 // changes to namegen word lists or mode lists don't cascade. The shared118118 // RNG is reserved purely for puzzle content generation.119119 func launchSeededGame(seed string, cfg *config.Config) error {120- // Prevent seeded puzzles from mimicking daily puzzle names by121- // silently lowercasing a "Daily" prefix so titles never start122- // with the real daily prefix "Daily ".123- if strings.HasPrefix(strings.ToLower(seed), "daily") {124- seed = strings.ToLower(seed[:len("daily")]) + seed[len("daily"):]125- }126-127- // Name uses its own sub-RNG so namegen changes can't affect mode128- // selection or puzzle generation.129- nameRNG := resolve.RNGFromString("name:" + seed)130- name := seed + " - " + namegen.GenerateSeeded(nameRNG)120+ seed = sessionflow.NormalizeSeed(seed)121+ name := sessionflow.SeededName(seed)131122 132- s, err := store.Open(cfg.DBPath)123+ s, err := openStoreFn(cfg.DBPath)133124 if err != nil {134125 return err135126 }136127 defer s.Close()137128 138- stats.InitModeXP(app.Categories)129+ stats.InitModeXP(catalog.Categories())139130 140131 // If a game with this name already exists, resume it (including141132 // abandoned games — seeded puzzles are deterministic and should142133 // always be resumable rather than duplicated).143134 if rec, err := s.GetDailyGame(name); err == nil && rec != nil {144- g, err := importSavedGame(rec)135+ g, err := sessionflow.ImportRecord(rec)145136 if err != nil {146137 return err147138 }148139 completed := rec.Status == store.StatusCompleted149140 150- // Resume abandoned seeded games by resetting their status.151- if rec.Status == store.StatusAbandoned {152- if err := s.UpdateStatus(rec.ID, store.StatusInProgress); err != nil {153- return fmt.Errorf("failed to mark seeded game in progress: %w", err)154- }141+ if err := sessionflow.ResumeAbandonedDeterministicRecord(s, rec); err != nil {142+ return err155143 }156144 157- return runGameProgram(s, cfg, g, rec.ID, completed)145+ return runGameProgramFn(s, cfg, g, rec.ID, completed)158146 }159147 160148 // Mode selection uses rendezvous hashing (independent of RNG).161- spawner, gameType, modeTitle, err := resolve.SeededMode(seed, app.GameCategories)149+ spawner, gameType, modeTitle, err := resolve.SeededMode(seed, catalog.All)162150 if err != nil {163151 return err164152 }
@@ -171,10 +159,10 @@ func launchSeededGame(seed string, cfg *config.Config) error {
171159 }172160 g = g.SetTitle(name)173161 174- rec, err := createGameRecord(s, g, name, gameType, modeTitle)162+ rec, err := sessionflow.CreateRecord(s, g, name, gameType, modeTitle)175163 if err != nil {176164 return err177165 }178166 179- return runGameProgram(s, cfg, g, rec.ID, false)167+ return runGameProgramFn(s, cfg, g, rec.ID, false)180168 }
M cmd/new_export.go +2 -2
M cmd/root.go +18 -6
@@ -7,6 +7,7 @@ import (
77 "strings"88 99 "github.com/FelineStateMachine/puzzletea/app"10+ "github.com/FelineStateMachine/puzzletea/catalog"1011 "github.com/FelineStateMachine/puzzletea/config"1112 "github.com/FelineStateMachine/puzzletea/stats"1213 "github.com/FelineStateMachine/puzzletea/store"
@@ -29,6 +30,13 @@ var (
2930 // flagSetSeed is declared in new.go and shared across root and new commands.3031 )3132 33+var (34+ launchNewGameFn = launchNewGame35+ launchSeededGameFn = launchSeededGame36+ continueGameFn = continueGame37+ openStoreFn = store.Open38+)39+3240 // RootCmd is the top-level Cobra command.3341 var RootCmd = &cobra.Command{3442 Use: "puzzletea",
@@ -36,13 +44,13 @@ var RootCmd = &cobra.Command{
3644 Short: "A terminal-based puzzle game framework",3745 Long: "PuzzleTea is a terminal-based puzzle game framework featuring Nonogram, Nurikabe, Sudoku, Word Search, Hashiwokakero, and Lights Out.",3846 RunE: func(cmd *cobra.Command, args []string) error {39- cfg := loadConfig(flagConfigPath)47+ cfg := loadActiveConfig()4048 4149 if flagSetSeed != "" {4250 if flagNew != "" || flagContinue != "" {4351 return fmt.Errorf("--set-seed cannot be combined with --new or --continue")4452 }45- return launchSeededGame(flagSetSeed, cfg)53+ return launchSeededGameFn(flagSetSeed, cfg)4654 }4755 if flagNew != "" {4856 parts := strings.SplitN(flagNew, ":", 2)
@@ -51,18 +59,18 @@ var RootCmd = &cobra.Command{
5159 if len(parts) > 1 {5260 modeArg = parts[1]5361 }54- return launchNewGame(gameArg, modeArg, "", cfg)62+ return launchNewGameFn(gameArg, modeArg, "", cfg)5563 }5664 if flagContinue != "" {57- return continueGame(flagContinue, cfg)65+ return continueGameFn(flagContinue, cfg)5866 }5967 // Default: launch TUI menu.60- s, err := store.Open(cfg.DBPath)68+ s, err := openStoreFn(cfg.DBPath)6169 if err != nil {6270 return err6371 }6472 defer s.Close()65- stats.InitModeXP(app.Categories)73+ stats.InitModeXP(catalog.Categories())6674 p := tea.NewProgram(app.InitialModel(s, cfg))6775 _, err = p.Run()6876 return err
@@ -79,6 +87,10 @@ func init() {
7987 RootCmd.AddCommand(newCmd, continueCmd, listCmd, exportPDFCmd)8088 }8189 90+func loadActiveConfig() *config.Config {91+ return loadConfig(flagConfigPath)92+}93+8294 // loadConfig reads the config file and applies the active theme. The --theme8395 // flag takes precedence over the persisted setting.8496 func loadConfig(configPath string) *config.Config {
M cmd/session.go +1 -41
M daily/daily.go +9 -45
@@ -5,19 +5,9 @@ import (
55 "math/rand/v2"66 "time"77 8+ "github.com/FelineStateMachine/puzzletea/catalog"89 "github.com/FelineStateMachine/puzzletea/game"9- "github.com/FelineStateMachine/puzzletea/hashiwokakero"10- "github.com/FelineStateMachine/puzzletea/hitori"11- "github.com/FelineStateMachine/puzzletea/lightsout"1210 "github.com/FelineStateMachine/puzzletea/namegen"13- "github.com/FelineStateMachine/puzzletea/nonogram"14- "github.com/FelineStateMachine/puzzletea/nurikabe"15- "github.com/FelineStateMachine/puzzletea/shikaku"16- "github.com/FelineStateMachine/puzzletea/sudoku"17- "github.com/FelineStateMachine/puzzletea/takuzu"18- "github.com/FelineStateMachine/puzzletea/wordsearch"19-20- "charm.land/bubbles/v2/list"2111 )2212 2313 // Entry pairs a SeededSpawner with metadata for the eligible daily pool.
@@ -27,44 +17,18 @@ type Entry struct {
2717 Mode string2818 }2919 30-// pool maps game type names to their DailyModes exports.31-// Each package owns which of its modes are eligible for daily rotation.32-var pool = []struct {33- gameType string34- modes []list.Item35-}{36- {"Nonogram", nonogram.DailyModes},37- {"Sudoku", sudoku.DailyModes},38- {"Takuzu", takuzu.DailyModes},39- {"Hashiwokakero", hashiwokakero.DailyModes},40- {"Hitori", hitori.DailyModes},41- {"Lights Out", lightsout.DailyModes},42- {"Shikaku", shikaku.DailyModes},43- {"Nurikabe", nurikabe.DailyModes},44- {"Word Search", wordsearch.DailyModes},45-}46-4720 // eligibleModes is the flattened pool built from each package's DailyModes.4821 var eligibleModes = buildEligibleModes()4922 5023 func buildEligibleModes() []Entry {51- var entries []Entry52- for _, p := range pool {53- for _, item := range p.modes {54- s, ok := item.(game.SeededSpawner)55- if !ok {56- continue57- }58- mode, ok := item.(game.Mode)59- if !ok {60- continue61- }62- entries = append(entries, Entry{63- Spawner: s,64- GameType: p.gameType,65- Mode: mode.Title(),66- })67- }24+ catalogEntries := catalog.DailyEntries()25+ entries := make([]Entry, 0, len(catalogEntries))26+ for _, entry := range catalogEntries {27+ entries = append(entries, Entry{28+ Spawner: entry.Spawner,29+ GameType: entry.GameType,30+ Mode: entry.Mode,31+ })6832 }6933 return entries7034 }
M game/game_test.go +31 -51
@@ -218,63 +218,43 @@ func TestCategory(t *testing.T) {
218218 }219219 }220220 221-// --- Registry (P1) ---221+// --- Definition (P1) ---222222 223-func TestRegistry(t *testing.T) {224- // Save and restore the global registry to avoid pollution.225- origRegistry := make(map[string]func([]byte) (Gamer, error))226- for k, v := range Registry {227- origRegistry[k] = v223+func TestDefinitionCategory(t *testing.T) {224+ def := Definition{225+ Name: "Sudoku",226+ Description: "Classic grid logic",227+ Help: "Rules",228228 }229- t.Cleanup(func() {230- Registry = origRegistry231- })232229 233- t.Run("Register adds function", func(t *testing.T) {234- called := false235- fn := func(data []byte) (Gamer, error) {236- called = true237- return nil, nil238- }239- Register("test-game", fn)240- if Registry["test-game"] == nil {241- t.Fatal("expected registry entry to be set")242- }243- if _, err := Registry["test-game"](nil); err != nil {244- t.Fatal(err)245- }246- if !called {247- t.Error("expected registered function to be called")248- }249- })230+ cat := def.Category()231+ if cat.Name != def.Name {232+ t.Fatalf("Category().Name = %q, want %q", cat.Name, def.Name)233+ }234+ if cat.Desc != def.Description {235+ t.Fatalf("Category().Desc = %q, want %q", cat.Desc, def.Description)236+ }237+ if cat.Help != def.Help {238+ t.Fatalf("Category().Help = %q, want %q", cat.Help, def.Help)239+ }240+}250241 251- t.Run("Register overwrites", func(t *testing.T) {252- first := false253- second := false254- Register("overwrite", func([]byte) (Gamer, error) {255- first = true256- return nil, nil257- })258- Register("overwrite", func([]byte) (Gamer, error) {259- second = true260- return nil, nil261- })262- if _, err := Registry["overwrite"](nil); err != nil {263- t.Fatal(err)264- }265- if first {266- t.Error("first function should not have been called")267- }268- if !second {269- t.Error("second function should have been called")270- }271- })242+func TestNormalizeName(t *testing.T) {243+ tests := []struct {244+ input string245+ want string246+ }{247+ {input: "Word Search", want: "word search"},248+ {input: "lights-out", want: "lights out"},249+ {input: "word_search", want: "word search"},250+ {input: " Sudoku ", want: "sudoku"},251+ }272252 273- t.Run("lookup missing key", func(t *testing.T) {274- if Registry["nonexistent"] != nil {275- t.Error("expected nil for missing key")253+ for _, tt := range tests {254+ if got := NormalizeName(tt.input); got != tt.want {255+ t.Fatalf("NormalizeName(%q) = %q, want %q", tt.input, got, tt.want)276256 }277- })257+ }278258 }279259 280260 // --- TitleBarView (P3) ---
M game/gamer.go +30 -7
@@ -4,6 +4,7 @@ package game
44 import (55 "context"66 "math/rand/v2"7+ "strings"78 89 "charm.land/bubbles/v2/key"910 "charm.land/bubbles/v2/list"
@@ -87,6 +88,35 @@ func (c Category) Title() string { return c.Name }
8788 func (c Category) Description() string { return c.Desc }8889 func (c Category) FilterValue() string { return c.Name }8990 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,108+ }109+}110+111+// NormalizeName lowercases and replaces hyphens/underscores with spaces for112+// fuzzy matching of CLI arguments to game names and aliases.113+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)118+}119+90120 // SpawnCompleteMsg is sent when an async Spawn() call finishes.91121 type SpawnCompleteMsg struct {92122 Game Gamer
@@ -96,10 +126,3 @@ type SpawnCompleteMsg struct {
96126 // HelpToggleMsg is sent from the root model to games when the user toggles97127 // the full help display with Ctrl+H.98128 type HelpToggleMsg struct{ Show bool }99-100-// Registry maps game type names to their import functions.101-var Registry = map[string]func([]byte) (Gamer, error){}102-103-func Register(name string, fn func([]byte) (Gamer, error)) {104- Registry[name] = fn105-}
M hashiwokakero/Gamemode.go +10 -6
M hitori/Gamemode.go +9 -6
M hitori/hitori_test.go +3 -8
M lightsout/Gamemode.go +10 -6
M nonogram/Gamemode.go +9 -6
M nurikabe/Gamemode.go +10 -6
M resolve/resolve.go +32 -58
@@ -8,61 +8,32 @@ import (
88 "strings"99 1010 "github.com/FelineStateMachine/puzzletea/game"11-12- "charm.land/bubbles/v2/list"1311 )1412 1513 // Normalize lowercases and replaces hyphens/underscores with spaces for16-// fuzzy matching of CLI arguments to game/mode names.14+// fuzzy matching of CLI arguments to mode names.1715 func Normalize(s string) string {18- s = strings.ToLower(s)19- s = strings.ReplaceAll(s, "-", " ")20- s = strings.ReplaceAll(s, "_", " ")21- return strings.TrimSpace(s)22-}23-24-// CategoryAliases maps short or alternate names to canonical category names.25-var CategoryAliases = map[string]string{26- "hashi": "hashiwokakero",27- "bridges": "hashiwokakero",28- "hitori": "hitori",29- "lights": "lights out",30- "nonogram": "nonogram",31- "nurikabe": "nurikabe",32- "islands": "nurikabe",33- "sea": "nurikabe",34- "sudoku": "sudoku",35- "takuzu": "takuzu",36- "binairo": "takuzu",37- "binary": "takuzu",38- "shikaku": "shikaku",39- "rectangles": "shikaku",40- "words": "word search",41- "wordsearch": "word search",42- "ws": "word search",16+ return game.NormalizeName(s)4317 }4418 4519 // Category finds a game category by name (case-insensitive,4620 // hyphen/underscore-tolerant, with alias support).47-func Category(name string, categories []list.Item) (game.Category, error) {48- norm := Normalize(name)49-50- if canonical, ok := CategoryAliases[norm]; ok {51- norm = canonical52- }21+func Category(name string, definitions []game.Definition) (game.Category, error) {22+ norm := game.NormalizeName(name)5323 54- for _, item := range categories {55- cat, ok := item.(game.Category)56- if !ok {57- continue24+ for _, def := range definitions {25+ if game.NormalizeName(def.Name) == norm {26+ return def.Category(), nil5827 }59- if Normalize(cat.Name) == norm {60- return cat, nil28+ for _, alias := range def.Aliases {29+ if game.NormalizeName(alias) == norm {30+ return def.Category(), nil31+ }6132 }6233 }6334 6435 return game.Category{}, fmt.Errorf("unknown game %q\n\nAvailable games:\n %s",65- name, strings.Join(CategoryNames(categories), "\n "))36+ name, joinLines(CategoryNames(definitions)))6637 }6738 6839 // Mode finds a mode within a category by name. If name is empty,
@@ -107,14 +78,10 @@ func Mode(cat game.Category, name string) (game.Spawner, string, error) {
10778 }10879 10980 // CategoryNames returns the display names of all game categories.110-func CategoryNames(categories []list.Item) []string {111- names := make([]string, 0, len(categories))112- for _, item := range categories {113- cat, ok := item.(game.Category)114- if !ok {115- continue116- }117- names = append(names, cat.Name)81+func CategoryNames(definitions []game.Definition) []string {82+ names := make([]string, 0, len(definitions))83+ for _, def := range definitions {84+ names = append(names, def.Name)11885 }11986 return names12087 }
@@ -156,16 +123,12 @@ type seededEntry struct {
156123 // This is resilient to changes in the category/mode lists: adding or157124 // removing a mode only affects seeds where the changed entry would have158125 // been the winner.159-func SeededMode(seed string, categories []list.Item) (game.SeededSpawner, string, string, error) {126+func SeededMode(seed string, definitions []game.Definition) (game.SeededSpawner, string, string, error) {160127 var best seededEntry161128 var bestHash uint64162129 found := false163- for _, item := range categories {164- cat, ok := item.(game.Category)165- if !ok {166- continue167- }168- for _, modeItem := range cat.Modes {130+ for _, def := range definitions {131+ for _, modeItem := range def.Modes {169132 mode, ok := modeItem.(game.Mode)170133 if !ok {171134 continue
@@ -177,7 +140,7 @@ func SeededMode(seed string, categories []list.Item) (game.SeededSpawner, string
177140 h := fnv.New64a()178141 h.Write([]byte(seed))179142 h.Write([]byte{0})180- h.Write([]byte(cat.Name))143+ h.Write([]byte(def.Name))181144 h.Write([]byte{0})182145 h.Write([]byte(mode.Title()))183146 score := h.Sum64()
@@ -185,7 +148,7 @@ func SeededMode(seed string, categories []list.Item) (game.SeededSpawner, string
185148 bestHash = score186149 best = seededEntry{187150 spawner: s,188- gameType: cat.Name,151+ gameType: def.Name,189152 mode: mode.Title(),190153 }191154 found = true
@@ -197,3 +160,14 @@ func SeededMode(seed string, categories []list.Item) (game.SeededSpawner, string
197160 }198161 return best.spawner, best.gameType, best.mode, nil199162 }163+164+func joinLines(values []string) string {165+ if len(values) == 0 {166+ return ""167+ }168+ result := values[0]169+ for i := 1; i < len(values); i++ {170+ result += "\n " + values[i]171+ }172+ return result173+}
M resolve/resolve_test.go +37 -34
@@ -4,28 +4,29 @@ import (
44 "strings"55 "testing"66 7+ "charm.land/bubbles/v2/list"78 "github.com/FelineStateMachine/puzzletea/game"89 "github.com/FelineStateMachine/puzzletea/hashiwokakero"910 "github.com/FelineStateMachine/puzzletea/hitori"1011 "github.com/FelineStateMachine/puzzletea/lightsout"1112 "github.com/FelineStateMachine/puzzletea/nonogram"1213 "github.com/FelineStateMachine/puzzletea/nurikabe"14+ "github.com/FelineStateMachine/puzzletea/shikaku"1315 "github.com/FelineStateMachine/puzzletea/sudoku"1416 "github.com/FelineStateMachine/puzzletea/takuzu"1517 "github.com/FelineStateMachine/puzzletea/wordsearch"16-17- "charm.land/bubbles/v2/list"1818 )1919 20-var testCategories = []list.Item{21- game.Category{Name: "Hashiwokakero", Desc: "Connect islands with bridges.", Modes: hashiwokakero.Modes},22- game.Category{Name: "Hitori", Desc: "Shade cells to eliminate duplicates.", Modes: hitori.Modes},23- game.Category{Name: "Lights Out", Desc: "Turn off all the lights.", Modes: lightsout.Modes},24- game.Category{Name: "Nonogram", Desc: "Fill cells to match row and column hints.", Modes: nonogram.Modes},25- game.Category{Name: "Nurikabe", Desc: "Build islands while keeping one connected sea.", Modes: nurikabe.Modes},26- game.Category{Name: "Sudoku", Desc: "Fill the 9x9 grid following sudoku rules.", Modes: sudoku.Modes},27- game.Category{Name: "Takuzu", Desc: "Fill the grid with ● and ○.", Modes: takuzu.Modes},28- game.Category{Name: "Word Search", Desc: "Find hidden words in a letter grid.", Modes: wordsearch.Modes},20+var testDefinitions = []game.Definition{21+ hashiwokakero.Definition,22+ hitori.Definition,23+ lightsout.Definition,24+ nonogram.Definition,25+ nurikabe.Definition,26+ shikaku.Definition,27+ sudoku.Definition,28+ takuzu.Definition,29+ wordsearch.Definition,2930 }3031 3132 func TestNormalize(t *testing.T) {
@@ -79,7 +80,7 @@ func TestCategory(t *testing.T) {
7980 8081 for _, tt := range exactTests {8182 t.Run(tt.name, func(t *testing.T) {82- cat, err := Category(tt.input, testCategories)83+ cat, err := Category(tt.input, testDefinitions)8384 if err != nil {8485 t.Fatalf("Category(%q) error: %v", tt.input, err)8586 }
@@ -113,7 +114,7 @@ func TestCategory(t *testing.T) {
113114 114115 for _, tt := range aliasTests {115116 t.Run("alias_"+tt.alias, func(t *testing.T) {116- cat, err := Category(tt.alias, testCategories)117+ cat, err := Category(tt.alias, testDefinitions)117118 if err != nil {118119 t.Fatalf("Category(%q) error: %v", tt.alias, err)119120 }
@@ -125,7 +126,7 @@ func TestCategory(t *testing.T) {
125126 126127 // Error cases.127128 t.Run("unknown game", func(t *testing.T) {128- _, err := Category("chess", testCategories)129+ _, err := Category("chess", testDefinitions)129130 if err == nil {130131 t.Fatal("expected error for unknown game")131132 }
@@ -135,7 +136,7 @@ func TestCategory(t *testing.T) {
135136 })136137 137138 t.Run("empty string", func(t *testing.T) {138- _, err := Category("", testCategories)139+ _, err := Category("", testDefinitions)139140 if err == nil {140141 t.Fatal("expected error for empty string")141142 }
@@ -144,7 +145,7 @@ func TestCategory(t *testing.T) {
144145 145146 func TestMode(t *testing.T) {146147 // Find a known category with modes for testing.147- sudokuCat, err := Category("Sudoku", testCategories)148+ sudokuCat, err := Category("Sudoku", testDefinitions)148149 if err != nil {149150 t.Fatal(err)150151 }
@@ -194,7 +195,7 @@ func TestMode(t *testing.T) {
194195 })195196 196197 t.Run("empty modes list", func(t *testing.T) {197- emptyCat := game.Category{Name: "Empty", Modes: []list.Item{}}198+ emptyCat := game.Category{Name: "Empty", Modes: nil}198199 _, _, err := Mode(emptyCat, "any")199200 if err == nil {200201 t.Fatal("expected error for empty modes")
@@ -206,9 +207,9 @@ func TestMode(t *testing.T) {
206207 }207208 208209 func TestCategoryNames(t *testing.T) {209- names := CategoryNames(testCategories)210- if len(names) != len(testCategories) {211- t.Fatalf("len(CategoryNames()) = %d, want %d", len(names), len(testCategories))210+ names := CategoryNames(testDefinitions)211+ if len(names) != len(testDefinitions) {212+ t.Fatalf("len(CategoryNames()) = %d, want %d", len(names), len(testDefinitions))212213 }213214 214215 // Verify all expected games are present.
@@ -218,6 +219,7 @@ func TestCategoryNames(t *testing.T) {
218219 "Lights Out": true,219220 "Nonogram": true,220221 "Nurikabe": true,222+ "Shikaku": true,221223 "Sudoku": true,222224 "Takuzu": true,223225 "Word Search": true,
@@ -234,7 +236,7 @@ func TestCategoryNames(t *testing.T) {
234236 }235237 236238 func TestModeNames(t *testing.T) {237- cat, err := Category("Lights Out", testCategories)239+ cat, err := Category("Lights Out", testDefinitions)238240 if err != nil {239241 t.Fatal(err)240242 }
@@ -291,11 +293,11 @@ func TestRNGFromString(t *testing.T) {
291293 292294 func TestSeededMode(t *testing.T) {293295 t.Run("deterministic selection", func(t *testing.T) {294- s1, gt1, m1, err := SeededMode("test-seed-42", testCategories)296+ s1, gt1, m1, err := SeededMode("test-seed-42", testDefinitions)295297 if err != nil {296298 t.Fatal(err)297299 }298- s2, gt2, m2, err := SeededMode("test-seed-42", testCategories)300+ s2, gt2, m2, err := SeededMode("test-seed-42", testDefinitions)299301 if err != nil {300302 t.Fatal(err)301303 }
@@ -312,7 +314,7 @@ func TestSeededMode(t *testing.T) {
312314 })313315 314316 t.Run("returns valid metadata", func(t *testing.T) {315- spawner, gameType, modeTitle, err := SeededMode("test-seed", testCategories)317+ spawner, gameType, modeTitle, err := SeededMode("test-seed", testDefinitions)316318 if err != nil {317319 t.Fatal(err)318320 }
@@ -328,7 +330,7 @@ func TestSeededMode(t *testing.T) {
328330 })329331 330332 t.Run("empty categories returns error", func(t *testing.T) {331- _, _, _, err := SeededMode("test", []list.Item{})333+ _, _, _, err := SeededMode("test", nil)332334 if err == nil {333335 t.Fatal("expected error for empty categories")334336 }
@@ -336,8 +338,8 @@ func TestSeededMode(t *testing.T) {
336338 337339 t.Run("categories with no seeded spawners returns error", func(t *testing.T) {338340 // Category with no modes at all.339- cats := []list.Item{340- game.Category{Name: "Empty", Modes: []list.Item{}},341+ cats := []game.Definition{342+ {Name: "Empty", Modes: nil, Import: func([]byte) (game.Gamer, error) { return nil, nil }},341343 }342344 _, _, _, err := SeededMode("test", cats)343345 if err == nil {
@@ -361,7 +363,7 @@ func TestSeededMode(t *testing.T) {
361363 // Record selections with original categories.362364 original := make([]selection, len(seeds))363365 for i, seed := range seeds {364- _, gt, m, err := SeededMode(seed, testCategories)366+ _, gt, m, err := SeededMode(seed, testDefinitions)365367 if err != nil {366368 t.Fatal(err)367369 }
@@ -369,12 +371,13 @@ func TestSeededMode(t *testing.T) {
369371 }370372 371373 // Add a synthetic category with one mode.372- extended := make([]list.Item, len(testCategories))373- copy(extended, testCategories)374- extended = append(extended, game.Category{375- Name: "Synthetic",376- Desc: "Fake game for testing",377- Modes: []list.Item{lightsout.Modes[0]},374+ extended := make([]game.Definition, len(testDefinitions))375+ copy(extended, testDefinitions)376+ extended = append(extended, game.Definition{377+ Name: "Synthetic",378+ Description: "Fake game for testing",379+ Modes: []list.Item{lightsout.Modes[0]},380+ Import: func([]byte) (game.Gamer, error) { return nil, nil },378381 })379382 380383 for i, seed := range seeds {
A session/session.go +98 -0
@@ -0,0 +1,98 @@
1+package session2+3+import (4+ "fmt"5+ "strconv"6+ "strings"7+8+ "github.com/FelineStateMachine/puzzletea/catalog"9+ "github.com/FelineStateMachine/puzzletea/game"10+ "github.com/FelineStateMachine/puzzletea/namegen"11+ "github.com/FelineStateMachine/puzzletea/resolve"12+ "github.com/FelineStateMachine/puzzletea/store"13+)14+15+// NormalizeSeed keeps seeded names distinct from real dailies.16+func NormalizeSeed(seed string) string {17+ seed = strings.TrimSpace(seed)18+ if strings.HasPrefix(strings.ToLower(seed), "daily") {19+ return strings.ToLower(seed[:len("daily")]) + seed[len("daily"):]20+ }21+ return seed22+}23+24+// SeededName derives the deterministic display name for a seeded puzzle.25+func SeededName(seed string) string {26+ nameRNG := resolve.RNGFromString("name:" + seed)27+ return seed + " - " + namegen.GenerateSeeded(nameRNG)28+}29+30+// ImportRecord reconstructs a saved game and reapplies the record title.31+func ImportRecord(rec *store.GameRecord) (game.Gamer, error) {32+ if rec == nil {33+ return nil, fmt.Errorf("nil game record")34+ }35+36+ g, err := catalog.Import(rec.GameType, []byte(rec.SaveState))37+ if err != nil {38+ return nil, err39+ }40+ return g.SetTitle(rec.Name), nil41+}42+43+// CreateRecord persists the initial state for a newly created game.44+func CreateRecord(45+ s *store.Store,46+ g game.Gamer,47+ name string,48+ gameType string,49+ modeTitle string,50+) (*store.GameRecord, error) {51+ initialState, err := g.GetSave()52+ if err != nil {53+ return nil, fmt.Errorf("failed to get initial save: %w", err)54+ }55+56+ rec := &store.GameRecord{57+ Name: name,58+ GameType: gameType,59+ Mode: modeTitle,60+ InitialState: string(initialState),61+ SaveState: string(initialState),62+ Status: store.StatusNew,63+ }64+ if err := s.CreateGame(rec); err != nil {65+ return nil, fmt.Errorf("failed to create game record: %w", err)66+ }67+ return rec, nil68+}69+70+// ResumeAbandonedDeterministicRecord marks an abandoned deterministic game71+// as in-progress before resuming it.72+func ResumeAbandonedDeterministicRecord(s *store.Store, rec *store.GameRecord) error {73+ if rec == nil || rec.Status != store.StatusAbandoned {74+ return nil75+ }76+ if err := s.UpdateStatus(rec.ID, store.StatusInProgress); err != nil {77+ return fmt.Errorf("failed to mark deterministic game in progress: %w", err)78+ }79+ return nil80+}81+82+// GenerateUniqueName generates a unique adjective-noun name, retrying if needed.83+func GenerateUniqueName(s *store.Store) string {84+ for range 100 {85+ name := namegen.Generate()86+ exists, err := s.NameExists(name)87+ if err != nil || !exists {88+ return name89+ }90+ }91+ for i := 1; ; i++ {92+ name := namegen.Generate() + "-" + strconv.Itoa(i)93+ exists, err := s.NameExists(name)94+ if err != nil || !exists {95+ return name96+ }97+ }98+}
A session/session_test.go +101 -0
@@ -0,0 +1,101 @@
1+package session2+3+import (4+ "path/filepath"5+ "testing"6+7+ "github.com/FelineStateMachine/puzzletea/lightsout"8+ "github.com/FelineStateMachine/puzzletea/store"9+)10+11+func TestNormalizeSeed(t *testing.T) {12+ tests := []struct {13+ input string14+ want string15+ }{16+ {input: "daily-challenge", want: "daily-challenge"},17+ {input: "DailyChallenge", want: "dailyChallenge"},18+ {input: "seed-123", want: "seed-123"},19+ }20+21+ for _, tt := range tests {22+ if got := NormalizeSeed(tt.input); got != tt.want {23+ t.Fatalf("NormalizeSeed(%q) = %q, want %q", tt.input, got, tt.want)24+ }25+ }26+}27+28+func TestSeededNameDeterministic(t *testing.T) {29+ seed := "zebra-seed"30+ if got, want := SeededName(seed), SeededName(seed); got != want {31+ t.Fatalf("SeededName(%q) = %q then %q", seed, got, want)32+ }33+}34+35+func TestImportRecordReappliesTitle(t *testing.T) {36+ g, err := lightsout.New(3, 3)37+ if err != nil {38+ t.Fatal(err)39+ }40+ save, err := g.GetSave()41+ if err != nil {42+ t.Fatal(err)43+ }44+45+ loaded, err := ImportRecord(&store.GameRecord{46+ Name: "saved-game",47+ GameType: "Lights Out",48+ SaveState: string(save),49+ })50+ if err != nil {51+ t.Fatalf("ImportRecord returned error: %v", err)52+ }53+ if loaded == nil {54+ t.Fatal("ImportRecord returned nil game")55+ }56+}57+58+func TestCreateRecordAndResumeAbandonedDeterministicRecord(t *testing.T) {59+ s := openSessionTestStore(t)60+61+ g, err := lightsout.New(3, 3)62+ if err != nil {63+ t.Fatal(err)64+ }65+ rec, err := CreateRecord(s, g, "demo", "Lights Out", "Easy")66+ if err != nil {67+ t.Fatalf("CreateRecord returned error: %v", err)68+ }69+ if rec.ID == 0 {70+ t.Fatal("CreateRecord did not populate ID")71+ }72+73+ rec.Status = store.StatusAbandoned74+ if err := s.UpdateStatus(rec.ID, store.StatusAbandoned); err != nil {75+ t.Fatal(err)76+ }77+ if err := ResumeAbandonedDeterministicRecord(s, rec); err != nil {78+ t.Fatalf("ResumeAbandonedDeterministicRecord returned error: %v", err)79+ }80+81+ saved, err := s.GetDailyGame(rec.Name)82+ if err != nil {83+ t.Fatal(err)84+ }85+ if saved == nil {86+ t.Fatal("expected saved record to exist")87+ }88+ if saved.Status != store.StatusInProgress {89+ t.Fatalf("status = %q, want %q", saved.Status, store.StatusInProgress)90+ }91+}92+93+func openSessionTestStore(t *testing.T) *store.Store {94+ t.Helper()95+ s, err := store.Open(filepath.Join(t.TempDir(), "session.db"))96+ if err != nil {97+ t.Fatal(err)98+ }99+ t.Cleanup(func() { _ = s.Close() })100+ return s101+}
M shikaku/Gamemode.go +10 -6
M store/store.go +0 -6
M sudoku/Gamemode.go +9 -6
M takuzu/Gamemode.go +10 -6
M wordsearch/Gamemode.go +10 -6