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>
| M | nonogram/ | 55 | ----- |
| A | nonogram/ | 339 | ++++++++++++++++++++++++++++++ |
| M | nonogram/ | 290 | ++++++++++++++++++++++++++ |
| M | takuzu/ | 33 | +++ |
@@ -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-}@@ -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+}@@ -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{}@@ -916,3 +916,36 @@ 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+ modes := []struct {924+ name string925+ size int926+ prefilled float64927+ runs int928+ }{929+ {"Beginner 6x6", 6, 0.50, 3},930+ {"Easy 6x6", 6, 0.40, 3},931+ {"Medium 8x8", 8, 0.40, 3},932+ {"Tricky 10x10", 10, 0.38, 2},933+ {"Hard 10x10", 10, 0.32, 2},934+ {"Very Hard 12x12", 12, 0.30, 1},935+ {"Extreme 14x14", 14, 0.28, 1},936+ }937+938+ for _, m := range modes {939+ t.Run(m.name, func(t *testing.T) {940+ for range m.runs {941+ complete := generateComplete(m.size)942+ puzzle, _ := generatePuzzle(complete, m.size, m.prefilled)943+ count := countSolutions(puzzle, m.size, 2)944+ if count != 1 {945+ t.Errorf("puzzle has %d solutions, want 1", count)946+ return947+ }948+ }949+ })950+ }951+}