- commit
- 5e988fd38b74326e38a08181ed3213726728e3a6
- parent
- 3200ad947aa4cd9f8ff7711a770c2cf1cdc95537
- author
- Dami <dami.etoile@gmail.com>
| A | AGENTS.md | 187 | ++++++++++++++++++ |
| D | CLAUDE.md | 280 | -------------------------- |
| M | justfile | 4 | + |
| M | nonogram/ | 6 | + |
| M | takuzu/ | 320 | ++++++++++++++++-------------- |
| M | takuzu/ | 3 | + |
@@ -0,0 +1,187 @@
1+# AGENTS.md - PuzzleTea Development Guide2+3+## Build, Test, and Quality Commands4+5+### Build & Run6+```bash7+just # build with version from git tags8+just run # build and run9+just install # install to $GOPATH/bin10+just clean # remove binary and dist/11+```12+Without `just`: `go build -ldflags "-X main.version=$(git describe --tags --always --dirty)" -o puzzletea`13+14+### Testing15+```bash16+just test # go test ./...17+just test-short # go test -short ./... (skips slow generator tests)18+go test ./nonogram/ # single package19+go test ./sudoku/ -run TestGenerateGrid # specific test20+```21+22+### Linting & Formatting23+```bash24+just lint # golangci-lint run ./...25+just fmt # gofumpt -w .26+just tidy # go mod tidy27+```28+**Always run `just fmt` and `just lint` before committing.**29+30+---31+32+## Code Style Guidelines33+34+### Formatting35+- Use `gofumpt` - run `just fmt`36+- No comments required unless explaining non-obvious logic37+- Keep lines under ~100 characters38+- Group imports: stdlib, puzzletea internal, Bubble Tea ecosystem39+40+### File Naming41+- **Capitalized**: `Gamemode.go`, `Model.go`, `Export.go`42+- **Lowercase**: `grid.go`, `keys.go`, `style.go`, `generator.go`, `<game>_test.go`43+44+### Naming Conventions45+- **Types**: PascalCase (`Model`, `NonogramMode`, `grid`)46+- **Variables/Fields**: camelCase (`rowHints`, `currentHints`)47+- **Constants**: camelCase for constants48+- **Interfaces**: PascalCase (`Gamer`, `Spawner`, `Mode`)49+50+### Type Declarations51+Prefer type aliases for clarity:52+```go53+type (54+ grid [][]rune55+ state string56+)57+```58+59+### Interface Compliance60+Use compile-time checks:61+```go62+var _ game.Gamer = Model{}63+var _ game.Mode = NonogramMode{}64+var _ game.Spawner = NonogramMode{}65+```66+67+### Error Handling68+- Return descriptive errors: `return Model{}, errors.New("puzzle width does not support row tomography definition")`69+- Check errors immediately; avoid wrapping unless adding context70+- Use `fmt.Errorf` with `%w` when wrapping71+72+### Imports (grouped, blank line between)73+```go74+import (75+ "errors"76+ "fmt"77+78+ "github.com/FelineStateMachine/puzzletea/game"79+ "github.com/charmbracelet/bubbles/key"80+ tea "github.com/charmbracelet/bubbletea"81+ "github.com/charmbracelet/lipgloss"82+)83+```84+85+---86+87+## TUI Patterns88+89+### Model Structure90+```go91+type Model struct {92+ width, height int93+ rowHints TomographyDefinition94+ colHints TomographyDefinition95+ cursor game.Cursor96+ grid grid97+ keys KeyMap98+ modeTitle string99+ showFullHelp bool100+ currentHints Hints101+ solved bool102+}103+```104+105+### Update Method Pattern106+```go107+func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {108+ switch msg := msg.(type) {109+ case game.HelpToggleMsg:110+ m.showFullHelp = msg.Show111+ case tea.KeyMsg:112+ switch {113+ case key.Matches(msg, m.keys.FillTile):114+ m.updateTile(filledTile)115+ }116+ m.cursor.Move(m.keys.CursorKeyMap, msg, m.width-1, m.height-1)117+ }118+ m.updateKeyBindings()119+ return m, nil120+}121+```122+123+### Required Gamer Methods124+`Init() tea.Cmd`, `Update(tea.Msg)`, `View() string`, `GetDebugInfo() string`, `GetFullHelp() string`, `GetSave() []byte`, `IsSolved() bool`, `SetTitle(string)`125+126+### Styling127+Use `lipgloss.AdaptiveColor` with ANSI 256 palette numbers:128+```go129+lipgloss.AdaptiveColor{Light: "255", Dark: "255"}130+```131+Avoid hex colors (`#rrggbb`).132+133+---134+135+## Testing Conventions136+137+Section comments with priority:138+```go139+// --- generateTomography (P0) ---140+```141+142+Table-driven tests with subtests:143+```go144+func TestGenerateTomography(t *testing.T) {145+ tests := []struct {146+ name string147+ grid grid148+ wantRows TomographyDefinition149+ }{150+ {name: "all filled row", grid: grid{...}, wantRows: ...},151+ }152+ for _, tt := range tests {153+ t.Run(tt.name, func(t *testing.T) {154+ // test logic155+ })156+ }157+}158+```159+160+Save/load round-trip:161+```go162+m, _ := New(mode, hints)163+data := m.GetSave()164+loaded, err := ImportModel(data)165+if err != nil { t.Fatal(err) }166+// verify state preserved167+```168+169+Priority: (P0) = critical, (P1) = important, (P2) = generators170+171+---172+173+## Project Structure174+```175+puzzletea/176+├── main.go, cmd.go, model.go, resolve.go # Core app177+├── game/ # Plugin interface, cursor, keys, style178+├── store/ # SQLite persistence179+├── namegen/ # Adjective-noun name generator180+├── nonogram/, sudoku/, wordsearch/, hashiwokakero/, lightsout/, takuzu/ # Puzzle games181+└── vhs/ # GIF preview tapes182+```183+184+---185+186+## Global Keybindings187+- `Ctrl+N`: Main menu | `Ctrl+C`: Quit (saves abandoned) | `Ctrl+E`: Debug overlay | `Ctrl+H`: Full help | `Enter`: Select | `Escape`: Back@@ -1,280 +0,0 @@
1-# CLAUDE.md2-3-## Project Overview4-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.6-7-## Commands8-9-### Building and Running10-```bash11-just # build with version from git tags12-just run # build and run13-just install # install to $GOPATH/bin14-just clean # remove binary and dist/15-```16-17-Or without just:18-```bash19-go build -ldflags "-X main.version=$(git describe --tags --always --dirty)" -o puzzletea20-```21-22-### CLI Usage23-```bash24-puzzletea # launch TUI menu (default)25-puzzletea new <game> [mode] # start a new game directly26-puzzletea new nonogram medium # example: new nonogram, medium mode27-puzzletea new lights-out # hyphenated names, default (first) mode28-puzzletea new hashi # aliases: hashi, bridges, lights, ws, words29-puzzletea continue <name> # resume a saved game by its unique name30-puzzletea list # print saved games table to stdout31-puzzletea list --all # include abandoned games32-puzzletea --new nonogram:medium # flag alias (colon-separated game:mode)33-puzzletea --continue <name> # flag alias34-```35-36-### Testing and Quality37-```bash38-just test # go test ./...39-just lint # golangci-lint run ./...40-just fmt # gofumpt -w .41-just tidy # go mod tidy42-```43-44-Run a single package's tests:45-```bash46-go test ./nonogram/47-go test ./sudoku/ -run TestGenerateGrid48-```49-50-### VHS GIF Previews51-```bash52-just vhs # generate all game preview GIFs (requires vhs + ffmpeg + ttyd)53-vhs vhs/menu.tape # generate a single GIF54-```55-56-## Architecture57-58-### Core Application Structure59-60-The application follows a **plugin-based architecture** where puzzle games implement the `game.Gamer` interface.61-62-- **`main.go`**: Entry point; calls `rootCmd.Execute()` (Cobra).63-- **`cmd.go`**: Cobra command definitions (`rootCmd`, `newCmd`, `continueCmd`, `listCmd`) and root-level `--new`/`--continue` flag aliases. Contains `launchNewGame()` and `continueGame()` helpers that open the store, resolve names, spawn/import the game, and launch the TUI in `gameView` state.64-- **`resolve.go`**: Case-insensitive, hyphen/underscore-tolerant game and mode name resolution. `resolveCategory(name)` matches against `GameCategories` with alias support (e.g. `hashi` → Hashiwokakero, `ws` → Word Search). `resolveMode(cat, name)` matches mode titles; defaults to first mode when name is empty.65-- **`model.go`**: Root model managing application state across five views:66- - `mainMenuView`: Top-level menu (Daily Puzzle, Generate, Continue)67- - `gameSelectView`: Category selection68- - `modeSelectView`: Difficulty/mode selection within a category69- - `gameView`: Active game instance70- - `continueView`: Saved game browser (table)71-72-Navigation: main menu → select category → select mode → play. `Escape` goes back one level, `Ctrl+N` returns to main menu from anywhere.73-74-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.75-76-### Plugin Interface (`game/`)77-78-All puzzle games must implement:79-80-1. **`game.Gamer`** — Active game instance: `Init`, `Update`, `View`, `GetDebugInfo`, `GetFullHelp`, `GetSave`, `IsSolved`, `SetTitle`81-2. **`game.Spawner`** — Creates a new game: `Spawn() (Gamer, error)`82-3. **`game.BaseMode`** — Embed in mode structs for free `Title()`, `Description()`, `FilterValue()`83-4. **`game.Category`** — Groups modes under a heading; satisfies `Mode` but not `Spawner`84-85-Shared utilities in `game/`:86-- **`cursor.go`**: `Cursor` type with `Move()` for grid-based navigation87-- **`keys.go`**: `CursorKeyMap` and `DefaultCursorKeyMap` for shared arrow/WASD/vim bindings88-- **`style.go`**: `TitleBarView()`, `DebugHeader()`, `DebugTable()` for consistent rendering89-90-Save/load uses `game.Registry` — each package registers its import function via `init()` calling `game.Register(name, fn)`.91-92-### Adding a New Puzzle Type93-94-#### Game package files (e.g. `mypuzzle/`)95-96-1. Create a new package under the project root (e.g., `mypuzzle/`)97-2. Define a mode struct embedding `game.BaseMode` that implements `game.Spawner`98-3. Add compile-time checks: `var _ game.Mode = MyMode{}` and `var _ game.Spawner = MyMode{}`99-4. Export a `Modes` variable (`[]list.Item`) listing available difficulties100-5. Implement `game.Gamer` on a `Model` struct101-6. Register the import function in an `init()`: `game.Register("My Puzzle", func(data []byte) (game.Gamer, error) { ... })`102-7. Write tests in `mypuzzle_test.go` (see Testing Conventions below)103-8. Create a `README.md` in the package directory (see Game README Format below)104-105-#### Main folder edits required106-107-9. **`model.go`**: Import the new package and add a `game.Category` entry to `GameCategories` (maintain alphabetical order):108- - Add import: `"github.com/FelineStateMachine/puzzletea/mypuzzle"`109- - Add entry: `game.Category{Name: "My Puzzle", Desc: "Short description.", Modes: mypuzzle.Modes}`110- - The import triggers the package's `init()`, which registers the save/load function — no blank import needed.111-10. **`resolve.go`**: Add the canonical name and any CLI aliases to the `categoryAliases` map (lowercase, spaces not hyphens).112-11. **`CLAUDE.md`**: Add a one-line entry to "Current Implementations" below.113-114-#### VHS and CI115-116-12. Create a VHS tape file in `vhs/<game>.tape` (see VHS Tape Files section)117-13. **`justfile`**: Add the new tape to the `vhs` target (e.g. `vhs vhs/mypuzzle.tape`)118-119-Each package follows a consistent file structure:120-- `Gamemode.go`: Mode struct, `NewMode()`, `Spawn()`, `Modes` var, `init()` with `game.Register()`121-- `Model.go`: `Model` struct implementing `game.Gamer`122-- `Export.go`: `Save` struct, `GetSave()`, `ImportModel([]byte)` function123-- `keys.go`: `KeyMap` struct with game-specific keybindings124-- `style.go`: lipgloss styling definitions125-- `generator.go`: Puzzle generation logic (where applicable)126-- `grid.go`: Grid type, serialization, and helpers (for grid-based games)127-128-### Current Implementations129-130-- **Nonogram** (`nonogram/`): Fill cells to match row/column hints. Three tile states: filled, marked, empty. Victory when grid tomography matches hint tomography.131-- **Word Search** (`wordsearch/`): Find hidden words in a letter grid. Supports 8 directions with configurable subsets per difficulty.132-- **Sudoku** (`sudoku/`): Classic 9x9 with configurable provided cells. Uses `cell` type with provided/user-entered distinction.133-- **Hashiwokakero** (`hashiwokakero/`): Connect islands with bridges (1 or 2). Navigation mode and bridge mode. Victory requires all islands satisfied and connected.134-- **Lights Out** (`lightsout/`): Toggle lights in a cross pattern to turn all off. Grid sizes from 3x3 to 9x9.135-- **Takuzu** (`takuzu/`): Fill grid with two symbols (● and ○). No triples, equal counts, unique lines. Grid sizes from 6x6 to 14x14.136-137-### Supporting Packages138-139-- **`store/`**: SQLite-based persistence (via `modernc.org/sqlite`). Stores game records with status tracking (new, in_progress, completed, abandoned). DB at `~/.puzzletea/history.db`.140-- **`namegen/`**: Generates unique adjective-noun names for saved games.141-142-### Global Keybindings143-144-- `Ctrl+N`: Return to main menu145-- `Ctrl+C`: Quit (saves current game as abandoned)146-- `Ctrl+E`: Toggle debug overlay147-- `Ctrl+H`: Toggle full help display148-- `Enter`: Select in menus149-- `Escape`: Go back one level150-151-### Continue View152-153-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()`.154-155-### Debug System156-157-Debug overlay (`Ctrl+E`) renders game-specific markdown via `glamour`. Games provide info via `GetDebugInfo()` using `game.DebugHeader()` and `game.DebugTable()` helpers.158-159-### Grid System Pattern160-161-Grid-based games (nonogram, wordsearch, sudoku) use:162-- Internal grid type: 2D slice (`[][]rune`, `[9][9]cell`, etc.)163-- `state` type: String serialization for save/load164-- `newGrid(state)` and `grid.String()` conversion functions165-166-Hashiwokakero uses `Island` and `Bridge` structs instead of a 2D grid. Lights Out uses `[][]bool` directly.167-168-### Versioning169-170-Version is injected at build time via `-ldflags "-X main.version=..."`. The `version` variable lives in `cmd.go` and defaults to `"dev"`. The justfile derives it from `git describe --tags`. GoReleaser (`.goreleaser.yml`) handles cross-platform release builds and injects the tag version automatically.171-172-### Formatting and Linting173-174-All Go code must pass `gofumpt` (strict superset of `gofmt`) and `golangci-lint` with the project's `.golangci.yml` config. Enabled linters: gofumpt, govet, errcheck, staticcheck, unused, gosimple, ineffassign, misspell. Run `just fmt` and `just lint` before committing.175-176-### Testing Conventions177-178-Each game package has a `<game>_test.go` file. Tests follow these conventions:179-180-- **Table-driven tests** with subtests (`t.Run`) for all pure functions.181-- **Priority annotations** in section comments: `(P0)` = critical core logic, `(P1)` = important functionality (save/load, interface checks), `(P2)` = generators and less critical paths.182-- **Section separators**: `// --- FunctionName (P0) ---` above each test group.183-- **Save/load round-trip tests**: Create a model, call `GetSave()`, pass bytes to `ImportModel()`, and verify state is preserved.184-- **Compile-time interface checks** in tests: `var _ game.Gamer = Model{}` (or `&Model{}`).185-- **Edge cases**: Empty grids, 1x1 grids, maximum sizes, boundary conditions.186-- **Generator validation**: Verify generated puzzles satisfy game rules (e.g. solvability, uniqueness).187-188-Run `just test` before committing. All tests must pass in CI.189-190-### File Naming Convention191-192-Game packages use capital first letters for the three core files and lowercase for helpers:193-- **Capital**: `Gamemode.go`, `Model.go`, `Export.go`194-- **Lowercase**: `grid.go`, `keys.go`, `style.go`, `generator.go`, `<game>_test.go`195-196-### Game README Format197-198-Each game package contains a `README.md` with this structure:199-200-1. **Title** (`# Game Name`)201-2. **One-line description**202-3. **GIF preview** (``)203-4. **How to Play** — rules and mechanics204-5. **Controls** — table with Key and Action columns; include game-specific keys plus shared keys (`Ctrl+H`, `Ctrl+E`, `Ctrl+N`)205-6. **Modes** — table listing each mode with parameters and description206-7. **Quick Start** — `puzzletea new <game> <mode>` examples207-208-See `nonogram/README.md` for a complete example.209-210-### Color Convention211-212-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.213-214-### VHS Tape Files (`vhs/`)215-216-Each game has a `.tape` file in `vhs/` that generates a GIF preview using [charmbracelet/vhs](https://github.com/charmbracelet/vhs). All tapes share a common preamble:217-218-```tape219-Output vhs/<game>.gif220-221-Set Theme "catppuccin-mocha"222-Set FontSize 14223-Set Width 1200224-Set Height 800225-Set Margin 60226-Set MarginFill "#A85C40"227-Set WindowBar Colorful228-229-Env COLORTERM "truecolor"230-Env CLICOLOR_FORCE "1"231-Env CI ""232-```233-234-The three `Env` lines are required for CI color support. `termenv` (used by lipgloss/bubbletea) checks `CI` env var and returns no-color when set — GitHub Actions sets `CI=true`. `COLORTERM=truecolor` sets the color profile, and `CLICOLOR_FORCE=1` is a fallback.235-236-After the preamble, each tape follows this structure:237-238-```tape239-Hide240-Type "go build -o puzzletea"241-Enter242-Sleep 2s243-Ctrl+L244-Show245-246-Type "./puzzletea new <game> <mode>"247-Sleep 500ms248-Enter249-Sleep 2s250-251-# Demo gameplay actions...252-253-Sleep 3s254-```255-256-- Use a `Hide`/`Show` block to build the binary and clear the screen before showing gameplay.257-- Use `Ctrl+L` (not `clear`) to clear the screen in the hidden block.258-- Demonstrate core gameplay mechanics (filling cells, toggling, navigating, etc.).259-- End with `Sleep 3s` to let viewers see the final state.260-- Add the new tape to the `vhs` target in the `justfile`.261-262-GIFs are not tracked in git — they are generated by CI and committed via PR.263-264-### CI/CD Workflows265-266-- **`ci.yml`**: Runs tests and linting on PRs and pushes to main.267-- **`release.yml`**: GoReleaser builds on tagged releases.268-- **`vhs.yml`**: Generates VHS GIF previews. Triggers on release, `workflow_dispatch`, or pushes to main that change `vhs/*.tape`. Creates a PR (`chore/regenerate-vhs-gifs` branch) with the updated GIFs rather than pushing directly to main (required by branch protection rules).269-270-## Module Information271-272-- **Module path**: `github.com/FelineStateMachine/puzzletea`273-- **Go version**: 1.24.5274-- **Key dependencies**:275- - `github.com/charmbracelet/bubbletea`: TUI framework276- - `github.com/charmbracelet/bubbles`: TUI components (list, table)277- - `github.com/charmbracelet/lipgloss`: Terminal styling278- - `github.com/charmbracelet/glamour`: Markdown rendering (debug view)279- - `github.com/spf13/cobra`: CLI command/flag management280- - `modernc.org/sqlite`: Pure-Go SQLite driver (game persistence)@@ -831,6 +831,9 @@ func TestGenerateLinePossibilities(t *testing.T) {
831831 // --- GenerateRandomTomography uniqueness (P0) ---832832 833833 func TestGenerateRandomTomography_AllModes_Unique(t *testing.T) {834+ if testing.Short() {835+ t.Skip("skipping slow generator test in short mode")836+ }834837 modes := []struct {835838 name string836839 width int@@ -898,6 +901,9 @@ var _ game.Gamer = Model{}
898901 // --- Spawn Performance Benchmark (P2) ---899902 900903 func TestSpawnPerformance(t *testing.T) {904+ if testing.Short() {905+ t.Skip("skipping slow performance test in short mode")906+ }901907 const numRuns = 20902908 903909 modeConfigs := []struct {@@ -2,14 +2,54 @@ package takuzu
22 33 import "math/rand/v2"44 5-// generateComplete fills an empty grid with a valid Takuzu solution using backtracking.5+type cellPos struct{ x, y int }6+7+// generateComplete fills an empty grid with a valid Takuzu solution using8+// backtracking with retry on failure.69 func generateComplete(size int) grid {710 g := newGrid(createEmptyState(size))8- fillGrid(g, size)9- return g11+12+ const maxRetries = 1013+ for attempt := 0; attempt < maxRetries; attempt++ {14+ // Reset grid15+ for y := range size {16+ for x := range size {17+ g[y][x] = emptyCell18+ }19+ }20+21+ if fillGrid(g, size) {22+ return g23+ }24+ }25+26+ // Last attempt - use more exhaustive search27+ for y := range size {28+ for x := range size {29+ g[y][x] = emptyCell30+ }31+ }32+ if fillGrid(g, size) {33+ return g34+ }35+36+ panic("failed to generate complete grid")1037 }1138 1239 func fillGrid(g grid, size int) bool {40+ order := make([]cellPos, 0, size*size)41+ for y := range size {42+ for x := range size {43+ if g[y][x] == emptyCell {44+ order = append(order, cellPos{x, y})45+ }46+ }47+ }48+49+ rand.Shuffle(len(order), func(i, j int) {50+ order[i], order[j] = order[j], order[i]51+ })52+1353 for y := range size {1454 for x := range size {1555 if g[y][x] != emptyCell {@@ -34,144 +74,6 @@ func fillGrid(g grid, size int) bool {
3474 return true3575 }3676 37-// canPlace checks whether placing val at (x,y) would violate Takuzu constraints.38-func canPlace(g grid, size, x, y int, val rune) bool {39- // Check no three consecutive in row.40- if x >= 2 && g[y][x-1] == val && g[y][x-2] == val {41- return false42- }43- if x >= 1 && x < size-1 && g[y][x-1] == val && g[y][x+1] == val {44- return false45- }46- if x <= size-3 && g[y][x+1] == val && g[y][x+2] == val {47- return false48- }49-50- // Check no three consecutive in column.51- if y >= 2 && g[y-1][x] == val && g[y-2][x] == val {52- return false53- }54- if y >= 1 && y < size-1 && g[y-1][x] == val && g[y+1][x] == val {55- return false56- }57- if y <= size-3 && g[y+1][x] == val && g[y+2][x] == val {58- return false59- }60-61- // Check count in row doesn't exceed size/2.62- half := size / 263- count := 064- for _, c := range g[y] {65- if c == val {66- count++67- }68- }69- if count >= half {70- return false71- }72-73- // Check count in column doesn't exceed size/2.74- count = 075- for r := range size {76- if g[r][x] == val {77- count++78- }79- }80- if count >= half {81- return false82- }83-84- // If this placement would complete the row, check uniqueness against other complete rows.85- // The cell at (x,y) is empty, so the row is complete iff every other cell is filled.86- if rowFilledExcept(g, y, x, size) {87- for other := range size {88- if other != y && rowFilled(g, other, size) && rowEqualWith(g[y], g[other], x, val) {89- return false90- }91- }92- }93-94- // If this placement would complete the column, check uniqueness against other complete columns.95- if colFilledExcept(g, x, y, size) {96- for other := range size {97- if other != x && colFilled(g, other, size) && colEqualWith(g, size, x, other, y, val) {98- return false99- }100- }101- }102-103- return true104-}105-106-// rowFilledExcept returns true if every cell in row y is filled except column skip.107-func rowFilledExcept(g grid, y, skip, size int) bool {108- for x := range size {109- if x != skip && g[y][x] == emptyCell {110- return false111- }112- }113- return true114-}115-116-// colFilledExcept returns true if every cell in column x is filled except row skip.117-func colFilledExcept(g grid, x, skip, size int) bool {118- for y := range size {119- if y != skip && g[y][x] == emptyCell {120- return false121- }122- }123- return true124-}125-126-// rowEqualWith compares rows a and b, treating a[overrideIdx] as overrideVal.127-func rowEqualWith(a, b []rune, overrideIdx int, overrideVal rune) bool {128- if len(a) != len(b) {129- return false130- }131- for i := range a {132- av := a[i]133- if i == overrideIdx {134- av = overrideVal135- }136- if av != b[i] {137- return false138- }139- }140- return true141-}142-143-// colEqualWith compares columns c1 and c2, treating g[overrideRow][c1] as overrideVal.144-func colEqualWith(g grid, size, c1, c2, overrideRow int, overrideVal rune) bool {145- for r := range size {146- v1 := g[r][c1]147- if r == overrideRow {148- v1 = overrideVal149- }150- if v1 != g[r][c2] {151- return false152- }153- }154- return true155-}156-157-func rowFilled(g grid, y, size int) bool {158- for x := range size {159- if g[y][x] == emptyCell {160- return false161- }162- }163- return true164-}165-166-func colFilled(g grid, x, size int) bool {167- for y := range size {168- if g[y][x] == emptyCell {169- return false170- }171- }172- return true173-}174-17577 // generatePuzzle removes cells from a complete grid to create a puzzle with a unique solution.17678 func generatePuzzle(complete grid, size int, prefilled float64) (puzzle grid, provided [][]bool) {17779 puzzle = complete.clone()@@ -185,12 +87,10 @@ func generatePuzzle(complete grid, size int, prefilled float64) (puzzle grid, pr
18587 18688 target := int(prefilled * float64(size*size))18789 188- // Shuffled positions.189- type pos struct{ x, y int }190- positions := make([]pos, 0, size*size)90+ positions := make([]cellPos, 0, size*size)19191 for y := range size {19292 for x := range size {193- positions = append(positions, pos{x, y})93+ positions = append(positions, cellPos{x, y})19494 }19595 }19696 rand.Shuffle(len(positions), func(i, j int) {@@ -236,7 +136,6 @@ func countSolutions(g grid, size, limit int) int {
236136 return count237137 }238138 }239- // All cells filled — check uniqueness constraints.240139 if hasUniqueLines(g, size) {241140 return 1242141 }@@ -281,7 +180,6 @@ func checkConstraints(g grid, size int) bool {
281180 282181 // hasUniqueLines checks that all rows are unique and all columns are unique.283182 func hasUniqueLines(g grid, size int) bool {284- // Check rows.285183 for i := range size {286184 for j := i + 1; j < size; j++ {287185 if rowEqual(g[i], g[j]) {@@ -289,7 +187,6 @@ func hasUniqueLines(g grid, size int) bool {
289187 }290188 }291189 }292- // Check columns.293190 for i := range size {294191 for j := i + 1; j < size; j++ {295192 if colEqual(g, size, i, j) {@@ -320,3 +217,130 @@ func colEqual(g grid, size, c1, c2 int) bool {
320217 }321218 return true322219 }220+221+// canPlace checks whether placing val at (x,y) would violate Takuzu constraints.222+func canPlace(g grid, size, x, y int, val rune) bool {223+ if x >= 2 && g[y][x-1] == val && g[y][x-2] == val {224+ return false225+ }226+ if x >= 1 && x < size-1 && g[y][x-1] == val && g[y][x+1] == val {227+ return false228+ }229+ if x <= size-3 && g[y][x+1] == val && g[y][x+2] == val {230+ return false231+ }232+233+ if y >= 2 && g[y-1][x] == val && g[y-2][x] == val {234+ return false235+ }236+ if y >= 1 && y < size-1 && g[y-1][x] == val && g[y+1][x] == val {237+ return false238+ }239+ if y <= size-3 && g[y+1][x] == val && g[y+2][x] == val {240+ return false241+ }242+243+ half := size / 2244+ count := 0245+ for _, c := range g[y] {246+ if c == val {247+ count++248+ }249+ }250+ if count >= half {251+ return false252+ }253+254+ count = 0255+ for r := range size {256+ if g[r][x] == val {257+ count++258+ }259+ }260+ if count >= half {261+ return false262+ }263+264+ if rowFilledExcept(g, y, x, size) {265+ for other := range size {266+ if other != y && rowFilled(g, other, size) && rowEqualWith(g[y], g[other], x, val) {267+ return false268+ }269+ }270+ }271+272+ if colFilledExcept(g, x, y, size) {273+ for other := range size {274+ if other != x && colFilled(g, other, size) && colEqualWith(g, size, x, other, y, val) {275+ return false276+ }277+ }278+ }279+280+ return true281+}282+283+func rowFilledExcept(g grid, y, skip, size int) bool {284+ for x := range size {285+ if x != skip && g[y][x] == emptyCell {286+ return false287+ }288+ }289+ return true290+}291+292+func colFilledExcept(g grid, x, skip, size int) bool {293+ for y := range size {294+ if y != skip && g[y][x] == emptyCell {295+ return false296+ }297+ }298+ return true299+}300+301+func rowEqualWith(a, b []rune, overrideIdx int, overrideVal rune) bool {302+ if len(a) != len(b) {303+ return false304+ }305+ for i := range a {306+ av := a[i]307+ if i == overrideIdx {308+ av = overrideVal309+ }310+ if av != b[i] {311+ return false312+ }313+ }314+ return true315+}316+317+func colEqualWith(g grid, size, c1, c2, overrideRow int, overrideVal rune) bool {318+ for r := range size {319+ v1 := g[r][c1]320+ if r == overrideRow {321+ v1 = overrideVal322+ }323+ if v1 != g[r][c2] {324+ return false325+ }326+ }327+ return true328+}329+330+func rowFilled(g grid, y, size int) bool {331+ for x := range size {332+ if g[y][x] == emptyCell {333+ return false334+ }335+ }336+ return true337+}338+339+func colFilled(g grid, x, size int) bool {340+ for y := range size {341+ if g[y][x] == emptyCell {342+ return false343+ }344+ }345+ return true346+}@@ -920,6 +920,9 @@ func TestImportModel_SolvedRecalculated(t *testing.T) {
920920 // --- Uniqueness across all modes (P0) ---921921 922922 func TestGeneratePuzzle_AllModes_Unique(t *testing.T) {923+ if testing.Short() {924+ t.Skip("skipping slow generator test in short mode")925+ }923926 modes := []struct {924927 name string925928 size int