clone

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

Add unique solution validation for puzzle generators

Dami committed 5397c4c
Move nonogram generator to separate file and add constraint propagation
solver to verify generated puzzles have exactly one solution. This ensures
players can solve puzzles through logic alone without guessing.

- Add countSolutions with timeout and limit support
- Add propagate/propagateLine for efficient constraint propagation
- Add generateLinePossibilities for line enumeration
- Update GenerateRandomTomography to verify uniqueness
- Add uniqueness tests for nonogram (5x5, 10x10)
- Add uniqueness tests for all takuzu modes
commit
5397c4ced5f9782310ddc142ace1c4a229f207aa
parent
6ae39a5ea28bd35e6406fce03bc953875b7828aa
author
Dami <dami.etoile@gmail.com>
4 files changed, +662 -55
Mnonogram/Export.go55-----
Anonogram/generator.go339++++++++++++++++++++++++++++++
Mnonogram/nonogram_test.go290++++++++++++++++++++++++++
Mtakuzu/takuzu_test.go33+++
M nonogram/Export.go +0 -55
A nonogram/generator.go +339 -0
@@ -0,0 +1,339 @@
1+package nonogram2+3+import (4+ "context"5+ "math/rand/v2"6+ "strings"7+ "time"8+)9+10+func GenerateRandomTomography(mode NonogramMode) Hints {11+ timeout := 5 * time.Second12+ for {13+ s := generateRandomState(mode.Height, mode.Width, mode.Density)14+ g := newGrid(s)15+ hints := generateTomography(g)16+ if !isValidPuzzle(hints, mode.Height, mode.Width) {17+ continue18+ }19+ ctx, cancel := context.WithTimeout(context.Background(), timeout)20+ count := countSolutions(hints, mode.Width, mode.Height, 2, ctx)21+ cancel()22+ if count == 1 {23+ return hints24+ }25+ }26+}27+28+func generateRandomState(h, w int, density float64) state {29+ if h <= 0 || w <= 0 {30+ return ""31+ }32+33+ density = max(0.1, min(0.9, density))34+35+ var b strings.Builder36+ b.Grow((w + 1) * h)37+38+ for i := range h {39+ rowDensity := density + (rand.Float64()-0.5)*0.340+ rowDensity = max(0.05, min(0.95, rowDensity))41+ for range w {42+ if rand.Float64() < rowDensity {43+ b.WriteRune(filledTile)44+ } else {45+ b.WriteRune(emptyTile)46+ }47+ }48+ if i < h-1 {49+ b.WriteRune('\n')50+ }51+ }52+53+ return state(b.String())54+}55+56+func isValidPuzzle(hints Hints, height, width int) bool {57+ for _, rh := range hints.rows {58+ if len(rh) == 1 && (rh[0] == 0 || rh[0] == width) {59+ return false60+ }61+ }62+ for _, ch := range hints.cols {63+ if len(ch) == 1 && (ch[0] == 0 || ch[0] == height) {64+ return false65+ }66+ }67+ return true68+}69+70+type cellState int71+72+const (73+ cellUnknown cellState = iota74+ cellEmpty75+ cellFilled76+)77+78+type solutionGrid struct {79+ cells [][]cellState80+ w, h int81+}82+83+func newSolutionGrid(w, h int) solutionGrid {84+ cells := make([][]cellState, h)85+ for y := range h {86+ cells[y] = make([]cellState, w)87+ for x := range w {88+ cells[y][x] = cellUnknown89+ }90+ }91+ return solutionGrid{cells: cells, w: w, h: h}92+}93+94+func (g *solutionGrid) clone() solutionGrid {95+ cells := make([][]cellState, g.h)96+ for y := range g.h {97+ cells[y] = make([]cellState, g.w)98+ copy(cells[y], g.cells[y])99+ }100+ return solutionGrid{cells: cells, w: g.w, h: g.h}101+}102+103+func (g *solutionGrid) set(x, y int, state cellState) bool {104+ if g.cells[y][x] != cellUnknown && g.cells[y][x] != state {105+ return false106+ }107+ g.cells[y][x] = state108+ return true109+}110+111+func (g *solutionGrid) isComplete() bool {112+ for y := range g.h {113+ for x := range g.w {114+ if g.cells[y][x] == cellUnknown {115+ return false116+ }117+ }118+ }119+ return true120+}121+122+func countSolutions(hints Hints, w, h, limit int, ctx context.Context) int {123+ g := newSolutionGrid(w, h)124+ return countSolutionsRecursive(g, hints, limit, ctx)125+}126+127+func countSolutionsRecursive(g solutionGrid, hints Hints, limit int, ctx context.Context) int {128+ select {129+ case <-ctx.Done():130+ return -1131+ default:132+ }133+134+ propagated, valid := propagate(g, hints)135+ if !valid {136+ return 0137+ }138+139+ if propagated.isComplete() {140+ return 1141+ }142+143+ for y := range propagated.h {144+ for x := range propagated.w {145+ if propagated.cells[y][x] == cellUnknown {146+ count := 0147+148+ g1 := propagated.clone()149+ if g1.set(x, y, cellFilled) {150+ n := countSolutionsRecursive(g1, hints, limit-count, ctx)151+ if n < 0 {152+ return n153+ }154+ count += n155+ if count >= limit {156+ return count157+ }158+ }159+160+ g2 := propagated.clone()161+ if g2.set(x, y, cellEmpty) {162+ n := countSolutionsRecursive(g2, hints, limit-count, ctx)163+ if n < 0 {164+ return n165+ }166+ count += n167+ if count >= limit {168+ return count169+ }170+ }171+172+ return count173+ }174+ }175+ }176+177+ return 0178+}179+180+func propagate(g solutionGrid, hints Hints) (solutionGrid, bool) {181+ changed := true182+ for changed {183+ changed = false184+185+ for y := range g.h {186+ row := make([]cellState, g.w)187+ for x := range g.w {188+ row[x] = g.cells[y][x]189+ }190+191+ newRow, valid := propagateLine(row, hints.rows[y])192+ if !valid {193+ return g, false194+ }195+196+ for x := range g.w {197+ if g.cells[y][x] == cellUnknown && newRow[x] != cellUnknown {198+ g.cells[y][x] = newRow[x]199+ changed = true200+ }201+ }202+ }203+204+ for x := range g.w {205+ col := make([]cellState, g.h)206+ for y := range g.h {207+ col[y] = g.cells[y][x]208+ }209+210+ newCol, valid := propagateLine(col, hints.cols[x])211+ if !valid {212+ return g, false213+ }214+215+ for y := range g.h {216+ if g.cells[y][x] == cellUnknown && newCol[y] != cellUnknown {217+ g.cells[y][x] = newCol[y]218+ changed = true219+ }220+ }221+ }222+ }223+224+ return g, true225+}226+227+func propagateLine(line []cellState, hint []int) ([]cellState, bool) {228+ n := len(line)229+ possibilities := generateLinePossibilities(n, hint)230+231+ filtered := make([][]cellState, 0, len(possibilities))232+ for _, poss := range possibilities {233+ if matchesLine(poss, line) {234+ filtered = append(filtered, poss)235+ }236+ }237+238+ if len(filtered) == 0 {239+ return nil, false240+ }241+242+ result := make([]cellState, n)243+ for i := range n {244+ allFilled := true245+ allEmpty := true246+ for _, poss := range filtered {247+ if poss[i] != cellFilled {248+ allFilled = false249+ }250+ if poss[i] != cellEmpty {251+ allEmpty = false252+ }253+ }254+ if allFilled {255+ result[i] = cellFilled256+ } else if allEmpty {257+ result[i] = cellEmpty258+ } else {259+ result[i] = cellUnknown260+ }261+ }262+263+ return result, true264+}265+266+func generateLinePossibilities(length int, hint []int) [][]cellState {267+ if len(hint) == 0 || (len(hint) == 1 && hint[0] == 0) {268+ result := make([]cellState, length)269+ for i := range result {270+ result[i] = cellEmpty271+ }272+ return [][]cellState{result}273+ }274+275+ totalFilled := 0276+ for _, h := range hint {277+ totalFilled += h278+ }279+ minLength := totalFilled + len(hint) - 1280+ if minLength > length {281+ return nil282+ }283+284+ return generateLinePossibilitiesRecursive(length, hint, 0, 0, make([]cellState, 0, length))285+}286+287+func generateLinePossibilitiesRecursive(length int, hint []int, hintIdx, pos int, current []cellState) [][]cellState {288+ if hintIdx == len(hint) {289+ for len(current) < length {290+ current = append(current, cellEmpty)291+ }292+ return [][]cellState{append([]cellState(nil), current...)}293+ }294+295+ blockLen := hint[hintIdx]296+ remainingHints := hint[hintIdx+1:]297+ remainingFilled := 0298+ for _, h := range remainingHints {299+ remainingFilled += h300+ }301+ minRemaining := remainingFilled + len(remainingHints)302+303+ maxStart := length - blockLen - minRemaining304+305+ var results [][]cellState306+307+ for start := pos; start <= maxStart; start++ {308+ newCurrent := append([]cellState(nil), current...)309+310+ for i := len(current); i < start; i++ {311+ newCurrent = append(newCurrent, cellEmpty)312+ }313+314+ for i := 0; i < blockLen; i++ {315+ newCurrent = append(newCurrent, cellFilled)316+ }317+318+ if hintIdx < len(hint)-1 {319+ newCurrent = append(newCurrent, cellEmpty)320+ results = append(results, generateLinePossibilitiesRecursive(length, hint, hintIdx+1, len(newCurrent), newCurrent)...)321+ } else {322+ results = append(results, generateLinePossibilitiesRecursive(length, hint, hintIdx+1, len(newCurrent), newCurrent)...)323+ }324+ }325+326+ return results327+}328+329+func matchesLine(possibility, line []cellState) bool {330+ if len(possibility) != len(line) {331+ return false332+ }333+ for i, p := range possibility {334+ if line[i] != cellUnknown && line[i] != p {335+ return false336+ }337+ }338+ return true339+}
M nonogram/nonogram_test.go +290 -0
@@ -1,9 +1,12 @@
11 package nonogram22 33 import (4+ "context"5+ "fmt"46 "reflect"57 "strings"68 "testing"9+ "time"710 811 "github.com/FelineStateMachine/puzzletea/game"912 )
@@ -577,6 +580,293 @@ func TestSaveLoadRoundTrip(t *testing.T) {
577580 })578581 }579582 583+// --- countSolutions (P0) ---584+585+func TestCountSolutions_Unique(t *testing.T) {586+ tests := []struct {587+ name string588+ hints Hints589+ w, h int590+ want int591+ }{592+ {593+ name: "3x3 permutation matrix has 6 solutions",594+ hints: Hints{595+ rows: TomographyDefinition{{1}, {1}, {1}},596+ cols: TomographyDefinition{{1}, {1}, {1}},597+ },598+ w: 3, h: 3,599+ want: 6,600+ },601+ {602+ name: "2x2 diagonal has 2 solutions",603+ hints: Hints{604+ rows: TomographyDefinition{{1}, {1}},605+ cols: TomographyDefinition{{1}, {1}},606+ },607+ w: 2, h: 2,608+ want: 2,609+ },610+ {611+ name: "single cell filled",612+ hints: Hints{613+ rows: TomographyDefinition{{1}},614+ cols: TomographyDefinition{{1}},615+ },616+ w: 1, h: 1,617+ want: 1,618+ },619+ {620+ name: "single cell empty",621+ hints: Hints{622+ rows: TomographyDefinition{{0}},623+ cols: TomographyDefinition{{0}},624+ },625+ w: 1, h: 1,626+ want: 1,627+ },628+ {629+ name: "full row constraint",630+ hints: Hints{631+ rows: TomographyDefinition{{3}},632+ cols: TomographyDefinition{{1}, {1}, {1}},633+ },634+ w: 3, h: 1,635+ want: 1,636+ },637+ {638+ name: "full 2x2 grid unique",639+ hints: Hints{640+ rows: TomographyDefinition{{2}, {2}},641+ cols: TomographyDefinition{{2}, {2}},642+ },643+ w: 2, h: 2,644+ want: 1,645+ },646+ {647+ name: "empty 2x2 grid unique",648+ hints: Hints{649+ rows: TomographyDefinition{{0}, {0}},650+ cols: TomographyDefinition{{0}, {0}},651+ },652+ w: 2, h: 2,653+ want: 1,654+ },655+ {656+ name: "L-shape unique",657+ hints: Hints{658+ rows: TomographyDefinition{{2}, {1}},659+ cols: TomographyDefinition{{1}, {2}},660+ },661+ w: 2, h: 2,662+ want: 1,663+ },664+ }665+666+ for _, tt := range tests {667+ t.Run(tt.name, func(t *testing.T) {668+ ctx := context.Background()669+ got := countSolutions(tt.hints, tt.w, tt.h, 20, ctx)670+ if got != tt.want {671+ t.Errorf("countSolutions = %d, want %d", got, tt.want)672+ }673+ })674+ }675+}676+677+func TestCountSolutions_Multiple(t *testing.T) {678+ t.Run("symmetric hints with multiple solutions", func(t *testing.T) {679+ hints := Hints{680+ rows: TomographyDefinition{{1}, {1}, {1}},681+ cols: TomographyDefinition{{1}, {1}, {1}},682+ }683+ ctx := context.Background()684+ got := countSolutions(hints, 3, 3, 10, ctx)685+ if got < 2 {686+ t.Errorf("expected multiple solutions, got %d", got)687+ }688+ })689+690+ t.Run("ambiguous pattern", func(t *testing.T) {691+ hints := Hints{692+ rows: TomographyDefinition{{1}, {1}},693+ cols: TomographyDefinition{{1}, {1}},694+ }695+ ctx := context.Background()696+ got := countSolutions(hints, 2, 2, 10, ctx)697+ if got != 2 {698+ t.Errorf("expected 2 solutions, got %d", got)699+ }700+ })701+}702+703+func TestCountSolutions_Limit(t *testing.T) {704+ t.Run("respects limit", func(t *testing.T) {705+ hints := Hints{706+ rows: TomographyDefinition{{1}, {1}, {1}},707+ cols: TomographyDefinition{{1}, {1}, {1}},708+ }709+ ctx := context.Background()710+ got := countSolutions(hints, 3, 3, 2, ctx)711+ if got > 2 {712+ t.Errorf("countSolutions = %d, should not exceed limit 2", got)713+ }714+ })715+}716+717+func TestCountSolutions_Timeout(t *testing.T) {718+ t.Run("returns on timeout", func(t *testing.T) {719+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)720+ defer cancel()721+ time.Sleep(1 * time.Millisecond)722+723+ hints := Hints{724+ rows: TomographyDefinition{{1}, {1}, {1}},725+ cols: TomographyDefinition{{1}, {1}, {1}},726+ }727+ got := countSolutions(hints, 3, 3, 10, ctx)728+ if got != -1 {729+ t.Errorf("expected -1 on timeout, got %d", got)730+ }731+ })732+}733+734+// --- propagateLine (P0) ---735+736+func TestPropagateLine(t *testing.T) {737+ tests := []struct {738+ name string739+ line []cellState740+ hint []int741+ want []cellState742+ wantOK bool743+ }{744+ {745+ name: "empty line with zero hint",746+ line: []cellState{cellUnknown, cellUnknown, cellUnknown},747+ hint: []int{0},748+ want: []cellState{cellEmpty, cellEmpty, cellEmpty},749+ wantOK: true,750+ },751+ {752+ name: "full line",753+ line: []cellState{cellUnknown, cellUnknown, cellUnknown},754+ hint: []int{3},755+ want: []cellState{cellFilled, cellFilled, cellFilled},756+ wantOK: true,757+ },758+ {759+ name: "single block in middle of 5",760+ line: []cellState{cellUnknown, cellUnknown, cellUnknown, cellUnknown, cellUnknown},761+ hint: []int{3},762+ want: []cellState{cellUnknown, cellUnknown, cellFilled, cellUnknown, cellUnknown},763+ wantOK: true,764+ },765+ {766+ name: "contradiction",767+ line: []cellState{cellFilled, cellUnknown, cellEmpty},768+ hint: []int{3},769+ want: nil,770+ wantOK: false,771+ },772+ {773+ name: "partial fill forced with [2,1]",774+ line: []cellState{cellFilled, cellUnknown, cellUnknown, cellUnknown, cellUnknown},775+ hint: []int{2, 1},776+ want: []cellState{cellFilled, cellFilled, cellEmpty, cellUnknown, cellUnknown},777+ wantOK: true,778+ },779+ }780+781+ for _, tt := range tests {782+ t.Run(tt.name, func(t *testing.T) {783+ got, ok := propagateLine(tt.line, tt.hint)784+ if ok != tt.wantOK {785+ t.Errorf("propagateLine ok = %v, want %v", ok, tt.wantOK)786+ return787+ }788+ if !tt.wantOK {789+ return790+ }791+ if len(got) != len(tt.want) {792+ t.Errorf("got length %d, want %d", len(got), len(tt.want))793+ return794+ }795+ for i := range got {796+ if got[i] != tt.want[i] {797+ t.Errorf("got[%d] = %v, want %v", i, got[i], tt.want[i])798+ }799+ }800+ })801+ }802+}803+804+// --- generateLinePossibilities (P0) ---805+806+func TestGenerateLinePossibilities(t *testing.T) {807+ tests := []struct {808+ name string809+ length int810+ hint []int811+ want int812+ }{813+ {"empty line", 3, []int{0}, 1},814+ {"full line", 3, []int{3}, 1},815+ {"single in 3", 3, []int{1}, 3},816+ {"two blocks", 5, []int{1, 1}, 6},817+ {"block of 2 in 5", 5, []int{2}, 4},818+ {"exact fit", 5, []int{2, 2}, 1},819+ {"impossible", 3, []int{4}, 0},820+ }821+822+ for _, tt := range tests {823+ t.Run(tt.name, func(t *testing.T) {824+ got := generateLinePossibilities(tt.length, tt.hint)825+ if len(got) != tt.want {826+ t.Errorf("got %d possibilities, want %d", len(got), tt.want)827+ }828+ })829+ }830+}831+832+// --- GenerateRandomTomography uniqueness (P0) ---833+834+func TestGenerateRandomTomography_5x5_Unique(t *testing.T) {835+ mode := NewMode("Test 5x5", "test mode", 5, 5, 0.5)836+837+ for i := range 5 {838+ t.Run(fmt.Sprintf("attempt_%d", i), func(t *testing.T) {839+ hints := GenerateRandomTomography(mode)840+841+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)842+ defer cancel()843+844+ count := countSolutions(hints, mode.Width, mode.Height, 2, ctx)845+ if count != 1 {846+ t.Errorf("puzzle has %d solutions, want 1", count)847+ }848+ })849+ }850+}851+852+func TestGenerateRandomTomography_10x10_Unique(t *testing.T) {853+ mode := NewMode("Test 10x10", "test mode", 10, 10, 0.5)854+855+ for i := range 3 {856+ t.Run(fmt.Sprintf("attempt_%d", i), func(t *testing.T) {857+ hints := GenerateRandomTomography(mode)858+859+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)860+ defer cancel()861+862+ count := countSolutions(hints, mode.Width, mode.Height, 2, ctx)863+ if count != 1 {864+ t.Errorf("puzzle has %d solutions, want 1", count)865+ }866+ })867+ }868+}869+580870 // Ensure the game import is used (it's needed for the init() registration side effect581871 // and the NewMode constructor uses game.NewBaseMode internally).582872 var _ game.Gamer = Model{}
M takuzu/takuzu_test.go +33 -0