clone

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

feat: add nurikabe and improve game session lifecycle

Dami committed 78dbe54
- add full Nurikabe implementation with mode registration, solver, generator, save/import support, key/mouse input handling, styling, docs, and comprehensive tests\n- integrate Nurikabe across app category selection, daily mode rotation, CLI docs/help text, root command copy, and alias resolution (nurikabe/islands/sea)\n- add cancellable spawn infrastructure with context timeouts, spawn job IDs, stale completion protection, and escape-driven cancellation during generation\n- extract shared game session helpers for import/create/run flows in app and cmd to reduce duplicated load/start logic\n- tighten persistence and stats paths with typed category initialization, safer store query helpers, and explicit current-daily handling for profile banner rendering\n- refine theme list rendering to prevent filter-induced title wrapping and preserve stable width, with focused UI/theme regression tests\n- ignore local golangci cache directory in .gitignore
commit
78dbe548d40b849ef1b954acf724a861b4010744
parent
8d31c6212912e45a84c3553744df1be8992b9cd0
author
Dami <dami.etoile@gmail.com>
32 files changed, +4038 -337
M.gitignore1+
MREADME.md18+-
Aapp/game_session.go38+
Mapp/model.go44+-
Mapp/spawn.go93++-
Aapp/spawn_test.go59++
Mapp/update.go150++--
Mcmd/continue.go19+-
Mcmd/new.go67+-
Mcmd/root.go4+-
Acmd/session.go65++
Mdaily/daily.go24+-
Mgame/gamer.go14+
Anurikabe/Export.go85++
Anurikabe/Gamemode.go86++
Anurikabe/Model.go255+++++++
Anurikabe/README.md53+
Anurikabe/generator.go1070++++++++++++++++++++++++++++++
Anurikabe/grid.go179+++++
Anurikabe/help.md38+
Anurikabe/keys.go48+
Anurikabe/mouse.go63++
Anurikabe/nurikabe_test.go673+++++++++++++++++++
Anurikabe/solver.go681+++++++++++++++++++
Anurikabe/style.go129++++
Mresolve/resolve.go66+-
Mresolve/resolve_test.go6+
Mstats/stats.go22+-
Mstore/store.go152++--
Mtheme/theme_test.go22+
Mui/list.go49+-
Mui/ui_test.go102+++
M .gitignore +1 -0
M README.md +13 -5
@@ -2,18 +2,18 @@
22 33 A terminal-based puzzle game collection built with [Bubble Tea](https://github.com/charmbracelet/bubbletea).44 5-Eight 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 a plugin architecture for adding new games.66 77 ![PuzzleTea menu](vhs/menu.gif)88 99 ## Features1010 11-- **8 puzzle games** -- Nonogram, Sudoku, Shikaku, Word Search, Hashiwokakero, Hitori, Lights Out, Takuzu11+- **9 puzzle games** -- Nonogram, Nurikabe, Sudoku, Shikaku, Word Search, Hashiwokakero, Hitori, Lights Out, Takuzu1212 - **Daily puzzles** -- A unique puzzle generated each day using deterministic seeding. Same date, same puzzle for everyone. Streak tracking rewards consecutive daily completions.1313 - **XP and leveling** -- Per-category levels based on victories. Harder modes yield more XP. Daily puzzles grant 2x XP.1414 - **Stats dashboard** -- Profile level, daily streak, victory counts, and XP progress bars per category.1515 - **365 color themes** -- Live-preview theme picker with WCAG-compliant contrast enforcement. Dark and light themes included.16-- **Mouse support** -- Click and drag in Nonogram, Shikaku, Word Search, and Lights Out.16+- **Mouse support** -- Click and drag in Nonogram, Nurikabe, Shikaku, and Word Search. Lights Out supports click-to-toggle.1717 - **Seeded puzzles** -- Share a seed string to generate identical puzzles across sessions and machines.1818 - **Save/load persistence** -- Games auto-save to SQLite. Resume any in-progress game by name.1919
@@ -22,6 +22,7 @@ Eight puzzle types, multiple difficulty modes, daily challenges, XP progression,
2222 | Game | Description | Modes |2323 |------|-------------|-------|2424 | **Nonogram** | Fill cells to match row and column hints | Easy/Medium/Hard across 5x5, 10x10, 15x15, 20x20 |25+| **Nurikabe** | Build islands while keeping one connected sea | 5 modes from 5x5 to 12x12 |2526 | **Shikaku** | Divide grid into rectangles matching cell counts | 5 modes from 7x7 to 11x11 |2627 | **Sudoku** | Classic 9x9 grid | Beginner, Easy, Medium, Hard, Expert, Diabolical |2728 | **Word Search** | Find hidden words in a letter grid | Easy, Medium, Hard (3-8 directions) |
@@ -116,7 +117,7 @@ puzzletea --continue amber-falcon
116117 117118 ### CLI Aliases118119 119-Several shorthand names are accepted for games: `hashi`/`bridges` for Hashiwokakero, `lights` for Lights Out, `binairo`/`binary` for Takuzu, `words`/`ws` for Word Search, `rectangles` for Shikaku.120+Several shorthand names are accepted for games: `hashi`/`bridges` for Hashiwokakero, `lights` for Lights Out, `islands`/`sea` for Nurikabe, `binairo`/`binary` for Takuzu, `words`/`ws` for Word Search, `rectangles` for Shikaku.120121 121122 ## Controls122123
@@ -138,7 +139,7 @@ Arrow keys, WASD, and Vim bindings (`hjkl`) are supported for grid movement acro
138139 139140 ### Mouse140141 141-Nonogram, Shikaku, and Word Search support click and drag. Lights Out supports click to toggle. See each game's help for details.142+Nonogram, Nurikabe, Shikaku, and Word Search support click and drag. Lights Out supports click to toggle. See each game's help for details.142143 143144 ## Game Persistence144145
@@ -153,6 +154,13 @@ Fill cells to match row and column hints.
153154 154155 [Game details and controls](nonogram/README.md)155156 157+### Nurikabe158+Build islands from clues while keeping one connected sea.159+160+Nurikabe preview GIF coming soon.161+162+[Game details and controls](nurikabe/README.md)163+156164 ### Shikaku157165 Divide the grid into rectangles, where each rectangle contains exactly the number of cells shown in its clue.158166
A app/game_session.go +38 -0
M app/model.go +30 -14
@@ -3,6 +3,7 @@
33 package app44 55 import (6+ "context"67 "time"78 89 "github.com/FelineStateMachine/puzzletea/config"
@@ -11,6 +12,7 @@ import (
1112 "github.com/FelineStateMachine/puzzletea/hitori"1213 "github.com/FelineStateMachine/puzzletea/lightsout"1314 "github.com/FelineStateMachine/puzzletea/nonogram"15+ "github.com/FelineStateMachine/puzzletea/nurikabe"1416 "github.com/FelineStateMachine/puzzletea/shikaku"1517 "github.com/FelineStateMachine/puzzletea/stats"1618 "github.com/FelineStateMachine/puzzletea/store"
@@ -30,16 +32,28 @@ import (
3032 "github.com/charmbracelet/glamour"3133 )3234 33-// GameCategories is the master registry of all puzzle types.34-var GameCategories = []list.Item{35- game.Category{Name: "Hashiwokakero", Desc: "Connect islands with bridges.", Modes: hashiwokakero.Modes, Help: hashiwokakero.HelpContent},36- game.Category{Name: "Hitori", Desc: "Shade cells to eliminate duplicates.", Modes: hitori.Modes, Help: hitori.HelpContent},37- game.Category{Name: "Lights Out", Desc: "Turn off all the lights.", Modes: lightsout.Modes, Help: lightsout.HelpContent},38- game.Category{Name: "Nonogram", Desc: "Fill cells to match row and column hints.", Modes: nonogram.Modes, Help: nonogram.HelpContent},39- game.Category{Name: "Shikaku", Desc: "Divide the grid into rectangles.", Modes: shikaku.Modes, Help: shikaku.HelpContent},40- game.Category{Name: "Sudoku", Desc: "Fill the 9x9 grid following sudoku rules.", Modes: sudoku.Modes, Help: sudoku.HelpContent},41- game.Category{Name: "Takuzu", Desc: "Fill the grid with ● and ○.", Modes: takuzu.Modes, Help: takuzu.HelpContent},42- game.Category{Name: "Word Search", Desc: "Find hidden words in a letter grid.", Modes: wordsearch.Modes, Help: wordsearch.HelpContent},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+}47+48+// GameCategories is the list.Item view of Categories for bubbles lists.49+var GameCategories = categoriesAsItems(Categories)50+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 items4357 }4458 4559 var mainMenuItems = []ui.MenuItem{
@@ -90,11 +104,13 @@ type model struct {
90104 continueTable table.Model91105 continueGames []store.GameRecord92106 93- mode game.Mode94- game game.Gamer107+ selectedModeTitle string108+ game game.Gamer95109 96- spinner spinner.Model97- generating bool // true while an async Spawn is in flight110+ spinner spinner.Model111+ generating bool // true while an async Spawn is in flight112+ spawnJobID int64113+ spawnCancel context.CancelFunc98114 99115 width int // available content width (terminal - rootStyle frame)100116 height int // available content height (terminal - rootStyle frame)
M app/spawn.go +76 -17
@@ -1,9 +1,11 @@
11 package app22 33 import (4+ "context"45 "log"56 "math/rand/v2"67 "strconv"8+ "time"79 810 "github.com/FelineStateMachine/puzzletea/game"911 "github.com/FelineStateMachine/puzzletea/namegen"
@@ -12,26 +14,85 @@ import (
1214 tea "charm.land/bubbletea/v2"1315 )1416 17+const spawnTimeout = 10 * time.Second18+19+type spawnCompleteMsg struct {20+ jobID int6421+ result game.SpawnCompleteMsg22+}23+1524 // spawnCmd returns a tea.Cmd that runs Spawn() off the main goroutine.16-func spawnCmd(spawner game.Spawner) tea.Cmd {25+func spawnCmd(spawner game.Spawner, ctx context.Context, jobID int64) tea.Cmd {1726 return func() tea.Msg {18- g, err := spawner.Spawn()19- return game.SpawnCompleteMsg{Game: g, Err: err}27+ var (28+ g game.Gamer29+ err error30+ )31+ if cancellable, ok := spawner.(game.CancellableSpawner); ok {32+ g, err = cancellable.SpawnContext(ctx)33+ } else {34+ g, err = spawner.Spawn()35+ }36+ return spawnCompleteMsg{37+ jobID: jobID,38+ result: game.SpawnCompleteMsg{Game: g, Err: err},39+ }2040 }2141 }2242 2343 // spawnSeededCmd returns a tea.Cmd that runs SpawnSeeded() off the main goroutine.2444 // The caller must not use rng after this call — *rand.Rand is not goroutine-safe2545 // and ownership is transferred to the spawned goroutine.26-func spawnSeededCmd(spawner game.SeededSpawner, rng *rand.Rand) tea.Cmd {46+func spawnSeededCmd(spawner game.SeededSpawner, rng *rand.Rand, ctx context.Context, jobID int64) tea.Cmd {2747 return func() tea.Msg {28- g, err := spawner.SpawnSeeded(rng)29- return game.SpawnCompleteMsg{Game: g, Err: err}48+ var (49+ g game.Gamer50+ err error51+ )52+ if cancellable, ok := spawner.(game.CancellableSeededSpawner); ok {53+ g, err = cancellable.SpawnSeededContext(ctx, rng)54+ } else {55+ g, err = spawner.SpawnSeeded(rng)56+ }57+ return spawnCompleteMsg{58+ jobID: jobID,59+ result: game.SpawnCompleteMsg{Game: g, Err: err},60+ }61+ }62+}63+64+func (m *model) beginSpawnContext() (context.Context, int64) {65+ m.cancelActiveSpawn()66+ m.spawnJobID++67+ jobID := m.spawnJobID68+ ctx, cancel := context.WithTimeout(context.Background(), spawnTimeout)69+ m.spawnCancel = cancel70+ m.generating = true71+ return ctx, jobID72+}73+74+func (m *model) cancelActiveSpawn() {75+ if m.spawnCancel != nil {76+ m.spawnCancel()77+ m.spawnCancel = nil3078 }79+ if m.generating {80+ // Invalidate late completion messages from a canceled job.81+ m.spawnJobID++82+ }83+ m.generating = false3184 }3285 33-func (m model) handleSpawnComplete(msg game.SpawnCompleteMsg) (tea.Model, tea.Cmd) {86+func (m model) handleSpawnComplete(jobID int64, msg game.SpawnCompleteMsg) (tea.Model, tea.Cmd) {87+ if jobID != m.spawnJobID {88+ return m, nil89+ }90+3491 m.generating = false92+ if m.spawnCancel != nil {93+ m.spawnCancel()94+ m.spawnCancel = nil95+ }3596 isDaily := m.dailyPending3697 isSeed := m.seedPending3798 m.dailyPending = false
@@ -57,22 +118,16 @@ func (m model) handleSpawnComplete(msg game.SpawnCompleteMsg) (tea.Model, tea.Cm
57118 name = m.dailyName58119 gameType = m.dailyGameType59120 modeTitle = m.dailyModeTitle60- m.game = msg.Game.SetTitle(name)61121 } else if isSeed {62122 name = m.seedName63123 gameType = m.seedGameType64124 modeTitle = m.seedModeTitle65- m.game = msg.Game.SetTitle(name)66125 } else {67126 name = GenerateUniqueName(m.store)68127 gameType = m.selectedCategory.Name69- modeTitle = m.mode.Title()70- m.game = msg.Game.SetTitle(name)128+ modeTitle = m.selectedModeTitle71129 }72- m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})73- m.game, _ = m.game.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})74- m.state = gameView75- m.completionSaved = false130+ m = m.activateGame(msg.Game.SetTitle(name), 0, false)76131 77132 // Capture initial state and create DB record.78133 initialState, err := m.game.GetSave()
@@ -106,10 +161,14 @@ func saveCurrentGame(m model, status store.GameStatus) model {
106161 log.Printf("failed to get save data: %v", err)107162 return m108163 }109- _ = m.store.UpdateSaveState(m.activeGameID, string(saveData))164+ if err := m.store.UpdateSaveState(m.activeGameID, string(saveData)); err != nil {165+ log.Printf("failed to update save state: %v", err)166+ }110167 // Don't overwrite a completed status when navigating away.111168 if !(m.completionSaved && status != store.StatusCompleted) {112- _ = m.store.UpdateStatus(m.activeGameID, status)169+ if err := m.store.UpdateStatus(m.activeGameID, status); err != nil {170+ log.Printf("failed to update game status: %v", err)171+ }113172 }114173 m.activeGameID = 0115174 m.game = nil
A app/spawn_test.go +59 -0
M app/update.go +64 -86
@@ -27,8 +27,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
2727 var cmd tea.Cmd2828 2929 switch msg := msg.(type) {30+ case spawnCompleteMsg:31+ return m.handleSpawnComplete(msg.jobID, msg.result)3032 case game.SpawnCompleteMsg:31- return m.handleSpawnComplete(msg)33+ // Backward compatibility for callers still using the old message type.34+ return m.handleSpawnComplete(m.spawnJobID, msg)3235 3336 case tea.WindowSizeMsg:3437 m.width = msg.Width
@@ -71,6 +74,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7174 if m.state == generatingView {7275 switch {7376 case key.Matches(msg, rootKeys.Escape):77+ m.cancelActiveSpawn()7478 if m.dailyPending || m.seedPending {7579 m.dailyPending = false7680 m.seedPending = false
@@ -114,32 +118,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
114118 115119 switch m.state {116120 case mainMenuView:117- if msg, ok := msg.(tea.KeyPressMsg); ok {118- switch msg.String() {119- case "up", "k":120- m.mainMenu.CursorUp()121- case "down", "j":122- m.mainMenu.CursorDown()123- }124- }121+ updateMainMenuCursor(msg, &m.mainMenu)125122 case playMenuView:126- if msg, ok := msg.(tea.KeyPressMsg); ok {127- switch msg.String() {128- case "up", "k":129- m.playMenu.CursorUp()130- case "down", "j":131- m.playMenu.CursorDown()132- }133- }123+ updateMainMenuCursor(msg, &m.playMenu)134124 case optionsMenuView:135- if msg, ok := msg.(tea.KeyPressMsg); ok {136- switch msg.String() {137- case "up", "k":138- m.optionsMenu.CursorUp()139- case "down", "j":140- m.optionsMenu.CursorDown()141- }142- }125+ updateMainMenuCursor(msg, &m.optionsMenu)143126 case seedInputView:144127 m.seedInput, cmd = m.seedInput.Update(msg)145128 case generatingView:
@@ -153,9 +136,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
153136 m.completionSaved = true154137 saveData, err := m.game.GetSave()155138 if err == nil {156- _ = m.store.UpdateSaveState(m.activeGameID, string(saveData))139+ if err := m.store.UpdateSaveState(m.activeGameID, string(saveData)); err != nil {140+ log.Printf("failed to save completion state: %v", err)141+ }142+ }143+ if err := m.store.UpdateStatus(m.activeGameID, store.StatusCompleted); err != nil {144+ log.Printf("failed to mark game completed: %v", err)157145 }158- _ = m.store.UpdateStatus(m.activeGameID, store.StatusCompleted)159146 }160147 case gameSelectView:161148 m.gameSelectList, cmd = m.gameSelectList.Update(msg)
@@ -184,6 +171,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
184171 return m, cmd185172 }186173 174+func updateMainMenuCursor(msg tea.Msg, menu *ui.MainMenu) {175+ keyMsg, ok := msg.(tea.KeyPressMsg)176+ if !ok {177+ return178+ }179+ switch keyMsg.String() {180+ case "up", "k":181+ menu.CursorUp()182+ case "down", "j":183+ menu.CursorDown()184+ }185+}186+187187 func (m model) handleEnter() (tea.Model, tea.Cmd) {188188 switch m.state {189189 case mainMenuView:
@@ -286,27 +286,14 @@ func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {
286286 }287287 288288 if rec != nil {289- // Seed game exists — resume it.290- importFn, ok := game.Registry[rec.GameType]291- if !ok {292- log.Printf("unknown game type for seeded game: %s", rec.GameType)293- return m, nil294- }295- g, err := importFn([]byte(rec.SaveState))296- if err != nil {297- log.Printf("failed to import seeded game: %v", err)298- return m, nil299- }300- m.game = g.SetTitle(rec.Name)301- m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})302- m.game, _ = m.game.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})303- m.activeGameID = rec.ID304- m.state = gameView305- m.completionSaved = rec.Status == store.StatusCompleted289+ // Seed game exists - resume it.290+ m = m.importAndActivateRecord(*rec)306291 307292 // Resume abandoned seeded games by resetting their status.308293 if rec.Status == store.StatusAbandoned {309- _ = m.store.UpdateStatus(rec.ID, store.StatusInProgress)294+ if err := m.store.UpdateStatus(rec.ID, store.StatusInProgress); err != nil {295+ log.Printf("failed to mark seeded game in progress: %v", err)296+ }310297 }311298 return m, nil312299 }
@@ -319,13 +306,13 @@ func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {
319306 }320307 321308 rng := resolve.RNGFromString(seed)309+ ctx, jobID := m.beginSpawnContext()322310 m.seedPending = true323311 m.seedName = name324312 m.seedGameType = gameType325313 m.seedModeTitle = modeTitle326314 m.state = generatingView327- m.generating = true328- return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng))315+ return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))329316 }330317 331318 func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {
@@ -341,26 +328,13 @@ func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {
341328 342329 if rec != nil {343330 // Daily exists - resume or review it.344- importFn, ok := game.Registry[rec.GameType]345- if !ok {346- log.Printf("unknown game type for daily: %s", rec.GameType)347- return m, nil348- }349- g, err := importFn([]byte(rec.SaveState))350- if err != nil {351- log.Printf("failed to import daily game: %v", err)352- return m, nil353- }354- m.game = g.SetTitle(rec.Name)355- m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})356- m.game, _ = m.game.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})357- m.activeGameID = rec.ID358- m.state = gameView359- m.completionSaved = rec.Status == store.StatusCompleted331+ m = m.importAndActivateRecord(*rec)360332 361333 // Resume abandoned dailies by resetting their status.362334 if rec.Status == store.StatusAbandoned {363- _ = m.store.UpdateStatus(rec.ID, store.StatusInProgress)335+ if err := m.store.UpdateStatus(rec.ID, store.StatusInProgress); err != nil {336+ log.Printf("failed to mark daily game in progress: %v", err)337+ }364338 }365339 return m, nil366340 }
@@ -369,14 +343,18 @@ func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {
369343 // Mode selection uses rendezvous hashing (independent of RNG).370344 // RNG is reserved purely for puzzle content generation.371345 spawner, gameType, modeTitle := daily.Mode(today)346+ if spawner == nil {347+ log.Printf("no daily mode available for %s", today.Format("2006-01-02"))348+ return m, nil349+ }372350 rng := daily.RNG(today)351+ ctx, jobID := m.beginSpawnContext()373352 m.dailyPending = true374353 m.dailyName = name375354 m.dailyGameType = gameType376355 m.dailyModeTitle = modeTitle377356 m.state = generatingView378- m.generating = true379- return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng))357+ return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))380358 }381359 382360 func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) {
@@ -392,14 +370,19 @@ func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) {
392370 }393371 394372 func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {395- m.mode, _ = m.modeSelectList.SelectedItem().(game.Mode)396- spawner, ok := m.mode.(game.Spawner)373+ item := m.modeSelectList.SelectedItem()374+ mode, ok := item.(game.Mode)397375 if !ok {398376 return m, nil399377 }378+ spawner, ok := item.(game.Spawner)379+ if !ok {380+ return m, nil381+ }382+ m.selectedModeTitle = mode.Title()383+ ctx, jobID := m.beginSpawnContext()400384 m.state = generatingView401- m.generating = true402- return m, tea.Batch(m.spinner.Tick, spawnCmd(spawner))385+ return m, tea.Batch(m.spinner.Tick, spawnCmd(spawner, ctx, jobID))403386 }404387 405388 func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {
@@ -408,21 +391,7 @@ func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {
408391 return m, nil409392 }410393 rec := m.continueGames[idx]411- importFn, ok := game.Registry[rec.GameType]412- if !ok {413- return m, nil414- }415- g, err := importFn([]byte(rec.SaveState))416- if err != nil {417- log.Printf("failed to import game: %v", err)418- return m, nil419- }420- m.game = g.SetTitle(rec.Name)421- m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})422- m.game, _ = m.game.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})423- m.activeGameID = rec.ID424- m.state = gameView425- m.completionSaved = rec.Status == store.StatusCompleted394+ m = m.importAndActivateRecord(rec)426395 return m, nil427396 }428397
@@ -466,6 +435,7 @@ func (m model) handleEscape() (tea.Model, tea.Cmd) {
466435 case seedInputView:467436 m.state = playMenuView468437 case generatingView:438+ m.cancelActiveSpawn()469439 m.state = modeSelectView470440 case modeSelectView:471441 m.state = gameSelectView
@@ -564,9 +534,17 @@ func (m model) handleStatsEnter() (tea.Model, tea.Cmd) {
564534 log.Printf("failed to get daily streak dates: %v", err)565535 return m, nil566536 }537+ now := time.Now()538+ currentDaily := false539+ rec, err := m.store.GetDailyGame(daily.Name(now))540+ if err != nil {541+ log.Printf("failed to check current daily: %v", err)542+ } else {543+ currentDaily = rec != nil544+ }567545 568546 m.statsCards = stats.BuildCards(catStats, modeStats)569- m.statsProfile = stats.BuildProfileBanner(catStats, modeStats, streakDates, m.store)547+ m.statsProfile = stats.BuildProfileBanner(catStats, modeStats, streakDates, currentDaily)570548 571549 // Banner is rendered as static chrome above the viewport; only the572550 // card grid scrolls. Compute available height for the viewport after
M cmd/continue.go +4 -15
M cmd/new.go +14 -53
@@ -2,18 +2,15 @@ package cmd
22 33 import (44 "fmt"5- "log"65 "strings"76 87 "github.com/FelineStateMachine/puzzletea/app"98 "github.com/FelineStateMachine/puzzletea/config"10- "github.com/FelineStateMachine/puzzletea/game"119 "github.com/FelineStateMachine/puzzletea/namegen"1210 "github.com/FelineStateMachine/puzzletea/resolve"1311 "github.com/FelineStateMachine/puzzletea/stats"1412 "github.com/FelineStateMachine/puzzletea/store"1513 16- tea "charm.land/bubbletea/v2"1714 "github.com/spf13/cobra"1815 )1916
@@ -74,32 +71,17 @@ func launchNewGame(gameArg, modeArg string, cfg *config.Config) error {
7471 }7572 defer s.Close()7673 77- stats.InitModeXP(app.GameCategories)74+ stats.InitModeXP(app.Categories)7875 7976 name := app.GenerateUniqueName(s)8077 g = g.SetTitle(name)8178 82- initialState, err := g.GetSave()79+ rec, err := createGameRecord(s, g, name, cat.Name, modeTitle)8380 if err != nil {84- return fmt.Errorf("failed to get initial save: %w", err)85- }86-87- rec := &store.GameRecord{88- Name: name,89- GameType: cat.Name,90- Mode: modeTitle,91- InitialState: string(initialState),92- SaveState: string(initialState),93- Status: store.StatusNew,94- }95- if err := s.CreateGame(rec); err != nil {96- log.Printf("failed to create game record: %v", err)81+ return err9782 }9883 99- m := app.InitialModelWithGame(s, cfg, g, rec.ID, false)100- p := tea.NewProgram(m)101- _, err = p.Run()102- return err84+ return runGameProgram(s, cfg, g, rec.ID, false)10385 }10486 10587 // launchSeededGame uses an arbitrary seed string to deterministically select
@@ -128,32 +110,26 @@ func launchSeededGame(seed string, cfg *config.Config) error {
128110 }129111 defer s.Close()130112 131- stats.InitModeXP(app.GameCategories)113+ stats.InitModeXP(app.Categories)132114 133115 // If a game with this name already exists, resume it (including134116 // abandoned games — seeded puzzles are deterministic and should135117 // always be resumable rather than duplicated).136118 if rec, err := s.GetDailyGame(name); err == nil && rec != nil {137- importFn, ok := game.Registry[rec.GameType]138- if !ok {139- return fmt.Errorf("unknown game type %q in save data", rec.GameType)140- }141- g, err := importFn([]byte(rec.SaveState))119+ g, err := importSavedGame(rec)142120 if err != nil {143- return fmt.Errorf("failed to import saved game: %w", err)121+ return err144122 }145- g = g.SetTitle(rec.Name)146123 completed := rec.Status == store.StatusCompleted147124 148125 // Resume abandoned seeded games by resetting their status.149126 if rec.Status == store.StatusAbandoned {150- _ = s.UpdateStatus(rec.ID, store.StatusInProgress)127+ if err := s.UpdateStatus(rec.ID, store.StatusInProgress); err != nil {128+ return fmt.Errorf("failed to mark seeded game in progress: %w", err)129+ }151130 }152131 153- m := app.InitialModelWithGame(s, cfg, g, rec.ID, completed)154- p := tea.NewProgram(m)155- _, err = p.Run()156- return err132+ return runGameProgram(s, cfg, g, rec.ID, completed)157133 }158134 159135 // Mode selection uses rendezvous hashing (independent of RNG).
@@ -170,25 +146,10 @@ func launchSeededGame(seed string, cfg *config.Config) error {
170146 }171147 g = g.SetTitle(name)172148 173- initialState, err := g.GetSave()149+ rec, err := createGameRecord(s, g, name, gameType, modeTitle)174150 if err != nil {175- return fmt.Errorf("failed to get initial save: %w", err)176- }177-178- rec := &store.GameRecord{179- Name: name,180- GameType: gameType,181- Mode: modeTitle,182- InitialState: string(initialState),183- SaveState: string(initialState),184- Status: store.StatusNew,185- }186- if err := s.CreateGame(rec); err != nil {187- log.Printf("failed to create game record: %v", err)151+ return err188152 }189153 190- m := app.InitialModelWithGame(s, cfg, g, rec.ID, false)191- p := tea.NewProgram(m)192- _, err = p.Run()193- return err154+ return runGameProgram(s, cfg, g, rec.ID, false)194155 }
M cmd/root.go +2 -2
A cmd/session.go +65 -0
@@ -0,0 +1,65 @@
1+package cmd2+3+import (4+ "fmt"5+6+ "github.com/FelineStateMachine/puzzletea/app"7+ "github.com/FelineStateMachine/puzzletea/config"8+ "github.com/FelineStateMachine/puzzletea/game"9+ "github.com/FelineStateMachine/puzzletea/store"10+11+ tea "charm.land/bubbletea/v2"12+)13+14+func importSavedGame(rec *store.GameRecord) (game.Gamer, error) {15+ importFn, ok := game.Registry[rec.GameType]16+ if !ok {17+ return nil, fmt.Errorf("unknown game type %q in save data", rec.GameType)18+ }19+20+ g, err := importFn([]byte(rec.SaveState))21+ if err != nil {22+ return nil, fmt.Errorf("failed to import game: %w", err)23+ }24+ return g.SetTitle(rec.Name), nil25+}26+27+func createGameRecord(28+ s *store.Store,29+ g game.Gamer,30+ name string,31+ gameType string,32+ modeTitle string,33+) (*store.GameRecord, error) {34+ initialState, err := g.GetSave()35+ if err != nil {36+ return nil, fmt.Errorf("failed to get initial save: %w", err)37+ }38+39+ rec := &store.GameRecord{40+ Name: name,41+ GameType: gameType,42+ Mode: modeTitle,43+ InitialState: string(initialState),44+ SaveState: string(initialState),45+ Status: store.StatusNew,46+ }47+ if err := s.CreateGame(rec); err != nil {48+ return nil, fmt.Errorf("failed to create game record: %w", err)49+ }50+51+ return rec, nil52+}53+54+func runGameProgram(55+ s *store.Store,56+ cfg *config.Config,57+ g game.Gamer,58+ activeGameID int64,59+ completionSaved bool,60+) error {61+ m := app.InitialModelWithGame(s, cfg, g, activeGameID, completionSaved)62+ p := tea.NewProgram(m)63+ _, err := p.Run()64+ return err65+}
M daily/daily.go +21 -3
@@ -11,6 +11,7 @@ import (
1111 "github.com/FelineStateMachine/puzzletea/lightsout"1212 "github.com/FelineStateMachine/puzzletea/namegen"1313 "github.com/FelineStateMachine/puzzletea/nonogram"14+ "github.com/FelineStateMachine/puzzletea/nurikabe"1415 "github.com/FelineStateMachine/puzzletea/shikaku"1516 "github.com/FelineStateMachine/puzzletea/sudoku"1617 "github.com/FelineStateMachine/puzzletea/takuzu"
@@ -39,6 +40,7 @@ var pool = []struct {
3940 {"Hitori", hitori.DailyModes},4041 {"Lights Out", lightsout.DailyModes},4142 {"Shikaku", shikaku.DailyModes},43+ {"Nurikabe", nurikabe.DailyModes},4244 {"Word Search", wordsearch.DailyModes},4345 }4446
@@ -49,11 +51,18 @@ func buildEligibleModes() []Entry {
4951 var entries []Entry5052 for _, p := range pool {5153 for _, item := range p.modes {52- s := item.(game.SeededSpawner)54+ s, ok := item.(game.SeededSpawner)55+ if !ok {56+ continue57+ }58+ mode, ok := item.(game.Mode)59+ if !ok {60+ continue61+ }5362 entries = append(entries, Entry{5463 Spawner: s,5564 GameType: p.gameType,56- Mode: item.(game.Mode).Title(),65+ Mode: mode.Title(),5766 })5867 }5968 }
@@ -95,10 +104,15 @@ func Name(date time.Time) string {
95104 // This is resilient to changes in the pool: adding or removing an entry96105 // only affects dates where the changed entry would have been the winner.97106 func Mode(date time.Time) (game.SeededSpawner, string, string) {107+ if len(eligibleModes) == 0 {108+ return nil, "", ""109+ }110+98111 dateStr := date.Format("2006-01-02")99112 100113 var best Entry101114 var bestHash uint64115+ found := false102116 for _, entry := range eligibleModes {103117 h := fnv.New64a()104118 h.Write([]byte(dateStr))
@@ -107,10 +121,14 @@ func Mode(date time.Time) (game.SeededSpawner, string, string) {
107121 h.Write([]byte{0})108122 h.Write([]byte(entry.Mode))109123 score := h.Sum64()110- if score > bestHash {124+ if !found || score > bestHash {111125 bestHash = score112126 best = entry127+ found = true113128 }114129 }130+ if !found {131+ return nil, "", ""132+ }115133 return best.Spawner, best.GameType, best.Mode116134 }
M game/gamer.go +14 -0
A nurikabe/Export.go +85 -0
@@ -0,0 +1,85 @@
1+package nurikabe2+3+import (4+ "encoding/json"5+ "fmt"6+7+ "github.com/FelineStateMachine/puzzletea/game"8+)9+10+type Save struct {11+ Width int `json:"width"`12+ Height int `json:"height"`13+ Clues string `json:"clues"`14+ Marks string `json:"marks"`15+ ModeTitle string `json:"mode_title"`16+}17+18+func (m Model) GetSave() ([]byte, error) {19+ save := Save{20+ Width: m.width,21+ Height: m.height,22+ Clues: serializeClues(m.clues),23+ Marks: m.marks.String(),24+ ModeTitle: m.modeTitle,25+ }26+ data, err := json.Marshal(save)27+ if err != nil {28+ return nil, fmt.Errorf("failed to marshal nurikabe save: %w", err)29+ }30+ return data, nil31+}32+33+func ImportModel(data []byte) (*Model, error) {34+ var s Save35+ if err := json.Unmarshal(data, &s); err != nil {36+ return nil, fmt.Errorf("failed to unmarshal nurikabe save: %w", err)37+ }38+ if s.Width <= 0 || s.Height <= 0 {39+ return nil, fmt.Errorf("invalid save dimensions: %dx%d", s.Width, s.Height)40+ }41+42+ clues, err := parseClues(s.Clues, s.Width, s.Height)43+ if err != nil {44+ return nil, fmt.Errorf("invalid clue data: %w", err)45+ }46+ if err := validateClues(clues, s.Width, s.Height); err != nil {47+ return nil, err48+ }49+50+ marks, err := parseGrid(s.Marks, s.Width, s.Height)51+ if err != nil {52+ return nil, fmt.Errorf("invalid mark data: %w", err)53+ }54+55+ for y := range s.Height {56+ for x := range s.Width {57+ if clues[y][x] > 0 {58+ marks[y][x] = islandCell59+ }60+ }61+ }62+63+ initial := newGrid(s.Width, s.Height, unknownCell)64+ for y := range s.Height {65+ for x := range s.Width {66+ if clues[y][x] > 0 {67+ initial[y][x] = islandCell68+ }69+ }70+ }71+72+ m := &Model{73+ width: s.Width,74+ height: s.Height,75+ clues: clues,76+ marks: marks,77+ initialMarks: initial,78+ cursor: game.Cursor{X: 0, Y: 0},79+ keys: DefaultKeyMap,80+ modeTitle: s.ModeTitle,81+ dragTarget: unknownCell,82+ }83+ m.recomputeDerivedState()84+ return m, nil85+}
A nurikabe/Gamemode.go +86 -0
@@ -0,0 +1,86 @@
1+package nurikabe2+3+import (4+ "context"5+ _ "embed"6+ "fmt"7+ "math/rand/v2"8+9+ "charm.land/bubbles/v2/list"10+ "github.com/FelineStateMachine/puzzletea/game"11+)12+13+//go:embed help.md14+var HelpContent string15+16+func init() {17+ game.Register("Nurikabe", func(data []byte) (game.Gamer, error) {18+ return ImportModel(data)19+ })20+}21+22+type NurikabeMode struct {23+ game.BaseMode24+ Width int25+ Height int26+ ClueDensity float6427+ MaxIslandSize int28+}29+30+var (31+ _ game.Mode = NurikabeMode{}32+ _ game.Spawner = NurikabeMode{}33+ _ game.SeededSpawner = NurikabeMode{}34+ _ game.CancellableSpawner = NurikabeMode{}35+ _ game.CancellableSeededSpawner = NurikabeMode{}36+)37+38+func NewMode(title, desc string, width, height int, clueDensity float64, maxIslandSize int) NurikabeMode {39+ return NurikabeMode{40+ BaseMode: game.NewBaseMode(title, desc),41+ Width: width,42+ Height: height,43+ ClueDensity: clueDensity,44+ MaxIslandSize: maxIslandSize,45+ }46+}47+48+func (n NurikabeMode) Spawn() (game.Gamer, error) {49+ return n.SpawnContext(context.Background())50+}51+52+func (n NurikabeMode) SpawnContext(ctx context.Context) (game.Gamer, error) {53+ p, err := GenerateWithContext(ctx, n)54+ if err != nil {55+ return nil, err56+ }57+ return New(n, p)58+}59+60+func (n NurikabeMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {61+ return n.SpawnSeededContext(context.Background(), rng)62+}63+64+func (n NurikabeMode) SpawnSeededContext(ctx context.Context, rng *rand.Rand) (game.Gamer, error) {65+ if rng == nil {66+ return nil, fmt.Errorf("nil RNG")67+ }68+ p, err := GenerateSeededWithContext(ctx, n, rng)69+ if err != nil {70+ return nil, err71+ }72+ return New(n, p)73+}74+75+var Modes = []list.Item{76+ NewMode("Mini", "5x5 grid, gentle introduction.", 5, 5, 0.28, 5),77+ NewMode("Easy", "7x7 grid, balanced logic.", 7, 7, 0.24, 7),78+ NewMode("Medium", "9x9 grid, moderate deduction.", 9, 9, 0.20, 9),79+ NewMode("Hard", "11x11 grid, lower clue density.", 11, 11, 0.16, 11),80+ NewMode("Expert", "12x12 grid, sparse clues and long chains.", 12, 12, 0.14, 12),81+}82+83+var DailyModes = []list.Item{84+ Modes[1], // Easy85+ Modes[2], // Medium86+}
A nurikabe/Model.go +255 -0
@@ -0,0 +1,255 @@
1+package nurikabe2+3+import (4+ "fmt"5+6+ "charm.land/bubbles/v2/key"7+ tea "charm.land/bubbletea/v2"8+ "charm.land/lipgloss/v2"9+ "github.com/FelineStateMachine/puzzletea/game"10+)11+12+type Model struct {13+ width, height int14+ clues clueGrid15+ marks grid16+ initialMarks grid17+ cursor game.Cursor18+ solved bool19+ conflicts [][]bool20+ keys KeyMap21+ modeTitle string22+ showFullHelp bool23+24+ dragging bool25+ dragTarget cellState26+27+ termWidth, termHeight int28+ originX, originY int29+ originValid bool30+31+ lastMouseX, lastMouseY int32+ lastMouseBtn string33+ lastMouseGridCol int34+ lastMouseGridRow int35+ lastMouseHit bool36+}37+38+var _ game.Gamer = Model{}39+40+func New(mode NurikabeMode, puzzle Puzzle) (game.Gamer, error) {41+ if puzzle.Width != mode.Width || puzzle.Height != mode.Height {42+ return Model{}, fmt.Errorf("puzzle size %dx%d does not match mode %dx%d", puzzle.Width, puzzle.Height, mode.Width, mode.Height)43+ }44+ if err := validateClues(puzzle.Clues, puzzle.Width, puzzle.Height); err != nil {45+ return Model{}, err46+ }47+48+ marks := newGrid(puzzle.Width, puzzle.Height, unknownCell)49+ for y := range puzzle.Height {50+ for x := range puzzle.Width {51+ if puzzle.Clues[y][x] > 0 {52+ marks[y][x] = islandCell53+ }54+ }55+ }56+57+ m := Model{58+ width: puzzle.Width,59+ height: puzzle.Height,60+ clues: cloneClues(puzzle.Clues),61+ marks: marks,62+ initialMarks: marks.clone(),63+ cursor: game.Cursor{X: 0, Y: 0},64+ keys: DefaultKeyMap,65+ modeTitle: mode.Title(),66+ dragTarget: unknownCell,67+ }68+ m.recomputeDerivedState()69+ return m, nil70+}71+72+func (m Model) Init() tea.Cmd {73+ return nil74+}75+76+func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {77+ switch msg := msg.(type) {78+ case game.HelpToggleMsg:79+ m.showFullHelp = msg.Show80+81+ case tea.WindowSizeMsg:82+ m.termWidth = msg.Width83+ m.termHeight = msg.Height84+ m.originValid = false85+86+ case tea.KeyPressMsg:87+ switch {88+ case key.Matches(msg, m.keys.SetSea):89+ if !m.solved {90+ m.setCellAtCursor(seaCell)91+ }92+ case key.Matches(msg, m.keys.SetIsland):93+ if !m.solved {94+ m.setCellAtCursor(islandCell)95+ }96+ case key.Matches(msg, m.keys.Clear):97+ if !m.solved {98+ m.setCellAtCursor(unknownCell)99+ }100+ default:101+ m.cursor.Move(m.keys.CursorKeyMap, msg, m.width-1, m.height-1)102+ }103+104+ case tea.MouseClickMsg:105+ m.lastMouseX, m.lastMouseY = msg.X, msg.Y106+ m.lastMouseBtn = msg.String()107+ col, row, ok := m.screenToGrid(msg.X, msg.Y)108+ m.lastMouseGridCol, m.lastMouseGridRow = col, row109+ m.lastMouseHit = ok110+ if m.solved || !ok {111+ break112+ }113+ m.cursor.X, m.cursor.Y = col, row114+ switch msg.Button {115+ case tea.MouseLeft:116+ target := m.leftClickTarget(m.marks[row][col])117+ m.dragging = true118+ m.dragTarget = target119+ m.setCellAtCursor(target)120+ case tea.MouseRight:121+ target := m.rightClickTarget()122+ m.dragging = true123+ m.dragTarget = target124+ m.setCellAtCursor(target)125+ }126+127+ case tea.MouseMotionMsg:128+ if m.solved || !m.dragging {129+ break130+ }131+ col, row, ok := m.screenToGrid(msg.X, msg.Y)132+ if !ok {133+ break134+ }135+ if col == m.cursor.X && row == m.cursor.Y {136+ break137+ }138+ m.cursor.X, m.cursor.Y = col, row139+ m.setCellAtCursor(m.dragTarget)140+141+ case tea.MouseReleaseMsg:142+ m.dragging = false143+ m.dragTarget = unknownCell144+ }145+146+ m.updateKeyBindings()147+ return m, nil148+}149+150+func (m *Model) setCellAtCursor(state cellState) {151+ x, y := m.cursor.X, m.cursor.Y152+ if isClueCell(m.clues, x, y) {153+ // Clue cells are immutable island cells.154+ m.marks[y][x] = islandCell155+ return156+ }157+ m.marks[y][x] = state158+ m.afterBoardChange()159+}160+161+func (m *Model) afterBoardChange() {162+ wasSolved := m.solved163+ m.recomputeDerivedState()164+ if wasSolved != m.solved {165+ m.originValid = false166+ }167+}168+169+func (m *Model) recomputeDerivedState() {170+ m.solved = isSolvedGrid(m.marks, m.clues)171+ m.conflicts = computeConflicts(m.marks, m.clues)172+}173+174+func (m Model) leftClickTarget(current cellState) cellState {175+ if current == seaCell {176+ return unknownCell177+ }178+ return seaCell179+}180+181+func (m Model) rightClickTarget() cellState {182+ return islandCell183+}184+185+func (m Model) View() string {186+ title := game.TitleBarView("Nurikabe", m.modeTitle, m.solved)187+ grid := gridView(m)188+ status := statusBarView(m.showFullHelp)189+ return lipgloss.JoinVertical(lipgloss.Center, title, grid, status)190+}191+192+func (m Model) SetTitle(t string) game.Gamer {193+ m.modeTitle = t194+ return m195+}196+197+func (m Model) IsSolved() bool {198+ return m.solved199+}200+201+func (m Model) Reset() game.Gamer {202+ m.marks = m.initialMarks.clone()203+ m.cursor = game.Cursor{}204+ m.dragging = false205+ m.dragTarget = unknownCell206+ m.recomputeDerivedState()207+ m.originValid = false208+ return m209+}210+211+func (m Model) GetDebugInfo() string {212+ status := "In Progress"213+ if m.solved {214+ status = "Solved"215+ }216+217+ unknownCount := 0218+ seaCount := 0219+ islandCount := 0220+ conflictCount := 0221+ for y := range m.height {222+ for x := range m.width {223+ switch m.marks[y][x] {224+ case unknownCell:225+ unknownCount++226+ case seaCell:227+ seaCount++228+ case islandCell:229+ islandCount++230+ }231+ if m.conflicts[y][x] {232+ conflictCount++233+ }234+ }235+ }236+237+ ox, oy := m.gridOrigin()238+ hit := "miss"239+ if m.lastMouseHit {240+ hit = fmt.Sprintf("(%d, %d)", m.lastMouseGridCol, m.lastMouseGridRow)241+ }242+243+ return game.DebugHeader("Nurikabe", [][2]string{244+ {"Status", status},245+ {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},246+ {"Grid Size", fmt.Sprintf("%d x %d", m.width, m.height)},247+ {"Clues", fmt.Sprintf("%d", countClues(m.clues))},248+ {"Cells", fmt.Sprintf("unknown=%d sea=%d island=%d", unknownCount, seaCount, islandCount)},249+ {"Conflicts", fmt.Sprintf("%d", conflictCount)},250+ {"Term Size", fmt.Sprintf("%d x %d", m.termWidth, m.termHeight)},251+ {"Grid Origin", fmt.Sprintf("(%d, %d)", ox, oy)},252+ {"Last Mouse", fmt.Sprintf("screen=(%d, %d) btn=%s grid=%s", m.lastMouseX, m.lastMouseY, m.lastMouseBtn, hit)},253+ {"Drag", fmt.Sprintf("active=%v target=%q", m.dragging, rune(m.dragTarget))},254+ })255+}
A nurikabe/README.md +53 -0
A nurikabe/generator.go +1070 -0
@@ -0,0 +1,1070 @@
1+package nurikabe2+3+import (4+ "context"5+ "errors"6+ "fmt"7+ "math"8+ "math/rand/v2"9+ "sort"10+ "sync"11+ "time"12+)13+14+const (15+ generationHardTimeout = 10 * time.Second16+ uniquenessMinRemainingBudget = 250 * time.Millisecond17+)18+19+type generationBudget struct {20+ attempts int21+ solvabilityNodes int22+ uniquenessNodes int23+ uniquenessProbeLimit int24+}25+26+type islandProfile struct {27+ baseSeaCoverage float6428+ seaJitterRatio float6429+ minSeaCoverage float6430+ maxSeaCoverage float6431+ minAverageSize float6432+ maxSingletonRatio float6433+ minLargestIsland int34+ maxLargestIsland int35+}36+37+type candidateState struct {38+ width, height int39+ sea [][]bool40+ labels [][]int41+ frontier []point42+ frontierPos [][]int43+44+ seaCount int45+ sea2x2Count int46+ nextLabel int47+ componentSize map[int]int48+ sizeHistogram map[int]int49+ singletons int50+ largestIsland int51+}52+53+type candidateStats struct {54+ clueCount int55+ totalLand int56+ singletonCount int57+ singletonRatio float6458+ largestIsland int59+ averageIsland float6460+ sizeEntropy float6461+ normalizedSpread float6462+ fragmentPenalty float6463+ clueDistance float6464+}65+66+type candidateResult struct {67+ attemptID int68+ puzzle Puzzle69+ stats candidateStats70+ score float6471+ clueKey string72+}73+74+type attemptSeed struct {75+ attemptID int76+ seedA uint6477+ seedB uint6478+}79+80+type attemptOutcome struct {81+ attemptID int82+ candidate *candidateResult83+ err error84+}85+86+type uniquenessOutcome struct {87+ attemptID int88+ count int89+ err error90+}91+92+func Generate(mode NurikabeMode) (Puzzle, error) {93+ rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))94+ return GenerateSeededWithContext(context.Background(), mode, rng)95+}96+97+func GenerateWithContext(ctx context.Context, mode NurikabeMode) (Puzzle, error) {98+ rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))99+ return GenerateSeededWithContext(ctx, mode, rng)100+}101+102+func GenerateSeeded(mode NurikabeMode, rng *rand.Rand) (Puzzle, error) {103+ return GenerateSeededWithContext(context.Background(), mode, rng)104+}105+106+func GenerateSeededWithContext(ctx context.Context, mode NurikabeMode, rng *rand.Rand) (Puzzle, error) {107+ if rng == nil {108+ return Puzzle{}, fmt.Errorf("nil RNG")109+ }110+ if mode.Width <= 0 || mode.Height <= 0 {111+ return Puzzle{}, fmt.Errorf("invalid mode size %dx%d", mode.Width, mode.Height)112+ }113+ if mode.MaxIslandSize <= 0 {114+ return Puzzle{}, fmt.Errorf("invalid max island size: %d", mode.MaxIslandSize)115+ }116+ if ctx == nil {117+ ctx = context.Background()118+ }119+120+ timeboxedCtx, cancel := context.WithTimeout(ctx, generationHardTimeout)121+ defer cancel()122+ deadline, _ := timeboxedCtx.Deadline()123+124+ area := mode.Width * mode.Height125+ clueTarget := int(math.Round(float64(area) * mode.ClueDensity))126+ if clueTarget < 2 {127+ clueTarget = 2128+ }129+ if clueTarget > area-2 {130+ clueTarget = area - 2131+ }132+133+ profile := modeIslandProfile(mode)134+ budget := modeGenerationBudget(mode)135+ workerCount := candidateWorkerCount(mode)136+137+ masterA := rng.Uint64()138+ masterB := rng.Uint64()139+140+ attemptCh := make(chan attemptSeed)141+ candidateCh := make(chan attemptOutcome, workerCount*2)142+ uniqueCh := make(chan candidateResult, budget.uniquenessProbeLimit)143+ uniqueResultCh := make(chan uniquenessOutcome, budget.uniquenessProbeLimit)144+145+ var candidateWG sync.WaitGroup146+ for range workerCount {147+ candidateWG.Add(1)148+ go func() {149+ defer candidateWG.Done()150+ candidateWorker(timeboxedCtx, mode, clueTarget, profile, attemptCh, candidateCh)151+ }()152+ }153+154+ var uniqueWG sync.WaitGroup155+ uniqueWG.Add(1)156+ go func() {157+ defer uniqueWG.Done()158+ uniquenessWorker(timeboxedCtx, budget, uniqueCh, uniqueResultCh)159+ }()160+ go func() {161+ uniqueWG.Wait()162+ close(uniqueResultCh)163+ }()164+165+ bestSolvable := candidateResult{}166+ haveBestSolvable := false167+ bestUnique := candidateResult{}168+ haveBestUnique := false169+170+ uniquenessQueued := 0171+ pendingUnique := map[int]candidateResult{}172+173+ drainUnique := func() {174+ for {175+ select {176+ case result, ok := <-uniqueResultCh:177+ if !ok {178+ return179+ }180+ candidate, ok := pendingUnique[result.attemptID]181+ delete(pendingUnique, result.attemptID)182+ if !ok {183+ continue184+ }185+ if result.err != nil {186+ if errors.Is(result.err, context.Canceled) ||187+ errors.Is(result.err, context.DeadlineExceeded) {188+ continue189+ }190+ continue191+ }192+ if result.count == 1 && (!haveBestUnique || betterCandidate(candidate, bestUnique)) {193+ bestUnique = candidate194+ haveBestUnique = true195+ }196+ default:197+ return198+ }199+ }200+ }201+202+ attemptID := 0203+outer:204+ for attemptID < budget.attempts {205+ if err := timeboxedCtx.Err(); err != nil {206+ break207+ }208+209+ roundSize := min(workerCount, budget.attempts-attemptID)210+ dispatched := 0211+ for i := 0; i < roundSize; i++ {212+ seedA, seedB := deriveAttemptSeeds(masterA, masterB, attemptID+i)213+ job := attemptSeed{attemptID: attemptID + i, seedA: seedA, seedB: seedB}214+ select {215+ case attemptCh <- job:216+ dispatched++217+ case <-timeboxedCtx.Done():218+ break outer219+ }220+ }221+222+ outcomes := make([]attemptOutcome, 0, dispatched)223+ for len(outcomes) < dispatched {224+ select {225+ case out := <-candidateCh:226+ outcomes = append(outcomes, out)227+ case result, ok := <-uniqueResultCh:228+ if !ok {229+ continue230+ }231+ candidate, ok := pendingUnique[result.attemptID]232+ delete(pendingUnique, result.attemptID)233+ if !ok {234+ continue235+ }236+ if result.err == nil && result.count == 1 && (!haveBestUnique || betterCandidate(candidate, bestUnique)) {237+ bestUnique = candidate238+ haveBestUnique = true239+ }240+ case <-timeboxedCtx.Done():241+ break outer242+ }243+ }244+245+ sort.Slice(outcomes, func(i, j int) bool {246+ return outcomes[i].attemptID < outcomes[j].attemptID247+ })248+249+ for _, out := range outcomes {250+ if out.candidate == nil {251+ continue252+ }253+ candidate := *out.candidate254+ if !haveBestSolvable || betterCandidate(candidate, bestSolvable) {255+ bestSolvable = candidate256+ haveBestSolvable = true257+ }258+259+ if shouldProbeUniqueness(candidate, haveBestSolvable, bestSolvable, haveBestUnique, bestUnique, uniquenessQueued, budget, deadline) {260+ pendingUnique[candidate.attemptID] = candidate261+ uniquenessQueued++262+ select {263+ case uniqueCh <- candidate:264+ case <-timeboxedCtx.Done():265+ break outer266+ }267+ }268+ }269+270+ drainUnique()271+ attemptID += dispatched272+ }273+274+ close(attemptCh)275+ candidateWG.Wait()276+ close(uniqueCh)277+ for result := range uniqueResultCh {278+ candidate, ok := pendingUnique[result.attemptID]279+ delete(pendingUnique, result.attemptID)280+ if !ok {281+ continue282+ }283+ if result.err == nil && result.count == 1 && (!haveBestUnique || betterCandidate(candidate, bestUnique)) {284+ bestUnique = candidate285+ haveBestUnique = true286+ }287+ }288+289+ if haveBestUnique {290+ return bestUnique.puzzle, nil291+ }292+ if haveBestSolvable {293+ return bestSolvable.puzzle, nil294+ }295+296+ if err := timeboxedCtx.Err(); err != nil {297+ if errors.Is(err, context.Canceled) {298+ return Puzzle{}, fmt.Errorf("nurikabe generation canceled: %w", err)299+ }300+ return Puzzle{}, fmt.Errorf("nurikabe generation timed out: %w", err)301+ }302+303+ return Puzzle{}, fmt.Errorf("unable to generate solvable nurikabe puzzle for mode %q within constraints", mode.Title())304+}305+306+func candidateWorker(307+ ctx context.Context,308+ mode NurikabeMode,309+ clueTarget int,310+ profile islandProfile,311+ attemptCh <-chan attemptSeed,312+ candidateCh chan<- attemptOutcome,313+) {314+ for {315+ select {316+ case <-ctx.Done():317+ return318+ case attempt, ok := <-attemptCh:319+ if !ok {320+ return321+ }322+323+ rng := rand.New(rand.NewPCG(attempt.seedA, attempt.seedB))324+ candidate, err := buildCandidateByCarving(mode, clueTarget, profile, rng, attempt.attemptID)325+ out := attemptOutcome{attemptID: attempt.attemptID, err: err}326+ if err == nil {327+ candidateCopy := candidate328+ out.candidate = &candidateCopy329+ }330+331+ select {332+ case candidateCh <- out:333+ case <-ctx.Done():334+ return335+ }336+ }337+ }338+}339+340+func uniquenessWorker(341+ ctx context.Context,342+ budget generationBudget,343+ uniqueCh <-chan candidateResult,344+ uniqueResultCh chan<- uniquenessOutcome,345+) {346+ for {347+ select {348+ case <-ctx.Done():349+ return350+ case candidate, ok := <-uniqueCh:351+ if !ok {352+ return353+ }354+355+ count, _, err := CountSolutionsContext(ctx, candidate.puzzle, 2, budget.uniquenessNodes)356+ result := uniquenessOutcome{attemptID: candidate.attemptID, count: count, err: err}357+ select {358+ case uniqueResultCh <- result:359+ case <-ctx.Done():360+ return361+ }362+ }363+ }364+}365+366+func buildCandidateByCarving(367+ mode NurikabeMode,368+ clueTarget int,369+ profile islandProfile,370+ rng *rand.Rand,371+ attemptID int,372+) (candidateResult, error) {373+ start := point{rng.IntN(mode.Width), rng.IntN(mode.Height)}374+ state := newCandidateState(mode.Width, mode.Height, start)375+376+ targetSea := chooseTargetSeaCells(mode, profile, rng)377+ for state.seaCount < targetSea {378+ candidate, ok := state.popFrontier(rng)379+ if !ok {380+ return candidateResult{}, fmt.Errorf("frontier exhausted")381+ }382+ if !state.canCarve(candidate) {383+ continue384+ }385+ if !state.carve(candidate) {386+ continue387+ }388+ }389+390+ components := state.components()391+ stats := state.stats(components, clueTarget)392+ if !stats.meetsProfile(mode, profile, clueTarget) {393+ return candidateResult{}, fmt.Errorf("distribution out of bounds")394+ }395+396+ clues := make(clueGrid, mode.Height)397+ for y := range mode.Height {398+ clues[y] = make([]int, mode.Width)399+ }400+ for _, component := range components {401+ if len(component) == 0 {402+ continue403+ }404+ clueCell := component[rng.IntN(len(component))]405+ clues[clueCell.y][clueCell.x] = len(component)406+ }407+408+ puzzle := Puzzle{Width: mode.Width, Height: mode.Height, Clues: clues}409+ if err := validateClues(puzzle.Clues, puzzle.Width, puzzle.Height); err != nil {410+ return candidateResult{}, err411+ }412+413+ completed := state.completedGrid(clues)414+ if !isSolvedGrid(completed, clues) {415+ return candidateResult{}, fmt.Errorf("constructed board failed solved validation")416+ }417+418+ stats.clueCount = len(components)419+ stats.clueDistance = clueDistance(stats.clueCount, clueTarget)420+421+ candidate := candidateResult{422+ attemptID: attemptID,423+ puzzle: puzzle,424+ stats: stats,425+ score: scoreCandidate(mode, stats),426+ clueKey: serializeClues(clues),427+ }428+429+ return candidate, nil430+}431+432+func newCandidateState(width, height int, start point) *candidateState {433+ sea := make([][]bool, height)434+ labels := make([][]int, height)435+ frontierPos := make([][]int, height)436+ for y := range height {437+ sea[y] = make([]bool, width)438+ labels[y] = make([]int, width)439+ frontierPos[y] = make([]int, width)440+ for x := range width {441+ labels[y][x] = 1442+ frontierPos[y][x] = -1443+ }444+ }445+446+ state := &candidateState{447+ width: width,448+ height: height,449+ sea: sea,450+ labels: labels,451+ frontier: make([]point, 0, width*height),452+ frontierPos: frontierPos,453+ componentSize: map[int]int{1: width * height},454+ sizeHistogram: map[int]int{width * height: 1},455+ singletons: 1,456+ largestIsland: width * height,457+ nextLabel: 2,458+ }459+ if width*height > 1 {460+ state.singletons = 0461+ }462+ state.carve(start)463+ return state464+}465+466+func (s *candidateState) inBounds(p point) bool {467+ return p.x >= 0 && p.x < s.width && p.y >= 0 && p.y < s.height468+}469+470+func (s *candidateState) canCarve(p point) bool {471+ if !s.inBounds(p) || s.sea[p.y][p.x] {472+ return false473+ }474+ if s.newSeaSquaresFromCarve(p) > 0 {475+ return false476+ }477+ return true478+}479+480+func (s *candidateState) carve(p point) bool {481+ if !s.canCarve(p) {482+ return false483+ }484+485+ oldLabel := s.labels[p.y][p.x]486+ if oldLabel < 0 {487+ return false488+ }489+490+ deltaSquares := s.newSeaSquaresFromCarve(p)491+ s.sea[p.y][p.x] = true492+ s.labels[p.y][p.x] = -1493+ s.seaCount++494+ s.sea2x2Count += deltaSquares495+ s.removeFrontier(p)496+497+ oldSize := s.componentSize[oldLabel]498+ s.removeComponent(oldLabel)499+500+ neighbors := s.landNeighborsWithLabel(p, oldLabel)501+ sort.Slice(neighbors, func(i, j int) bool {502+ if neighbors[i].y != neighbors[j].y {503+ return neighbors[i].y < neighbors[j].y504+ }505+ return neighbors[i].x < neighbors[j].x506+ })507+ neighbors = dedupePoints(neighbors)508+509+ switch len(neighbors) {510+ case 0:511+ // Entire component removed.512+ case 1:513+ s.setComponent(oldLabel, oldSize-1)514+ default:515+ s.relabelSplit(oldLabel, neighbors)516+ }517+518+ for _, d := range dirs {519+ n := point{x: p.x + d.x, y: p.y + d.y}520+ if !s.inBounds(n) || s.sea[n.y][n.x] {521+ continue522+ }523+ s.addFrontier(n)524+ }525+526+ return true527+}528+529+func dedupePoints(in []point) []point {530+ if len(in) < 2 {531+ return in532+ }533+ out := in[:1]534+ for i := 1; i < len(in); i++ {535+ if in[i] != in[i-1] {536+ out = append(out, in[i])537+ }538+ }539+ return out540+}541+542+func (s *candidateState) landNeighborsWithLabel(p point, label int) []point {543+ neighbors := make([]point, 0, 4)544+ for _, d := range dirs {545+ n := point{x: p.x + d.x, y: p.y + d.y}546+ if !s.inBounds(n) {547+ continue548+ }549+ if s.labels[n.y][n.x] == label {550+ neighbors = append(neighbors, n)551+ }552+ }553+ return neighbors554+}555+556+func (s *candidateState) relabelSplit(oldLabel int, seeds []point) {557+ visited := make([][]bool, s.height)558+ for y := range s.height {559+ visited[y] = make([]bool, s.width)560+ }561+562+ componentIndex := 0563+ for _, seed := range seeds {564+ if visited[seed.y][seed.x] || s.labels[seed.y][seed.x] != oldLabel {565+ continue566+ }567+568+ newLabel := oldLabel569+ if componentIndex > 0 {570+ newLabel = s.nextLabel571+ s.nextLabel++572+ }573+574+ queue := []point{seed}575+ visited[seed.y][seed.x] = true576+ cells := make([]point, 0, 16)577+ for len(queue) > 0 {578+ cell := queue[0]579+ queue = queue[1:]580+ cells = append(cells, cell)581+582+ for _, d := range dirs {583+ n := point{x: cell.x + d.x, y: cell.y + d.y}584+ if !s.inBounds(n) || visited[n.y][n.x] || s.labels[n.y][n.x] != oldLabel {585+ continue586+ }587+ visited[n.y][n.x] = true588+ queue = append(queue, n)589+ }590+ }591+592+ if newLabel != oldLabel {593+ for _, cell := range cells {594+ s.labels[cell.y][cell.x] = newLabel595+ }596+ }597+ s.setComponent(newLabel, len(cells))598+ componentIndex++599+ }600+}601+602+func (s *candidateState) newSeaSquaresFromCarve(p point) int {603+ count := 0604+ for dy := -1; dy <= 0; dy++ {605+ for dx := -1; dx <= 0; dx++ {606+ x0 := p.x + dx607+ y0 := p.y + dy608+ if x0 < 0 || y0 < 0 || x0+1 >= s.width || y0+1 >= s.height {609+ continue610+ }611+612+ allSea := true613+ for yy := y0; yy <= y0+1; yy++ {614+ for xx := x0; xx <= x0+1; xx++ {615+ if xx == p.x && yy == p.y {616+ continue617+ }618+ if !s.sea[yy][xx] {619+ allSea = false620+ break621+ }622+ }623+ if !allSea {624+ break625+ }626+ }627+ if allSea {628+ count++629+ }630+ }631+ }632+ return count633+}634+635+func (s *candidateState) addFrontier(p point) {636+ if !s.inBounds(p) || s.sea[p.y][p.x] || s.frontierPos[p.y][p.x] >= 0 {637+ return638+ }639+ idx := len(s.frontier)640+ s.frontier = append(s.frontier, p)641+ s.frontierPos[p.y][p.x] = idx642+}643+644+func (s *candidateState) removeFrontier(p point) {645+ if !s.inBounds(p) {646+ return647+ }648+ idx := s.frontierPos[p.y][p.x]649+ if idx < 0 || idx >= len(s.frontier) {650+ return651+ }652+653+ last := len(s.frontier) - 1654+ tail := s.frontier[last]655+ s.frontier[idx] = tail656+ s.frontierPos[tail.y][tail.x] = idx657+ s.frontier = s.frontier[:last]658+ s.frontierPos[p.y][p.x] = -1659+}660+661+func (s *candidateState) popFrontier(rng *rand.Rand) (point, bool) {662+ if len(s.frontier) == 0 {663+ return point{}, false664+ }665+ idx := rng.IntN(len(s.frontier))666+ picked := s.frontier[idx]667+ s.removeFrontier(picked)668+ return picked, true669+}670+671+func (s *candidateState) removeComponent(label int) {672+ size, ok := s.componentSize[label]673+ if !ok {674+ return675+ }676+ delete(s.componentSize, label)677+ s.sizeHistogram[size]--678+ if s.sizeHistogram[size] <= 0 {679+ delete(s.sizeHistogram, size)680+ }681+ if size == 1 {682+ s.singletons--683+ }684+ if size == s.largestIsland {685+ s.recomputeLargestIsland()686+ }687+}688+689+func (s *candidateState) setComponent(label, size int) {690+ if size <= 0 {691+ return692+ }693+ s.componentSize[label] = size694+ s.sizeHistogram[size]++695+ if size == 1 {696+ s.singletons++697+ }698+ if size > s.largestIsland {699+ s.largestIsland = size700+ }701+}702+703+func (s *candidateState) recomputeLargestIsland() {704+ largest := 0705+ for size := range s.sizeHistogram {706+ if size > largest {707+ largest = size708+ }709+ }710+ s.largestIsland = largest711+}712+713+func (s *candidateState) components() [][]point {714+ byLabel := map[int][]point{}715+ labels := make([]int, 0, len(s.componentSize))716+ for label := range s.componentSize {717+ labels = append(labels, label)718+ }719+ sort.Ints(labels)720+721+ for y := 0; y < s.height; y++ {722+ for x := 0; x < s.width; x++ {723+ label := s.labels[y][x]724+ if label < 0 {725+ continue726+ }727+ byLabel[label] = append(byLabel[label], point{x: x, y: y})728+ }729+ }730+731+ components := make([][]point, 0, len(labels))732+ for _, label := range labels {733+ component := byLabel[label]734+ if len(component) == 0 {735+ continue736+ }737+ components = append(components, component)738+ }739+ return components740+}741+742+func (s *candidateState) completedGrid(clues clueGrid) grid {743+ g := newGrid(s.width, s.height, islandCell)744+ for y := 0; y < s.height; y++ {745+ for x := 0; x < s.width; x++ {746+ if s.sea[y][x] {747+ g[y][x] = seaCell748+ continue749+ }750+ if clues[y][x] > 0 {751+ g[y][x] = islandCell752+ }753+ }754+ }755+ return g756+}757+758+func (s *candidateState) stats(components [][]point, clueTarget int) candidateStats {759+ stats := candidateStats{}760+ if len(components) == 0 {761+ return stats762+ }763+764+ sizes := make([]int, 0, len(components))765+ total := 0766+ for _, component := range components {767+ size := len(component)768+ sizes = append(sizes, size)769+ total += size770+ if size == 1 {771+ stats.singletonCount++772+ }773+ if size > stats.largestIsland {774+ stats.largestIsland = size775+ }776+ }777+778+ stats.clueCount = len(components)779+ stats.totalLand = total780+ stats.singletonRatio = float64(stats.singletonCount) / float64(max(1, len(components)))781+ stats.averageIsland = float64(total) / float64(len(components))782+ stats.sizeEntropy = componentSizeEntropy(sizes)783+ stats.normalizedSpread = normalizedSpread(sizes)784+ stats.fragmentPenalty = fragmentPenalty(len(components), clueTarget)785+ stats.clueDistance = clueDistance(len(components), clueTarget)786+787+ return stats788+}789+790+func componentSizeEntropy(sizes []int) float64 {791+ if len(sizes) == 0 {792+ return 0793+ }794+ freq := map[int]int{}795+ for _, size := range sizes {796+ freq[size]++797+ }798+ denom := float64(len(sizes))799+ entropy := 0.0800+ for _, count := range freq {801+ p := float64(count) / denom802+ entropy -= p * math.Log(p)803+ }804+ return entropy805+}806+807+func normalizedSpread(sizes []int) float64 {808+ if len(sizes) == 0 {809+ return 0810+ }811+ mean := 0.0812+ for _, size := range sizes {813+ mean += float64(size)814+ }815+ mean /= float64(len(sizes))816+ if mean == 0 {817+ return 0818+ }819+ variance := 0.0820+ for _, size := range sizes {821+ d := float64(size) - mean822+ variance += d * d823+ }824+ variance /= float64(len(sizes))825+ return math.Sqrt(variance) / mean826+}827+828+func fragmentPenalty(clueCount, clueTarget int) float64 {829+ if clueTarget <= 0 {830+ return 0831+ }832+ delta := math.Abs(float64(clueCount - clueTarget))833+ return delta / float64(clueTarget)834+}835+836+func clueDistance(clueCount, clueTarget int) float64 {837+ if clueTarget <= 0 {838+ return 0839+ }840+ return math.Abs(float64(clueCount-clueTarget)) / float64(clueTarget)841+}842+843+func (s candidateStats) meetsProfile(mode NurikabeMode, profile islandProfile, clueTarget int) bool {844+ if s.clueCount < 2 {845+ return false846+ }847+ if s.singletonRatio > profile.maxSingletonRatio {848+ return false849+ }850+ if s.averageIsland < profile.minAverageSize {851+ return false852+ }853+ if s.largestIsland < profile.minLargestIsland {854+ return false855+ }856+ maxLargest := mode.MaxIslandSize * 2857+ if profile.maxLargestIsland > 0 {858+ maxLargest = min(maxLargest, profile.maxLargestIsland)859+ }860+ if s.largestIsland > maxLargest {861+ return false862+ }863+864+ tol := max(2, clueTarget/2)865+ if s.clueCount < max(2, clueTarget-tol) || s.clueCount > clueTarget+tol {866+ return false867+ }868+869+ return true870+}871+872+func scoreCandidate(mode NurikabeMode, stats candidateStats) float64 {873+ score := 0.0874+ score += stats.sizeEntropy * 3.2875+ score += stats.normalizedSpread * 1.3876+ score -= stats.singletonRatio * 2.6877+ score -= stats.fragmentPenalty * 1.6878+ score -= stats.clueDistance * 0.9879+ score += min(1.5, float64(stats.largestIsland)/float64(max(1, mode.MaxIslandSize))) * 0.4880+ idealLargest := float64(max(mode.Width, mode.Height)) * 1.45881+ if stats.largestIsland > int(math.Round(idealLargest)) {882+ oversize := (float64(stats.largestIsland) - idealLargest) / max(1.0, idealLargest)883+ score -= oversize * 1.2884+ }885+ return score886+}887+888+func betterCandidate(a, b candidateResult) bool {889+ if d := a.score - b.score; math.Abs(d) > 1e-9 {890+ return d > 0891+ }892+ if a.stats.singletonCount != b.stats.singletonCount {893+ return a.stats.singletonCount < b.stats.singletonCount894+ }895+ if a.attemptID != b.attemptID {896+ return a.attemptID < b.attemptID897+ }898+ return a.clueKey < b.clueKey899+}900+901+func shouldProbeUniqueness(902+ candidate candidateResult,903+ haveBestSolvable bool,904+ bestSolvable candidateResult,905+ haveBestUnique bool,906+ bestUnique candidateResult,907+ queued int,908+ budget generationBudget,909+ deadline time.Time,910+) bool {911+ if queued >= budget.uniquenessProbeLimit {912+ return false913+ }914+ if time.Until(deadline) < uniquenessMinRemainingBudget {915+ return false916+ }917+ if queued < 3 {918+ return true919+ }920+ if haveBestUnique {921+ return candidate.score > bestUnique.score-0.45922+ }923+ if !haveBestSolvable {924+ return true925+ }926+ return candidate.score >= bestSolvable.score-0.8927+}928+929+func chooseTargetSeaCells(mode NurikabeMode, profile islandProfile, rng *rand.Rand) int {930+ area := mode.Width * mode.Height931+ targetSea := int(math.Round(float64(area) * profile.baseSeaCoverage))932+ jitter := int(math.Round(float64(area) * profile.seaJitterRatio))933+ if jitter > 0 {934+ targetSea += rng.IntN(2*jitter+1) - jitter935+ }936+937+ minSea := max(1, int(math.Round(float64(area)*profile.minSeaCoverage)))938+ maxSea := min(area-1, int(math.Round(float64(area)*profile.maxSeaCoverage)))939+ if targetSea < minSea {940+ targetSea = minSea941+ }942+ if targetSea > maxSea {943+ targetSea = maxSea944+ }945+946+ return targetSea947+}948+949+func deriveAttemptSeeds(masterA, masterB uint64, attemptID int) (uint64, uint64) {950+ id := uint64(attemptID + 1)951+ seedA := splitmix64(masterA ^ (id * 0x9e3779b97f4a7c15))952+ seedB := splitmix64(masterB ^ (id * 0xbf58476d1ce4e5b9))953+ return seedA, seedB954+}955+956+func splitmix64(x uint64) uint64 {957+ x += 0x9e3779b97f4a7c15958+ x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9959+ x = (x ^ (x >> 27)) * 0x94d049bb133111eb960+ return x ^ (x >> 31)961+}962+963+func candidateWorkerCount(mode NurikabeMode) int {964+ switch mode.Title() {965+ case "Mini":966+ return 1967+ case "Easy":968+ return 2969+ case "Medium":970+ return 2971+ case "Hard":972+ return 3973+ case "Expert":974+ return 3975+ default:976+ return 2977+ }978+}979+980+func modeIslandProfile(mode NurikabeMode) islandProfile {981+ switch mode.Title() {982+ case "Mini":983+ return islandProfile{984+ baseSeaCoverage: 0.51,985+ seaJitterRatio: 0.05,986+ minSeaCoverage: 0.40,987+ maxSeaCoverage: 0.62,988+ minAverageSize: 1.85,989+ maxSingletonRatio: 0.63,990+ minLargestIsland: 3,991+ maxLargestIsland: 0,992+ }993+ case "Easy":994+ return islandProfile{995+ baseSeaCoverage: 0.55,996+ seaJitterRatio: 0.05,997+ minSeaCoverage: 0.42,998+ maxSeaCoverage: 0.64,999+ minAverageSize: 1.95,1000+ maxSingletonRatio: 0.56,1001+ minLargestIsland: 4,1002+ maxLargestIsland: 0,1003+ }1004+ case "Medium":1005+ return islandProfile{1006+ baseSeaCoverage: 0.58,1007+ seaJitterRatio: 0.05,1008+ minSeaCoverage: 0.44,1009+ maxSeaCoverage: 0.65,1010+ minAverageSize: 1.95,1011+ maxSingletonRatio: 0.58,1012+ minLargestIsland: 5,1013+ maxLargestIsland: 0,1014+ }1015+ case "Hard":1016+ return islandProfile{1017+ baseSeaCoverage: 0.60,1018+ seaJitterRatio: 0.04,1019+ minSeaCoverage: 0.44,1020+ maxSeaCoverage: 0.67,1021+ minAverageSize: 1.8,1022+ maxSingletonRatio: 0.72,1023+ minLargestIsland: 4,1024+ maxLargestIsland: 20,1025+ }1026+ case "Expert":1027+ return islandProfile{1028+ baseSeaCoverage: 0.62,1029+ seaJitterRatio: 0.04,1030+ minSeaCoverage: 0.44,1031+ maxSeaCoverage: 0.68,1032+ minAverageSize: 1.7,1033+ maxSingletonRatio: 0.78,1034+ minLargestIsland: 4,1035+ maxLargestIsland: 22,1036+ }1037+ default:1038+ return islandProfile{1039+ baseSeaCoverage: 0.55,1040+ seaJitterRatio: 0.05,1041+ minSeaCoverage: 0.42,1042+ maxSeaCoverage: 0.65,1043+ minAverageSize: 2.1,1044+ maxSingletonRatio: 0.48,1045+ minLargestIsland: 4,1046+ maxLargestIsland: 0,1047+ }1048+ }1049+}1050+1051+func modeGenerationBudget(mode NurikabeMode) generationBudget {1052+ switch mode.Title() {1053+ case "Mini":1054+ return generationBudget{attempts: 320, solvabilityNodes: 25000, uniquenessNodes: 60000, uniquenessProbeLimit: 6}1055+ case "Easy":1056+ return generationBudget{attempts: 400, solvabilityNodes: 45000, uniquenessNodes: 100000, uniquenessProbeLimit: 8}1057+ case "Medium":1058+ return generationBudget{attempts: 500, solvabilityNodes: 70000, uniquenessNodes: 140000, uniquenessProbeLimit: 10}1059+ case "Hard":1060+ return generationBudget{attempts: 650, solvabilityNodes: 95000, uniquenessNodes: 180000, uniquenessProbeLimit: 12}1061+ case "Expert":1062+ return generationBudget{attempts: 800, solvabilityNodes: 120000, uniquenessNodes: 220000, uniquenessProbeLimit: 14}1063+ default:1064+ return generationBudget{attempts: 400, solvabilityNodes: 45000, uniquenessNodes: 100000, uniquenessProbeLimit: 8}1065+ }1066+}1067+1068+func generationNodeLimit(mode NurikabeMode) int {1069+ return modeGenerationBudget(mode).solvabilityNodes1070+}
A nurikabe/grid.go +179 -0
@@ -0,0 +1,179 @@
1+package nurikabe2+3+import (4+ "fmt"5+ "strconv"6+ "strings"7+)8+9+type cellState rune10+11+const (12+ unknownCell cellState = '?'13+ seaCell cellState = '~'14+ islandCell cellState = 'o'15+ legacyRequiredLand cellState = '!'16+)17+18+type (19+ grid [][]cellState20+ clueGrid [][]int21+)22+23+type Puzzle struct {24+ Width, Height int25+ Clues clueGrid26+}27+28+func newGrid(width, height int, fill cellState) grid {29+ g := make(grid, height)30+ for y := range height {31+ g[y] = make([]cellState, width)32+ for x := range width {33+ g[y][x] = fill34+ }35+ }36+ return g37+}38+39+func (g grid) clone() grid {40+ c := make(grid, len(g))41+ for y, row := range g {42+ c[y] = make([]cellState, len(row))43+ copy(c[y], row)44+ }45+ return c46+}47+48+func (g grid) String() string {49+ rows := make([]string, len(g))50+ for y, row := range g {51+ var b strings.Builder52+ b.Grow(len(row))53+ for _, c := range row {54+ b.WriteRune(rune(c))55+ }56+ rows[y] = b.String()57+ }58+ return strings.Join(rows, "\n")59+}60+61+func parseGrid(s string, width, height int) (grid, error) {62+ rows := strings.Split(s, "\n")63+ if len(rows) != height {64+ return nil, fmt.Errorf("grid row count mismatch: got %d, want %d", len(rows), height)65+ }66+67+ g := make(grid, height)68+ for y := range height {69+ runes := []rune(rows[y])70+ if len(runes) != width {71+ return nil, fmt.Errorf("grid width mismatch at row %d: got %d, want %d", y, len(runes), width)72+ }73+ g[y] = make([]cellState, width)74+ for x, r := range runes {75+ switch cellState(r) {76+ case unknownCell, seaCell, islandCell:77+ g[y][x] = cellState(r)78+ case legacyRequiredLand:79+ // Backward compatibility: old required marks now map to island.80+ g[y][x] = islandCell81+ default:82+ return nil, fmt.Errorf("invalid grid symbol %q at (%d,%d)", r, x, y)83+ }84+ }85+ }86+87+ return g, nil88+}89+90+func serializeClues(clues clueGrid) string {91+ rows := make([]string, len(clues))92+ for y, row := range clues {93+ parts := make([]string, len(row))94+ for x, v := range row {95+ if v < 0 {96+ v = 097+ }98+ parts[x] = strconv.Itoa(v)99+ }100+ rows[y] = strings.Join(parts, ",")101+ }102+ return strings.Join(rows, "\n")103+}104+105+func parseClues(s string, width, height int) (clueGrid, error) {106+ rows := strings.Split(strings.TrimSpace(s), "\n")107+ if height == 0 || width == 0 {108+ return nil, fmt.Errorf("invalid clue dimensions: %dx%d", width, height)109+ }110+ if len(rows) != height {111+ return nil, fmt.Errorf("clue row count mismatch: got %d, want %d", len(rows), height)112+ }113+114+ clues := make(clueGrid, height)115+ for y := range height {116+ parts := strings.Split(rows[y], ",")117+ if len(parts) != width {118+ return nil, fmt.Errorf("clue width mismatch at row %d: got %d, want %d", y, len(parts), width)119+ }120+ clues[y] = make([]int, width)121+ for x, p := range parts {122+ v, err := strconv.Atoi(strings.TrimSpace(p))123+ if err != nil {124+ return nil, fmt.Errorf("invalid clue value %q at (%d,%d): %w", p, x, y, err)125+ }126+ if v < 0 {127+ return nil, fmt.Errorf("negative clue value %d at (%d,%d)", v, x, y)128+ }129+ clues[y][x] = v130+ }131+ }132+133+ return clues, nil134+}135+136+func cloneClues(c clueGrid) clueGrid {137+ out := make(clueGrid, len(c))138+ for y, row := range c {139+ out[y] = make([]int, len(row))140+ copy(out[y], row)141+ }142+ return out143+}144+145+func validateClues(clues clueGrid, width, height int) error {146+ if width <= 0 || height <= 0 {147+ return fmt.Errorf("invalid dimensions: %dx%d", width, height)148+ }149+ if len(clues) != height {150+ return fmt.Errorf("clue row count mismatch: got %d, want %d", len(clues), height)151+ }152+ for y, row := range clues {153+ if len(row) != width {154+ return fmt.Errorf("clue width mismatch at row %d: got %d, want %d", y, len(row), width)155+ }156+ for x, v := range row {157+ if v < 0 {158+ return fmt.Errorf("negative clue at (%d,%d)", x, y)159+ }160+ }161+ }162+ return nil163+}164+165+func isClueCell(clues clueGrid, x, y int) bool {166+ return y >= 0 && y < len(clues) && x >= 0 && x < len(clues[y]) && clues[y][x] > 0167+}168+169+func countClues(clues clueGrid) int {170+ total := 0171+ for _, row := range clues {172+ for _, v := range row {173+ if v > 0 {174+ total++175+ }176+ }177+ }178+ return total179+}
A nurikabe/help.md +38 -0
A nurikabe/keys.go +48 -0
A nurikabe/mouse.go +63 -0
@@ -0,0 +1,63 @@
1+package nurikabe2+3+import (4+ "strings"5+6+ "charm.land/lipgloss/v2"7+ "github.com/FelineStateMachine/puzzletea/game"8+)9+10+// screenToGrid converts terminal screen coordinates to grid cell coordinates.11+// Returns (col, row, ok) where ok is false if click landed outside a cell.12+func (m *Model) screenToGrid(screenX, screenY int) (col, row int, ok bool) {13+ ox, oy := m.cachedGridOrigin()14+ lx := screenX - ox15+ ly := screenY - oy16+ if lx < 0 || ly < 0 {17+ return 0, 0, false18+ }19+20+ col = lx / cellWidth21+ row = ly22+23+ if col < 0 || col >= m.width || row < 0 || row >= m.height {24+ return 0, 0, false25+ }26+ return col, row, true27+}28+29+func (m *Model) cachedGridOrigin() (x, y int) {30+ if m.originValid {31+ return m.originX, m.originY32+ }33+ x, y = m.gridOrigin()34+ m.originX, m.originY = x, y35+ m.originValid = true36+ return x, y37+}38+39+func (m *Model) gridOrigin() (x, y int) {40+ gameView := m.View()41+ viewWidth := lipgloss.Width(gameView)42+ viewHeight := lipgloss.Height(gameView)43+44+ if viewWidth > m.termWidth {45+ viewWidth = m.termWidth46+ }47+48+ centerX := max((m.termWidth-viewWidth)/2, 0)49+ centerY := max((m.termHeight-viewHeight)/2, 0)50+51+ title := game.TitleBarView("Nurikabe", m.modeTitle, m.solved)52+ titleHeight := strings.Count(title, "\n") + 153+54+ gridOuterWidth := lipgloss.Width(gridView(*m))55+ gridPadLeft := max((viewWidth-gridOuterWidth)/2, 0)56+57+ const borderLeft = 158+ const borderTop = 159+60+ x = centerX + gridPadLeft + borderLeft61+ y = centerY + titleHeight + borderTop62+ return x, y63+}
A nurikabe/nurikabe_test.go +673 -0
@@ -0,0 +1,673 @@
1+package nurikabe2+3+import (4+ "context"5+ "errors"6+ "math/rand/v2"7+ "runtime"8+ "testing"9+ "time"10+11+ tea "charm.land/bubbletea/v2"12+ "github.com/FelineStateMachine/puzzletea/game"13+)14+15+func makeClues(rows ...[]int) clueGrid {16+ out := make(clueGrid, len(rows))17+ for y, row := range rows {18+ out[y] = make([]int, len(row))19+ copy(out[y], row)20+ }21+ return out22+}23+24+func makeMarksFromClues(clues clueGrid) grid {25+ h := len(clues)26+ w := len(clues[0])27+ g := newGrid(w, h, seaCell)28+ for y := range h {29+ for x := range w {30+ if clues[y][x] > 0 {31+ g[y][x] = islandCell32+ }33+ }34+ }35+ return g36+}37+38+func uniquePuzzleFixture() Puzzle {39+ clues := makeClues(40+ []int{1, 0, 1, 0, 1},41+ []int{0, 0, 0, 0, 0},42+ []int{0, 1, 0, 1, 0},43+ []int{0, 0, 0, 0, 0},44+ []int{1, 0, 1, 0, 1},45+ )46+ return Puzzle{Width: 5, Height: 5, Clues: clues}47+}48+49+// --- Grid and serialization (P0) ---50+51+func TestGridStringAndParse(t *testing.T) {52+ g := grid{53+ []cellState{unknownCell, seaCell, islandCell},54+ []cellState{seaCell, islandCell, unknownCell},55+ }56+ data := g.String()57+ round, err := parseGrid(data, 3, 2)58+ if err != nil {59+ t.Fatalf("parseGrid error: %v", err)60+ }61+ if round.String() != data {62+ t.Errorf("round-trip mismatch: got %q want %q", round.String(), data)63+ }64+}65+66+func TestParseGridLegacyRequiredLand(t *testing.T) {67+ g, err := parseGrid("?!\n~o", 2, 2)68+ if err != nil {69+ t.Fatalf("parseGrid error: %v", err)70+ }71+ if g[0][1] != islandCell {72+ t.Fatalf("legacy required-land marker should load as island, got %q", rune(g[0][1]))73+ }74+}75+76+func TestGridClone(t *testing.T) {77+ g := newGrid(2, 2, unknownCell)78+ g[0][0] = seaCell79+ c := g.clone()80+ c[0][0] = islandCell81+ if g[0][0] == islandCell {82+ t.Fatal("clone shares memory with original")83+ }84+}85+86+func TestClueSerializationRoundTrip(t *testing.T) {87+ clues := makeClues(88+ []int{0, 12, 0},89+ []int{3, 0, 1},90+ )91+ enc := serializeClues(clues)92+ dec, err := parseClues(enc, 3, 2)93+ if err != nil {94+ t.Fatalf("parseClues error: %v", err)95+ }96+ if serializeClues(dec) != enc {97+ t.Errorf("clue round-trip mismatch: got %q want %q", serializeClues(dec), enc)98+ }99+}100+101+// --- Rule checks (P0) ---102+103+func TestHasSeaSquare(t *testing.T) {104+ g := grid{105+ []cellState{seaCell, seaCell, islandCell},106+ []cellState{seaCell, seaCell, islandCell},107+ []cellState{islandCell, islandCell, islandCell},108+ }109+ if !hasSeaSquare(g) {110+ t.Fatal("expected 2x2 sea block")111+ }112+}113+114+func TestSeaSquareCellDetection(t *testing.T) {115+ g := grid{116+ []cellState{seaCell, seaCell, islandCell},117+ []cellState{seaCell, seaCell, islandCell},118+ []cellState{islandCell, islandCell, islandCell},119+ }120+121+ for _, p := range []point{{0, 0}, {1, 0}, {0, 1}, {1, 1}} {122+ if !isSeaSquareCell(g, p.x, p.y) {123+ t.Fatalf("expected (%d,%d) to be part of a 2x2 sea block", p.x, p.y)124+ }125+ }126+127+ if isSeaSquareCell(g, 2, 2) {128+ t.Fatal("non-sea cell should not be part of a 2x2 sea block")129+ }130+131+ lShape := grid{132+ []cellState{seaCell, seaCell, unknownCell},133+ []cellState{seaCell, islandCell, unknownCell},134+ []cellState{unknownCell, unknownCell, unknownCell},135+ }136+ if isSeaSquareCell(lShape, 0, 0) {137+ t.Fatal("L-shape sea should not be flagged as 2x2")138+ }139+}140+141+func TestSeaConnectivity(t *testing.T) {142+ connected := grid{143+ []cellState{seaCell, seaCell, islandCell},144+ []cellState{islandCell, seaCell, islandCell},145+ []cellState{islandCell, seaCell, seaCell},146+ }147+ if !isSeaConnected(connected) {148+ t.Fatal("expected connected sea")149+ }150+151+ disconnected := grid{152+ []cellState{seaCell, islandCell, seaCell},153+ []cellState{islandCell, islandCell, islandCell},154+ []cellState{seaCell, islandCell, seaCell},155+ }156+ if isSeaConnected(disconnected) {157+ t.Fatal("expected disconnected sea")158+ }159+}160+161+func TestIsSolvedGrid(t *testing.T) {162+ p := uniquePuzzleFixture()163+ marks := makeMarksFromClues(p.Clues)164+ if !isSolvedGrid(marks, p.Clues) {165+ t.Fatal("expected solved fixture")166+ }167+}168+169+// --- Solver counts (P0) ---170+171+func TestCountSolutions(t *testing.T) {172+ tests := []struct {173+ name string174+ p Puzzle175+ want int176+ }{177+ {178+ name: "zero solutions",179+ p: Puzzle{Width: 1, Height: 1, Clues: makeClues(180+ []int{1},181+ )},182+ want: 0,183+ },184+ {185+ name: "one solution",186+ p: Puzzle{Width: 2, Height: 2, Clues: makeClues(187+ []int{1, 0},188+ []int{0, 0},189+ )},190+ want: 1,191+ },192+ {193+ name: "two solutions",194+ p: Puzzle{Width: 2, Height: 2, Clues: makeClues(195+ []int{2, 0},196+ []int{0, 0},197+ )},198+ want: 2,199+ },200+ }201+202+ for _, tt := range tests {203+ t.Run(tt.name, func(t *testing.T) {204+ got, _, err := CountSolutions(tt.p, 3, 50000)205+ if err != nil {206+ t.Fatalf("CountSolutions error: %v", err)207+ }208+ if got != tt.want {209+ t.Fatalf("CountSolutions = %d, want %d", got, tt.want)210+ }211+ })212+ }213+}214+215+// --- Save/load round-trip (P1) ---216+217+func TestSaveLoadRoundTrip(t *testing.T) {218+ mode := NewMode("Mini", "test", 5, 5, 0.25, 5)219+ p := uniquePuzzleFixture()220+ g, err := New(mode, p)221+ if err != nil {222+ t.Fatalf("New error: %v", err)223+ }224+ m := g.(Model)225+226+ m.cursor = pointToCursor(1, 1)227+ m.setCellAtCursor(islandCell)228+ m.cursor = pointToCursor(3, 3)229+ m.setCellAtCursor(seaCell)230+231+ data, err := m.GetSave()232+ if err != nil {233+ t.Fatalf("GetSave error: %v", err)234+ }235+ loaded, err := ImportModel(data)236+ if err != nil {237+ t.Fatalf("ImportModel error: %v", err)238+ }239+240+ if loaded.width != m.width || loaded.height != m.height {241+ t.Fatalf("size mismatch: got %dx%d want %dx%d", loaded.width, loaded.height, m.width, m.height)242+ }243+ if serializeClues(loaded.clues) != serializeClues(m.clues) {244+ t.Fatal("clues mismatch after round-trip")245+ }246+ if loaded.marks.String() != m.marks.String() {247+ t.Fatal("marks mismatch after round-trip")248+ }249+}250+251+func pointToCursor(x, y int) game.Cursor {252+ return game.Cursor{X: x, Y: y}253+}254+255+// --- Model input behavior (P1) ---256+257+func TestModelKeyboardUpdate(t *testing.T) {258+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)259+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(260+ []int{2, 0, 0},261+ []int{0, 0, 0},262+ []int{0, 0, 1},263+ )}264+ g, err := New(mode, p)265+ if err != nil {266+ t.Fatalf("New error: %v", err)267+ }268+ m := g.(Model)269+ m.cursor = pointToCursor(1, 1)270+271+ next, _ := m.Update(tea.KeyPressMsg{Code: 'x', Text: "x"})272+ m = next.(Model)273+ if m.marks[1][1] != seaCell {274+ t.Fatalf("expected sea at (1,1), got %q", rune(m.marks[1][1]))275+ }276+277+ next, _ = m.Update(tea.KeyPressMsg{Code: 'z', Text: "z"})278+ m = next.(Model)279+ if m.marks[1][1] != islandCell {280+ t.Fatalf("expected island at (1,1), got %q", rune(m.marks[1][1]))281+ }282+}283+284+func TestModelMouseDragPaint(t *testing.T) {285+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)286+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(287+ []int{2, 0, 0},288+ []int{0, 0, 0},289+ []int{0, 0, 1},290+ )}291+ g, err := New(mode, p)292+ if err != nil {293+ t.Fatalf("New error: %v", err)294+ }295+ m := g.(Model)296+297+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})298+ m = next.(Model)299+ ox, oy := m.gridOrigin()300+301+ click := tea.MouseClickMsg{X: ox + cellWidth, Y: oy + 1, Button: tea.MouseLeft}302+ next, _ = m.Update(click)303+ m = next.(Model)304+305+ drag := tea.MouseMotionMsg{X: ox + 2*cellWidth, Y: oy + 1, Button: tea.MouseLeft}306+ next, _ = m.Update(drag)307+ m = next.(Model)308+309+ next, _ = m.Update(tea.MouseReleaseMsg{})310+ m = next.(Model)311+312+ if m.marks[1][1] != seaCell || m.marks[1][2] != seaCell {313+ t.Fatalf("drag paint failed: row= %q", string([]rune{rune(m.marks[1][0]), rune(m.marks[1][1]), rune(m.marks[1][2])}))314+ }315+}316+317+func TestModelMouseLeftClickToggleBehavior(t *testing.T) {318+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)319+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(320+ []int{2, 0, 0},321+ []int{0, 0, 0},322+ []int{0, 0, 1},323+ )}324+ g, err := New(mode, p)325+ if err != nil {326+ t.Fatalf("New error: %v", err)327+ }328+ m := g.(Model)329+330+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})331+ m = next.(Model)332+ ox, oy := m.gridOrigin()333+334+ cellX := ox + cellWidth335+ cellY := oy + 1336+337+ next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseLeft})338+ m = next.(Model)339+ if m.marks[1][1] != seaCell {340+ t.Fatalf("left click from unknown should set sea, got %q", rune(m.marks[1][1]))341+ }342+343+ next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseLeft})344+ m = next.(Model)345+ if m.marks[1][1] != unknownCell {346+ t.Fatalf("left click from sea should set unknown, got %q", rune(m.marks[1][1]))347+ }348+349+ next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseRight})350+ m = next.(Model)351+ if m.marks[1][1] != islandCell {352+ t.Fatalf("right click should set island, got %q", rune(m.marks[1][1]))353+ }354+355+ next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseLeft})356+ m = next.(Model)357+ if m.marks[1][1] != seaCell {358+ t.Fatalf("left click from island should set sea, got %q", rune(m.marks[1][1]))359+ }360+}361+362+func TestModelMouseRightClickSetsIsland(t *testing.T) {363+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)364+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(365+ []int{2, 0, 0},366+ []int{0, 0, 0},367+ []int{0, 0, 1},368+ )}369+ g, err := New(mode, p)370+ if err != nil {371+ t.Fatalf("New error: %v", err)372+ }373+ m := g.(Model)374+375+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})376+ m = next.(Model)377+ ox, oy := m.gridOrigin()378+379+ cellX := ox + cellWidth380+ cellY := oy + 1381+382+ next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseRight})383+ m = next.(Model)384+ if m.marks[1][1] != islandCell {385+ t.Fatalf("expected island after first right click, got %q", rune(m.marks[1][1]))386+ }387+388+ next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseRight})389+ m = next.(Model)390+ if m.marks[1][1] != islandCell {391+ t.Fatalf("expected island after second right click, got %q", rune(m.marks[1][1]))392+ }393+}394+395+func TestModelMouseRightDragPaintIsland(t *testing.T) {396+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)397+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(398+ []int{2, 0, 0},399+ []int{0, 0, 0},400+ []int{0, 0, 1},401+ )}402+ g, err := New(mode, p)403+ if err != nil {404+ t.Fatalf("New error: %v", err)405+ }406+ m := g.(Model)407+408+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})409+ m = next.(Model)410+ ox, oy := m.gridOrigin()411+412+ next, _ = m.Update(tea.MouseClickMsg{X: ox + cellWidth, Y: oy + 1, Button: tea.MouseRight})413+ m = next.(Model)414+415+ next, _ = m.Update(tea.MouseMotionMsg{X: ox + 2*cellWidth, Y: oy + 1, Button: tea.MouseRight})416+ m = next.(Model)417+418+ next, _ = m.Update(tea.MouseReleaseMsg{})419+ m = next.(Model)420+421+ if m.marks[1][1] != islandCell || m.marks[1][2] != islandCell {422+ t.Fatalf("right drag should paint island: row= %q", string([]rune{rune(m.marks[1][0]), rune(m.marks[1][1]), rune(m.marks[1][2])}))423+ }424+}425+426+func TestModelMouseLeftDragPaintUnknownFromSea(t *testing.T) {427+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)428+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(429+ []int{2, 0, 0},430+ []int{0, 0, 0},431+ []int{0, 0, 1},432+ )}433+ g, err := New(mode, p)434+ if err != nil {435+ t.Fatalf("New error: %v", err)436+ }437+ m := g.(Model)438+ m.cursor = pointToCursor(1, 1)439+ m.setCellAtCursor(seaCell)440+441+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})442+ m = next.(Model)443+ ox, oy := m.gridOrigin()444+445+ next, _ = m.Update(tea.MouseClickMsg{X: ox + cellWidth, Y: oy + 1, Button: tea.MouseLeft})446+ m = next.(Model)447+448+ next, _ = m.Update(tea.MouseMotionMsg{X: ox + 2*cellWidth, Y: oy + 1, Button: tea.MouseLeft})449+ m = next.(Model)450+451+ next, _ = m.Update(tea.MouseReleaseMsg{})452+ m = next.(Model)453+454+ if m.marks[1][1] != unknownCell || m.marks[1][2] != unknownCell {455+ t.Fatalf("left drag from sea should paint unknown: row= %q", string([]rune{rune(m.marks[1][0]), rune(m.marks[1][1]), rune(m.marks[1][2])}))456+ }457+}458+459+// --- Seeded generation determinism (P1) ---460+461+func TestGenerateSeededDeterministicConcurrent(t *testing.T) {462+ mode := Modes[0].(NurikabeMode)463+ rng1 := rand.New(rand.NewPCG(11, 29))464+ rng2 := rand.New(rand.NewPCG(11, 29))465+ rng3 := rand.New(rand.NewPCG(11, 29))466+467+ p1, err := GenerateSeeded(mode, rng1)468+ if err != nil {469+ t.Fatalf("GenerateSeeded #1 error: %v", err)470+ }471+ p2, err := GenerateSeeded(mode, rng2)472+ if err != nil {473+ t.Fatalf("GenerateSeeded #2 error: %v", err)474+ }475+ p3, err := GenerateSeeded(mode, rng3)476+ if err != nil {477+ t.Fatalf("GenerateSeeded #3 error: %v", err)478+ }479+480+ if serializeClues(p1.Clues) != serializeClues(p2.Clues) {481+ t.Fatal("seeded generation is not deterministic")482+ }483+ if serializeClues(p1.Clues) != serializeClues(p3.Clues) {484+ t.Fatal("seeded generation diverged across repeated runs")485+ }486+}487+488+// --- Generator runtime and validity (P2) ---489+490+func TestGenerateReturnsWithin10SecondsAllModes(t *testing.T) {491+ if testing.Short() {492+ t.Skip("skipping slow generator budget test in short mode")493+ }494+495+ for i, item := range Modes {496+ mode := item.(NurikabeMode)497+ t.Run(mode.Title(), func(t *testing.T) {498+ start := time.Now()499+ rng := rand.New(rand.NewPCG(uint64(100+i), uint64(999+i)))500+ _, err := GenerateSeeded(mode, rng)501+ if err != nil {502+ t.Fatalf("GenerateSeeded error: %v", err)503+ }504+ if elapsed := time.Since(start); elapsed > generationHardTimeout+500*time.Millisecond {505+ t.Fatalf("generation exceeded budget for %s: %s", mode.Title(), elapsed)506+ }507+ })508+ }509+}510+511+func TestConstructedCandidateValidCompletedBoard(t *testing.T) {512+ mode := Modes[2].(NurikabeMode) // Medium513+ profile := modeIslandProfile(mode)514+ clueTarget := int(float64(mode.Width*mode.Height) * mode.ClueDensity)515+ if clueTarget < 2 {516+ clueTarget = 2517+ }518+519+ rng := rand.New(rand.NewPCG(901, 1777))520+ candidate, err := buildCandidateByCarving(mode, clueTarget, profile, rng, 0)521+ if err != nil {522+ t.Fatalf("buildCandidateByCarving error: %v", err)523+ }524+525+ if err := validateClues(candidate.puzzle.Clues, candidate.puzzle.Width, candidate.puzzle.Height); err != nil {526+ t.Fatalf("invalid candidate clues: %v", err)527+ }528+529+ solved, _, err := CountSolutions(candidate.puzzle, 1, 2000000)530+ if err != nil {531+ t.Fatalf("CountSolutions error: %v", err)532+ }533+ if solved < 1 {534+ t.Fatalf("expected constructed candidate to be solvable, got %d", solved)535+ }536+}537+538+func TestGenerateSeededReturnsSolvable(t *testing.T) {539+ if testing.Short() {540+ t.Skip("skipping slow generator solvability test in short mode")541+ }542+543+ for i, item := range Modes {544+ mode := item.(NurikabeMode)545+ t.Run(mode.Title(), func(t *testing.T) {546+ rng := rand.New(rand.NewPCG(uint64(500+i), uint64(1200+i)))547+ p, err := GenerateSeeded(mode, rng)548+ if err != nil {549+ t.Fatalf("GenerateSeeded error: %v", err)550+ }551+ if err := validateClues(p.Clues, p.Width, p.Height); err != nil {552+ t.Fatalf("invalid generated puzzle clues: %v", err)553+ }554+555+ nodeLimit := max(300000, generationNodeLimit(mode)*8)556+ count, _, err := CountSolutionsContext(context.Background(), p, 1, nodeLimit)557+ if err != nil {558+ if errors.Is(err, errNodeLimit) {559+ t.Skipf("solver node cap reached at %d for %s; generation remains solvable-by-construction", nodeLimit, mode.Title())560+ }561+ t.Fatalf("CountSolutionsContext error: %v", err)562+ }563+ if count < 1 {564+ t.Fatalf("expected at least one solution, got %d", count)565+ }566+ })567+ }568+}569+570+func TestGenerateSeededAvoidsSingletonFlood(t *testing.T) {571+ if testing.Short() {572+ t.Skip("skipping slow singleton distribution test in short mode")573+ }574+575+ for i, item := range Modes {576+ mode := item.(NurikabeMode)577+ t.Run(mode.Title(), func(t *testing.T) {578+ rng := rand.New(rand.NewPCG(uint64(700+i), uint64(1700+i)))579+ p, err := GenerateSeeded(mode, rng)580+ if err != nil {581+ t.Fatalf("GenerateSeeded error: %v", err)582+ }583+584+ clueCount := 0585+ singletons := 0586+ for y := 0; y < p.Height; y++ {587+ for x := 0; x < p.Width; x++ {588+ if p.Clues[y][x] <= 0 {589+ continue590+ }591+ clueCount++592+ if p.Clues[y][x] == 1 {593+ singletons++594+ }595+ }596+ }597+ if clueCount == 0 {598+ t.Fatal("expected at least one clue")599+ }600+601+ profile := modeIslandProfile(mode)602+ ratio := float64(singletons) / float64(clueCount)603+ // Keep a small tolerance for fallback cases under the 10s SLA.604+ if ratio > profile.maxSingletonRatio+0.12 {605+ t.Fatalf(606+ "singleton ratio too high for %s: got %.2f (%d/%d), target <= %.2f",607+ mode.Title(),608+ ratio,609+ singletons,610+ clueCount,611+ profile.maxSingletonRatio+0.12,612+ )613+ }614+ })615+ }616+}617+618+func TestUniqueFirstFallbackPolicy(t *testing.T) {619+ mode := Modes[1].(NurikabeMode) // Easy620+ rng := rand.New(rand.NewPCG(2026, 219))621+ p, err := GenerateSeeded(mode, rng)622+ if err != nil {623+ t.Fatalf("GenerateSeeded error: %v", err)624+ }625+626+ count, _, err := CountSolutionsContext(context.Background(), p, 2, generationNodeLimit(mode))627+ if err != nil {628+ t.Fatalf("CountSolutionsContext error: %v", err)629+ }630+ if count < 1 {631+ t.Fatalf("expected generated puzzle to be solvable, got %d", count)632+ }633+ if count > 2 {634+ t.Fatalf("solution count should be capped by limit: got %d", count)635+ }636+}637+638+func TestGenerateSeededCancellationFast(t *testing.T) {639+ mode := Modes[4].(NurikabeMode) // Expert gives enough work to exercise cancel path.640+ ctx, cancel := context.WithCancel(context.Background())641+ cancel()642+643+ start := time.Now()644+ rng := rand.New(rand.NewPCG(33, 77))645+ _, err := GenerateSeededWithContext(ctx, mode, rng)646+ if err == nil {647+ t.Fatal("expected cancellation error, got nil")648+ }649+ if !errors.Is(err, context.Canceled) {650+ t.Fatalf("expected context.Canceled, got %v", err)651+ }652+ if elapsed := time.Since(start); elapsed > 150*time.Millisecond {653+ t.Fatalf("cancellation should return quickly, took %s", elapsed)654+ }655+}656+657+func TestNoGoroutineLeakOnCancel(t *testing.T) {658+ mode := Modes[4].(NurikabeMode)659+ before := runtime.NumGoroutine()660+661+ for i := 0; i < 8; i++ {662+ ctx, cancel := context.WithCancel(context.Background())663+ cancel()664+ rng := rand.New(rand.NewPCG(uint64(400+i), uint64(900+i)))665+ _, _ = GenerateSeededWithContext(ctx, mode, rng)666+ }667+668+ time.Sleep(80 * time.Millisecond)669+ after := runtime.NumGoroutine()670+ if delta := after - before; delta > 6 {671+ t.Fatalf("possible goroutine leak: before=%d after=%d delta=%d", before, after, delta)672+ }673+}
A nurikabe/solver.go +681 -0
@@ -0,0 +1,681 @@
1+package nurikabe2+3+import (4+ "context"5+ "errors"6+ "fmt"7+)8+9+var errNodeLimit = errors.New("solver node limit exceeded")10+11+type SolveStats struct {12+ Nodes int13+ Branches int14+ MaxDepth int15+}16+17+type point struct {18+ x, y int19+}20+21+type islandComponent struct {22+ cells []point23+ clueCount int24+ clueValue int25+ cluePos point26+}27+28+var dirs = [4]point{{0, -1}, {0, 1}, {-1, 0}, {1, 0}}29+30+func CountSolutions(puzzle Puzzle, limit, nodeLimit int) (int, SolveStats, error) {31+ return CountSolutionsContext(context.Background(), puzzle, limit, nodeLimit)32+}33+34+func CountSolutionsContext(ctx context.Context, puzzle Puzzle, limit, nodeLimit int) (int, SolveStats, error) {35+ if ctx == nil {36+ ctx = context.Background()37+ }38+ if limit <= 0 {39+ return 0, SolveStats{}, nil40+ }41+ if nodeLimit <= 0 {42+ nodeLimit = 25000043+ }44+ if err := validateClues(puzzle.Clues, puzzle.Width, puzzle.Height); err != nil {45+ return 0, SolveStats{}, err46+ }47+48+ state := newGrid(puzzle.Width, puzzle.Height, unknownCell)49+ for y := range puzzle.Height {50+ for x := range puzzle.Width {51+ if puzzle.Clues[y][x] > 0 {52+ state[y][x] = islandCell53+ }54+ }55+ }56+57+ stats := SolveStats{}58+ solutions := 059+ var dfs func(grid, int) error60+ dfs = func(g grid, depth int) error {61+ if err := ctx.Err(); err != nil {62+ return err63+ }64+ if stats.Nodes >= nodeLimit {65+ return errNodeLimit66+ }67+ stats.Nodes++68+ if depth > stats.MaxDepth {69+ stats.MaxDepth = depth70+ }71+72+ if err := propagate(g, puzzle.Clues); err != nil {73+ return nil74+ }75+ if err := validatePartial(g, puzzle.Clues); err != nil {76+ return nil77+ }78+79+ x, y, ok := pickUnknown(g, puzzle.Clues)80+ if !ok {81+ if isSolvedGrid(g, puzzle.Clues) {82+ solutions++83+ }84+ return nil85+ }86+87+ order := candidateOrder(g, puzzle.Clues, x, y)88+ stats.Branches++89+ for _, candidate := range order {90+ if err := ctx.Err(); err != nil {91+ return err92+ }93+ next := g.clone()94+ next[y][x] = candidate95+ if err := dfs(next, depth+1); err != nil {96+ if errors.Is(err, errNodeLimit) ||97+ errors.Is(err, context.Canceled) ||98+ errors.Is(err, context.DeadlineExceeded) {99+ return err100+ }101+ continue102+ }103+ if solutions >= limit {104+ return nil105+ }106+ }107+108+ return nil109+ }110+111+ err := dfs(state, 0)112+ if errors.Is(err, errNodeLimit) ||113+ errors.Is(err, context.Canceled) ||114+ errors.Is(err, context.DeadlineExceeded) {115+ return solutions, stats, err116+ }117+ return solutions, stats, nil118+}119+120+func propagate(g grid, clues clueGrid) error {121+ height := len(g)122+ if height == 0 {123+ return nil124+ }125+ width := len(g[0])126+127+ for {128+ changed := false129+ comps, compIdx := islandComponents(g, clues)130+131+ for _, comp := range comps {132+ if comp.clueCount == 1 && len(comp.cells) == comp.clueValue {133+ for _, c := range comp.cells {134+ for _, d := range dirs {135+ nx, ny := c.x+d.x, c.y+d.y136+ if nx < 0 || nx >= width || ny < 0 || ny >= height {137+ continue138+ }139+ if g[ny][nx] == unknownCell {140+ g[ny][nx] = seaCell141+ changed = true142+ }143+ }144+ }145+ }146+ }147+148+ for y := range height {149+ for x := range width {150+ if g[y][x] != unknownCell {151+ continue152+ }153+ seen := map[point]bool{}154+ for _, d := range dirs {155+ nx, ny := x+d.x, y+d.y156+ if nx < 0 || nx >= width || ny < 0 || ny >= height {157+ continue158+ }159+ if g[ny][nx] != islandCell {160+ continue161+ }162+ idx := compIdx[ny][nx]163+ if idx < 0 || idx >= len(comps) {164+ continue165+ }166+ comp := comps[idx]167+ if comp.clueCount == 1 {168+ seen[comp.cluePos] = true169+ }170+ }171+ if len(seen) >= 2 {172+ g[y][x] = seaCell173+ changed = true174+ }175+ }176+ }177+178+ if err := validatePartial(g, clues); err != nil {179+ return err180+ }181+ if !changed {182+ return nil183+ }184+ }185+}186+187+func validatePartial(g grid, clues clueGrid) error {188+ if hasSeaSquare(g) {189+ return fmt.Errorf("contains 2x2 sea block")190+ }191+ if !seaCanRemainConnected(g) {192+ return fmt.Errorf("sea components cannot be connected")193+ }194+195+ comps, _ := islandComponents(g, clues)196+ for _, comp := range comps {197+ size := len(comp.cells)198+ switch {199+ case comp.clueCount > 1:200+ return fmt.Errorf("island has multiple clues")201+ case comp.clueCount == 1 && size > comp.clueValue:202+ return fmt.Errorf("island exceeds clue size")203+ case comp.clueCount == 0 && !componentCanReachClue(g, clues, comp):204+ return fmt.Errorf("orphan island component")205+ case comp.clueCount == 1 && maxReachableForComponent(g, clues, comp) < comp.clueValue:206+ return fmt.Errorf("clue cannot reach required size")207+ }208+ }209+210+ return nil211+}212+213+func isSolvedGrid(g grid, clues clueGrid) bool {214+ if len(g) == 0 || len(g[0]) == 0 {215+ return false216+ }217+218+ height := len(g)219+ width := len(g[0])220+ for y := range height {221+ for x := range width {222+ if clues[y][x] > 0 && g[y][x] != islandCell {223+ return false224+ }225+ if g[y][x] == unknownCell {226+ return false227+ }228+ }229+ }230+231+ if hasSeaSquare(g) {232+ return false233+ }234+ if !isSeaConnected(g) {235+ return false236+ }237+238+ comps, _ := islandComponents(g, clues)239+ seenClues := 0240+ for _, comp := range comps {241+ if comp.clueCount != 1 {242+ return false243+ }244+ if len(comp.cells) != comp.clueValue {245+ return false246+ }247+ seenClues++248+ }249+250+ return seenClues == countClues(clues)251+}252+253+func pickUnknown(g grid, clues clueGrid) (x, y int, ok bool) {254+ bestScore := -1255+ for yy, row := range g {256+ for xx := range row {257+ if g[yy][xx] != unknownCell {258+ continue259+ }260+ if clues[yy][xx] > 0 {261+ continue262+ }263+ score := 0264+ for _, d := range dirs {265+ nx, ny := xx+d.x, yy+d.y266+ if nx < 0 || ny < 0 || ny >= len(g) || nx >= len(row) {267+ continue268+ }269+ switch g[ny][nx] {270+ case islandCell:271+ score += 3272+ case seaCell:273+ score += 1274+ }275+ if clues[ny][nx] > 0 {276+ score += 4277+ }278+ }279+ if score > bestScore {280+ bestScore = score281+ x, y = xx, yy282+ ok = true283+ }284+ }285+ }286+ return x, y, ok287+}288+289+func candidateOrder(g grid, clues clueGrid, x, y int) []cellState {290+ islandFirst := false291+ for _, d := range dirs {292+ nx, ny := x+d.x, y+d.y293+ if nx < 0 || ny < 0 || ny >= len(g) || nx >= len(g[0]) {294+ continue295+ }296+ if g[ny][nx] == islandCell || clues[ny][nx] > 0 {297+ islandFirst = true298+ break299+ }300+ }301+302+ if islandFirst {303+ return []cellState{islandCell, seaCell}304+ }305+ return []cellState{seaCell, islandCell}306+}307+308+func seaCanRemainConnected(g grid) bool {309+ height := len(g)310+ if height == 0 {311+ return true312+ }313+ width := len(g[0])314+315+ start := point{-1, -1}316+ seaCount := 0317+ for y := range height {318+ for x := range width {319+ if g[y][x] == seaCell {320+ seaCount++321+ if start.x == -1 {322+ start = point{x, y}323+ }324+ }325+ }326+ }327+ if seaCount <= 1 {328+ return true329+ }330+331+ visited := make([][]bool, height)332+ for y := range height {333+ visited[y] = make([]bool, width)334+ }335+ queue := []point{start}336+ visited[start.y][start.x] = true337+338+ for len(queue) > 0 {339+ p := queue[0]340+ queue = queue[1:]341+ for _, d := range dirs {342+ nx, ny := p.x+d.x, p.y+d.y343+ if nx < 0 || ny < 0 || nx >= width || ny >= height {344+ continue345+ }346+ if visited[ny][nx] || g[ny][nx] == islandCell {347+ continue348+ }349+ visited[ny][nx] = true350+ queue = append(queue, point{nx, ny})351+ }352+ }353+354+ for y := range height {355+ for x := range width {356+ if g[y][x] == seaCell && !visited[y][x] {357+ return false358+ }359+ }360+ }361+ return true362+}363+364+func isSeaConnected(g grid) bool {365+ height := len(g)366+ if height == 0 {367+ return false368+ }369+ width := len(g[0])370+371+ start := point{-1, -1}372+ seaCount := 0373+ for y := range height {374+ for x := range width {375+ if g[y][x] == seaCell {376+ seaCount++377+ if start.x == -1 {378+ start = point{x, y}379+ }380+ }381+ }382+ }383+ if seaCount == 0 {384+ return false385+ }386+387+ visited := make([][]bool, height)388+ for y := range height {389+ visited[y] = make([]bool, width)390+ }391+392+ queue := []point{start}393+ visited[start.y][start.x] = true394+ seen := 1395+396+ for len(queue) > 0 {397+ p := queue[0]398+ queue = queue[1:]399+ for _, d := range dirs {400+ nx, ny := p.x+d.x, p.y+d.y401+ if nx < 0 || ny < 0 || nx >= width || ny >= height {402+ continue403+ }404+ if visited[ny][nx] || g[ny][nx] != seaCell {405+ continue406+ }407+ visited[ny][nx] = true408+ seen++409+ queue = append(queue, point{nx, ny})410+ }411+ }412+413+ return seen == seaCount414+}415+416+func hasSeaSquare(g grid) bool {417+ height := len(g)418+ if height == 0 {419+ return false420+ }421+ width := len(g[0])422+ for y := 0; y < height-1; y++ {423+ for x := 0; x < width-1; x++ {424+ if g[y][x] == seaCell && g[y][x+1] == seaCell && g[y+1][x] == seaCell && g[y+1][x+1] == seaCell {425+ return true426+ }427+ }428+ }429+ return false430+}431+432+func islandComponents(g grid, clues clueGrid) ([]islandComponent, [][]int) {433+ height := len(g)434+ width := len(g[0])435+ idx := make([][]int, height)436+ for y := range height {437+ idx[y] = make([]int, width)438+ for x := range width {439+ idx[y][x] = -1440+ }441+ }442+443+ components := make([]islandComponent, 0)444+ for y := range height {445+ for x := range width {446+ if g[y][x] != islandCell || idx[y][x] >= 0 {447+ continue448+ }449+450+ compIndex := len(components)451+ comp := islandComponent{cluePos: point{-1, -1}}452+ queue := []point{{x, y}}453+ idx[y][x] = compIndex454+455+ for len(queue) > 0 {456+ p := queue[0]457+ queue = queue[1:]458+ comp.cells = append(comp.cells, p)459+ if clues[p.y][p.x] > 0 {460+ comp.clueCount++461+ comp.clueValue = clues[p.y][p.x]462+ comp.cluePos = p463+ }464+465+ for _, d := range dirs {466+ nx, ny := p.x+d.x, p.y+d.y467+ if nx < 0 || ny < 0 || nx >= width || ny >= height {468+ continue469+ }470+ if g[ny][nx] != islandCell || idx[ny][nx] >= 0 {471+ continue472+ }473+ idx[ny][nx] = compIndex474+ queue = append(queue, point{nx, ny})475+ }476+ }477+478+ components = append(components, comp)479+ }480+ }481+482+ return components, idx483+}484+485+func componentCanReachClue(g grid, clues clueGrid, comp islandComponent) bool {486+ height := len(g)487+ width := len(g[0])488+ seen := make([][]bool, height)489+ for y := range height {490+ seen[y] = make([]bool, width)491+ }492+493+ queue := make([]point, len(comp.cells))494+ copy(queue, comp.cells)495+ for _, c := range comp.cells {496+ seen[c.y][c.x] = true497+ }498+499+ for len(queue) > 0 {500+ p := queue[0]501+ queue = queue[1:]502+ if clues[p.y][p.x] > 0 {503+ return true504+ }505+ for _, d := range dirs {506+ nx, ny := p.x+d.x, p.y+d.y507+ if nx < 0 || ny < 0 || nx >= width || ny >= height {508+ continue509+ }510+ if seen[ny][nx] || g[ny][nx] == seaCell {511+ continue512+ }513+ seen[ny][nx] = true514+ queue = append(queue, point{nx, ny})515+ }516+ }517+518+ return false519+}520+521+func maxReachableForComponent(g grid, clues clueGrid, comp islandComponent) int {522+ if comp.clueCount != 1 {523+ return 0524+ }525+526+ height := len(g)527+ width := len(g[0])528+ seen := make([][]bool, height)529+ for y := range height {530+ seen[y] = make([]bool, width)531+ }532+533+ queue := make([]point, len(comp.cells))534+ copy(queue, comp.cells)535+ for _, c := range comp.cells {536+ seen[c.y][c.x] = true537+ }538+539+ count := len(comp.cells)540+ for len(queue) > 0 {541+ p := queue[0]542+ queue = queue[1:]543+ for _, d := range dirs {544+ nx, ny := p.x+d.x, p.y+d.y545+ if nx < 0 || ny < 0 || nx >= width || ny >= height {546+ continue547+ }548+ if seen[ny][nx] || g[ny][nx] == seaCell {549+ continue550+ }551+ if clues[ny][nx] > 0 && (nx != comp.cluePos.x || ny != comp.cluePos.y) {552+ continue553+ }554+ seen[ny][nx] = true555+ count++556+ queue = append(queue, point{nx, ny})557+ }558+ }559+560+ return count561+}562+563+func hasAnyUnknown(g grid) bool {564+ for _, row := range g {565+ for _, c := range row {566+ if c == unknownCell {567+ return true568+ }569+ }570+ }571+ return false572+}573+574+func computeConflicts(marks grid, clues clueGrid) [][]bool {575+ height := len(marks)576+ width := len(marks[0])577+ conflicts := make([][]bool, height)578+ for y := range height {579+ conflicts[y] = make([]bool, width)580+ }581+582+ for y := 0; y < height-1; y++ {583+ for x := 0; x < width-1; x++ {584+ if marks[y][x] == seaCell && marks[y][x+1] == seaCell && marks[y+1][x] == seaCell && marks[y+1][x+1] == seaCell {585+ conflicts[y][x] = true586+ conflicts[y][x+1] = true587+ conflicts[y+1][x] = true588+ conflicts[y+1][x+1] = true589+ }590+ }591+ }592+593+ comps, _ := islandComponents(marks, clues)594+ for _, comp := range comps {595+ bad := false596+ size := len(comp.cells)597+ if comp.clueCount > 1 {598+ bad = true599+ }600+ if comp.clueCount == 1 && size > comp.clueValue {601+ bad = true602+ }603+ if comp.clueCount == 0 && !componentCanReachClue(marks, clues, comp) {604+ bad = true605+ }606+ if !hasAnyUnknown(marks) && comp.clueCount == 1 && size != comp.clueValue {607+ bad = true608+ }609+ if bad {610+ for _, c := range comp.cells {611+ conflicts[c.y][c.x] = true612+ }613+ }614+ }615+616+ if !hasAnyUnknown(marks) {617+ markDisconnectedSeaConflicts(marks, conflicts)618+ }619+620+ return conflicts621+}622+623+func markDisconnectedSeaConflicts(marks grid, conflicts [][]bool) {624+ height := len(marks)625+ width := len(marks[0])626+ visited := make([][]bool, height)627+ for y := range height {628+ visited[y] = make([]bool, width)629+ }630+631+ var components [][]point632+ for y := range height {633+ for x := range width {634+ if marks[y][x] != seaCell || visited[y][x] {635+ continue636+ }637+ queue := []point{{x, y}}638+ visited[y][x] = true639+ component := []point{}640+ for len(queue) > 0 {641+ p := queue[0]642+ queue = queue[1:]643+ component = append(component, p)644+ for _, d := range dirs {645+ nx, ny := p.x+d.x, p.y+d.y646+ if nx < 0 || ny < 0 || nx >= width || ny >= height {647+ continue648+ }649+ if visited[ny][nx] || marks[ny][nx] != seaCell {650+ continue651+ }652+ visited[ny][nx] = true653+ queue = append(queue, point{nx, ny})654+ }655+ }656+ components = append(components, component)657+ }658+ }659+660+ if len(components) <= 1 {661+ return662+ }663+664+ largestIdx := 0665+ largestSize := len(components[0])666+ for i := 1; i < len(components); i++ {667+ if len(components[i]) > largestSize {668+ largestSize = len(components[i])669+ largestIdx = i670+ }671+ }672+673+ for i, component := range components {674+ if i == largestIdx {675+ continue676+ }677+ for _, c := range component {678+ conflicts[c.y][c.x] = true679+ }680+ }681+}
A nurikabe/style.go +129 -0
@@ -0,0 +1,129 @@
1+package nurikabe2+3+import (4+ "fmt"5+6+ "charm.land/lipgloss/v2"7+ "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/FelineStateMachine/puzzletea/theme"9+)10+11+const cellWidth = 312+13+func gridBorderStyle() lipgloss.Style {14+ p := theme.Current()15+ return lipgloss.NewStyle().16+ Border(lipgloss.RoundedBorder()).17+ BorderForeground(p.Border).18+ BorderBackground(p.BG)19+}20+21+func gridBorderSolvedStyle() lipgloss.Style {22+ p := theme.Current()23+ return lipgloss.NewStyle().24+ Border(lipgloss.RoundedBorder()).25+ BorderForeground(p.SuccessBorder).26+ BorderBackground(p.SuccessBG)27+}28+29+func cellStyle(c cellState, clue int, isCursor, solved, conflict, inSeaSquare bool) (lipgloss.Style, string) {30+ p := theme.Current()31+ display := " "32+ landBg := theme.Blend(p.Surface, p.Success, 0.45)33+ style := lipgloss.NewStyle().Background(landBg).Foreground(theme.TextOnBG(landBg))34+35+ switch {36+ case clue > 0:37+ display = fmt.Sprintf("%2d ", clue)38+ if clue < 10 {39+ display = fmt.Sprintf(" %d ", clue)40+ }41+42+ style = lipgloss.NewStyle().43+ Foreground(p.Info).44+ Background(landBg).45+ Bold(true)46+ case c == seaCell:47+ display = " ~ "48+ if inSeaSquare {49+ display = " @ "50+ }51+ seaBg := theme.Blend(p.BG, p.Secondary, 0.24)52+ style = style.Background(seaBg).Foreground(theme.TextOnBG(seaBg))53+ case c == islandCell:54+ display = " \u00b7 "55+ style = style.Background(landBg).Foreground(theme.TextOnBG(landBg))56+ }57+58+ if solved {59+ style = style.Background(p.SuccessBG).Foreground(theme.TextOnBG(p.SuccessBG))60+ }61+ if conflict {62+ style = style.Foreground(game.ConflictFG()).Background(game.ConflictBG())63+ }64+ if isCursor && solved {65+ style = game.CursorSolvedStyle()66+ } else if isCursor {67+ style = game.CursorStyle()68+ }69+70+ return style.Width(cellWidth).AlignHorizontal(lipgloss.Center), display71+}72+73+func gridView(m Model) string {74+ rows := make([]string, m.height)75+ for y := range m.height {76+ cells := make([]string, m.width)77+ for x := range m.width {78+ isCursor := x == m.cursor.X && y == m.cursor.Y79+ inSeaSquare := isSeaSquareCell(m.marks, x, y)80+ style, display := cellStyle(m.marks[y][x], m.clues[y][x], isCursor, m.solved, m.conflicts[y][x], inSeaSquare)81+ cells[x] = style.Render(display)82+ }83+ rows[y] = lipgloss.JoinHorizontal(lipgloss.Top, cells...)84+ }85+86+ content := lipgloss.JoinVertical(lipgloss.Left, rows...)87+ if m.solved {88+ return gridBorderSolvedStyle().Render(content)89+ }90+ return gridBorderStyle().Render(content)91+}92+93+// isSeaSquareCell reports whether (x,y) is part of any 2x2 sea block.94+func isSeaSquareCell(marks grid, x, y int) bool {95+ if len(marks) == 0 || len(marks[0]) == 0 {96+ return false97+ }98+ if y < 0 || y >= len(marks) || x < 0 || x >= len(marks[0]) {99+ return false100+ }101+ if marks[y][x] != seaCell {102+ return false103+ }104+105+ startX := max(0, x-1)106+ endX := min(len(marks[0])-2, x)107+ startY := max(0, y-1)108+ endY := min(len(marks)-2, y)109+110+ for yy := startY; yy <= endY; yy++ {111+ for xx := startX; xx <= endX; xx++ {112+ if marks[yy][xx] == seaCell &&113+ marks[yy][xx+1] == seaCell &&114+ marks[yy+1][xx] == seaCell &&115+ marks[yy+1][xx+1] == seaCell {116+ return true117+ }118+ }119+ }120+121+ return false122+}123+124+func statusBarView(showFullHelp bool) string {125+ if showFullHelp {126+ return game.StatusBarStyle().Render("arrows/wasd: move x: sea z: island bkspc: clear LMB: toggle sea/unknown RMB: island ctrl+n: menu ctrl+r: reset ctrl+h: help")127+ }128+ return game.StatusBarStyle().Render("x: sea z: island bkspc: clear LMB: toggle sea/unknown RMB: island")129+}
M resolve/resolve.go +52 -14
@@ -28,6 +28,9 @@ var CategoryAliases = map[string]string{
2828 "hitori": "hitori",2929 "lights": "lights out",3030 "nonogram": "nonogram",31+ "nurikabe": "nurikabe",32+ "islands": "nurikabe",33+ "sea": "nurikabe",3134 "sudoku": "sudoku",3235 "takuzu": "takuzu",3336 "binairo": "takuzu",
@@ -49,7 +52,10 @@ func Category(name string, categories []list.Item) (game.Category, error) {
4952 }5053 5154 for _, item := range categories {52- cat := item.(game.Category)55+ cat, ok := item.(game.Category)56+ if !ok {57+ continue58+ }5359 if Normalize(cat.Name) == norm {5460 return cat, nil5561 }
@@ -67,15 +73,32 @@ func Mode(cat game.Category, name string) (game.Spawner, string, error) {
6773 }6874 6975 if name == "" {70- m := cat.Modes[0].(game.Mode)71- return m.(game.Spawner), m.Title(), nil76+ for _, item := range cat.Modes {77+ m, ok := item.(game.Mode)78+ if !ok {79+ continue80+ }81+ s, ok := item.(game.Spawner)82+ if !ok {83+ continue84+ }85+ return s, m.Title(), nil86+ }87+ return nil, "", fmt.Errorf("game %q has no spawnable modes", cat.Name)7288 }7389 7490 norm := Normalize(name)7591 for _, item := range cat.Modes {76- m := item.(game.Mode)92+ m, ok := item.(game.Mode)93+ if !ok {94+ continue95+ }7796 if Normalize(m.Title()) == norm {78- return m.(game.Spawner), m.Title(), nil97+ s, ok := item.(game.Spawner)98+ if !ok {99+ return nil, "", fmt.Errorf("mode %q for %s is not spawnable", m.Title(), cat.Name)100+ }101+ return s, m.Title(), nil79102 }80103 }81104
@@ -85,18 +108,26 @@ func Mode(cat game.Category, name string) (game.Spawner, string, error) {
85108 86109 // CategoryNames returns the display names of all game categories.87110 func CategoryNames(categories []list.Item) []string {88- names := make([]string, len(categories))89- for i, item := range categories {90- names[i] = item.(game.Category).Name111+ 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)91118 }92119 return names93120 }94121 95122 // ModeNames returns the display names of all modes in a category.96123 func ModeNames(cat game.Category) []string {97- names := make([]string, len(cat.Modes))98- for i, item := range cat.Modes {99- names[i] = item.(game.Mode).Title()124+ names := make([]string, 0, len(cat.Modes))125+ for _, item := range cat.Modes {126+ mode, ok := item.(game.Mode)127+ if !ok {128+ continue129+ }130+ names = append(names, mode.Title())100131 }101132 return names102133 }
@@ -130,8 +161,15 @@ func SeededMode(seed string, categories []list.Item) (game.SeededSpawner, string
130161 var bestHash uint64131162 found := false132163 for _, item := range categories {133- cat := item.(game.Category)164+ cat, ok := item.(game.Category)165+ if !ok {166+ continue167+ }134168 for _, modeItem := range cat.Modes {169+ mode, ok := modeItem.(game.Mode)170+ if !ok {171+ continue172+ }135173 s, ok := modeItem.(game.SeededSpawner)136174 if !ok {137175 continue
@@ -141,14 +179,14 @@ func SeededMode(seed string, categories []list.Item) (game.SeededSpawner, string
141179 h.Write([]byte{0})142180 h.Write([]byte(cat.Name))143181 h.Write([]byte{0})144- h.Write([]byte(modeItem.(game.Mode).Title()))182+ h.Write([]byte(mode.Title()))145183 score := h.Sum64()146184 if !found || score > bestHash {147185 bestHash = score148186 best = seededEntry{149187 spawner: s,150188 gameType: cat.Name,151- mode: modeItem.(game.Mode).Title(),189+ mode: mode.Title(),152190 }153191 found = true154192 }
M resolve/resolve_test.go +6 -0
@@ -9,6 +9,7 @@ import (
99 "github.com/FelineStateMachine/puzzletea/hitori"1010 "github.com/FelineStateMachine/puzzletea/lightsout"1111 "github.com/FelineStateMachine/puzzletea/nonogram"12+ "github.com/FelineStateMachine/puzzletea/nurikabe"1213 "github.com/FelineStateMachine/puzzletea/sudoku"1314 "github.com/FelineStateMachine/puzzletea/takuzu"1415 "github.com/FelineStateMachine/puzzletea/wordsearch"
@@ -21,6 +22,7 @@ var testCategories = []list.Item{
2122 game.Category{Name: "Hitori", Desc: "Shade cells to eliminate duplicates.", Modes: hitori.Modes},2223 game.Category{Name: "Lights Out", Desc: "Turn off all the lights.", Modes: lightsout.Modes},2324 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},2426 game.Category{Name: "Sudoku", Desc: "Fill the 9x9 grid following sudoku rules.", Modes: sudoku.Modes},2527 game.Category{Name: "Takuzu", Desc: "Fill the grid with ● and ○.", Modes: takuzu.Modes},2628 game.Category{Name: "Word Search", Desc: "Find hidden words in a letter grid.", Modes: wordsearch.Modes},
@@ -102,6 +104,9 @@ func TestCategory(t *testing.T) {
102104 {"binairo", "Takuzu"},103105 {"binary", "Takuzu"},104106 {"nonogram", "Nonogram"},107+ {"nurikabe", "Nurikabe"},108+ {"islands", "Nurikabe"},109+ {"sea", "Nurikabe"},105110 {"sudoku", "Sudoku"},106111 {"takuzu", "Takuzu"},107112 }
@@ -212,6 +217,7 @@ func TestCategoryNames(t *testing.T) {
212217 "Hitori": true,213218 "Lights Out": true,214219 "Nonogram": true,220+ "Nurikabe": true,215221 "Sudoku": true,216222 "Takuzu": true,217223 "Word Search": true,
M stats/stats.go +8 -14
@@ -8,12 +8,10 @@ import (
88 "strings"99 "time"1010 11- "github.com/FelineStateMachine/puzzletea/daily"1211 "github.com/FelineStateMachine/puzzletea/game"1312 "github.com/FelineStateMachine/puzzletea/store"1413 "github.com/FelineStateMachine/puzzletea/theme"1514 16- "charm.land/bubbles/v2/list"1715 "charm.land/lipgloss/v2"1816 )1917
@@ -28,13 +26,15 @@ var ModeXP map[ModeKey]int
2826 2927 // InitModeXP builds the ModeXP map from the provided game categories.3028 // Must be called once at startup before any stats operations.31-func InitModeXP(categories []list.Item) {29+func InitModeXP(categories []game.Category) {3230 ModeXP = make(map[ModeKey]int, 64)33- for _, item := range categories {34- cat := item.(game.Category)31+ for _, cat := range categories {3532 count := len(cat.Modes)3633 for i, m := range cat.Modes {37- mode := m.(game.Mode)34+ mode, ok := m.(game.Mode)35+ if !ok {36+ continue37+ }3838 xp := int(math.Round(float64(i) / float64(count) * 10))3939 if xp < 1 {4040 xp = 1
@@ -192,7 +192,7 @@ func BuildProfileBanner(
192192 catStats []store.CategoryStat,193193 modeStats []store.ModeStat,194194 streakDates []time.Time,195- s *store.Store,195+ currentDaily bool,196196 ) ProfileBanner {197197 profileLevel := 0198198 totalDailies := 0
@@ -202,13 +202,7 @@ func BuildProfileBanner(
202202 totalDailies += cs.TimesDaily203203 }204204 205- now := time.Now()206- streak := ComputeDailyStreak(streakDates, now)207-208- // Check if today's daily exists in the DB.209- todayName := daily.Name(now)210- rec, _ := s.GetDailyGame(todayName)211- currentDaily := rec != nil205+ streak := ComputeDailyStreak(streakDates, time.Now())212206 213207 return ProfileBanner{214208 ProfileLevel: profileLevel,
M store/store.go +66 -86
@@ -61,6 +61,58 @@ type Store struct {
6161 db *sql.DB6262 }6363 64+const gameSelectColumns = `id, name, game_type, mode, initial_state, save_state, status,65+ created_at, updated_at, completed_at`66+67+type rowScanner interface {68+ Scan(dest ...any) error69+}70+71+func scanGameRecord(scanner rowScanner) (GameRecord, error) {72+ var g GameRecord73+ var completedAt sql.NullTime74+ if err := scanner.Scan(75+ &g.ID, &g.Name, &g.GameType, &g.Mode,76+ &g.InitialState, &g.SaveState, &g.Status,77+ &g.CreatedAt, &g.UpdatedAt, &completedAt,78+ ); err != nil {79+ return GameRecord{}, err80+ }81+ if completedAt.Valid {82+ g.CompletedAt = &completedAt.Time83+ }84+ return g, nil85+}86+87+func (s *Store) listGamesByQuery(query string, args ...any) ([]GameRecord, error) {88+ rows, err := s.db.Query(query, args...)89+ if err != nil {90+ return nil, fmt.Errorf("querying games: %w", err)91+ }92+ defer rows.Close()93+94+ var games []GameRecord95+ for rows.Next() {96+ g, err := scanGameRecord(rows)97+ if err != nil {98+ return nil, fmt.Errorf("scanning game row: %w", err)99+ }100+ games = append(games, g)101+ }102+ return games, rows.Err()103+}104+105+func (s *Store) getGameByQuery(query string, args ...any) (*GameRecord, error) {106+ g, err := scanGameRecord(s.db.QueryRow(query, args...))107+ if errors.Is(err, sql.ErrNoRows) {108+ return nil, nil109+ }110+ if err != nil {111+ return nil, err112+ }113+ return &g, nil114+}115+64116 // DefaultDBPath returns ~/.puzzletea/history.db.65117 func DefaultDBPath() string {66118 home, _ := os.UserHomeDir()
@@ -159,125 +211,53 @@ func (s *Store) NameExists(name string) (bool, error) {
159211 160212 // ListGames returns all non-abandoned games ordered by most recently updated.161213 func (s *Store) ListGames() ([]GameRecord, error) {162- rows, err := s.db.Query(163- `SELECT id, name, game_type, mode, initial_state, save_state, status,164- created_at, updated_at, completed_at214+ return s.listGamesByQuery(215+ fmt.Sprintf(`SELECT %s165216 FROM games166217 WHERE status != ?167- ORDER BY updated_at DESC`,218+ ORDER BY updated_at DESC`, gameSelectColumns),168219 StatusAbandoned,169220 )170- if err != nil {171- return nil, fmt.Errorf("querying games: %w", err)172- }173- defer rows.Close()174-175- var games []GameRecord176- for rows.Next() {177- var g GameRecord178- var completedAt sql.NullTime179- if err := rows.Scan(180- &g.ID, &g.Name, &g.GameType, &g.Mode,181- &g.InitialState, &g.SaveState, &g.Status,182- &g.CreatedAt, &g.UpdatedAt, &completedAt,183- ); err != nil {184- return nil, fmt.Errorf("scanning game row: %w", err)185- }186- if completedAt.Valid {187- g.CompletedAt = &completedAt.Time188- }189- games = append(games, g)190- }191- return games, rows.Err()192221 }193222 194223 // GetDailyGame looks up a daily game by name, including abandoned ones.195224 // Daily puzzles should always be resumable regardless of status.196225 // Returns nil, nil if no matching game is found.197226 func (s *Store) GetDailyGame(name string) (*GameRecord, error) {198- var g GameRecord199- var completedAt sql.NullTime200- err := s.db.QueryRow(201- `SELECT id, name, game_type, mode, initial_state, save_state, status,202- created_at, updated_at, completed_at227+ g, err := s.getGameByQuery(228+ fmt.Sprintf(`SELECT %s203229 FROM games204- WHERE name = ?`,230+ WHERE name = ?`, gameSelectColumns),205231 name,206- ).Scan(207- &g.ID, &g.Name, &g.GameType, &g.Mode,208- &g.InitialState, &g.SaveState, &g.Status,209- &g.CreatedAt, &g.UpdatedAt, &completedAt,210232 )211- if err == sql.ErrNoRows {212- return nil, nil213- }214233 if err != nil {215234 return nil, fmt.Errorf("querying daily game: %w", err)216235 }217- if completedAt.Valid {218- g.CompletedAt = &completedAt.Time219- }220- return &g, nil236+ return g, nil221237 }222238 223239 // GetGameByName looks up a single non-abandoned game by its unique name.224240 // Returns nil, nil if no matching game is found.225241 func (s *Store) GetGameByName(name string) (*GameRecord, error) {226- var g GameRecord227- var completedAt sql.NullTime228- err := s.db.QueryRow(229- `SELECT id, name, game_type, mode, initial_state, save_state, status,230- created_at, updated_at, completed_at242+ g, err := s.getGameByQuery(243+ fmt.Sprintf(`SELECT %s231244 FROM games232- WHERE name = ? AND status != ?`,245+ WHERE name = ? AND status != ?`, gameSelectColumns),233246 name, StatusAbandoned,234- ).Scan(235- &g.ID, &g.Name, &g.GameType, &g.Mode,236- &g.InitialState, &g.SaveState, &g.Status,237- &g.CreatedAt, &g.UpdatedAt, &completedAt,238247 )239- if errors.Is(err, sql.ErrNoRows) {240- return nil, nil241- }242248 if err != nil {243249 return nil, fmt.Errorf("querying game by name: %w", err)244250 }245- if completedAt.Valid {246- g.CompletedAt = &completedAt.Time247- }248- return &g, nil251+ return g, nil249252 }250253 251254 // ListAllGames returns all games (including abandoned) ordered by most recently updated.252255 func (s *Store) ListAllGames() ([]GameRecord, error) {253- rows, err := s.db.Query(254- `SELECT id, name, game_type, mode, initial_state, save_state, status,255- created_at, updated_at, completed_at256+ return s.listGamesByQuery(257+ fmt.Sprintf(`SELECT %s256258 FROM games257- ORDER BY updated_at DESC`,259+ ORDER BY updated_at DESC`, gameSelectColumns),258260 )259- if err != nil {260- return nil, fmt.Errorf("querying games: %w", err)261- }262- defer rows.Close()263-264- var games []GameRecord265- for rows.Next() {266- var g GameRecord267- var completedAt sql.NullTime268- if err := rows.Scan(269- &g.ID, &g.Name, &g.GameType, &g.Mode,270- &g.InitialState, &g.SaveState, &g.Status,271- &g.CreatedAt, &g.UpdatedAt, &completedAt,272- ); err != nil {273- return nil, fmt.Errorf("scanning game row: %w", err)274- }275- if completedAt.Valid {276- g.CompletedAt = &completedAt.Time277- }278- games = append(games, g)279- }280- return games, rows.Err()281261 }282262 283263 func (s *Store) Close() error {
M theme/theme_test.go +22 -0
M ui/list.go +21 -28
@@ -5,6 +5,25 @@ import (
55 "github.com/FelineStateMachine/puzzletea/theme"66 )77 8+func applyThemeListStyles(d *list.DefaultDelegate, p theme.Palette, width int) {9+ // Title styles intentionally avoid fixed Width: bubbles uses them for10+ // rune-level filter highlighting and Width can force wrapped segments.11+ d.Styles.SelectedTitle = d.Styles.SelectedTitle.12+ Foreground(p.Accent).13+ BorderLeftForeground(p.Accent)14+ d.Styles.NormalTitle = d.Styles.NormalTitle.15+ Foreground(p.FG)16+17+ // Description lines carry the fixed width so the list column remains stable.18+ d.Styles.SelectedDesc = d.Styles.SelectedDesc.19+ Foreground(p.AccentSoft).20+ BorderLeftForeground(p.Accent).21+ Width(width)22+ d.Styles.NormalDesc = d.Styles.NormalDesc.23+ Foreground(p.TextDim).24+ Width(width)25+}26+827 // InitList creates a styled list widget with the active theme's colors.928 // The list title is hidden because lists are rendered inside Panel frames1029 // that provide their own styled title.
@@ -39,20 +58,7 @@ func InitList(items []list.Item, title string) list.Model {
3958 func InitThemeList(items []list.Item, width, height int) list.Model {4059 p := theme.Current()4160 d := list.NewDefaultDelegate()42- d.Styles.SelectedTitle = d.Styles.SelectedTitle.43- Foreground(p.Accent).44- BorderLeftForeground(p.Accent).45- Width(width)46- d.Styles.SelectedDesc = d.Styles.SelectedDesc.47- Foreground(p.AccentSoft).48- BorderLeftForeground(p.Accent).49- Width(width)50- d.Styles.NormalTitle = d.Styles.NormalTitle.51- Foreground(p.FG).52- Width(width)53- d.Styles.NormalDesc = d.Styles.NormalDesc.54- Foreground(p.TextDim).55- Width(width)61+ applyThemeListStyles(&d, p, width)5662 5763 l := list.New(items, d, width, height)5864 l.SetShowTitle(false)
@@ -71,20 +77,7 @@ func UpdateThemeListStyles(l *list.Model) {
7177 7278 // Preserve the width that was set when the list was created.7379 w := l.Width()74- d.Styles.SelectedTitle = d.Styles.SelectedTitle.75- Foreground(p.Accent).76- BorderLeftForeground(p.Accent).77- Width(w)78- d.Styles.SelectedDesc = d.Styles.SelectedDesc.79- Foreground(p.AccentSoft).80- BorderLeftForeground(p.Accent).81- Width(w)82- d.Styles.NormalTitle = d.Styles.NormalTitle.83- Foreground(p.FG).84- Width(w)85- d.Styles.NormalDesc = d.Styles.NormalDesc.86- Foreground(p.TextDim).87- Width(w)80+ applyThemeListStyles(&d, p, w)8881 8982 l.SetDelegate(d)9083 }
M ui/ui_test.go +102 -0
@@ -1,11 +1,15 @@
11 package ui22 33 import (4+ "strings"45 "testing"6+ "unicode"57 68 "github.com/FelineStateMachine/puzzletea/store"9+ "github.com/FelineStateMachine/puzzletea/theme"710 811 "charm.land/bubbles/v2/list"12+ "github.com/charmbracelet/x/ansi"913 )1014 1115 // --- MenuItem (P0) ---
@@ -92,3 +96,101 @@ func TestInitListDisablesQuitAndFilter(t *testing.T) {
9296 t.Error("expected help to be disabled")9397 }9498 }99+100+func TestThemeListFilterDoesNotSplitTitle(t *testing.T) {101+ longest := longestThemeName(t)102+103+ items := []list.Item{104+ MenuItem{ItemTitle: "Short", Desc: "dark theme"},105+ MenuItem{ItemTitle: longest, Desc: "dark theme"},106+ }107+ l := InitThemeList(items, theme.MaxNameLen+4, 12)108+ l.Select(1)109+ l.SetFilterText(alternatingCasePrefix(longest, 6))110+111+ rendered := ansi.Strip(l.View())112+ if !strings.Contains(strings.ToLower(rendered), strings.ToLower(longest)) {113+ t.Fatalf("expected rendered output to contain full longest theme name %q\nview:\n%s", longest, rendered)114+ }115+116+ nameRunes := []rune(longest)117+ if len(nameRunes) >= 8 {118+ head := string(nameRunes[:4])119+ tail := string(nameRunes[4:8])120+ if strings.Contains(strings.ToLower(rendered), strings.ToLower(head+"\n"+tail)) {121+ t.Fatalf("expected longest theme title not to split across lines\nview:\n%s", rendered)122+ }123+ }124+}125+126+func TestThemeListWidthStableWithSelectionAndFilter(t *testing.T) {127+ longest := longestThemeName(t)128+129+ items := []list.Item{130+ MenuItem{ItemTitle: "Dracula", Desc: "dark theme"},131+ MenuItem{ItemTitle: longest, Desc: "dark theme"},132+ }133+ l := InitThemeList(items, theme.MaxNameLen+4, 12)134+135+ baseline := renderedWidth(l.View())136+ if baseline <= 0 {137+ t.Fatalf("expected positive rendered width, got %d", baseline)138+ }139+140+ l.Select(1)141+ if got := renderedWidth(l.View()); got != baseline {142+ t.Fatalf("expected stable width after selecting longest theme, got %d want %d", got, baseline)143+ }144+145+ l.SetFilterText(alternatingCasePrefix(longest, 6))146+ if got := renderedWidth(l.View()); got != baseline {147+ t.Fatalf("expected stable width with filter applied, got %d want %d", got, baseline)148+ }149+150+ l.Select(0)151+ if got := renderedWidth(l.View()); got != baseline {152+ t.Fatalf("expected stable width after selecting short theme while filtered, got %d want %d", got, baseline)153+ }154+}155+156+func longestThemeName(t *testing.T) string {157+ t.Helper()158+159+ longest := theme.DefaultThemeName160+ themes := theme.AllThemes()161+ if len(themes) == 0 {162+ t.Fatal("expected at least one embedded theme")163+ }164+ for _, th := range themes {165+ if len(th.Name) > len(longest) {166+ longest = th.Name167+ }168+ }169+ return longest170+}171+172+func alternatingCasePrefix(s string, n int) string {173+ r := []rune(s)174+ if n > len(r) {175+ n = len(r)176+ }177+ r = r[:n]178+ for i, ch := range r {179+ if i%2 == 0 {180+ r[i] = unicode.ToLower(ch)181+ } else {182+ r[i] = unicode.ToUpper(ch)183+ }184+ }185+ return string(r)186+}187+188+func renderedWidth(s string) int {189+ maxW := 0190+ for _, line := range strings.Split(s, "\n") {191+ if w := ansi.StringWidth(line); w > maxW {192+ maxW = w193+ }194+ }195+ return maxW196+}