clone

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

perf: improve takuzu and hitori generation paths

Dami committed 4b80db4
commit
4b80db40ed2b53c889def6966a3bbe02aff1ce97
parent
fd8090b7115726eb8333f22161cca1253b68611c
author
Dami <dami.etoile@gmail.com>
6 files changed, +694 -74
Ahitori/benchmark_test.go73+++++++
Mhitori/generator.go162+++++++++++++++-
Mhitori/hitori_test.go127+++++++++++++
Atakuzu/benchmark_test.go59++++++
Mtakuzu/generator.go304++++++++++++++++++++++++------
Mtakuzu/takuzu_test.go43++++
A hitori/benchmark_test.go +73 -0
@@ -0,0 +1,73 @@
1+package hitori2+3+import (4+ "fmt"5+ "math/rand/v2"6+ "testing"7+)8+9+type hitoriBenchMode struct {10+ size int11+ blackRatio float6412+}13+14+var hitoriBenchModes = []hitoriBenchMode{15+ {size: 5, blackRatio: 0.32},16+ {size: 6, blackRatio: 0.32},17+ {size: 8, blackRatio: 0.30},18+ {size: 9, blackRatio: 0.30},19+ {size: 10, blackRatio: 0.30},20+ {size: 12, blackRatio: 0.28},21+}22+23+func BenchmarkGenerateBySize(b *testing.B) {24+ for _, mode := range hitoriBenchModes {25+ name := fmt.Sprintf("%dx%d", mode.size, mode.size)26+ b.Run(name, func(b *testing.B) {27+ rng := rand.New(rand.NewPCG(uint64(mode.size), uint64(mode.size*23+5)))28+ b.ReportAllocs()29+ b.ResetTimer()30+31+ for range b.N {32+ base := generateLatinSquareSeeded(mode.size, rng)33+ mask := generateValidMaskSeeded(mode.size, mode.blackRatio, rng)34+ puzzle := constructPuzzleSeeded(base, mask, rng)35+ if len(puzzle) != mode.size {36+ b.Fatalf("unexpected puzzle size %d", mode.size)37+ }38+ }39+ })40+ }41+}42+43+func BenchmarkCountPuzzleSolutionsBySize(b *testing.B) {44+ for _, mode := range hitoriBenchModes {45+ name := fmt.Sprintf("%dx%d", mode.size, mode.size)46+ b.Run(name, func(b *testing.B) {47+ rng := rand.New(rand.NewPCG(uint64(mode.size*97), uint64(mode.size*31+7)))48+ puzzle := buildSolverBenchPuzzle(mode.size, rng)49+50+ b.ReportAllocs()51+ b.ResetTimer()52+53+ for range b.N {54+ count := countPuzzleSolutions(puzzle, mode.size, 2)55+ if count < 1 {56+ b.Fatalf("countPuzzleSolutions returned %d, want >= 1", count)57+ }58+ }59+ })60+ }61+}62+63+func buildSolverBenchPuzzle(size int, rng *rand.Rand) grid {64+ puzzle := generateLatinSquareSeeded(size, rng)65+66+ for y := range size {67+ x := (y * 2) % size68+ next := (x + 1) % size69+ puzzle[y][next] = puzzle[y][x]70+ }71+72+ return puzzle73+}
M hitori/generator.go +149 -13
@@ -96,7 +96,8 @@ func generateValidMaskSeeded(size int, blackRatio float64, rng *rand.Rand) [][]b
9696 9797 mask[p.y][p.x] = true9898 99- if !whiteCellsConnected(mask, size) {99+ if requiresConnectivityCheck(mask, size, p.x, p.y) &&100+ !whiteCellsConnected(mask, size) {100101 mask[p.y][p.x] = false101102 continue102103 }
@@ -123,6 +124,23 @@ func hasOrthogonalNeighbor(mask [][]bool, size, x, y int) bool {
123124 return false124125 }125126 127+func requiresConnectivityCheck(mask [][]bool, size, x, y int) bool {128+ neighbors := 0129+ if x > 0 && !mask[y][x-1] {130+ neighbors++131+ }132+ if x < size-1 && !mask[y][x+1] {133+ neighbors++134+ }135+ if y > 0 && !mask[y-1][x] {136+ neighbors++137+ }138+ if y < size-1 && !mask[y+1][x] {139+ neighbors++140+ }141+ return neighbors > 1142+}143+126144 func whiteCellsConnected(mask [][]bool, size int) bool {127145 startX, startY := -1, -1128146 whiteCount := 0
@@ -226,15 +244,14 @@ func refinePuzzleSeeded(puzzle grid, mask [][]bool, size int, rng *rand.Rand) (g
226244 227245 // Try to refine each black cell's value to reduce solution count.228246 refined := puzzle.clone()247+ currentCount := countPuzzleSolutions(refined, size, 3)229248 for _, bc := range blackCells {230249 // Collect candidate numbers for this black cell.231250 candidates := cellCandidates(refined, mask, size, bc.x, bc.y)232- rng.Shuffle(len(candidates), func(i, j int) {233- candidates[i], candidates[j] = candidates[j], candidates[i]234- })251+ rankRefinementCandidates(refined, mask, size, bc.x, bc.y, candidates, rng)235252 236253 best := refined[bc.y][bc.x]237- bestCount := countPuzzleSolutions(refined, size, 3)254+ bestCount := currentCount238255 239256 for _, num := range candidates {240257 if num == best {
@@ -251,11 +268,61 @@ func refinePuzzleSeeded(puzzle grid, mask [][]bool, size int, rng *rand.Rand) (g
251268 }252269 }253270 refined[bc.y][bc.x] = best271+ currentCount = bestCount272+273+ if currentCount == 1 {274+ return refined, true275+ }254276 }255277 256278 return refined, countPuzzleSolutions(refined, size, 2) == 1257279 }258280 281+func rankRefinementCandidates(282+ puzzle grid,283+ mask [][]bool,284+ size, x, y int,285+ candidates []rune,286+ rng *rand.Rand,287+) {288+ if len(candidates) <= 1 {289+ return290+ }291+292+ rng.Shuffle(len(candidates), func(i, j int) {293+ candidates[i], candidates[j] = candidates[j], candidates[i]294+ })295+296+ for i := 1; i < len(candidates); i++ {297+ key := candidates[i]298+ keyScore := candidateConstraintScore(puzzle, mask, size, x, y, key)299+ j := i - 1300+ for ; j >= 0; j-- {301+ score := candidateConstraintScore(puzzle, mask, size, x, y, candidates[j])302+ if score >= keyScore {303+ break304+ }305+ candidates[j+1] = candidates[j]306+ }307+ candidates[j+1] = key308+ }309+}310+311+func candidateConstraintScore(puzzle grid, mask [][]bool, size, x, y int, num rune) int {312+ score := 0313+ for cx := range size {314+ if cx != x && !mask[y][cx] && puzzle[y][cx] == num {315+ score++316+ }317+ }318+ for cy := range size {319+ if cy != y && !mask[cy][x] && puzzle[cy][x] == num {320+ score++321+ }322+ }323+ return score324+}325+259326 // cellCandidates returns all valid number choices for a black cell at (x,y):260327 // any number that appears among white cells in the same row or column.261328 func cellCandidates(puzzle grid, mask [][]bool, size, x, y int) []rune {
@@ -291,11 +358,18 @@ func countPuzzleSolutions(puzzle grid, size, limit int) int {
291358 for y := range size {292359 st[y] = make([]solverState, size)293360 }294- return solveBT(puzzle, st, size, 0, limit)361+ order := rowMajorOrder(size)362+ return solveBT(puzzle, st, order, 0, limit)295363 }296364 297-func solveBT(puzzle grid, st [][]solverState, size, pos, limit int) int {298- if pos == size*size {365+func solveBT(366+ puzzle grid,367+ st [][]solverState,368+ order []cellPos,369+ pos, limit int,370+) int {371+ size := len(st)372+ if pos == len(order) {299373 marks := stateToMarks(st, size)300374 if allWhiteConnected(marks, size) {301375 return 1
@@ -303,30 +377,86 @@ func solveBT(puzzle grid, st [][]solverState, size, pos, limit int) int {
303377 return 0304378 }305379 306- x, y := pos%size, pos/size380+ nextIdx := pickNextOrderIndex(puzzle, st, order, pos, size)381+ order[pos], order[nextIdx] = order[nextIdx], order[pos]382+383+ x, y := order[pos].x, order[pos].y384+ whiteOK := canBeWhite(puzzle, st, size, x, y)385+ blackOK := canBeBlack(st, size, x, y)307386 count := 0308387 309- if canBeWhite(puzzle, st, size, x, y) {388+ if !whiteOK && !blackOK {389+ order[pos], order[nextIdx] = order[nextIdx], order[pos]390+ return 0391+ }392+393+ if whiteOK {310394 st[y][x] = white311- count += solveBT(puzzle, st, size, pos+1, limit-count)395+ count += solveBT(puzzle, st, order, pos+1, limit-count)312396 st[y][x] = unknown313397 if count >= limit {398+ order[pos], order[nextIdx] = order[nextIdx], order[pos]314399 return count315400 }316401 }317402 318- if canBeBlack(st, size, x, y) {403+ if blackOK {319404 st[y][x] = black320- count += solveBT(puzzle, st, size, pos+1, limit-count)405+ count += solveBT(puzzle, st, order, pos+1, limit-count)321406 st[y][x] = unknown322407 if count >= limit {408+ order[pos], order[nextIdx] = order[nextIdx], order[pos]323409 return count324410 }325411 }326412 413+ order[pos], order[nextIdx] = order[nextIdx], order[pos]327414 return count328415 }329416 417+func rowMajorOrder(size int) []cellPos {418+ order := make([]cellPos, 0, size*size)419+ for y := range size {420+ for x := range size {421+ order = append(order, cellPos{x, y})422+ }423+ }424+ return order425+}426+427+func pickNextOrderIndex(428+ puzzle grid,429+ st [][]solverState,430+ order []cellPos,431+ pos, size int,432+) int {433+ const lookaheadWindow = 2434+435+ end := pos + lookaheadWindow436+ if end > len(order) {437+ end = len(order)438+ }439+440+ for i := pos; i < end; i++ {441+ x, y := order[i].x, order[i].y442+ whiteOK := canBeWhite(puzzle, st, size, x, y)443+ blackOK := canBeBlack(st, size, x, y)444+445+ options := 0446+ if whiteOK {447+ options++448+ }449+ if blackOK {450+ options++451+ }452+ if options <= 1 {453+ return i454+ }455+ }456+457+ return pos458+}459+330460 func canBeWhite(puzzle grid, st [][]solverState, size, x, y int) bool {331461 num := puzzle[y][x]332462 for i := range size {
@@ -346,9 +476,15 @@ func canBeBlack(st [][]solverState, size, x, y int) bool {
346476 if x > 0 && st[y][x-1] == black {347477 return false348478 }479+ if x < size-1 && st[y][x+1] == black {480+ return false481+ }349482 if y > 0 && st[y-1][x] == black {350483 return false351484 }485+ if y < size-1 && st[y+1][x] == black {486+ return false487+ }352488 return true353489 }354490
M hitori/hitori_test.go +127 -0
@@ -2,6 +2,7 @@ package hitori
22 33 import (44 "encoding/json"5+ "math/rand/v2"56 "testing"67 78 "github.com/FelineStateMachine/puzzletea/game"
@@ -823,6 +824,132 @@ func TestCountPuzzleSolutions_KnownPuzzle(t *testing.T) {
823824 }824825 }825826 827+// --- Uniqueness and connectivity regression (P0) ---828+829+func TestGenerateValidMaskSeeded_AllModes_NoAdjacencyAndConnected(t *testing.T) {830+ if testing.Short() {831+ t.Skip("skipping generator test in short mode")832+ }833+834+ modes := []struct {835+ name string836+ size int837+ blackRatio float64838+ seeds []uint64839+ }{840+ {"Mini 5x5", 5, 0.32, []uint64{11, 17}},841+ {"Easy 6x6", 6, 0.32, []uint64{19, 23}},842+ {"Medium 8x8", 8, 0.30, []uint64{29, 31}},843+ {"Tricky 9x9", 9, 0.30, []uint64{37}},844+ {"Hard 10x10", 10, 0.30, []uint64{41}},845+ {"Expert 12x12", 12, 0.28, []uint64{43}},846+ }847+848+ for _, mode := range modes {849+ t.Run(mode.name, func(t *testing.T) {850+ for _, seed := range mode.seeds {851+ rng := rand.New(rand.NewPCG(seed, seed*7+3))852+ mask := generateValidMaskSeeded(mode.size, mode.blackRatio, rng)853+854+ for y := range mode.size {855+ for x := range mode.size {856+ if mask[y][x] && hasOrthogonalNeighbor(mask, mode.size, x, y) {857+ t.Fatalf("seed %d produced adjacent black cells at (%d,%d)", seed, x, y)858+ }859+ }860+ }861+862+ if !whiteCellsConnected(mask, mode.size) {863+ t.Fatalf("seed %d produced disconnected white cells", seed)864+ }865+ }866+ })867+ }868+}869+870+func TestGenerateSeeded_AllModes_UniqueAndConnected(t *testing.T) {871+ if testing.Short() {872+ t.Skip("skipping generator test in short mode")873+ }874+875+ modes := []struct {876+ name string877+ size int878+ blackRatio float64879+ seeds []uint64880+ }{881+ {"Mini 5x5", 5, 0.32, []uint64{47, 53}},882+ {"Easy 6x6", 6, 0.32, []uint64{59, 61}},883+ {"Medium 8x8", 8, 0.30, []uint64{67, 71}},884+ {"Tricky 9x9", 9, 0.30, []uint64{73}},885+ {"Hard 10x10", 10, 0.30, []uint64{79}},886+ {"Expert 12x12", 12, 0.28, []uint64{83}},887+ }888+889+ for _, mode := range modes {890+ t.Run(mode.name, func(t *testing.T) {891+ for _, seed := range mode.seeds {892+ rng := rand.New(rand.NewPCG(seed, seed*11+5))893+ puzzle, err := GenerateSeeded(mode.size, mode.blackRatio, rng)894+ if err != nil {895+ t.Fatalf("seed %d GenerateSeeded: %v", seed, err)896+ }897+898+ if count := countPuzzleSolutions(puzzle, mode.size, 2); count != 1 {899+ t.Fatalf("seed %d puzzle has %d solutions, want 1", seed, count)900+ }901+902+ marks, ok := findFirstSolutionMarks(puzzle, mode.size)903+ if !ok {904+ t.Fatalf("seed %d no valid solution marks found", seed)905+ }906+ if !isValidSolution(puzzle, marks, mode.size) {907+ t.Fatalf("seed %d solution marks violate invariants", seed)908+ }909+ }910+ })911+ }912+}913+914+func findFirstSolutionMarks(puzzle grid, size int) ([][]cellMark, bool) {915+ st := make([][]solverState, size)916+ for y := range size {917+ st[y] = make([]solverState, size)918+ }919+920+ var solution [][]cellMark921+ var search func(pos int) bool922+ search = func(pos int) bool {923+ if pos == size*size {924+ marks := stateToMarks(st, size)925+ if allWhiteConnected(marks, size) {926+ solution = marks927+ return true928+ }929+ return false930+ }931+932+ x, y := pos%size, pos/size933+ if canBeWhite(puzzle, st, size, x, y) {934+ st[y][x] = white935+ if search(pos + 1) {936+ return true937+ }938+ }939+ if canBeBlack(st, size, x, y) {940+ st[y][x] = black941+ if search(pos + 1) {942+ return true943+ }944+ }945+946+ st[y][x] = unknown947+ return false948+ }949+950+ return solution, search(0)951+}952+826953 // --- Registration (P0) ---827954 828955 func TestRegistration(t *testing.T) {
A takuzu/benchmark_test.go +59 -0
@@ -0,0 +1,59 @@
1+package takuzu2+3+import (4+ "fmt"5+ "math/rand/v2"6+ "testing"7+)8+9+type takuzuBenchMode struct {10+ size int11+ prefilled float6412+}13+14+var takuzuBenchModes = []takuzuBenchMode{15+ {size: 8, prefilled: 0.40},16+ {size: 10, prefilled: 0.32},17+ {size: 12, prefilled: 0.30},18+ {size: 14, prefilled: 0.28},19+}20+21+func BenchmarkGeneratePuzzleSizes(b *testing.B) {22+ for _, mode := range takuzuBenchModes {23+ name := fmt.Sprintf("%dx%d", mode.size, mode.size)24+ b.Run(name, func(b *testing.B) {25+ rng := rand.New(rand.NewPCG(uint64(mode.size), uint64(mode.size*17+3)))26+ b.ReportAllocs()27+ b.ResetTimer()28+29+ for range b.N {30+ complete := generateCompleteSeeded(mode.size, rng)31+ puzzle, provided := generatePuzzleSeeded(complete, mode.size, mode.prefilled, rng)32+ if len(puzzle) != mode.size || len(provided) != mode.size {33+ b.Fatalf("unexpected puzzle dimensions for size %d", mode.size)34+ }35+ }36+ })37+ }38+}39+40+func BenchmarkCountSolutionsSizes(b *testing.B) {41+ for _, mode := range takuzuBenchModes {42+ name := fmt.Sprintf("%dx%d", mode.size, mode.size)43+ b.Run(name, func(b *testing.B) {44+ rng := rand.New(rand.NewPCG(uint64(mode.size*101), uint64(mode.size*37+11)))45+ complete := generateCompleteSeeded(mode.size, rng)46+ puzzle, _ := generatePuzzleSeeded(complete, mode.size, mode.prefilled, rng)47+48+ b.ReportAllocs()49+ b.ResetTimer()50+51+ for range b.N {52+ count := countSolutions(puzzle, mode.size, 2)53+ if count != 1 {54+ b.Fatalf("countSolutions returned %d, want 1", count)55+ }56+ }57+ })58+ }59+}
M takuzu/generator.go +243 -61
@@ -4,6 +4,68 @@ import "math/rand/v2"
44 55 type cellPos struct{ x, y int }66 7+type lineStats struct {8+ rowZero []int9+ rowOne []int10+ colZero []int11+ colOne []int12+ rowFilled []int13+ colFilled []int14+}15+16+type mrvChoice struct {17+ x, y int18+ vals [2]rune19+ count int20+}21+22+func newLineStats(g grid, size int) *lineStats {23+ stats := &lineStats{24+ rowZero: make([]int, size),25+ rowOne: make([]int, size),26+ colZero: make([]int, size),27+ colOne: make([]int, size),28+ rowFilled: make([]int, size),29+ colFilled: make([]int, size),30+ }31+32+ for y := range size {33+ for x := range size {34+ val := g[y][x]35+ if val == emptyCell {36+ continue37+ }38+39+ stats.rowFilled[y]++40+ stats.colFilled[x]++41+ switch val {42+ case zeroCell:43+ stats.rowZero[y]++44+ stats.colZero[x]++45+ case oneCell:46+ stats.rowOne[y]++47+ stats.colOne[x]++48+ }49+ }50+ }51+52+ return stats53+}54+55+func (s *lineStats) apply(x, y int, val rune, delta int) {56+ s.rowFilled[y] += delta57+ s.colFilled[x] += delta58+59+ switch val {60+ case zeroCell:61+ s.rowZero[y] += delta62+ s.colZero[x] += delta63+ case oneCell:64+ s.rowOne[y] += delta65+ s.colOne[x] += delta66+ }67+}68+769 // generateComplete fills an empty grid with a valid Takuzu solution using870 // backtracking with retry on failure.971 func generateComplete(size int) grid {
@@ -46,28 +108,88 @@ func fillGrid(g grid, size int) bool {
46108 }47109 48110 func fillGridSeeded(g grid, size int, rng *rand.Rand) bool {111+ stats := newLineStats(g, size)112+ return fillGridSeededWithStats(g, size, rng, stats)113+}114+115+func fillGridSeededWithStats(g grid, size int, rng *rand.Rand, stats *lineStats) bool {116+ choice := selectMRVCell(g, size, stats, rng)117+ if choice.x < 0 {118+ return true119+ }120+ if choice.count == 0 {121+ return false122+ }123+124+ if choice.count == 2 && rng.IntN(2) == 0 {125+ choice.vals[0], choice.vals[1] = choice.vals[1], choice.vals[0]126+ }127+128+ for i := range choice.count {129+ v := choice.vals[i]130+ g[choice.y][choice.x] = v131+ stats.apply(choice.x, choice.y, v, 1)132+ if fillGridSeededWithStats(g, size, rng, stats) {133+ return true134+ }135+ stats.apply(choice.x, choice.y, v, -1)136+ g[choice.y][choice.x] = emptyCell137+ }138+139+ return false140+}141+142+func selectMRVCell(g grid, size int, stats *lineStats, rng *rand.Rand) mrvChoice {143+ choice := mrvChoice{x: -1, y: -1, count: 3}144+ tieCount := 0145+49146 for y := range size {50147 for x := range size {51148 if g[y][x] != emptyCell {52149 continue53150 }54- vals := [2]rune{zeroCell, oneCell}55- if rng.IntN(2) == 0 {56- vals[0], vals[1] = vals[1], vals[0]151+152+ vals, count := cellCandidates(g, size, x, y, stats)153+ if count == 0 {154+ return mrvChoice{x: x, y: y}57155 }58- for _, v := range vals {59- if canPlace(g, size, x, y, v) {60- g[y][x] = v61- if fillGridSeeded(g, size, rng) {62- return true63- }64- g[y][x] = emptyCell156+157+ if count < choice.count {158+ choice = mrvChoice{x: x, y: y, vals: vals, count: count}159+ tieCount = 1160+ continue161+ }162+163+ if count == choice.count {164+ tieCount++165+ if rng != nil && rng.IntN(tieCount) == 0 {166+ choice = mrvChoice{x: x, y: y, vals: vals, count: count}65167 }66168 }67- return false68169 }69170 }70- return true171+172+ if choice.x < 0 {173+ return mrvChoice{x: -1, y: -1, count: 0}174+ }175+176+ return choice177+}178+179+func cellCandidates(g grid, size, x, y int, stats *lineStats) ([2]rune, int) {180+ var vals [2]rune181+ count := 0182+183+ if canPlaceWithStats(g, size, x, y, zeroCell, stats) {184+ vals[count] = zeroCell185+ count++186+ }187+ if canPlaceWithStats(g, size, x, y, oneCell, stats) {188+ vals[count] = oneCell189+ count++190+ }191+192+ return vals, count71193 }72194 73195 // generatePuzzle removes cells from a complete grid to create a puzzle with a unique solution.
@@ -88,59 +210,120 @@ func generatePuzzleSeeded(complete grid, size int, prefilled float64, rng *rand.
88210 89211 target := int(prefilled * float64(size*size))90212 91- positions := make([]cellPos, 0, size*size)213+ rowProvided := make([]int, size)214+ colProvided := make([]int, size)215+ for i := range size {216+ rowProvided[i] = size217+ colProvided[i] = size218+ }219+220+ candidates := make([]cellPos, 0, size*size)92221 for y := range size {93222 for x := range size {94- positions = append(positions, cellPos{x, y})223+ candidates = append(candidates, cellPos{x, y})95224 }96225 }97- rng.Shuffle(len(positions), func(i, j int) {98- positions[i], positions[j] = positions[j], positions[i]226+ rng.Shuffle(len(candidates), func(i, j int) {227+ candidates[i], candidates[j] = candidates[j], candidates[i]99228 })100229 230+ stats := newLineStats(puzzle, size)101231 filled := size * size102- for _, p := range positions {103- if filled <= target {104- break232+ for len(candidates) > 0 && filled > target {233+ bestIdx := pickRemovalCandidate(candidates, puzzle, rowProvided, colProvided, rng)234+ p := candidates[bestIdx]235+ last := len(candidates) - 1236+ candidates[bestIdx] = candidates[last]237+ candidates = candidates[:last]238+239+ if puzzle[p.y][p.x] == emptyCell {240+ continue105241 }242+106243 saved := puzzle[p.y][p.x]107244 puzzle[p.y][p.x] = emptyCell108- if countSolutions(puzzle, size, 2) != 1 {245+ stats.apply(p.x, p.y, saved, -1)246+ if countSolutionsWithStats(puzzle, size, 2, stats) != 1 {109247 puzzle[p.y][p.x] = saved248+ stats.apply(p.x, p.y, saved, 1)110249 } else {111250 provided[p.y][p.x] = false112251 filled--252+ rowProvided[p.y]--253+ colProvided[p.x]--113254 }114255 }115256 116257 return puzzle, provided117258 }118259 260+func pickRemovalCandidate(candidates []cellPos, puzzle grid, rowProvided, colProvided []int, rng *rand.Rand) int {261+ bestIdx := 0262+ bestScore := clueRemovalScore(candidates[0], puzzle, rowProvided, colProvided)263+264+ for i := 1; i < len(candidates); i++ {265+ score := clueRemovalScore(candidates[i], puzzle, rowProvided, colProvided)266+ if score > bestScore || (score == bestScore && rng.IntN(2) == 0) {267+ bestIdx = i268+ bestScore = score269+ }270+ }271+272+ return bestIdx273+}274+275+func clueRemovalScore(p cellPos, puzzle grid, rowProvided, colProvided []int) int {276+ size := len(puzzle)277+ score := rowProvided[p.y] + colProvided[p.x]278+279+ if p.x > 0 && puzzle[p.y][p.x-1] != emptyCell {280+ score++281+ }282+ if p.x < size-1 && puzzle[p.y][p.x+1] != emptyCell {283+ score++284+ }285+ if p.y > 0 && puzzle[p.y-1][p.x] != emptyCell {286+ score++287+ }288+ if p.y < size-1 && puzzle[p.y+1][p.x] != emptyCell {289+ score++290+ }291+292+ return score293+}294+119295 // countSolutions counts solutions of the grid up to limit using backtracking.120296 func countSolutions(g grid, size, limit int) int {121- for y := range size {122- for x := range size {123- if g[y][x] != emptyCell {124- continue125- }126- count := 0127- for _, v := range [2]rune{zeroCell, oneCell} {128- if canPlace(g, size, x, y, v) {129- g[y][x] = v130- count += countSolutions(g, size, limit-count)131- g[y][x] = emptyCell132- if count >= limit {133- return count134- }135- }136- }137- return count297+ stats := newLineStats(g, size)298+ return countSolutionsWithStats(g, size, limit, stats)299+}300+301+func countSolutionsWithStats(g grid, size, limit int, stats *lineStats) int {302+ choice := selectMRVCell(g, size, stats, nil)303+ if choice.x < 0 {304+ if hasUniqueLines(g, size) {305+ return 1306+ }307+ return 0308+ }309+ if choice.count == 0 {310+ return 0311+ }312+313+ total := 0314+ for i := range choice.count {315+ v := choice.vals[i]316+ g[choice.y][choice.x] = v317+ stats.apply(choice.x, choice.y, v, 1)318+ total += countSolutionsWithStats(g, size, limit-total, stats)319+ stats.apply(choice.x, choice.y, v, -1)320+ g[choice.y][choice.x] = emptyCell321+ if total >= limit {322+ return total138323 }139324 }140- if hasUniqueLines(g, size) {141- return 1142- }143- return 0325+326+ return total144327 }145328 146329 // checkConstraints verifies no-triples and equal-count rules for every row and column.
@@ -221,6 +404,15 @@ func colEqual(g grid, size, c1, c2 int) bool {
221404 222405 // canPlace checks whether placing val at (x,y) would violate Takuzu constraints.223406 func canPlace(g grid, size, x, y int, val rune) bool {407+ stats := newLineStats(g, size)408+ return canPlaceWithStats(g, size, x, y, val, stats)409+}410+411+func canPlaceWithStats(g grid, size, x, y int, val rune, stats *lineStats) bool {412+ if g[y][x] != emptyCell {413+ return false414+ }415+224416 if x >= 2 && g[y][x-1] == val && g[y][x-2] == val {225417 return false226418 }
@@ -242,37 +434,27 @@ func canPlace(g grid, size, x, y int, val rune) bool {
242434 }243435 244436 half := size / 2245- count := 0246- for _, c := range g[y] {247- if c == val {248- count++437+ if val == zeroCell {438+ if stats.rowZero[y] >= half || stats.colZero[x] >= half {439+ return false249440 }250- }251- if count >= half {252- return false253- }254-255- count = 0256- for r := range size {257- if g[r][x] == val {258- count++441+ } else if val == oneCell {442+ if stats.rowOne[y] >= half || stats.colOne[x] >= half {443+ return false259444 }260445 }261- if count >= half {262- return false263- }264446 265- if rowFilledExcept(g, y, x, size) {447+ if stats.rowFilled[y] == size-1 {266448 for other := range size {267- if other != y && rowFilled(g, other, size) && rowEqualWith(g[y], g[other], x, val) {449+ if other != y && stats.rowFilled[other] == size && rowEqualWith(g[y], g[other], x, val) {268450 return false269451 }270452 }271453 }272454 273- if colFilledExcept(g, x, y, size) {455+ if stats.colFilled[x] == size-1 {274456 for other := range size {275- if other != x && colFilled(g, other, size) && colEqualWith(g, size, x, other, y, val) {457+ if other != x && stats.colFilled[other] == size && colEqualWith(g, size, x, other, y, val) {276458 return false277459 }278460 }
M takuzu/takuzu_test.go +43 -0
@@ -2,6 +2,7 @@ package takuzu
22 33 import (44 "encoding/json"5+ "math/rand/v2"56 "testing"67 78 "github.com/FelineStateMachine/puzzletea/game"
@@ -952,3 +953,45 @@ func TestGeneratePuzzle_AllModes_Unique(t *testing.T) {
952953 })953954 }954955 }956+957+func TestGeneratePuzzle_AllModes_SeededRegression(t *testing.T) {958+ if testing.Short() {959+ t.Skip("skipping slow generator test in short mode")960+ }961+962+ modes := []struct {963+ name string964+ size int965+ prefilled float64966+ seeds []uint64967+ }{968+ {"Beginner 6x6", 6, 0.50, []uint64{11}},969+ {"Easy 6x6", 6, 0.40, []uint64{23}},970+ {"Medium 8x8", 8, 0.40, []uint64{31}},971+ {"Tricky 10x10", 10, 0.38, []uint64{41}},972+ {"Hard 10x10", 10, 0.32, []uint64{43}},973+ {"Very Hard 12x12", 12, 0.30, []uint64{47}},974+ {"Extreme 14x14", 14, 0.28, []uint64{53}},975+ }976+977+ for _, mode := range modes {978+ t.Run(mode.name, func(t *testing.T) {979+ for _, seed := range mode.seeds {980+ rng := rand.New(rand.NewPCG(seed, seed*13+1))981+ complete := generateCompleteSeeded(mode.size, rng)982+983+ if !checkConstraints(complete, mode.size) {984+ t.Fatalf("seed %d produced invalid complete grid", seed)985+ }986+ if !hasUniqueLines(complete, mode.size) {987+ t.Fatalf("seed %d produced duplicate lines in complete grid", seed)988+ }989+990+ puzzle, _ := generatePuzzleSeeded(complete, mode.size, mode.prefilled, rng)991+ if count := countSolutions(puzzle, mode.size, 2); count != 1 {992+ t.Fatalf("seed %d produced puzzle with %d solutions, want 1", seed, count)993+ }994+ }995+ })996+ }997+}