clone

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

Merge pull request #41 from FelineStateMachine/feat/nurikabe-session-improvements

FelineStateMachine committed fd8090b
Add Nurikabe puzzle and harden game session flow
commit
fd8090b7115726eb8333f22161cca1253b68611c
parent
8d31c6212912e45a84c3553744df1be8992b9cd0
parent
3c05f0f6ac8122f4ee8df4196ba00c48d1aa66ca
author
FelineStateMachine <dami.etoile@gmail.com>
55 files changed, +5132 -446
M.gitignore1+
MREADME.md18+-
Aapp/game_session.go38+
Aapp/help_layout_test.go57++
Mapp/model.go48+-
Mapp/spawn.go93++-
Aapp/spawn_test.go59++
Mapp/update.go267++++---
Aapp/update_test.go240+++++++
Mapp/view.go8+-
Mcmd/continue.go19+-
Mcmd/new.go67+-
Mcmd/root.go4+-
Acmd/session.go65++
Mdaily/daily.go24+-
Mgame/game_test.go74++
Mgame/gamer.go14+
Mgame/style.go114+++
Mhashiwokakero/Model.go19+-
Mhashiwokakero/style.go2+-
Mhitori/Model.go7+-
Mjustfile1+
Mlightsout/Model.go7+-
Mnonogram/Model.go6+-
Mnonogram/nonogram_test.go30+
Anurikabe/Export.go85++
Anurikabe/Gamemode.go86++
Anurikabe/Model.go264+++++++
Anurikabe/README.md53+
Anurikabe/generator.go1070++++++++++++++++++++++++++++++
Anurikabe/grid.go179+++++
Anurikabe/help.md38+
Anurikabe/keys.go48+
Anurikabe/mouse.go63++
Anurikabe/nurikabe_test.go747+++++++++++++++++++++
Anurikabe/solver.go681+++++++++++++++++++
Anurikabe/style.go129++++
Mresolve/resolve.go66+-
Mresolve/resolve_test.go6+
Mshikaku/Model.go25+-
Mshikaku/shikaku_test.go86++
Mshikaku/style.go4+-
Mstats/stats.go87+-
Mstats/stats_test.go71++
Mstore/store.go152++--
Msudoku/Model.go7+-
Mtakuzu/Model.go5+-
Mtheme/palette.go14+-
Mtheme/theme_test.go22+
Mui/list.go96++-
Mui/ui_test.go114+++
Avhs/nurikabe.tape68++
Mwordsearch/Model.go1+
Mwordsearch/style.go5+-
Mwordsearch/wordsearch_test.go24+
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](vhs/nurikabe.gif)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
A app/help_layout_test.go +57 -0
@@ -0,0 +1,57 @@
1+package app2+3+import (4+ "strings"5+ "testing"6+7+ "github.com/FelineStateMachine/puzzletea/stats"8+ "github.com/FelineStateMachine/puzzletea/ui"9+10+ "charm.land/lipgloss/v2"11+)12+13+func TestHelpViewportSizeKeepsPanelInsideInsetBounds(t *testing.T) {14+ const (15+ windowWidth = 12016+ windowHeight = 4017+ )18+19+ helpWidth, helpHeight := helpViewportSize(windowWidth, windowHeight)20+ line := strings.Repeat("x", helpWidth)21+ content := strings.TrimSuffix(strings.Repeat(line+"\n", helpHeight), "\n")22+ panel := ui.Panel("Guide", content, "esc back")23+24+ if got := lipgloss.Width(panel); got > windowWidth-(helpPanelInsetX*2) {25+ t.Fatalf("panel width = %d, want <= %d", got, windowWidth-(helpPanelInsetX*2))26+ }27+ if got := lipgloss.Height(panel); got > windowHeight-(helpPanelInsetY*2) {28+ t.Fatalf("panel height = %d, want <= %d", got, windowHeight-(helpPanelInsetY*2))29+ }30+}31+32+func TestHelpViewportSizeNeverFallsBelowOne(t *testing.T) {33+ helpWidth, helpHeight := helpViewportSize(1, 1)34+ if helpWidth != 1 {35+ t.Fatalf("help width = %d, want 1", helpWidth)36+ }37+ if helpHeight != 1 {38+ t.Fatalf("help height = %d, want 1", helpHeight)39+ }40+}41+42+func TestStatsViewportSizeMatchesHelpWidth(t *testing.T) {43+ helpWidth, _ := helpViewportSize(120, 40)44+ statsWidth, _ := statsViewportSize(120, 40, nil)45+ if statsWidth != helpWidth {46+ t.Fatalf("stats width = %d, want %d", statsWidth, helpWidth)47+ }48+}49+50+func TestStatsViewportSizeReservesBannerHeight(t *testing.T) {51+ _, helpHeight := helpViewportSize(120, 40)52+ cards := []stats.Card{{GameType: "Nurikabe"}}53+ _, statsHeight := statsViewportSize(120, 40, cards)54+ if statsHeight != helpHeight-5 {55+ t.Fatalf("stats height = %d, want %d", statsHeight, helpHeight-5)56+ }57+}
M app/model.go +32 -16
@@ -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)
@@ -152,7 +168,7 @@ func newSpinner() spinner.Model {
152168 // InitialModel creates the root TUI model for the main menu.153169 func InitialModel(s *store.Store, cfg *config.Config) model {154170 r := initDebugRenderer()155- l := ui.InitList(GameCategories, "Select Category")171+ l := ui.InitCategoryList(GameCategories, "Select Category")156172 mm := ui.NewMainMenu(mainMenuItems)157173 return model{158174 state: mainMenuView,
@@ -169,7 +185,7 @@ func InitialModel(s *store.Store, cfg *config.Config) model {
169185 // bypassing the menu. Used by CLI flags (--new, --continue).170186 func InitialModelWithGame(s *store.Store, cfg *config.Config, g game.Gamer, activeGameID int64, completionSaved bool) model {171187 r := initDebugRenderer()172- l := ui.InitList(GameCategories, "Select Category")188+ l := ui.InitCategoryList(GameCategories, "Select Category")173189 mm := ui.NewMainMenu(mainMenuItems)174190 return model{175191 state: gameView,
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 +143 -124
@@ -23,12 +23,80 @@ import (
2323 "github.com/charmbracelet/glamour"2424 )2525 26+const (27+ helpPanelInsetX = 228+ helpPanelInsetY = 129+ helpPanelHorizontalTrim = 630+ helpPanelVerticalTrim = 831+)32+33+func helpViewportSize(width, height int) (int, int) {34+ panelWidth := max(width-(helpPanelInsetX*2), 1)35+ panelHeight := max(height-(helpPanelInsetY*2), 1)36+ contentWidth := max(panelWidth-helpPanelHorizontalTrim, 1)37+ contentHeight := max(panelHeight-helpPanelVerticalTrim, 1)38+ return contentWidth, contentHeight39+}40+41+func statsViewportSize(width, height int, cards []stats.Card) (int, int) {42+ contentWidth, _ := helpViewportSize(width, height)43+ panelHeight := max(height-(helpPanelInsetY*2), 1)44+ contentHeight := max(panelHeight-stats.StaticHeight(cards), 1)45+ return contentWidth, contentHeight46+}47+48+func (m model) updateHelpDetailViewport() model {49+ helpWidth, helpHeight := helpViewportSize(m.width, m.height)50+ if m.helpRenderer == nil || m.helpRendererWidth != helpWidth {51+ renderer, err := glamour.NewTermRenderer(52+ glamour.WithAutoStyle(),53+ glamour.WithWordWrap(helpWidth),54+ )55+ if err != nil {56+ log.Printf("failed to create help renderer: %v", err)57+ m.helpRenderer = nil58+ m.helpRendererWidth = 059+ } else {60+ m.helpRenderer = renderer61+ m.helpRendererWidth = helpWidth62+ }63+ }64+65+ rendered := m.helpCategory.Help66+ if m.helpRenderer != nil {67+ out, err := m.helpRenderer.Render(m.helpCategory.Help)68+ if err != nil {69+ log.Printf("failed to render help: %v", err)70+ } else {71+ rendered = out72+ }73+ }74+75+ m.helpViewport = viewport.New(76+ viewport.WithWidth(helpWidth),77+ viewport.WithHeight(helpHeight),78+ )79+ m.helpViewport.SetContent(rendered)80+ return m81+}82+83+func (m model) updateStatsViewport() model {84+ statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.statsCards)85+ m.statsViewport.SetWidth(statsWidth)86+ m.statsViewport.SetHeight(statsHeight)87+ m.statsViewport.SetContent(stats.RenderCardGrid(m.statsCards, statsWidth))88+ return m89+}90+2691 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {2792 var cmd tea.Cmd2893 2994 switch msg := msg.(type) {95+ case spawnCompleteMsg:96+ return m.handleSpawnComplete(msg.jobID, msg.result)3097 case game.SpawnCompleteMsg:31- return m.handleSpawnComplete(msg)98+ // Backward compatibility for callers still using the old message type.99+ return m.handleSpawnComplete(m.spawnJobID, msg)32100 33101 case tea.WindowSizeMsg:34102 m.width = msg.Width
@@ -50,8 +118,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
50118 m.helpSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.helpSelectList)))51119 }52120 if m.state == helpDetailView {53- m.helpViewport.SetWidth(m.width)54- m.helpViewport.SetHeight(m.height - 2)121+ m = m.updateHelpDetailViewport()55122 }56123 if m.state == themeSelectView {57124 const maxVisibleItems = 8
@@ -59,11 +126,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
59126 m.themeList.SetSize(listW, min(m.height, maxVisibleItems*3))60127 }61128 if m.state == statsView {62- cw := stats.ContentWidth(m.width)63- m.statsViewport.SetWidth(cw)64- vpH := stats.ViewportHeight(m.height - stats.StaticHeight(m.statsCards))65- m.statsViewport.SetHeight(vpH)66- m.statsViewport.SetContent(stats.RenderCardGrid(m.statsCards, cw))129+ m = m.updateStatsViewport()67130 }68131 69132 case tea.KeyPressMsg:
@@ -71,6 +134,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
71134 if m.state == generatingView {72135 switch {73136 case key.Matches(msg, rootKeys.Escape):137+ m.cancelActiveSpawn()74138 if m.dailyPending || m.seedPending {75139 m.dailyPending = false76140 m.seedPending = false
@@ -94,7 +158,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
94158 return m.handleEnter()95159 }96160 case key.Matches(msg, rootKeys.Escape):97- return m.handleEscape()161+ if m.state != gameView {162+ return m.handleEscape()163+ }98164 case key.Matches(msg, rootKeys.Quit):99165 m = saveCurrentGame(m, store.StatusAbandoned)100166 return m, tea.Quit
@@ -114,32 +180,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
114180 115181 switch m.state {116182 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- }183+ updateMainMenuCursor(msg, &m.mainMenu)125184 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- }185+ updateMainMenuCursor(msg, &m.playMenu)134186 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- }187+ updateMainMenuCursor(msg, &m.optionsMenu)143188 case seedInputView:144189 m.seedInput, cmd = m.seedInput.Update(msg)145190 case generatingView:
@@ -153,9 +198,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
153198 m.completionSaved = true154199 saveData, err := m.game.GetSave()155200 if err == nil {156- _ = m.store.UpdateSaveState(m.activeGameID, string(saveData))201+ if err := m.store.UpdateSaveState(m.activeGameID, string(saveData)); err != nil {202+ log.Printf("failed to save completion state: %v", err)203+ }204+ }205+ if err := m.store.UpdateStatus(m.activeGameID, store.StatusCompleted); err != nil {206+ log.Printf("failed to mark game completed: %v", err)157207 }158- _ = m.store.UpdateStatus(m.activeGameID, store.StatusCompleted)159208 }160209 case gameSelectView:161210 m.gameSelectList, cmd = m.gameSelectList.Update(msg)
@@ -184,6 +233,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
184233 return m, cmd185234 }186235 236+func updateMainMenuCursor(msg tea.Msg, menu *ui.MainMenu) {237+ keyMsg, ok := msg.(tea.KeyPressMsg)238+ if !ok {239+ return240+ }241+ switch keyMsg.String() {242+ case "up", "k":243+ menu.CursorUp()244+ case "down", "j":245+ menu.CursorDown()246+ }247+}248+187249 func (m model) handleEnter() (tea.Model, tea.Cmd) {188250 switch m.state {189251 case mainMenuView:
@@ -286,27 +348,15 @@ func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {
286348 }287349 288350 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.StatusCompleted351+ // Seed game exists - resume it.352+ var resumed bool353+ m, resumed = m.importAndActivateRecord(*rec)306354 307355 // Resume abandoned seeded games by resetting their status.308- if rec.Status == store.StatusAbandoned {309- _ = m.store.UpdateStatus(rec.ID, store.StatusInProgress)356+ if resumed && rec.Status == store.StatusAbandoned {357+ if err := m.store.UpdateStatus(rec.ID, store.StatusInProgress); err != nil {358+ log.Printf("failed to mark seeded game in progress: %v", err)359+ }310360 }311361 return m, nil312362 }
@@ -319,13 +369,13 @@ func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {
319369 }320370 321371 rng := resolve.RNGFromString(seed)372+ ctx, jobID := m.beginSpawnContext()322373 m.seedPending = true323374 m.seedName = name324375 m.seedGameType = gameType325376 m.seedModeTitle = modeTitle326377 m.state = generatingView327- m.generating = true328- return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng))378+ return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))329379 }330380 331381 func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {
@@ -341,26 +391,14 @@ func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {
341391 342392 if rec != nil {343393 // 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.StatusCompleted394+ var resumed bool395+ m, resumed = m.importAndActivateRecord(*rec)360396 361397 // Resume abandoned dailies by resetting their status.362- if rec.Status == store.StatusAbandoned {363- _ = m.store.UpdateStatus(rec.ID, store.StatusInProgress)398+ if resumed && rec.Status == store.StatusAbandoned {399+ if err := m.store.UpdateStatus(rec.ID, store.StatusInProgress); err != nil {400+ log.Printf("failed to mark daily game in progress: %v", err)401+ }364402 }365403 return m, nil366404 }
@@ -369,14 +407,18 @@ func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {
369407 // Mode selection uses rendezvous hashing (independent of RNG).370408 // RNG is reserved purely for puzzle content generation.371409 spawner, gameType, modeTitle := daily.Mode(today)410+ if spawner == nil {411+ log.Printf("no daily mode available for %s", today.Format("2006-01-02"))412+ return m, nil413+ }372414 rng := daily.RNG(today)415+ ctx, jobID := m.beginSpawnContext()373416 m.dailyPending = true374417 m.dailyName = name375418 m.dailyGameType = gameType376419 m.dailyModeTitle = modeTitle377420 m.state = generatingView378- m.generating = true379- return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng))421+ return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))380422 }381423 382424 func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) {
@@ -392,14 +434,19 @@ func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) {
392434 }393435 394436 func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {395- m.mode, _ = m.modeSelectList.SelectedItem().(game.Mode)396- spawner, ok := m.mode.(game.Spawner)437+ item := m.modeSelectList.SelectedItem()438+ mode, ok := item.(game.Mode)439+ if !ok {440+ return m, nil441+ }442+ spawner, ok := item.(game.Spawner)397443 if !ok {398444 return m, nil399445 }446+ m.selectedModeTitle = mode.Title()447+ ctx, jobID := m.beginSpawnContext()400448 m.state = generatingView401- m.generating = true402- return m, tea.Batch(m.spinner.Tick, spawnCmd(spawner))449+ return m, tea.Batch(m.spinner.Tick, spawnCmd(spawner, ctx, jobID))403450 }404451 405452 func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {
@@ -408,21 +455,7 @@ func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {
408455 return m, nil409456 }410457 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.StatusCompleted458+ m, _ = m.importAndActivateRecord(rec)426459 return m, nil427460 }428461
@@ -432,27 +465,7 @@ func (m model) handleHelpSelectEnter() (tea.Model, tea.Cmd) {
432465 return m, nil433466 }434467 m.helpCategory = cat435-436- if m.helpRenderer == nil || m.helpRendererWidth != m.width {437- renderer, err := glamour.NewTermRenderer(438- glamour.WithAutoStyle(),439- glamour.WithWordWrap(m.width),440- )441- if err != nil {442- log.Printf("failed to create help renderer: %v", err)443- return m, nil444- }445- m.helpRenderer = renderer446- m.helpRendererWidth = m.width447- }448- rendered, err := m.helpRenderer.Render(cat.Help)449- if err != nil {450- log.Printf("failed to render help: %v", err)451- return m, nil452- }453-454- m.helpViewport = viewport.New(viewport.WithWidth(m.width), viewport.WithHeight(m.height-2))455- m.helpViewport.SetContent(rendered)468+ m = m.updateHelpDetailViewport()456469 m.state = helpDetailView457470 return m, nil458471 }
@@ -466,6 +479,7 @@ func (m model) handleEscape() (tea.Model, tea.Cmd) {
466479 case seedInputView:467480 m.state = playMenuView468481 case generatingView:482+ m.cancelActiveSpawn()469483 m.state = modeSelectView470484 case modeSelectView:471485 m.state = gameSelectView
@@ -564,19 +578,24 @@ func (m model) handleStatsEnter() (tea.Model, tea.Cmd) {
564578 log.Printf("failed to get daily streak dates: %v", err)565579 return m, nil566580 }581+ now := time.Now()582+ currentDaily := false583+ rec, err := m.store.GetDailyGame(daily.Name(now))584+ if err != nil {585+ log.Printf("failed to check current daily: %v", err)586+ } else {587+ currentDaily = rec != nil588+ }567589 568590 m.statsCards = stats.BuildCards(catStats, modeStats)569- m.statsProfile = stats.BuildProfileBanner(catStats, modeStats, streakDates, m.store)591+ m.statsProfile = stats.BuildProfileBanner(catStats, modeStats, streakDates, currentDaily)570592 571- // Banner is rendered as static chrome above the viewport; only the572- // card grid scrolls. Compute available height for the viewport after573- // accounting for the panel frame and banner.574- vpHeight := stats.ViewportHeight(m.height - stats.StaticHeight(m.statsCards))593+ statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.statsCards)575594 m.statsViewport = viewport.New(576- viewport.WithWidth(stats.ContentWidth(m.width)),577- viewport.WithHeight(vpHeight),595+ viewport.WithWidth(statsWidth),596+ viewport.WithHeight(statsHeight),578597 )579- m.statsViewport.SetContent(stats.RenderCardGrid(m.statsCards, stats.ContentWidth(m.width)))598+ m.statsViewport.SetContent(stats.RenderCardGrid(m.statsCards, statsWidth))580599 m.state = statsView581600 return m, nil582601 }
A app/update_test.go +240 -0
@@ -0,0 +1,240 @@
1+package app2+3+import (4+ "path/filepath"5+ "testing"6+ "time"7+8+ "charm.land/bubbles/v2/key"9+ "charm.land/bubbles/v2/textinput"10+ tea "charm.land/bubbletea/v2"11+ "github.com/FelineStateMachine/puzzletea/daily"12+ "github.com/FelineStateMachine/puzzletea/game"13+ "github.com/FelineStateMachine/puzzletea/namegen"14+ "github.com/FelineStateMachine/puzzletea/resolve"15+ "github.com/FelineStateMachine/puzzletea/store"16+)17+18+type escapeTrackingGame struct {19+ updateCalls int20+ sawEscape bool21+}22+23+func (g *escapeTrackingGame) GetDebugInfo() string {24+ return ""25+}26+27+func (g *escapeTrackingGame) GetFullHelp() [][]key.Binding {28+ return nil29+}30+31+func (g *escapeTrackingGame) GetSave() ([]byte, error) {32+ return nil, nil33+}34+35+func (g *escapeTrackingGame) IsSolved() bool {36+ return false37+}38+39+func (g *escapeTrackingGame) Reset() game.Gamer {40+ return g41+}42+43+func (g *escapeTrackingGame) SetTitle(string) game.Gamer {44+ return g45+}46+47+func (g *escapeTrackingGame) Init() tea.Cmd {48+ return nil49+}50+51+func (g *escapeTrackingGame) View() string {52+ return ""53+}54+55+func (g *escapeTrackingGame) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {56+ g.updateCalls++57+ keyMsg, ok := msg.(tea.KeyPressMsg)58+ if ok && keyMsg.Code == tea.KeyEscape {59+ g.sawEscape = true60+ }61+ return g, nil62+}63+64+func TestGameViewEscapePassesThroughToGame(t *testing.T) {65+ tracker := &escapeTrackingGame{}66+ m := model{67+ state: gameView,68+ game: tracker,69+ }70+71+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})72+ got := next.(model)73+74+ if got.state != gameView {75+ t.Fatalf("expected to remain in gameView, got %d", got.state)76+ }77+ if tracker.updateCalls != 1 {78+ t.Fatalf("expected game Update to be called once, got %d", tracker.updateCalls)79+ }80+ if !tracker.sawEscape {81+ t.Fatal("expected game to receive escape key")82+ }83+}84+85+func TestImportAndActivateRecordSuccessFlag(t *testing.T) {86+ unknown := model{state: playMenuView}87+ if _, ok := unknown.importAndActivateRecord(store.GameRecord{GameType: "NoSuchGameType"}); ok {88+ t.Fatal("expected unknown game type import to fail")89+ }90+91+ const gameType = "TestImportAndActivateRecordGame"92+ prev, hadPrev := game.Registry[gameType]93+ game.Register(gameType, func(_ []byte) (game.Gamer, error) {94+ return &escapeTrackingGame{}, nil95+ })96+ t.Cleanup(func() {97+ if hadPrev {98+ game.Registry[gameType] = prev99+ return100+ }101+ delete(game.Registry, gameType)102+ })103+104+ rec := store.GameRecord{105+ ID: 42,106+ Name: "resume-me",107+ GameType: gameType,108+ SaveState: "{}",109+ Status: store.StatusCompleted,110+ }111+112+ next, ok := (model{113+ state: playMenuView,114+ width: 80,115+ height: 24,116+ showFullHelp: true,117+ }).importAndActivateRecord(rec)118+ if !ok {119+ t.Fatal("expected known game type import to succeed")120+ }121+ if next.state != gameView {122+ t.Fatalf("state = %d, want %d (gameView)", next.state, gameView)123+ }124+ if next.activeGameID != rec.ID {125+ t.Fatalf("activeGameID = %d, want %d", next.activeGameID, rec.ID)126+ }127+ if !next.completionSaved {128+ t.Fatal("expected completionSaved to be true for completed record")129+ }130+ if next.game == nil {131+ t.Fatal("expected game to be activated")132+ }133+}134+135+func TestHandleSeedConfirmDoesNotResumeStatusWhenImportFails(t *testing.T) {136+ s := openAppTestStore(t)137+ seed := "import-failure-seed"138+ nameRNG := resolve.RNGFromString("name:" + seed)139+ name := seed + " - " + namegen.GenerateSeeded(nameRNG)140+ rec := &store.GameRecord{141+ Name: name,142+ GameType: "NoSuchGameType",143+ Mode: "Any",144+ InitialState: "{}",145+ SaveState: "{}",146+ Status: store.StatusAbandoned,147+ }148+ if err := s.CreateGame(rec); err != nil {149+ t.Fatal(err)150+ }151+152+ ti := textinput.New()153+ ti.SetValue(seed)154+ m := model{155+ state: seedInputView,156+ store: s,157+ seedInput: ti,158+ }159+160+ next, _ := m.handleSeedConfirm()161+ got := next.(model)162+ if got.state != seedInputView {163+ t.Fatalf("state = %d, want %d (seedInputView)", got.state, seedInputView)164+ }165+166+ saved, err := s.GetDailyGame(name)167+ if err != nil {168+ t.Fatal(err)169+ }170+ if saved == nil {171+ t.Fatal("expected seeded record to exist")172+ }173+ if saved.Status != store.StatusAbandoned {174+ t.Fatalf("status = %q, want %q", saved.Status, store.StatusAbandoned)175+ }176+}177+178+func TestHandleDailyPuzzleDoesNotResumeStatusWhenImportFails(t *testing.T) {179+ s := openAppTestStore(t)180+ now := time.Now()181+ dates := []time.Time{182+ now.AddDate(0, 0, -1),183+ now,184+ now.AddDate(0, 0, 1),185+ }186+187+ seen := map[string]bool{}188+ for _, d := range dates {189+ name := daily.Name(d)190+ if seen[name] {191+ continue192+ }193+ seen[name] = true194+ rec := &store.GameRecord{195+ Name: name,196+ GameType: "NoSuchGameType",197+ Mode: "Any",198+ InitialState: "{}",199+ SaveState: "{}",200+ Status: store.StatusAbandoned,201+ }202+ if err := s.CreateGame(rec); err != nil {203+ t.Fatal(err)204+ }205+ }206+207+ m := model{208+ state: playMenuView,209+ store: s,210+ }211+212+ next, _ := m.handleDailyPuzzle()213+ got := next.(model)214+ if got.state != playMenuView {215+ t.Fatalf("state = %d, want %d (playMenuView)", got.state, playMenuView)216+ }217+218+ for name := range seen {219+ saved, err := s.GetDailyGame(name)220+ if err != nil {221+ t.Fatal(err)222+ }223+ if saved == nil {224+ t.Fatalf("expected daily record %q to exist", name)225+ }226+ if saved.Status != store.StatusAbandoned {227+ t.Fatalf("record %q status = %q, want %q", name, saved.Status, store.StatusAbandoned)228+ }229+ }230+}231+232+func openAppTestStore(t *testing.T) *store.Store {233+ t.Helper()234+ s, err := store.Open(filepath.Join(t.TempDir(), "test.db"))235+ if err != nil {236+ t.Fatal(err)237+ }238+ t.Cleanup(func() { _ = s.Close() })239+ return s240+}
M app/view.go +3 -5
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/game_test.go +74 -0
@@ -5,6 +5,7 @@ import (
55 "testing"66 77 tea "charm.land/bubbletea/v2"8+ "charm.land/lipgloss/v2"89 )910 1011 // --- Cursor.Move (P0) ---
@@ -300,6 +301,79 @@ func TestTitleBarView(t *testing.T) {
300301 })301302 }302303 304+// --- ComposeGameView (P1) ---305+306+func TestComposeGameView(t *testing.T) {307+ t.Run("secondary width is fixed at 150 percent of primary", func(t *testing.T) {308+ primary := lipgloss.NewStyle().Width(12).Render("grid")309+ view := ComposeGameView(310+ "title-width",311+ primary,312+ "this secondary row is much wider than the primary area",313+ )314+315+ if got, want := lipgloss.Width(view), 18; got != want {316+ t.Errorf("width = %d, want %d", got, want)317+ }318+ })319+320+ t.Run("long secondary wraps and increases height", func(t *testing.T) {321+ primary := lipgloss.NewStyle().Width(10).Render("grid")322+323+ short := ComposeGameView("title-wrap", primary, "short row")324+ long := ComposeGameView(325+ "title-wrap",326+ primary,327+ "this secondary row should wrap to multiple lines when constrained",328+ )329+330+ if got, want := lipgloss.Width(long), 15; got != want {331+ t.Errorf("width = %d, want %d", got, want)332+ }333+ if got, want := lipgloss.Width(short), 15; got != want {334+ t.Errorf("width = %d, want %d", got, want)335+ }336+ if lipgloss.Height(long) <= lipgloss.Height(short) {337+ t.Errorf("height = %d, want > %d", lipgloss.Height(long), lipgloss.Height(short))338+ }339+ })340+341+ t.Run("wrap prefers double-space boundaries", func(t *testing.T) {342+ row := "alpha: one beta: two gamma: three"343+ got := wrapRowOnDoubleSpace(row, 18)344+ want := "alpha: one\nbeta: two\ngamma: three"345+ if got != want {346+ t.Fatalf("wrapRowOnDoubleSpace() = %q, want %q", got, want)347+ }348+ })349+350+ t.Run("stable row reserves max variant height", func(t *testing.T) {351+ primary := lipgloss.NewStyle().Width(10).Render("grid")352+ longCurrent := ComposeGameViewRows(353+ "title-pad",354+ primary,355+ StableRow(356+ "alpha: one beta: two gamma: three",357+ "alpha: one",358+ "alpha: one beta: two gamma: three",359+ ),360+ )361+ shortCurrent := ComposeGameViewRows(362+ "title-pad",363+ primary,364+ StableRow(365+ "alpha: one",366+ "alpha: one",367+ "alpha: one beta: two gamma: three",368+ ),369+ )370+371+ if got, want := lipgloss.Height(shortCurrent), lipgloss.Height(longCurrent); got != want {372+ t.Fatalf("height(short) = %d, want %d", got, want)373+ }374+ })375+}376+303377 // --- DebugHeader (P3) ---304378 305379 func TestDebugHeader(t *testing.T) {
M game/gamer.go +14 -0
M game/style.go +114 -0
@@ -72,6 +72,120 @@ func TitleBarView(gameName, modeName string, solved bool) string {
7272 return lipgloss.NewStyle().PaddingBottom(1).Render(title)7373 }7474 75+// ComposeGameView joins title + primary content + optional secondary rows.76+// Secondary rows are width-anchored to primary so long help/info text wraps77+// instead of widening the full layout and shifting centered views.78+func ComposeGameView(title, primary string, secondary ...string) string {79+ rows := make([]SecondaryRow, 0, len(secondary))80+ for _, row := range secondary {81+ rows = append(rows, StaticRow(row))82+ }83+ return ComposeGameViewRows(title, primary, rows...)84+}85+86+// SecondaryRow defines one auxiliary row below the main game grid.87+// Variants can be provided to reserve the tallest wrapped height88+// up-front and avoid layout shift when row content changes.89+type SecondaryRow struct {90+ Current string91+ Variants []string92+}93+94+func StaticRow(row string) SecondaryRow {95+ return SecondaryRow{Current: row}96+}97+98+func StableRow(current string, variants ...string) SecondaryRow {99+ return SecondaryRow{Current: current, Variants: variants}100+}101+102+// ComposeGameViewRows joins title + primary content + optional secondary rows.103+// Secondary rows are rendered at 150% of primary width to reduce wrapping.104+// If variants are provided for a row, the row reserves the max wrapped height105+// across the variants and current value to avoid height jitter.106+func ComposeGameViewRows(title, primary string, rows ...SecondaryRow) string {107+ sections := []string{title, primary}108+ primaryWidth := lipgloss.Width(primary)109+ if primaryWidth <= 0 {110+ for _, row := range rows {111+ sections = append(sections, row.Current)112+ }113+ return lipgloss.JoinVertical(lipgloss.Center, sections...)114+ }115+116+ secondaryWidth := primaryWidth + (primaryWidth / 2)117+ rowStyle := lipgloss.NewStyle().Width(secondaryWidth).AlignHorizontal(lipgloss.Center)118+ for _, row := range rows {119+ rendered := wrapAndPadSecondary(row, secondaryWidth)120+ sections = append(sections, rowStyle.Render(rendered))121+ }122+123+ return lipgloss.JoinVertical(lipgloss.Center, sections...)124+}125+126+func wrapAndPadSecondary(row SecondaryRow, width int) string {127+ current := wrapRowOnDoubleSpace(row.Current, width)128+ target := lipgloss.Height(current)129+ for _, variant := range row.Variants {130+ h := lipgloss.Height(wrapRowOnDoubleSpace(variant, width))131+ if h > target {132+ target = h133+ }134+ }135+ if h := lipgloss.Height(current); target > h {136+ current += strings.Repeat("\n", target-h)137+ }138+ return current139+}140+141+func wrapRowOnDoubleSpace(row string, width int) string {142+ if width <= 0 || !strings.Contains(row, " ") {143+ return row144+ }145+146+ lines := strings.Split(row, "\n")147+ var out []string148+ for _, line := range lines {149+ out = append(out, wrapLineOnDoubleSpace(line, width)...)150+ }151+ return strings.Join(out, "\n")152+}153+154+func wrapLineOnDoubleSpace(line string, width int) []string {155+ if width <= 0 || lipgloss.Width(line) <= width || !strings.Contains(line, " ") {156+ return []string{line}157+ }158+159+ parts := strings.Split(line, " ")160+ var (161+ wrapped []string162+ current string163+ )164+ for _, part := range parts {165+ if part == "" {166+ continue167+ }168+ if current == "" {169+ current = part170+ continue171+ }172+ candidate := current + " " + part173+ if lipgloss.Width(candidate) <= width {174+ current = candidate175+ continue176+ }177+ wrapped = append(wrapped, current)178+ current = part179+ }180+ if current != "" {181+ wrapped = append(wrapped, current)182+ }183+ if len(wrapped) == 0 {184+ return []string{line}185+ }186+ return wrapped187+}188+75189 // DebugHeader returns the markdown heading and property table header for debug info.76190 // rows is a list of [key, value] pairs for the "Game State" table.77191 func DebugHeader(title string, rows [][2]string) string {
M hashiwokakero/Model.go +11 -8
M hashiwokakero/style.go +1 -1
M hitori/Model.go +5 -2
M justfile +1 -0
M lightsout/Model.go +5 -2
M nonogram/Model.go +5 -1
M nonogram/nonogram_test.go +30 -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 +264 -0
@@ -0,0 +1,264 @@
1+package nurikabe2+3+import (4+ "fmt"5+6+ "charm.land/bubbles/v2/key"7+ tea "charm.land/bubbletea/v2"8+ "github.com/FelineStateMachine/puzzletea/game"9+)10+11+type Model struct {12+ width, height int13+ clues clueGrid14+ marks grid15+ initialMarks grid16+ cursor game.Cursor17+ solved bool18+ conflicts [][]bool19+ keys KeyMap20+ modeTitle string21+ showFullHelp bool22+23+ dragging bool24+ dragTarget cellState25+26+ termWidth, termHeight int27+ originX, originY int28+ originValid bool29+30+ lastMouseX, lastMouseY int31+ lastMouseBtn string32+ lastMouseGridCol int33+ lastMouseGridRow int34+ lastMouseHit bool35+}36+37+var _ game.Gamer = Model{}38+39+func New(mode NurikabeMode, puzzle Puzzle) (game.Gamer, error) {40+ if puzzle.Width != mode.Width || puzzle.Height != mode.Height {41+ return Model{}, fmt.Errorf("puzzle size %dx%d does not match mode %dx%d", puzzle.Width, puzzle.Height, mode.Width, mode.Height)42+ }43+ if err := validateClues(puzzle.Clues, puzzle.Width, puzzle.Height); err != nil {44+ return Model{}, err45+ }46+47+ marks := newGrid(puzzle.Width, puzzle.Height, unknownCell)48+ for y := range puzzle.Height {49+ for x := range puzzle.Width {50+ if puzzle.Clues[y][x] > 0 {51+ marks[y][x] = islandCell52+ }53+ }54+ }55+56+ m := Model{57+ width: puzzle.Width,58+ height: puzzle.Height,59+ clues: cloneClues(puzzle.Clues),60+ marks: marks,61+ initialMarks: marks.clone(),62+ cursor: game.Cursor{X: 0, Y: 0},63+ keys: DefaultKeyMap,64+ modeTitle: mode.Title(),65+ dragTarget: unknownCell,66+ }67+ m.recomputeDerivedState()68+ return m, nil69+}70+71+func (m Model) Init() tea.Cmd {72+ return nil73+}74+75+func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {76+ switch msg := msg.(type) {77+ case game.HelpToggleMsg:78+ m.showFullHelp = msg.Show79+ m.originValid = false80+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+ current := m.marks[m.cursor.Y][m.cursor.X]95+ m.setCellAtCursor(m.islandTarget(current))96+ }97+ case key.Matches(msg, m.keys.Clear):98+ if !m.solved {99+ m.setCellAtCursor(unknownCell)100+ }101+ default:102+ m.cursor.Move(m.keys.CursorKeyMap, msg, m.width-1, m.height-1)103+ }104+105+ case tea.MouseClickMsg:106+ m.lastMouseX, m.lastMouseY = msg.X, msg.Y107+ m.lastMouseBtn = msg.String()108+ col, row, ok := m.screenToGrid(msg.X, msg.Y)109+ m.lastMouseGridCol, m.lastMouseGridRow = col, row110+ m.lastMouseHit = ok111+ if m.solved || !ok {112+ break113+ }114+ m.cursor.X, m.cursor.Y = col, row115+ switch msg.Button {116+ case tea.MouseLeft:117+ target := m.leftClickTarget()118+ m.dragging = true119+ m.dragTarget = target120+ m.setCellAtCursor(target)121+ case tea.MouseRight:122+ target := m.rightClickTarget(m.marks[row][col])123+ m.dragging = true124+ m.dragTarget = target125+ m.setCellAtCursor(target)126+ }127+128+ case tea.MouseMotionMsg:129+ if m.solved || !m.dragging {130+ break131+ }132+ col, row, ok := m.screenToGrid(msg.X, msg.Y)133+ if !ok {134+ break135+ }136+ if col == m.cursor.X && row == m.cursor.Y {137+ break138+ }139+ m.cursor.X, m.cursor.Y = col, row140+ m.setCellAtCursor(m.dragTarget)141+142+ case tea.MouseReleaseMsg:143+ m.dragging = false144+ m.dragTarget = unknownCell145+ }146+147+ m.updateKeyBindings()148+ return m, nil149+}150+151+func (m *Model) setCellAtCursor(state cellState) {152+ x, y := m.cursor.X, m.cursor.Y153+ if isClueCell(m.clues, x, y) {154+ // Clue cells are immutable island cells.155+ m.marks[y][x] = islandCell156+ return157+ }158+ m.marks[y][x] = state159+ m.afterBoardChange()160+}161+162+func (m *Model) afterBoardChange() {163+ wasSolved := m.solved164+ m.recomputeDerivedState()165+ if wasSolved != m.solved {166+ m.originValid = false167+ }168+}169+170+func (m *Model) recomputeDerivedState() {171+ m.solved = isSolvedGrid(m.marks, m.clues)172+ m.conflicts = computeConflicts(m.marks, m.clues)173+}174+175+func (m Model) leftClickTarget() cellState {176+ return seaCell177+}178+179+func (m Model) rightClickTarget(current cellState) cellState {180+ return m.islandTarget(current)181+}182+183+func (m Model) islandTarget(current cellState) cellState {184+ if current == islandCell {185+ return unknownCell186+ }187+ return islandCell188+}189+190+func (m Model) View() string {191+ title := game.TitleBarView("Nurikabe", m.modeTitle, m.solved)192+ grid := gridView(m)193+ if m.solved {194+ return game.ComposeGameView(title, grid)195+ }196+197+ status := statusBarView(m.showFullHelp)198+ return game.ComposeGameViewRows(title, grid, game.StableRow(status, statusBarView(false), statusBarView(true)))199+}200+201+func (m Model) SetTitle(t string) game.Gamer {202+ m.modeTitle = t203+ return m204+}205+206+func (m Model) IsSolved() bool {207+ return m.solved208+}209+210+func (m Model) Reset() game.Gamer {211+ m.marks = m.initialMarks.clone()212+ m.cursor = game.Cursor{}213+ m.dragging = false214+ m.dragTarget = unknownCell215+ m.recomputeDerivedState()216+ m.originValid = false217+ return m218+}219+220+func (m Model) GetDebugInfo() string {221+ status := "In Progress"222+ if m.solved {223+ status = "Solved"224+ }225+226+ unknownCount := 0227+ seaCount := 0228+ islandCount := 0229+ conflictCount := 0230+ for y := range m.height {231+ for x := range m.width {232+ switch m.marks[y][x] {233+ case unknownCell:234+ unknownCount++235+ case seaCell:236+ seaCount++237+ case islandCell:238+ islandCount++239+ }240+ if m.conflicts[y][x] {241+ conflictCount++242+ }243+ }244+ }245+246+ ox, oy := m.gridOrigin()247+ hit := "miss"248+ if m.lastMouseHit {249+ hit = fmt.Sprintf("(%d, %d)", m.lastMouseGridCol, m.lastMouseGridRow)250+ }251+252+ return game.DebugHeader("Nurikabe", [][2]string{253+ {"Status", status},254+ {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},255+ {"Grid Size", fmt.Sprintf("%d x %d", m.width, m.height)},256+ {"Clues", fmt.Sprintf("%d", countClues(m.clues))},257+ {"Cells", fmt.Sprintf("unknown=%d sea=%d island=%d", unknownCount, seaCount, islandCount)},258+ {"Conflicts", fmt.Sprintf("%d", conflictCount)},259+ {"Term Size", fmt.Sprintf("%d x %d", m.termWidth, m.termHeight)},260+ {"Grid Origin", fmt.Sprintf("(%d, %d)", ox, oy)},261+ {"Last Mouse", fmt.Sprintf("screen=(%d, %d) btn=%s grid=%s", m.lastMouseX, m.lastMouseY, m.lastMouseBtn, hit)},262+ {"Drag", fmt.Sprintf("active=%v target=%q", m.dragging, rune(m.dragTarget))},263+ })264+}
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 +747 -0
@@ -0,0 +1,747 @@
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+ next, _ = m.Update(tea.KeyPressMsg{Code: 'z', Text: "z"})284+ m = next.(Model)285+ if m.marks[1][1] != unknownCell {286+ t.Fatalf("expected unknown at (1,1), got %q", rune(m.marks[1][1]))287+ }288+}289+290+func TestModelMouseDragPaint(t *testing.T) {291+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)292+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(293+ []int{2, 0, 0},294+ []int{0, 0, 0},295+ []int{0, 0, 1},296+ )}297+ g, err := New(mode, p)298+ if err != nil {299+ t.Fatalf("New error: %v", err)300+ }301+ m := g.(Model)302+303+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})304+ m = next.(Model)305+ ox, oy := m.gridOrigin()306+307+ click := tea.MouseClickMsg{X: ox + cellWidth, Y: oy + 1, Button: tea.MouseLeft}308+ next, _ = m.Update(click)309+ m = next.(Model)310+311+ drag := tea.MouseMotionMsg{X: ox + 2*cellWidth, Y: oy + 1, Button: tea.MouseLeft}312+ next, _ = m.Update(drag)313+ m = next.(Model)314+315+ next, _ = m.Update(tea.MouseReleaseMsg{})316+ m = next.(Model)317+318+ if m.marks[1][1] != seaCell || m.marks[1][2] != seaCell {319+ t.Fatalf("drag paint failed: row= %q", string([]rune{rune(m.marks[1][0]), rune(m.marks[1][1]), rune(m.marks[1][2])}))320+ }321+}322+323+func TestModelMouseClickMirrorsKeyboard(t *testing.T) {324+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)325+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(326+ []int{2, 0, 0},327+ []int{0, 0, 0},328+ []int{0, 0, 1},329+ )}330+ g, err := New(mode, p)331+ if err != nil {332+ t.Fatalf("New error: %v", err)333+ }334+ m := g.(Model)335+336+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})337+ m = next.(Model)338+ ox, oy := m.gridOrigin()339+340+ cellX := ox + cellWidth341+ cellY := oy + 1342+343+ next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseLeft})344+ m = next.(Model)345+ if m.marks[1][1] != seaCell {346+ t.Fatalf("left click from unknown should set sea, got %q", rune(m.marks[1][1]))347+ }348+349+ next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseLeft})350+ m = next.(Model)351+ if m.marks[1][1] != seaCell {352+ t.Fatalf("left click from sea should keep sea, got %q", rune(m.marks[1][1]))353+ }354+355+ next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseRight})356+ m = next.(Model)357+ if m.marks[1][1] != islandCell {358+ t.Fatalf("right click should set island, got %q", rune(m.marks[1][1]))359+ }360+361+ next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseRight})362+ m = next.(Model)363+ if m.marks[1][1] != unknownCell {364+ t.Fatalf("right click from island should set unknown, got %q", rune(m.marks[1][1]))365+ }366+367+ next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseLeft})368+ m = next.(Model)369+ if m.marks[1][1] != seaCell {370+ t.Fatalf("left click from unknown should set sea, got %q", rune(m.marks[1][1]))371+ }372+}373+374+func TestModelMouseRightClickTogglesIsland(t *testing.T) {375+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)376+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(377+ []int{2, 0, 0},378+ []int{0, 0, 0},379+ []int{0, 0, 1},380+ )}381+ g, err := New(mode, p)382+ if err != nil {383+ t.Fatalf("New error: %v", err)384+ }385+ m := g.(Model)386+387+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})388+ m = next.(Model)389+ ox, oy := m.gridOrigin()390+391+ cellX := ox + cellWidth392+ cellY := oy + 1393+394+ next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseRight})395+ m = next.(Model)396+ if m.marks[1][1] != islandCell {397+ t.Fatalf("expected island after first right click, got %q", rune(m.marks[1][1]))398+ }399+400+ next, _ = m.Update(tea.MouseClickMsg{X: cellX, Y: cellY, Button: tea.MouseRight})401+ m = next.(Model)402+ if m.marks[1][1] != unknownCell {403+ t.Fatalf("expected unknown after second right click, got %q", rune(m.marks[1][1]))404+ }405+}406+407+func TestModelMouseRightDragPaintIsland(t *testing.T) {408+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)409+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(410+ []int{2, 0, 0},411+ []int{0, 0, 0},412+ []int{0, 0, 1},413+ )}414+ g, err := New(mode, p)415+ if err != nil {416+ t.Fatalf("New error: %v", err)417+ }418+ m := g.(Model)419+420+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})421+ m = next.(Model)422+ ox, oy := m.gridOrigin()423+424+ next, _ = m.Update(tea.MouseClickMsg{X: ox + cellWidth, Y: oy + 1, Button: tea.MouseRight})425+ m = next.(Model)426+427+ next, _ = m.Update(tea.MouseMotionMsg{X: ox + 2*cellWidth, Y: oy + 1, Button: tea.MouseRight})428+ m = next.(Model)429+430+ next, _ = m.Update(tea.MouseReleaseMsg{})431+ m = next.(Model)432+433+ if m.marks[1][1] != islandCell || m.marks[1][2] != islandCell {434+ 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])}))435+ }436+}437+438+func TestModelMouseRightDragClearsIsland(t *testing.T) {439+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)440+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(441+ []int{2, 0, 0},442+ []int{0, 0, 0},443+ []int{0, 0, 1},444+ )}445+ g, err := New(mode, p)446+ if err != nil {447+ t.Fatalf("New error: %v", err)448+ }449+ m := g.(Model)450+ m.cursor = pointToCursor(1, 1)451+ m.setCellAtCursor(islandCell)452+ m.cursor = pointToCursor(2, 1)453+ m.setCellAtCursor(islandCell)454+455+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})456+ m = next.(Model)457+ ox, oy := m.gridOrigin()458+459+ next, _ = m.Update(tea.MouseClickMsg{X: ox + cellWidth, Y: oy + 1, Button: tea.MouseRight})460+ m = next.(Model)461+462+ next, _ = m.Update(tea.MouseMotionMsg{X: ox + 2*cellWidth, Y: oy + 1, Button: tea.MouseRight})463+ m = next.(Model)464+465+ next, _ = m.Update(tea.MouseReleaseMsg{})466+ m = next.(Model)467+468+ if m.marks[1][1] != unknownCell || m.marks[1][2] != unknownCell {469+ t.Fatalf("right drag from island should clear to unknown: row= %q", string([]rune{rune(m.marks[1][0]), rune(m.marks[1][1]), rune(m.marks[1][2])}))470+ }471+}472+473+func TestModelMouseLeftDragPaintSeaFromSea(t *testing.T) {474+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)475+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(476+ []int{2, 0, 0},477+ []int{0, 0, 0},478+ []int{0, 0, 1},479+ )}480+ g, err := New(mode, p)481+ if err != nil {482+ t.Fatalf("New error: %v", err)483+ }484+ m := g.(Model)485+ m.cursor = pointToCursor(1, 1)486+ m.setCellAtCursor(seaCell)487+488+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})489+ m = next.(Model)490+ ox, oy := m.gridOrigin()491+492+ next, _ = m.Update(tea.MouseClickMsg{X: ox + cellWidth, Y: oy + 1, Button: tea.MouseLeft})493+ m = next.(Model)494+495+ next, _ = m.Update(tea.MouseMotionMsg{X: ox + 2*cellWidth, Y: oy + 1, Button: tea.MouseLeft})496+ m = next.(Model)497+498+ next, _ = m.Update(tea.MouseReleaseMsg{})499+ m = next.(Model)500+501+ if m.marks[1][1] != seaCell || m.marks[1][2] != seaCell {502+ t.Fatalf("left drag from sea should paint sea: row= %q", string([]rune{rune(m.marks[1][0]), rune(m.marks[1][1]), rune(m.marks[1][2])}))503+ }504+}505+506+func TestHelpToggleInvalidatesOriginCache(t *testing.T) {507+ mode := NewMode("Mini", "test", 3, 3, 0.3, 5)508+ p := Puzzle{Width: 3, Height: 3, Clues: makeClues(509+ []int{2, 0, 0},510+ []int{0, 0, 0},511+ []int{0, 0, 1},512+ )}513+ g, err := New(mode, p)514+ if err != nil {515+ t.Fatalf("New error: %v", err)516+ }517+ m := g.(Model)518+ m.originValid = true519+ m.originX = 5520+ m.originY = 7521+522+ next, _ := m.Update(game.HelpToggleMsg{Show: true})523+ got := next.(Model)524+525+ if !got.showFullHelp {526+ t.Fatal("expected showFullHelp to be true")527+ }528+ if got.originValid {529+ t.Fatal("expected origin cache to be invalidated")530+ }531+}532+533+// --- Seeded generation determinism (P1) ---534+535+func TestGenerateSeededDeterministicConcurrent(t *testing.T) {536+ mode := Modes[0].(NurikabeMode)537+ rng1 := rand.New(rand.NewPCG(11, 29))538+ rng2 := rand.New(rand.NewPCG(11, 29))539+ rng3 := rand.New(rand.NewPCG(11, 29))540+541+ p1, err := GenerateSeeded(mode, rng1)542+ if err != nil {543+ t.Fatalf("GenerateSeeded #1 error: %v", err)544+ }545+ p2, err := GenerateSeeded(mode, rng2)546+ if err != nil {547+ t.Fatalf("GenerateSeeded #2 error: %v", err)548+ }549+ p3, err := GenerateSeeded(mode, rng3)550+ if err != nil {551+ t.Fatalf("GenerateSeeded #3 error: %v", err)552+ }553+554+ if serializeClues(p1.Clues) != serializeClues(p2.Clues) {555+ t.Fatal("seeded generation is not deterministic")556+ }557+ if serializeClues(p1.Clues) != serializeClues(p3.Clues) {558+ t.Fatal("seeded generation diverged across repeated runs")559+ }560+}561+562+// --- Generator runtime and validity (P2) ---563+564+func TestGenerateReturnsWithin10SecondsAllModes(t *testing.T) {565+ if testing.Short() {566+ t.Skip("skipping slow generator budget test in short mode")567+ }568+569+ for i, item := range Modes {570+ mode := item.(NurikabeMode)571+ t.Run(mode.Title(), func(t *testing.T) {572+ start := time.Now()573+ rng := rand.New(rand.NewPCG(uint64(100+i), uint64(999+i)))574+ _, err := GenerateSeeded(mode, rng)575+ if err != nil {576+ t.Fatalf("GenerateSeeded error: %v", err)577+ }578+ if elapsed := time.Since(start); elapsed > generationHardTimeout+500*time.Millisecond {579+ t.Fatalf("generation exceeded budget for %s: %s", mode.Title(), elapsed)580+ }581+ })582+ }583+}584+585+func TestConstructedCandidateValidCompletedBoard(t *testing.T) {586+ mode := Modes[2].(NurikabeMode) // Medium587+ profile := modeIslandProfile(mode)588+ clueTarget := int(float64(mode.Width*mode.Height) * mode.ClueDensity)589+ if clueTarget < 2 {590+ clueTarget = 2591+ }592+593+ rng := rand.New(rand.NewPCG(901, 1777))594+ candidate, err := buildCandidateByCarving(mode, clueTarget, profile, rng, 0)595+ if err != nil {596+ t.Fatalf("buildCandidateByCarving error: %v", err)597+ }598+599+ if err := validateClues(candidate.puzzle.Clues, candidate.puzzle.Width, candidate.puzzle.Height); err != nil {600+ t.Fatalf("invalid candidate clues: %v", err)601+ }602+603+ solved, _, err := CountSolutions(candidate.puzzle, 1, 2000000)604+ if err != nil {605+ t.Fatalf("CountSolutions error: %v", err)606+ }607+ if solved < 1 {608+ t.Fatalf("expected constructed candidate to be solvable, got %d", solved)609+ }610+}611+612+func TestGenerateSeededReturnsSolvable(t *testing.T) {613+ if testing.Short() {614+ t.Skip("skipping slow generator solvability test in short mode")615+ }616+617+ for i, item := range Modes {618+ mode := item.(NurikabeMode)619+ t.Run(mode.Title(), func(t *testing.T) {620+ rng := rand.New(rand.NewPCG(uint64(500+i), uint64(1200+i)))621+ p, err := GenerateSeeded(mode, rng)622+ if err != nil {623+ t.Fatalf("GenerateSeeded error: %v", err)624+ }625+ if err := validateClues(p.Clues, p.Width, p.Height); err != nil {626+ t.Fatalf("invalid generated puzzle clues: %v", err)627+ }628+629+ nodeLimit := max(300000, generationNodeLimit(mode)*8)630+ count, _, err := CountSolutionsContext(context.Background(), p, 1, nodeLimit)631+ if err != nil {632+ if errors.Is(err, errNodeLimit) {633+ t.Skipf("solver node cap reached at %d for %s; generation remains solvable-by-construction", nodeLimit, mode.Title())634+ }635+ t.Fatalf("CountSolutionsContext error: %v", err)636+ }637+ if count < 1 {638+ t.Fatalf("expected at least one solution, got %d", count)639+ }640+ })641+ }642+}643+644+func TestGenerateSeededAvoidsSingletonFlood(t *testing.T) {645+ if testing.Short() {646+ t.Skip("skipping slow singleton distribution test in short mode")647+ }648+649+ for i, item := range Modes {650+ mode := item.(NurikabeMode)651+ t.Run(mode.Title(), func(t *testing.T) {652+ rng := rand.New(rand.NewPCG(uint64(700+i), uint64(1700+i)))653+ p, err := GenerateSeeded(mode, rng)654+ if err != nil {655+ t.Fatalf("GenerateSeeded error: %v", err)656+ }657+658+ clueCount := 0659+ singletons := 0660+ for y := 0; y < p.Height; y++ {661+ for x := 0; x < p.Width; x++ {662+ if p.Clues[y][x] <= 0 {663+ continue664+ }665+ clueCount++666+ if p.Clues[y][x] == 1 {667+ singletons++668+ }669+ }670+ }671+ if clueCount == 0 {672+ t.Fatal("expected at least one clue")673+ }674+675+ profile := modeIslandProfile(mode)676+ ratio := float64(singletons) / float64(clueCount)677+ // Keep a small tolerance for fallback cases under the 10s SLA.678+ if ratio > profile.maxSingletonRatio+0.12 {679+ t.Fatalf(680+ "singleton ratio too high for %s: got %.2f (%d/%d), target <= %.2f",681+ mode.Title(),682+ ratio,683+ singletons,684+ clueCount,685+ profile.maxSingletonRatio+0.12,686+ )687+ }688+ })689+ }690+}691+692+func TestUniqueFirstFallbackPolicy(t *testing.T) {693+ mode := Modes[1].(NurikabeMode) // Easy694+ rng := rand.New(rand.NewPCG(2026, 219))695+ p, err := GenerateSeeded(mode, rng)696+ if err != nil {697+ t.Fatalf("GenerateSeeded error: %v", err)698+ }699+700+ count, _, err := CountSolutionsContext(context.Background(), p, 2, generationNodeLimit(mode))701+ if err != nil {702+ t.Fatalf("CountSolutionsContext error: %v", err)703+ }704+ if count < 1 {705+ t.Fatalf("expected generated puzzle to be solvable, got %d", count)706+ }707+ if count > 2 {708+ t.Fatalf("solution count should be capped by limit: got %d", count)709+ }710+}711+712+func TestGenerateSeededCancellationFast(t *testing.T) {713+ mode := Modes[4].(NurikabeMode) // Expert gives enough work to exercise cancel path.714+ ctx, cancel := context.WithCancel(context.Background())715+ cancel()716+717+ start := time.Now()718+ rng := rand.New(rand.NewPCG(33, 77))719+ _, err := GenerateSeededWithContext(ctx, mode, rng)720+ if err == nil {721+ t.Fatal("expected cancellation error, got nil")722+ }723+ if !errors.Is(err, context.Canceled) {724+ t.Fatalf("expected context.Canceled, got %v", err)725+ }726+ if elapsed := time.Since(start); elapsed > 150*time.Millisecond {727+ t.Fatalf("cancellation should return quickly, took %s", elapsed)728+ }729+}730+731+func TestNoGoroutineLeakOnCancel(t *testing.T) {732+ mode := Modes[4].(NurikabeMode)733+ before := runtime.NumGoroutine()734+735+ for i := 0; i < 8; i++ {736+ ctx, cancel := context.WithCancel(context.Background())737+ cancel()738+ rng := rand.New(rand.NewPCG(uint64(400+i), uint64(900+i)))739+ _, _ = GenerateSeededWithContext(ctx, mode, rng)740+ }741+742+ time.Sleep(80 * time.Millisecond)743+ after := runtime.NumGoroutine()744+ if delta := after - before; delta > 6 {745+ t.Fatalf("possible goroutine leak: before=%d after=%d delta=%d", before, after, delta)746+ }747+}
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.BG, 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/LMB: sea z/RMB: island bkspc: clear ctrl+n: menu ctrl+r: reset ctrl+h: help")127+ }128+ return game.StatusBarStyle().Render("x/LMB: sea z/RMB: island bkspc: clear")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 shikaku/Model.go +20 -5
@@ -7,7 +7,6 @@ import (
77 88 "charm.land/bubbles/v2/key"99 tea "charm.land/bubbletea/v2"10- "charm.land/lipgloss/v2"1110 )1211 1312 // expansion tracks how far the preview rectangle extends from the clue cell.
@@ -75,6 +74,7 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
7574 switch msg := msg.(type) {7675 case game.HelpToggleMsg:7776 m.showFullHelp = msg.Show77+ m.originValid = false7878 7979 case tea.WindowSizeMsg:8080 m.termWidth = msg.Width
@@ -249,6 +249,13 @@ func rectFromCorners(x1, y1, x2, y2 int) Rectangle {
249249 }250250 }251251 252+func (m Model) cancelPendingRectangle() Model {253+ m.selectedClue = nil254+ m.mouseDragAnchor = nil255+ m.mousePreview = nil256+ return m257+}258+252259 func (m Model) handleNavMode(msg tea.KeyPressMsg) Model {253260 switch {254261 case key.Matches(msg, m.keys.Select):
@@ -270,6 +277,8 @@ func (m Model) handleNavMode(msg tea.KeyPressMsg) Model {
270277 if owner >= 0 {271278 m.puzzle.RemoveRectangle(owner)272279 }280+ case key.Matches(msg, m.keys.Cancel):281+ m = m.cancelPendingRectangle()273282 default:274283 m.cursor.Move(m.keys.CursorKeyMap, msg, m.puzzle.Width-1, m.puzzle.Height-1)275284 }
@@ -325,7 +334,7 @@ func (m Model) handleExpansionMode(msg tea.KeyPressMsg) Model {
325334 m.selectedClue = nil326335 }327336 case key.Matches(msg, m.keys.Cancel):328- m.selectedClue = nil337+ m = m.cancelPendingRectangle()329338 case key.Matches(msg, m.keys.Delete):330339 m.puzzle.RemoveRectangle(clue.ID)331340 m.selectedClue = nil
@@ -338,11 +347,17 @@ func (m Model) View() string {
338347 title := game.TitleBarView("Shikaku", m.modeTitle, solved)339348 grid := gridView(m, solved)340349 if solved {341- return lipgloss.JoinVertical(lipgloss.Center, title, grid)350+ return game.ComposeGameView(title, grid)342351 }352+ selected := m.selectedClue != nil343353 info := infoView(&m.puzzle)344- status := statusBarView(m.selectedClue != nil, m.showFullHelp)345- return lipgloss.JoinVertical(lipgloss.Center, title, grid, info, status)354+ status := statusBarView(selected, m.showFullHelp)355+ return game.ComposeGameViewRows(356+ title,357+ grid,358+ game.StaticRow(info),359+ game.StableRow(status, statusBarView(selected, false), statusBarView(selected, true)),360+ )346361 }347362 348363 func (m Model) SetTitle(t string) game.Gamer {
M shikaku/shikaku_test.go +86 -0
@@ -4,6 +4,10 @@ import (
44 "encoding/json"55 "math/rand/v2"66 "testing"7+8+ tea "charm.land/bubbletea/v2"9+ "charm.land/lipgloss/v2"10+ "github.com/FelineStateMachine/puzzletea/game"711 )812 913 // --- helpers ---
@@ -549,6 +553,43 @@ func TestScreenToGrid(t *testing.T) {
549553 }550554 }551555 556+func TestHelpToggleInvalidatesOriginCache(t *testing.T) {557+ m := Model{558+ puzzle: *simplePuzzle(),559+ keys: DefaultKeyMap,560+ modeTitle: "Test",561+ originX: 7,562+ originY: 11,563+ originValid: true,564+ }565+566+ next, _ := m.Update(game.HelpToggleMsg{Show: true})567+ got := next.(Model)568+569+ if !got.showFullHelp {570+ t.Fatal("expected showFullHelp to be true")571+ }572+ if got.originValid {573+ t.Fatal("expected origin cache to be invalidated")574+ }575+}576+577+func TestViewWidthStableWhenFullHelpExpanded(t *testing.T) {578+ m := Model{579+ puzzle: *simplePuzzle(),580+ keys: DefaultKeyMap,581+ modeTitle: "Test",582+ }583+584+ shortWidth := lipgloss.Width(m.View())585+ next, _ := m.Update(game.HelpToggleMsg{Show: true})586+ fullWidth := lipgloss.Width(next.(Model).View())587+588+ if fullWidth > shortWidth {589+ t.Errorf("full-help width = %d, want <= %d", fullWidth, shortWidth)590+ }591+}592+552593 // --- rectFromCorners (P1) ---553594 554595 func TestRectFromCorners(t *testing.T) {
@@ -579,6 +620,51 @@ func TestRectFromCorners(t *testing.T) {
579620 }580621 }581622 623+// --- Escape cancel behavior (P1) ---624+625+func TestEscapeCancelsPendingExpansion(t *testing.T) {626+ p := simplePuzzle()627+ selected := 0628+ m := Model{629+ puzzle: *p,630+ selectedClue: &selected,631+ expansion: expansion{632+ clueID: 0,633+ right: 1,634+ },635+ keys: DefaultKeyMap,636+ }637+638+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})639+ got := next.(Model)640+641+ if got.selectedClue != nil {642+ t.Fatal("expected selected clue to be cleared on escape")643+ }644+}645+646+func TestEscapeCancelsPendingMousePreviewInNavMode(t *testing.T) {647+ p := simplePuzzle()648+ anchor := [2]int{0, 0}649+ preview := Rectangle{X: 0, Y: 0, W: 2, H: 2}650+ m := Model{651+ puzzle: *p,652+ keys: DefaultKeyMap,653+ mouseDragAnchor: &anchor,654+ mousePreview: &preview,655+ }656+657+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})658+ got := next.(Model)659+660+ if got.mouseDragAnchor != nil {661+ t.Fatal("expected mouse drag anchor to be cleared on escape")662+ }663+ if got.mousePreview != nil {664+ t.Fatal("expected mouse preview to be cleared on escape")665+ }666+}667+582668 // --- CluesInRect (P1) ---583669 584670 func TestCluesInRect(t *testing.T) {
M shikaku/style.go +2 -2
M stats/stats.go +47 -40
@@ -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,
@@ -247,17 +241,19 @@ func StaticHeight(cards []Card) int {
247241 // CardFullWidth is the rendered width of a single card including border + padding.248242 const CardFullWidth = CardInnerWidth + 4 // 34249243 244+const cardColumnGap = 1245+250246 // ContentWidth returns the inner content width for the stats panel.251-// In 2-column mode the content is two cards wide; in 1-column mode it is252-// one card wide. The result is clamped so it never exceeds the available253-// terminal width minus the panel chrome (border + padding = 6 horizontal chars).247+// It snaps to the widest whole-card grid that fits the available panel space.248+// The result never exceeds terminal width minus panel chrome.254249 func ContentWidth(termWidth int) int {255- twoCol := CardFullWidth * 2 // 68256- available := termWidth - 6 // panel border (1+1) + padding (2+2)257- if available >= twoCol {258- return twoCol250+ available := max(termWidth-6, CardFullWidth) // panel border (1+1) + padding (2+2)251+ cols := cardColumnCount(available)252+ width := cardGridWidth(cols)253+ if width > available {254+ return available259255 }260- return max(CardFullWidth, min(available, CardFullWidth))256+ return width261257 }262258 263259 // ViewportHeight computes the viewport height for the card grid.
@@ -439,10 +435,7 @@ func RenderCardGrid(cards []Card, width int) string {
439435 Render("No stats yet \u2014 play some puzzles!")440436 }441437 442- if width >= CardFullWidth*2 {443- return renderCardColumns(cards, 2)444- }445- return renderCardColumns(cards, 1)438+ return renderCardRows(cards, cardColumnCount(width))446439 }447440 448441 // RenderView renders the full stats view (banner + card grid) as a single
@@ -454,11 +447,7 @@ func RenderView(banner ProfileBanner, cards []Card, width int) string {
454447 Render("No stats yet \u2014 play some puzzles!")455448 }456449 457- bannerWidth := width458- if bannerWidth > 70 {459- bannerWidth = 70460- }461- bannerStr := RenderBanner(banner, bannerWidth)450+ bannerStr := RenderBanner(banner, width)462451 cardGrid := RenderCardGrid(cards, width)463452 464453 return lipgloss.JoinVertical(lipgloss.Left,
@@ -468,24 +457,42 @@ func RenderView(banner ProfileBanner, cards []Card, width int) string {
468457 )469458 }470459 471-func renderCardColumns(cards []Card, cols int) string {460+func cardColumnCount(width int) int {461+ if width <= CardFullWidth {462+ return 1463+ }464+ cols := (width + cardColumnGap) / (CardFullWidth + cardColumnGap)465+ return max(cols, 1)466+}467+468+func cardGridWidth(cols int) int {472469 if cols < 1 {473470 cols = 1474471 }472+ return cols*CardFullWidth + (cols-1)*cardColumnGap473+}475474 476- palette := theme.Current().CardColors()475+func renderCardRows(cards []Card, cols int) string {476+ if cols < 1 {477+ cols = 1478+ }477479 478- columns := make([][]string, cols)480+ palette := theme.Current().ThemeColors()481+ rendered := make([]string, len(cards))479482 for i, c := range cards {480- col := i % cols481483 titleColor := palette[i%len(palette)]482- columns[col] = append(columns[col], RenderCard(c, titleColor))484+ rendered[i] = RenderCard(c, titleColor)483485 }484486 485- rendered := make([]string, cols)486- for i, col := range columns {487- rendered[i] = lipgloss.JoinVertical(lipgloss.Left, col...)487+ rows := make([]string, 0, (len(rendered)+cols-1)/cols)488+ for i := 0; i < len(rendered); i += cols {489+ end := min(i+cols, len(rendered))490+ row := rendered[i]491+ for j := i + 1; j < end; j++ {492+ row = lipgloss.JoinHorizontal(lipgloss.Top, row, strings.Repeat(" ", cardColumnGap), rendered[j])493+ }494+ rows = append(rows, row)488495 }489496 490- return lipgloss.JoinHorizontal(lipgloss.Top, rendered...)497+ return lipgloss.JoinVertical(lipgloss.Left, rows...)491498 }
M stats/stats_test.go +71 -0
@@ -1,10 +1,12 @@
11 package stats22 33 import (4+ "strings"45 "testing"56 "time"67 78 "github.com/FelineStateMachine/puzzletea/store"9+ "github.com/charmbracelet/x/ansi"810 )911 1012 // --- ModeXP map (P0) ---
@@ -333,3 +335,72 @@ func TestRenderViewEmpty(t *testing.T) {
333335 t.Error("expected non-empty output for empty state")334336 }335337 }338+339+// --- Card grid layout (P1) ---340+341+func TestCardColumnCount(t *testing.T) {342+ tests := []struct {343+ name string344+ width int345+ want int346+ }{347+ {name: "narrow", width: 20, want: 1},348+ {name: "single card", width: CardFullWidth, want: 1},349+ {name: "two cards", width: cardGridWidth(2), want: 2},350+ {name: "three cards", width: cardGridWidth(3), want: 3},351+ }352+353+ for _, tt := range tests {354+ t.Run(tt.name, func(t *testing.T) {355+ if got := cardColumnCount(tt.width); got != tt.want {356+ t.Fatalf("cardColumnCount(%d) = %d, want %d", tt.width, got, tt.want)357+ }358+ })359+ }360+}361+362+func TestRenderCardGridWrapsRowsByWidth(t *testing.T) {363+ cards := testCards(5)364+ width := cardGridWidth(3)365+ rendered := ansi.Strip(RenderCardGrid(cards, width))366+ lines := strings.Split(rendered, "\n")367+ if len(lines) <= CardHeight {368+ t.Fatalf("expected wrapped second row, got %d lines", len(lines))369+ }370+ if got := strings.Count(lines[0], "╭"); got != 3 {371+ t.Fatalf("first row cards = %d, want 3", got)372+ }373+ if got := strings.Count(lines[CardHeight], "╭"); got != 2 {374+ t.Fatalf("second row cards = %d, want 2", got)375+ }376+ if got := maxRenderedWidth(rendered); got > width {377+ t.Fatalf("rendered width = %d, want <= %d", got, width)378+ }379+}380+381+func testCards(n int) []Card {382+ cards := make([]Card, n)383+ for i := range n {384+ cards[i] = Card{385+ GameType: "Test",386+ Level: 1,387+ PreferredMode: "Easy",388+ Victories: 1,389+ Attempts: 1,390+ DailyPlayed: 1,391+ CurrentXP: 1,392+ NextLevelXP: 5,393+ }394+ }395+ return cards396+}397+398+func maxRenderedWidth(s string) int {399+ maxW := 0400+ for _, line := range strings.Split(s, "\n") {401+ if w := ansi.StringWidth(line); w > maxW {402+ maxW = w403+ }404+ }405+ return maxW406+}
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 sudoku/Model.go +5 -2

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

M takuzu/Model.go +2 -3

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

M theme/palette.go +7 -7

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

M theme/theme_test.go +22 -0

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

M ui/list.go +64 -32

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

M ui/ui_test.go +114 -0

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

A vhs/nurikabe.tape +68 -0

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

M wordsearch/Model.go +1 -0

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

M wordsearch/style.go +4 -1

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

M wordsearch/wordsearch_test.go +24 -0

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