clone

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

Phase 1 - Bug Fix: Fixed cell.String() in sudoku/cell.go swapping row/column labels (c.x, c.y → c.y, c.x).

Dami committed 45270ca
Phase 2 - Dead Code Removal: Removed unused GetTomography() from
nonogram/Export.go and dead isl1/isl2 lookups with _ = isl1; _ = isl2
suppressions in hashiwokakero/generator.go.

  Phase 3 - Documentation: Added Solved field comments to all 6
Export.go files explaining it's persisted but recomputed on import.

  Phase 4 - math/rand/v2 Migration: Updated 4 files from deprecated
math/rand to math/rand/v2, changing Intn → IntN.

  Phase 5 - Error Wrapping: Changed %v → %w in hashiwokakero/Export.go
and sudoku/Export.go.

  Phase 6 - Compile-time Assertions: Added var _ game.Gamer = Model{} to
nonogram, wordsearch, sudoku, and hashiwokakero Model.go.

  Phase 7 - var → const: Extracted cellWidth/cellHeight integer values
from var blocks to const declarations in lightsout, takuzu, and nonogram
style.go.

  Phase 8 - Package-level Colors: Extracted inline
lipgloss.AdaptiveColor allocations to package-level solvedBG vars in
nonogram and takuzu style.go.

  Phase 9 - Sudoku O(1) Lookup: Added providedGrid [9][9]bool field,
replaced all slices.Contains(m.provided, c) with O(1) array lookups,
simplified IsProvidedCell().

  Phase 10 - Single IsSolved per Frame: Sudoku View() now computes
conflicts and solved state once and passes them to renderGrid.
Hashiwokakero gridView now accepts solved bool
  parameter.

  Phase 11 - Hashiwokakero Optimizations: FindIslandByID uses direct
slice index, FindIslandAt uses lazy posIndex map, BridgeCount uses lazy
bridgeCounts array, IsConnected builds
  adjacency list for BFS.

  Phase 12 - Wordsearch Render: Added foundCells [][]bool for O(1)
