clone

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

Merge pull request #33 from FelineStateMachine/refactor/split-main-package-into-components

FelineStateMachine committed 4b25b3a
refactor: split main package model into focused components
commit
4b25b3adcf10266cf22aa7a87157de319e4d01f9
parent
bba30fc732861cc900644f978c32036f7d1c66bb
parent
fb7eab1870a3113e71c096bc96bdcf007b1cdda8
author
FelineStateMachine <dami.etoile@gmail.com>
16 files changed, +1118 -792
MAGENTS.md187++++----
Mcmd.go8+-
Rdaily.go → daily/daily.go58+-
Adaily/daily_test.go158+++++++
Adebug.go23+
Mmodel.go633+-----------------------------
Rresolve.go → resolve/resolve.go48+-
Rresolve_test.go → resolve/resolve_test.go72++-
Aspawn.go128++++++
Aui/list.go33++
Aui/menuitem.go11+
Aui/style.go18+
Aui/table.go76++++
Aui/ui_test.go94++++
Aupdate.go299++++++++++++++
Aview.go64+++
M AGENTS.md +94 -93
@@ -13,10 +13,11 @@ Without `just`: `go build -ldflags "-X main.version=$(git describe --tags --alwa
1313 1414 ### Testing1515 ```bash16-just test # go test ./...17-just test-short # go test -short ./... (skips slow generator tests)18-go test ./nonogram/ # single package19-go test ./sudoku/ -run TestGenerateGrid # specific test16+just test # go test ./...17+just test-short # go test -short ./... (skips slow generator tests)18+go test ./nonogram/ # single package19+go test ./sudoku/ -run TestGenerateGrid # specific test20+go test ./resolve/ -run TestCategory -v # specific test, verbose2021 ```2122 2223 ### Linting & Formatting
@@ -25,51 +26,44 @@ just lint # golangci-lint run ./...
2526 just fmt # gofumpt -w .2627 just tidy # go mod tidy2728 ```29+2830 **Always run `just fmt` and `just lint` before committing.**2931 3032 ---3133 32-## Code Style Guidelines33-34-### Formatting35-- Use `gofumpt` - run `just fmt`36-- No comments required unless explaining non-obvious logic37-- Keep lines under ~100 characters38-- Group imports: stdlib, puzzletea internal, Bubble Tea ecosystem34+## Project Structure35+```36+puzzletea/37+├── main.go, cmd.go, model.go # Entry point, CLI (cobra), root TUI model38+├── update.go, view.go # Root model update loop, view rendering39+├── spawn.go, debug.go # Game spawn/save lifecycle, debug overlay40+├── game/ # Plugin interfaces (Gamer, Mode, Spawner), cursor, keys, style41+├── store/ # SQLite persistence (GameRecord, CRUD)42+├── ui/ # Shared UI: menu list, table, styles, MenuItem43+├── daily/ # Daily puzzle seeding, RNG, mode selection44+├── resolve/ # CLI argument resolution (category/mode name matching)45+├── namegen/ # Adjective-noun name generator46+├── hashiwokakero/, hitori/, lightsout/, nonogram/47+├── sudoku/, takuzu/, wordsearch/ # Puzzle game packages48+├── plan/ # Design/planning documents49+└── vhs/ # GIF preview tapes50+```3951 40-### File Naming41-- **Capitalized**: `Gamemode.go`, `Model.go`, `Export.go`52+Each puzzle package follows a consistent file structure:53+- **Capitalized**: `Model.go`, `Gamemode.go`, `Export.go`4254 - **Lowercase**: `grid.go`, `keys.go`, `style.go`, `generator.go`, `<game>_test.go`4355 44-### Naming Conventions45-- **Types**: PascalCase (`Model`, `NonogramMode`, `grid`)46-- **Variables/Fields**: camelCase (`rowHints`, `currentHints`)47-- **Constants**: camelCase for constants48-- **Interfaces**: PascalCase (`Gamer`, `Spawner`, `Mode`)49-50-### Type Declarations51-Prefer type aliases for clarity:52-```go53-type (54- grid [][]rune55- state string56-)57-```56+---5857 59-### Interface Compliance60-Use compile-time checks:61-```go62-var _ game.Gamer = Model{}63-var _ game.Mode = NonogramMode{}64-var _ game.Spawner = NonogramMode{}65-```58+## Code Style Guidelines6659 67-### Error Handling68-- Return descriptive errors: `return Model{}, errors.New("puzzle width does not support row tomography definition")`69-- Check errors immediately; avoid wrapping unless adding context70-- Use `fmt.Errorf` with `%w` when wrapping60+### Formatting61+- Use `gofumpt` (stricter than gofmt) -- run `just fmt`62+- No comments required unless explaining non-obvious logic63+- Keep lines under ~100 characters7164 72-### Imports (grouped, blank line between)65+### Imports66+Two groups separated by a blank line: stdlib, then everything else (internal + external sorted together). When there are many internal imports, a third group separating internal from external is acceptable.7367 ```go7468 import (7569 "errors"
@@ -82,64 +76,79 @@ import (
8276 )8377 ```8478 79+### Naming Conventions80+- **Types**: PascalCase (`Model`, `NonogramMode`, `Entry`)81+- **Unexported types**: camelCase or lowercase (`grid`, `state`, `menuItem`)82+- **Variables/Fields**: camelCase (`rowHints`, `currentHints`)83+- **Constants**: camelCase (`mainMenuView`, `gameSelectView`)84+- **Interfaces**: PascalCase (`Gamer`, `Spawner`, `Mode`)85+86+### Type Declarations87+Prefer grouped type aliases: `type ( grid [][]rune; state string )`88+89+### Interface Compliance90+Use compile-time checks: `var _ game.Gamer = Model{}`, `var _ game.Spawner = NonogramMode{}`91+92+### Error Handling93+- Return descriptive errors: `errors.New("puzzle width does not support row tomography definition")`94+- Check errors immediately; use `fmt.Errorf` with `%w` only when wrapping adds context95+96+### Styling97+Use `lipgloss.AdaptiveColor` with ANSI 256 palette numbers. Avoid hex colors.98+8599 ---86100 87-## TUI Patterns101+## Plugin Architecture88102 89-### Model Structure103+### Gamer Interface (game/gamer.go)104+Every puzzle `Model` must implement:90105 ```go91-type Model struct {92- width, height int93- rowHints TomographyDefinition94- colHints TomographyDefinition95- cursor game.Cursor96- grid grid97- keys KeyMap98- modeTitle string99- showFullHelp bool100- currentHints Hints101- solved bool106+type Gamer interface {107+ Init() tea.Cmd108+ Update(msg tea.Msg) (Gamer, tea.Cmd)109+ View() string110+ GetDebugInfo() string111+ GetFullHelp() [][]key.Binding112+ GetSave() ([]byte, error)113+ IsSolved() bool114+ Reset() Gamer115+ SetTitle(string) Gamer102116 }103117 ```104118 105-### Update Method Pattern119+### Mode/Spawner Interfaces106120 ```go107-func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {108- switch msg := msg.(type) {109- case game.HelpToggleMsg:110- m.showFullHelp = msg.Show111- case tea.KeyMsg:112- switch {113- case key.Matches(msg, m.keys.FillTile):114- m.updateTile(filledTile)115- }116- m.cursor.Move(m.keys.CursorKeyMap, msg, m.width-1, m.height-1)117- }118- m.updateKeyBindings()119- return m, nil121+type Spawner interface { Spawn() (Gamer, error) }122+type SeededSpawner interface {123+ Spawner124+ SpawnSeeded(rng *rand.Rand) (Gamer, error)120125 }121126 ```122127 123-### Required Gamer Methods124-`Init() tea.Cmd`, `Update(tea.Msg)`, `View() string`, `GetDebugInfo() string`, `GetFullHelp() string`, `GetSave() []byte`, `IsSolved() bool`, `SetTitle(string)`128+### Puzzle Package Exports129+Every puzzle package exports `Modes`, `DailyModes` (`[]list.Item`), `HelpContent` (`string`), `NewMode(...)`, `New(...)`, `ImportModel([]byte) (*Model, error)`, and `DefaultKeyMap`.125130 126-### Styling127-Use `lipgloss.AdaptiveColor` with ANSI 256 palette numbers:131+Each package registers itself via `init()`:128132 ```go129-lipgloss.AdaptiveColor{Light: "255", Dark: "255"}133+func init() {134+ game.Register("Nonogram", func(data []byte) (game.Gamer, error) {135+ return ImportModel(data)136+ })137+}130138 ```131-Avoid hex colors (`#rrggbb`).132139 133140 ---134141 135142 ## Testing Conventions136143 137-Section comments with priority:144+### Section Comments with Priority138145 ```go139-// --- generateTomography (P0) ---146+// --- generateTomography (P0) --- // P0 = critical147+// --- Grid serialization (P1) --- // P1 = important148+// --- generateRandomState (P2) --- // P2 = generators/slow140149 ```141150 142-Table-driven tests with subtests:151+### Table-Driven Tests with Subtests143152 ```go144153 func TestGenerateTomography(t *testing.T) {145154 tests := []struct {
@@ -151,37 +160,29 @@ func TestGenerateTomography(t *testing.T) {
151160 }152161 for _, tt := range tests {153162 t.Run(tt.name, func(t *testing.T) {154- // test logic163+ // test logic using t.Errorf / t.Fatalf only (no assertion libraries)155164 })156165 }157166 }158167 ```159168 160-Save/load round-trip:169+### Save/Load Round-Trip Pattern161170 ```go162-m, _ := New(mode, hints)163-data := m.GetSave()171+data, err := m.GetSave()172+if err != nil { t.Fatal(err) }164173 loaded, err := ImportModel(data)165174 if err != nil { t.Fatal(err) }166175 // verify state preserved167176 ```168177 169-Priority: (P0) = critical, (P1) = important, (P2) = generators170-171----172-173-## Project Structure174-```175-puzzletea/176-├── main.go, cmd.go, model.go, resolve.go # Core app177-├── game/ # Plugin interface, cursor, keys, style178-├── store/ # SQLite persistence179-├── namegen/ # Adjective-noun name generator180-├── nonogram/, sudoku/, wordsearch/, hashiwokakero/, lightsout/, takuzu/ # Puzzle games181-└── vhs/ # GIF preview tapes178+### Slow Test Gating179+```go180+if testing.Short() {181+ t.Skip("skipping slow generator test in short mode")182+}182183 ```183184 184185 ---185186 186187 ## Global Keybindings187-- `Ctrl+N`: Main menu | `Ctrl+C`: Quit (saves abandoned) | `Ctrl+E`: Debug overlay | `Ctrl+H`: Full help | `Enter`: Select | `Escape`: Back188+`Ctrl+N` Main menu | `Ctrl+C` Quit (saves abandoned) | `Ctrl+E` Debug overlay | `Ctrl+H` Full help | `Ctrl+R` Reset puzzle | `Enter` Select | `Escape` Back
M cmd.go +5 -3
R daily.go → daily/daily.go +29 -29
@@ -1,4 +1,4 @@
1-package main1+package daily22 33 import (44 "hash/fnv"
@@ -18,16 +18,16 @@ import (
1818 "github.com/charmbracelet/bubbles/list"1919 )2020 21-// dailyEntry pairs a SeededSpawner with metadata for the eligible daily pool.22-type dailyEntry struct {23- spawner game.SeededSpawner24- gameType string25- mode string21+// Entry pairs a SeededSpawner with metadata for the eligible daily pool.22+type Entry struct {23+ Spawner game.SeededSpawner24+ GameType string25+ Mode string2626 }2727 28-// dailyPool maps game type names to their DailyModes exports.28+// pool maps game type names to their DailyModes exports.2929 // Each package owns which of its modes are eligible for daily rotation.30-var dailyPool = []struct {30+var pool = []struct {3131 gameType string3232 modes []list.Item3333 }{
@@ -40,51 +40,51 @@ var dailyPool = []struct {
4040 {"Word Search", wordsearch.DailyModes},4141 }4242 43-// eligibleDailyModes is the flattened pool built from each package's DailyModes.44-var eligibleDailyModes = buildEligibleDailyModes()43+// eligibleModes is the flattened pool built from each package's DailyModes.44+var eligibleModes = buildEligibleModes()4545 46-func buildEligibleDailyModes() []dailyEntry {47- var entries []dailyEntry48- for _, p := range dailyPool {46+func buildEligibleModes() []Entry {47+ var entries []Entry48+ for _, p := range pool {4949 for _, item := range p.modes {5050 s := item.(game.SeededSpawner)51- entries = append(entries, dailyEntry{52- spawner: s,53- gameType: p.gameType,54- mode: item.(game.Mode).Title(),51+ entries = append(entries, Entry{52+ Spawner: s,53+ GameType: p.gameType,54+ Mode: item.(game.Mode).Title(),5555 })5656 }5757 }5858 return entries5959 }6060 61-// dailySeed returns a deterministic int64 seed derived from the date.62-func dailySeed(date time.Time) uint64 {61+// Seed returns a deterministic uint64 seed derived from the date.62+func Seed(date time.Time) uint64 {6363 dateStr := date.Format("2006-01-02")6464 h := fnv.New64a()6565 h.Write([]byte(dateStr))6666 return h.Sum64()6767 }6868 69-// dailyRNG creates a deterministic RNG seeded from the given date.70-func dailyRNG(date time.Time) *rand.Rand {71- seed := dailySeed(date)69+// RNG creates a deterministic RNG seeded from the given date.70+func RNG(date time.Time) *rand.Rand {71+ seed := Seed(date)7272 return rand.New(rand.NewPCG(seed, ^seed))7373 }7474 75-// dailyName generates the daily puzzle name in the format:75+// Name generates the daily puzzle name in the format:7676 // "Daily Feb 14 26 - amber-falcon"7777 //78-// NOTE: this must be called before dailyMode on the same RNG — the number78+// NOTE: this must be called before Mode on the same RNG — the number7979 // of draws here determines which mode is selected. Changing this function8080 // will shift daily puzzles for all future dates.81-func dailyName(date time.Time, rng *rand.Rand) string {81+func Name(date time.Time, rng *rand.Rand) string {8282 return "Daily " + date.Format("Jan _2 06") + " - " + namegen.GenerateSeeded(rng)8383 }8484 85-// dailyMode selects the daily mode from the eligible pool using the seeded RNG.85+// Mode selects the daily mode from the eligible pool using the seeded RNG.8686 // Returns the spawner, game type name, and mode title.87-func dailyMode(rng *rand.Rand) (game.SeededSpawner, string, string) {88- entry := eligibleDailyModes[rng.IntN(len(eligibleDailyModes))]89- return entry.spawner, entry.gameType, entry.mode87+func Mode(rng *rand.Rand) (game.SeededSpawner, string, string) {88+ entry := eligibleModes[rng.IntN(len(eligibleModes))]89+ return entry.Spawner, entry.GameType, entry.Mode9090 }
A daily/daily_test.go +158 -0
@@ -0,0 +1,158 @@
1+package daily2+3+import (4+ "math/rand/v2"5+ "strings"6+ "testing"7+ "time"8+)9+10+// --- Seed (P0) ---11+12+func TestSeed(t *testing.T) {13+ tests := []struct {14+ name string15+ dateA time.Time16+ dateB time.Time17+ wantSame bool18+ }{19+ {20+ name: "same date same seed",21+ dateA: time.Date(2026, 2, 14, 10, 30, 0, 0, time.UTC),22+ dateB: time.Date(2026, 2, 14, 23, 59, 0, 0, time.UTC),23+ wantSame: true,24+ },25+ {26+ name: "different dates different seeds",27+ dateA: time.Date(2026, 2, 14, 0, 0, 0, 0, time.UTC),28+ dateB: time.Date(2026, 2, 15, 0, 0, 0, 0, time.UTC),29+ wantSame: false,30+ },31+ }32+33+ for _, tt := range tests {34+ t.Run(tt.name, func(t *testing.T) {35+ seedA := Seed(tt.dateA)36+ seedB := Seed(tt.dateB)37+ if (seedA == seedB) != tt.wantSame {38+ t.Errorf("Seed(%v)=%d, Seed(%v)=%d, wantSame=%v",39+ tt.dateA, seedA, tt.dateB, seedB, tt.wantSame)40+ }41+ })42+ }43+}44+45+func TestSeedDeterministic(t *testing.T) {46+ date := time.Date(2026, 6, 15, 12, 0, 0, 0, time.UTC)47+ s1 := Seed(date)48+ s2 := Seed(date)49+ if s1 != s2 {50+ t.Errorf("Seed is not deterministic: %d != %d", s1, s2)51+ }52+}53+54+// --- RNG (P0) ---55+56+func TestRNG(t *testing.T) {57+ date := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)58+ rng1 := RNG(date)59+ rng2 := RNG(date)60+61+ // Same date should produce the same sequence of random numbers.62+ for i := range 10 {63+ a := rng1.Int64()64+ b := rng2.Int64()65+ if a != b {66+ t.Fatalf("RNG diverged at draw %d: %d != %d", i, a, b)67+ }68+ }69+}70+71+func TestRNGDifferentDates(t *testing.T) {72+ rng1 := RNG(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))73+ rng2 := RNG(time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC))74+75+ // Different dates should produce different sequences.76+ same := true77+ for range 5 {78+ if rng1.Int64() != rng2.Int64() {79+ same = false80+ break81+ }82+ }83+ if same {84+ t.Error("RNG produced identical sequences for different dates")85+ }86+}87+88+// --- Name (P0) ---89+90+func TestName(t *testing.T) {91+ date := time.Date(2026, 2, 14, 0, 0, 0, 0, time.UTC)92+ rng := RNG(date)93+ name := Name(date, rng)94+95+ if !strings.HasPrefix(name, "Daily Feb 14 26 - ") {96+ t.Errorf("Name = %q, want prefix %q", name, "Daily Feb 14 26 - ")97+ }98+}99+100+func TestNameDeterministic(t *testing.T) {101+ date := time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC)102+ name1 := Name(date, RNG(date))103+ name2 := Name(date, RNG(date))104+ if name1 != name2 {105+ t.Errorf("Name not deterministic: %q != %q", name1, name2)106+ }107+}108+109+// --- Mode (P1) ---110+111+func TestMode(t *testing.T) {112+ rng := rand.New(rand.NewPCG(42, 99))113+ spawner, gameType, mode := Mode(rng)114+115+ if spawner == nil {116+ t.Fatal("Mode returned nil spawner")117+ }118+ if gameType == "" {119+ t.Error("Mode returned empty gameType")120+ }121+ if mode == "" {122+ t.Error("Mode returned empty mode title")123+ }124+}125+126+func TestModeDeterministic(t *testing.T) {127+ rng1 := rand.New(rand.NewPCG(100, 200))128+ rng2 := rand.New(rand.NewPCG(100, 200))129+130+ _, gt1, m1 := Mode(rng1)131+ _, gt2, m2 := Mode(rng2)132+133+ if gt1 != gt2 || m1 != m2 {134+ t.Errorf("Mode not deterministic: (%q,%q) vs (%q,%q)", gt1, m1, gt2, m2)135+ }136+}137+138+// --- EligibleModes pool (P1) ---139+140+func TestEligibleModesNotEmpty(t *testing.T) {141+ if len(eligibleModes) == 0 {142+ t.Fatal("eligibleModes is empty; no daily modes registered")143+ }144+}145+146+func TestEligibleModesHaveMetadata(t *testing.T) {147+ for i, e := range eligibleModes {148+ if e.Spawner == nil {149+ t.Errorf("eligibleModes[%d].Spawner is nil", i)150+ }151+ if e.GameType == "" {152+ t.Errorf("eligibleModes[%d].GameType is empty", i)153+ }154+ if e.Mode == "" {155+ t.Errorf("eligibleModes[%d].Mode is empty", i)156+ }157+ }158+}
A debug.go +23 -0
M model.go +16 -617
@@ -1,22 +1,19 @@
11 package main22 33 import (4- "fmt"5- "log"6- "math/rand/v2"7- "strconv"84 "time"95 106 "github.com/FelineStateMachine/puzzletea/game"117 "github.com/FelineStateMachine/puzzletea/hashiwokakero"128 "github.com/FelineStateMachine/puzzletea/hitori"139 "github.com/FelineStateMachine/puzzletea/lightsout"14- "github.com/FelineStateMachine/puzzletea/namegen"1510 "github.com/FelineStateMachine/puzzletea/nonogram"1611 "github.com/FelineStateMachine/puzzletea/store"1712 "github.com/FelineStateMachine/puzzletea/sudoku"1813 "github.com/FelineStateMachine/puzzletea/takuzu"14+ "github.com/FelineStateMachine/puzzletea/ui"1915 "github.com/FelineStateMachine/puzzletea/wordsearch"16+2017 "github.com/charmbracelet/bubbles/list"2118 "github.com/charmbracelet/bubbles/spinner"2219 "github.com/charmbracelet/bubbles/table"
@@ -27,17 +24,6 @@ import (
2724 )2825 2926 var (30- // Earth-tone palette for menus — ANSI 256 colors with light/dark adaptivity.31- menuAccent = lipgloss.AdaptiveColor{Light: "130", Dark: "173"}32- menuAccentLight = lipgloss.AdaptiveColor{Light: "137", Dark: "180"}33- menuText = lipgloss.AdaptiveColor{Light: "235", Dark: "252"}34- menuTextDim = lipgloss.AdaptiveColor{Light: "243", Dark: "243"}35- menuDim = lipgloss.AdaptiveColor{Light: "250", Dark: "238"}36- menuTableHeader = lipgloss.AdaptiveColor{Light: "130", Dark: "180"}37-38- rootStyle = lipgloss.NewStyle().Margin(1, 2)39- debugStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).BorderForeground(lipgloss.Color("124"))40-4127 GameCategories = []list.Item{4228 game.Category{Name: "Hashiwokakero", Desc: "Connect islands with bridges.", Modes: hashiwokakero.Modes, Help: hashiwokakero.HelpContent},4329 game.Category{Name: "Hitori", Desc: "Shade cells to eliminate duplicates.", Modes: hitori.Modes, Help: hitori.HelpContent},
@@ -47,6 +33,14 @@ var (
4733 game.Category{Name: "Takuzu", Desc: "Fill the grid with ● and ○.", Modes: takuzu.Modes, Help: takuzu.HelpContent},4834 game.Category{Name: "Word Search", Desc: "Find hidden words in a letter grid.", Modes: wordsearch.Modes, Help: wordsearch.HelpContent},4935 }36+37+ mainMenuItems = []list.Item{38+ ui.MenuItem{ItemTitle: "Daily Puzzle", Desc: time.Now().Format("Jan _2 06")},39+ ui.MenuItem{ItemTitle: "Generate", Desc: "a new puzzle."},40+ ui.MenuItem{ItemTitle: "Continue", Desc: "a previous puzzle."},41+ ui.MenuItem{ItemTitle: "Guides", Desc: "to learn."},42+ ui.MenuItem{ItemTitle: "Quit", Desc: "the game."},43+ }5044 )5145 5246 const (
@@ -60,23 +54,6 @@ const (
6054 helpDetailView6155 )6256 63-type menuItem struct {64- title string65- desc string66-}67-68-func (i menuItem) Title() string { return i.title }69-func (i menuItem) Description() string { return i.desc }70-func (i menuItem) FilterValue() string { return i.title }71-72-var mainMenuItems = []list.Item{73- menuItem{title: "Daily Puzzle", desc: time.Now().Format("Jan _2 06")},74- menuItem{title: "Generate", desc: "a new puzzle."},75- menuItem{title: "Continue", desc: "a previous puzzle."},76- menuItem{title: "Guides", desc: "to learn."},77- menuItem{title: "Quit", desc: "the game."},78-}79-8057 type model struct {8158 state int8259
@@ -120,11 +97,11 @@ type model struct {
12097 12198 func initialModel(s *store.Store) model {12299 r := initDebugRenderer()123- l := initGameSelectList()124- ml := initMainMenuList()100+ l := ui.InitList(GameCategories, "Select Category")101+ ml := ui.InitList(mainMenuItems, "puzzletea")125102 sp := spinner.New()126103 sp.Spinner = spinner.Dot127- sp.Style = lipgloss.NewStyle().Foreground(menuAccent)104+ sp.Style = lipgloss.NewStyle().Foreground(ui.MenuAccent)128105 return model{129106 state: mainMenuView,130107 debugRenderer: r,
@@ -139,11 +116,11 @@ func initialModel(s *store.Store) model {
139116 // bypassing the menu. Used by CLI flags (--new, --continue).140117 func initialModelWithGame(s *store.Store, g game.Gamer, activeGameID int64, completionSaved bool) model {141118 r := initDebugRenderer()142- l := initGameSelectList()143- ml := initMainMenuList()119+ l := ui.InitList(GameCategories, "Select Category")120+ ml := ui.InitList(mainMenuItems, "puzzletea")144121 sp := spinner.New()145122 sp.Spinner = spinner.Dot146- sp.Style = lipgloss.NewStyle().Foreground(menuAccent)123+ sp.Style = lipgloss.NewStyle().Foreground(ui.MenuAccent)147124 return model{148125 state: gameView,149126 debugRenderer: r,
@@ -160,581 +137,3 @@ func initialModelWithGame(s *store.Store, g game.Gamer, activeGameID int64, comp
160137 func (m model) Init() tea.Cmd {161138 return nil162139 }163-164-func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {165- var cmd tea.Cmd166-167- switch msg := msg.(type) {168- case game.SpawnCompleteMsg:169- return m.handleSpawnComplete(msg)170-171- case tea.WindowSizeMsg:172- h, v := rootStyle.GetFrameSize()173- w, ht := msg.Width-h, msg.Height-v174- m.width = w175- m.height = ht176- menuW, menuH := min(w, 64), min(ht, 24)177- m.mainMenuList.SetSize(menuW, menuH)178- m.gameSelectList.SetSize(menuW, menuH)179- if m.state == modeSelectView {180- m.modeSelectList.SetSize(menuW, menuH)181- }182- if m.state == continueView {183- m.continueTable.SetWidth(w)184- m.continueTable.SetHeight(ht)185- }186- if m.state == helpSelectView {187- m.helpSelectList.SetSize(menuW, menuH)188- }189- if m.state == helpDetailView {190- m.helpViewport.Width = w191- m.helpViewport.Height = ht - 2192- }193-194- case tea.KeyMsg:195- // During generation, only allow Escape (to cancel) and Ctrl+C (to quit).196- if m.state == generatingView {197- switch msg.Type {198- case tea.KeyEscape:199- if m.dailyPending {200- m.dailyPending = false201- m.state = mainMenuView202- } else {203- m.state = modeSelectView204- }205- return m, nil206- case tea.KeyCtrlC:207- return m, tea.Quit208- }209- return m, nil210- }211- switch msg.Type {212- case tea.KeyCtrlN:213- m = saveCurrentGame(m, store.StatusInProgress)214- m.state = mainMenuView215- m.debug = false216- case tea.KeyEnter:217- if m.state != gameView {218- return m.handleEnter()219- }220- case tea.KeyEscape:221- return m.handleEscape()222- case tea.KeyCtrlC:223- m = saveCurrentGame(m, store.StatusAbandoned)224- return m, tea.Quit225- case tea.KeyCtrlE:226- m.debug = !m.debug227- case tea.KeyCtrlH:228- m.showFullHelp = !m.showFullHelp229- if m.state == gameView && m.game != nil {230- m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})231- }232- case tea.KeyCtrlR:233- if m.state == gameView && m.game != nil {234- m.game = m.game.Reset()235- }236- }237- }238-239- switch m.state {240- case mainMenuView:241- m.mainMenuList, cmd = m.mainMenuList.Update(msg)242- case generatingView:243- m.spinner, cmd = m.spinner.Update(msg)244- case gameView:245- m.game, cmd = m.game.Update(msg)246- if m.debug {247- m.debuginfo = m.renderDebugInfo()248- }249- if !m.completionSaved && m.game.IsSolved() {250- m.completionSaved = true251- saveData, err := m.game.GetSave()252- if err == nil {253- _ = m.store.UpdateSaveState(m.activeGameID, string(saveData))254- }255- _ = m.store.UpdateStatus(m.activeGameID, store.StatusCompleted)256- }257- case gameSelectView:258- m.gameSelectList, cmd = m.gameSelectList.Update(msg)259- case modeSelectView:260- m.modeSelectList, cmd = m.modeSelectList.Update(msg)261- case continueView:262- m.continueTable, cmd = m.continueTable.Update(msg)263- case helpSelectView:264- m.helpSelectList, cmd = m.helpSelectList.Update(msg)265- case helpDetailView:266- m.helpViewport, cmd = m.helpViewport.Update(msg)267- }268-269- return m, cmd270-}271-272-func (m model) handleEnter() (tea.Model, tea.Cmd) {273- switch m.state {274- case mainMenuView:275- return m.handleMainMenuEnter()276- case gameSelectView:277- return m.handleGameSelectEnter()278- case modeSelectView:279- return m.handleModeSelectEnter()280- case continueView:281- return m.handleContinueEnter()282- case helpSelectView:283- return m.handleHelpSelectEnter()284- }285- return m, nil286-}287-288-func (m model) handleMainMenuEnter() (tea.Model, tea.Cmd) {289- item, ok := m.mainMenuList.SelectedItem().(menuItem)290- if !ok {291- return m, nil292- }293- switch item.title {294- case "Daily Puzzle":295- return m.handleDailyPuzzle()296- case "Generate":297- m.state = gameSelectView298- case "Continue":299- m.initContinueTable()300- m.state = continueView301- case "Guides":302- m.helpSelectList = initList(GameCategories, "How to Play")303- m.helpSelectList.SetSize(min(m.width, 64), min(m.height, 24))304- m.state = helpSelectView305- case "Quit":306- return m, tea.Quit307- }308- return m, nil309-}310-311-func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {312- today := time.Now()313- rng := dailyRNG(today)314- name := dailyName(today, rng)315-316- // Check if a daily game already exists for today.317- rec, err := m.store.GetDailyGame(name)318- if err != nil {319- log.Printf("failed to check daily game: %v", err)320- return m, nil321- }322-323- if rec != nil {324- // Daily exists - resume or review it.325- importFn, ok := game.Registry[rec.GameType]326- if !ok {327- log.Printf("unknown game type for daily: %s", rec.GameType)328- return m, nil329- }330- g, err := importFn([]byte(rec.SaveState))331- if err != nil {332- log.Printf("failed to import daily game: %v", err)333- return m, nil334- }335- m.game = g.SetTitle(rec.Name)336- m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})337- m.activeGameID = rec.ID338- m.state = gameView339- m.completionSaved = rec.Status == store.StatusCompleted340-341- // Resume abandoned dailies by resetting their status.342- if rec.Status == store.StatusAbandoned {343- _ = m.store.UpdateStatus(rec.ID, store.StatusInProgress)344- }345- return m, nil346- }347-348- // No existing daily - generate a new one.349- spawner, gameType, modeTitle := dailyMode(rng)350- m.dailyPending = true351- m.dailyName = name352- m.dailyGameType = gameType353- m.dailyModeTitle = modeTitle354- m.state = generatingView355- m.generating = true356- return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng))357-}358-359-func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) {360- cat, ok := m.gameSelectList.SelectedItem().(game.Category)361- if !ok {362- return m, nil363- }364- m.selectedCategory = cat365- m.modeSelectList = initModeSelectList(cat)366- m.modeSelectList.SetSize(m.gameSelectList.Width(), m.gameSelectList.Height())367- m.state = modeSelectView368- return m, nil369-}370-371-func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {372- m.mode, _ = m.modeSelectList.SelectedItem().(game.Mode)373- spawner, ok := m.mode.(game.Spawner)374- if !ok {375- return m, nil376- }377- m.state = generatingView378- m.generating = true379- return m, tea.Batch(m.spinner.Tick, spawnCmd(spawner))380-}381-382-// spawnCmd returns a tea.Cmd that runs Spawn() off the main goroutine.383-func spawnCmd(spawner game.Spawner) tea.Cmd {384- return func() tea.Msg {385- g, err := spawner.Spawn()386- return game.SpawnCompleteMsg{Game: g, Err: err}387- }388-}389-390-// spawnSeededCmd returns a tea.Cmd that runs SpawnSeeded() off the main goroutine.391-// The caller must not use rng after this call — *rand.Rand is not goroutine-safe392-// and ownership is transferred to the spawned goroutine.393-func spawnSeededCmd(spawner game.SeededSpawner, rng *rand.Rand) tea.Cmd {394- return func() tea.Msg {395- g, err := spawner.SpawnSeeded(rng)396- return game.SpawnCompleteMsg{Game: g, Err: err}397- }398-}399-400-func (m model) handleSpawnComplete(msg game.SpawnCompleteMsg) (tea.Model, tea.Cmd) {401- m.generating = false402- isDaily := m.dailyPending403- m.dailyPending = false404-405- // If the user navigated away while generating, discard the result.406- if m.state != generatingView {407- return m, nil408- }409-410- if msg.Err != nil {411- log.Printf("failed to spawn game: %v", msg.Err)412- if isDaily {413- m.state = mainMenuView414- } else {415- m.state = modeSelectView416- }417- return m, nil418- }419-420- var name, gameType, modeTitle string421- if isDaily {422- name = m.dailyName423- gameType = m.dailyGameType424- modeTitle = m.dailyModeTitle425- m.game = msg.Game.SetTitle(name)426- } else {427- name = generateUniqueName(m.store)428- gameType = m.selectedCategory.Name429- modeTitle = m.mode.Title()430- m.game = msg.Game.SetTitle(name)431- }432- m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})433- m.state = gameView434- m.completionSaved = false435-436- // Capture initial state and create DB record.437- initialState, err := m.game.GetSave()438- if err != nil {439- log.Printf("failed to get initial save: %v", err)440- return m, nil441- }442- rec := &store.GameRecord{443- Name: name,444- GameType: gameType,445- Mode: modeTitle,446- InitialState: string(initialState),447- SaveState: string(initialState),448- Status: store.StatusNew,449- }450- if err := m.store.CreateGame(rec); err != nil {451- log.Printf("failed to create game record: %v", err)452- } else {453- m.activeGameID = rec.ID454- }455- return m, nil456-}457-458-func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {459- idx := m.continueTable.Cursor()460- if idx < 0 || idx >= len(m.continueGames) {461- return m, nil462- }463- rec := m.continueGames[idx]464- importFn, ok := game.Registry[rec.GameType]465- if !ok {466- return m, nil467- }468- g, err := importFn([]byte(rec.SaveState))469- if err != nil {470- log.Printf("failed to import game: %v", err)471- return m, nil472- }473- m.game = g.SetTitle(rec.Name)474- m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})475- m.activeGameID = rec.ID476- m.state = gameView477- m.completionSaved = rec.Status == store.StatusCompleted478- return m, nil479-}480-481-func (m model) handleHelpSelectEnter() (tea.Model, tea.Cmd) {482- cat, ok := m.helpSelectList.SelectedItem().(game.Category)483- if !ok {484- return m, nil485- }486- m.helpCategory = cat487-488- renderer, err := glamour.NewTermRenderer(489- glamour.WithAutoStyle(),490- glamour.WithWordWrap(m.width),491- )492- if err != nil {493- log.Printf("failed to create help renderer: %v", err)494- return m, nil495- }496- rendered, err := renderer.Render(cat.Help)497- if err != nil {498- log.Printf("failed to render help: %v", err)499- return m, nil500- }501-502- m.helpViewport = viewport.New(m.width, m.height-2)503- m.helpViewport.SetContent(rendered)504- m.state = helpDetailView505- return m, nil506-}507-508-func (m model) handleEscape() (tea.Model, tea.Cmd) {509- switch m.state {510- case generatingView:511- m.state = modeSelectView512- case modeSelectView:513- m.state = gameSelectView514- case gameSelectView, continueView:515- m.state = mainMenuView516- case helpDetailView:517- m.state = helpSelectView518- case helpSelectView:519- m.state = mainMenuView520- }521- return m, nil522-}523-524-func (m model) View() string {525- switch m.state {526- case mainMenuView:527- return rootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.mainMenuList.View()))528- case gameSelectView:529- return rootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.gameSelectList.View()))530- case modeSelectView:531- return rootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.modeSelectList.View()))532- case generatingView:533- s := m.spinner.View() + " Generating puzzle..."534- return rootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, s))535- case continueView:536- var s string537- if len(m.continueGames) == 0 {538- s = "No saved games yet.\n\nPress Escape to return."539- } else {540- title := lipgloss.NewStyle().541- Bold(true).542- Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "255"}).543- Background(menuAccent).544- Padding(0, 1).545- Render("Saved Games")546- s = lipgloss.JoinVertical(lipgloss.Left, title, "", m.continueTable.View())547- }548- return rootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, s))549- case gameView:550- if m.game == nil {551- return ""552- }553- var debugInfo string554- if m.debug {555- debugInfo = debugStyle.Render(m.debuginfo)556- }557- s := lipgloss.JoinVertical(lipgloss.Center,558- m.game.View(),559- debugInfo,560- )561- return rootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, s))562- case helpSelectView:563- return rootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.helpSelectList.View()))564- case helpDetailView:565- footer := lipgloss.NewStyle().566- Foreground(menuTextDim).567- Render("↑/↓ scroll • esc back")568- s := lipgloss.JoinVertical(lipgloss.Left,569- m.helpViewport.View(),570- footer,571- )572- return rootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, s))573- default:574- s := fmt.Sprintf("unknown state: %d", m.state)575- panic(s)576- }577-}578-579-func initList(items []list.Item, title string) list.Model {580- d := list.NewDefaultDelegate()581- d.Styles.SelectedTitle = d.Styles.SelectedTitle.582- Foreground(menuAccent).583- BorderLeftForeground(menuAccent)584- d.Styles.SelectedDesc = d.Styles.SelectedDesc.585- Foreground(menuAccentLight).586- BorderLeftForeground(menuAccent)587- d.Styles.NormalTitle = d.Styles.NormalTitle.588- Foreground(menuText)589- d.Styles.NormalDesc = d.Styles.NormalDesc.590- Foreground(menuTextDim)591-592- l := list.New(items, d, 64, 64)593- l.Title = title594- l.Styles.Title = lipgloss.NewStyle().595- Bold(true).596- Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "255"}).597- Background(menuAccent).598- Padding(0, 1)599- l.DisableQuitKeybindings()600- l.SetFilteringEnabled(false)601- l.SetShowHelp(false)602- return l603-}604-605-func initMainMenuList() list.Model {606- return initList(mainMenuItems, "puzzletea")607-}608-609-func initGameSelectList() list.Model {610- return initList(GameCategories, "Select Category")611-}612-613-func initModeSelectList(cat game.Category) list.Model {614- return initList(cat.Modes, cat.Name+" - Select Mode")615-}616-617-func (m *model) initContinueTable() {618- games, err := m.store.ListGames()619- if err != nil {620- log.Printf("failed to list games: %v", err)621- games = nil622- }623- m.continueGames = games624-625- columns := []table.Column{626- {Title: "Name", Width: 34},627- {Title: "Game", Width: 15},628- {Title: "Mode", Width: 15},629- {Title: "Status", Width: 12},630- {Title: "Last Updated", Width: 20},631- }632-633- rows := make([]table.Row, len(games))634- for i, g := range games {635- rows[i] = table.Row{636- g.Name,637- g.GameType,638- g.Mode,639- formatStatus(g.Status),640- g.UpdatedAt.Local().Format("Jan 02 15:04"),641- }642- }643-644- t := table.New(645- table.WithColumns(columns),646- table.WithRows(rows),647- table.WithFocused(true),648- table.WithHeight(max(m.height-2, 1)),649- )650-651- s := table.DefaultStyles()652- s.Header = s.Header.653- Bold(true).654- Foreground(menuTableHeader).655- BorderStyle(lipgloss.NormalBorder()).656- BorderBottom(true).657- BorderForeground(menuDim)658- s.Selected = s.Selected.659- Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "255"}).660- Background(menuAccent).661- Bold(true)662- s.Cell = s.Cell.663- Foreground(menuText)664- t.SetStyles(s)665-666- m.continueTable = t667-}668-669-func formatStatus(s store.GameStatus) string {670- switch s {671- case store.StatusNew:672- return "New"673- case store.StatusInProgress:674- return "In Progress"675- case store.StatusCompleted:676- return "Completed"677- default:678- return string(s)679- }680-}681-682-func initDebugRenderer() *glamour.TermRenderer {683- renderer, err := glamour.NewTermRenderer(684- glamour.WithAutoStyle(),685- glamour.WithPreservedNewLines(),686- glamour.WithChromaFormatter("terminal16m"),687- )688- if err != nil {689- panic(err)690- }691- return renderer692-}693-694-func (m model) renderDebugInfo() string {695- s, err := m.debugRenderer.Render(m.game.GetDebugInfo())696- if err != nil {697- panic(err)698- }699-700- return s701-}702-703-// saveCurrentGame saves the current game state to the DB if a game is active.704-func saveCurrentGame(m model, status store.GameStatus) model {705- if m.game == nil || m.activeGameID == 0 {706- return m707- }708- saveData, err := m.game.GetSave()709- if err != nil {710- log.Printf("failed to get save data: %v", err)711- return m712- }713- _ = m.store.UpdateSaveState(m.activeGameID, string(saveData))714- // Don't overwrite a completed status when navigating away.715- if !(m.completionSaved && status != store.StatusCompleted) {716- _ = m.store.UpdateStatus(m.activeGameID, status)717- }718- m.activeGameID = 0719- m.game = nil720- return m721-}722-723-// generateUniqueName generates a unique adjective-noun name, retrying if needed.724-func generateUniqueName(s *store.Store) string {725- for range 100 {726- name := namegen.Generate()727- exists, err := s.NameExists(name)728- if err != nil || !exists {729- return name730- }731- }732- // Fallback: append a number733- for i := 1; ; i++ {734- name := namegen.Generate() + "-" + strconv.Itoa(i)735- exists, err := s.NameExists(name)736- if err != nil || !exists {737- return name738- }739- }740-}
R resolve.go → resolve/resolve.go +25 -23
@@ -1,23 +1,25 @@
1-package main1+package resolve22 33 import (44 "fmt"55 "strings"66 77 "github.com/FelineStateMachine/puzzletea/game"8+9+ "github.com/charmbracelet/bubbles/list"810 )911 10-// normalize lowercases and replaces hyphens/underscores with spaces for12+// Normalize lowercases and replaces hyphens/underscores with spaces for1113 // fuzzy matching of CLI arguments to game/mode names.12-func normalize(s string) string {14+func Normalize(s string) string {1315 s = strings.ToLower(s)1416 s = strings.ReplaceAll(s, "-", " ")1517 s = strings.ReplaceAll(s, "_", " ")1618 return strings.TrimSpace(s)1719 }1820 19-// categoryAliases maps short or alternate names to canonical category names.20-var categoryAliases = map[string]string{21+// CategoryAliases maps short or alternate names to canonical category names.22+var CategoryAliases = map[string]string{2123 "hashi": "hashiwokakero",2224 "bridges": "hashiwokakero",2325 "hitori": "hitori",
@@ -32,30 +34,30 @@ var categoryAliases = map[string]string{
3234 "ws": "word search",3335 }3436 35-// resolveCategory finds a game category by name (case-insensitive,37+// Category finds a game category by name (case-insensitive,3638 // hyphen/underscore-tolerant, with alias support).37-func resolveCategory(name string) (game.Category, error) {38- norm := normalize(name)39+func Category(name string, categories []list.Item) (game.Category, error) {40+ norm := Normalize(name)3941 4042 // Check aliases first.41- if canonical, ok := categoryAliases[norm]; ok {43+ if canonical, ok := CategoryAliases[norm]; ok {4244 norm = canonical4345 }4446 45- for _, item := range GameCategories {47+ for _, item := range categories {4648 cat := item.(game.Category)47- if normalize(cat.Name) == norm {49+ if Normalize(cat.Name) == norm {4850 return cat, nil4951 }5052 }5153 5254 return game.Category{}, fmt.Errorf("unknown game %q\n\nAvailable games:\n %s",53- name, strings.Join(listCategoryNames(), "\n "))55+ name, strings.Join(CategoryNames(categories), "\n "))5456 }5557 56-// resolveMode finds a mode within a category by name. If name is empty,58+// Mode finds a mode within a category by name. If name is empty,5759 // returns the first (default) mode.58-func resolveMode(cat game.Category, name string) (game.Spawner, string, error) {60+func Mode(cat game.Category, name string) (game.Spawner, string, error) {5961 if len(cat.Modes) == 0 {6062 return nil, "", fmt.Errorf("game %q has no available modes", cat.Name)6163 }
@@ -65,29 +67,29 @@ func resolveMode(cat game.Category, name string) (game.Spawner, string, error) {
6567 return m.(game.Spawner), m.Title(), nil6668 }6769 68- norm := normalize(name)70+ norm := Normalize(name)6971 for _, item := range cat.Modes {7072 m := item.(game.Mode)71- if normalize(m.Title()) == norm {73+ if Normalize(m.Title()) == norm {7274 return m.(game.Spawner), m.Title(), nil7375 }7476 }7577 7678 return nil, "", fmt.Errorf("unknown mode %q for %s\n\nAvailable modes:\n %s",77- name, cat.Name, strings.Join(listModeNames(cat), "\n "))79+ name, cat.Name, strings.Join(ModeNames(cat), "\n "))7880 }7981 80-// listCategoryNames returns the display names of all game categories.81-func listCategoryNames() []string {82- names := make([]string, len(GameCategories))83- for i, item := range GameCategories {82+// CategoryNames returns the display names of all game categories.83+func CategoryNames(categories []list.Item) []string {84+ names := make([]string, len(categories))85+ for i, item := range categories {8486 names[i] = item.(game.Category).Name8587 }8688 return names8789 }8890 89-// listModeNames returns the display names of all modes in a category.90-func listModeNames(cat game.Category) []string {91+// ModeNames returns the display names of all modes in a category.92+func ModeNames(cat game.Category) []string {9193 names := make([]string, len(cat.Modes))9294 for i, item := range cat.Modes {9395 names[i] = item.(game.Mode).Title()
R resolve_test.go → resolve/resolve_test.go +45 -27
@@ -1,13 +1,31 @@
1-package main1+package resolve22 33 import (44 "strings"55 "testing"66 77 "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/sudoku"13+ "github.com/FelineStateMachine/puzzletea/takuzu"14+ "github.com/FelineStateMachine/puzzletea/wordsearch"15+816 "github.com/charmbracelet/bubbles/list"917 )1018 19+var testCategories = []list.Item{20+ game.Category{Name: "Hashiwokakero", Desc: "Connect islands with bridges.", Modes: hashiwokakero.Modes},21+ game.Category{Name: "Hitori", Desc: "Shade cells to eliminate duplicates.", Modes: hitori.Modes},22+ game.Category{Name: "Lights Out", Desc: "Turn off all the lights.", Modes: lightsout.Modes},23+ game.Category{Name: "Nonogram", Desc: "Fill cells to match row and column hints.", Modes: nonogram.Modes},24+ game.Category{Name: "Sudoku", Desc: "Fill the 9x9 grid following sudoku rules.", Modes: sudoku.Modes},25+ game.Category{Name: "Takuzu", Desc: "Fill the grid with ● and ○.", Modes: takuzu.Modes},26+ game.Category{Name: "Word Search", Desc: "Find hidden words in a letter grid.", Modes: wordsearch.Modes},27+}28+1129 func TestNormalize(t *testing.T) {1230 tests := []struct {1331 name string
@@ -29,15 +47,15 @@ func TestNormalize(t *testing.T) {
2947 3048 for _, tt := range tests {3149 t.Run(tt.name, func(t *testing.T) {32- got := normalize(tt.input)50+ got := Normalize(tt.input)3351 if got != tt.want {34- t.Errorf("normalize(%q) = %q, want %q", tt.input, got, tt.want)52+ t.Errorf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)3553 }3654 })3755 }3856 }3957 40-func TestResolveCategory(t *testing.T) {58+func TestCategory(t *testing.T) {4159 // Exact and case-insensitive matches.4260 exactTests := []struct {4361 name string
@@ -59,12 +77,12 @@ func TestResolveCategory(t *testing.T) {
5977 6078 for _, tt := range exactTests {6179 t.Run(tt.name, func(t *testing.T) {62- cat, err := resolveCategory(tt.input)80+ cat, err := Category(tt.input, testCategories)6381 if err != nil {64- t.Fatalf("resolveCategory(%q) error: %v", tt.input, err)82+ t.Fatalf("Category(%q) error: %v", tt.input, err)6583 }6684 if cat.Name != tt.wantName {67- t.Errorf("resolveCategory(%q).Name = %q, want %q", tt.input, cat.Name, tt.wantName)85+ t.Errorf("Category(%q).Name = %q, want %q", tt.input, cat.Name, tt.wantName)6886 }6987 })7088 }
@@ -90,19 +108,19 @@ func TestResolveCategory(t *testing.T) {
90108 91109 for _, tt := range aliasTests {92110 t.Run("alias_"+tt.alias, func(t *testing.T) {93- cat, err := resolveCategory(tt.alias)111+ cat, err := Category(tt.alias, testCategories)94112 if err != nil {95- t.Fatalf("resolveCategory(%q) error: %v", tt.alias, err)113+ t.Fatalf("Category(%q) error: %v", tt.alias, err)96114 }97115 if cat.Name != tt.wantName {98- t.Errorf("resolveCategory(%q).Name = %q, want %q", tt.alias, cat.Name, tt.wantName)116+ t.Errorf("Category(%q).Name = %q, want %q", tt.alias, cat.Name, tt.wantName)99117 }100118 })101119 }102120 103121 // Error cases.104122 t.Run("unknown game", func(t *testing.T) {105- _, err := resolveCategory("chess")123+ _, err := Category("chess", testCategories)106124 if err == nil {107125 t.Fatal("expected error for unknown game")108126 }
@@ -112,22 +130,22 @@ func TestResolveCategory(t *testing.T) {
112130 })113131 114132 t.Run("empty string", func(t *testing.T) {115- _, err := resolveCategory("")133+ _, err := Category("", testCategories)116134 if err == nil {117135 t.Fatal("expected error for empty string")118136 }119137 })120138 }121139 122-func TestResolveMode(t *testing.T) {140+func TestMode(t *testing.T) {123141 // Find a known category with modes for testing.124- sudoku, err := resolveCategory("Sudoku")142+ sudokuCat, err := Category("Sudoku", testCategories)125143 if err != nil {126144 t.Fatal(err)127145 }128146 129147 t.Run("empty name returns default (first) mode", func(t *testing.T) {130- spawner, title, err := resolveMode(sudoku, "")148+ spawner, title, err := Mode(sudokuCat, "")131149 if err != nil {132150 t.Fatal(err)133151 }
@@ -141,7 +159,7 @@ func TestResolveMode(t *testing.T) {
141159 })142160 143161 t.Run("exact mode title", func(t *testing.T) {144- _, title, err := resolveMode(sudoku, "Easy")162+ _, title, err := Mode(sudokuCat, "Easy")145163 if err != nil {146164 t.Fatal(err)147165 }
@@ -151,7 +169,7 @@ func TestResolveMode(t *testing.T) {
151169 })152170 153171 t.Run("case insensitive", func(t *testing.T) {154- _, title, err := resolveMode(sudoku, "easy")172+ _, title, err := Mode(sudokuCat, "easy")155173 if err != nil {156174 t.Fatal(err)157175 }
@@ -161,7 +179,7 @@ func TestResolveMode(t *testing.T) {
161179 })162180 163181 t.Run("unknown mode", func(t *testing.T) {164- _, _, err := resolveMode(sudoku, "impossible")182+ _, _, err := Mode(sudokuCat, "impossible")165183 if err == nil {166184 t.Fatal("expected error for unknown mode")167185 }
@@ -172,7 +190,7 @@ func TestResolveMode(t *testing.T) {
172190 173191 t.Run("empty modes list", func(t *testing.T) {174192 emptyCat := game.Category{Name: "Empty", Modes: []list.Item{}}175- _, _, err := resolveMode(emptyCat, "any")193+ _, _, err := Mode(emptyCat, "any")176194 if err == nil {177195 t.Fatal("expected error for empty modes")178196 }
@@ -182,10 +200,10 @@ func TestResolveMode(t *testing.T) {
182200 })183201 }184202 185-func TestListCategoryNames(t *testing.T) {186- names := listCategoryNames()187- if len(names) != len(GameCategories) {188- t.Fatalf("len(listCategoryNames()) = %d, want %d", len(names), len(GameCategories))203+func TestCategoryNames(t *testing.T) {204+ names := CategoryNames(testCategories)205+ if len(names) != len(testCategories) {206+ t.Fatalf("len(CategoryNames()) = %d, want %d", len(names), len(testCategories))189207 }190208 191209 // Verify all expected games are present.
@@ -209,15 +227,15 @@ func TestListCategoryNames(t *testing.T) {
209227 }210228 }211229 212-func TestListModeNames(t *testing.T) {213- cat, err := resolveCategory("Lights Out")230+func TestModeNames(t *testing.T) {231+ cat, err := Category("Lights Out", testCategories)214232 if err != nil {215233 t.Fatal(err)216234 }217235 218- names := listModeNames(cat)236+ names := ModeNames(cat)219237 if len(names) != len(cat.Modes) {220- t.Fatalf("len(listModeNames) = %d, want %d", len(names), len(cat.Modes))238+ t.Fatalf("len(ModeNames) = %d, want %d", len(names), len(cat.Modes))221239 }222240 223241 // Lights Out modes: Easy, Medium, Hard, Extreme.
A spawn.go +128 -0
@@ -0,0 +1,128 @@
1+package main2+3+import (4+ "log"5+ "math/rand/v2"6+ "strconv"7+8+ "github.com/FelineStateMachine/puzzletea/game"9+ "github.com/FelineStateMachine/puzzletea/namegen"10+ "github.com/FelineStateMachine/puzzletea/store"11+12+ tea "github.com/charmbracelet/bubbletea"13+)14+15+// spawnCmd returns a tea.Cmd that runs Spawn() off the main goroutine.16+func spawnCmd(spawner game.Spawner) tea.Cmd {17+ return func() tea.Msg {18+ g, err := spawner.Spawn()19+ return game.SpawnCompleteMsg{Game: g, Err: err}20+ }21+}22+23+// spawnSeededCmd returns a tea.Cmd that runs SpawnSeeded() off the main goroutine.24+// The caller must not use rng after this call — *rand.Rand is not goroutine-safe25+// and ownership is transferred to the spawned goroutine.26+func spawnSeededCmd(spawner game.SeededSpawner, rng *rand.Rand) tea.Cmd {27+ return func() tea.Msg {28+ g, err := spawner.SpawnSeeded(rng)29+ return game.SpawnCompleteMsg{Game: g, Err: err}30+ }31+}32+33+func (m model) handleSpawnComplete(msg game.SpawnCompleteMsg) (tea.Model, tea.Cmd) {34+ m.generating = false35+ isDaily := m.dailyPending36+ m.dailyPending = false37+38+ // If the user navigated away while generating, discard the result.39+ if m.state != generatingView {40+ return m, nil41+ }42+43+ if msg.Err != nil {44+ log.Printf("failed to spawn game: %v", msg.Err)45+ if isDaily {46+ m.state = mainMenuView47+ } else {48+ m.state = modeSelectView49+ }50+ return m, nil51+ }52+53+ var name, gameType, modeTitle string54+ if isDaily {55+ name = m.dailyName56+ gameType = m.dailyGameType57+ modeTitle = m.dailyModeTitle58+ m.game = msg.Game.SetTitle(name)59+ } else {60+ name = generateUniqueName(m.store)61+ gameType = m.selectedCategory.Name62+ modeTitle = m.mode.Title()63+ m.game = msg.Game.SetTitle(name)64+ }65+ m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})66+ m.state = gameView67+ m.completionSaved = false68+69+ // Capture initial state and create DB record.70+ initialState, err := m.game.GetSave()71+ if err != nil {72+ log.Printf("failed to get initial save: %v", err)73+ return m, nil74+ }75+ rec := &store.GameRecord{76+ Name: name,77+ GameType: gameType,78+ Mode: modeTitle,79+ InitialState: string(initialState),80+ SaveState: string(initialState),81+ Status: store.StatusNew,82+ }83+ if err := m.store.CreateGame(rec); err != nil {84+ log.Printf("failed to create game record: %v", err)85+ } else {86+ m.activeGameID = rec.ID87+ }88+ return m, nil89+}90+91+// saveCurrentGame saves the current game state to the DB if a game is active.92+func saveCurrentGame(m model, status store.GameStatus) model {93+ if m.game == nil || m.activeGameID == 0 {94+ return m95+ }96+ saveData, err := m.game.GetSave()97+ if err != nil {98+ log.Printf("failed to get save data: %v", err)99+ return m100+ }101+ _ = m.store.UpdateSaveState(m.activeGameID, string(saveData))102+ // Don't overwrite a completed status when navigating away.103+ if !(m.completionSaved && status != store.StatusCompleted) {104+ _ = m.store.UpdateStatus(m.activeGameID, status)105+ }106+ m.activeGameID = 0107+ m.game = nil108+ return m109+}110+111+// generateUniqueName generates a unique adjective-noun name, retrying if needed.112+func generateUniqueName(s *store.Store) string {113+ for range 100 {114+ name := namegen.Generate()115+ exists, err := s.NameExists(name)116+ if err != nil || !exists {117+ return name118+ }119+ }120+ // Fallback: append a number121+ for i := 1; ; i++ {122+ name := namegen.Generate() + "-" + strconv.Itoa(i)123+ exists, err := s.NameExists(name)124+ if err != nil || !exists {125+ return name126+ }127+ }128+}
A ui/list.go +33 -0
A ui/menuitem.go +11 -0
A ui/style.go +18 -0
A ui/table.go +76 -0
@@ -0,0 +1,76 @@
1+package ui2+3+import (4+ "log"5+6+ "github.com/FelineStateMachine/puzzletea/store"7+ "github.com/charmbracelet/bubbles/table"8+ "github.com/charmbracelet/lipgloss"9+)10+11+// FormatStatus converts a GameStatus enum to a human-readable display string.12+func FormatStatus(s store.GameStatus) string {13+ switch s {14+ case store.StatusNew:15+ return "New"16+ case store.StatusInProgress:17+ return "In Progress"18+ case store.StatusCompleted:19+ return "Completed"20+ default:21+ return string(s)22+ }23+}24+25+// InitContinueTable loads saved games from the store and builds the table26+// widget. Returns the table, the loaded games, and the column definitions.27+func InitContinueTable(s *store.Store, height int) (table.Model, []store.GameRecord) {28+ games, err := s.ListGames()29+ if err != nil {30+ log.Printf("failed to list games: %v", err)31+ games = nil32+ }33+34+ columns := []table.Column{35+ {Title: "Name", Width: 34},36+ {Title: "Game", Width: 15},37+ {Title: "Mode", Width: 15},38+ {Title: "Status", Width: 12},39+ {Title: "Last Updated", Width: 20},40+ }41+42+ rows := make([]table.Row, len(games))43+ for i, g := range games {44+ rows[i] = table.Row{45+ g.Name,46+ g.GameType,47+ g.Mode,48+ FormatStatus(g.Status),49+ g.UpdatedAt.Local().Format("Jan 02 15:04"),50+ }51+ }52+53+ t := table.New(54+ table.WithColumns(columns),55+ table.WithRows(rows),56+ table.WithFocused(true),57+ table.WithHeight(max(height-2, 1)),58+ )59+60+ st := table.DefaultStyles()61+ st.Header = st.Header.62+ Bold(true).63+ Foreground(MenuTableHeader).64+ BorderStyle(lipgloss.NormalBorder()).65+ BorderBottom(true).66+ BorderForeground(MenuDim)67+ st.Selected = st.Selected.68+ Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "255"}).69+ Background(MenuAccent).70+ Bold(true)71+ st.Cell = st.Cell.72+ Foreground(MenuText)73+ t.SetStyles(st)74+75+ return t, games76+}
A ui/ui_test.go +94 -0
@@ -0,0 +1,94 @@
1+package ui2+3+import (4+ "testing"5+6+ "github.com/FelineStateMachine/puzzletea/store"7+8+ "github.com/charmbracelet/bubbles/list"9+)10+11+// --- MenuItem (P0) ---12+13+func TestMenuItemTitle(t *testing.T) {14+ item := MenuItem{ItemTitle: "Generate", Desc: "a new puzzle."}15+ if got := item.Title(); got != "Generate" {16+ t.Errorf("Title() = %q, want %q", got, "Generate")17+ }18+}19+20+func TestMenuItemDescription(t *testing.T) {21+ item := MenuItem{ItemTitle: "Generate", Desc: "a new puzzle."}22+ if got := item.Description(); got != "a new puzzle." {23+ t.Errorf("Description() = %q, want %q", got, "a new puzzle.")24+ }25+}26+27+func TestMenuItemFilterValue(t *testing.T) {28+ item := MenuItem{ItemTitle: "Generate", Desc: "a new puzzle."}29+ if got := item.FilterValue(); got != "Generate" {30+ t.Errorf("FilterValue() = %q, want %q", got, "Generate")31+ }32+}33+34+// --- FormatStatus (P0) ---35+36+func TestFormatStatus(t *testing.T) {37+ tests := []struct {38+ status store.GameStatus39+ want string40+ }{41+ {store.StatusNew, "New"},42+ {store.StatusInProgress, "In Progress"},43+ {store.StatusCompleted, "Completed"},44+ {store.StatusAbandoned, "abandoned"},45+ }46+47+ for _, tt := range tests {48+ t.Run(string(tt.status), func(t *testing.T) {49+ got := FormatStatus(tt.status)50+ if got != tt.want {51+ t.Errorf("FormatStatus(%q) = %q, want %q", tt.status, got, tt.want)52+ }53+ })54+ }55+}56+57+// --- InitList (P1) ---58+59+func TestInitList(t *testing.T) {60+ items := []list.Item{61+ MenuItem{ItemTitle: "One", Desc: "first"},62+ MenuItem{ItemTitle: "Two", Desc: "second"},63+ }64+65+ l := InitList(items, "Test Menu")66+67+ if l.Title != "Test Menu" {68+ t.Errorf("Title = %q, want %q", l.Title, "Test Menu")69+ }70+71+ allItems := l.Items()72+ if len(allItems) != 2 {73+ t.Fatalf("Items count = %d, want 2", len(allItems))74+ }75+76+ first := allItems[0].(MenuItem)77+ if first.Title() != "One" {78+ t.Errorf("first item Title = %q, want %q", first.Title(), "One")79+ }80+}81+82+func TestInitListDisablesQuitAndFilter(t *testing.T) {83+ l := InitList(nil, "Empty")84+85+ // Filtering should be disabled.86+ if l.FilteringEnabled() {87+ t.Error("expected filtering to be disabled")88+ }89+90+ // ShowHelp should be disabled.91+ if l.ShowHelp() {92+ t.Error("expected help to be disabled")93+ }94+}
A update.go +299 -0
@@ -0,0 +1,299 @@
1+package main2+3+import (4+ "log"5+ "time"6+7+ "github.com/FelineStateMachine/puzzletea/daily"8+ "github.com/FelineStateMachine/puzzletea/game"9+ "github.com/FelineStateMachine/puzzletea/store"10+ "github.com/FelineStateMachine/puzzletea/ui"11+12+ "github.com/charmbracelet/bubbles/viewport"13+ tea "github.com/charmbracelet/bubbletea"14+ "github.com/charmbracelet/glamour"15+)16+17+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {18+ var cmd tea.Cmd19+20+ switch msg := msg.(type) {21+ case game.SpawnCompleteMsg:22+ return m.handleSpawnComplete(msg)23+24+ case tea.WindowSizeMsg:25+ h, v := ui.RootStyle.GetFrameSize()26+ w, ht := msg.Width-h, msg.Height-v27+ m.width = w28+ m.height = ht29+ menuW, menuH := min(w, 64), min(ht, 24)30+ m.mainMenuList.SetSize(menuW, menuH)31+ m.gameSelectList.SetSize(menuW, menuH)32+ if m.state == modeSelectView {33+ m.modeSelectList.SetSize(menuW, menuH)34+ }35+ if m.state == continueView {36+ m.continueTable.SetWidth(w)37+ m.continueTable.SetHeight(ht)38+ }39+ if m.state == helpSelectView {40+ m.helpSelectList.SetSize(menuW, menuH)41+ }42+ if m.state == helpDetailView {43+ m.helpViewport.Width = w44+ m.helpViewport.Height = ht - 245+ }46+47+ case tea.KeyMsg:48+ // During generation, only allow Escape (to cancel) and Ctrl+C (to quit).49+ if m.state == generatingView {50+ switch msg.Type {51+ case tea.KeyEscape:52+ if m.dailyPending {53+ m.dailyPending = false54+ m.state = mainMenuView55+ } else {56+ m.state = modeSelectView57+ }58+ return m, nil59+ case tea.KeyCtrlC:60+ return m, tea.Quit61+ }62+ return m, nil63+ }64+ switch msg.Type {65+ case tea.KeyCtrlN:66+ m = saveCurrentGame(m, store.StatusInProgress)67+ m.state = mainMenuView68+ m.debug = false69+ case tea.KeyEnter:70+ if m.state != gameView {71+ return m.handleEnter()72+ }73+ case tea.KeyEscape:74+ return m.handleEscape()75+ case tea.KeyCtrlC:76+ m = saveCurrentGame(m, store.StatusAbandoned)77+ return m, tea.Quit78+ case tea.KeyCtrlE:79+ m.debug = !m.debug80+ case tea.KeyCtrlH:81+ m.showFullHelp = !m.showFullHelp82+ if m.state == gameView && m.game != nil {83+ m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})84+ }85+ case tea.KeyCtrlR:86+ if m.state == gameView && m.game != nil {87+ m.game = m.game.Reset()88+ }89+ }90+ }91+92+ switch m.state {93+ case mainMenuView:94+ m.mainMenuList, cmd = m.mainMenuList.Update(msg)95+ case generatingView:96+ m.spinner, cmd = m.spinner.Update(msg)97+ case gameView:98+ m.game, cmd = m.game.Update(msg)99+ if m.debug {100+ m.debuginfo = m.renderDebugInfo()101+ }102+ if !m.completionSaved && m.game.IsSolved() {103+ m.completionSaved = true104+ saveData, err := m.game.GetSave()105+ if err == nil {106+ _ = m.store.UpdateSaveState(m.activeGameID, string(saveData))107+ }108+ _ = m.store.UpdateStatus(m.activeGameID, store.StatusCompleted)109+ }110+ case gameSelectView:111+ m.gameSelectList, cmd = m.gameSelectList.Update(msg)112+ case modeSelectView:113+ m.modeSelectList, cmd = m.modeSelectList.Update(msg)114+ case continueView:115+ m.continueTable, cmd = m.continueTable.Update(msg)116+ case helpSelectView:117+ m.helpSelectList, cmd = m.helpSelectList.Update(msg)118+ case helpDetailView:119+ m.helpViewport, cmd = m.helpViewport.Update(msg)120+ }121+122+ return m, cmd123+}124+125+func (m model) handleEnter() (tea.Model, tea.Cmd) {126+ switch m.state {127+ case mainMenuView:128+ return m.handleMainMenuEnter()129+ case gameSelectView:130+ return m.handleGameSelectEnter()131+ case modeSelectView:132+ return m.handleModeSelectEnter()133+ case continueView:134+ return m.handleContinueEnter()135+ case helpSelectView:136+ return m.handleHelpSelectEnter()137+ }138+ return m, nil139+}140+141+func (m model) handleMainMenuEnter() (tea.Model, tea.Cmd) {142+ item, ok := m.mainMenuList.SelectedItem().(ui.MenuItem)143+ if !ok {144+ return m, nil145+ }146+ switch item.Title() {147+ case "Daily Puzzle":148+ return m.handleDailyPuzzle()149+ case "Generate":150+ m.state = gameSelectView151+ case "Continue":152+ m.continueTable, m.continueGames = ui.InitContinueTable(m.store, m.height)153+ m.state = continueView154+ case "Guides":155+ m.helpSelectList = ui.InitList(GameCategories, "How to Play")156+ m.helpSelectList.SetSize(min(m.width, 64), min(m.height, 24))157+ m.state = helpSelectView158+ case "Quit":159+ return m, tea.Quit160+ }161+ return m, nil162+}163+164+func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {165+ today := time.Now()166+ rng := daily.RNG(today)167+ name := daily.Name(today, rng)168+169+ // Check if a daily game already exists for today.170+ rec, err := m.store.GetDailyGame(name)171+ if err != nil {172+ log.Printf("failed to check daily game: %v", err)173+ return m, nil174+ }175+176+ if rec != nil {177+ // Daily exists - resume or review it.178+ importFn, ok := game.Registry[rec.GameType]179+ if !ok {180+ log.Printf("unknown game type for daily: %s", rec.GameType)181+ return m, nil182+ }183+ g, err := importFn([]byte(rec.SaveState))184+ if err != nil {185+ log.Printf("failed to import daily game: %v", err)186+ return m, nil187+ }188+ m.game = g.SetTitle(rec.Name)189+ m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})190+ m.activeGameID = rec.ID191+ m.state = gameView192+ m.completionSaved = rec.Status == store.StatusCompleted193+194+ // Resume abandoned dailies by resetting their status.195+ if rec.Status == store.StatusAbandoned {196+ _ = m.store.UpdateStatus(rec.ID, store.StatusInProgress)197+ }198+ return m, nil199+ }200+201+ // No existing daily - generate a new one.202+ spawner, gameType, modeTitle := daily.Mode(rng)203+ m.dailyPending = true204+ m.dailyName = name205+ m.dailyGameType = gameType206+ m.dailyModeTitle = modeTitle207+ m.state = generatingView208+ m.generating = true209+ return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng))210+}211+212+func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) {213+ cat, ok := m.gameSelectList.SelectedItem().(game.Category)214+ if !ok {215+ return m, nil216+ }217+ m.selectedCategory = cat218+ m.modeSelectList = ui.InitList(cat.Modes, cat.Name+" - Select Mode")219+ m.modeSelectList.SetSize(m.gameSelectList.Width(), m.gameSelectList.Height())220+ m.state = modeSelectView221+ return m, nil222+}223+224+func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {225+ m.mode, _ = m.modeSelectList.SelectedItem().(game.Mode)226+ spawner, ok := m.mode.(game.Spawner)227+ if !ok {228+ return m, nil229+ }230+ m.state = generatingView231+ m.generating = true232+ return m, tea.Batch(m.spinner.Tick, spawnCmd(spawner))233+}234+235+func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {236+ idx := m.continueTable.Cursor()237+ if idx < 0 || idx >= len(m.continueGames) {238+ return m, nil239+ }240+ rec := m.continueGames[idx]241+ importFn, ok := game.Registry[rec.GameType]242+ if !ok {243+ return m, nil244+ }245+ g, err := importFn([]byte(rec.SaveState))246+ if err != nil {247+ log.Printf("failed to import game: %v", err)248+ return m, nil249+ }250+ m.game = g.SetTitle(rec.Name)251+ m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})252+ m.activeGameID = rec.ID253+ m.state = gameView254+ m.completionSaved = rec.Status == store.StatusCompleted255+ return m, nil256+}257+258+func (m model) handleHelpSelectEnter() (tea.Model, tea.Cmd) {259+ cat, ok := m.helpSelectList.SelectedItem().(game.Category)260+ if !ok {261+ return m, nil262+ }263+ m.helpCategory = cat264+265+ renderer, err := glamour.NewTermRenderer(266+ glamour.WithAutoStyle(),267+ glamour.WithWordWrap(m.width),268+ )269+ if err != nil {270+ log.Printf("failed to create help renderer: %v", err)271+ return m, nil272+ }273+ rendered, err := renderer.Render(cat.Help)274+ if err != nil {275+ log.Printf("failed to render help: %v", err)276+ return m, nil277+ }278+279+ m.helpViewport = viewport.New(m.width, m.height-2)280+ m.helpViewport.SetContent(rendered)281+ m.state = helpDetailView282+ return m, nil283+}284+285+func (m model) handleEscape() (tea.Model, tea.Cmd) {286+ switch m.state {287+ case generatingView:288+ m.state = modeSelectView289+ case modeSelectView:290+ m.state = gameSelectView291+ case gameSelectView, continueView:292+ m.state = mainMenuView293+ case helpDetailView:294+ m.state = helpSelectView295+ case helpSelectView:296+ m.state = mainMenuView297+ }298+ return m, nil299+}
A view.go +64 -0
@@ -0,0 +1,64 @@
1+package main2+3+import (4+ "fmt"5+6+ "github.com/FelineStateMachine/puzzletea/ui"7+8+ "github.com/charmbracelet/lipgloss"9+)10+11+func (m model) View() string {12+ switch m.state {13+ case mainMenuView:14+ return ui.RootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.mainMenuList.View()))15+ case gameSelectView:16+ return ui.RootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.gameSelectList.View()))17+ case modeSelectView:18+ return ui.RootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.modeSelectList.View()))19+ case generatingView:20+ s := m.spinner.View() + " Generating puzzle..."21+ return ui.RootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, s))22+ case continueView:23+ var s string24+ if len(m.continueGames) == 0 {25+ s = "No saved games yet.\n\nPress Escape to return."26+ } else {27+ title := lipgloss.NewStyle().28+ Bold(true).29+ Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "255"}).30+ Background(ui.MenuAccent).31+ Padding(0, 1).32+ Render("Saved Games")33+ s = lipgloss.JoinVertical(lipgloss.Left, title, "", m.continueTable.View())34+ }35+ return ui.RootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, s))36+ case gameView:37+ if m.game == nil {38+ return ""39+ }40+ var debugInfo string41+ if m.debug {42+ debugInfo = ui.DebugStyle.Render(m.debuginfo)43+ }44+ s := lipgloss.JoinVertical(lipgloss.Center,45+ m.game.View(),46+ debugInfo,47+ )48+ return ui.RootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, s))49+ case helpSelectView:50+ return ui.RootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.helpSelectList.View()))51+ case helpDetailView:52+ footer := lipgloss.NewStyle().53+ Foreground(ui.MenuTextDim).54+ Render("↑/↓ scroll • esc back")55+ s := lipgloss.JoinVertical(lipgloss.Left,56+ m.helpViewport.View(),57+ footer,58+ )59+ return ui.RootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, s))60+ default:61+ s := fmt.Sprintf("unknown state: %d", m.state)62+ panic(s)63+ }64+}