clone

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

add lightsout

Dami committed 721c2c9
commit
721c2c924abe96af9b5bea40a6db009bbb75b963
parent
01cb60fc12239e8251848b7cbc6227b5515a3c2a
author
Dami <dami@Damis-MacBook-Air.local>
25 files changed, +884 -485
MCLAUDE.md101++++++++-------
Mgame/gamer.go1+
Mgame/style.go49+++++++-
Mhashiwokakero/Model.go45+++----
Mhashiwokakero/generator.go23+---
Mhashiwokakero/island.go43++++--
Alightsout/Export.go54++++++++
Alightsout/Gamemode.go45+++++++
Alightsout/Model.go98++++++++++++++
Alightsout/generator.go67++++++++++
Alightsout/keys.go35+++++
Alightsout/style.go85+++++++++++++
Mmodel.go203+++++++++++++++++-------------
Mnamegen/namegen.go1+
Mnonogram/Export.go22++-
Mnonogram/Model.go110+++++++----------
Mnonogram/grid.go12--
Mstore/store.go1+
Msudoku/Gamemode.go8+-
Msudoku/Model.go57++++-----
Msudoku/cell.go18+--
Msudoku/style.go82+++++++++---
Mwordsearch/Model.go153++++++++++++-----------
Mwordsearch/grid.go8-
Mwordsearch/style.go48+------
M CLAUDE.md +52 -49
@@ -1,31 +1,21 @@
11 # CLAUDE.md22 3-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.4-53 ## Project Overview64 7-PuzzleTea is a terminal-based puzzle game framework built with the Bubble Tea TUI library. It provides a plugin architecture for different puzzle types with a shared menu system and debug interface.5+PuzzleTea is a terminal-based puzzle game framework built with the Bubble Tea TUI library. It provides a plugin architecture for different puzzle types with a shared menu system, save/load persistence, and debug interface.86 97 ## Commands108 119 ### Building and Running1210 ```bash13-# Run the application1411 go run .15-16-# Build the binary1712 go build -o puzzletea18-19-# Update dependencies2013 go mod tidy2114 ```2215 2316 ### Testing2417 ```bash25-# Run all tests (no tests exist yet)2618 go test ./...27-28-# Run tests for a specific package2919 go test ./nonogram3020 ```3121
@@ -33,85 +23,97 @@ go test ./nonogram
3323 3424 ### Core Application Structure3525 36-The application follows a **plugin-based architecture** where puzzle games implement the `game.Gamer` interface:26+The application follows a **plugin-based architecture** where puzzle games implement the `game.Gamer` interface.3727 38-- **Main entry point** (`main.go`): Initializes the Bubble Tea program39-- **Root model** (`model.go`): Manages application state with three views:40- - `gameSelectView`: Category selection (Nonogram, Word Search, Sudoku, Hashiwokakero)28+- **`main.go`**: Initializes store and Bubble Tea program.29+- **`model.go`**: Root model managing application state across five views:30+ - `mainMenuView`: Top-level menu (Daily Puzzle, Generate, Continue)31+ - `gameSelectView`: Category selection4132 - `modeSelectView`: Difficulty/mode selection within a category4233 - `gameView`: Active game instance34+ - `continueView`: Saved game browser (table)35+36+Navigation: main menu → select category → select mode → play. `Escape` goes back one level, `Ctrl+N` returns to main menu from anywhere.4337 44-Navigation flow: select a game category → select a mode/difficulty → play the game. `Escape` returns from mode select to game select, `Ctrl+N` returns to game select from anywhere.38+In `model.go`, key events are handled in a single `switch msg.Type` block. Each key type (e.g. `tea.KeyEnter`) has **one `case`** with an if-chain that checks `m.state`. Never add a duplicate `case` for the same key type.4539 46-### Plugin Interface (`game/gamer.go`)40+### Plugin Interface (`game/`)4741 4842 All puzzle games must implement:4943 50-1. **`game.Gamer`** - The active game instance (Init, Update, View, GetDebugInfo, GetFullHelp, GetSave)51-2. **`game.Spawner`** - Creates a new game instance: `Spawn() (Gamer, error)`52-3. **`game.BaseMode`** - Embed this in your mode struct to get `Title()`, `Description()`, and `FilterValue()` for free53-4. **`game.Category`** - Groups related modes under a heading in the menu; satisfies `Mode` but not `Spawner`44+1. **`game.Gamer`** — Active game instance: `Init`, `Update`, `View`, `GetDebugInfo`, `GetFullHelp`, `GetSave`, `IsSolved`, `SetTitle`45+2. **`game.Spawner`** — Creates a new game: `Spawn() (Gamer, error)`46+3. **`game.BaseMode`** — Embed in mode structs for free `Title()`, `Description()`, `FilterValue()`47+4. **`game.Category`** — Groups modes under a heading; satisfies `Mode` but not `Spawner`48+49+Shared utilities in `game/`:50+- **`cursor.go`**: `Cursor` type with `Move()` for grid-based navigation51+- **`keys.go`**: `CursorKeyMap` and `DefaultCursorKeyMap` for shared arrow/WASD/vim bindings52+- **`style.go`**: `TitleBarView()`, `DebugHeader()`, `DebugTable()` for consistent rendering53+54+Save/load uses `game.Registry` — each package registers its import function via `init()` calling `game.Register(name, fn)`.5455 5556 ### Adding a New Puzzle Type5657 5758 1. Create a new package under the project root (e.g., `mypuzzle/`)58-2. Define a mode struct that embeds `game.BaseMode` and implements `game.Spawner`59-3. Use compile-time interface checks: `var _ game.Mode = MyMode{}` and `var _ game.Spawner = MyMode{}`59+2. Define a mode struct embedding `game.BaseMode` that implements `game.Spawner`60+3. Add compile-time checks: `var _ game.Mode = MyMode{}` and `var _ game.Spawner = MyMode{}`6061 4. Export a `Modes` variable (`[]list.Item`) listing available difficulties6162 5. Implement `game.Gamer` on a `Model` struct62-6. Register the category in `model.go`'s `GameCategories` slice63+6. Register the import function in an `init()`: `game.Register("My Puzzle", func(data []byte) (game.Gamer, error) { ... })`64+7. Add the category to `GameCategories` in `model.go`6365 6466 Each package follows a consistent file structure:65-- `Gamemode.go`: Mode struct, `NewMode()`, `Spawn()`, `Modes` var67+- `Gamemode.go`: Mode struct, `NewMode()`, `Spawn()`, `Modes` var, `init()` with `game.Register()`6668 - `Model.go`: `Model` struct implementing `game.Gamer`67-- `Export.go`: Exported `Save` struct, `GetSave()` method (on Model), `ImportModel([]byte) (*Model, error)` function68-- `keys.go`: `KeyMap` struct with game-specific keybindings, `var DefaultKeyMap = KeyMap{...}`69+- `Export.go`: `Save` struct, `GetSave()`, `ImportModel([]byte)` function70+- `keys.go`: `KeyMap` struct with game-specific keybindings6971 - `style.go`: lipgloss styling definitions7072 - `generator.go`: Puzzle generation logic (where applicable)73+- `grid.go`: Grid type, serialization, and helpers (for grid-based games)7174 7275 ### Current Implementations7376 74-**Nonogram** (`nonogram/`): Grid-based puzzle with row/column hints. Three tile states: filled (`.`), marked (`-`), empty (` `). Victory when current grid tomography matches hint tomography.75-76-**Word Search** (`wordsearch/`): Find hidden words in a letter grid. Select start and end positions to highlight words. Supports 8 directions (Right, Down, DownRight, DownLeft, Left, Up, UpRight, UpLeft) with configurable subsets per difficulty.77+- **Nonogram** (`nonogram/`): Fill cells to match row/column hints. Three tile states: filled, marked, empty. Victory when grid tomography matches hint tomography.78+- **Word Search** (`wordsearch/`): Find hidden words in a letter grid. Supports 8 directions with configurable subsets per difficulty.79+- **Sudoku** (`sudoku/`): Classic 9x9 with configurable provided cells. Uses `cell` type with provided/user-entered distinction.80+- **Hashiwokakero** (`hashiwokakero/`): Connect islands with bridges (1 or 2). Navigation mode and bridge mode. Victory requires all islands satisfied and connected.81+- **Lights Out** (`lightsout/`): Toggle lights in a cross pattern to turn all off. Grid sizes from 3x3 to 9x9.7782 78-**Sudoku** (`sudoku/`): Classic 9x9 sudoku with configurable number of provided cells. Uses `cell` type with provided/user-entered distinction.83+### Supporting Packages7984 80-**Hashiwokakero** (`hashiwokakero/`): Connect islands with bridges (1 or 2). Two input modes: navigation mode (move cursor between islands) and bridge mode (select an island, then cycle bridges in a direction). Victory requires all islands satisfied and connected. Uses `Island`, `Bridge`, `Puzzle` types.85+- **`store/`**: SQLite-based persistence (via `modernc.org/sqlite`). Stores game records with status tracking (new, in_progress, completed, abandoned). DB at `~/.puzzletea/history.db`.86+- **`namegen/`**: Generates unique adjective-noun names for saved games.8187 8288 ### Global Keybindings8389 84-Handled by root model:85-- `Ctrl+N`: Return to game select from any view86-- `Ctrl+C`: Quit application90+- `Ctrl+N`: Return to main menu91+- `Ctrl+C`: Quit (saves current game as abandoned)8792 - `Ctrl+E`: Toggle debug overlay88-- `Enter`: Select item in menus89-- `Escape`: Return from mode select to game select90-91-Individual games handle their own gameplay keybindings via `KeyMap` types in `keys.go`.92-93-In `model.go`, key events are handled in a single `switch msg.Type` block. Each key type (e.g. `tea.KeyEnter`) has **one `case`** with an if-chain that checks `m.state` to determine per-view behavior. Never add a duplicate `case` for the same key type — add new state checks within the existing case.93+- `Ctrl+H`: Toggle full help display94+- `Enter`: Select in menus95+- `Escape`: Go back one level9496 9597 ### Continue View9698 97-The continue view (`continueView`) displays saved games in a `table.Model`. Game records are cached in `m.continueGames` (populated by `initContinueTable()` each time the user navigates to the view). Use this slice directly by index to look up a selected game — no need to re-query the store. Pressing Enter on a row calls the matching package's `ImportModel()` to restore the game.99+Displays saved games in a `table.Model`. Records cached in `m.continueGames` (populated by `initContinueTable()`). Use this slice by index — no need to re-query the store. Enter calls the matching package's `ImportModel()`.98100 99101 ### Debug System100102 101-The debug overlay (`Ctrl+E`) displays game-specific markdown rendered via `glamour`. Each game provides debug info via `GetDebugInfo()` — typically cursor position, dimensions, solved state, and game-specific details.103+Debug overlay (`Ctrl+E`) renders game-specific markdown via `glamour`. Games provide info via `GetDebugInfo()` using `game.DebugHeader()` and `game.DebugTable()` helpers.102104 103105 ### Grid System Pattern104106 105-Puzzle implementations use a similar grid pattern:106-- Internal grid type: 2D slice (`[][]rune` for nonogram/wordsearch, `[][]cell` for sudoku)107+Grid-based games (nonogram, wordsearch, sudoku) use:108+- Internal grid type: 2D slice (`[][]rune`, `[9][9]cell`, etc.)107109 - `state` type: String serialization for save/load108-- Conversion functions: `newGrid(state)` and `grid.String()`110+- `newGrid(state)` and `grid.String()` conversion functions109111 110-Hashiwokakero uses a different approach with `Island` and `Bridge` structs rather than a 2D grid.112+Hashiwokakero uses `Island` and `Bridge` structs instead of a 2D grid. Lights Out uses `[][]bool` directly.111113 112114 ### Color Convention113115 114-All colors should use `lipgloss.AdaptiveColor{Light: "X", Dark: "Y"}` with ANSI 256 palette numbers to support both light and dark terminals. Avoid raw hex colors (`#rrggbb`) as they require true-color support and don't adapt to terminal background.116+All colors must use `lipgloss.AdaptiveColor{Light: "X", Dark: "Y"}` with ANSI 256 palette numbers. Avoid hex colors (`#rrggbb`) — they require true-color support and don't adapt to terminal background.115117 116118 ## Module Information117119
@@ -119,6 +121,7 @@ All colors should use `lipgloss.AdaptiveColor{Light: "X", Dark: "Y"}` with ANSI
119121 - **Go version**: 1.24.5120122 - **Key dependencies**:121123 - `github.com/charmbracelet/bubbletea`: TUI framework122- - `github.com/charmbracelet/bubbles`: TUI components (list)124+ - `github.com/charmbracelet/bubbles`: TUI components (list, table)123125 - `github.com/charmbracelet/lipgloss`: Terminal styling124126 - `github.com/charmbracelet/glamour`: Markdown rendering (debug view)127+ - `modernc.org/sqlite`: Pure-Go SQLite driver (game persistence)
M game/gamer.go +1 -0
M game/style.go +46 -3
@@ -1,6 +1,11 @@
11 package game22 3-import "github.com/charmbracelet/lipgloss"3+import (4+ "fmt"5+ "strings"6+7+ "github.com/charmbracelet/lipgloss"8+)49 510 var (611 titleStyle = lipgloss.NewStyle().
@@ -16,10 +21,48 @@ var (
1621 1722 // TitleBarView renders a title bar with the game name, mode name, and optional solved badge.1823 func TitleBarView(gameName, modeName string, solved bool) string {19- title := titleStyle.Render(gameName + " " + modeName)24+ title := titleStyle.Render(gameName + " - " + modeName)2025 if solved {2126 badge := solvedBadgeStyle.Render(" SOLVED")22- return title + badge27+ return title + badge + "\nctrl+n to play again"2328 }2429 return title2530 }31+32+// DebugHeader returns the markdown heading and property table header for debug info.33+// rows is a list of [key, value] pairs for the "Game State" table.34+func DebugHeader(title string, rows [][2]string) string {35+ var sb strings.Builder36+ fmt.Fprintf(&sb, "# %s\n\n## Game State\n\n", title)37+ sb.WriteString("| Property | Value |\n| :--- | :--- |\n")38+ for _, r := range rows {39+ fmt.Fprintf(&sb, "| %s | %s |\n", r[0], r[1])40+ }41+ return sb.String()42+}43+44+// DebugTable returns a markdown table section with a heading and arbitrary columns.45+// headers is the column names, rows is a list of row values.46+func DebugTable(heading string, headers []string, rows [][]string) string {47+ var sb strings.Builder48+ fmt.Fprintf(&sb, "\n## %s\n\n", heading)49+50+ sb.WriteString("|")51+ for _, h := range headers {52+ fmt.Fprintf(&sb, " %s |", h)53+ }54+ sb.WriteString("\n|")55+ for range headers {56+ sb.WriteString(" :--- |")57+ }58+ sb.WriteString("\n")59+60+ for _, row := range rows {61+ sb.WriteString("|")62+ for _, cell := range row {63+ fmt.Fprintf(&sb, " %s |", cell)64+ }65+ sb.WriteString("\n")66+ }67+ return sb.String()68+}
M hashiwokakero/Model.go +20 -25
@@ -1,3 +1,4 @@
1+// Package hashiwokakero implements the bridge-connecting puzzle game.12 package hashiwokakero23 34 import (
@@ -155,34 +156,22 @@ func (m Model) GetDebugInfo() string {
155156 connStr = "Yes"156157 }157158 158- s := fmt.Sprintf(159- "# Hashiwokakero\n\n"+160- "## Game State\n\n"+161- "| Property | Value |\n"+162- "| :--- | :--- |\n"+163- "| Status | %s |\n"+164- "| Grid Size | %dx%d |\n"+165- "| Islands | %d |\n"+166- "| Bridges | %d |\n"+167- "| Connected | %s |\n"+168- "| Cursor Island | %d |\n",169- status,170- m.puzzle.Width, m.puzzle.Height,171- len(m.puzzle.Islands),172- len(m.puzzle.Bridges),173- connStr,174- m.cursorIsland,175- )176-177159 selectedStr := "None"178160 if m.selectedIsland != nil {179161 selectedStr = fmt.Sprintf("%d", *m.selectedIsland)180162 }181- s += fmt.Sprintf("| Selected Island | %s |\n", selectedStr)182163 183- s += "\n## Islands\n\n"184- s += "| ID | Pos | Required | Current | Status |\n"185- s += "| :--- | :--- | :--- | :--- | :--- |\n"164+ s := game.DebugHeader("Hashiwokakero", [][2]string{165+ {"Status", status},166+ {"Grid Size", fmt.Sprintf("%dx%d", m.puzzle.Width, m.puzzle.Height)},167+ {"Islands", fmt.Sprintf("%d", len(m.puzzle.Islands))},168+ {"Bridges", fmt.Sprintf("%d", len(m.puzzle.Bridges))},169+ {"Connected", connStr},170+ {"Cursor Island", fmt.Sprintf("%d", m.cursorIsland)},171+ {"Selected Island", selectedStr},172+ })173+174+ var rows [][]string186175 for _, isl := range m.puzzle.Islands {187176 current := m.puzzle.BridgeCount(isl.ID)188177 islStatus := "Unsatisfied"
@@ -191,9 +180,15 @@ func (m Model) GetDebugInfo() string {
191180 } else if current > isl.Required {192181 islStatus = "Over"193182 }194- s += fmt.Sprintf("| %d | (%d,%d) | %d | %d | %s |\n",195- isl.ID, isl.X, isl.Y, isl.Required, current, islStatus)183+ rows = append(rows, []string{184+ fmt.Sprintf("%d", isl.ID),185+ fmt.Sprintf("(%d,%d)", isl.X, isl.Y),186+ fmt.Sprintf("%d", isl.Required),187+ fmt.Sprintf("%d", current),188+ islStatus,189+ })196190 }191+ s += game.DebugTable("Islands", []string{"ID", "Pos", "Required", "Current", "Status"}, rows)197192 198193 return s199194 }
M hashiwokakero/generator.go +1 -22
M hashiwokakero/island.go +30 -13
@@ -36,6 +36,7 @@ type Puzzle struct {
3636 Width, Height int3737 Islands []Island3838 Bridges []Bridge39+ cellCache [][]cellInfo // lazily built, invalidated on bridge changes3940 }4041 4142 // FindIslandAt returns the island at (x,y), or nil.
@@ -162,6 +163,8 @@ func (p *Puzzle) SetBridge(id1, id2, count int) {
162163 a, b = b, a163164 }164165 166+ p.invalidateCache()167+165168 if count == 0 {166169 // Remove bridge167170 for i := range p.Bridges {
@@ -183,16 +186,25 @@ func (p *Puzzle) SetBridge(id1, id2, count int) {
183186 p.Bridges = append(p.Bridges, Bridge{Island1: a, Island2: b, Count: count})184187 }185188 186-// CellContent returns what occupies the given grid cell.189+// CellContent returns what occupies the given grid cell, using a lazily-built cache.187190 func (p *Puzzle) CellContent(x, y int) cellInfo {188- // Check islands first191+ if p.cellCache == nil {192+ p.rebuildCellCache()193+ }194+ return p.cellCache[y][x]195+}196+197+// rebuildCellCache recomputes the full cell grid from islands and bridges.198+func (p *Puzzle) rebuildCellCache() {199+ cache := make([][]cellInfo, p.Height)200+ for y := range cache {201+ cache[y] = make([]cellInfo, p.Width)202+ }203+189204 for _, isl := range p.Islands {190- if isl.X == x && isl.Y == y {191- return cellInfo{Kind: cellIsland, IslandID: isl.ID}192- }205+ cache[isl.Y][isl.X] = cellInfo{Kind: cellIsland, IslandID: isl.ID}193206 }194207 195- // Check bridges196208 for i, b := range p.Bridges {197209 isl1 := p.FindIslandByID(b.Island1)198210 isl2 := p.FindIslandByID(b.Island2)
@@ -200,28 +212,33 @@ func (p *Puzzle) CellContent(x, y int) cellInfo {
200212 continue201213 }202214 203- if isl1.Y == isl2.Y && y == isl1.Y {215+ if isl1.Y == isl2.Y {204216 // Horizontal bridge205217 minX, maxX := isl1.X, isl2.X206218 if minX > maxX {207219 minX, maxX = maxX, minX208220 }209- if x > minX && x < maxX {210- return cellInfo{Kind: cellBridgeH, BridgeIdx: i, BridgeCount: b.Count}221+ for x := minX + 1; x < maxX; x++ {222+ cache[isl1.Y][x] = cellInfo{Kind: cellBridgeH, BridgeIdx: i, BridgeCount: b.Count}211223 }212- } else if isl1.X == isl2.X && x == isl1.X {224+ } else if isl1.X == isl2.X {213225 // Vertical bridge214226 minY, maxY := isl1.Y, isl2.Y215227 if minY > maxY {216228 minY, maxY = maxY, minY217229 }218- if y > minY && y < maxY {219- return cellInfo{Kind: cellBridgeV, BridgeIdx: i, BridgeCount: b.Count}230+ for y := minY + 1; y < maxY; y++ {231+ cache[y][isl1.X] = cellInfo{Kind: cellBridgeV, BridgeIdx: i, BridgeCount: b.Count}220232 }221233 }222234 }223235 224- return cellInfo{Kind: cellEmpty}236+ p.cellCache = cache237+}238+239+// invalidateCache marks the cell cache as stale.240+func (p *Puzzle) invalidateCache() {241+ p.cellCache = nil225242 }226243 227244 // IsConnected checks if all islands form a single connected component via bridges.
A lightsout/Export.go +54 -0
A lightsout/Gamemode.go +45 -0
A lightsout/Model.go +98 -0
@@ -0,0 +1,98 @@
1+// Package lightsout implements the lights out toggle puzzle game.2+package lightsout3+4+import (5+ "fmt"6+7+ "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/charmbracelet/bubbles/key"9+ tea "github.com/charmbracelet/bubbletea"10+ "github.com/charmbracelet/lipgloss"11+)12+13+type Model struct {14+ grid [][]bool15+ width int16+ height int17+ cursor game.Cursor18+ keys KeyMap19+ modeTitle string20+ showFullHelp bool21+}22+23+var _ game.Gamer = Model{}24+25+func New(w, h int) (Model, error) {26+ grid := Generate(w, h)27+ return Model{28+ grid: grid,29+ width: w,30+ height: h,31+ cursor: game.Cursor{X: w / 2, Y: h / 2},32+ keys: DefaultKeyMap,33+ }, nil34+}35+36+func (m Model) Init() tea.Cmd {37+ return nil38+}39+40+func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {41+ switch msg := msg.(type) {42+ case game.HelpToggleMsg:43+ m.showFullHelp = msg.Show44+ case tea.KeyMsg:45+ switch {46+ case key.Matches(msg, m.keys.Toggle):47+ if !m.IsSolved() {48+ Toggle(m.grid, m.cursor.X, m.cursor.Y)49+ }50+ default:51+ m.cursor.Move(m.keys.CursorKeyMap, msg, m.width-1, m.height-1)52+ }53+ }54+ m.updateKeyBindings()55+ return m, nil56+}57+58+func (m Model) View() string {59+ solved := m.IsSolved()60+61+ title := game.TitleBarView("Lights Out", m.modeTitle, solved)62+ grid := gridView(m.grid, m.cursor, solved)63+ status := statusBarView(m.showFullHelp)64+65+ return lipgloss.JoinVertical(lipgloss.Left, title, grid, status)66+}67+68+func (m Model) SetTitle(t string) game.Gamer {69+ m.modeTitle = t70+ return m71+}72+73+func (m Model) IsSolved() bool {74+ return IsSolved(m.grid)75+}76+77+func (m Model) GetDebugInfo() string {78+ status := "In Progress"79+ if m.IsSolved() {80+ status = "Solved"81+ }82+83+ lightsOn := 084+ for _, row := range m.grid {85+ for _, cell := range row {86+ if cell {87+ lightsOn++88+ }89+ }90+ }91+92+ return game.DebugHeader("Lights Out", [][2]string{93+ {"Status", status},94+ {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},95+ {"Grid Size", fmt.Sprintf("%d x %d", m.width, m.height)},96+ {"Lights On", fmt.Sprintf("%d / %d", lightsOn, m.width*m.height)},97+ })98+}
A lightsout/generator.go +67 -0
@@ -0,0 +1,67 @@
1+package lightsout2+3+import (4+ "math/rand"5+)6+7+// Generate creates a new w x h grid with a solvable puzzle.8+// It starts with all lights off and applies random toggles.9+func Generate(w, h int) [][]bool {10+ grid := make([][]bool, h)11+ for y := 0; y < h; y++ {12+ grid[y] = make([]bool, w)13+ }14+15+ // Iterate through every cell and randomly decide whether to "click" it.16+ // Since clicking a cell twice cancels out, and the order of clicks doesn't matter,17+ // iterating once through all cells covers all reachable states from "all off".18+ for y := 0; y < h; y++ {19+ for x := 0; x < w; x++ {20+ if rand.Intn(2) == 1 {21+ Toggle(grid, x, y)22+ }23+ }24+ }25+26+ // Ensure the puzzle isn't already solved (all off).27+ if IsSolved(grid) {28+ // Toggle one random cell to ensure at least some lights are on.29+ Toggle(grid, rand.Intn(w), rand.Intn(h))30+ }31+32+ return grid33+}34+35+// Toggle switches the state of the cell at (x,y) and its immediate neighbors.36+// It assumes grid is rectangular.37+func Toggle(grid [][]bool, x, y int) {38+ h := len(grid)39+ if h == 0 {40+ return41+ }42+ w := len(grid[0])43+44+ // Helper to safely toggle45+ safeToggle := func(c, r int) {46+ if c >= 0 && c < w && r >= 0 && r < h {47+ grid[r][c] = !grid[r][c]48+ }49+ }50+51+ safeToggle(x, y) // Center52+ safeToggle(x, y-1) // Up53+ safeToggle(x, y+1) // Down54+ safeToggle(x-1, y) // Left55+ safeToggle(x+1, y) // Right56+}57+58+func IsSolved(grid [][]bool) bool {59+ for _, row := range grid {60+ for _, cell := range row {61+ if cell {62+ return false63+ }64+ }65+ }66+ return true67+}
A lightsout/keys.go +35 -0
A lightsout/style.go +85 -0
@@ -0,0 +1,85 @@
1+package lightsout2+3+import (4+ "strings"5+6+ "github.com/FelineStateMachine/puzzletea/game"7+ "github.com/charmbracelet/lipgloss"8+)9+10+var (11+ baseStyle = lipgloss.NewStyle()12+13+ colorOn = lipgloss.AdaptiveColor{Light: "222", Dark: "180"}14+ colorOff = lipgloss.AdaptiveColor{Light: "254", Dark: "236"}15+16+ styleOn = baseStyle.17+ Background(colorOn)18+19+ styleOff = baseStyle.20+ Background(colorOff)21+22+ cursorStyle = baseStyle.23+ Bold(true).24+ Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "235"}).25+ Background(lipgloss.AdaptiveColor{Light: "130", Dark: "173"})26+27+ solvedStyle = baseStyle.28+ Background(lipgloss.AdaptiveColor{Light: "151", Dark: "22"})29+30+ gridBorderStyle = lipgloss.NewStyle().31+ Border(lipgloss.RoundedBorder()).32+ BorderForeground(lipgloss.AdaptiveColor{Light: "250", Dark: "240"})33+34+ gridBorderSolvedStyle = lipgloss.NewStyle().35+ Border(lipgloss.RoundedBorder()).36+ BorderForeground(lipgloss.AdaptiveColor{Light: "22", Dark: "149"})37+38+ statusBarStyle = lipgloss.NewStyle().39+ Foreground(lipgloss.AdaptiveColor{Light: "244", Dark: "244"}).40+ MarginTop(1)41+42+ cellWidth = 443+ cellHeight = 244+)45+46+func cellView(isOn, isCursor, solved bool) string {47+ s := styleOff48+ if isOn {49+ s = styleOn50+ }51+52+ if solved {53+ s = solvedStyle54+ } else if isCursor {55+ s = cursorStyle56+ }57+58+ content := strings.Repeat(" ", cellWidth)59+ return s.Width(cellWidth).Height(cellHeight).Render(content)60+}61+62+func gridView(g [][]bool, c game.Cursor, solved bool) string {63+ var rows []string64+ for y, row := range g {65+ var rowCells []string66+ for x, cell := range row {67+ isCursor := x == c.X && y == c.Y68+ rowCells = append(rowCells, cellView(cell, isCursor, solved))69+ }70+ rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, rowCells...))71+ }72+ grid := lipgloss.JoinVertical(lipgloss.Left, rows...)73+74+ if solved {75+ return gridBorderSolvedStyle.Render(grid)76+ }77+ return gridBorderStyle.Render(grid)78+}79+80+func statusBarView(showFullHelp bool) string {81+ if showFullHelp {82+ return statusBarStyle.Render("arrows/wasd: move space/enter: toggle ctrl+n: menu ctrl+h: help")83+ }84+ return statusBarStyle.Render("space/enter: toggle")85+}
M model.go +114 -89
@@ -8,6 +8,7 @@ import (
88 99 "github.com/FelineStateMachine/puzzletea/game"1010 "github.com/FelineStateMachine/puzzletea/hashiwokakero"11+ "github.com/FelineStateMachine/puzzletea/lightsout"1112 "github.com/FelineStateMachine/puzzletea/namegen"1213 "github.com/FelineStateMachine/puzzletea/nonogram"1314 "github.com/FelineStateMachine/puzzletea/store"
@@ -34,6 +35,7 @@ var (
3435 3536 GameCategories = []list.Item{3637 game.Category{Name: "Hashiwokakero", Desc: "Connect islands with bridges.", Modes: hashiwokakero.Modes},38+ game.Category{Name: "Lights Out", Desc: "Turn off all the lights.", Modes: lightsout.Modes},3739 game.Category{Name: "Nonogram", Desc: "Fill cells to match row and column hints.", Modes: nonogram.Modes},3840 game.Category{Name: "Sudoku", Desc: "Fill the 9x9 grid following sudoku rules.", Modes: sudoku.Modes},3941 game.Category{Name: "Word Search", Desc: "Find hidden words in a letter grid.", Modes: wordsearch.Modes},
@@ -48,7 +50,6 @@ const (
4850 continueView4951 )5052 51-// menuItem satisfies list.Item for the main menu.5253 type menuItem struct {5354 title string5455 desc string
@@ -129,95 +130,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
129130 m.state = mainMenuView130131 m.debug = false131132 case tea.KeyEnter:132- if m.state == mainMenuView {133- item, ok := m.mainMenuList.SelectedItem().(menuItem)134- if !ok {135- return m, nil136- }137- switch item.title {138- case "New Puzzle":139- m.state = gameSelectView140- case "Continue":141- m.initContinueTable()142- m.state = continueView143- }144- return m, nil145- }146- if m.state == gameSelectView {147- cat, ok := m.gameSelectList.SelectedItem().(game.Category)148- if !ok {149- return m, nil150- }151- m.selectedCategory = cat152- m.modeSelectList = initModeSelectList(cat)153- m.modeSelectList.SetSize(m.gameSelectList.Width(), m.gameSelectList.Height())154- m.state = modeSelectView155- return m, nil156- }157- if m.state == modeSelectView {158- m.mode, _ = m.modeSelectList.SelectedItem().(game.Mode)159- g, err := m.mode.(game.Spawner).Spawn()160- if err != nil {161- return m, nil162- }163- name := generateUniqueName(m.store)164- m.game = g.SetTitle(name)165- m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})166- m.state = gameView167- m.completionSaved = false168-169- // Capture initial state and create DB record.170- initialState, err := m.game.GetSave()171- if err != nil {172- log.Printf("failed to get initial save: %v", err)173- return m, nil174- }175- rec := &store.GameRecord{176- Name: name,177- GameType: m.selectedCategory.Name,178- Mode: m.mode.Title(),179- InitialState: string(initialState),180- SaveState: string(initialState),181- Status: store.StatusNew,182- }183- if err := m.store.CreateGame(rec); err != nil {184- log.Printf("failed to create game record: %v", err)185- } else {186- m.activeGameID = rec.ID187- }188- return m, nil189- }190- if m.state == continueView {191- idx := m.continueTable.Cursor()192- if idx < 0 || idx >= len(m.continueGames) {193- return m, nil194- }195- rec := m.continueGames[idx]196- importFn, ok := game.Registry[rec.GameType]197- if !ok {198- return m, nil199- }200- g, err := importFn([]byte(rec.SaveState))201- if err != nil {202- log.Printf("failed to import game: %v", err)203- return m, nil204- }205- m.game = g.SetTitle(rec.Name)206- m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})207- m.activeGameID = rec.ID208- m.state = gameView209- m.completionSaved = rec.Status == store.StatusCompleted210- return m, nil133+ if m.state != gameView {134+ return m.handleEnter()211135 }212136 case tea.KeyEscape:213- if m.state == modeSelectView {214- m.state = gameSelectView215- return m, nil216- }217- if m.state == gameSelectView || m.state == continueView {218- m.state = mainMenuView219- return m, nil220- }137+ return m.handleEscape()221138 case tea.KeyCtrlC:222139 m = saveCurrentGame(m, store.StatusAbandoned)223140 return m, tea.Quit
@@ -258,6 +175,114 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
258175 return m, cmd259176 }260177 178+func (m model) handleEnter() (tea.Model, tea.Cmd) {179+ switch m.state {180+ case mainMenuView:181+ return m.handleMainMenuEnter()182+ case gameSelectView:183+ return m.handleGameSelectEnter()184+ case modeSelectView:185+ return m.handleModeSelectEnter()186+ case continueView:187+ return m.handleContinueEnter()188+ }189+ return m, nil190+}191+192+func (m model) handleMainMenuEnter() (tea.Model, tea.Cmd) {193+ item, ok := m.mainMenuList.SelectedItem().(menuItem)194+ if !ok {195+ return m, nil196+ }197+ switch item.title {198+ case "Generate":199+ m.state = gameSelectView200+ case "Continue":201+ m.initContinueTable()202+ m.state = continueView203+ }204+ return m, nil205+}206+207+func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) {208+ cat, ok := m.gameSelectList.SelectedItem().(game.Category)209+ if !ok {210+ return m, nil211+ }212+ m.selectedCategory = cat213+ m.modeSelectList = initModeSelectList(cat)214+ m.modeSelectList.SetSize(m.gameSelectList.Width(), m.gameSelectList.Height())215+ m.state = modeSelectView216+ return m, nil217+}218+219+func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {220+ m.mode, _ = m.modeSelectList.SelectedItem().(game.Mode)221+ g, err := m.mode.(game.Spawner).Spawn()222+ if err != nil {223+ return m, nil224+ }225+ name := generateUniqueName(m.store)226+ m.game = g.SetTitle(name)227+ m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})228+ m.state = gameView229+ m.completionSaved = false230+231+ // Capture initial state and create DB record.232+ initialState, err := m.game.GetSave()233+ if err != nil {234+ log.Printf("failed to get initial save: %v", err)235+ return m, nil236+ }237+ rec := &store.GameRecord{238+ Name: name,239+ GameType: m.selectedCategory.Name,240+ Mode: m.mode.Title(),241+ InitialState: string(initialState),242+ SaveState: string(initialState),243+ Status: store.StatusNew,244+ }245+ if err := m.store.CreateGame(rec); err != nil {246+ log.Printf("failed to create game record: %v", err)247+ } else {248+ m.activeGameID = rec.ID249+ }250+ return m, nil251+}252+253+func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {254+ idx := m.continueTable.Cursor()255+ if idx < 0 || idx >= len(m.continueGames) {256+ return m, nil257+ }258+ rec := m.continueGames[idx]259+ importFn, ok := game.Registry[rec.GameType]260+ if !ok {261+ return m, nil262+ }263+ g, err := importFn([]byte(rec.SaveState))264+ if err != nil {265+ log.Printf("failed to import game: %v", err)266+ return m, nil267+ }268+ m.game = g.SetTitle(rec.Name)269+ m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})270+ m.activeGameID = rec.ID271+ m.state = gameView272+ m.completionSaved = rec.Status == store.StatusCompleted273+ return m, nil274+}275+276+func (m model) handleEscape() (tea.Model, tea.Cmd) {277+ switch m.state {278+ case modeSelectView:279+ m.state = gameSelectView280+ case gameSelectView, continueView:281+ m.state = mainMenuView282+ }283+ return m, nil284+}285+261286 func (m model) View() string {262287 var s string263288
@@ -330,7 +355,7 @@ func initMainMenuList() list.Model {
330355 }331356 332357 func initGameSelectList() list.Model {333- return initList(GameCategories, "Select Game")358+ return initList(GameCategories, "Select Category")334359 }335360 336361 func initModeSelectList(cat game.Category) list.Model {
M namegen/namegen.go +1 -0
M nonogram/Export.go +12 -10
M nonogram/Model.go +45 -65
@@ -1,3 +1,4 @@
1+// Package nonogram implements the grid-based picture logic puzzle.12 package nonogram23 34 import (
@@ -25,26 +26,31 @@ type Model struct {
2526 keys KeyMap2627 modeTitle string2728 showFullHelp bool29+ currentHints Hints // cached tomography of the current grid30+ solved bool // cached solved state2831 }2932 30-func New(mode NonogramMode, hints Hints, save ...string) (game.Gamer, error) {33+func New(mode NonogramMode, hints Hints) (game.Gamer, error) {3134 h, w := mode.Height, mode.Width3235 r, c := hints.rows, hints.cols3336 if w < r.RequiredLen() {34- return Model{}, errors.New("Puzzle width does not support row tomography definition.")37+ return Model{}, errors.New("puzzle width does not support row tomography definition")3538 }3639 if h < c.RequiredLen() {37- return Model{}, errors.New("Puzzle height does not support column tomography definition.")40+ return Model{}, errors.New("puzzle height does not support column tomography definition")3841 }39- s := loadSave(createEmptyState(h, w), save...)42+ g := newGrid(createEmptyState(h, w))43+ curr := generateTomography(g)4044 m := Model{41- width: w,42- height: h,43- rowHints: r,44- colHints: c,45- grid: newGrid(s),46- keys: DefaultKeyMap,47- modeTitle: mode.Title(),45+ width: w,46+ height: h,47+ rowHints: r,48+ colHints: c,49+ grid: g,50+ keys: DefaultKeyMap,51+ modeTitle: mode.Title(),52+ currentHints: curr,53+ solved: curr.rows.equal(r) && curr.cols.equal(c),4854 }4955 5056 return m, nil
@@ -75,15 +81,12 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
7581 }7682 7783 func (m Model) View() string {78- curr := generateTomography(m.grid)79- solved := curr.rows.equal(m.rowHints) && curr.cols.equal(m.colHints)80-8184 maxWidth, maxHeight := m.rowHints.RequiredLen()*cellWidth, m.colHints.RequiredLen()8285 83- title := game.TitleBarView("Nonogram", m.modeTitle, solved)84- g := gridView(m.grid, m.cursor, solved)85- r := rowHintView(m.rowHints, maxWidth, curr.rows)86- c := colHintView(m.colHints, maxHeight, curr.cols)86+ title := game.TitleBarView("Nonogram", m.modeTitle, m.solved)87+ g := gridView(m.grid, m.cursor, m.solved)88+ r := rowHintView(m.rowHints, maxWidth, m.currentHints.rows)89+ c := colHintView(m.colHints, maxHeight, m.currentHints.cols)8790 spacer := baseStyle.Width(maxWidth).Height(maxHeight).Render("")8891 status := nonoStatusBarView(m.showFullHelp)8992
@@ -96,64 +99,40 @@ func (m Model) View() string {
9699 }97100 98101 func (m Model) GetDebugInfo() string {99- curr := generateTomography(m.grid)100- solved := curr.rows.equal(m.rowHints) && curr.cols.equal(m.colHints)101-102102 status := "In Progress"103- if solved {103+ if m.solved {104104 status = "Solved"105105 }106106 107- s := fmt.Sprintf(108- "# Nonogram\n\n"+109- "## Game State\n\n"+110- "| Property | Value |\n"+111- "| :--- | :--- |\n"+112- "| Status | %s |\n"+113- "| Cursor | (%d, %d) |\n"+114- "| Grid Size | %d x %d |\n"+115- "| Hint Widths | row: %d, col: %d |\n",116- status,117- m.cursor.X, m.cursor.Y,118- m.width, m.height,119- m.rowHints.RequiredLen()*cellWidth, m.colHints.RequiredLen(),120- )121-122- s += "\n## Row Tomography\n\n"123- s += "| Row | Hint | Current | Match |\n"124- s += "| :--- | :--- | :--- | :--- |\n"125- for i, hint := range m.rowHints {126- var currStr string127- match := false128- if i < len(curr.rows) {129- currStr = intSliceStr(curr.rows[i])130- match = intSliceEqual(hint, curr.rows[i])131- }132- matchStr := "No"133- if match {134- matchStr = "Yes"135- }136- s += fmt.Sprintf("| %d | %s | %s | %s |\n", i, intSliceStr(hint), currStr, matchStr)137- }107+ s := game.DebugHeader("Nonogram", [][2]string{108+ {"Status", status},109+ {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},110+ {"Grid Size", fmt.Sprintf("%d x %d", m.width, m.height)},111+ {"Hint Widths", fmt.Sprintf("row: %d, col: %d", m.rowHints.RequiredLen()*cellWidth, m.colHints.RequiredLen())},112+ })113+114+ s += tomoDebugTable("Row Tomography", "Row", m.rowHints, m.currentHints.rows)115+ s += tomoDebugTable("Column Tomography", "Col", m.colHints, m.currentHints.cols)116+117+ return s118+}138119 139- s += "\n## Column Tomography\n\n"140- s += "| Col | Hint | Current | Match |\n"141- s += "| :--- | :--- | :--- | :--- |\n"142- for i, hint := range m.colHints {120+func tomoDebugTable(heading, label string, hints TomographyDefinition, current TomographyDefinition) string {121+ var rows [][]string122+ for i, hint := range hints {143123 var currStr string144124 match := false145- if i < len(curr.cols) {146- currStr = intSliceStr(curr.cols[i])147- match = intSliceEqual(hint, curr.cols[i])125+ if i < len(current) {126+ currStr = intSliceStr(current[i])127+ match = intSliceEqual(hint, current[i])148128 }149129 matchStr := "No"150130 if match {151131 matchStr = "Yes"152132 }153- s += fmt.Sprintf("| %d | %s | %s | %s |\n", i, intSliceStr(hint), currStr, matchStr)133+ rows = append(rows, []string{fmt.Sprintf("%d", i), intSliceStr(hint), currStr, matchStr})154134 }155-156- return s135+ return game.DebugTable(heading, []string{label, "Hint", "Current", "Match"}, rows)157136 }158137 159138 func intSliceStr(s []int) string {
@@ -176,10 +155,11 @@ func (m Model) SetTitle(t string) game.Gamer {
176155 }177156 178157 func (m Model) IsSolved() bool {179- curr := generateTomography(m.grid)180- return curr.rows.equal(m.rowHints) && curr.cols.equal(m.colHints)158+ return m.solved181159 }182160 183161 func (m *Model) updateTile(r rune) {184162 m.grid[m.cursor.Y][m.cursor.X] = r163+ m.currentHints = generateTomography(m.grid)164+ m.solved = m.currentHints.rows.equal(m.rowHints) && m.currentHints.cols.equal(m.colHints)185165 }
M nonogram/grid.go +0 -12
M store/store.go +1 -0
M sudoku/Gamemode.go +6 -2
M sudoku/Model.go +24 -33
@@ -1,3 +1,4 @@
1+// Package sudoku implements the classic number-placement puzzle.12 package sudoku23 34 import (
@@ -22,9 +23,8 @@ type Model struct {
2223 showFullHelp bool2324 }2425 25-// New creates a new sudoku game using the provided cell values.26-func New(mode SudokuMode, provided []cell, save ...string) (game.Gamer, error) {27- g := loadSave(newGrid(provided), save...)26+func New(mode SudokuMode, provided []cell) (game.Gamer, error) {27+ g := newGrid(provided)2828 m := Model{2929 grid: g,3030 provided: provided,
@@ -88,20 +88,20 @@ func (m Model) View() string {
8888 8989 // GetDebugInfo implements game.Gamer.9090 func (m Model) GetDebugInfo() string {91+ conflicts := computeConflicts(m.grid)9192 cursorCell := m.grid[m.cursor.Y][m.cursor.X]9293 isProvided := slices.Contains(m.provided, cursorCell)93- conflict := hasConflict(m, cursorCell, m.cursor.X, m.cursor.Y)94+ conflict := conflicts[m.cursor.Y][m.cursor.X]9495 solved := m.isSolved()9596 9697 filledCount := 09798 conflictCount := 09899 for y := range gridSize {99100 for x := range gridSize {100- c := m.grid[y][x]101- if c.v != 0 {101+ if m.grid[y][x].v != 0 {102102 filledCount++103103 }104- if c.v != 0 && hasConflict(m, c, x, y) {104+ if conflicts[y][x] {105105 conflictCount++106106 }107107 }
@@ -112,36 +112,27 @@ func (m Model) GetDebugInfo() string {
112112 status = "Solved"113113 }114114 115- s := fmt.Sprintf(116- "# Sudoku\n\n"+117- "## Game State\n\n"+118- "| Property | Value |\n"+119- "| :--- | :--- |\n"+120- "| Status | %s |\n"+121- "| Cursor | (%d, %d) |\n"+122- "| Cell Value | %s |\n"+123- "| Is Provided | %v |\n"+124- "| Has Conflict | %v |\n"+125- "| Cells Filled | %d / 81 |\n"+126- "| Conflict Count | %d |\n"+127- "| Provided Count | %d |\n",128- status,129- m.cursor.X, m.cursor.Y,130- cellContent(cursorCell),131- isProvided,132- conflict,133- filledCount,134- conflictCount,135- len(m.provided),136- )115+ s := game.DebugHeader("Sudoku", [][2]string{116+ {"Status", status},117+ {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},118+ {"Cell Value", cellContent(cursorCell)},119+ {"Is Provided", fmt.Sprintf("%v", isProvided)},120+ {"Has Conflict", fmt.Sprintf("%v", conflict)},121+ {"Cells Filled", fmt.Sprintf("%d / 81", filledCount)},122+ {"Conflict Count", fmt.Sprintf("%d", conflictCount)},123+ {"Provided Count", fmt.Sprintf("%d", len(m.provided))},124+ })137125 138126 if len(m.provided) > 0 {139- s += "\n## Provided Cells\n\n"140- s += "| Row | Col | Value |\n"141- s += "| :--- | :--- | :--- |\n"127+ var rows [][]string142128 for _, p := range m.provided {143- s += fmt.Sprintf("| %d | %d | %d |\n", p.y, p.x, p.v)129+ rows = append(rows, []string{130+ fmt.Sprintf("%d", p.y),131+ fmt.Sprintf("%d", p.x),132+ fmt.Sprintf("%d", p.v),133+ })144134 }135+ s += game.DebugTable("Provided Cells", []string{"Row", "Col", "Value"}, rows)145136 }146137 147138 return s
M sudoku/cell.go +2 -16
M sudoku/style.go +59 -23
@@ -51,13 +51,14 @@ var (
5151 )5252 5353 func renderGrid(m Model) string {54+ conflicts := computeConflicts(m.grid)5455 var rows []string5556 5657 for y := range gridSize {5758 var cells []string5859 for x := range gridSize {5960 c := m.grid[y][x]60- style := getCellStyle(m, c, x, y)61+ style := getCellStyle(m, c, x, y, conflicts[y][x])6162 content := cellContent(c)6263 rendered := style.Width(sudokuCellWidth).Align(lipgloss.Center).Render(content)6364 cells = append(cells, rendered)
@@ -94,13 +95,13 @@ func renderGrid(m Model) string {
9495 return gridBorderStyle.Render(grid)9596 }9697 97-func getCellStyle(m Model, c cell, x, y int) lipgloss.Style {98+func getCellStyle(m Model, c cell, x, y int, conflict bool) lipgloss.Style {9899 // Priority: cursor > conflict > provided > user > empty99100 if m.cursor.X == x && m.cursor.Y == y {100101 return cursorCellStyle101102 }102103 103- if c.v != 0 && hasConflict(m, c, x, y) {104+ if conflict {104105 return conflictCellStyle105106 }106107
@@ -140,37 +141,72 @@ func cellContent(c cell) string {
140141 return fmt.Sprintf("%d", c.v)141142 }142143 143-func hasConflict(m Model, c cell, x, y int) bool {144- if c.v == 0 {145- return false146- }144+// computeConflicts returns a grid of booleans indicating which cells have conflicts.145+// A cell has a conflict if its value appears more than once in its row, column, or 3x3 box.146+func computeConflicts(g grid) [gridSize][gridSize]bool {147+ var conflicts [gridSize][gridSize]bool147148 148- // Check row149- for cx := range gridSize {150- if cx != x && m.grid[y][cx].v == c.v {151- return true149+ // Check rows150+ for y := range gridSize {151+ var seen [10][]int // value → list of x positions152+ for x := range gridSize {153+ v := g[y][x].v154+ if v != 0 {155+ seen[v] = append(seen[v], x)156+ }157+ }158+ for v := 1; v <= 9; v++ {159+ if len(seen[v]) > 1 {160+ for _, x := range seen[v] {161+ conflicts[y][x] = true162+ }163+ }152164 }153165 }154166 155- // Check column156- for cy := range gridSize {157- if cy != y && m.grid[cy][x].v == c.v {158- return true167+ // Check columns168+ for x := range gridSize {169+ var seen [10][]int170+ for y := range gridSize {171+ v := g[y][x].v172+ if v != 0 {173+ seen[v] = append(seen[v], y)174+ }175+ }176+ for v := 1; v <= 9; v++ {177+ if len(seen[v]) > 1 {178+ for _, y := range seen[v] {179+ conflicts[y][x] = true180+ }181+ }159182 }160183 }161184 162- // Check 3x3 box163- boxStartX := (x / 3) * 3164- boxStartY := (y / 3) * 3165- for by := boxStartY; by < boxStartY+3; by++ {166- for bx := boxStartX; bx < boxStartX+3; bx++ {167- if (bx != x || by != y) && m.grid[by][bx].v == c.v {168- return true185+ // Check 3x3 boxes186+ for boxY := range 3 {187+ for boxX := range 3 {188+ type pos struct{ y, x int }189+ var seen [10][]pos190+ for dy := range 3 {191+ for dx := range 3 {192+ y, x := boxY*3+dy, boxX*3+dx193+ v := g[y][x].v194+ if v != 0 {195+ seen[v] = append(seen[v], pos{y, x})196+ }197+ }198+ }199+ for v := 1; v <= 9; v++ {200+ if len(seen[v]) > 1 {201+ for _, p := range seen[v] {202+ conflicts[p.y][p.x] = true203+ }204+ }169205 }170206 }171207 }172208 173- return false209+ return conflicts174210 }175211 176212 func statusBarView(showFullHelp bool) string {
M wordsearch/Model.go +80 -73
@@ -1,3 +1,4 @@
1+// Package wordsearch implements the word-finding grid puzzle.12 package wordsearch23 34 import (
@@ -83,49 +84,22 @@ func (m *Model) handleSelect() {
8384 }8485 8586 func (m *Model) validateSelection() {86- start := m.selectionStart87- end := m.cursor88-89- // Calculate direction90- dx := 091- dy := 092-93- if end.X > start.X {94- dx = 195- } else if end.X < start.X {96- dx = -197- }98-99- if end.Y > start.Y {100- dy = 1101- } else if end.Y < start.Y {102- dy = -1103- }104-105- // Must be a valid line (horizontal, vertical, or diagonal)106- if dx == 0 && dy == 0 {107- return // Same position108- }109-110- // Verify it's a straight line111- distX := abs(end.X - start.X)112- distY := abs(end.Y - start.Y)113-114- if dx != 0 && dy != 0 && distX != distY {115- return // Not a valid diagonal87+ var letters strings.Builder88+ valid := walkLine(m.selectionStart, m.cursor, func(x, y int) {89+ letters.WriteRune(m.grid.Get(x, y))90+ })91+ if !valid {92+ return11693 }11794 118- // Extract letters along the selection119- letters := m.extractLetters(start, end, dx, dy)120- lettersReverse := reverseString(letters)95+ word := letters.String()96+ wordReverse := reverseString(word)12197 122- // Check against all unfound words12398 for i := range m.words {12499 if m.words[i].Found {125100 continue126101 }127-128- if m.words[i].Text == letters || m.words[i].Text == lettersReverse {102+ if m.words[i].Text == word || m.words[i].Text == wordReverse {129103 m.words[i].Found = true130104 m.checkWin()131105 return
@@ -133,24 +107,6 @@ func (m *Model) validateSelection() {
133107 }134108 }135109 136-func (m *Model) extractLetters(start, end game.Cursor, dx, dy int) string {137- var letters strings.Builder138- x, y := start.X, start.Y139-140- for {141- letters.WriteRune(m.grid.Get(x, y))142-143- if x == end.X && y == end.Y {144- break145- }146-147- x += dx148- y += dy149- }150-151- return letters.String()152-}153-154110 func (m *Model) checkWin() {155111 allFound := true156112 for _, word := range m.words {
@@ -167,34 +123,37 @@ func (m Model) View() string {
167123 }168124 169125 func (m Model) GetDebugInfo() string {170- var sb strings.Builder171-172- sb.WriteString("# Word Search Debug\n\n")173- sb.WriteString(fmt.Sprintf("**Grid Size:** %dx%d\n\n", m.width, m.height))174- sb.WriteString(fmt.Sprintf("**Cursor:** (%d, %d)\n\n", m.cursor.X, m.cursor.Y))175- sb.WriteString(fmt.Sprintf("**Selection State:** %v\n\n", m.selection))176-126+ rows := [][2]string{127+ {"Grid Size", fmt.Sprintf("%dx%d", m.width, m.height)},128+ {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},129+ {"Selection State", fmt.Sprintf("%v", m.selection)},130+ }177131 if m.selection == startSelected {178- sb.WriteString(fmt.Sprintf("**Selection Start:** (%d, %d)\n\n", m.selectionStart.X, m.selectionStart.Y))132+ rows = append(rows, [2]string{"Selection Start", fmt.Sprintf("(%d, %d)", m.selectionStart.X, m.selectionStart.Y)})179133 }134+ rows = append(rows,135+ [2]string{"Words Found", fmt.Sprintf("%d/%d", m.countFoundWords(), len(m.words))},136+ [2]string{"Won", fmt.Sprintf("%v", m.solved)},137+ )180138 181- sb.WriteString(fmt.Sprintf("**Words Found:** %d/%d\n\n", m.countFoundWords(), len(m.words)))182- sb.WriteString(fmt.Sprintf("**Won:** %v\n\n", m.solved))183-184- sb.WriteString("## Words\n\n")185- sb.WriteString("| Word | Found | Start | End | Direction |\n")186- sb.WriteString("|------|-------|-------|-----|----------|\n")139+ s := game.DebugHeader("Word Search", rows)187140 141+ var tableRows [][]string188142 for _, word := range m.words {189- found := "❌"143+ found := "No"190144 if word.Found {191- found = "✓"145+ found = "Yes"192146 }193- sb.WriteString(fmt.Sprintf("| %s | %s | (%d,%d) | (%d,%d) | %d |\n",194- word.Text, found, word.Start.X, word.Start.Y, word.End.X, word.End.Y, word.Direction))147+ tableRows = append(tableRows, []string{148+ word.Text, found,149+ fmt.Sprintf("(%d,%d)", word.Start.X, word.Start.Y),150+ fmt.Sprintf("(%d,%d)", word.End.X, word.End.Y),151+ fmt.Sprintf("%d", word.Direction),152+ })195153 }154+ s += game.DebugTable("Words", []string{"Word", "Found", "Start", "End", "Direction"}, tableRows)196155 197- return sb.String()156+ return s198157 }199158 200159 func (m Model) SetTitle(t string) game.Gamer {
@@ -216,6 +175,54 @@ func (m Model) countFoundWords() int {
216175 return count217176 }218177 178+// lineDirection returns the unit direction vector from start to end179+// and whether the line is valid (horizontal, vertical, or diagonal).180+func lineDirection(start, end game.Cursor) (dx, dy int, valid bool) {181+ if start.X == end.X && start.Y == end.Y {182+ return 0, 0, false183+ }184+185+ if end.X > start.X {186+ dx = 1187+ } else if end.X < start.X {188+ dx = -1189+ }190+191+ if end.Y > start.Y {192+ dy = 1193+ } else if end.Y < start.Y {194+ dy = -1195+ }196+197+ distX := abs(end.X - start.X)198+ distY := abs(end.Y - start.Y)199+ if dx != 0 && dy != 0 && distX != distY {200+ return 0, 0, false201+ }202+203+ return dx, dy, true204+}205+206+// walkLine calls fn for each cell on the line from start to end.207+// Returns false if the line is not valid.208+func walkLine(start, end game.Cursor, fn func(x, y int)) bool {209+ dx, dy, valid := lineDirection(start, end)210+ if !valid {211+ return false212+ }213+214+ x, y := start.X, start.Y215+ for {216+ fn(x, y)217+ if x == end.X && y == end.Y {218+ break219+ }220+ x += dx221+ y += dy222+ }223+ return true224+}225+219226 func abs(x int) int {220227 if x < 0 {221228 return -x
M wordsearch/grid.go +0 -8
M wordsearch/style.go +6 -42