Implement a complete Hitori number puzzle with 6 difficulty modes (5x5 to 12x12). Players shade cells to eliminate duplicate numbers while keeping all white cells connected. - Latin square generation with mask-based puzzle construction - Backtracking solver with uniqueness verification and refinement - Three cell states: unmarked, shaded (x), circled (z) - Lipgloss rendering with crosshair, box borders, solved highlight - JSON save/load persistence - 32 tests covering validation, generation, save/load round-trip - README, VHS tape, root wiring (menu, aliases, justfile)
- commit
- ac1d42d97069707c9851baf6c691fb1e358713fb
- parent
- 5471c93fd9cf231e97c2f8c6a18be77276741841
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 10 | +- |
| A | hitori/ | 55 | +++ |
| A | hitori/ | 56 | +++ |
| A | hitori/ | 173 | ++++++++ |
| A | hitori/ | 48 | ++ |
| A | hitori/ | 381 | ++++++++++++++++++ |
| A | hitori/ | 103 | +++++ |
| A | hitori/ | 653 | ++++++++++++++++++++++++++++++ |
| A | hitori/ | 52 | ++ |
| A | hitori/ | 122 | ++++++ |
| A | hitori/ | 138 | ++++++ |
| M | justfile | 1 | + |
| M | model.go | 2 | + |
| A | plan/ | 415 | +++++++++++++++++++ |
| A | plan/ | 142 | +++++++ |
| M | resolve.go | 1 | + |
| M | resolve_test.go | 2 | + |
| A | vhs/ | 67 | +++ |
@@ -2,7 +2,7 @@
22 33 A terminal-based puzzle game collection built with [Bubble Tea](https://github.com/charmbracelet/bubbletea).44 5-Six puzzle types, multiple difficulty modes, save/load persistence, and a plugin architecture for adding new games.5+Seven puzzle types, multiple difficulty modes, save/load persistence, and a plugin architecture for adding new games.66 77 88 @@ -14,6 +14,7 @@ Six puzzle types, multiple difficulty modes, save/load persistence, and a plugin
1414 | **Sudoku** | Classic 9x9 grid | Beginner, Easy, Medium, Hard, Expert, Diabolical |1515 | **Word Search** | Find hidden words in a letter grid | Easy, Medium, Hard (3-8 directions) |1616 | **Hashiwokakero** | Connect islands with bridges | 12 modes across 7x7 to 13x13 grids |17+| **Hitori** | Shade cells to eliminate duplicates | 6 modes from 5x5 to 12x12 |1718 | **Lights Out** | Toggle lights to turn all off | Easy (3x3) to Extreme (9x9) |1819 | **Takuzu** | Fill grid with two symbols (●/○) | 7 modes from 6x6 to 14x14 |1920 @@ -47,6 +48,13 @@ Connect islands with bridges.
4748 4849 [Game details and controls](hashiwokakero/README.md)4950 51+### Hitori52+Shade cells to eliminate duplicate numbers.53+54+55+56+[Game details and controls](hitori/README.md)57+5058 ### Lights Out5159 Toggle lights to turn all off.5260 @@ -0,0 +1,55 @@
1+package hitori2+3+import (4+ "encoding/json"5+ "fmt"6+7+ "github.com/FelineStateMachine/puzzletea/game"8+)9+10+// Save represents the serialized state of a Hitori game.11+type Save struct {12+ Size int `json:"size"`13+ Numbers string `json:"numbers"`14+ Marks string `json:"marks"`15+ ModeTitle string `json:"mode_title"`16+}17+18+func (m Model) GetSave() ([]byte, error) {19+ save := Save{20+ Size: m.size,21+ Numbers: m.numbers.String(),22+ Marks: serializeMarks(m.marks),23+ ModeTitle: m.modeTitle,24+ }25+ data, err := json.Marshal(save)26+ if err != nil {27+ return nil, fmt.Errorf("failed to marshal hitori save: %w", err)28+ }29+ return data, nil30+}31+32+// ImportModel reconstructs a Model from saved JSON data.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 hitori save: %w", err)37+ }38+ if s.Size <= 0 {39+ return nil, fmt.Errorf("invalid grid size in save: %d", s.Size)40+ }41+42+ numbers := newGrid(state(s.Numbers))43+ marks := deserializeMarks(s.Marks, s.Size)44+45+ m := &Model{46+ size: s.Size,47+ numbers: numbers,48+ marks: marks,49+ cursor: game.Cursor{X: 0, Y: 0},50+ keys: DefaultKeyMap,51+ modeTitle: s.ModeTitle,52+ }53+ m.solved = m.checkSolved()54+ return m, nil55+}@@ -0,0 +1,56 @@
1+package hitori2+3+import (4+ "github.com/FelineStateMachine/puzzletea/game"5+6+ "github.com/charmbracelet/bubbles/list"7+)8+9+func init() {10+ game.Register("Hitori", func(data []byte) (game.Gamer, error) {11+ return ImportModel(data)12+ })13+}14+15+// HitoriMode represents a specific difficulty configuration for Hitori.16+type HitoriMode struct {17+ game.BaseMode18+ Size int19+ BlackRatio float6420+}21+22+var (23+ _ game.Mode = HitoriMode{}24+ _ game.Spawner = HitoriMode{}25+)26+27+// NewMode creates a new Hitori game mode.28+func NewMode(title, desc string, size int, blackRatio float64) HitoriMode {29+ return HitoriMode{30+ BaseMode: game.NewBaseMode(title, desc),31+ Size: size,32+ BlackRatio: blackRatio,33+ }34+}35+36+// Spawn creates a new game instance for this mode.37+func (h HitoriMode) Spawn() (game.Gamer, error) {38+ puzzle, err := Generate(h.Size, h.BlackRatio)39+ if err != nil {40+ return nil, err41+ }42+ return New(h, puzzle)43+}44+45+// Modes defines the available difficulty levels.46+// Difficulty is controlled by grid size and the ratio of black cells.47+// Smaller grids with fewer black cells are easier to deduce; larger grids48+// with more black cells require deeper chains of logic.49+var Modes = []list.Item{50+ NewMode("Mini", "5\u00d75 grid, gentle introduction.", 5, 0.32),51+ NewMode("Easy", "6\u00d76 grid, straightforward logic.", 6, 0.32),52+ NewMode("Medium", "8\u00d78 grid, moderate challenge.", 8, 0.30),53+ NewMode("Tricky", "9\u00d79 grid, requires careful deduction.", 9, 0.30),54+ NewMode("Hard", "10\u00d710 grid, advanced logic chains.", 10, 0.30),55+ NewMode("Expert", "12\u00d712 grid, maximum challenge.", 12, 0.28),56+}@@ -0,0 +1,173 @@
1+// Package hitori implements the Hitori number puzzle game.2+package hitori3+4+import (5+ "fmt"6+7+ "github.com/FelineStateMachine/puzzletea/game"8+9+ "github.com/charmbracelet/bubbles/key"10+ tea "github.com/charmbracelet/bubbletea"11+ "github.com/charmbracelet/lipgloss"12+)13+14+// Model implements game.Gamer for Hitori.15+type Model struct {16+ size int17+ numbers grid18+ marks [][]cellMark19+ initialMarks [][]cellMark20+ cursor game.Cursor21+ solved bool22+ keys KeyMap23+ modeTitle string24+ showFullHelp bool25+}26+27+var _ game.Gamer = Model{}28+29+// New creates a new Hitori game model.30+func New(mode HitoriMode, numbers grid) (game.Gamer, error) {31+ if mode.Size <= 0 {32+ return Model{}, fmt.Errorf("invalid grid size: %d", mode.Size)33+ }34+ if len(numbers) != mode.Size {35+ return Model{}, fmt.Errorf("numbers grid has %d rows, expected %d", len(numbers), mode.Size)36+ }37+ for y, row := range numbers {38+ if len(row) != mode.Size {39+ return Model{}, fmt.Errorf("numbers row %d has %d columns, expected %d", y, len(row), mode.Size)40+ }41+ }42+43+ marks := newMarks(mode.Size)44+ m := Model{45+ size: mode.Size,46+ numbers: numbers,47+ marks: marks,48+ initialMarks: cloneMarks(marks),49+ cursor: game.Cursor{X: 0, Y: 0},50+ keys: DefaultKeyMap,51+ modeTitle: mode.Title(),52+ }53+ m.solved = m.checkSolved()54+ return m, nil55+}56+57+func (m Model) Init() tea.Cmd {58+ return nil59+}60+61+func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {62+ switch msg := msg.(type) {63+ case game.HelpToggleMsg:64+ m.showFullHelp = msg.Show65+ case game.ResetMsg:66+ m.marks = cloneMarks(m.initialMarks)67+ m.solved = m.checkSolved()68+ m.cursor = game.Cursor{}69+ case tea.KeyMsg:70+ switch {71+ case key.Matches(msg, m.keys.ShadeCell):72+ if !m.solved {73+ m.toggleMark(shaded)74+ }75+ case key.Matches(msg, m.keys.CircleCell):76+ if !m.solved {77+ m.toggleMark(circled)78+ }79+ case key.Matches(msg, m.keys.ClearCell):80+ if !m.solved {81+ m.marks[m.cursor.Y][m.cursor.X] = unmarked82+ m.solved = m.checkSolved()83+ }84+ default:85+ m.cursor.Move(m.keys.CursorKeyMap, msg, m.size-1, m.size-1)86+ }87+ }88+ m.updateKeyBindings()89+ return m, nil90+}91+92+// toggleMark sets the cell to the given mark if it's currently different,93+// otherwise clears it to unmarked.94+func (m *Model) toggleMark(mark cellMark) {95+ if m.marks[m.cursor.Y][m.cursor.X] == mark {96+ m.marks[m.cursor.Y][m.cursor.X] = unmarked97+ } else {98+ m.marks[m.cursor.Y][m.cursor.X] = mark99+ }100+ m.solved = m.checkSolved()101+}102+103+func (m Model) View() string {104+ title := game.TitleBarView("Hitori", m.modeTitle, m.solved)105+ grid := gridView(m.numbers, m.marks, m.cursor, m.solved)106+ status := statusBarView(m.showFullHelp)107+ return lipgloss.JoinVertical(lipgloss.Center, title, grid, status)108+}109+110+func (m Model) SetTitle(t string) game.Gamer {111+ m.modeTitle = t112+ return m113+}114+115+func (m Model) IsSolved() bool {116+ return m.solved117+}118+119+func (m Model) Reset() game.Gamer {120+ m.marks = cloneMarks(m.initialMarks)121+ m.solved = m.checkSolved()122+ m.cursor = game.Cursor{}123+ return m124+}125+126+func (m Model) checkSolved() bool {127+ // At least one cell must be shaded for a valid solution.128+ hasShaded := false129+ for y := range m.size {130+ for x := range m.size {131+ if m.marks[y][x] == shaded {132+ hasShaded = true133+ break134+ }135+ }136+ if hasShaded {137+ break138+ }139+ }140+ if !hasShaded {141+ return false142+ }143+144+ return isValidSolution(m.numbers, m.marks, m.size)145+}146+147+func (m Model) GetDebugInfo() string {148+ status := "In Progress"149+ if m.solved {150+ status = "Solved"151+ }152+153+ shadedCount := 0154+ circledCount := 0155+ for y := range m.size {156+ for x := range m.size {157+ switch m.marks[y][x] {158+ case shaded:159+ shadedCount++160+ case circled:161+ circledCount++162+ }163+ }164+ }165+166+ return game.DebugHeader("Hitori", [][2]string{167+ {"Status", status},168+ {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},169+ {"Grid Size", fmt.Sprintf("%d\u00d7%d", m.size, m.size)},170+ {"Shaded Cells", fmt.Sprintf("%d", shadedCount)},171+ {"Circled Cells", fmt.Sprintf("%d", circledCount)},172+ })173+}@@ -0,0 +1,48 @@
1+# Hitori2+3+Shade cells to eliminate duplicate numbers in every row and column.4+5+6+7+## How to Play8+9+The grid starts filled with numbers. Shade some cells black so that three10+rules are satisfied:11+12+1. **No duplicates**: No number appears more than once in any row or column among the unshaded (white) cells.13+2. **No adjacent shading**: No two shaded cells share an edge (diagonal is fine).14+3. **White connectivity**: All unshaded cells must form a single orthogonally connected region.15+16+The puzzle is solved when all three rules hold simultaneously.17+18+## Controls19+20+| Key | Action |21+|-----|--------|22+| Arrow keys / WASD / hjkl | Move cursor |23+| `x` | Shade cell (toggle) |24+| `z` | Circle cell as confirmed white (toggle) |25+| `Backspace` | Clear cell to unmarked |26+| `Ctrl+H` | Toggle full help |27+| `Ctrl+E` | Toggle debug overlay |28+| `Ctrl+R` | Reset puzzle |29+| `Ctrl+N` | Return to main menu |30+31+## Modes32+33+| Mode | Grid | Description |34+|------|------|-------------|35+| Mini | 5x5 | Gentle introduction |36+| Easy | 6x6 | Straightforward logic |37+| Medium | 8x8 | Moderate challenge |38+| Tricky | 9x9 | Requires careful deduction |39+| Hard | 10x10 | Advanced logic chains |40+| Expert | 12x12 | Maximum challenge |41+42+## Quick Start43+44+```bash45+puzzletea new hitori mini46+puzzletea new hitori medium47+puzzletea new hitori expert48+```@@ -0,0 +1,381 @@
1+package hitori2+3+import (4+ "errors"5+ "math/rand/v2"6+)7+8+type cellPos struct{ x, y int }9+10+// generateLatinSquare creates an NxN grid where each number 1..N appears11+// exactly once in each row and column. Uses cyclic shifts with row and12+// column shuffling.13+func generateLatinSquare(size int) grid {14+ firstRow := make([]rune, size)15+ for i := range size {16+ firstRow[i] = rune('1' + i)17+ }18+ rand.Shuffle(len(firstRow), func(i, j int) {19+ firstRow[i], firstRow[j] = firstRow[j], firstRow[i]20+ })21+22+ g := make(grid, size)23+ for y := range size {24+ g[y] = make([]rune, size)25+ for x := range size {26+ g[y][x] = firstRow[(x+y)%size]27+ }28+ }29+30+ cols := make([]int, size)31+ for i := range size {32+ cols[i] = i33+ }34+ rand.Shuffle(len(cols), func(i, j int) {35+ cols[i], cols[j] = cols[j], cols[i]36+ })37+38+ rowOrder := make([]int, size)39+ for i := range size {40+ rowOrder[i] = i41+ }42+ rand.Shuffle(len(rowOrder), func(i, j int) {43+ rowOrder[i], rowOrder[j] = rowOrder[j], rowOrder[i]44+ })45+46+ result := make(grid, size)47+ for y := range size {48+ result[y] = make([]rune, size)49+ for x := range size {50+ result[y][x] = g[rowOrder[y]][cols[x]]51+ }52+ }53+54+ return result55+}56+57+// generateValidMask determines which cells should be black. The mask58+// satisfies: no adjacent black cells, and all white cells connected.59+func generateValidMask(size int, blackRatio float64) [][]bool {60+ mask := make([][]bool, size)61+ for y := range size {62+ mask[y] = make([]bool, size)63+ }64+65+ targetBlack := int(blackRatio * float64(size*size))66+67+ positions := make([]cellPos, 0, size*size)68+ for y := range size {69+ for x := range size {70+ positions = append(positions, cellPos{x, y})71+ }72+ }73+ rand.Shuffle(len(positions), func(i, j int) {74+ positions[i], positions[j] = positions[j], positions[i]75+ })76+77+ blackCount := 078+ for _, p := range positions {79+ if blackCount >= targetBlack {80+ break81+ }82+83+ if hasOrthogonalNeighbor(mask, size, p.x, p.y) {84+ continue85+ }86+87+ mask[p.y][p.x] = true88+89+ if !whiteCellsConnected(mask, size) {90+ mask[p.y][p.x] = false91+ continue92+ }93+94+ blackCount++95+ }96+97+ return mask98+}99+100+func hasOrthogonalNeighbor(mask [][]bool, size, x, y int) bool {101+ if x > 0 && mask[y][x-1] {102+ return true103+ }104+ if x < size-1 && mask[y][x+1] {105+ return true106+ }107+ if y > 0 && mask[y-1][x] {108+ return true109+ }110+ if y < size-1 && mask[y+1][x] {111+ return true112+ }113+ return false114+}115+116+func whiteCellsConnected(mask [][]bool, size int) bool {117+ startX, startY := -1, -1118+ whiteCount := 0119+ for y := range size {120+ for x := range size {121+ if !mask[y][x] {122+ whiteCount++123+ if startX == -1 {124+ startX, startY = x, y125+ }126+ }127+ }128+ }129+ if whiteCount <= 1 {130+ return true131+ }132+133+ visited := make([][]bool, size)134+ for y := range size {135+ visited[y] = make([]bool, size)136+ }137+138+ queue := []cellPos{{startX, startY}}139+ visited[startY][startX] = true140+ visitedCount := 1141+142+ dirs := [4]cellPos{{0, -1}, {0, 1}, {-1, 0}, {1, 0}}143+ for len(queue) > 0 {144+ curr := queue[0]145+ queue = queue[1:]146+147+ for _, d := range dirs {148+ nx, ny := curr.x+d.x, curr.y+d.y149+ if nx >= 0 && nx < size && ny >= 0 && ny < size &&150+ !visited[ny][nx] && !mask[ny][nx] {151+ visited[ny][nx] = true152+ visitedCount++153+ queue = append(queue, cellPos{nx, ny})154+ }155+ }156+ }157+158+ return visitedCount == whiteCount159+}160+161+// constructPuzzle builds the initial puzzle grid from a Latin square and mask.162+// White cells keep their original number. Black cells get a duplicate from163+// a white cell in the same row.164+func constructPuzzle(baseGrid grid, mask [][]bool) grid {165+ size := len(baseGrid)166+ puzzle := baseGrid.clone()167+168+ for y := range size {169+ for x := range size {170+ if !mask[y][x] {171+ continue172+ }173+ // Collect white cell numbers in same row.174+ var rowNums []rune175+ for cx := range size {176+ if cx != x && !mask[y][cx] {177+ rowNums = append(rowNums, baseGrid[y][cx])178+ }179+ }180+ if len(rowNums) > 0 {181+ puzzle[y][x] = rowNums[rand.IntN(len(rowNums))]182+ continue183+ }184+ // Fallback: column numbers.185+ var colNums []rune186+ for cy := range size {187+ if cy != y && !mask[cy][x] {188+ colNums = append(colNums, baseGrid[cy][x])189+ }190+ }191+ if len(colNums) > 0 {192+ puzzle[y][x] = colNums[rand.IntN(len(colNums))]193+ }194+ }195+ }196+ return puzzle197+}198+199+// refinePuzzle tries different number assignments for black cells to achieve200+// a unique solution. It iterates through each black cell trying all possible201+// duplicate values, checking if the resulting puzzle has exactly one solution.202+func refinePuzzle(puzzle grid, mask [][]bool, size int) (grid, bool) {203+ // Collect black cell positions.204+ var blackCells []cellPos205+ for y := range size {206+ for x := range size {207+ if mask[y][x] {208+ blackCells = append(blackCells, cellPos{x, y})209+ }210+ }211+ }212+213+ if len(blackCells) == 0 {214+ return puzzle, countPuzzleSolutions(puzzle, size, 2) == 1215+ }216+217+ // Try to refine each black cell's value to reduce solution count.218+ refined := puzzle.clone()219+ for _, bc := range blackCells {220+ // Collect candidate numbers for this black cell.221+ candidates := cellCandidates(refined, mask, size, bc.x, bc.y)222+ rand.Shuffle(len(candidates), func(i, j int) {223+ candidates[i], candidates[j] = candidates[j], candidates[i]224+ })225+226+ best := refined[bc.y][bc.x]227+ bestCount := countPuzzleSolutions(refined, size, 3)228+229+ for _, num := range candidates {230+ if num == best {231+ continue232+ }233+ refined[bc.y][bc.x] = num234+ c := countPuzzleSolutions(refined, size, 3)235+ if c == 1 {236+ return refined, true237+ }238+ if c > 0 && c < bestCount {239+ best = num240+ bestCount = c241+ }242+ }243+ refined[bc.y][bc.x] = best244+ }245+246+ return refined, countPuzzleSolutions(refined, size, 2) == 1247+}248+249+// cellCandidates returns all valid number choices for a black cell at (x,y):250+// any number that appears among white cells in the same row or column.251+func cellCandidates(puzzle grid, mask [][]bool, size, x, y int) []rune {252+ seen := map[rune]bool{}253+ for cx := range size {254+ if cx != x && !mask[y][cx] {255+ seen[puzzle[y][cx]] = true256+ }257+ }258+ for cy := range size {259+ if cy != y && !mask[cy][x] {260+ seen[puzzle[cy][x]] = true261+ }262+ }263+ result := make([]rune, 0, len(seen))264+ for num := range seen {265+ result = append(result, num)266+ }267+ return result268+}269+270+// solverState is used during backtracking to track cell assignments.271+type solverState int272+273+const (274+ unknown solverState = iota275+ white276+ black277+)278+279+func countPuzzleSolutions(puzzle grid, size, limit int) int {280+ st := make([][]solverState, size)281+ for y := range size {282+ st[y] = make([]solverState, size)283+ }284+ return solveBT(puzzle, st, size, 0, limit)285+}286+287+func solveBT(puzzle grid, st [][]solverState, size, pos, limit int) int {288+ if pos == size*size {289+ marks := stateToMarks(st, size)290+ if allWhiteConnected(marks, size) {291+ return 1292+ }293+ return 0294+ }295+296+ x, y := pos%size, pos/size297+ count := 0298+299+ if canBeWhite(puzzle, st, size, x, y) {300+ st[y][x] = white301+ count += solveBT(puzzle, st, size, pos+1, limit-count)302+ st[y][x] = unknown303+ if count >= limit {304+ return count305+ }306+ }307+308+ if canBeBlack(st, size, x, y) {309+ st[y][x] = black310+ count += solveBT(puzzle, st, size, pos+1, limit-count)311+ st[y][x] = unknown312+ if count >= limit {313+ return count314+ }315+ }316+317+ return count318+}319+320+func canBeWhite(puzzle grid, st [][]solverState, size, x, y int) bool {321+ num := puzzle[y][x]322+ for i := range size {323+ if i != x && st[y][i] == white && puzzle[y][i] == num {324+ return false325+ }326+ }327+ for i := range size {328+ if i != y && st[i][x] == white && puzzle[i][x] == num {329+ return false330+ }331+ }332+ return true333+}334+335+func canBeBlack(st [][]solverState, size, x, y int) bool {336+ if x > 0 && st[y][x-1] == black {337+ return false338+ }339+ if y > 0 && st[y-1][x] == black {340+ return false341+ }342+ return true343+}344+345+func stateToMarks(st [][]solverState, size int) [][]cellMark {346+ marks := make([][]cellMark, size)347+ for y := range size {348+ marks[y] = make([]cellMark, size)349+ for x := range size {350+ if st[y][x] == black {351+ marks[y][x] = shaded352+ }353+ }354+ }355+ return marks356+}357+358+// Generate creates a Hitori puzzle of the given size. Returns the puzzle359+// numbers grid or an error if generation fails after all attempts.360+func Generate(size int, blackRatio float64) (grid, error) {361+ const maxAttempts = 200362+363+ for range maxAttempts {364+ baseGrid := generateLatinSquare(size)365+ mask := generateValidMask(size, blackRatio)366+ puzzle := constructPuzzle(baseGrid, mask)367+368+ // First try the random construction.369+ if countPuzzleSolutions(puzzle, size, 2) == 1 {370+ return puzzle, nil371+ }372+373+ // Refine by trying different values for black cells.374+ refined, ok := refinePuzzle(puzzle, mask, size)375+ if ok {376+ return refined, nil377+ }378+ }379+380+ return nil, errors.New("failed to generate hitori puzzle after max attempts")381+}@@ -0,0 +1,103 @@
1+package hitori2+3+import "strings"4+5+type cellMark int6+7+const (8+ unmarked cellMark = iota9+ shaded10+ circled11+)12+13+type (14+ grid [][]rune15+ state string16+)17+18+func newGrid(s state) grid {19+ rows := strings.Split(string(s), "\n")20+ g := make(grid, len(rows))21+ for i, row := range rows {22+ g[i] = []rune(row)23+ }24+ return g25+}26+27+func (g grid) String() string {28+ rows := make([]string, len(g))29+ for i, row := range g {30+ rows[i] = string(row)31+ }32+ return strings.Join(rows, "\n")33+}34+35+func (g grid) clone() grid {36+ c := make(grid, len(g))37+ for i, row := range g {38+ c[i] = make([]rune, len(row))39+ copy(c[i], row)40+ }41+ return c42+}43+44+func newMarks(size int) [][]cellMark {45+ marks := make([][]cellMark, size)46+ for y := range size {47+ marks[y] = make([]cellMark, size)48+ }49+ return marks50+}51+52+func cloneMarks(marks [][]cellMark) [][]cellMark {53+ c := make([][]cellMark, len(marks))54+ for y, row := range marks {55+ c[y] = make([]cellMark, len(row))56+ copy(c[y], row)57+ }58+ return c59+}60+61+func serializeMarks(marks [][]cellMark) string {62+ var b strings.Builder63+ for y, row := range marks {64+ for _, m := range row {65+ switch m {66+ case shaded:67+ b.WriteByte('X')68+ case circled:69+ b.WriteByte('O')70+ default:71+ b.WriteByte('.')72+ }73+ }74+ if y < len(marks)-1 {75+ b.WriteByte('\n')76+ }77+ }78+ return b.String()79+}80+81+func deserializeMarks(s string, size int) [][]cellMark {82+ rows := strings.Split(s, "\n")83+ marks := make([][]cellMark, size)84+ for y := range size {85+ marks[y] = make([]cellMark, size)86+ if y < len(rows) {87+ for x, r := range rows[y] {88+ if x >= size {89+ break90+ }91+ switch r {92+ case 'X':93+ marks[y][x] = shaded94+ case 'O':95+ marks[y][x] = circled96+ default:97+ marks[y][x] = unmarked98+ }99+ }100+ }101+ }102+ return marks103+}@@ -0,0 +1,653 @@
1+package hitori2+3+import (4+ "encoding/json"5+ "testing"6+7+ "github.com/FelineStateMachine/puzzletea/game"8+)9+10+// --- Helpers ---11+12+func makeGrid(rows ...string) grid {13+ g := make(grid, len(rows))14+ for i, row := range rows {15+ g[i] = []rune(row)16+ }17+ return g18+}19+20+func makeMarks(rows ...string) [][]cellMark {21+ marks := make([][]cellMark, len(rows))22+ for y, row := range rows {23+ marks[y] = make([]cellMark, len(row))24+ for x, r := range row {25+ switch r {26+ case 'X':27+ marks[y][x] = shaded28+ case 'O':29+ marks[y][x] = circled30+ default:31+ marks[y][x] = unmarked32+ }33+ }34+ }35+ return marks36+}37+38+func testMode(size int) HitoriMode {39+ return NewMode("Test", "test mode", size, 0.25)40+}41+42+// --- Grid and serialization (P0) ---43+44+func TestNewGrid(t *testing.T) {45+ g := newGrid("123\n456\n789")46+ if len(g) != 3 {47+ t.Fatalf("expected 3 rows, got %d", len(g))48+ }49+ if string(g[0]) != "123" {50+ t.Errorf("row 0 = %q, want %q", string(g[0]), "123")51+ }52+ if string(g[2]) != "789" {53+ t.Errorf("row 2 = %q, want %q", string(g[2]), "789")54+ }55+}56+57+func TestGridString(t *testing.T) {58+ g := makeGrid("123", "456", "789")59+ got := g.String()60+ want := "123\n456\n789"61+ if got != want {62+ t.Errorf("grid.String() = %q, want %q", got, want)63+ }64+}65+66+func TestGridClone(t *testing.T) {67+ g := makeGrid("123", "456")68+ c := g.clone()69+ c[0][0] = 'X'70+ if g[0][0] == 'X' {71+ t.Error("clone shares memory with original")72+ }73+}74+75+func TestSerializeDeserializeMarks(t *testing.T) {76+ marks := makeMarks("..X", "O..", "XO.")77+ serialized := serializeMarks(marks)78+ deserialized := deserializeMarks(serialized, 3)79+80+ for y := range 3 {81+ for x := range 3 {82+ if deserialized[y][x] != marks[y][x] {83+ t.Errorf("mark[%d][%d] = %d, want %d", y, x, deserialized[y][x], marks[y][x])84+ }85+ }86+ }87+}88+89+func TestCloneMarks(t *testing.T) {90+ marks := makeMarks("X.", ".O")91+ c := cloneMarks(marks)92+ c[0][0] = unmarked93+ if marks[0][0] != shaded {94+ t.Error("cloneMarks shares memory with original")95+ }96+}97+98+// --- Validation: hasNoDuplicatesInRows (P0) ---99+100+func TestHasNoDuplicatesInRows(t *testing.T) {101+ tests := []struct {102+ name string103+ numbers grid104+ marks [][]cellMark105+ want bool106+ }{107+ {108+ name: "no duplicates",109+ numbers: makeGrid("123", "231", "312"),110+ marks: makeMarks("...", "...", "..."),111+ want: true,112+ },113+ {114+ name: "duplicate in row but one is shaded",115+ numbers: makeGrid("113", "231", "312"),116+ marks: makeMarks("X..", "...", "..."),117+ want: true,118+ },119+ {120+ name: "duplicate in row neither shaded",121+ numbers: makeGrid("113", "231", "312"),122+ marks: makeMarks("...", "...", "..."),123+ want: false,124+ },125+ }126+127+ for _, tt := range tests {128+ t.Run(tt.name, func(t *testing.T) {129+ got := hasNoDuplicatesInRows(tt.numbers, tt.marks, len(tt.numbers))130+ if got != tt.want {131+ t.Errorf("hasNoDuplicatesInRows = %v, want %v", got, tt.want)132+ }133+ })134+ }135+}136+137+// --- Validation: hasNoDuplicatesInCols (P0) ---138+139+func TestHasNoDuplicatesInCols(t *testing.T) {140+ tests := []struct {141+ name string142+ numbers grid143+ marks [][]cellMark144+ want bool145+ }{146+ {147+ name: "no duplicates in columns",148+ numbers: makeGrid("123", "231", "312"),149+ marks: makeMarks("...", "...", "..."),150+ want: true,151+ },152+ {153+ name: "duplicate in column but one shaded",154+ numbers: makeGrid("123", "131", "312"),155+ marks: makeMarks("...", "X..", "..."),156+ want: true,157+ },158+ {159+ name: "duplicate in column neither shaded",160+ numbers: makeGrid("123", "131", "312"),161+ marks: makeMarks("...", "...", "..."),162+ want: false,163+ },164+ }165+166+ for _, tt := range tests {167+ t.Run(tt.name, func(t *testing.T) {168+ got := hasNoDuplicatesInCols(tt.numbers, tt.marks, len(tt.numbers))169+ if got != tt.want {170+ t.Errorf("hasNoDuplicatesInCols = %v, want %v", got, tt.want)171+ }172+ })173+ }174+}175+176+// --- Validation: hasNoAdjacentShaded (P0) ---177+178+func TestHasNoAdjacentShaded(t *testing.T) {179+ tests := []struct {180+ name string181+ marks [][]cellMark182+ want bool183+ }{184+ {185+ name: "no shaded cells",186+ marks: makeMarks("...", "...", "..."),187+ want: true,188+ },189+ {190+ name: "diagonal shaded OK",191+ marks: makeMarks("X..", ".X.", "..X"),192+ want: true,193+ },194+ {195+ name: "horizontal adjacent",196+ marks: makeMarks("XX.", "...", "..."),197+ want: false,198+ },199+ {200+ name: "vertical adjacent",201+ marks: makeMarks("X..", "X..", "..."),202+ want: false,203+ },204+ {205+ name: "single shaded",206+ marks: makeMarks("...", ".X.", "..."),207+ want: true,208+ },209+ {210+ name: "checkerboard pattern",211+ marks: makeMarks("X.X.", ".X.X", "X.X.", ".X.X"),212+ want: true,213+ },214+ }215+216+ for _, tt := range tests {217+ t.Run(tt.name, func(t *testing.T) {218+ got := hasNoAdjacentShaded(tt.marks, len(tt.marks))219+ if got != tt.want {220+ t.Errorf("hasNoAdjacentShaded = %v, want %v", got, tt.want)221+ }222+ })223+ }224+}225+226+// --- Validation: allWhiteConnected (P0) ---227+228+func TestAllWhiteConnected(t *testing.T) {229+ tests := []struct {230+ name string231+ marks [][]cellMark232+ want bool233+ }{234+ {235+ name: "all white",236+ marks: makeMarks("...", "...", "..."),237+ want: true,238+ },239+ {240+ name: "connected with some shaded",241+ marks: makeMarks("X..", "...", "..X"),242+ want: true,243+ },244+ {245+ name: "disconnected by shaded column",246+ marks: makeMarks(".X.", ".X.", ".X."),247+ want: false,248+ },249+ {250+ name: "single white cell surrounded",251+ marks: makeMarks("XXX", "X.X", "XXX"),252+ want: true,253+ },254+ }255+256+ for _, tt := range tests {257+ t.Run(tt.name, func(t *testing.T) {258+ got := allWhiteConnected(tt.marks, len(tt.marks))259+ if got != tt.want {260+ t.Errorf("allWhiteConnected = %v, want %v", got, tt.want)261+ }262+ })263+ }264+}265+266+// --- isValidSolution (P0) ---267+268+func TestIsValidSolution(t *testing.T) {269+ // A valid 4x4 Hitori solution:270+ // Numbers: 1 2 1 3 Marks: X . . .271+ // 2 3 4 1 . . . .272+ // 3 1 2 4 . . . .273+ // 1 4 3 2 . . X .274+ // After shading: row 0 has [2,1,3], row 3 has [1,4,2].275+ // No dups in rows/cols, no adjacent shaded, white connected.276+ numbers := makeGrid("1213", "2341", "3124", "1432")277+ marks := makeMarks("X...", "....", "....", "..X.")278+279+ if !isValidSolution(numbers, marks, 4) {280+ t.Error("expected valid solution")281+ }282+}283+284+func TestIsValidSolution_InvalidDuplicate(t *testing.T) {285+ numbers := makeGrid("1213", "2341", "3124", "1432")286+ marks := makeMarks("....", "....", "....", "....") // no shading, row 0 has dup 1287+ if isValidSolution(numbers, marks, 4) {288+ t.Error("expected invalid: duplicate 1 in row 0")289+ }290+}291+292+func TestIsValidSolution_InvalidAdjacent(t *testing.T) {293+ numbers := makeGrid("1213", "2341", "3124", "1432")294+ marks := makeMarks("XX..", "....", "....", "....") // adjacent shaded295+ if isValidSolution(numbers, marks, 4) {296+ t.Error("expected invalid: adjacent shaded cells")297+ }298+}299+300+// --- Latin square generation (P1) ---301+302+func TestGenerateLatinSquare(t *testing.T) {303+ if testing.Short() {304+ t.Skip("skipping generator test in short mode")305+ }306+307+ sizes := []int{4, 5, 6, 8}308+ for _, size := range sizes {309+ t.Run("", func(t *testing.T) {310+ g := generateLatinSquare(size)311+312+ // Check rows have all numbers.313+ for y := range size {314+ seen := map[rune]bool{}315+ for x := range size {316+ seen[g[y][x]] = true317+ }318+ if len(seen) != size {319+ t.Errorf("row %d has %d unique numbers, want %d", y, len(seen), size)320+ }321+ }322+323+ // Check columns have all numbers.324+ for x := range size {325+ seen := map[rune]bool{}326+ for y := range size {327+ seen[g[y][x]] = true328+ }329+ if len(seen) != size {330+ t.Errorf("col %d has %d unique numbers, want %d", x, len(seen), size)331+ }332+ }333+ })334+ }335+}336+337+// --- Mask generation (P1) ---338+339+func TestGenerateValidMask(t *testing.T) {340+ if testing.Short() {341+ t.Skip("skipping generator test in short mode")342+ }343+344+ size := 6345+ mask := generateValidMask(size, 0.25)346+347+ // No adjacent black cells.348+ for y := range size {349+ for x := range size {350+ if !mask[y][x] {351+ continue352+ }353+ if x > 0 && mask[y][x-1] {354+ t.Errorf("adjacent black cells at (%d,%d) and (%d,%d)", x, y, x-1, y)355+ }356+ if y > 0 && mask[y-1][x] {357+ t.Errorf("adjacent black cells at (%d,%d) and (%d,%d)", x, y, x, y-1)358+ }359+ }360+ }361+362+ // White cells connected.363+ if !whiteCellsConnected(mask, size) {364+ t.Error("white cells are not connected")365+ }366+}367+368+// --- Save/load round-trip (P1) ---369+370+func TestSaveLoadRoundTrip(t *testing.T) {371+ numbers := makeGrid("1213", "2341", "3124", "1432")372+ mode := testMode(4)373+ gamer, err := New(mode, numbers)374+ if err != nil {375+ t.Fatalf("New: %v", err)376+ }377+378+ data, err := gamer.GetSave()379+ if err != nil {380+ t.Fatalf("GetSave: %v", err)381+ }382+383+ loaded, err := ImportModel(data)384+ if err != nil {385+ t.Fatalf("ImportModel: %v", err)386+ }387+388+ // Verify grid preserved.389+ if loaded.numbers.String() != numbers.String() {390+ t.Error("numbers grid not preserved after round-trip")391+ }392+ if loaded.size != 4 {393+ t.Errorf("size = %d, want 4", loaded.size)394+ }395+}396+397+func TestSaveLoadRoundTrip_WithMarks(t *testing.T) {398+ numbers := makeGrid("1213", "2341", "3124", "1432")399+ mode := testMode(4)400+ gamer, err := New(mode, numbers)401+ if err != nil {402+ t.Fatalf("New: %v", err)403+ }404+405+ // Apply some marks via the model.406+ m := gamer.(Model)407+ m.marks[0][0] = shaded408+ m.marks[1][1] = circled409+410+ data, err := m.GetSave()411+ if err != nil {412+ t.Fatalf("GetSave: %v", err)413+ }414+415+ loaded, err := ImportModel(data)416+ if err != nil {417+ t.Fatalf("ImportModel: %v", err)418+ }419+420+ if loaded.marks[0][0] != shaded {421+ t.Error("shaded mark not preserved")422+ }423+ if loaded.marks[1][1] != circled {424+ t.Error("circled mark not preserved")425+ }426+}427+428+func TestImportModel_InvalidJSON(t *testing.T) {429+ _, err := ImportModel([]byte("not json"))430+ if err == nil {431+ t.Error("expected error for invalid JSON")432+ }433+}434+435+func TestImportModel_InvalidSize(t *testing.T) {436+ data, _ := json.Marshal(Save{Size: 0, Numbers: "", Marks: ""})437+ _, err := ImportModel(data)438+ if err == nil {439+ t.Error("expected error for zero size")440+ }441+}442+443+// --- Model construction (P0) ---444+445+func TestNew_ValidGrid(t *testing.T) {446+ mode := testMode(3)447+ numbers := makeGrid("123", "231", "312")448+ g, err := New(mode, numbers)449+ if err != nil {450+ t.Fatalf("New: %v", err)451+ }452+ if g.IsSolved() {453+ t.Error("new game should not be solved")454+ }455+}456+457+func TestNew_InvalidSize(t *testing.T) {458+ mode := testMode(0)459+ _, err := New(mode, makeGrid())460+ if err == nil {461+ t.Error("expected error for zero size")462+ }463+}464+465+func TestNew_RowMismatch(t *testing.T) {466+ mode := testMode(3)467+ _, err := New(mode, makeGrid("12", "23"))468+ if err == nil {469+ t.Error("expected error for row count mismatch")470+ }471+}472+473+func TestNew_ColumnMismatch(t *testing.T) {474+ mode := testMode(3)475+ _, err := New(mode, makeGrid("12", "23", "31"))476+ if err == nil {477+ t.Error("expected error for column width mismatch")478+ }479+}480+481+// --- Model methods (P0) ---482+483+func TestModel_Reset(t *testing.T) {484+ mode := testMode(3)485+ numbers := makeGrid("123", "231", "312")486+ g, _ := New(mode, numbers)487+488+ m := g.(Model)489+ m.marks[0][0] = shaded490+ m.marks[1][1] = circled491+492+ reset := m.Reset().(Model)493+ if reset.marks[0][0] != unmarked || reset.marks[1][1] != unmarked {494+ t.Error("Reset did not clear marks")495+ }496+}497+498+func TestModel_SetTitle(t *testing.T) {499+ mode := testMode(3)500+ numbers := makeGrid("123", "231", "312")501+ g, _ := New(mode, numbers)502+503+ updated := g.SetTitle("Custom Title").(Model)504+ if updated.modeTitle != "Custom Title" {505+ t.Errorf("modeTitle = %q, want %q", updated.modeTitle, "Custom Title")506+ }507+}508+509+func TestModel_GetFullHelp(t *testing.T) {510+ mode := testMode(3)511+ numbers := makeGrid("123", "231", "312")512+ g, _ := New(mode, numbers)513+ help := g.GetFullHelp()514+ if len(help) != 2 {515+ t.Errorf("expected 2 help groups, got %d", len(help))516+ }517+}518+519+func TestModel_GetDebugInfo(t *testing.T) {520+ mode := testMode(3)521+ numbers := makeGrid("123", "231", "312")522+ g, _ := New(mode, numbers)523+ info := g.GetDebugInfo()524+ if info == "" {525+ t.Error("expected non-empty debug info")526+ }527+}528+529+func TestModel_View(t *testing.T) {530+ mode := testMode(3)531+ numbers := makeGrid("123", "231", "312")532+ g, _ := New(mode, numbers)533+ view := g.View()534+ if view == "" {535+ t.Error("expected non-empty view")536+ }537+}538+539+// --- Gamer interface compliance (P0) ---540+541+func TestGamerInterface(t *testing.T) {542+ var _ game.Gamer = Model{}543+}544+545+// --- Spawner/Mode interface compliance (P0) ---546+547+func TestModeInterfaces(t *testing.T) {548+ var _ game.Mode = HitoriMode{}549+ var _ game.Spawner = HitoriMode{}550+}551+552+func TestModeTitleDescription(t *testing.T) {553+ mode := NewMode("Easy", "6x6 grid", 6, 0.22)554+ if mode.Title() != "Easy" {555+ t.Errorf("Title = %q, want %q", mode.Title(), "Easy")556+ }557+ if mode.Description() != "6x6 grid" {558+ t.Errorf("Description = %q, want %q", mode.Description(), "6x6 grid")559+ }560+}561+562+// --- Puzzle generation (P2) ---563+564+func TestGenerate_SmallPuzzle(t *testing.T) {565+ if testing.Short() {566+ t.Skip("skipping generator test in short mode")567+ }568+569+ puzzle, err := Generate(5, 0.32)570+ if err != nil {571+ t.Fatalf("Generate: %v", err)572+ }573+ if len(puzzle) != 5 {574+ t.Errorf("puzzle has %d rows, want 5", len(puzzle))575+ }576+ for y, row := range puzzle {577+ if len(row) != 5 {578+ t.Errorf("row %d has %d cols, want 5", y, len(row))579+ }580+ for x, num := range row {581+ if num < '1' || num > '5' {582+ t.Errorf("puzzle[%d][%d] = %c, want 1-5", y, x, num)583+ }584+ }585+ }586+587+ // Verify it has exactly one solution.588+ solutions := countPuzzleSolutions(puzzle, 5, 2)589+ if solutions != 1 {590+ t.Errorf("puzzle has %d solutions, want exactly 1", solutions)591+ }592+}593+594+func TestSpawn_Mini(t *testing.T) {595+ if testing.Short() {596+ t.Skip("skipping generator test in short mode")597+ }598+599+ mode := NewMode("Mini", "5x5 grid", 5, 0.32)600+ g, err := mode.Spawn()601+ if err != nil {602+ t.Fatalf("Spawn: %v", err)603+ }604+ if g == nil {605+ t.Fatal("Spawn returned nil game")606+ }607+ if g.IsSolved() {608+ t.Error("freshly spawned game should not be solved")609+ }610+}611+612+// --- Solver validation (P1) ---613+614+func TestCountPuzzleSolutions_KnownPuzzle(t *testing.T) {615+ if testing.Short() {616+ t.Skip("skipping solver test in short mode")617+ }618+619+ // A simple 4x4 puzzle with known unique solution.620+ // Numbers: 1 2 1 3621+ // 2 3 4 1622+ // 3 1 2 4623+ // 1 4 3 2624+ // Solution: shade (0,0) and (2,3) -> unique625+ puzzle := makeGrid("1213", "2341", "3124", "1432")626+627+ solutions := countPuzzleSolutions(puzzle, 4, 2)628+ if solutions < 1 {629+ t.Errorf("expected at least 1 solution, got %d", solutions)630+ }631+}632+633+// --- Registration (P0) ---634+635+func TestRegistration(t *testing.T) {636+ fn, ok := game.Registry["Hitori"]637+ if !ok {638+ t.Fatal("Hitori not registered in game.Registry")639+ }640+641+ numbers := makeGrid("123", "231", "312")642+ mode := testMode(3)643+ g, _ := New(mode, numbers)644+ data, _ := g.GetSave()645+646+ loaded, err := fn(data)647+ if err != nil {648+ t.Fatalf("Registry import: %v", err)649+ }650+ if loaded == nil {651+ t.Fatal("Registry import returned nil")652+ }653+}@@ -0,0 +1,52 @@
1+package hitori2+3+import (4+ "github.com/FelineStateMachine/puzzletea/game"5+6+ "github.com/charmbracelet/bubbles/key"7+)8+9+// KeyMap defines the keybindings for Hitori.10+type KeyMap struct {11+ game.CursorKeyMap12+ ShadeCell key.Binding13+ CircleCell key.Binding14+ ClearCell key.Binding15+}16+17+// DefaultKeyMap provides the standard keybindings.18+var DefaultKeyMap = KeyMap{19+ CursorKeyMap: game.DefaultCursorKeyMap,20+ ShadeCell: key.NewBinding(21+ key.WithKeys("x"),22+ key.WithHelp("x", "Shade"),23+ ),24+ CircleCell: key.NewBinding(25+ key.WithKeys("z"),26+ key.WithHelp("z", "Circle"),27+ ),28+ ClearCell: key.NewBinding(29+ key.WithKeys("backspace", "delete"),30+ key.WithHelp("bkspc", "Clear"),31+ ),32+}33+34+func (m *Model) updateKeyBindings() {35+ m.keys.Up.SetEnabled(m.cursor.Y > 0)36+ m.keys.Down.SetEnabled(m.cursor.Y < m.size-1)37+ m.keys.Left.SetEnabled(m.cursor.X > 0)38+ m.keys.Right.SetEnabled(m.cursor.X < m.size-1)39+40+ solved := m.solved41+ m.keys.ShadeCell.SetEnabled(!solved)42+ m.keys.CircleCell.SetEnabled(!solved)43+ m.keys.ClearCell.SetEnabled(!solved)44+}45+46+// GetFullHelp implements game.Gamer.47+func (m Model) GetFullHelp() [][]key.Binding {48+ return [][]key.Binding{49+ {m.keys.Up, m.keys.Down, m.keys.Left, m.keys.Right},50+ {m.keys.ShadeCell, m.keys.CircleCell, m.keys.ClearCell},51+ }52+}@@ -0,0 +1,122 @@
1+package hitori2+3+// hasNoDuplicatesInRows returns true if no row contains a duplicate number4+// among the non-shaded cells.5+func hasNoDuplicatesInRows(numbers grid, marks [][]cellMark, size int) bool {6+ for y := range size {7+ seen := map[rune]bool{}8+ for x := range size {9+ if marks[y][x] == shaded {10+ continue11+ }12+ num := numbers[y][x]13+ if seen[num] {14+ return false15+ }16+ seen[num] = true17+ }18+ }19+ return true20+}21+22+// hasNoDuplicatesInCols returns true if no column contains a duplicate number23+// among the non-shaded cells.24+func hasNoDuplicatesInCols(numbers grid, marks [][]cellMark, size int) bool {25+ for x := range size {26+ seen := map[rune]bool{}27+ for y := range size {28+ if marks[y][x] == shaded {29+ continue30+ }31+ num := numbers[y][x]32+ if seen[num] {33+ return false34+ }35+ seen[num] = true36+ }37+ }38+ return true39+}40+41+// hasNoAdjacentShaded returns true if no two shaded cells share an edge.42+func hasNoAdjacentShaded(marks [][]cellMark, size int) bool {43+ for y := range size {44+ for x := range size {45+ if marks[y][x] != shaded {46+ continue47+ }48+ if x > 0 && marks[y][x-1] == shaded {49+ return false50+ }51+ if x < size-1 && marks[y][x+1] == shaded {52+ return false53+ }54+ if y > 0 && marks[y-1][x] == shaded {55+ return false56+ }57+ if y < size-1 && marks[y+1][x] == shaded {58+ return false59+ }60+ }61+ }62+ return true63+}64+65+// allWhiteConnected returns true if all non-shaded cells form a single66+// orthogonally connected region. Uses BFS.67+func allWhiteConnected(marks [][]cellMark, size int) bool {68+ // Find first non-shaded cell.69+ startX, startY := -1, -170+ whiteCount := 071+ for y := range size {72+ for x := range size {73+ if marks[y][x] != shaded {74+ whiteCount++75+ if startX == -1 {76+ startX, startY = x, y77+ }78+ }79+ }80+ }81+82+ if whiteCount <= 1 {83+ return true84+ }85+86+ // BFS from the first white cell.87+ type pos struct{ x, y int }88+ visited := make([][]bool, size)89+ for y := range size {90+ visited[y] = make([]bool, size)91+ }92+93+ queue := []pos{{startX, startY}}94+ visited[startY][startX] = true95+ visitedCount := 196+97+ dirs := [4]pos{{0, -1}, {0, 1}, {-1, 0}, {1, 0}}98+ for len(queue) > 0 {99+ curr := queue[0]100+ queue = queue[1:]101+102+ for _, d := range dirs {103+ nx, ny := curr.x+d.x, curr.y+d.y104+ if nx >= 0 && nx < size && ny >= 0 && ny < size &&105+ !visited[ny][nx] && marks[ny][nx] != shaded {106+ visited[ny][nx] = true107+ visitedCount++108+ queue = append(queue, pos{nx, ny})109+ }110+ }111+ }112+113+ return visitedCount == whiteCount114+}115+116+// isValidSolution checks all three Hitori constraints.117+func isValidSolution(numbers grid, marks [][]cellMark, size int) bool {118+ return hasNoDuplicatesInRows(numbers, marks, size) &&119+ hasNoDuplicatesInCols(numbers, marks, size) &&120+ hasNoAdjacentShaded(marks, size) &&121+ allWhiteConnected(marks, size)122+}@@ -0,0 +1,138 @@
1+package hitori2+3+import (4+ "fmt"5+6+ "github.com/FelineStateMachine/puzzletea/game"7+8+ "github.com/charmbracelet/lipgloss"9+)10+11+var (12+ baseStyle = lipgloss.NewStyle()13+ backgroundColor = lipgloss.AdaptiveColor{Light: "254", Dark: "235"}14+15+ numberStyle = baseStyle.16+ Foreground(lipgloss.AdaptiveColor{Light: "236", Dark: "250"}).17+ Background(backgroundColor)18+19+ shadedStyle = baseStyle.20+ Foreground(lipgloss.AdaptiveColor{Light: "254", Dark: "238"}).21+ Background(lipgloss.AdaptiveColor{Light: "240", Dark: "238"})22+23+ circledStyle = baseStyle.24+ Foreground(lipgloss.AdaptiveColor{Light: "25", Dark: "75"}).25+ Background(backgroundColor)26+27+ cursorStyle = baseStyle.28+ Bold(true).29+ Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "235"}).30+ Background(lipgloss.AdaptiveColor{Light: "130", Dark: "214"})31+32+ crosshairBG = lipgloss.AdaptiveColor{Light: "254", Dark: "237"}33+ solvedBG = lipgloss.AdaptiveColor{Light: "151", Dark: "22"}34+35+ borderFG = lipgloss.AdaptiveColor{Light: "250", Dark: "240"}36+37+ statusBarStyle = lipgloss.NewStyle().38+ Foreground(lipgloss.AdaptiveColor{Light: "244", Dark: "244"}).39+ MarginTop(1)40+)41+42+const cellWidth = 343+44+func cellView(num rune, mark cellMark, isCursor, inCursorRow, inCursorCol, solved bool) string {45+ var s lipgloss.Style46+ var display string47+48+ switch mark {49+ case shaded:50+ s = shadedStyle51+ display = " \u25A0 " // ■52+ case circled:53+ s = circledStyle54+ display = fmt.Sprintf(" %c ", num)55+ default:56+ s = numberStyle57+ display = fmt.Sprintf(" %c ", num)58+ }59+60+ if isCursor && !solved {61+ s = s.Background(cursorStyle.GetBackground()).62+ Foreground(cursorStyle.GetForeground()).63+ Bold(true)64+ } else if !solved && (inCursorRow || inCursorCol) {65+ s = s.Background(crosshairBG)66+ }67+68+ if solved {69+ s = s.Background(solvedBG)70+ }71+72+ return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(display)73+}74+75+func gridView(numbers grid, marks [][]cellMark, c game.Cursor, solved bool) string {76+ w := 077+ if len(numbers) > 0 {78+ w = len(numbers[0])79+ }80+81+ var rows []string82+ for y, row := range numbers {83+ var rowCells []string84+ inCursorRow := y == c.Y85+86+ rowCells = append(rowCells, borderChar("\u2502", solved, !solved && inCursorRow))87+88+ for x, num := range row {89+ isCursor := x == c.X && y == c.Y90+ inCursorCol := x == c.X91+ cell := cellView(num, marks[y][x], isCursor, inCursorRow, inCursorCol, solved)92+ rowCells = append(rowCells, cell)93+ }94+95+ rowCells = append(rowCells, borderChar("\u2502", solved, !solved && inCursorRow))96+ rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, rowCells...))97+ }98+99+ topRow := hBorderRow(w, c.X, "\u256D", "\u256E", solved)100+ botRow := hBorderRow(w, c.X, "\u2570", "\u256F", solved)101+102+ return lipgloss.JoinVertical(lipgloss.Left, topRow, lipgloss.JoinVertical(lipgloss.Left, rows...), botRow)103+}104+105+func borderChar(ch string, solved, highlight bool) string {106+ s := baseStyle.Foreground(borderFG).Background(backgroundColor)107+ if solved {108+ s = baseStyle.Foreground(lipgloss.AdaptiveColor{Light: "22", Dark: "149"}).Background(solvedBG)109+ } else if highlight {110+ s = s.Background(crosshairBG)111+ }112+ return s.Render(ch)113+}114+115+func hBorderRow(w, cursorX int, left, right string, solved bool) string {116+ var parts []string117+ parts = append(parts, borderChar(left, solved, false))118+ segment := "\u2500\u2500\u2500" // ───119+ for x := range w {120+ highlight := !solved && x == cursorX121+ s := baseStyle.Foreground(borderFG).Background(backgroundColor)122+ if solved {123+ s = baseStyle.Foreground(lipgloss.AdaptiveColor{Light: "22", Dark: "149"}).Background(solvedBG)124+ } else if highlight {125+ s = s.Background(crosshairBG)126+ }127+ parts = append(parts, s.Render(segment))128+ }129+ parts = append(parts, borderChar(right, solved, false))130+ return lipgloss.JoinHorizontal(lipgloss.Top, parts...)131+}132+133+func statusBarView(showFullHelp bool) string {134+ if showFullHelp {135+ return statusBarStyle.Render("arrows/wasd: move x: shade z: circle bkspc: clear ctrl+n: menu ctrl+r: reset ctrl+h: help")136+ }137+ return statusBarStyle.Render("x: shade z: circle bkspc: clear")138+}@@ -8,6 +8,7 @@ import (
88 99 "github.com/FelineStateMachine/puzzletea/game"1010 "github.com/FelineStateMachine/puzzletea/hashiwokakero"11+ "github.com/FelineStateMachine/puzzletea/hitori"1112 "github.com/FelineStateMachine/puzzletea/lightsout"1213 "github.com/FelineStateMachine/puzzletea/namegen"1314 "github.com/FelineStateMachine/puzzletea/nonogram"@@ -37,6 +38,7 @@ var (
3738 3839 GameCategories = []list.Item{3940 game.Category{Name: "Hashiwokakero", Desc: "Connect islands with bridges.", Modes: hashiwokakero.Modes},41+ game.Category{Name: "Hitori", Desc: "Shade cells to eliminate duplicates.", Modes: hitori.Modes},4042 game.Category{Name: "Lights Out", Desc: "Turn off all the lights.", Modes: lightsout.Modes},4143 game.Category{Name: "Nonogram", Desc: "Fill cells to match row and column hints.", Modes: nonogram.Modes},4244 game.Category{Name: "Sudoku", Desc: "Fill the 9x9 grid following sudoku rules.", Modes: sudoku.Modes},@@ -0,0 +1,415 @@
1+# Hitori Puzzle Generation2+3+This document outlines a strategy for generating NxN Hitori puzzles with a guaranteed unique solution.4+5+## Overview6+7+Hitori puzzles start with a **complete grid of numbers**. The player must shade some cells black so that:8+1. No number appears twice in any row or column among the unshaded (white) cells9+2. No two black cells share an edge (diagonal is allowed)10+3. All white cells remain orthogonally connected11+12+The generation approach:13+1. **Create a Latin Square** (valid solved state with no duplicates in rows/cols)14+2. **Generate a valid cell mask** (which cells are black, obeying Hitori rules)15+3. **Construct the puzzle** (add duplicates for black cells in the solution)16+4. **Verify uniqueness** via backtracking solver17+18+---19+20+## Step 1: Create a Latin Square21+22+A Latin Square is an NxN grid where each number (1-N) appears exactly once in each row and column. This forms the "white cell" solution.23+24+### Algorithm25+26+```go27+func generateLatinSquare(size int) grid {28+ // 1. Create first row with shuffled 1-N29+ firstRow := make([]rune, size)30+ nums := make([]rune, size)31+ for i := range size {32+ nums[i] = rune('1' + i)33+ }34+ rand.Shuffle(len(nums), func(i, j int) { nums[i], nums[j] = nums[j], nums[i] })35+ copy(firstRow, nums)36+37+ // 2. Generate subsequent rows by cyclic shift38+ g := make(grid, size)39+ for y := range size {40+ g[y] = make([]rune, size)41+ for x := range size {42+ g[y][x] = firstRow[(x+y)%size]43+ }44+ }45+46+ // 3. Shuffle columns (preserves Latin property)47+ cols := make([]int, size)48+ for i := range size { cols[i] = i }49+ rand.Shuffle(len(cols), func(i, j int) { cols[i], cols[j] = cols[j], cols[i] })50+51+ result := make(grid, size)52+ for y := range size {53+ result[y] = make([]rune, size)54+ for x := range size {55+ result[y][x] = g[y][cols[x]]56+ }57+ }58+59+ return result60+}61+```62+63+### Why this works64+- Cyclic shifts guarantee each row has all N numbers65+- Column shuffling preserves the Latin property (each column still has all N numbers)66+- The result is a valid Hitori "white cell" solution67+68+---69+70+## Step 2: Generate a Valid Cell Mask71+72+We need to decide which cells are black. The mask must satisfy:73+1. **No adjacent black cells**: Black cells cannot share an edge (can touch diagonally)74+2. **White cells connected**: All white cells must form one orthogonal component75+76+### Algorithm77+78+```go79+func generateValidMask(size int, blackRatio float64) [][]bool {80+ mask := make([][]bool, size)81+ for y := range size {82+ mask[y] = make([]bool, size)83+ }84+85+ targetBlack := int(blackRatio * float64(size*size))86+ attempts := 087+ const maxAttempts = 100088+89+ for countBlack(mask, size) < targetBlack && attempts < maxAttempts {90+ attempts++91+ x, y := rand.IntN(size), rand.IntN(size)92+ 93+ if mask[y][x] {94+ continue // Already black95+ }96+97+ // Check no adjacent black98+ if hasOrthogonalNeighbor(mask, size, x, y) {99+ continue // Would violate adjacency rule100+ }101+102+ mask[y][x] = true103+104+ // Verify white connectivity105+ if !whiteCellsConnected(mask, size) {106+ mask[y][x] = false // Revert: would isolate white cells107+ }108+ }109+110+ return mask111+}112+```113+114+### Helper: Check Orthogonal Neighbor115+116+```go117+func hasOrthogonalNeighbor(mask [][]bool, size, x, y int) bool {118+ if x > 0 && mask[y][x-1] { return true }119+ if x < size-1 && mask[y][x+1] { return true }120+ if y > 0 && mask[y-1][x] { return true }121+ if y < size-1 && mask[y+1][x] { return true }122+ return false123+}124+```125+126+### Helper: White Cell Connectivity (BFS/DFS)127+128+```go129+func whiteCellsConnected(mask [][]bool, size int) bool {130+ // Find first white cell131+ startX, startY := -1, -1132+ found := false133+ for y := range size {134+ for x := range size {135+ if !mask[y][x] {136+ startX, startY = x, y137+ found = true138+ break139+ }140+ }141+ if found {142+ break143+ }144+ }145+ if startX == -1 {146+ return true // No white cells (all black)147+ }148+149+ // BFS to count reachable white cells150+ visited := make([][]bool, size)151+ for y := range size {152+ visited[y] = make([]bool, size)153+ }154+155+ queue := []cellPos{{startX, startY}}156+ visited[startY][startX] = true157+ visitedCount := 1158+159+ for len(queue) > 0 {160+ curr := queue[len(queue)-1]161+ queue = queue[:len(queue)-1]162+163+ // Check 4 orthogonal neighbors164+ for _, d := range []struct{dx, dy int}{{0,-1},{0,1},{-1,0},{1,0}} {165+ nx, ny := curr.x+d.dx, curr.y+d.dy166+ if nx >= 0 && nx < size && ny >= 0 && ny < size {167+ if !mask[ny][nx] && !visited[ny][nx] {168+ visited[ny][nx] = true169+ visitedCount++170+ queue = append(queue, cellPos{nx, ny})171+ }172+ }173+ }174+ }175+176+ // Count total white cells177+ whiteCount := 0178+ for y := range size {179+ for x := range size {180+ if !mask[y][x] {181+ whiteCount++182+ }183+ }184+ }185+186+ return visitedCount == whiteCount187+}188+```189+190+---191+192+## Step 3: Construct the Puzzle193+194+Now combine the Latin Square and mask to create the puzzle grid:195+- White cells: keep their original number196+- Black cells: copy a number from another white cell in the same row OR column197+198+```go199+func constructPuzzle(baseGrid grid, mask [][]bool) grid {200+ size := len(baseGrid)201+ puzzle := make(grid, size)202+ for y := range size {203+ puzzle[y] = make([]rune, size)204+ for x := range size {205+ if mask[y][x] {206+ // Black cell: introduce duplicate from same row or column207+ // Choose random white cell in row or column208+ whiteCellsInRow := findWhiteCellsInRow(mask, size, y, x)209+ whiteCellsInCol := findWhiteCellsInCol(mask, size, y, x)210+ 211+ allWhite := append(whiteCellsInRow, whiteCellsInCol...)212+ if len(allWhite) == 0 {213+ panic("no white cells to copy from")214+ }215+ 216+ pick := allWhite[rand.IntN(len(allWhite))]217+ puzzle[y][x] = baseGrid[pick.y][pick.x]218+ } else {219+ puzzle[y][x] = baseGrid[y][x]220+ }221+ }222+ }223+ return puzzle224+}225+226+func findWhiteCellsInRow(mask [][]bool, size, y, excludeX int) []cellPos {227+ var result []cellPos228+ for x := range size {229+ if x != excludeX && !mask[y][x] {230+ result = append(result, cellPos{x, y})231+ }232+ }233+ return result234+}235+236+func findWhiteCellsInCol(mask [][]bool, size, x, excludeY int) []cellPos {237+ var result []cellPos238+ for y := range size {239+ if y != excludeY && !mask[y][x] {240+ result = append(result, cellPos{x, y})241+ }242+ }243+ return result244+}245+```246+247+---248+249+## Step 4: Verify Uniqueness via Backtracking Solver250+251+The puzzle may have multiple valid solutions. We must verify exactly one exists.252+253+### Algorithm254+255+```go256+func countSolutions(puzzle grid, size, limit int) int {257+ // Convert puzzle to mutable state: cell can be 'white' or 'black'258+ // Start with all cells marked as unknown259+ 260+ return countSolutionsRecursive(state, 0, limit)261+}262+263+func countSolutionsRecursive(state [][]cellState, pos int, limit int) int {264+ if pos == size*size {265+ if isValidComplete(state) {266+ return 1267+ }268+ return 0269+ }270+271+ x, y := pos%size, pos/size272+ 273+ // Skip cells that are already determined (if using progressive solving)274+ // For pure backtracking, try both states:275+ 276+ // Try marking as white277+ if canBeWhite(state, size, x, y) {278+ state[y][x] = white279+ count := countSolutionsRecursive(state, pos+1, limit)280+ state[y][x] = unknown281+ if count >= limit {282+ return count283+ }284+ }285+286+ // Try marking as black287+ if canBeBlack(state, size, x, y) {288+ state[y][x] = black289+ if countSolutionsRecursive(state, pos+1, limit) >= limit {290+ return limit291+ }292+ state[y][x] = unknown293+ }294+295+ return 0296+}297+```298+299+### Constraint Checking Functions300+301+```go302+func canBeWhite(state [][]cellState, size, x, y int) bool {303+ // Get the number from puzzle at this position304+ num := puzzle[y][x]305+ 306+ // Check: if this cell is white, no duplicate in row307+ for i := range size {308+ if i != x && state[y][i] == white && puzzle[y][i] == num {309+ return false310+ }311+ }312+ // Check: if this cell is white, no duplicate in column313+ for i := range size {314+ if i != y && state[i][x] == white && puzzle[i][x] == num {315+ return false316+ }317+ }318+ return true319+}320+321+func canBeBlack(state [][]cellState, size, x, y int) bool {322+ // Check: no adjacent black cells323+ if x > 0 && state[y][x-1] == black { return false }324+ if x < size-1 && state[y][x+1] == black { return false }325+ if y > 0 && state[y-1][x] == black { return false }326+ if y < size-1 && state[y+1][x] == black { return false }327+ 328+ // Check: removing this cell doesn't isolate white cells329+ // (This is expensive - use connectivity check)330+ return whiteCellsConnectedAfter(state, size, x, y)331+}332+```333+334+---335+336+## Step 5: The Generation Loop337+338+```go339+func Generate(size int, difficulty float64) (grid, [][]bool, error) {340+ blackRatio := 0.2 + (1-difficulty)*0.2 // 0.2 (easy) to 0.4 (hard)341+ 342+ for attempt := 0; attempt < 100; attempt++ {343+ // Phase 1: Generate candidate344+ baseGrid := generateLatinSquare(size)345+ mask := generateValidMask(size, blackRatio)346+ puzzle := constructPuzzle(baseGrid, mask)347+ 348+ // Phase 2: Verify uniqueness349+ solutionCount := countSolutions(puzzle, size, 2)350+ 351+ if solutionCount == 1 {352+ // Convert mask to 'provided' format (true = given, false = player filled)353+ provided := make([][]bool, size)354+ for y := range size {355+ provided[y] = make([]bool, size)356+ for x := range size {357+ provided[y][x] = true // All numbers are given358+ }359+ }360+ return puzzle, provided, nil361+ }362+ // else: discard and retry363+ }364+ 365+ return nil, nil, errors.New("failed to generate puzzle after max attempts")366+}367+```368+369+---370+371+## Tuning Parameters372+373+| Parameter | Range | Effect |374+|-----------|-------|--------|375+| `blackRatio` | 0.15-0.35 | Higher = more black cells = harder to deduce |376+| `maxAttempts` | 100-1000 | Retry limit for generation loop |377+| `size` | 4-12 | Puzzle dimensions |378+379+---380+381+## Common Issues & Solutions382+383+| Issue | Cause | Solution |384+|-------|-------|----------|385+| Generator hangs | Mask creates impossible connectivity | Increase `maxAttempts`, lower `blackRatio` |386+| Few solutions found | Too many black cells | Reduce `blackRatio` |387+| Generation too slow | Connectivity check O(n²) per cell | Use Union-Find instead of BFS |388+389+### Optimization: Union-Find for Connectivity390+391+```go392+type UnionFind struct {393+ parent []int394+ size []int395+}396+397+func (uf *UnionFind) find(x int) int {398+ if uf.parent[x] != x {399+ uf.parent[x] = uf.find(uf.parent[x])400+ }401+ return uf.parent[x]402+}403+404+func (uf *UnionFind) union(x, y int) {405+ px, py := uf.find(x), uf.find(y)406+ if px == py {407+ return408+ }409+ if uf.size[px] < uf.size[py] {410+ px, py = py, px411+ }412+ uf.parent[py] = px413+ uf.size[px] += uf.size[py]414+}415+```@@ -0,0 +1,142 @@
1+# Hitori Rendering Implementation Plan2+3+## Overview4+5+This document outlines the implementation of the Hitori puzzle rendering using Bubble Tea and Lipgloss. The rendering builds on patterns established in the existing Nonogram implementation (`nonogram/style.go`, `nonogram/Model.go`).6+7+## Hitori Puzzle Rules8+9+1. **No duplicate numbers** in any row/column among white (unshaded) cells10+2. **No adjacent black cells** - black cells cannot share an edge (diagonal allowed)11+3. **All white cells orthogonally connected** - single contiguous region12+13+## Implementation Components14+15+### 1. Files to Create16+17+| File | Purpose |18+|------|---------|19+| `hitori/Model.go` | Main game model, implements `game.Gamer` interface |20+| `hitori/style.go` | Lipgloss styles and rendering functions |21+| `hitori/grid.go` | Grid data structure and manipulation |22+| `hitori/keys.go` | Key bindings |23+| `hitori/Gamemode.go` | Menu entry and spawner |24+| `hitori/solver.go` | Validation/solving logic |25+26+### 2. Model Structure27+28+```go29+type Model struct {30+ width, height int31+ grid grid // current puzzle state (numbers + shading)32+ solutionMask [][]bool // true = cell should be white in solution33+ cursor game.Cursor34+ keys KeyMap35+ modeTitle string36+ showFullHelp bool37+ solved bool38+}39+```40+41+### 3. Grid Cell States42+43+Unlike Nonogram, Hitori has three states per cell:44+- **Number cell**: displayed with number (rune '1'-'9')45+- **Marked (X)**: player marked as black46+- **Cleared**: player marked as white (empty)47+48+```go49+const (50+ filledTile = 'X' // marked black51+ emptyTile = ' ' // cleared/white52+ // Numbers 1-9 stored directly as rune53+)54+```55+56+### 4. Key Bindings (from keys.go)57+58+- Arrow keys / WASD: Move cursor59+- `X` or `Z`: Mark cell as black (shaded)60+- `Space` or `Backspace`: Clear cell (mark as white)61+- `Enter`: Toggle cell state (number → X → space → number)62+- `Ctrl+R`: Reset puzzle63+- `Ctrl+H`: Toggle help64+65+### 5. Rendering with Lipgloss66+67+Following `nonogram/style.go` patterns:68+69+```go70+// Cell styles71+var (72+ baseStyle = lipgloss.NewStyle()73+ 74+ numberStyle = baseStyle.75+ Foreground(lipgloss.AdaptiveColor{Light: "250", Dark: "250"}).76+ Background(lipgloss.AdaptiveColor{Light: "255", Dark: "235"})77+ 78+ markedStyle = baseStyle.79+ Foreground(lipgloss.AdaptiveColor{Light: "88", Dark: "196"}).80+ Background(lipgloss.AdaptiveColor{Light: "254", Dark: "235"})81+ 82+ cursorStyle = baseStyle.83+ Bold(true).84+ Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "235"}).85+ Background(lipgloss.AdaptiveColor{Light: "130", Dark: "214"})86+ 87+ solvedStyle = baseStyle.88+ Background(lipgloss.AdaptiveColor{Light: "151", Dark: "22"})89+)90+```91+92+### 6. Grid Rendering93+94+The grid renders similarly to Nonogram:95+- Each cell is `cellWidth = 3` characters wide96+- Separator lines every 5 cells (`spacerEvery = 5`)97+- Use `lipgloss.JoinHorizontal` / `lipgloss.JoinVertical` to assemble98+- Crosshair highlight on cursor row/column99+100+```go101+func gridView(g grid, c game.Cursor, solved bool) string {102+ // Iterate rows, render each cell with appropriate style103+ // Apply cursor highlighting, crosshair for cursor row/col104+ // Join with vertical separators105+}106+```107+108+### 7. Validation (Solver)109+110+The `IsSolved()` check must verify:111+1. No duplicate numbers in any row among non-marked cells112+2. No adjacent marked cells113+3. All non-marked cells are orthogonally connected114+115+### 8. Game.Gamer Interface Methods116+117+```go118+func (m Model) Init() tea.Cmd119+func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd)120+func (m Model) View() string121+func (m Model) GetDebugInfo() string122+func (m Model) GetFullHelp() [][]key.Binding123+func (m Model) GetSave() ([]byte, error)124+func (m Model) IsSolved() bool125+func (m Model) Reset() game.Gamer126+func (m Model) SetTitle(string) game.Gamer127+```128+129+### 9. Integration130+131+Register in main menu via `cmd.go`:132+```go133+game.Registry["hitori"] = hitori.ImportModel134+```135+136+---137+138+## References139+140+- Bubble Tea: https://github.com/charmbracelet/bubbletea141+- Lipgloss: https://github.com/charmbracelet/lipgloss142+- Nonogram implementation: `nonogram/style.go`, `nonogram/Model.go`@@ -76,6 +76,7 @@ func TestResolveCategory(t *testing.T) {
7676 }{7777 {"hashi", "Hashiwokakero"},7878 {"bridges", "Hashiwokakero"},79+ {"hitori", "Hitori"},7980 {"lights", "Lights Out"},8081 {"ws", "Word Search"},8182 {"words", "Word Search"},@@ -190,6 +191,7 @@ func TestListCategoryNames(t *testing.T) {
190191 // Verify all expected games are present.191192 expected := map[string]bool{192193 "Hashiwokakero": true,194+ "Hitori": true,193195 "Lights Out": true,194196 "Nonogram": true,195197 "Sudoku": true,@@ -0,0 +1,67 @@
1+Output vhs/hitori.gif2+3+Set Theme "catppuccin-mocha"4+Set FontSize 185+Set Width 12006+Set Height 9407+Set Margin 808+Set MarginFill "#6E6352"9+Set WindowBar ColorfulRight10+11+Env COLORTERM "truecolor"12+Env CLICOLOR_FORCE "1"13+Env CI ""14+15+Hide16+Type "go build -o puzzletea"17+Enter18+Sleep 2s19+Ctrl+L20+Show21+22+Type "./puzzletea new hitori medium"23+Sleep 500ms24+Enter25+Sleep 2s26+27+# Shade a cell28+Type "x"29+Sleep 500ms30+31+# Move and circle a cell as confirmed white32+Right33+Sleep 300ms34+Type "z"35+Sleep 500ms36+37+# Move down and shade another38+Down39+Sleep 300ms40+Type "x"41+Sleep 500ms42+43+# Demonstrate clearing44+Backspace45+Sleep 500ms46+47+# Re-shade48+Type "x"49+Sleep 500ms50+51+# Navigate around to show crosshair52+Down53+Down54+Sleep 300ms55+Right56+Right57+Sleep 300ms58+59+# Shade a few more60+Type "x"61+Sleep 500ms62+Down63+Sleep 300ms64+Type "z"65+Sleep 500ms66+67+Sleep 3s