found-word cell lookup instead of per-cell O(words * wordLen) scan.
commit
45270ca2c20d1162596f956322d00634137da00f
parent
df4f76118ee1caab11189a8fba0046b472117b70
author
Dami <dami.etoile@gmail.com>
22 files changed, +160 -105
Mhashiwokakero/Export.go4+-
Mhashiwokakero/Model.go4+-
Mhashiwokakero/generator.go14++---
Mhashiwokakero/island.go80+++++++++++++++++++++---------
Mhashiwokakero/style.go3+-
Mlightsout/Export.go2-
Mlightsout/generator.go6+-
Mlightsout/style.go2+
Mnonogram/Export.go8+---
Mnonogram/Model.go2+
Mnonogram/style.go7++-
Msudoku/Export.go11++--
Msudoku/Model.go34+++++++++----
Msudoku/cell.go8++-
Msudoku/keys.go8+---
Msudoku/style.go8+--
Mtakuzu/Export.go2-
Mtakuzu/style.go7++-
Mwordsearch/Export.go1+
Mwordsearch/Model.go38+++++++++++---
Mwordsearch/generator.go10++--
Mwordsearch/style.go6+--
M hashiwokakero/Export.go +1 -3
M hashiwokakero/Model.go +3 -1
M hashiwokakero/generator.go +5 -9
@@ -1,12 +1,12 @@
11 package hashiwokakero22 33 import (4- "math/rand"4+ "math/rand/v2"55 )66 77 // GeneratePuzzle creates a solvable hashiwokakero puzzle for the given mode.88 func GeneratePuzzle(mode HashiMode) Puzzle {9- islandCount := mode.MinIslands + rand.Intn(mode.MaxIslands-mode.MinIslands+1)9+ islandCount := mode.MinIslands + rand.IntN(mode.MaxIslands-mode.MinIslands+1)1010 1111 for {1212 p := tryGenerate(mode.Width, mode.Height, islandCount)
@@ -69,8 +69,8 @@ func placeIslands(width, height, count int) []Island {
6969 for i := range count {7070 placed := false7171 for range 200 {72- x := rand.Intn(width)73- y := rand.Intn(height)72+ x := rand.IntN(width)73+ y := rand.IntN(height)7474 p := pos{x, y}7575 7676 if occupied[p] {
@@ -177,7 +177,7 @@ func buildSpanningTree(p *Puzzle) bool {
177177 if p.WouldCross(pr.id1, pr.id2) {178178 continue179179 }180- count := 1 + rand.Intn(2) // 1 or 2 bridges180+ count := 1 + rand.IntN(2) // 1 or 2 bridges181181 p.SetBridge(pr.id1, pr.id2, count)182182 union(pr.id1, pr.id2)183183 edgesAdded++
@@ -242,8 +242,6 @@ func addExtraBridges(p *Puzzle) {
242242 }243243 244244 // Don't let any island exceed 8 bridges245- isl1 := p.FindIslandByID(pr.id1)246- isl2 := p.FindIslandByID(pr.id2)247245 addAmount := newCount248246 if existing != nil {249247 addAmount = newCount - existing.Count
@@ -251,8 +249,6 @@ func addExtraBridges(p *Puzzle) {
251249 if p.BridgeCount(pr.id1)+addAmount > 8 || p.BridgeCount(pr.id2)+addAmount > 8 {252250 continue253251 }254- _ = isl1255- _ = isl2256252 257253 p.SetBridge(pr.id1, pr.id2, newCount)258254 added++
M hashiwokakero/island.go +56 -24
@@ -36,21 +36,35 @@ type Puzzle struct {
3636 Width, Height int3737 Islands []Island3838 Bridges []Bridge39- cellCache [][]cellInfo // lazily built, invalidated on bridge changes39+ cellCache [][]cellInfo // lazily built, invalidated on bridge changes40+ posIndex map[[2]int]int // (x,y) → island slice index; lazily built41+ bridgeCounts []int // per-island bridge count; lazily built4042 }4143 4244 // FindIslandAt returns the island at (x,y), or nil.4345 func (p *Puzzle) FindIslandAt(x, y int) *Island {44- for i := range p.Islands {45- if p.Islands[i].X == x && p.Islands[i].Y == y {46- return &p.Islands[i]47- }46+ if p.posIndex == nil {47+ p.buildPosIndex()48+ }49+ if idx, ok := p.posIndex[[2]int{x, y}]; ok {50+ return &p.Islands[idx]4851 }4952 return nil5053 }5154 55+func (p *Puzzle) buildPosIndex() {56+ p.posIndex = make(map[[2]int]int, len(p.Islands))57+ for i, isl := range p.Islands {58+ p.posIndex[[2]int{isl.X, isl.Y}] = i59+ }60+}61+5262 // FindIslandByID returns the island with the given ID, or nil.5363 func (p *Puzzle) FindIslandByID(id int) *Island {64+ if id >= 0 && id < len(p.Islands) && p.Islands[id].ID == id {65+ return &p.Islands[id]66+ }67+ // Fallback for non-sequential IDs5468 for i := range p.Islands {5569 if p.Islands[i].ID == id {5670 return &p.Islands[i]
@@ -75,13 +89,25 @@ func (p *Puzzle) GetBridge(id1, id2 int) *Bridge {
7589 7690 // BridgeCount returns the total number of bridges connected to an island.7791 func (p *Puzzle) BridgeCount(islandID int) int {78- count := 092+ if p.bridgeCounts == nil {93+ p.buildBridgeCounts()94+ }95+ if islandID >= 0 && islandID < len(p.bridgeCounts) {96+ return p.bridgeCounts[islandID]97+ }98+ return 099+}100+101+func (p *Puzzle) buildBridgeCounts() {102+ p.bridgeCounts = make([]int, len(p.Islands))79103 for _, b := range p.Bridges {80- if b.Island1 == islandID || b.Island2 == islandID {81- count += b.Count104+ if b.Island1 >= 0 && b.Island1 < len(p.bridgeCounts) {105+ p.bridgeCounts[b.Island1] += b.Count106+ }107+ if b.Island2 >= 0 && b.Island2 < len(p.bridgeCounts) {108+ p.bridgeCounts[b.Island2] += b.Count82109 }83110 }84- return count85111 }86112 87113 // FindAdjacentIsland casts a ray from the island at fromID in direction (dx,dy)
@@ -236,42 +262,48 @@ func (p *Puzzle) rebuildCellCache() {
236262 p.cellCache = cache237263 }238264 239-// invalidateCache marks the cell cache as stale.265+// invalidateCache marks all lazy caches as stale.240266 func (p *Puzzle) invalidateCache() {241267 p.cellCache = nil268+ p.posIndex = nil269+ p.bridgeCounts = nil242270 }243271 244272 // IsConnected checks if all islands form a single connected component via bridges.245273 func (p *Puzzle) IsConnected() bool {246- if len(p.Islands) <= 1 {274+ n := len(p.Islands)275+ if n <= 1 {247276 return true248277 }249278 if len(p.Bridges) == 0 {250279 return false251280 }252281 253- visited := make(map[int]bool)282+ // Build adjacency list from bridges283+ adj := make(map[int][]int, n)284+ for _, b := range p.Bridges {285+ adj[b.Island1] = append(adj[b.Island1], b.Island2)286+ adj[b.Island2] = append(adj[b.Island2], b.Island1)287+ }288+289+ visited := make([]bool, n)254290 queue := []int{p.Islands[0].ID}291+ visited[p.Islands[0].ID] = true292+ count := 1255293 256294 for len(queue) > 0 {257295 cur := queue[0]258296 queue = queue[1:]259- if visited[cur] {260- continue261- }262- visited[cur] = true263-264- for _, b := range p.Bridges {265- if b.Island1 == cur && !visited[b.Island2] {266- queue = append(queue, b.Island2)267- }268- if b.Island2 == cur && !visited[b.Island1] {269- queue = append(queue, b.Island1)297+ for _, nb := range adj[cur] {298+ if !visited[nb] {299+ visited[nb] = true300+ count++301+ queue = append(queue, nb)270302 }271303 }272304 }273305 274- return len(visited) == len(p.Islands)306+ return count == n275307 }276308 277309 // IsSolved returns true if the puzzle is completely and correctly solved.
M hashiwokakero/style.go +1 -2
M lightsout/Export.go +0 -2
M lightsout/generator.go +3 -3
M lightsout/style.go +2 -0
M nonogram/Export.go +1 -7
M nonogram/Model.go +2 -0
M nonogram/style.go +5 -2
M sudoku/Export.go +5 -6
M sudoku/Model.go +23 -11
@@ -3,7 +3,6 @@ package sudoku
33 44 import (55 "fmt"6- "slices"76 "strconv"87 98 "github.com/FelineStateMachine/puzzletea/game"
@@ -14,22 +13,34 @@ import (
1413 1514 const gridSize = 91615 16+var _ game.Gamer = Model{}17+1718 type Model struct {1819 cursor game.Cursor1920 grid grid2021 provided []cell22+ providedGrid [gridSize][gridSize]bool2123 keys KeyMap2224 modeTitle string2325 showFullHelp bool2426 }2527 28+func buildProvidedGrid(provided []cell) [gridSize][gridSize]bool {29+ var pg [gridSize][gridSize]bool30+ for _, c := range provided {31+ pg[c.y][c.x] = true32+ }33+ return pg34+}35+2636 func New(mode SudokuMode, provided []cell) (game.Gamer, error) {2737 g := newGrid(provided)2838 m := Model{29- grid: g,30- provided: provided,31- keys: DefaultKeyMap,32- modeTitle: mode.Title(),39+ grid: g,40+ provided: provided,41+ providedGrid: buildProvidedGrid(provided),42+ keys: DefaultKeyMap,43+ modeTitle: mode.Title(),3344 }3445 return m, nil3546 }
@@ -70,17 +81,18 @@ func (m Model) IsSolved() bool {
7081 }7182 7283 func (m *Model) updateCell(v int) {73- c := &m.grid[m.cursor.Y][m.cursor.X]74- if slices.Contains(m.provided, *c) {84+ if m.providedGrid[m.cursor.Y][m.cursor.X] {7585 return7686 }77- c.v = v87+ m.grid[m.cursor.Y][m.cursor.X].v = v7888 }7989 8090 // View implements game.Gamer.8191 func (m Model) View() string {82- title := game.TitleBarView("Sudoku", m.modeTitle, m.isSolved())83- grid := renderGrid(m)92+ conflicts := computeConflicts(m.grid)93+ solved := isSolvedWith(m.grid, conflicts)94+ title := game.TitleBarView("Sudoku", m.modeTitle, solved)95+ grid := renderGrid(m, solved, conflicts)8496 status := statusBarView(m.showFullHelp)8597 8698 return lipgloss.JoinVertical(lipgloss.Left, title, grid, status)
@@ -90,7 +102,7 @@ func (m Model) View() string {
90102 func (m Model) GetDebugInfo() string {91103 conflicts := computeConflicts(m.grid)92104 cursorCell := m.grid[m.cursor.Y][m.cursor.X]93- isProvided := slices.Contains(m.provided, cursorCell)105+ isProvided := m.providedGrid[m.cursor.Y][m.cursor.X]94106 conflict := conflicts[m.cursor.Y][m.cursor.X]95107 solved := m.isSolved()96108
M sudoku/cell.go +6 -2
M sudoku/keys.go +1 -7
M sudoku/style.go +3 -5
M takuzu/Export.go +0 -2
M takuzu/style.go +5 -2
M wordsearch/Export.go +1 -0
M wordsearch/Model.go +30 -8
M wordsearch/generator.go +5 -5
M wordsearch/style.go +2 -4