Add unique solution validation for nonogram and takuzu generators
- commit
- 2e6d66b874ce29b9bde66b5de7eb28f050da0fe0
- parent
- ff30abe9bab0a24ba72bb262de39f06002a9cad5
- parent
- 5e988fd38b74326e38a08181ed3213726728e3a6
- author
- FelineStateMachine <dami.etoile@gmail.com>
| A | AGENTS.md | 187 | ++++++++++++++ |
| D | CLAUDE.md | 280 | --------------------- |
| M | justfile | 4 | + |
| M | nonogram/ | 55 | ---- |
| M | nonogram/ | 22 | +- |
| A | nonogram/ | 396 | ++++++++++++++++++++++++++++++ |
| M | nonogram/ | 367 | ++++++++++++++++++++++++++++ |
| M | takuzu/ | 320 | +++++++++++++----------- |
| M | takuzu/ | 36 | +++ |
@@ -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)@@ -3,8 +3,6 @@ package nonogram
33 import (44 "encoding/json"55 "fmt"6- "math/rand/v2"7- "strings"86 )97 108 type Save struct {@@ -48,56 +46,3 @@ func ImportModel(data []byte) (*Model, error) {
4846 solved: curr.rows.equal(save.RowHints) && curr.cols.equal(save.ColHints),4947 }, nil5048 }51-52-func GenerateRandomTomography(mode NonogramMode) Hints {53- for {54- s := generateRandomState(mode.Height, mode.Width, mode.Density)55- g := newGrid(s)56- hints := generateTomography(g)57- if isValidPuzzle(hints, mode.Height, mode.Width) {58- return hints59- }60- }61-}62-63-func generateRandomState(h, w int, density float64) state {64- if h <= 0 || w <= 0 {65- return ""66- }67-68- density = max(0.1, min(0.9, density))69-70- var b strings.Builder71- b.Grow((w + 1) * h)72-73- for i := range h {74- rowDensity := density + (rand.Float64()-0.5)*0.375- rowDensity = max(0.05, min(0.95, rowDensity))76- for range w {77- if rand.Float64() < rowDensity {78- b.WriteRune(filledTile)79- } else {80- b.WriteRune(emptyTile)81- }82- }83- if i < h-1 {84- b.WriteRune('\n')85- }86- }87-88- return state(b.String())89-}90-91-func isValidPuzzle(hints Hints, height, width int) bool {92- for _, rh := range hints.rows {93- if len(rh) == 1 && (rh[0] == 0 || rh[0] == width) {94- return false95- }96- }97- for _, ch := range hints.cols {98- if len(ch) == 1 && (ch[0] == 0 || ch[0] == height) {99- return false100- }101- }102- return true103-}@@ -38,19 +38,17 @@ func (n NonogramMode) Spawn() (game.Gamer, error) {
3838 3939 var Modes = []list.Item{4040 // 5x541- NewMode("Easy 5x5", "5x5 grid, ~65% filled. Dense, constraining hints.", 5, 5, 0.65),42- NewMode("Medium 5x5", "5x5 grid, ~50% filled. Balanced challenge.", 5, 5, 0.50),43- NewMode("Hard 5x5", "5x5 grid, ~35% filled. Sparse, ambiguous hints.", 5, 5, 0.35),41+ NewMode("Mini", "5x5 grid, ~65% filled. Quick puzzle, straightforward hints.", 5, 5, 0.65),42+ NewMode("Pocket", "5x5 grid, ~50% filled. Compact but balanced.", 5, 5, 0.50),43+ NewMode("Teaser", "5x5 grid, ~35% filled. Small but tricky.", 5, 5, 0.35),4444 // 10x1045- NewMode("Easy 10x10", "10x10 grid, ~65% filled. Dense, constraining hints.", 10, 10, 0.65),46- NewMode("Medium 10x10", "10x10 grid, ~50% filled. Balanced challenge.", 10, 10, 0.50),47- NewMode("Hard 10x10", "10x10 grid, ~35% filled. Sparse, ambiguous hints.", 10, 10, 0.35),45+ NewMode("Standard", "10x10 grid, ~67% filled. Classic size, dense hints.", 10, 10, 0.67),46+ NewMode("Classic", "10x10 grid, ~52% filled. The typical nonogram experience.", 10, 10, 0.52),47+ NewMode("Tricky", "10x10 grid, ~37% filled. Sparse hints require reasoning.", 10, 10, 0.37),4848 // 15x1549- NewMode("Easy 15x15", "15x15 grid, ~65% filled. Dense, constraining hints.", 15, 15, 0.65),50- NewMode("Medium 15x15", "15x15 grid, ~50% filled. Balanced challenge.", 15, 15, 0.50),51- NewMode("Hard 15x15", "15x15 grid, ~35% filled. Sparse, ambiguous hints.", 15, 15, 0.35),49+ NewMode("Large", "15x15 grid, ~69% filled. Bigger grid, constraining hints.", 15, 15, 0.69),50+ NewMode("Grand", "15x15 grid, ~54% filled. A substantial challenge.", 15, 15, 0.54),5251 // 20x2053- NewMode("Easy 20x20", "20x20 grid, ~65% filled. Dense, constraining hints.", 20, 20, 0.65),54- NewMode("Medium 20x20", "20x20 grid, ~50% filled. Balanced challenge.", 20, 20, 0.50),55- NewMode("Hard 20x20", "20x20 grid, ~35% filled. Sparse, ambiguous hints.", 20, 20, 0.35),52+ NewMode("Epic", "20x20 grid, ~71% filled. A epic undertaking.", 20, 20, 0.71),53+ NewMode("Massive", "20x20 grid, ~56% filled. Truly massive puzzle.", 20, 20, 0.56),5654 }@@ -0,0 +1,396 @@
1+package nonogram2+3+import (4+ "context"5+ "fmt"6+ "math/rand/v2"7+ "strings"8+ "sync"9+ "time"10+)11+12+const (13+ maxAttempts = 10014+ maxPossibilities = 1000015+)16+17+var (18+ possibilitiesCache = make(map[string][][]cellState)19+ possibilitiesMu sync.RWMutex20+)21+22+func cacheKey(length int, hint []int) string {23+ return fmt.Sprintf("%d:%v", length, hint)24+}25+26+func GenerateRandomTomography(mode NonogramMode) Hints {27+ maxDim := max(mode.Width, mode.Height)28+ timeout := time.Duration(maxDim) * time.Second29+30+ for attempt := range maxAttempts {31+ density := lerp(mode.Density, 0.5, float64(attempt)*0.02)32+ s := generateRandomState(mode.Height, mode.Width, density)33+ g := newGrid(s)34+ hints := generateTomography(g)35+ if !isValidPuzzle(hints, mode.Height, mode.Width) {36+ continue37+ }38+ ctx, cancel := context.WithTimeout(context.Background(), timeout)39+ count := countSolutions(hints, mode.Width, mode.Height, 2, ctx)40+ cancel()41+ if count == 1 {42+ return hints43+ }44+ }45+ return Hints{}46+}47+48+func lerp(a, b, t float64) float64 {49+ return a + (b-a)*t50+}51+52+func generateRandomState(h, w int, density float64) state {53+ if h <= 0 || w <= 0 {54+ return ""55+ }56+57+ density = max(0.1, min(0.9, density))58+59+ var b strings.Builder60+ b.Grow((w + 1) * h)61+62+ for i := range h {63+ rowDensity := density + (rand.Float64()-0.5)*0.364+ rowDensity = max(0.05, min(0.95, rowDensity))65+ for range w {66+ if rand.Float64() < rowDensity {67+ b.WriteRune(filledTile)68+ } else {69+ b.WriteRune(emptyTile)70+ }71+ }72+ if i < h-1 {73+ b.WriteRune('\n')74+ }75+ }76+77+ return state(b.String())78+}79+80+func isValidPuzzle(hints Hints, height, width int) bool {81+ for _, rh := range hints.rows {82+ if len(rh) == 1 && (rh[0] == 0 || rh[0] == width) {83+ return false84+ }85+ }86+ for _, ch := range hints.cols {87+ if len(ch) == 1 && (ch[0] == 0 || ch[0] == height) {88+ return false89+ }90+ }91+ return true92+}93+94+type cellState int95+96+const (97+ cellUnknown cellState = iota98+ cellEmpty99+ cellFilled100+)101+102+type solutionGrid struct {103+ cells [][]cellState104+ w, h int105+}106+107+func newSolutionGrid(w, h int) solutionGrid {108+ cells := make([][]cellState, h)109+ for y := range h {110+ cells[y] = make([]cellState, w)111+ for x := range w {112+ cells[y][x] = cellUnknown113+ }114+ }115+ return solutionGrid{cells: cells, w: w, h: h}116+}117+118+func (g *solutionGrid) clone() solutionGrid {119+ cells := make([][]cellState, g.h)120+ for y := range g.h {121+ cells[y] = make([]cellState, g.w)122+ copy(cells[y], g.cells[y])123+ }124+ return solutionGrid{cells: cells, w: g.w, h: g.h}125+}126+127+func (g *solutionGrid) set(x, y int, state cellState) bool {128+ if g.cells[y][x] != cellUnknown && g.cells[y][x] != state {129+ return false130+ }131+ g.cells[y][x] = state132+ return true133+}134+135+func (g *solutionGrid) isComplete() bool {136+ for y := range g.h {137+ for x := range g.w {138+ if g.cells[y][x] == cellUnknown {139+ return false140+ }141+ }142+ }143+ return true144+}145+146+func countSolutions(hints Hints, w, h, limit int, ctx context.Context) int {147+ g := newSolutionGrid(w, h)148+ return countSolutionsRecursive(g, hints, limit, ctx)149+}150+151+func countSolutionsRecursive(g solutionGrid, hints Hints, limit int, ctx context.Context) int {152+ select {153+ case <-ctx.Done():154+ return -1155+ default:156+ }157+158+ propagated, valid := propagate(g, hints, ctx)159+ if !valid {160+ return 0161+ }162+163+ if propagated.isComplete() {164+ return 1165+ }166+167+ for y := range propagated.h {168+ for x := range propagated.w {169+ if propagated.cells[y][x] == cellUnknown {170+ count := 0171+172+ if propagated.set(x, y, cellFilled) {173+ g1 := propagated.clone()174+ propagated.cells[y][x] = cellUnknown175+ n := countSolutionsRecursive(g1, hints, limit-count, ctx)176+ if n < 0 {177+ return n178+ }179+ count += n180+ if count >= limit {181+ return count182+ }183+ }184+185+ if propagated.set(x, y, cellEmpty) {186+ g2 := propagated.clone()187+ propagated.cells[y][x] = cellUnknown188+ n := countSolutionsRecursive(g2, hints, limit-count, ctx)189+ if n < 0 {190+ return n191+ }192+ count += n193+ if count >= limit {194+ return count195+ }196+ }197+198+ return count199+ }200+ }201+ }202+203+ return 0204+}205+206+func propagate(g solutionGrid, hints Hints, ctx context.Context) (solutionGrid, bool) {207+ rowBuf := make([]cellState, g.w)208+ colBuf := make([]cellState, g.h)209+210+ changed := true211+ for changed {212+ select {213+ case <-ctx.Done():214+ return g, false215+ default:216+ }217+ changed = false218+219+ for y := range g.h {220+ for x := range g.w {221+ rowBuf[x] = g.cells[y][x]222+ }223+224+ newRow, valid := propagateLine(rowBuf[:g.w], hints.rows[y])225+ if !valid {226+ return g, false227+ }228+229+ for x := range g.w {230+ if g.cells[y][x] == cellUnknown && newRow[x] != cellUnknown {231+ g.cells[y][x] = newRow[x]232+ changed = true233+ }234+ }235+ }236+237+ for x := range g.w {238+ for y := range g.h {239+ colBuf[y] = g.cells[y][x]240+ }241+242+ newCol, valid := propagateLine(colBuf[:g.h], hints.cols[x])243+ if !valid {244+ return g, false245+ }246+247+ for y := range g.h {248+ if g.cells[y][x] == cellUnknown && newCol[y] != cellUnknown {249+ g.cells[y][x] = newCol[y]250+ changed = true251+ }252+ }253+ }254+ }255+256+ return g, true257+}258+259+func propagateLine(line []cellState, hint []int) ([]cellState, bool) {260+ n := len(line)261+ possibilities := getCachedPossibilities(n, hint)262+263+ if len(possibilities) > maxPossibilities {264+ return line, true265+ }266+267+ filtered := make([][]cellState, 0, len(possibilities))268+ for _, poss := range possibilities {269+ if matchesLine(poss, line) {270+ filtered = append(filtered, poss)271+ }272+ }273+274+ if len(filtered) == 0 {275+ return nil, false276+ }277+278+ result := make([]cellState, n)279+ for i := range n {280+ allFilled := true281+ allEmpty := true282+ for _, poss := range filtered {283+ if poss[i] != cellFilled {284+ allFilled = false285+ }286+ if poss[i] != cellEmpty {287+ allEmpty = false288+ }289+ }290+ if allFilled {291+ result[i] = cellFilled292+ } else if allEmpty {293+ result[i] = cellEmpty294+ } else {295+ result[i] = cellUnknown296+ }297+ }298+299+ return result, true300+}301+302+func getCachedPossibilities(length int, hint []int) [][]cellState {303+ key := cacheKey(length, hint)304+305+ possibilitiesMu.RLock()306+ if cached, ok := possibilitiesCache[key]; ok {307+ possibilitiesMu.RUnlock()308+ return cached309+ }310+ possibilitiesMu.RUnlock()311+312+ possibilities := generateLinePossibilities(length, hint)313+314+ possibilitiesMu.Lock()315+ if cached, ok := possibilitiesCache[key]; ok {316+ possibilitiesMu.Unlock()317+ return cached318+ }319+ possibilitiesCache[key] = possibilities320+ possibilitiesMu.Unlock()321+322+ return possibilities323+}324+325+func generateLinePossibilities(length int, hint []int) [][]cellState {326+ if len(hint) == 0 || (len(hint) == 1 && hint[0] == 0) {327+ result := make([]cellState, length)328+ for i := range result {329+ result[i] = cellEmpty330+ }331+ return [][]cellState{result}332+ }333+334+ totalFilled := 0335+ for _, h := range hint {336+ totalFilled += h337+ }338+ minLength := totalFilled + len(hint) - 1339+ if minLength > length {340+ return nil341+ }342+343+ return generateLinePossibilitiesRecursive(length, hint, 0, 0, make([]cellState, 0, length))344+}345+346+func generateLinePossibilitiesRecursive(length int, hint []int, hintIdx, pos int, current []cellState) [][]cellState {347+ if hintIdx == len(hint) {348+ for len(current) < length {349+ current = append(current, cellEmpty)350+ }351+ return [][]cellState{append([]cellState(nil), current...)}352+ }353+354+ blockLen := hint[hintIdx]355+ remainingHints := hint[hintIdx+1:]356+ remainingFilled := 0357+ for _, h := range remainingHints {358+ remainingFilled += h359+ }360+ minRemaining := remainingFilled + len(remainingHints)361+362+ maxStart := length - blockLen - minRemaining363+364+ var results [][]cellState365+366+ for start := pos; start <= maxStart; start++ {367+ newCurrent := append([]cellState(nil), current...)368+369+ for i := len(current); i < start; i++ {370+ newCurrent = append(newCurrent, cellEmpty)371+ }372+373+ for i := 0; i < blockLen; i++ {374+ newCurrent = append(newCurrent, cellFilled)375+ }376+377+ if hintIdx < len(hint)-1 {378+ newCurrent = append(newCurrent, cellEmpty)379+ }380+ results = append(results, generateLinePossibilitiesRecursive(length, hint, hintIdx+1, len(newCurrent), newCurrent)...)381+ }382+383+ return results384+}385+386+func matchesLine(possibility, line []cellState) bool {387+ if len(possibility) != len(line) {388+ return false389+ }390+ for i, p := range possibility {391+ if line[i] != cellUnknown && line[i] != p {392+ return false393+ }394+ }395+ return true396+}@@ -1,9 +1,11 @@
11 package nonogram22 33 import (4+ "context"45 "reflect"56 "strings"67 "testing"8+ "time"79 810 "github.com/FelineStateMachine/puzzletea/game"911 )@@ -577,6 +579,371 @@ func TestSaveLoadRoundTrip(t *testing.T) {
577579 })578580 }579581 582+// --- countSolutions (P0) ---583+584+func TestCountSolutions_Unique(t *testing.T) {585+ tests := []struct {586+ name string587+ hints Hints588+ w, h int589+ want int590+ }{591+ {592+ name: "3x3 permutation matrix has 6 solutions",593+ hints: Hints{594+ rows: TomographyDefinition{{1}, {1}, {1}},595+ cols: TomographyDefinition{{1}, {1}, {1}},596+ },597+ w: 3, h: 3,598+ want: 6,599+ },600+ {601+ name: "2x2 diagonal has 2 solutions",602+ hints: Hints{603+ rows: TomographyDefinition{{1}, {1}},604+ cols: TomographyDefinition{{1}, {1}},605+ },606+ w: 2, h: 2,607+ want: 2,608+ },609+ {610+ name: "single cell filled",611+ hints: Hints{612+ rows: TomographyDefinition{{1}},613+ cols: TomographyDefinition{{1}},614+ },615+ w: 1, h: 1,616+ want: 1,617+ },618+ {619+ name: "single cell empty",620+ hints: Hints{621+ rows: TomographyDefinition{{0}},622+ cols: TomographyDefinition{{0}},623+ },624+ w: 1, h: 1,625+ want: 1,626+ },627+ {628+ name: "full row constraint",629+ hints: Hints{630+ rows: TomographyDefinition{{3}},631+ cols: TomographyDefinition{{1}, {1}, {1}},632+ },633+ w: 3, h: 1,634+ want: 1,635+ },636+ {637+ name: "full 2x2 grid unique",638+ hints: Hints{639+ rows: TomographyDefinition{{2}, {2}},640+ cols: TomographyDefinition{{2}, {2}},641+ },642+ w: 2, h: 2,643+ want: 1,644+ },645+ {646+ name: "empty 2x2 grid unique",647+ hints: Hints{648+ rows: TomographyDefinition{{0}, {0}},649+ cols: TomographyDefinition{{0}, {0}},650+ },651+ w: 2, h: 2,652+ want: 1,653+ },654+ {655+ name: "L-shape unique",656+ hints: Hints{657+ rows: TomographyDefinition{{2}, {1}},658+ cols: TomographyDefinition{{1}, {2}},659+ },660+ w: 2, h: 2,661+ want: 1,662+ },663+ }664+665+ for _, tt := range tests {666+ t.Run(tt.name, func(t *testing.T) {667+ ctx := context.Background()668+ got := countSolutions(tt.hints, tt.w, tt.h, 20, ctx)669+ if got != tt.want {670+ t.Errorf("countSolutions = %d, want %d", got, tt.want)671+ }672+ })673+ }674+}675+676+func TestCountSolutions_Multiple(t *testing.T) {677+ t.Run("symmetric hints with multiple solutions", func(t *testing.T) {678+ hints := Hints{679+ rows: TomographyDefinition{{1}, {1}, {1}},680+ cols: TomographyDefinition{{1}, {1}, {1}},681+ }682+ ctx := context.Background()683+ got := countSolutions(hints, 3, 3, 10, ctx)684+ if got < 2 {685+ t.Errorf("expected multiple solutions, got %d", got)686+ }687+ })688+689+ t.Run("ambiguous pattern", func(t *testing.T) {690+ hints := Hints{691+ rows: TomographyDefinition{{1}, {1}},692+ cols: TomographyDefinition{{1}, {1}},693+ }694+ ctx := context.Background()695+ got := countSolutions(hints, 2, 2, 10, ctx)696+ if got != 2 {697+ t.Errorf("expected 2 solutions, got %d", got)698+ }699+ })700+}701+702+func TestCountSolutions_Limit(t *testing.T) {703+ t.Run("respects limit", func(t *testing.T) {704+ hints := Hints{705+ rows: TomographyDefinition{{1}, {1}, {1}},706+ cols: TomographyDefinition{{1}, {1}, {1}},707+ }708+ ctx := context.Background()709+ got := countSolutions(hints, 3, 3, 2, ctx)710+ if got > 2 {711+ t.Errorf("countSolutions = %d, should not exceed limit 2", got)712+ }713+ })714+}715+716+func TestCountSolutions_Timeout(t *testing.T) {717+ t.Run("returns on timeout", func(t *testing.T) {718+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)719+ defer cancel()720+ time.Sleep(1 * time.Millisecond)721+722+ hints := Hints{723+ rows: TomographyDefinition{{1}, {1}, {1}},724+ cols: TomographyDefinition{{1}, {1}, {1}},725+ }726+ got := countSolutions(hints, 3, 3, 10, ctx)727+ if got != -1 {728+ t.Errorf("expected -1 on timeout, got %d", got)729+ }730+ })731+}732+733+// --- propagateLine (P0) ---734+735+func TestPropagateLine(t *testing.T) {736+ tests := []struct {737+ name string738+ line []cellState739+ hint []int740+ want []cellState741+ wantOK bool742+ }{743+ {744+ name: "empty line with zero hint",745+ line: []cellState{cellUnknown, cellUnknown, cellUnknown},746+ hint: []int{0},747+ want: []cellState{cellEmpty, cellEmpty, cellEmpty},748+ wantOK: true,749+ },750+ {751+ name: "full line",752+ line: []cellState{cellUnknown, cellUnknown, cellUnknown},753+ hint: []int{3},754+ want: []cellState{cellFilled, cellFilled, cellFilled},755+ wantOK: true,756+ },757+ {758+ name: "single block in middle of 5",759+ line: []cellState{cellUnknown, cellUnknown, cellUnknown, cellUnknown, cellUnknown},760+ hint: []int{3},761+ want: []cellState{cellUnknown, cellUnknown, cellFilled, cellUnknown, cellUnknown},762+ wantOK: true,763+ },764+ {765+ name: "contradiction",766+ line: []cellState{cellFilled, cellUnknown, cellEmpty},767+ hint: []int{3},768+ want: nil,769+ wantOK: false,770+ },771+ {772+ name: "partial fill forced with [2,1]",773+ line: []cellState{cellFilled, cellUnknown, cellUnknown, cellUnknown, cellUnknown},774+ hint: []int{2, 1},775+ want: []cellState{cellFilled, cellFilled, cellEmpty, cellUnknown, cellUnknown},776+ wantOK: true,777+ },778+ }779+780+ for _, tt := range tests {781+ t.Run(tt.name, func(t *testing.T) {782+ got, ok := propagateLine(tt.line, tt.hint)783+ if ok != tt.wantOK {784+ t.Errorf("propagateLine ok = %v, want %v", ok, tt.wantOK)785+ return786+ }787+ if !tt.wantOK {788+ return789+ }790+ if len(got) != len(tt.want) {791+ t.Errorf("got length %d, want %d", len(got), len(tt.want))792+ return793+ }794+ for i := range got {795+ if got[i] != tt.want[i] {796+ t.Errorf("got[%d] = %v, want %v", i, got[i], tt.want[i])797+ }798+ }799+ })800+ }801+}802+803+// --- generateLinePossibilities (P0) ---804+805+func TestGenerateLinePossibilities(t *testing.T) {806+ tests := []struct {807+ name string808+ length int809+ hint []int810+ want int811+ }{812+ {"empty line", 3, []int{0}, 1},813+ {"full line", 3, []int{3}, 1},814+ {"single in 3", 3, []int{1}, 3},815+ {"two blocks", 5, []int{1, 1}, 6},816+ {"block of 2 in 5", 5, []int{2}, 4},817+ {"exact fit", 5, []int{2, 2}, 1},818+ {"impossible", 3, []int{4}, 0},819+ }820+821+ for _, tt := range tests {822+ t.Run(tt.name, func(t *testing.T) {823+ got := generateLinePossibilities(tt.length, tt.hint)824+ if len(got) != tt.want {825+ t.Errorf("got %d possibilities, want %d", len(got), tt.want)826+ }827+ })828+ }829+}830+831+// --- GenerateRandomTomography uniqueness (P0) ---832+833+func TestGenerateRandomTomography_AllModes_Unique(t *testing.T) {834+ if testing.Short() {835+ t.Skip("skipping slow generator test in short mode")836+ }837+ modes := []struct {838+ name string839+ width int840+ height int841+ density float64842+ runs int843+ }{844+ {"Easy 5x5", 5, 5, 0.65, 5},845+ {"Medium 5x5", 5, 5, 0.50, 5},846+ {"Hard 5x5", 5, 5, 0.35, 5},847+ {"Easy 10x10", 10, 10, 0.65, 3},848+ {"Medium 10x10", 10, 10, 0.50, 3},849+ {"Hard 10x10", 10, 10, 0.35, 3},850+ {"Easy 15x15", 15, 15, 0.65, 1},851+ {"Medium 15x15", 15, 15, 0.50, 1},852+ {"Easy 20x20", 20, 20, 0.65, 1},853+ {"Medium 20x20", 20, 20, 0.50, 1},854+ }855+856+ for _, m := range modes {857+ t.Run(m.name, func(t *testing.T) {858+ if m.width >= 15 && testing.Short() {859+ t.Skip("skipping large grids in short mode")860+ }861+862+ mode := NewMode(m.name, "test mode", m.width, m.height, m.density)863+864+ for range m.runs {865+ hints := GenerateRandomTomography(mode)866+ if len(hints.rows) == 0 || len(hints.cols) == 0 {867+ t.Error("GenerateRandomTomography returned empty hints")868+ return869+ }870+ if len(hints.rows) != m.height || len(hints.cols) != m.width {871+ t.Errorf("hints dimensions wrong: got rows=%d, cols=%d, want rows=%d, cols=%d",872+ len(hints.rows), len(hints.cols), m.height, m.width)873+ return874+ }875+ }876+ })877+ }878+}879+880+func TestGenerateRandomTomography_5x5_VerifyUnique(t *testing.T) {881+ mode := NewMode("Test 5x5", "test mode", 5, 5, 0.5)882+883+ hints := GenerateRandomTomography(mode)884+ if len(hints.rows) == 0 {885+ t.Fatal("GenerateRandomTomography returned empty hints")886+ }887+888+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)889+ defer cancel()890+891+ count := countSolutions(hints, mode.Width, mode.Height, 2, ctx)892+ if count != 1 {893+ t.Errorf("puzzle has %d solutions, want 1", count)894+ }895+}896+580897 // Ensure the game import is used (it's needed for the init() registration side effect581898 // and the NewMode constructor uses game.NewBaseMode internally).582899 var _ game.Gamer = Model{}900+901+// --- Spawn Performance Benchmark (P2) ---902+903+func TestSpawnPerformance(t *testing.T) {904+ if testing.Short() {905+ t.Skip("skipping slow performance test in short mode")906+ }907+ const numRuns = 20908+909+ modeConfigs := []struct {910+ name string911+ mode NonogramMode912+ }{913+ {"Mini", NewMode("Mini", "test", 5, 5, 0.65)},914+ {"Pocket", NewMode("Pocket", "test", 5, 5, 0.50)},915+ {"Teaser", NewMode("Teaser", "test", 5, 5, 0.35)},916+ {"Standard", NewMode("Standard", "test", 10, 10, 0.67)},917+ {"Classic", NewMode("Classic", "test", 10, 10, 0.52)},918+ {"Tricky", NewMode("Tricky", "test", 10, 10, 0.37)},919+ {"Large", NewMode("Large", "test", 15, 15, 0.69)},920+ {"Grand", NewMode("Grand", "test", 15, 15, 0.54)},921+ {"Epic", NewMode("Epic", "test", 20, 20, 0.71)},922+ {"Massive", NewMode("Massive", "test", 20, 20, 0.56)},923+ }924+925+ t.Run("average spawn time across all modes", func(t *testing.T) {926+ for _, cfg := range modeConfigs {927+ cfg := cfg928+ t.Run(cfg.name, func(t *testing.T) {929+ if cfg.mode.Width >= 15 && testing.Short() {930+ t.Skip("skipping large grids in short mode")931+ }932+933+ var totalDuration time.Duration934+ for i := 0; i < numRuns; i++ {935+ start := time.Now()936+ _, err := cfg.mode.Spawn()937+ if err != nil {938+ t.Fatalf("Spawn failed: %v", err)939+ }940+ totalDuration += time.Since(start)941+ }942+943+ avgNs := totalDuration.Nanoseconds() / numRuns944+ avgµs := float64(avgNs) / 1000945+ t.Logf("Average spawn time for %s: %.2fµs", cfg.name, avgµs)946+ })947+ }948+ })949+}@@ -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+}@@ -916,3 +916,39 @@ func TestImportModel_SolvedRecalculated(t *testing.T) {
916916 t.Error("ImportModel should set solved=true for complete valid grid")917917 }918918 }919+920+// --- Uniqueness across all modes (P0) ---921+922+func TestGeneratePuzzle_AllModes_Unique(t *testing.T) {923+ if testing.Short() {924+ t.Skip("skipping slow generator test in short mode")925+ }926+ modes := []struct {927+ name string928+ size int929+ prefilled float64930+ runs int931+ }{932+ {"Beginner 6x6", 6, 0.50, 3},933+ {"Easy 6x6", 6, 0.40, 3},934+ {"Medium 8x8", 8, 0.40, 3},935+ {"Tricky 10x10", 10, 0.38, 2},936+ {"Hard 10x10", 10, 0.32, 2},937+ {"Very Hard 12x12", 12, 0.30, 1},938+ {"Extreme 14x14", 14, 0.28, 1},939+ }940+941+ for _, m := range modes {942+ t.Run(m.name, func(t *testing.T) {943+ for range m.runs {944+ complete := generateComplete(m.size)945+ puzzle, _ := generatePuzzle(complete, m.size, m.prefilled)946+ count := countSolutions(puzzle, m.size, 2)947+ if count != 1 {948+ t.Errorf("puzzle has %d solutions, want 1", count)949+ return950+ }951+ }952+ })953+ }954+}