Speed up puzzle generation across all games + improved seeded play
- commit
- e7a98808ffb72a72a6e146c3366ac2410a03ccea
- parent
- fd8090b7115726eb8333f22161cca1253b68611c
- parent
- cfe1c313f7ad127acc5ba79596ffee47ef72a6f4
- author
- FelineStateMachine <dami.etoile@gmail.com>
@@ -11,6 +11,14 @@ just clean # remove binary and dist/
1111 ```1212 Without `just`: `go build -ldflags "-X github.com/FelineStateMachine/puzzletea/cmd.Version=$(git describe --tags --always --dirty)" -o puzzletea`1313 14+### CLI Seed Flags15+```bash16+puzzletea new --set-seed myseed # deterministic game/mode/puzzle selection17+puzzletea new nonogram epic --with-seed s1 # deterministic puzzle in selected game/mode18+```19+- `--set-seed` cannot be combined with positional game/mode arguments.20+- `--with-seed` is used with explicit game/mode arguments for mode-local reproducibility.21+1422 ### Testing1523 ```bash1624 just test # go test ./...@@ -96,10 +96,14 @@ puzzletea list --all # include abandoned games
9696 puzzletea continue amber-falcon # resume by name9797 ```9898 99-Use a seed for deterministic puzzle generation:99+Use seeds for deterministic puzzle generation:100100 101101 ```bash102+# Deterministically selects game, mode, and puzzle from one seed.102103 puzzletea new --set-seed myseed104+105+# Deterministically generates a puzzle within a chosen game/mode.106+puzzletea new nonogram epic --with-seed myseed103107 ```104108 105109 Override the color theme:@@ -6,6 +6,7 @@ import (
66 77 "github.com/FelineStateMachine/puzzletea/app"88 "github.com/FelineStateMachine/puzzletea/config"9+ "github.com/FelineStateMachine/puzzletea/game"910 "github.com/FelineStateMachine/puzzletea/namegen"1011 "github.com/FelineStateMachine/puzzletea/resolve"1112 "github.com/FelineStateMachine/puzzletea/stats"@@ -14,7 +15,10 @@ import (
1415 "github.com/spf13/cobra"1516 )1617 17-var flagSetSeed string18+var (19+ flagSetSeed string20+ flagWithSeed string21+)1822 1923 var newCmd = &cobra.Command{2024 Use: "new <game> [mode]",@@ -40,16 +44,17 @@ var newCmd = &cobra.Command{
4044 if len(args) > 1 {4145 modeArg = args[1]4246 }43- return launchNewGame(gameArg, modeArg, cfg)47+ return launchNewGame(gameArg, modeArg, flagWithSeed, cfg)4448 },4549 }4650 4751 func init() {4852 newCmd.Flags().StringVar(&flagSetSeed, "set-seed", "", "seed string for deterministic puzzle selection and generation")53+ newCmd.Flags().StringVar(&flagWithSeed, "with-seed", "", "seed string for deterministic puzzle generation within the selected game/mode")4954 }5055 5156 // launchNewGame resolves the game/mode, spawns a new game, and launches the TUI.52-func launchNewGame(gameArg, modeArg string, cfg *config.Config) error {57+func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {5358 cat, err := resolve.Category(gameArg, app.GameCategories)5459 if err != nil {5560 return err@@ -60,7 +65,7 @@ func launchNewGame(gameArg, modeArg string, cfg *config.Config) error {
6065 return err6166 }6267 63- g, err := spawner.Spawn()68+ g, err := spawnFromMode(spawner, seed)6469 if err != nil {6570 return fmt.Errorf("failed to spawn game: %w", err)6671 }@@ -84,6 +89,18 @@ func launchNewGame(gameArg, modeArg string, cfg *config.Config) error {
8489 return runGameProgram(s, cfg, g, rec.ID, false)8590 }8691 92+func spawnFromMode(spawner game.Spawner, seed string) (game.Gamer, error) {93+ if seed == "" {94+ return spawner.Spawn()95+ }96+97+ seeded, ok := spawner.(game.SeededSpawner)98+ if !ok {99+ return nil, fmt.Errorf("mode does not support deterministic spawning")100+ }101+ return seeded.SpawnSeeded(resolve.RNGFromString(seed))102+}103+87104 // launchSeededGame uses an arbitrary seed string to deterministically select88105 // a game type, mode, and puzzle. The same seed always produces the same puzzle.89106 // If a game with the same seed-derived name already exists, it is resumed.@@ -0,0 +1,100 @@
1+package hashiwokakero2+3+import (4+ "math/rand/v2"5+ "testing"6+)7+8+func skipBenchmarkInShortMode(b *testing.B) {9+ b.Helper()10+ if testing.Short() {11+ b.Skip("skipping benchmark in short mode")12+ }13+}14+15+func benchmarkHashiModesByBoardSize() []HashiMode {16+ return []HashiMode{17+ NewMode("Hard 7x7", "benchmark", 7, 7, 17, 20),18+ NewMode("Hard 9x9", "benchmark", 9, 9, 28, 32),19+ NewMode("Hard 11x11", "benchmark", 11, 11, 42, 48),20+ NewMode("Hard 13x13", "benchmark", 13, 13, 59, 68),21+ }22+}23+24+func BenchmarkGeneratePuzzleByBoardSize(b *testing.B) {25+ skipBenchmarkInShortMode(b)26+27+ for modeIndex, mode := range benchmarkHashiModesByBoardSize() {28+ mode := mode29+ modeIndex := modeIndex30+ b.Run(mode.Title(), func(b *testing.B) {31+ b.ReportAllocs()32+ for i := 0; i < b.N; i++ {33+ var (34+ puzzle Puzzle35+ err error36+ )37+ for attempt := range 32 {38+ rng := rand.New(rand.NewPCG(uint64(i+1), uint64(modeIndex*10+attempt+1)))39+ puzzle, err = GeneratePuzzleSeeded(mode, rng)40+ if err == nil {41+ break42+ }43+ }44+ if err != nil {45+ b.Fatalf("GeneratePuzzleSeeded failed: %v", err)46+ }47+ if puzzle.Width != mode.Width || puzzle.Height != mode.Height {48+ b.Fatalf("wrong dimensions: got %dx%d, want %dx%d", puzzle.Width, puzzle.Height, mode.Width, mode.Height)49+ }50+ }51+ })52+ }53+}54+55+func BenchmarkBuildSpanningTree(b *testing.B) {56+ skipBenchmarkInShortMode(b)57+58+ for modeIndex, mode := range benchmarkHashiModesByBoardSize() {59+ mode := mode60+ modeIndex := modeIndex61+ b.Run(mode.Title(), func(b *testing.B) {62+ base, err := GeneratePuzzleSeeded(mode, rand.New(rand.NewPCG(uint64(modeIndex+50), uint64(modeIndex+60))))63+ if err != nil {64+ b.Fatalf("seed puzzle generation failed: %v", err)65+ }66+67+ islands := make([]Island, len(base.Islands))68+ copy(islands, base.Islands)69+70+ b.ReportAllocs()71+ b.ResetTimer()72+ successes := 073+ for i := 0; i < b.N; i++ {74+ ok := false75+ for attempt := range 32 {76+ islandCopy := make([]Island, len(islands))77+ copy(islandCopy, islands)78+79+ p := Puzzle{80+ Width: mode.Width,81+ Height: mode.Height,82+ Islands: islandCopy,83+ }84+85+ ok = buildSpanningTreeSeeded(86+ &p,87+ rand.New(rand.NewPCG(uint64(i+1), uint64(modeIndex*100+attempt+1))),88+ )89+ if ok {90+ successes++91+ break92+ }93+ }94+ }95+ if successes == 0 {96+ b.Fatal("buildSpanningTreeSeeded never succeeded")97+ }98+ })99+ }100+}@@ -0,0 +1,159 @@
1+package hashiwokakero2+3+import (4+ "math/rand/v2"5+ "testing"6+)7+8+func testBridgeLayoutByMode(t *testing.T, mode HashiMode) Puzzle {9+ t.Helper()10+11+ for attempt := range 64 {12+ rng := rand.New(rand.NewPCG(uint64(mode.Width*100+attempt+1), uint64(mode.Height*1000+attempt+1)))13+ islandCount := mode.MinIslands + rng.IntN(mode.MaxIslands-mode.MinIslands+1)14+ islands := placeIslandsSeeded(mode.Width, mode.Height, islandCount, rng)15+ if islands == nil {16+ continue17+ }18+19+ p := Puzzle{20+ Width: mode.Width,21+ Height: mode.Height,22+ Islands: islands,23+ }24+25+ if !buildSpanningTreeSeeded(&p, rng) {26+ continue27+ }28+ addExtraBridgesSeeded(&p, rng)29+ return p30+ }31+32+ t.Fatalf("failed to build bridge layout for %s", mode.Title())33+ return Puzzle{}34+}35+36+func bridgeAxisSpan(p *Puzzle, b Bridge) (x1, y1, x2, y2 int, horizontal, ok bool) {37+ a := p.FindIslandByID(b.Island1)38+ c := p.FindIslandByID(b.Island2)39+ if a == nil || c == nil {40+ return 0, 0, 0, 0, false, false41+ }42+43+ if a.Y == c.Y {44+ return a.X, a.Y, c.X, c.Y, true, true45+ }46+ if a.X == c.X {47+ return a.X, a.Y, c.X, c.Y, false, true48+ }49+ return 0, 0, 0, 0, false, false50+}51+52+func rangeContainsInterior(v, bound1, bound2 int) bool {53+ min, max := bound1, bound254+ if min > max {55+ min, max = max, min56+ }57+ return v > min && v < max58+}59+60+func bridgesCross(p *Puzzle, a, b Bridge) bool {61+ if a.Island1 == b.Island1 || a.Island1 == b.Island2 || a.Island2 == b.Island1 || a.Island2 == b.Island2 {62+ return false63+ }64+65+ ax1, ay1, ax2, ay2, aHorizontal, aok := bridgeAxisSpan(p, a)66+ bx1, by1, bx2, by2, bHorizontal, bok := bridgeAxisSpan(p, b)67+ if !aok || !bok || aHorizontal == bHorizontal {68+ return false69+ }70+71+ hx1, hy, hx2 := ax1, ay1, ax272+ vx, vy1, vy2 := bx1, by1, by273+ if !aHorizontal {74+ hx1, hy, hx2 = bx1, by1, bx275+ vx, vy1, vy2 = ax1, ay1, ay276+ }77+78+ return rangeContainsInterior(vx, hx1, hx2) && rangeContainsInterior(hy, vy1, vy2)79+}80+81+func assertNoBridgeCrossings(t *testing.T, p *Puzzle) {82+ t.Helper()83+84+ for i := 0; i < len(p.Bridges); i++ {85+ for j := i + 1; j < len(p.Bridges); j++ {86+ if bridgesCross(p, p.Bridges[i], p.Bridges[j]) {87+ t.Fatalf("bridge %d crosses bridge %d", i, j)88+ }89+ }90+ }91+}92+93+func TestGeneratePuzzleRequiredCountsByBoardSize(t *testing.T) {94+ for modeIndex, mode := range benchmarkHashiModesByBoardSize() {95+ mode := mode96+ modeIndex := modeIndex97+98+ t.Run(mode.Title(), func(t *testing.T) {99+ puzzle, err := GeneratePuzzleSeeded(mode, rand.New(rand.NewPCG(uint64(modeIndex+900), uint64(modeIndex+901))))100+ if err != nil {101+ t.Fatalf("GeneratePuzzleSeeded failed: %v", err)102+ }103+ if puzzle.Width != mode.Width || puzzle.Height != mode.Height {104+ t.Fatalf("dimensions = %dx%d, want %dx%d", puzzle.Width, puzzle.Height, mode.Width, mode.Height)105+ }106+ if len(puzzle.Bridges) != 0 {107+ t.Fatalf("expected cleared bridges, got %d", len(puzzle.Bridges))108+ }109+ for _, island := range puzzle.Islands {110+ if island.Required < 1 || island.Required > 8 {111+ t.Fatalf("island %d required=%d out of range", island.ID, island.Required)112+ }113+ }114+ })115+ }116+}117+118+func TestBridgeLayoutConstraintInvariantsByBoardSize(t *testing.T) {119+ for _, mode := range benchmarkHashiModesByBoardSize() {120+ mode := mode121+ t.Run(mode.Title(), func(t *testing.T) {122+ p := testBridgeLayoutByMode(t, mode)123+124+ if !p.IsConnected() {125+ t.Fatal("expected connected bridge layout")126+ }127+128+ seen := make(map[[2]int]bool, len(p.Bridges))129+ for _, b := range p.Bridges {130+ if b.Count < 1 || b.Count > 2 {131+ t.Fatalf("bridge count out of range: %d", b.Count)132+ }133+ key := [2]int{b.Island1, b.Island2}134+ if seen[key] {135+ t.Fatalf("duplicate bridge pair: %v", key)136+ }137+ seen[key] = true138+139+ i1 := p.FindIslandByID(b.Island1)140+ i2 := p.FindIslandByID(b.Island2)141+ if i1 == nil || i2 == nil {142+ t.Fatalf("bridge references unknown island: %+v", b)143+ }144+ if !isDirectlyConnectable(&p, *i1, *i2) {145+ t.Fatalf("bridge between non-directly-connectable islands: %+v", b)146+ }147+ }148+149+ assertNoBridgeCrossings(t, &p)150+151+ for _, island := range p.Islands {152+ count := p.BridgeCount(island.ID)153+ if count < 1 || count > 8 {154+ t.Fatalf("island %d bridge count=%d out of range", island.ID, count)155+ }156+ }157+ })158+ }159+}@@ -7,6 +7,11 @@ import (
77 88 const maxGenerateAttempts = 20099 10+type islandPair struct {11+ id1 int12+ id2 int13+}14+1015 // GeneratePuzzle creates a solvable hashiwokakero puzzle for the given mode.1116 func GeneratePuzzle(mode HashiMode) (Puzzle, error) {1217 rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))@@ -37,11 +42,12 @@ func tryGenerateSeeded(width, height, islandCount int, rng *rand.Rand) *Puzzle {
3742 Islands: islands,3843 }3944 40- if !buildSpanningTreeSeeded(p, rng) {45+ pairs := connectableIslandPairs(p)46+ if !buildSpanningTreeWithPairsSeeded(p, pairs, rng) {4147 return nil4248 }4349 44- addExtraBridgesSeeded(p, rng)50+ addExtraBridgesWithPairsSeeded(p, pairs, rng)4551 4652 for i := range p.Islands {4753 p.Islands[i].Required = p.BridgeCount(p.Islands[i].ID)@@ -119,13 +125,11 @@ func placeIslandsSeeded(width, height, count int, rng *rand.Rand) []Island {
119125 // buildSpanningTreeSeeded connects all islands using a randomized approach.120126 // Returns false if unable to connect all islands.121127 func buildSpanningTreeSeeded(p *Puzzle, rng *rand.Rand) bool {122- if len(p.Islands) <= 1 {123- return true124- }125-126- type pair struct{ id1, id2 int }127- var pairs []pair128+ return buildSpanningTreeWithPairsSeeded(p, connectableIslandPairs(p), rng)129+}128130 131+func connectableIslandPairs(p *Puzzle) []islandPair {132+ pairs := make([]islandPair, 0, len(p.Islands)*2)129133 for i := range len(p.Islands) {130134 for j := i + 1; j < len(p.Islands); j++ {131135 a := p.Islands[i]@@ -136,13 +140,26 @@ func buildSpanningTreeSeeded(p *Puzzle, rng *rand.Rand) bool {
136140 }137141 138142 if isDirectlyConnectable(p, a, b) {139- pairs = append(pairs, pair{a.ID, b.ID})143+ pairs = append(pairs, islandPair{id1: a.ID, id2: b.ID})140144 }141145 }142146 }147+ return pairs148+}143149 144- rng.Shuffle(len(pairs), func(i, j int) {145- pairs[i], pairs[j] = pairs[j], pairs[i]150+func buildSpanningTreeWithPairsSeeded(p *Puzzle, pairs []islandPair, rng *rand.Rand) bool {151+ if len(p.Islands) <= 1 {152+ return true153+ }154+ if len(pairs) == 0 {155+ return false156+ }157+158+ order := make([]islandPair, len(pairs))159+ copy(order, pairs)160+161+ rng.Shuffle(len(order), func(i, j int) {162+ order[i], order[j] = order[j], order[i]146163 })147164 148165 // Union-Find to build spanning tree@@ -175,7 +192,7 @@ func buildSpanningTreeSeeded(p *Puzzle, rng *rand.Rand) bool {
175192 }176193 177194 edgesAdded := 0178- for _, pr := range pairs {195+ for _, pr := range order {179196 if find(pr.id1) == find(pr.id2) {180197 continue181198 }@@ -197,38 +214,33 @@ func buildSpanningTreeSeeded(p *Puzzle, rng *rand.Rand) bool {
197214 198215 // addExtraBridgesSeeded adds additional bridges beyond the spanning tree for complexity.199216 func addExtraBridgesSeeded(p *Puzzle, rng *rand.Rand) {200- var pairs []struct{ id1, id2 int }201-202- for i := 0; i < len(p.Islands); i++ {203- for j := i + 1; j < len(p.Islands); j++ {204- a := p.Islands[i]205- b := p.Islands[j]206-207- if a.X != b.X && a.Y != b.Y {208- continue209- }210-211- if !isDirectlyConnectable(p, a, b) {212- continue213- }217+ addExtraBridgesWithPairsSeeded(p, connectableIslandPairs(p), rng)218+}214219 215- existing := p.GetBridge(a.ID, b.ID)216- if existing != nil && existing.Count >= 2 {217- continue // already maxed218- }220+func addExtraBridgesWithPairsSeeded(p *Puzzle, pairs []islandPair, rng *rand.Rand) {221+ eligible := make([]islandPair, 0, len(pairs))222+ degrees := make(map[int]int, len(p.Islands))223+ for _, bridge := range p.Bridges {224+ degrees[bridge.Island1] += bridge.Count225+ degrees[bridge.Island2] += bridge.Count226+ }219227 220- pairs = append(pairs, struct{ id1, id2 int }{a.ID, b.ID})228+ for _, pr := range pairs {229+ existing := p.GetBridge(pr.id1, pr.id2)230+ if existing != nil && existing.Count >= 2 {231+ continue // already maxed221232 }233+ eligible = append(eligible, pr)222234 }223235 224- rng.Shuffle(len(pairs), func(i, j int) {225- pairs[i], pairs[j] = pairs[j], pairs[i]236+ rng.Shuffle(len(eligible), func(i, j int) {237+ eligible[i], eligible[j] = eligible[j], eligible[i]226238 })227239 228240 // Add bridges to ~30% of eligible pairs229- limit := len(pairs) / 3241+ limit := len(eligible) / 3230242 added := 0231- for _, pr := range pairs {243+ for _, pr := range eligible {232244 if added >= limit {233245 break234246 }@@ -252,11 +264,13 @@ func addExtraBridgesSeeded(p *Puzzle, rng *rand.Rand) {
252264 if existing != nil {253265 addAmount = newCount - existing.Count254266 }255- if p.BridgeCount(pr.id1)+addAmount > 8 || p.BridgeCount(pr.id2)+addAmount > 8 {267+ if degrees[pr.id1]+addAmount > 8 || degrees[pr.id2]+addAmount > 8 {256268 continue257269 }258270 259271 p.SetBridge(pr.id1, pr.id2, newCount)272+ degrees[pr.id1] += addAmount273+ degrees[pr.id2] += addAmount260274 added++261275 }262276 }@@ -140,7 +140,7 @@ func TestSetBridge(t *testing.T) {
140140 }141141 })142142 143- t.Run("invalidates caches", func(t *testing.T) {143+ t.Run("invalidates bridge-derived caches only", func(t *testing.T) {144144 p := fourCornerPuzzle()145145 // Trigger cache builds.146146 _ = p.BridgeCount(0)@@ -152,12 +152,15 @@ func TestSetBridge(t *testing.T) {
152152 if p.cellCache != nil {153153 t.Error("cellCache should be nil after SetBridge")154154 }155- if p.posIndex != nil {156- t.Error("posIndex should be nil after SetBridge")155+ if p.posIndex == nil {156+ t.Error("posIndex should be preserved after SetBridge")157157 }158158 if p.bridgeCounts != nil {159159 t.Error("bridgeCounts should be nil after SetBridge")160160 }161+ if p.bridgeIndex == nil {162+ t.Error("bridgeIndex should remain available after SetBridge")163+ }161164 })162165 }163166 @@ -39,6 +39,7 @@ type Puzzle struct {
3939 cellCache [][]cellInfo // lazily built, invalidated on bridge changes4040 posIndex map[[2]int]int // (x,y) → island slice index; lazily built4141 bridgeCounts []int // per-island bridge count; lazily built42+ bridgeIndex map[[2]int]int // (island1,island2) -> Bridges index; lazily built4243 }4344 4445 // FindIslandAt returns the island at (x,y), or nil.@@ -59,6 +60,21 @@ func (p *Puzzle) buildPosIndex() {
5960 }6061 }6162 63+func bridgeKey(id1, id2 int) [2]int {64+ a, b := id1, id265+ if a > b {66+ a, b = b, a67+ }68+ return [2]int{a, b}69+}70+71+func (p *Puzzle) buildBridgeIndex() {72+ p.bridgeIndex = make(map[[2]int]int, len(p.Bridges))73+ for i, b := range p.Bridges {74+ p.bridgeIndex[bridgeKey(b.Island1, b.Island2)] = i75+ }76+}77+6278 // FindIslandByID returns the island with the given ID, or nil.6379 func (p *Puzzle) FindIslandByID(id int) *Island {6480 if id >= 0 && id < len(p.Islands) && p.Islands[id].ID == id {@@ -75,13 +91,13 @@ func (p *Puzzle) FindIslandByID(id int) *Island {
7591 7692 // GetBridge returns the bridge between two islands (by ID), or nil.7793 func (p *Puzzle) GetBridge(id1, id2 int) *Bridge {78- a, b := id1, id279- if a > b {80- a, b = b, a94+ if p.bridgeIndex == nil {95+ p.buildBridgeIndex()8196 }82- for i := range p.Bridges {83- if p.Bridges[i].Island1 == a && p.Bridges[i].Island2 == b {84- return &p.Bridges[i]97+98+ if idx, ok := p.bridgeIndex[bridgeKey(id1, id2)]; ok {99+ if idx >= 0 && idx < len(p.Bridges) {100+ return &p.Bridges[idx]85101 }86102 }87103 return nil@@ -186,27 +202,35 @@ func (p *Puzzle) SetBridge(id1, id2, count int) {
186202 a, b = b, a187203 }188204 189- p.invalidateCache()205+ p.invalidateBridgeCaches()206+ if p.bridgeIndex == nil {207+ p.buildBridgeIndex()208+ }209+ key := bridgeKey(a, b)190210 191211 if count == 0 {192212 // Remove bridge193- for i := range p.Bridges {194- if p.Bridges[i].Island1 == a && p.Bridges[i].Island2 == b {195- p.Bridges = append(p.Bridges[:i], p.Bridges[i+1:]...)196- return197- }213+ idx, ok := p.bridgeIndex[key]214+ if !ok {215+ return216+ }217+218+ p.Bridges = append(p.Bridges[:idx], p.Bridges[idx+1:]...)219+ delete(p.bridgeIndex, key)220+ for i := idx; i < len(p.Bridges); i++ {221+ p.bridgeIndex[bridgeKey(p.Bridges[i].Island1, p.Bridges[i].Island2)] = i198222 }199223 return200224 }201225 202226 // Update or create bridge203- for i := range p.Bridges {204- if p.Bridges[i].Island1 == a && p.Bridges[i].Island2 == b {205- p.Bridges[i].Count = count206- return207- }227+ if idx, ok := p.bridgeIndex[key]; ok {228+ p.Bridges[idx].Count = count229+ return208230 }231+209232 p.Bridges = append(p.Bridges, Bridge{Island1: a, Island2: b, Count: count})233+ p.bridgeIndex[key] = len(p.Bridges) - 1210234 }211235 212236 // CellContent returns what occupies the given grid cell, using a lazily-built cache.@@ -264,6 +288,13 @@ func (p *Puzzle) invalidateCache() {
264288 p.cellCache = nil265289 p.posIndex = nil266290 p.bridgeCounts = nil291+ p.bridgeIndex = nil292+}293+294+// invalidateBridgeCaches marks bridge-derived caches as stale.295+func (p *Puzzle) invalidateBridgeCaches() {296+ p.cellCache = nil297+ p.bridgeCounts = nil267298 }268299 269300 // IsConnected checks if all islands form a single connected component via bridges.@@ -0,0 +1,84 @@
1+package hitori2+3+import (4+ "fmt"5+ "math/rand/v2"6+ "testing"7+)8+9+func skipBenchmarkInShortMode(b *testing.B) {10+ b.Helper()11+ if testing.Short() {12+ b.Skip("skipping benchmark in short mode")13+ }14+}15+16+type hitoriBenchMode struct {17+ size int18+ blackRatio float6419+}20+21+var hitoriBenchModes = []hitoriBenchMode{22+ {size: 5, blackRatio: 0.32},23+ {size: 6, blackRatio: 0.32},24+ {size: 8, blackRatio: 0.30},25+ {size: 9, blackRatio: 0.30},26+ {size: 10, blackRatio: 0.30},27+ {size: 12, blackRatio: 0.28},28+}29+30+func BenchmarkGenerateBySize(b *testing.B) {31+ skipBenchmarkInShortMode(b)32+33+ for _, mode := range hitoriBenchModes {34+ name := fmt.Sprintf("%dx%d", mode.size, mode.size)35+ b.Run(name, func(b *testing.B) {36+ rng := rand.New(rand.NewPCG(uint64(mode.size), uint64(mode.size*23+5)))37+ b.ReportAllocs()38+ b.ResetTimer()39+40+ for range b.N {41+ base := generateLatinSquareSeeded(mode.size, rng)42+ mask := generateValidMaskSeeded(mode.size, mode.blackRatio, rng)43+ puzzle := constructPuzzleSeeded(base, mask, rng)44+ if len(puzzle) != mode.size {45+ b.Fatalf("unexpected puzzle size %d", mode.size)46+ }47+ }48+ })49+ }50+}51+52+func BenchmarkCountPuzzleSolutionsBySize(b *testing.B) {53+ skipBenchmarkInShortMode(b)54+55+ for _, mode := range hitoriBenchModes {56+ name := fmt.Sprintf("%dx%d", mode.size, mode.size)57+ b.Run(name, func(b *testing.B) {58+ rng := rand.New(rand.NewPCG(uint64(mode.size*97), uint64(mode.size*31+7)))59+ puzzle := buildSolverBenchPuzzle(mode.size, rng)60+61+ b.ReportAllocs()62+ b.ResetTimer()63+64+ for range b.N {65+ count := countPuzzleSolutions(puzzle, mode.size, 2)66+ if count < 1 {67+ b.Fatalf("countPuzzleSolutions returned %d, want >= 1", count)68+ }69+ }70+ })71+ }72+}73+74+func buildSolverBenchPuzzle(size int, rng *rand.Rand) grid {75+ puzzle := generateLatinSquareSeeded(size, rng)76+77+ for y := range size {78+ x := (y * 2) % size79+ next := (x + 1) % size80+ puzzle[y][next] = puzzle[y][x]81+ }82+83+ return puzzle84+}@@ -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 @@ -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) {@@ -0,0 +1,68 @@
1+package lightsout2+3+import (4+ "math/rand/v2"5+ "testing"6+)7+8+func skipBenchmarkInShortMode(b *testing.B) {9+ b.Helper()10+ if testing.Short() {11+ b.Skip("skipping benchmark in short mode")12+ }13+}14+15+func BenchmarkGenerateByMode(b *testing.B) {16+ skipBenchmarkInShortMode(b)17+18+ cases := []struct {19+ name string20+ w int21+ h int22+ }{23+ {name: "easy_3x3", w: 3, h: 3},24+ {name: "medium_5x5", w: 5, h: 5},25+ {name: "hard_7x7", w: 7, h: 7},26+ {name: "extreme_9x9", w: 9, h: 9},27+ }28+29+ for _, tt := range cases {30+ b.Run(tt.name, func(b *testing.B) {31+ rng := rand.New(rand.NewPCG(11, 12))32+ b.ReportAllocs()33+34+ for range b.N {35+ _ = GenerateSeeded(tt.w, tt.h, rng)36+ }37+ })38+ }39+}40+41+func BenchmarkToggle(b *testing.B) {42+ skipBenchmarkInShortMode(b)43+44+ cases := []struct {45+ name string46+ w int47+ h int48+ x int49+ y int50+ }{51+ {name: "easy_center", w: 3, h: 3, x: 1, y: 1},52+ {name: "medium_center", w: 5, h: 5, x: 2, y: 2},53+ {name: "hard_center", w: 7, h: 7, x: 3, y: 3},54+ {name: "extreme_center", w: 9, h: 9, x: 4, y: 4},55+ {name: "extreme_corner", w: 9, h: 9, x: 0, y: 0},56+ }57+58+ for _, tt := range cases {59+ b.Run(tt.name, func(b *testing.B) {60+ g := makeGrid(tt.h, tt.w, false)61+ b.ReportAllocs()62+63+ for range b.N {64+ Toggle(g, tt.x, tt.y)65+ }66+ })67+ }68+}@@ -2,6 +2,7 @@ package lightsout
22 33 import (44 "encoding/json"5+ "math/rand/v2"56 "testing"67 78 "github.com/FelineStateMachine/puzzletea/game"@@ -43,6 +44,18 @@ func makeGrid(h, w int, val bool) [][]bool {
4344 return g4445 }4546 47+func countLightsOn(g [][]bool) int {48+ total := 049+ for y := range g {50+ for x := range g[y] {51+ if g[y][x] {52+ total++53+ }54+ }55+ }56+ return total57+}58+4659 // --- Toggle (P0) ---4760 4861 func TestToggle_Center(t *testing.T) {@@ -232,6 +245,50 @@ func TestGenerate_HasLightsOn(t *testing.T) {
232245 }233246 }234247 248+func TestGenerateSeeded_DistributionNotSolved(t *testing.T) {249+ tests := []struct {250+ name string251+ w int252+ h int253+ }{254+ {name: "easy_3x3", w: 3, h: 3},255+ {name: "medium_5x5", w: 5, h: 5},256+ {name: "hard_7x7", w: 7, h: 7},257+ {name: "extreme_9x9", w: 9, h: 9},258+ }259+260+ for _, tt := range tests {261+ t.Run(tt.name, func(t *testing.T) {262+ minLightsOn := tt.w * tt.h263+ maxLightsOn := 0264+265+ for seed := range 128 {266+ rng := rand.New(rand.NewPCG(uint64(seed+1), uint64(seed+2)))267+ g := GenerateSeeded(tt.w, tt.h, rng)268+269+ if IsSolved(g) {270+ t.Fatalf("GenerateSeeded(%d,%d) produced solved grid at seed %d", tt.w, tt.h, seed)271+ }272+273+ lightsOn := countLightsOn(g)274+ if lightsOn < minLightsOn {275+ minLightsOn = lightsOn276+ }277+ if lightsOn > maxLightsOn {278+ maxLightsOn = lightsOn279+ }280+ }281+282+ if minLightsOn <= 0 {283+ t.Fatalf("min lights on = %d, want > 0", minLightsOn)284+ }285+ if minLightsOn == maxLightsOn {286+ t.Fatalf("lights-on distribution did not vary: min=max=%d", minLightsOn)287+ }288+ })289+ }290+}291+235292 // --- New (P1) ---236293 237294 func TestNew(t *testing.T) {@@ -2,8 +2,8 @@ package nonogram
22 33 import (44 "context"5- "fmt"65 "math/rand/v2"6+ "strconv"77 "strings"88 "sync"99 "time"@@ -11,16 +11,49 @@ import (
1111 1212 const (1313 maxAttempts = 10014- maxPossibilities = 1000014+ maxPossibilities = 2000015+ maxCacheEntries = 1638416+ maxCacheCells = 500_000_0001517 )1618 19+type lineCacheKey struct {20+ length int21+ hint string22+}23+24+type possibilitiesCacheEntry struct {25+ possibilities [][]cellState26+ cellCost int27+}28+1729 var (18- possibilitiesCache = make(map[string][][]cellState)19- possibilitiesMu sync.RWMutex30+ possibilitiesCache = make(map[lineCacheKey]*possibilitiesCacheEntry)31+ possibilitiesOrder = make([]lineCacheKey, 0, maxCacheEntries)32+ possibilitiesOrderHead = 033+ possibilitiesCacheSize int34+ possibilitiesMu sync.Mutex2035 )2136 22-func cacheKey(length int, hint []int) string {23- return fmt.Sprintf("%d:%v", length, hint)37+func cacheKey(length int, hint []int) lineCacheKey {38+ return lineCacheKey{39+ length: length,40+ hint: encodeHint(hint),41+ }42+}43+44+func encodeHint(hint []int) string {45+ if len(hint) == 0 {46+ return ""47+ }48+ var b strings.Builder49+ b.Grow(len(hint) * 3)50+ for i, v := range hint {51+ if i > 0 {52+ b.WriteByte(',')53+ }54+ b.WriteString(strconv.Itoa(v))55+ }56+ return b.String()2457 }2558 2659 func GenerateRandomTomography(mode NonogramMode) Hints {@@ -114,6 +147,15 @@ type solutionGrid struct {
114147 w, h int115148 }116149 150+type cellChange struct {151+ x, y int152+ prev cellState153+}154+155+type undoStack struct {156+ changes []cellChange157+}158+117159 func newSolutionGrid(w, h int) solutionGrid {118160 cells := make([][]cellState, h)119161 for y := range h {@@ -125,23 +167,6 @@ func newSolutionGrid(w, h int) solutionGrid {
125167 return solutionGrid{cells: cells, w: w, h: h}126168 }127169 128-func (g *solutionGrid) clone() solutionGrid {129- cells := make([][]cellState, g.h)130- for y := range g.h {131- cells[y] = make([]cellState, g.w)132- copy(cells[y], g.cells[y])133- }134- return solutionGrid{cells: cells, w: g.w, h: g.h}135-}136-137-func (g *solutionGrid) set(x, y int, state cellState) bool {138- if g.cells[y][x] != cellUnknown && g.cells[y][x] != state {139- return false140- }141- g.cells[y][x] = state142- return true143-}144-145170 func (g *solutionGrid) isComplete() bool {146171 for y := range g.h {147172 for x := range g.w {@@ -155,65 +180,69 @@ func (g *solutionGrid) isComplete() bool {
155180 156181 func countSolutions(hints Hints, w, h, limit int, ctx context.Context) int {157182 g := newSolutionGrid(w, h)158- return countSolutionsRecursive(g, hints, limit, ctx)183+ stack := undoStack{184+ changes: make([]cellChange, 0, w*h),185+ }186+ return countSolutionsRecursive(&g, hints, limit, ctx, &stack)159187 }160188 161-func countSolutionsRecursive(g solutionGrid, hints Hints, limit int, ctx context.Context) int {189+func countSolutionsRecursive(190+ g *solutionGrid,191+ hints Hints,192+ limit int,193+ ctx context.Context,194+ stack *undoStack,195+) int {196+ if limit <= 0 {197+ return 0198+ }162199 select {163200 case <-ctx.Done():164201 return -1165202 default:166203 }167204 168- propagated, valid := propagate(g, hints, ctx)169- if !valid {205+ mark := stack.mark()206+ if !propagateInPlace(g, hints, ctx, stack) {207+ stack.revert(g, mark)170208 return 0171209 }172210 173- if propagated.isComplete() {211+ if g.isComplete() {212+ stack.revert(g, mark)174213 return 1175214 }176215 177- for y := range propagated.h {178- for x := range propagated.w {179- if propagated.cells[y][x] == cellUnknown {180- count := 0181-182- if propagated.set(x, y, cellFilled) {183- g1 := propagated.clone()184- propagated.cells[y][x] = cellUnknown185- n := countSolutionsRecursive(g1, hints, limit-count, ctx)186- if n < 0 {187- return n188- }189- count += n190- if count >= limit {191- return count192- }193- }194-195- if propagated.set(x, y, cellEmpty) {196- g2 := propagated.clone()197- propagated.cells[y][x] = cellUnknown198- n := countSolutionsRecursive(g2, hints, limit-count, ctx)199- if n < 0 {200- return n201- }202- count += n203- if count >= limit {204- return count205- }206- }216+ x, y, ok := pickMostConstrainedCell(*g, hints)217+ if !ok {218+ stack.revert(g, mark)219+ return 0220+ }207221 222+ count := 0223+ branchOrder := [2]cellState{cellFilled, cellEmpty}224+ for _, value := range branchOrder {225+ branchMark := stack.mark()226+ if stack.set(g, x, y, value) {227+ n := countSolutionsRecursive(g, hints, limit-count, ctx, stack)228+ if n < 0 {229+ stack.revert(g, mark)230+ return n231+ }232+ count += n233+ if count >= limit {234+ stack.revert(g, mark)208235 return count209236 }210237 }238+ stack.revert(g, branchMark)211239 }212240 213- return 0241+ stack.revert(g, mark)242+ return count214243 }215244 216-func propagate(g solutionGrid, hints Hints, ctx context.Context) (solutionGrid, bool) {245+func propagateInPlace(g *solutionGrid, hints Hints, ctx context.Context, stack *undoStack) bool {217246 rowBuf := make([]cellState, g.w)218247 colBuf := make([]cellState, g.h)219248 @@ -221,7 +250,7 @@ func propagate(g solutionGrid, hints Hints, ctx context.Context) (solutionGrid,
221250 for changed {222251 select {223252 case <-ctx.Done():224- return g, false253+ return false225254 default:226255 }227256 changed = false@@ -233,12 +262,14 @@ func propagate(g solutionGrid, hints Hints, ctx context.Context) (solutionGrid,
233262 234263 newRow, valid := propagateLine(rowBuf[:g.w], hints.rows[y])235264 if !valid {236- return g, false265+ return false237266 }238267 239268 for x := range g.w {240269 if g.cells[y][x] == cellUnknown && newRow[x] != cellUnknown {241- g.cells[y][x] = newRow[x]270+ if !stack.set(g, x, y, newRow[x]) {271+ return false272+ }242273 changed = true243274 }244275 }@@ -251,19 +282,94 @@ func propagate(g solutionGrid, hints Hints, ctx context.Context) (solutionGrid,
251282 252283 newCol, valid := propagateLine(colBuf[:g.h], hints.cols[x])253284 if !valid {254- return g, false285+ return false255286 }256287 257288 for y := range g.h {258289 if g.cells[y][x] == cellUnknown && newCol[y] != cellUnknown {259- g.cells[y][x] = newCol[y]290+ if !stack.set(g, x, y, newCol[y]) {291+ return false292+ }260293 changed = true261294 }262295 }263296 }264297 }265298 266- return g, true299+ return true300+}301+302+func (s *undoStack) mark() int {303+ return len(s.changes)304+}305+306+func (s *undoStack) set(g *solutionGrid, x, y int, state cellState) bool {307+ current := g.cells[y][x]308+ if current == state {309+ return true310+ }311+ if current != cellUnknown {312+ return false313+ }314+ s.changes = append(s.changes, cellChange{x: x, y: y, prev: current})315+ g.cells[y][x] = state316+ return true317+}318+319+func (s *undoStack) revert(g *solutionGrid, mark int) {320+ for i := len(s.changes) - 1; i >= mark; i-- {321+ change := s.changes[i]322+ g.cells[change.y][change.x] = change.prev323+ }324+ s.changes = s.changes[:mark]325+}326+327+func pickMostConstrainedCell(g solutionGrid, hints Hints) (x, y int, ok bool) {328+ firstX, firstY := -1, -1329+ bestNeighborScore := -1330+ bestHintWeight := -1331+332+ for yy := range g.h {333+ for xx := range g.w {334+ if g.cells[yy][xx] != cellUnknown {335+ continue336+ }337+ if firstX < 0 {338+ firstX, firstY = xx, yy339+ }340+341+ neighborScore := 0342+ if yy > 0 && g.cells[yy-1][xx] != cellUnknown {343+ neighborScore++344+ }345+ if yy+1 < g.h && g.cells[yy+1][xx] != cellUnknown {346+ neighborScore++347+ }348+ if xx > 0 && g.cells[yy][xx-1] != cellUnknown {349+ neighborScore++350+ }351+ if xx+1 < g.w && g.cells[yy][xx+1] != cellUnknown {352+ neighborScore++353+ }354+355+ hintWeight := len(hints.rows[yy]) + len(hints.cols[xx])356+ if neighborScore > bestNeighborScore ||357+ (neighborScore == bestNeighborScore && hintWeight > bestHintWeight) {358+ bestNeighborScore = neighborScore359+ bestHintWeight = hintWeight360+ x, y = xx, yy361+ ok = true362+ }363+ }364+ }365+366+ if firstX < 0 {367+ return 0, 0, false368+ }369+ if bestNeighborScore <= 0 {370+ return firstX, firstY, true371+ }372+ return x, y, ok267373 }268374 269375 func propagateLine(line []cellState, hint []int) ([]cellState, bool) {@@ -312,26 +418,60 @@ func propagateLine(line []cellState, hint []int) ([]cellState, bool) {
312418 func getCachedPossibilities(length int, hint []int) [][]cellState {313419 key := cacheKey(length, hint)314420 315- possibilitiesMu.RLock()316- if cached, ok := possibilitiesCache[key]; ok {317- possibilitiesMu.RUnlock()421+ possibilitiesMu.Lock()422+ if entry, ok := possibilitiesCache[key]; ok {423+ cached := entry.possibilities424+ possibilitiesMu.Unlock()318425 return cached319426 }320- possibilitiesMu.RUnlock()427+ possibilitiesMu.Unlock()321428 322429 possibilities := generateLinePossibilities(length, hint)430+ cellCost := len(possibilities) * length431+ if cellCost <= 0 || cellCost > maxCacheCells {432+ return possibilities433+ }323434 324435 possibilitiesMu.Lock()325- if cached, ok := possibilitiesCache[key]; ok {436+ if entry, ok := possibilitiesCache[key]; ok {326437 possibilitiesMu.Unlock()327- return cached438+ return entry.possibilities439+ }440+ possibilitiesCache[key] = &possibilitiesCacheEntry{441+ possibilities: possibilities,442+ cellCost: cellCost,328443 }329- possibilitiesCache[key] = possibilities444+ possibilitiesOrder = append(possibilitiesOrder, key)445+ possibilitiesCacheSize += cellCost446+ trimPossibilitiesCacheLocked()330447 possibilitiesMu.Unlock()331448 332449 return possibilities333450 }334451 452+func trimPossibilitiesCacheLocked() {453+ for len(possibilitiesCache) > maxCacheEntries || possibilitiesCacheSize > maxCacheCells {454+ if possibilitiesOrderHead >= len(possibilitiesOrder) {455+ return456+ }457+ key := possibilitiesOrder[possibilitiesOrderHead]458+ possibilitiesOrderHead++459+ entry, ok := possibilitiesCache[key]460+ if !ok {461+ continue462+ }463+ possibilitiesCacheSize -= entry.cellCost464+ delete(possibilitiesCache, key)465+ }466+467+ if possibilitiesOrderHead > 1024 && possibilitiesOrderHead*2 > len(possibilitiesOrder) {468+ compacted := make([]lineCacheKey, len(possibilitiesOrder)-possibilitiesOrderHead)469+ copy(compacted, possibilitiesOrder[possibilitiesOrderHead:])470+ possibilitiesOrder = compacted471+ possibilitiesOrderHead = 0472+ }473+}474+335475 func generateLinePossibilities(length int, hint []int) [][]cellState {336476 if len(hint) == 0 || (len(hint) == 1 && hint[0] == 0) {337477 result := make([]cellState, length)@@ -2,6 +2,7 @@ package nonogram
22 33 import (44 "context"5+ "math/rand/v2"56 "reflect"67 "strings"78 "testing"@@ -894,6 +895,80 @@ func TestGenerateRandomTomography_5x5_VerifyUnique(t *testing.T) {
894895 }895896 }896897 898+func TestGenerateRandomTomography_LargeModes_Unique(t *testing.T) {899+ if testing.Short() {900+ t.Skip("skipping large-mode uniqueness regression in short mode")901+ }902+903+ cases := []struct {904+ name string905+ mode NonogramMode906+ seedA uint64907+ seedB uint64908+ }{909+ {name: "Epic", mode: NewMode("Epic", "test", 20, 20, 0.71), seedA: 3101, seedB: 8101},910+ {name: "Massive", mode: NewMode("Massive", "test", 20, 20, 0.56), seedA: 4703, seedB: 9109},911+ }912+913+ for _, tc := range cases {914+ tc := tc915+ t.Run(tc.name, func(t *testing.T) {916+ rng := rand.New(rand.NewPCG(tc.seedA, tc.seedB))917+ hints := GenerateRandomTomographySeeded(tc.mode, rng)918+ if len(hints.rows) != tc.mode.Height || len(hints.cols) != tc.mode.Width {919+ t.Fatalf(920+ "invalid hints dimensions rows=%d cols=%d want rows=%d cols=%d",921+ len(hints.rows),922+ len(hints.cols),923+ tc.mode.Height,924+ tc.mode.Width,925+ )926+ }927+928+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)929+ defer cancel()930+ count := countSolutions(hints, tc.mode.Width, tc.mode.Height, 2, ctx)931+ if count != 1 {932+ t.Fatalf("expected unique solution for %s, got %d", tc.name, count)933+ }934+ })935+ }936+}937+938+func TestGenerateRandomTomography_LargeModes_PerfBudget(t *testing.T) {939+ if testing.Short() {940+ t.Skip("skipping large-mode perf regression in short mode")941+ }942+943+ const maxDuration = 30 * time.Second944+ cases := []struct {945+ name string946+ mode NonogramMode947+ seedA uint64948+ seedB uint64949+ }{950+ {name: "Epic", mode: NewMode("Epic", "test", 20, 20, 0.71), seedA: 5209, seedB: 8219},951+ {name: "Massive", mode: NewMode("Massive", "test", 20, 20, 0.56), seedA: 5303, seedB: 12203},952+ }953+954+ for _, tc := range cases {955+ tc := tc956+ t.Run(tc.name, func(t *testing.T) {957+ start := time.Now()958+ rng := rand.New(rand.NewPCG(tc.seedA, tc.seedB))959+ hints := GenerateRandomTomographySeeded(tc.mode, rng)960+ elapsed := time.Since(start)961+962+ if len(hints.rows) == 0 || len(hints.cols) == 0 {963+ t.Fatalf("GenerateRandomTomographySeeded returned empty hints for %s", tc.name)964+ }965+ if elapsed > maxDuration {966+ t.Fatalf("%s generation exceeded %s: %s", tc.name, maxDuration, elapsed)967+ }968+ })969+ }970+}971+897972 var _ game.Gamer = Model{}898973 899974 // --- Spawn Performance Benchmark (P2) ---@@ -1180,3 +1255,86 @@ func TestHelpToggleInvalidatesOriginCache(t *testing.T) {
11801255 t.Fatal("expected origin cache to be invalidated")11811256 }11821257 }1258+1259+// --- Benchmarks (P2) ---1260+1261+func skipBenchmarkInShortMode(b *testing.B) {1262+ b.Helper()1263+ if testing.Short() {1264+ b.Skip("skipping benchmark in short mode")1265+ }1266+}1267+1268+func BenchmarkGenerateRandomTomographyModes(b *testing.B) {1269+ skipBenchmarkInShortMode(b)1270+1271+ modeConfigs := []struct {1272+ name string1273+ mode NonogramMode1274+ seedA uint641275+ }{1276+ {name: "5x5-Mini", mode: NewMode("Mini", "bench", 5, 5, 0.65), seedA: 100000},1277+ {name: "10x10-Classic", mode: NewMode("Classic", "bench", 10, 10, 0.52), seedA: 200000},1278+ {name: "15x15-Grand", mode: NewMode("Grand", "bench", 15, 15, 0.54), seedA: 300000},1279+ {name: "20x20-Epic", mode: NewMode("Epic", "bench", 20, 20, 0.71), seedA: 350000},1280+ {name: "20x20-Massive", mode: NewMode("Massive", "bench", 20, 20, 0.56), seedA: 400000},1281+ }1282+1283+ for _, cfg := range modeConfigs {1284+ cfg := cfg1285+ b.Run(cfg.name, func(b *testing.B) {1286+ b.ReportAllocs()1287+ for n := 0; n < b.N; n++ {1288+ seedA := cfg.seedA1289+ seedB := uint64((n + 1) * 1777)1290+ rng := rand.New(rand.NewPCG(seedA, seedB))1291+ hints := GenerateRandomTomographySeeded(cfg.mode, rng)1292+ if len(hints.rows) != cfg.mode.Height || len(hints.cols) != cfg.mode.Width {1293+ b.Fatalf(1294+ "invalid hint dimensions rows=%d cols=%d want rows=%d cols=%d",1295+ len(hints.rows),1296+ len(hints.cols),1297+ cfg.mode.Height,1298+ cfg.mode.Width,1299+ )1300+ }1301+ }1302+ })1303+ }1304+}1305+1306+func BenchmarkCountSolutionsLarge(b *testing.B) {1307+ skipBenchmarkInShortMode(b)1308+1309+ cases := []struct {1310+ name string1311+ mode NonogramMode1312+ seedA uint641313+ seedB uint641314+ }{1315+ {name: "15x15-Grand", mode: NewMode("Grand", "bench", 15, 15, 0.54), seedA: 7301, seedB: 8101},1316+ {name: "20x20-Massive", mode: NewMode("Massive", "bench", 20, 20, 0.56), seedA: 9103, seedB: 10429},1317+ }1318+1319+ for _, tc := range cases {1320+ tc := tc1321+ b.Run(tc.name, func(b *testing.B) {1322+ rng := rand.New(rand.NewPCG(tc.seedA, tc.seedB))1323+ hints := GenerateRandomTomographySeeded(tc.mode, rng)1324+ if len(hints.rows) == 0 || len(hints.cols) == 0 {1325+ b.Fatal("GenerateRandomTomographySeeded returned empty hints")1326+ }1327+1328+ b.ReportAllocs()1329+ b.ResetTimer()1330+ for n := 0; n < b.N; n++ {1331+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)1332+ count := countSolutions(hints, tc.mode.Width, tc.mode.Height, 2, ctx)1333+ cancel()1334+ if count < 1 {1335+ b.Fatalf("expected at least one solution, got %d", count)1336+ }1337+ }1338+ })1339+ }1340+}@@ -83,10 +83,15 @@ type attemptOutcome struct {
8383 err error8484 }8585 86+type uniquenessJob struct {87+ clueKey string88+ puzzle Puzzle89+}90+8691 type uniquenessOutcome struct {87- attemptID int88- count int89- err error92+ clueKey string93+ count int94+ err error9095 }9196 9297 func Generate(mode NurikabeMode) (Puzzle, error) {@@ -133,13 +138,14 @@ func GenerateSeededWithContext(ctx context.Context, mode NurikabeMode, rng *rand
133138 profile := modeIslandProfile(mode)134139 budget := modeGenerationBudget(mode)135140 workerCount := candidateWorkerCount(mode)141+ uniquenessWorkers := uniquenessWorkerCount(mode)136142 137143 masterA := rng.Uint64()138144 masterB := rng.Uint64()139145 140146 attemptCh := make(chan attemptSeed)141147 candidateCh := make(chan attemptOutcome, workerCount*2)142- uniqueCh := make(chan candidateResult, budget.uniquenessProbeLimit)148+ uniqueCh := make(chan uniquenessJob, budget.uniquenessProbeLimit)143149 uniqueResultCh := make(chan uniquenessOutcome, budget.uniquenessProbeLimit)144150 145151 var candidateWG sync.WaitGroup@@ -152,11 +158,13 @@ func GenerateSeededWithContext(ctx context.Context, mode NurikabeMode, rng *rand
152158 }153159 154160 var uniqueWG sync.WaitGroup155- uniqueWG.Add(1)156- go func() {157- defer uniqueWG.Done()158- uniquenessWorker(timeboxedCtx, budget, uniqueCh, uniqueResultCh)159- }()161+ for range uniquenessWorkers {162+ uniqueWG.Add(1)163+ go func() {164+ defer uniqueWG.Done()165+ uniquenessWorker(timeboxedCtx, budget, uniqueCh, uniqueResultCh)166+ }()167+ }160168 go func() {161169 uniqueWG.Wait()162170 close(uniqueResultCh)@@ -167,8 +175,29 @@ func GenerateSeededWithContext(ctx context.Context, mode NurikabeMode, rng *rand
167175 bestUnique := candidateResult{}168176 haveBestUnique := false169177 170- uniquenessQueued := 0171- pendingUnique := map[int]candidateResult{}178+ pendingUniqueByKey := map[string]candidateResult{}179+ knownUniqueByKey := map[string]uniquenessOutcome{}180+ uniquenessScheduled := 0181+182+ handleUniqueResult := func(result uniquenessOutcome) {183+ knownUniqueByKey[result.clueKey] = result184+ candidate, ok := pendingUniqueByKey[result.clueKey]185+ delete(pendingUniqueByKey, result.clueKey)186+ if !ok {187+ return188+ }189+ if result.err != nil {190+ if errors.Is(result.err, context.Canceled) ||191+ errors.Is(result.err, context.DeadlineExceeded) {192+ return193+ }194+ return195+ }196+ if result.count == 1 && (!haveBestUnique || betterCandidate(candidate, bestUnique)) {197+ bestUnique = candidate198+ haveBestUnique = true199+ }200+ }172201 173202 drainUnique := func() {174203 for {@@ -177,22 +206,7 @@ func GenerateSeededWithContext(ctx context.Context, mode NurikabeMode, rng *rand
177206 if !ok {178207 return179208 }180- candidate, ok := pendingUnique[result.attemptID]181- delete(pendingUnique, result.attemptID)182- if !ok {183- continue184- }185- if result.err != nil {186- if errors.Is(result.err, context.Canceled) ||187- errors.Is(result.err, context.DeadlineExceeded) {188- continue189- }190- continue191- }192- if result.count == 1 && (!haveBestUnique || betterCandidate(candidate, bestUnique)) {193- bestUnique = candidate194- haveBestUnique = true195- }209+ handleUniqueResult(result)196210 default:197211 return198212 }@@ -205,6 +219,7 @@ outer:
205219 if err := timeboxedCtx.Err(); err != nil {206220 break207221 }222+ drainUnique()208223 209224 roundSize := min(workerCount, budget.attempts-attemptID)210225 dispatched := 0@@ -224,19 +239,6 @@ outer:
224239 select {225240 case out := <-candidateCh:226241 outcomes = append(outcomes, out)227- case result, ok := <-uniqueResultCh:228- if !ok {229- continue230- }231- candidate, ok := pendingUnique[result.attemptID]232- delete(pendingUnique, result.attemptID)233- if !ok {234- continue235- }236- if result.err == nil && result.count == 1 && (!haveBestUnique || betterCandidate(candidate, bestUnique)) {237- bestUnique = candidate238- haveBestUnique = true239- }240242 case <-timeboxedCtx.Done():241243 break outer242244 }@@ -256,11 +258,32 @@ outer:
256258 haveBestSolvable = true257259 }258260 259- if shouldProbeUniqueness(candidate, haveBestSolvable, bestSolvable, haveBestUnique, bestUnique, uniquenessQueued, budget, deadline) {260- pendingUnique[candidate.attemptID] = candidate261- uniquenessQueued++261+ if knownResult, ok := knownUniqueByKey[candidate.clueKey]; ok {262+ if knownResult.err == nil &&263+ knownResult.count == 1 &&264+ (!haveBestUnique || betterCandidate(candidate, bestUnique)) {265+ bestUnique = candidate266+ haveBestUnique = true267+ }268+ continue269+ }270+271+ if pendingCandidate, ok := pendingUniqueByKey[candidate.clueKey]; ok {272+ if betterCandidate(candidate, pendingCandidate) {273+ pendingUniqueByKey[candidate.clueKey] = candidate274+ }275+ continue276+ }277+278+ if shouldProbeUniqueness(candidate, haveBestSolvable, bestSolvable, uniquenessScheduled, budget, deadline) {279+ job := uniquenessJob{280+ clueKey: candidate.clueKey,281+ puzzle: candidate.puzzle,282+ }283+ pendingUniqueByKey[candidate.clueKey] = candidate262284 select {263- case uniqueCh <- candidate:285+ case uniqueCh <- job:286+ uniquenessScheduled++264287 case <-timeboxedCtx.Done():265288 break outer266289 }@@ -275,15 +298,7 @@ outer:
275298 candidateWG.Wait()276299 close(uniqueCh)277300 for result := range uniqueResultCh {278- candidate, ok := pendingUnique[result.attemptID]279- delete(pendingUnique, result.attemptID)280- if !ok {281- continue282- }283- if result.err == nil && result.count == 1 && (!haveBestUnique || betterCandidate(candidate, bestUnique)) {284- bestUnique = candidate285- haveBestUnique = true286- }301+ handleUniqueResult(result)287302 }288303 289304 if haveBestUnique {@@ -340,20 +355,20 @@ func candidateWorker(
340355 func uniquenessWorker(341356 ctx context.Context,342357 budget generationBudget,343- uniqueCh <-chan candidateResult,358+ uniqueCh <-chan uniquenessJob,344359 uniqueResultCh chan<- uniquenessOutcome,345360 ) {346361 for {347362 select {348363 case <-ctx.Done():349364 return350- case candidate, ok := <-uniqueCh:365+ case job, ok := <-uniqueCh:351366 if !ok {352367 return353368 }354369 355- count, _, err := CountSolutionsContext(ctx, candidate.puzzle, 2, budget.uniquenessNodes)356- result := uniquenessOutcome{attemptID: candidate.attemptID, count: count, err: err}370+ count, _, err := CountSolutionsContext(ctx, job.puzzle, 2, budget.uniquenessNodes)371+ result := uniquenessOutcome{clueKey: job.clueKey, count: count, err: err}357372 select {358373 case uniqueResultCh <- result:359374 case <-ctx.Done():@@ -902,24 +917,19 @@ func shouldProbeUniqueness(
902917 candidate candidateResult,903918 haveBestSolvable bool,904919 bestSolvable candidateResult,905- haveBestUnique bool,906- bestUnique candidateResult,907- queued int,920+ scheduled int,908921 budget generationBudget,909922 deadline time.Time,910923 ) bool {911- if queued >= budget.uniquenessProbeLimit {924+ if scheduled >= budget.uniquenessProbeLimit {912925 return false913926 }914927 if time.Until(deadline) < uniquenessMinRemainingBudget {915928 return false916929 }917- if queued < 3 {930+ if scheduled < 3 {918931 return true919932 }920- if haveBestUnique {921- return candidate.score > bestUnique.score-0.45922- }923933 if !haveBestSolvable {924934 return true925935 }@@ -969,9 +979,26 @@ func candidateWorkerCount(mode NurikabeMode) int {
969979 case "Medium":970980 return 2971981 case "Hard":972- return 3982+ return 4983+ case "Expert":984+ return 4985+ default:986+ return 2987+ }988+}989+990+func uniquenessWorkerCount(mode NurikabeMode) int {991+ switch mode.Title() {992+ case "Mini":993+ return 1994+ case "Easy":995+ return 1996+ case "Medium":997+ return 2998+ case "Hard":999+ return 49731000 case "Expert":974- return 31001+ return 59751002 default:9761003 return 29771004 }@@ -1057,9 +1084,9 @@ func modeGenerationBudget(mode NurikabeMode) generationBudget {
10571084 case "Medium":10581085 return generationBudget{attempts: 500, solvabilityNodes: 70000, uniquenessNodes: 140000, uniquenessProbeLimit: 10}10591086 case "Hard":1060- return generationBudget{attempts: 650, solvabilityNodes: 95000, uniquenessNodes: 180000, uniquenessProbeLimit: 12}1087+ return generationBudget{attempts: 420, solvabilityNodes: 70000, uniquenessNodes: 120000, uniquenessProbeLimit: 8}10611088 case "Expert":1062- return generationBudget{attempts: 800, solvabilityNodes: 120000, uniquenessNodes: 220000, uniquenessProbeLimit: 14}1089+ return generationBudget{attempts: 320, solvabilityNodes: 70000, uniquenessNodes: 100000, uniquenessProbeLimit: 6}10631090 default:10641091 return generationBudget{attempts: 400, solvabilityNodes: 45000, uniquenessNodes: 100000, uniquenessProbeLimit: 8}10651092 }@@ -728,6 +728,26 @@ func TestGenerateSeededCancellationFast(t *testing.T) {
728728 }729729 }730730 731+func TestGenerateSeededDeadlineExceededFast(t *testing.T) {732+ mode := Modes[4].(NurikabeMode) // Expert keeps deadline path realistic.733+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)734+ defer cancel()735+ time.Sleep(1 * time.Millisecond)736+737+ start := time.Now()738+ rng := rand.New(rand.NewPCG(44, 88))739+ _, err := GenerateSeededWithContext(ctx, mode, rng)740+ if err == nil {741+ t.Fatal("expected deadline exceeded error, got nil")742+ }743+ if !errors.Is(err, context.DeadlineExceeded) {744+ t.Fatalf("expected context.DeadlineExceeded, got %v", err)745+ }746+ if elapsed := time.Since(start); elapsed > 200*time.Millisecond {747+ t.Fatalf("deadline cancellation should return quickly, took %s", elapsed)748+ }749+}750+731751 func TestNoGoroutineLeakOnCancel(t *testing.T) {732752 mode := Modes[4].(NurikabeMode)733753 before := runtime.NumGoroutine()@@ -745,3 +765,92 @@ func TestNoGoroutineLeakOnCancel(t *testing.T) {
745765 t.Fatalf("possible goroutine leak: before=%d after=%d delta=%d", before, after, delta)746766 }747767 }768+769+func TestNoGoroutineLeakOnDeadlineTimeout(t *testing.T) {770+ mode := Modes[4].(NurikabeMode)771+ before := runtime.NumGoroutine()772+773+ for i := 0; i < 8; i++ {774+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)775+ rng := rand.New(rand.NewPCG(uint64(1400+i), uint64(1900+i)))776+ _, _ = GenerateSeededWithContext(ctx, mode, rng)777+ cancel()778+ }779+780+ time.Sleep(120 * time.Millisecond)781+ after := runtime.NumGoroutine()782+ if delta := after - before; delta > 8 {783+ t.Fatalf("possible goroutine leak on deadline timeout: before=%d after=%d delta=%d", before, after, delta)784+ }785+}786+787+// --- Benchmarks (P2) ---788+789+func skipBenchmarkInShortMode(b *testing.B) {790+ b.Helper()791+ if testing.Short() {792+ b.Skip("skipping benchmark in short mode")793+ }794+}795+796+func BenchmarkGenerateSeededModes(b *testing.B) {797+ skipBenchmarkInShortMode(b)798+799+ for i, item := range Modes {800+ mode := item.(NurikabeMode)801+ modeIdx := i802+ b.Run(mode.Title(), func(b *testing.B) {803+ b.ReportAllocs()804+ for n := 0; n < b.N; n++ {805+ seedA := uint64(100 + modeIdx)806+ seedB := uint64(1000 + modeIdx)807+ rng := rand.New(rand.NewPCG(seedA, seedB))808+ if _, err := GenerateSeeded(mode, rng); err != nil {809+ b.Fatalf("GenerateSeeded error: %v", err)810+ }811+ }812+ })813+ }814+}815+816+func BenchmarkCountSolutions(b *testing.B) {817+ skipBenchmarkInShortMode(b)818+819+ easyMode := Modes[1].(NurikabeMode)820+ easyRNG := rand.New(rand.NewPCG(7001, 9001))821+ easyPuzzle, err := GenerateSeeded(easyMode, easyRNG)822+ if err != nil {823+ b.Fatalf("GenerateSeeded setup error: %v", err)824+ }825+826+ cases := []struct {827+ name string828+ puzzle Puzzle829+ nodeLimit int830+ }{831+ {832+ name: "Fixture5x5",833+ puzzle: uniquePuzzleFixture(),834+ nodeLimit: 120000,835+ },836+ {837+ name: "GeneratedEasy",838+ puzzle: easyPuzzle,839+ nodeLimit: generationNodeLimit(easyMode) * 4,840+ },841+ }842+843+ for _, tc := range cases {844+ tc := tc845+ b.Run(tc.name, func(b *testing.B) {846+ b.ReportAllocs()847+ b.ResetTimer()848+ for n := 0; n < b.N; n++ {849+ _, _, solveErr := CountSolutionsContext(context.Background(), tc.puzzle, 2, tc.nodeLimit)850+ if solveErr != nil && !errors.Is(solveErr, errNodeLimit) {851+ b.Fatalf("CountSolutionsContext error: %v", solveErr)852+ }853+ }854+ })855+ }856+}@@ -27,6 +27,57 @@ type islandComponent struct {
2727 2828 var dirs = [4]point{{0, -1}, {0, 1}, {-1, 0}, {1, 0}}2929 30+type solverScratch struct {31+ width int32+ height int33+34+ compIdx [][]int35+ seen [][]int36+ seenMark int37+ workQueue []point38+}39+40+func newSolverScratch(width, height int) *solverScratch {41+ s := &solverScratch{42+ width: width,43+ height: height,44+ }45+ if width <= 0 || height <= 0 {46+ return s47+ }48+49+ s.compIdx = make([][]int, height)50+ s.seen = make([][]int, height)51+ for y := range height {52+ s.compIdx[y] = make([]int, width)53+ s.seen[y] = make([]int, width)54+ }55+ return s56+}57+58+func (s *solverScratch) resetComponentIndex() [][]int {59+ for y := range s.height {60+ for x := range s.width {61+ s.compIdx[y][x] = -162+ }63+ }64+ return s.compIdx65+}66+67+func (s *solverScratch) nextSeenMark() int {68+ s.seenMark++69+ if s.seenMark > 0 {70+ return s.seenMark71+ }72+ for y := range s.height {73+ for x := range s.width {74+ s.seen[y][x] = 075+ }76+ }77+ s.seenMark = 178+ return s.seenMark79+}80+3081 func CountSolutions(puzzle Puzzle, limit, nodeLimit int) (int, SolveStats, error) {3182 return CountSolutionsContext(context.Background(), puzzle, limit, nodeLimit)3283 }@@ -56,6 +107,7 @@ func CountSolutionsContext(ctx context.Context, puzzle Puzzle, limit, nodeLimit
56107 57108 stats := SolveStats{}58109 solutions := 0110+ scratch := newSolverScratch(puzzle.Width, puzzle.Height)59111 var dfs func(grid, int) error60112 dfs = func(g grid, depth int) error {61113 if err := ctx.Err(); err != nil {@@ -69,16 +121,16 @@ func CountSolutionsContext(ctx context.Context, puzzle Puzzle, limit, nodeLimit
69121 stats.MaxDepth = depth70122 }71123 72- if err := propagate(g, puzzle.Clues); err != nil {124+ if err := propagateWithScratch(g, puzzle.Clues, scratch); err != nil {73125 return nil74126 }75- if err := validatePartial(g, puzzle.Clues); err != nil {127+ if err := validatePartialWithScratch(g, puzzle.Clues, scratch); err != nil {76128 return nil77129 }78130 79131 x, y, ok := pickUnknown(g, puzzle.Clues)80132 if !ok {81- if isSolvedGrid(g, puzzle.Clues) {133+ if isSolvedGridWithScratch(g, puzzle.Clues, scratch) {82134 solutions++83135 }84136 return nil@@ -117,7 +169,7 @@ func CountSolutionsContext(ctx context.Context, puzzle Puzzle, limit, nodeLimit
117169 return solutions, stats, nil118170 }119171 120-func propagate(g grid, clues clueGrid) error {172+func propagateWithScratch(g grid, clues clueGrid, scratch *solverScratch) error {121173 height := len(g)122174 if height == 0 {123175 return nil@@ -126,7 +178,7 @@ func propagate(g grid, clues clueGrid) error {
126178 127179 for {128180 changed := false129- comps, compIdx := islandComponents(g, clues)181+ comps, compIdx := islandComponentsWithScratch(g, clues, scratch)130182 131183 for _, comp := range comps {132184 if comp.clueCount == 1 && len(comp.cells) == comp.clueValue {@@ -150,7 +202,8 @@ func propagate(g grid, clues clueGrid) error {
150202 if g[y][x] != unknownCell {151203 continue152204 }153- seen := map[point]bool{}205+ firstClueComp := -1206+ clashes := false154207 for _, d := range dirs {155208 nx, ny := x+d.x, y+d.y156209 if nx < 0 || nx >= width || ny < 0 || ny >= height {@@ -165,17 +218,24 @@ func propagate(g grid, clues clueGrid) error {
165218 }166219 comp := comps[idx]167220 if comp.clueCount == 1 {168- seen[comp.cluePos] = true221+ if firstClueComp == -1 {222+ firstClueComp = idx223+ continue224+ }225+ if firstClueComp != idx {226+ clashes = true227+ break228+ }169229 }170230 }171- if len(seen) >= 2 {231+ if clashes {172232 g[y][x] = seaCell173233 changed = true174234 }175235 }176236 }177237 178- if err := validatePartial(g, clues); err != nil {238+ if err := validatePartialWithScratch(g, clues, scratch); err != nil {179239 return err180240 }181241 if !changed {@@ -184,15 +244,15 @@ func propagate(g grid, clues clueGrid) error {
184244 }185245 }186246 187-func validatePartial(g grid, clues clueGrid) error {247+func validatePartialWithScratch(g grid, clues clueGrid, scratch *solverScratch) error {188248 if hasSeaSquare(g) {189249 return fmt.Errorf("contains 2x2 sea block")190250 }191- if !seaCanRemainConnected(g) {251+ if !seaCanRemainConnectedWithScratch(g, scratch) {192252 return fmt.Errorf("sea components cannot be connected")193253 }194254 195- comps, _ := islandComponents(g, clues)255+ comps, _ := islandComponentsWithScratch(g, clues, scratch)196256 for _, comp := range comps {197257 size := len(comp.cells)198258 switch {@@ -200,9 +260,9 @@ func validatePartial(g grid, clues clueGrid) error {
200260 return fmt.Errorf("island has multiple clues")201261 case comp.clueCount == 1 && size > comp.clueValue:202262 return fmt.Errorf("island exceeds clue size")203- case comp.clueCount == 0 && !componentCanReachClue(g, clues, comp):263+ case comp.clueCount == 0 && !componentCanReachClueWithScratch(g, clues, comp, scratch):204264 return fmt.Errorf("orphan island component")205- case comp.clueCount == 1 && maxReachableForComponent(g, clues, comp) < comp.clueValue:265+ case comp.clueCount == 1 && maxReachableForComponentWithScratch(g, clues, comp, scratch) < comp.clueValue:206266 return fmt.Errorf("clue cannot reach required size")207267 }208268 }@@ -214,6 +274,14 @@ func isSolvedGrid(g grid, clues clueGrid) bool {
214274 if len(g) == 0 || len(g[0]) == 0 {215275 return false216276 }277+ scratch := newSolverScratch(len(g[0]), len(g))278+ return isSolvedGridWithScratch(g, clues, scratch)279+}280+281+func isSolvedGridWithScratch(g grid, clues clueGrid, scratch *solverScratch) bool {282+ if len(g) == 0 || len(g[0]) == 0 {283+ return false284+ }217285 218286 height := len(g)219287 width := len(g[0])@@ -231,11 +299,11 @@ func isSolvedGrid(g grid, clues clueGrid) bool {
231299 if hasSeaSquare(g) {232300 return false233301 }234- if !isSeaConnected(g) {302+ if !isSeaConnectedWithScratch(g, scratch) {235303 return false236304 }237305 238- comps, _ := islandComponents(g, clues)306+ comps, _ := islandComponentsWithScratch(g, clues, scratch)239307 seenClues := 0240308 for _, comp := range comps {241309 if comp.clueCount != 1 {@@ -305,7 +373,7 @@ func candidateOrder(g grid, clues clueGrid, x, y int) []cellState {
305373 return []cellState{seaCell, islandCell}306374 }307375 308-func seaCanRemainConnected(g grid) bool {376+func seaCanRemainConnectedWithScratch(g grid, scratch *solverScratch) bool {309377 height := len(g)310378 if height == 0 {311379 return true@@ -328,32 +396,30 @@ func seaCanRemainConnected(g grid) bool {
328396 return true329397 }330398 331- visited := make([][]bool, height)332- for y := range height {333- visited[y] = make([]bool, width)334- }335- queue := []point{start}336- visited[start.y][start.x] = true399+ seenMark := scratch.nextSeenMark()400+ scratch.workQueue = scratch.workQueue[:0]401+ scratch.workQueue = append(scratch.workQueue, start)402+ scratch.seen[start.y][start.x] = seenMark337403 338- for len(queue) > 0 {339- p := queue[0]340- queue = queue[1:]404+ for len(scratch.workQueue) > 0 {405+ p := scratch.workQueue[0]406+ scratch.workQueue = scratch.workQueue[1:]341407 for _, d := range dirs {342408 nx, ny := p.x+d.x, p.y+d.y343409 if nx < 0 || ny < 0 || nx >= width || ny >= height {344410 continue345411 }346- if visited[ny][nx] || g[ny][nx] == islandCell {412+ if scratch.seen[ny][nx] == seenMark || g[ny][nx] == islandCell {347413 continue348414 }349- visited[ny][nx] = true350- queue = append(queue, point{nx, ny})415+ scratch.seen[ny][nx] = seenMark416+ scratch.workQueue = append(scratch.workQueue, point{nx, ny})351417 }352418 }353419 354420 for y := range height {355421 for x := range width {356- if g[y][x] == seaCell && !visited[y][x] {422+ if g[y][x] == seaCell && scratch.seen[y][x] != seenMark {357423 return false358424 }359425 }@@ -362,6 +428,14 @@ func seaCanRemainConnected(g grid) bool {
362428 }363429 364430 func isSeaConnected(g grid) bool {431+ if len(g) == 0 || len(g[0]) == 0 {432+ return false433+ }434+ scratch := newSolverScratch(len(g[0]), len(g))435+ return isSeaConnectedWithScratch(g, scratch)436+}437+438+func isSeaConnectedWithScratch(g grid, scratch *solverScratch) bool {365439 height := len(g)366440 if height == 0 {367441 return false@@ -384,29 +458,26 @@ func isSeaConnected(g grid) bool {
384458 return false385459 }386460 387- visited := make([][]bool, height)388- for y := range height {389- visited[y] = make([]bool, width)390- }391-392- queue := []point{start}393- visited[start.y][start.x] = true461+ seenMark := scratch.nextSeenMark()462+ scratch.workQueue = scratch.workQueue[:0]463+ scratch.workQueue = append(scratch.workQueue, start)464+ scratch.seen[start.y][start.x] = seenMark394465 seen := 1395466 396- for len(queue) > 0 {397- p := queue[0]398- queue = queue[1:]467+ for len(scratch.workQueue) > 0 {468+ p := scratch.workQueue[0]469+ scratch.workQueue = scratch.workQueue[1:]399470 for _, d := range dirs {400471 nx, ny := p.x+d.x, p.y+d.y401472 if nx < 0 || ny < 0 || nx >= width || ny >= height {402473 continue403474 }404- if visited[ny][nx] || g[ny][nx] != seaCell {475+ if scratch.seen[ny][nx] == seenMark || g[ny][nx] != seaCell {405476 continue406477 }407- visited[ny][nx] = true478+ scratch.seen[ny][nx] = seenMark408479 seen++409- queue = append(queue, point{nx, ny})480+ scratch.workQueue = append(scratch.workQueue, point{nx, ny})410481 }411482 }412483 @@ -430,15 +501,17 @@ func hasSeaSquare(g grid) bool {
430501 }431502 432503 func islandComponents(g grid, clues clueGrid) ([]islandComponent, [][]int) {504+ if len(g) == 0 || len(g[0]) == 0 {505+ return nil, nil506+ }507+ scratch := newSolverScratch(len(g[0]), len(g))508+ return islandComponentsWithScratch(g, clues, scratch)509+}510+511+func islandComponentsWithScratch(g grid, clues clueGrid, scratch *solverScratch) ([]islandComponent, [][]int) {433512 height := len(g)434513 width := len(g[0])435- idx := make([][]int, height)436- for y := range height {437- idx[y] = make([]int, width)438- for x := range width {439- idx[y][x] = -1440- }441- }514+ idx := scratch.resetComponentIndex()442515 443516 components := make([]islandComponent, 0)444517 for y := range height {@@ -448,13 +521,17 @@ func islandComponents(g grid, clues clueGrid) ([]islandComponent, [][]int) {
448521 }449522 450523 compIndex := len(components)451- comp := islandComponent{cluePos: point{-1, -1}}452- queue := []point{{x, y}}524+ comp := islandComponent{525+ cluePos: point{-1, -1},526+ cells: make([]point, 0, 16),527+ }528+ scratch.workQueue = scratch.workQueue[:0]529+ scratch.workQueue = append(scratch.workQueue, point{x, y})453530 idx[y][x] = compIndex454531 455- for len(queue) > 0 {456- p := queue[0]457- queue = queue[1:]532+ for len(scratch.workQueue) > 0 {533+ p := scratch.workQueue[0]534+ scratch.workQueue = scratch.workQueue[1:]458535 comp.cells = append(comp.cells, p)459536 if clues[p.y][p.x] > 0 {460537 comp.clueCount++@@ -471,7 +548,7 @@ func islandComponents(g grid, clues clueGrid) ([]islandComponent, [][]int) {
471548 continue472549 }473550 idx[ny][nx] = compIndex474- queue = append(queue, point{nx, ny})551+ scratch.workQueue = append(scratch.workQueue, point{nx, ny})475552 }476553 }477554 @@ -483,22 +560,31 @@ func islandComponents(g grid, clues clueGrid) ([]islandComponent, [][]int) {
483560 }484561 485562 func componentCanReachClue(g grid, clues clueGrid, comp islandComponent) bool {486- height := len(g)487- width := len(g[0])488- seen := make([][]bool, height)489- for y := range height {490- seen[y] = make([]bool, width)563+ if len(g) == 0 || len(g[0]) == 0 {564+ return false491565 }566+ scratch := newSolverScratch(len(g[0]), len(g))567+ return componentCanReachClueWithScratch(g, clues, comp, scratch)568+}492569 493- queue := make([]point, len(comp.cells))494- copy(queue, comp.cells)570+func componentCanReachClueWithScratch(571+ g grid,572+ clues clueGrid,573+ comp islandComponent,574+ scratch *solverScratch,575+) bool {576+ height := len(g)577+ width := len(g[0])578+ seenMark := scratch.nextSeenMark()579+ scratch.workQueue = scratch.workQueue[:0]495580 for _, c := range comp.cells {496- seen[c.y][c.x] = true581+ scratch.seen[c.y][c.x] = seenMark582+ scratch.workQueue = append(scratch.workQueue, c)497583 }498584 499- for len(queue) > 0 {500- p := queue[0]501- queue = queue[1:]585+ for len(scratch.workQueue) > 0 {586+ p := scratch.workQueue[0]587+ scratch.workQueue = scratch.workQueue[1:]502588 if clues[p.y][p.x] > 0 {503589 return true504590 }@@ -507,53 +593,54 @@ func componentCanReachClue(g grid, clues clueGrid, comp islandComponent) bool {
507593 if nx < 0 || ny < 0 || nx >= width || ny >= height {508594 continue509595 }510- if seen[ny][nx] || g[ny][nx] == seaCell {596+ if scratch.seen[ny][nx] == seenMark || g[ny][nx] == seaCell {511597 continue512598 }513- seen[ny][nx] = true514- queue = append(queue, point{nx, ny})599+ scratch.seen[ny][nx] = seenMark600+ scratch.workQueue = append(scratch.workQueue, point{nx, ny})515601 }516602 }517603 518604 return false519605 }520606 521-func maxReachableForComponent(g grid, clues clueGrid, comp islandComponent) int {607+func maxReachableForComponentWithScratch(608+ g grid,609+ clues clueGrid,610+ comp islandComponent,611+ scratch *solverScratch,612+) int {522613 if comp.clueCount != 1 {523614 return 0524615 }525616 526617 height := len(g)527618 width := len(g[0])528- seen := make([][]bool, height)529- for y := range height {530- seen[y] = make([]bool, width)531- }532-533- queue := make([]point, len(comp.cells))534- copy(queue, comp.cells)619+ seenMark := scratch.nextSeenMark()620+ scratch.workQueue = scratch.workQueue[:0]535621 for _, c := range comp.cells {536- seen[c.y][c.x] = true622+ scratch.seen[c.y][c.x] = seenMark623+ scratch.workQueue = append(scratch.workQueue, c)537624 }538625 539626 count := len(comp.cells)540- for len(queue) > 0 {541- p := queue[0]542- queue = queue[1:]627+ for len(scratch.workQueue) > 0 {628+ p := scratch.workQueue[0]629+ scratch.workQueue = scratch.workQueue[1:]543630 for _, d := range dirs {544631 nx, ny := p.x+d.x, p.y+d.y545632 if nx < 0 || ny < 0 || nx >= width || ny >= height {546633 continue547634 }548- if seen[ny][nx] || g[ny][nx] == seaCell {635+ if scratch.seen[ny][nx] == seenMark || g[ny][nx] == seaCell {549636 continue550637 }551638 if clues[ny][nx] > 0 && (nx != comp.cluePos.x || ny != comp.cluePos.y) {552639 continue553640 }554- seen[ny][nx] = true641+ scratch.seen[ny][nx] = seenMark555642 count++556- queue = append(queue, point{nx, ny})643+ scratch.workQueue = append(scratch.workQueue, point{nx, ny})557644 }558645 }559646 @@ -0,0 +1,86 @@
1+package shikaku2+3+import (4+ "math/rand/v2"5+ "testing"6+)7+8+func skipBenchmarkInShortMode(b *testing.B) {9+ b.Helper()10+ if testing.Short() {11+ b.Skip("skipping benchmark in short mode")12+ }13+}14+15+func benchmarkShikakuModes() []ShikakuMode {16+ return []ShikakuMode{17+ NewMode("Mini 5x5", "benchmark", 5, 5, 5),18+ NewMode("Easy 7x7", "benchmark", 7, 7, 8),19+ NewMode("Medium 8x8", "benchmark", 8, 8, 12),20+ NewMode("Hard 10x10", "benchmark", 10, 10, 15),21+ NewMode("Expert 12x12", "benchmark", 12, 12, 20),22+ }23+}24+25+func makeUncoveredGenerationGrid(width, height int) [][]int {26+ g := make([][]int, height)27+ for y := range height {28+ g[y] = make([]int, width)29+ for x := range width {30+ g[y][x] = -131+ }32+ }33+ return g34+}35+36+func BenchmarkGeneratePuzzleBySize(b *testing.B) {37+ skipBenchmarkInShortMode(b)38+39+ for modeIndex, mode := range benchmarkShikakuModes() {40+ mode := mode41+ modeIndex := modeIndex42+ b.Run(mode.Title(), func(b *testing.B) {43+ b.ReportAllocs()44+ for i := 0; i < b.N; i++ {45+ var (46+ puzzle Puzzle47+ err error48+ )49+ for attempt := range 3 {50+ rng := rand.New(rand.NewPCG(uint64(i+1), uint64(modeIndex*10+attempt+1)))51+ puzzle, err = GeneratePuzzleSeeded(mode.Width, mode.Height, mode.MaxRectSize, rng)52+ if err == nil {53+ break54+ }55+ }56+ if err != nil {57+ b.Fatalf("GeneratePuzzleSeeded failed: %v", err)58+ }59+ if puzzle.Width != mode.Width || puzzle.Height != mode.Height {60+ b.Fatalf("wrong dimensions: got %dx%d, want %dx%d", puzzle.Width, puzzle.Height, mode.Width, mode.Height)61+ }62+ }63+ })64+ }65+}66+67+func BenchmarkPartitionGrid(b *testing.B) {68+ skipBenchmarkInShortMode(b)69+70+ for modeIndex, mode := range benchmarkShikakuModes() {71+ mode := mode72+ modeIndex := modeIndex73+ b.Run(mode.Title(), func(b *testing.B) {74+ b.ReportAllocs()75+ for i := 0; i < b.N; i++ {76+ rng := rand.New(rand.NewPCG(uint64(i+1), uint64(modeIndex+100)))77+ grid := makeUncoveredGenerationGrid(mode.Width, mode.Height)78+ partitions := make([]genRect, 0, mode.Width*mode.Height/2)79+ ok := partitionGrid(grid, mode.Width, mode.Height, mode.MaxRectSize, &partitions, rng)80+ if !ok {81+ b.Fatalf("partitionGrid failed at iteration %d", i)82+ }83+ }84+ })85+ }86+}@@ -0,0 +1,135 @@
1+package shikaku2+3+import (4+ "math/rand/v2"5+ "testing"6+)7+8+func TestPartitionGridCoverageBySize(t *testing.T) {9+ for modeIndex, mode := range benchmarkShikakuModes() {10+ mode := mode11+ modeIndex := modeIndex12+13+ t.Run(mode.Title(), func(t *testing.T) {14+ grid := makeUncoveredGenerationGrid(mode.Width, mode.Height)15+ partitions := make([]genRect, 0, mode.Width*mode.Height/2)16+17+ ok := partitionGrid(18+ grid,19+ mode.Width,20+ mode.Height,21+ mode.MaxRectSize,22+ &partitions,23+ rand.New(rand.NewPCG(uint64(modeIndex+700), uint64(modeIndex+701))),24+ )25+ if !ok {26+ t.Fatal("partitionGrid returned false")27+ }28+ if len(partitions) == 0 {29+ t.Fatal("expected at least one partition")30+ }31+32+ covered := make([][]bool, mode.Height)33+ for y := range mode.Height {34+ covered[y] = make([]bool, mode.Width)35+ }36+37+ totalArea := 038+ for _, r := range partitions {39+ if r.w <= 0 || r.h <= 0 {40+ t.Fatalf("invalid partition size: %+v", r)41+ }42+ if r.x < 0 || r.y < 0 || r.x+r.w > mode.Width || r.y+r.h > mode.Height {43+ t.Fatalf("partition out of bounds: %+v", r)44+ }45+ if r.w*r.h > mode.MaxRectSize {46+ t.Fatalf("partition area %d exceeds max %d", r.w*r.h, mode.MaxRectSize)47+ }48+49+ totalArea += r.w * r.h50+ for dy := range r.h {51+ for dx := range r.w {52+ x, y := r.x+dx, r.y+dy53+ if covered[y][x] {54+ t.Fatalf("overlap at (%d,%d)", x, y)55+ }56+ covered[y][x] = true57+ }58+ }59+ }60+61+ if totalArea != mode.Width*mode.Height {62+ t.Fatalf("partition area sum=%d, want %d", totalArea, mode.Width*mode.Height)63+ }64+65+ for y := range mode.Height {66+ for x := range mode.Width {67+ if !covered[y][x] {68+ t.Fatalf("uncovered cell at (%d,%d)", x, y)69+ }70+ if grid[y][x] < 0 {71+ t.Fatalf("grid cell (%d,%d) left uncovered in generation grid", x, y)72+ }73+ }74+ }75+ })76+ }77+}78+79+func TestGeneratePuzzleClueInvariantsBySize(t *testing.T) {80+ for modeIndex, mode := range benchmarkShikakuModes() {81+ mode := mode82+ modeIndex := modeIndex83+84+ t.Run(mode.Title(), func(t *testing.T) {85+ puzzle, err := GeneratePuzzleSeeded(86+ mode.Width,87+ mode.Height,88+ mode.MaxRectSize,89+ rand.New(rand.NewPCG(uint64(modeIndex+800), uint64(modeIndex+801))),90+ )91+ if err != nil {92+ t.Fatalf("GeneratePuzzleSeeded failed: %v", err)93+ }94+ if puzzle.Width != mode.Width || puzzle.Height != mode.Height {95+ t.Fatalf("dimensions = %dx%d, want %dx%d", puzzle.Width, puzzle.Height, mode.Width, mode.Height)96+ }97+ if len(puzzle.Rectangles) != 0 {98+ t.Fatalf("expected no pre-placed rectangles, got %d", len(puzzle.Rectangles))99+ }100+101+ seenPos := make(map[[2]int]bool, len(puzzle.Clues))102+ seenID := make(map[int]bool, len(puzzle.Clues))103+ sum := 0104+105+ for _, clue := range puzzle.Clues {106+ if clue.X < 0 || clue.X >= mode.Width || clue.Y < 0 || clue.Y >= mode.Height {107+ t.Fatalf("clue out of bounds: %+v", clue)108+ }109+ if clue.Value < 1 || clue.Value > mode.MaxRectSize {110+ t.Fatalf("clue value out of range: %+v", clue)111+ }112+113+ pos := [2]int{clue.X, clue.Y}114+ if seenPos[pos] {115+ t.Fatalf("duplicate clue position: (%d,%d)", clue.X, clue.Y)116+ }117+ seenPos[pos] = true118+119+ if seenID[clue.ID] {120+ t.Fatalf("duplicate clue ID: %d", clue.ID)121+ }122+ seenID[clue.ID] = true123+124+ sum += clue.Value125+ }126+127+ if sum != mode.Width*mode.Height {128+ t.Fatalf("sum of clue values=%d, want %d", sum, mode.Width*mode.Height)129+ }130+ if len(seenID) != len(puzzle.Clues) {131+ t.Fatal("clue IDs are not unique")132+ }133+ })134+ }135+}@@ -12,6 +12,29 @@ type genRect struct {
1212 x, y, w, h int1313 }1414 15+type dim struct {16+ w, h int17+}18+19+type templateCacheKey struct {20+ x, y int21+ maxW int22+ maxH int23+ maxArea int24+}25+26+type failedStateKey struct {27+ x, y int28+ maxW int29+ maxH int30+ remaining int31+}32+33+type partitionSearchState struct {34+ templateCache map[templateCacheKey][]dim35+ failedStates map[failedStateKey]bool36+}37+1538 // GeneratePuzzle creates a Shikaku puzzle with random RNG.1639 func GeneratePuzzle(width, height, maxRectSize int) (Puzzle, error) {1740 rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))@@ -39,24 +62,31 @@ func tryGenerate(width, height, maxRectSize int, rng *rand.Rand) *Puzzle {
3962 }4063 }4164 42- var partitions []genRect43-65+ partitions := make([]genRect, 0, width*height/2)4466 if !partitionGrid(grid, width, height, maxRectSize, &partitions, rng) {4567 return nil4668 }4769 4870 // Place clues: pick a random cell within each partition.4971 clues := make([]Clue, len(partitions))72+ allSingles := true5073 for i, r := range partitions {5174 cx := r.x + rng.IntN(r.w)5275 cy := r.y + rng.IntN(r.h)76+ area := r.w * r.h77+ if area > 1 {78+ allSingles = false79+ }5380 clues[i] = Clue{5481 ID: i,5582 X: cx,5683 Y: cy,57- Value: r.w * r.h,84+ Value: area,5885 }5986 }87+ if allSingles {88+ return nil89+ }6090 6191 return &Puzzle{6292 Width: width,@@ -65,61 +95,182 @@ func tryGenerate(width, height, maxRectSize int, rng *rand.Rand) *Puzzle {
6595 }6696 }6797 68-// partitionGrid recursively fills the grid with non-overlapping rectangles.69-func partitionGrid(grid [][]int, width, height, maxRectSize int, partitions *[]genRect, rng *rand.Rand) bool {70- // Find first uncovered cell (top-left scan).71- fx, fy := -1, -172- for y := range height {73- for x := range width {74- if grid[y][x] == -1 {75- fx, fy = x, y76- break77- }78- }79- if fx >= 0 {80- break81- }98+func isAnchorCell(grid [][]int, x, y int) bool {99+ if grid[y][x] != -1 {100+ return false82101 }102+ if x > 0 && grid[y][x-1] == -1 {103+ return false104+ }105+ if y > 0 && grid[y-1][x] == -1 {106+ return false107+ }108+ return true109+}83110 84- if fx < 0 {85- return true // all cells covered111+func anchorExtents(grid [][]int, x, y, width, height int) (maxW, maxH int) {112+ maxW = 0113+ for cx := x; cx < width && grid[y][cx] == -1; cx++ {114+ maxW++86115 }116+ maxH = 0117+ for cy := y; cy < height && grid[cy][x] == -1; cy++ {118+ maxH++119+ }120+ return maxW, maxH121+}87122 88- type dim struct{ w, h int }89- var candidates []dim123+func candidateTemplatesForAnchor(124+ state *partitionSearchState,125+ x,126+ y,127+ maxW,128+ maxH,129+ maxRectSize int,130+) []dim {131+ key := templateCacheKey{132+ x: x,133+ y: y,134+ maxW: maxW,135+ maxH: maxH,136+ maxArea: maxRectSize,137+ }138+ if templates, ok := state.templateCache[key]; ok {139+ return templates140+ }90141 91- // Enumerate all rectangle sizes that fit at (fx, fy).92- for w := 1; w <= width-fx; w++ {93- for h := 1; h <= height-fy; h++ {142+ templates := make([]dim, 0, maxW*maxH)143+ for w := 1; w <= maxW; w++ {144+ for h := 1; h <= maxH; h++ {94145 if w*h > maxRectSize {95146 continue96147 }97- if fits(grid, fx, fy, w, h) {98- candidates = append(candidates, dim{w, h})148+ templates = append(templates, dim{w: w, h: h})149+ }150+ }151+152+ state.templateCache[key] = templates153+ return templates154+}155+156+func selectMostConstrainedAnchor(157+ grid [][]int,158+ width,159+ height,160+ maxRectSize int,161+ state *partitionSearchState,162+) (x, y, maxW, maxH int, candidates []dim, found bool) {163+ bestCount := width*height + 1164+ bestX, bestY := -1, -1165+ bestW, bestH := 0, 0166+167+ for y := range height {168+ for x := range width {169+ if !isAnchorCell(grid, x, y) {170+ continue171+ }172+173+ curW, curH := anchorExtents(grid, x, y, width, height)174+ templates := candidateTemplatesForAnchor(state, x, y, curW, curH, maxRectSize)175+ valid := make([]dim, 0, len(templates))176+ for _, c := range templates {177+ if fits(grid, x, y, c.w, c.h) {178+ valid = append(valid, c)179+ }180+ }181+182+ if len(valid) == 0 {183+ return x, y, curW, curH, nil, true184+ }185+ if len(valid) < bestCount {186+ bestCount = len(valid)187+ bestX, bestY = x, y188+ bestW, bestH = curW, curH189+ candidates = valid190+ found = true191+ }192+ if bestCount == 1 {193+ return bestX, bestY, bestW, bestH, candidates, true99194 }100195 }101196 }102197 103- // Shuffle for randomness.104- rng.Shuffle(len(candidates), func(i, j int) {105- candidates[i], candidates[j] = candidates[j], candidates[i]198+ if !found {199+ return 0, 0, 0, 0, nil, false200+ }201+ return bestX, bestY, bestW, bestH, candidates, true202+}203+204+// partitionGrid recursively fills the grid with non-overlapping rectangles.205+func partitionGrid(grid [][]int, width, height, maxRectSize int, partitions *[]genRect, rng *rand.Rand) bool {206+ state := partitionSearchState{207+ templateCache: make(map[templateCacheKey][]dim, width*height),208+ failedStates: make(map[failedStateKey]bool, width*height),209+ }210+ return partitionGridRec(grid, width, height, maxRectSize, partitions, rng, &state, width*height)211+}212+213+func partitionGridRec(214+ grid [][]int,215+ width,216+ height,217+ maxRectSize int,218+ partitions *[]genRect,219+ rng *rand.Rand,220+ state *partitionSearchState,221+ remaining int,222+) bool {223+ x, y, maxW, maxH, candidates, found := selectMostConstrainedAnchor(224+ grid,225+ width,226+ height,227+ maxRectSize,228+ state,229+ )230+ if !found {231+ return true // all cells covered232+ }233+234+ failKey := failedStateKey{x: x, y: y, maxW: maxW, maxH: maxH, remaining: remaining}235+ if state.failedStates[failKey] {236+ return false237+ }238+ if len(candidates) == 0 {239+ state.failedStates[failKey] = true240+ return false241+ }242+243+ order := make([]dim, len(candidates))244+ copy(order, candidates)245+ rng.Shuffle(len(order), func(i, j int) {246+ order[i], order[j] = order[j], order[i]106247 })107248 108249 id := len(*partitions)109- for _, c := range candidates {250+ for _, c := range order {110251 // Place rectangle.111- place(grid, fx, fy, c.w, c.h, id)112- *partitions = append(*partitions, genRect{fx, fy, c.w, c.h})252+ place(grid, x, y, c.w, c.h, id)253+ *partitions = append(*partitions, genRect{x, y, c.w, c.h})113254 114- if partitionGrid(grid, width, height, maxRectSize, partitions, rng) {255+ if partitionGridRec(256+ grid,257+ width,258+ height,259+ maxRectSize,260+ partitions,261+ rng,262+ state,263+ remaining-(c.w*c.h),264+ ) {115265 return true116266 }117267 118268 // Backtrack.119269 *partitions = (*partitions)[:len(*partitions)-1]120- clearGrid(grid, fx, fy, c.w, c.h)270+ clearGrid(grid, x, y, c.w, c.h)121271 }122272 273+ state.failedStates[failKey] = true123274 return false124275 }125276 @@ -0,0 +1,151 @@
1+package sudoku2+3+import (4+ "fmt"5+ "math/rand/v2"6+ "testing"7+)8+9+func skipBenchmarkInShortMode(b *testing.B) {10+ b.Helper()11+ if testing.Short() {12+ b.Skip("skipping benchmark in short mode")13+ }14+}15+16+func benchmarkSudokuModes() []SudokuMode {17+ return []SudokuMode{18+ NewMode("Beginner", "benchmark", 45),19+ NewMode("Easy", "benchmark", 38),20+ NewMode("Medium", "benchmark", 32),21+ NewMode("Hard", "benchmark", 27),22+ NewMode("Expert", "benchmark", 22),23+ NewMode("Diabolical", "benchmark", 17),24+ }25+}26+27+func BenchmarkGenerateProvidedCellsByMode(b *testing.B) {28+ skipBenchmarkInShortMode(b)29+30+ for modeIndex, mode := range benchmarkSudokuModes() {31+ mode := mode32+ modeIndex := modeIndex33+ b.Run(mode.Title(), func(b *testing.B) {34+ b.ReportAllocs()35+ for i := 0; i < b.N; i++ {36+ rng := rand.New(rand.NewPCG(uint64(i+1), uint64(modeIndex+10_000)))37+ cells := GenerateProvidedCellsSeeded(mode, rng)38+ if len(cells) < mode.ProvidedCount {39+ b.Fatalf("provided count %d below target %d", len(cells), mode.ProvidedCount)40+ }41+ }42+ })43+ }44+}45+46+func BenchmarkCountSolutionsByDifficulty(b *testing.B) {47+ skipBenchmarkInShortMode(b)48+49+ for modeIndex, mode := range benchmarkSudokuModes() {50+ mode := mode51+ modeIndex := modeIndex52+ b.Run(mode.Title(), func(b *testing.B) {53+ rng := rand.New(rand.NewPCG(uint64(modeIndex+1), uint64(modeIndex+2)))54+ cells := GenerateProvidedCellsSeeded(mode, rng)55+ source := newGrid(cells)56+ if got := countSolutions(&source, 2); got != 1 {57+ b.Fatalf("expected unique seed puzzle for %s, got %d", mode.Title(), got)58+ }59+60+ b.ReportAllocs()61+ b.ResetTimer()62+63+ for i := 0; i < b.N; i++ {64+ g := source65+ if got := countSolutions(&g, 2); got != 1 {66+ b.Fatalf("countSolutions(%s) = %d, want 1", mode.Title(), got)67+ }68+ }69+ })70+ }71+}72+73+func BenchmarkGenerateProvidedCellsSeededStable(b *testing.B) {74+ skipBenchmarkInShortMode(b)75+76+ mode := NewMode("Hard", "benchmark", 27)77+ b.ReportAllocs()78+ for i := 0; i < b.N; i++ {79+ rng := rand.New(rand.NewPCG(uint64(i+1), uint64(i+100)))80+ cells := GenerateProvidedCellsSeeded(mode, rng)81+ if len(cells) < mode.ProvidedCount {82+ b.Fatalf("generated %d clues, want >= %d", len(cells), mode.ProvidedCount)83+ }84+ }85+}86+87+func BenchmarkCountSolutionsFixedPuzzle(b *testing.B) {88+ skipBenchmarkInShortMode(b)89+90+ mode := NewMode("Expert", "benchmark", 22)91+ cells := GenerateProvidedCellsSeeded(mode, rand.New(rand.NewPCG(42, 84)))92+ source := newGrid(cells)93+ if got := countSolutions(&source, 2); got != 1 {94+ b.Fatalf("seed puzzle not unique: %d", got)95+ }96+97+ b.ReportAllocs()98+ b.ResetTimer()99+ for i := 0; i < b.N; i++ {100+ g := source101+ count := countSolutions(&g, 2)102+ if count != 1 {103+ b.Fatalf("iteration %d: countSolutions=%d, want 1", i, count)104+ }105+ }106+}107+108+func BenchmarkIsValidAllCells(b *testing.B) {109+ skipBenchmarkInShortMode(b)110+111+ mode := NewMode("Medium", "benchmark", 32)112+ cells := GenerateProvidedCellsSeeded(mode, rand.New(rand.NewPCG(7, 11)))113+ g := newGrid(cells)114+115+ b.ReportAllocs()116+ b.ResetTimer()117+ for i := 0; i < b.N; i++ {118+ for y := range gridSize {119+ for x := range gridSize {120+ val := g[y][x].v121+ if val == 0 {122+ val = ((x + y) % gridSize) + 1123+ }124+ if !isValid(&g, val, x, y) && g[y][x].v == val {125+ b.Fatalf("unexpected invalid self-check at (%d,%d)", x, y)126+ }127+ }128+ }129+ }130+}131+132+func BenchmarkCountSolutionsModesSummary(b *testing.B) {133+ skipBenchmarkInShortMode(b)134+135+ modes := benchmarkSudokuModes()136+ b.ReportAllocs()137+ for i := 0; i < b.N; i++ {138+ mode := modes[i%len(modes)]139+ rng := rand.New(rand.NewPCG(uint64(i+1), uint64(i+2)))140+ cells := GenerateProvidedCellsSeeded(mode, rng)141+ g := newGrid(cells)142+ if got := countSolutions(&g, 2); got != 1 {143+ b.Fatalf("mode %s uniqueness check failed: %d", mode.Title(), got)144+ }145+ }146+}147+148+func ExampleBenchmarkGenerateProvidedCellsByMode() {149+ fmt.Println("run with: go test ./sudoku -run '^$' -bench BenchmarkGenerateProvidedCellsByMode -benchmem")150+ // Output: run with: go test ./sudoku -run '^$' -bench BenchmarkGenerateProvidedCellsByMode -benchmem151+}@@ -2,11 +2,156 @@ package sudoku
22 33 import "math/rand/v2"44 5+const allDigitsMask uint16 = 0x3FE6+7+type solverMasks struct {8+ row [gridSize]uint169+ col [gridSize]uint1610+ box [gridSize]uint1611+}12+513 // newRNG creates a fresh non-deterministic RNG.614 func newRNG() *rand.Rand {715 return rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))816 }917 18+func valueBit(val int) uint16 {19+ return uint16(1 << val)20+}21+22+func boxIndex(x, y int) int {23+ return (y/3)*3 + (x / 3)24+}25+26+func buildMasks(g *grid) (solverMasks, bool) {27+ var masks solverMasks28+ for y := range gridSize {29+ for x := range gridSize {30+ val := g[y][x].v31+ if val == 0 {32+ continue33+ }34+ if val < 0 || val > gridSize {35+ return solverMasks{}, false36+ }37+38+ bit := valueBit(val)39+ box := boxIndex(x, y)40+ if masks.row[y]&bit != 0 || masks.col[x]&bit != 0 || masks.box[box]&bit != 0 {41+ return solverMasks{}, false42+ }43+44+ masks.row[y] |= bit45+ masks.col[x] |= bit46+ masks.box[box] |= bit47+ }48+ }49+ return masks, true50+}51+52+func canPlace(masks *solverMasks, val, x, y int) bool {53+ bit := valueBit(val)54+ box := boxIndex(x, y)55+ return masks.row[y]&bit == 0 && masks.col[x]&bit == 0 && masks.box[box]&bit == 056+}57+58+func placeValue(g *grid, masks *solverMasks, val, x, y int) {59+ bit := valueBit(val)60+ box := boxIndex(x, y)61+ g[y][x].v = val62+ masks.row[y] |= bit63+ masks.col[x] |= bit64+ masks.box[box] |= bit65+}66+67+func clearValue(g *grid, masks *solverMasks, val, x, y int) {68+ bit := valueBit(val)69+ box := boxIndex(x, y)70+ g[y][x].v = 071+ masks.row[y] &^= bit72+ masks.col[x] &^= bit73+ masks.box[box] &^= bit74+}75+76+func candidatesForCell(masks *solverMasks, x, y int) uint16 {77+ used := masks.row[y] | masks.col[x] | masks.box[boxIndex(x, y)]78+ return allDigitsMask &^ used79+}80+81+func findFirstEmptyCell(g *grid) (x, y int, found bool) {82+ for y := range gridSize {83+ for x := range gridSize {84+ if g[y][x].v == 0 {85+ return x, y, true86+ }87+ }88+ }89+ return 0, 0, false90+}91+92+func fillGridRec(g *grid, rng *rand.Rand, masks *solverMasks) bool {93+ x, y, found := findFirstEmptyCell(g)94+ if !found {95+ return true96+ }97+98+ order := rng.Perm(gridSize)99+ for _, i := range order {100+ val := i + 1101+ if !canPlace(masks, val, x, y) {102+ continue103+ }104+105+ placeValue(g, masks, val, x, y)106+ if fillGridRec(g, rng, masks) {107+ return true108+ }109+ clearValue(g, masks, val, x, y)110+ }111+112+ return false113+}114+115+func countCandidateBits(mask uint16) int {116+ count := 0117+ for val := 1; val <= gridSize; val++ {118+ if mask&valueBit(val) != 0 {119+ count++120+ }121+ }122+ return count123+}124+125+func findMostConstrainedEmptyCell(g *grid, masks *solverMasks) (x, y int, candidates uint16, found bool) {126+ bestCount := gridSize + 1127+ bestX, bestY := 0, 0128+ for y := range gridSize {129+ for x := range gridSize {130+ if g[y][x].v != 0 {131+ continue132+ }133+134+ mask := candidatesForCell(masks, x, y)135+ candidateCount := countCandidateBits(mask)136+ if candidateCount == 0 {137+ return x, y, 0, true138+ }139+140+ if !found || candidateCount < bestCount {141+ bestCount = candidateCount142+ candidates = mask143+ bestX, bestY = x, y144+ found = true145+ }146+ if bestCount == 1 {147+ return bestX, bestY, candidates, true148+ }149+ }150+ }151+152+ return bestX, bestY, candidates, found153+}154+10155 // isValid checks whether placing val at (x, y) in the grid is valid.11156 func isValid(g *grid, val, x, y int) bool {12157 for i := range gridSize {@@ -35,61 +180,56 @@ func fillGrid(g *grid) bool {
35180 }36181 37182 func fillGridSeeded(g *grid, rng *rand.Rand) bool {38- for y := range gridSize {39- for x := range gridSize {40- if g[y][x].v == 0 {41- order := rng.Perm(gridSize)42- for _, i := range order {43- val := i + 144- if isValid(g, val, x, y) {45- g[y][x].v = val46- if fillGridSeeded(g, rng) {47- return true48- }49- g[y][x].v = 050- }51- }52- return false53- }54- }183+ masks, ok := buildMasks(g)184+ if !ok {185+ return false55186 }56- return true187+ return fillGridRec(g, rng, &masks)57188 }58189 59190 // countSolutions counts solutions of the grid up to limit using backtracking.60191 // Returns 0 immediately if the grid already contains conflicting values.61192 func countSolutions(g *grid, limit int) int {62- for y := range gridSize {63- for x := range gridSize {64- if g[y][x].v != 0 && !isValid(g, g[y][x].v, x, y) {65- return 066- }67- }193+ if limit <= 0 {194+ return 0195+ }196+197+ masks, ok := buildMasks(g)198+ if !ok {199+ return 068200 }69- return countSolutionsRec(g, limit)201+ return countSolutionsRec(g, limit, &masks)70202 }71203 72204 // countSolutionsRec is the recursive backtracking helper for countSolutions.73-func countSolutionsRec(g *grid, limit int) int {74- for y := range gridSize {75- for x := range gridSize {76- if g[y][x].v == 0 {77- count := 078- for val := 1; val <= gridSize; val++ {79- if isValid(g, val, x, y) {80- g[y][x].v = val81- count += countSolutionsRec(g, limit-count)82- g[y][x].v = 083- if count >= limit {84- return count85- }86- }87- }88- return count89- }205+func countSolutionsRec(g *grid, limit int, masks *solverMasks) int {206+ if limit <= 0 {207+ return 0208+ }209+210+ x, y, candidateMask, found := findMostConstrainedEmptyCell(g, masks)211+ if !found {212+ return 1213+ }214+ if candidateMask == 0 {215+ return 0216+ }217+218+ count := 0219+ for val := 1; val <= gridSize; val++ {220+ bit := valueBit(val)221+ if candidateMask&bit == 0 {222+ continue223+ }224+225+ placeValue(g, masks, val, x, y)226+ count += countSolutionsRec(g, limit-count, masks)227+ clearValue(g, masks, val, x, y)228+ if count >= limit {229+ return count90230 }91231 }92- return 1232+ return count93233 }94234 95235 // GenerateProvidedCells generates a random sudoku puzzle with the number of@@ -98,12 +238,49 @@ func GenerateProvidedCells(m SudokuMode) []cell {
98238 return GenerateProvidedCellsSeeded(m, newRNG())99239 }100240 241+type pos struct{ x, y int }242+243+func clueRemovalScore(g *grid, x, y int) int {244+ score := 0245+ for i := range gridSize {246+ if i != x && g[y][i].v == 0 {247+ score++248+ }249+ if i != y && g[i][x].v == 0 {250+ score++251+ }252+ }253+254+ boxX, boxY := (x/3)*3, (y/3)*3255+ for by := boxY; by < boxY+3; by++ {256+ for bx := boxX; bx < boxX+3; bx++ {257+ if (bx != x || by != y) && g[by][bx].v == 0 {258+ score++259+ }260+ }261+ }262+263+ return score264+}265+266+func pickRemovalCandidateIndex(g *grid, positions []pos, rng *rand.Rand) int {267+ bestIndex := 0268+ bestScore := clueRemovalScore(g, positions[0].x, positions[0].y)269+ for i := 1; i < len(positions); i++ {270+ score := clueRemovalScore(g, positions[i].x, positions[i].y)271+ if score < bestScore || (score == bestScore && rng.IntN(2) == 0) {272+ bestScore = score273+ bestIndex = i274+ }275+ }276+ return bestIndex277+}278+101279 func GenerateProvidedCellsSeeded(m SudokuMode, rng *rand.Rand) []cell {102280 g := newGrid(nil)103281 fillGridSeeded(&g, rng)104282 105283 // Build shuffled list of all positions106- type pos struct{ x, y int }107284 positions := make([]pos, 0, gridSize*gridSize)108285 for y := range gridSize {109286 for x := range gridSize {@@ -114,12 +291,14 @@ func GenerateProvidedCellsSeeded(m SudokuMode, rng *rand.Rand) []cell {
114291 positions[i], positions[j] = positions[j], positions[i]115292 })116293 117- // Iteratively remove cells, ensuring unique solution294+ // Iteratively remove cells in low-impact-first order while preserving uniqueness.118295 filled := gridSize * gridSize119- for _, p := range positions {120- if filled <= m.ProvidedCount {121- break122- }296+ for len(positions) > 0 && filled > m.ProvidedCount {297+ idx := pickRemovalCandidateIndex(&g, positions, rng)298+ p := positions[idx]299+ positions[idx] = positions[len(positions)-1]300+ positions = positions[:len(positions)-1]301+123302 saved := g[p.y][p.x].v124303 g[p.y][p.x].v = 0125304 if countSolutions(&g, 2) != 1 {@@ -0,0 +1,26 @@
1+package sudoku2+3+import (4+ "math/rand/v2"5+ "testing"6+)7+8+func TestGenerateProvidedCellsUniquenessAcrossModes(t *testing.T) {9+ for modeIndex, mode := range benchmarkSudokuModes() {10+ mode := mode11+ modeIndex := modeIndex12+13+ t.Run(mode.Title(), func(t *testing.T) {14+ rng := rand.New(rand.NewPCG(uint64(modeIndex+101), uint64(modeIndex+1001)))15+ cells := GenerateProvidedCellsSeeded(mode, rng)16+ if len(cells) < mode.ProvidedCount {17+ t.Fatalf("generated %d clues, want >= %d", len(cells), mode.ProvidedCount)18+ }19+20+ g := newGrid(cells)21+ if got := countSolutions(&g, 2); got != 1 {22+ t.Fatalf("countSolutions(limit=2) = %d, want 1", got)23+ }24+ })25+ }26+}@@ -0,0 +1,70 @@
1+package takuzu2+3+import (4+ "fmt"5+ "math/rand/v2"6+ "testing"7+)8+9+func skipBenchmarkInShortMode(b *testing.B) {10+ b.Helper()11+ if testing.Short() {12+ b.Skip("skipping benchmark in short mode")13+ }14+}15+16+type takuzuBenchMode struct {17+ size int18+ prefilled float6419+}20+21+var takuzuBenchModes = []takuzuBenchMode{22+ {size: 8, prefilled: 0.40},23+ {size: 10, prefilled: 0.32},24+ {size: 12, prefilled: 0.30},25+ {size: 14, prefilled: 0.28},26+}27+28+func BenchmarkGeneratePuzzleSizes(b *testing.B) {29+ skipBenchmarkInShortMode(b)30+31+ for _, mode := range takuzuBenchModes {32+ name := fmt.Sprintf("%dx%d", mode.size, mode.size)33+ b.Run(name, func(b *testing.B) {34+ rng := rand.New(rand.NewPCG(uint64(mode.size), uint64(mode.size*17+3)))35+ b.ReportAllocs()36+ b.ResetTimer()37+38+ for range b.N {39+ complete := generateCompleteSeeded(mode.size, rng)40+ puzzle, provided := generatePuzzleSeeded(complete, mode.size, mode.prefilled, rng)41+ if len(puzzle) != mode.size || len(provided) != mode.size {42+ b.Fatalf("unexpected puzzle dimensions for size %d", mode.size)43+ }44+ }45+ })46+ }47+}48+49+func BenchmarkCountSolutionsSizes(b *testing.B) {50+ skipBenchmarkInShortMode(b)51+52+ for _, mode := range takuzuBenchModes {53+ name := fmt.Sprintf("%dx%d", mode.size, mode.size)54+ b.Run(name, func(b *testing.B) {55+ rng := rand.New(rand.NewPCG(uint64(mode.size*101), uint64(mode.size*37+11)))56+ complete := generateCompleteSeeded(mode.size, rng)57+ puzzle, _ := generatePuzzleSeeded(complete, mode.size, mode.prefilled, rng)58+59+ b.ReportAllocs()60+ b.ResetTimer()61+62+ for range b.N {63+ count := countSolutions(puzzle, mode.size, 2)64+ if count != 1 {65+ b.Fatalf("countSolutions returned %d, want 1", count)66+ }67+ }68+ })69+ }70+}@@ -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 }@@ -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+}@@ -2,8 +2,8 @@ Output vhs/nonogram.gif
22 33 Set Theme "Monokai Pro"44 Set FontSize 145-Set Width 12006-Set Height 8605+Set Width 16006+Set Height 120077 Set Margin 6088 Set MarginFill "#A85C40"99 Set WindowBar ColorfulRight@@ -19,7 +19,7 @@ Sleep 2s
1919 Ctrl+L2020 Show2121 22-Type "./puzzletea new nonogram epic"22+Type './puzzletea --theme "Monokai Pro" new nonogram epic --with-seed "vhs-nonogram-epic"'2323 Sleep 500ms2424 Enter2525 Sleep 2s@@ -1,7 +1,7 @@
11 Output vhs/wordsearch.gif22 33 Set Theme "Solarized Darcula"4-Set FontSize 164+Set FontSize 1455 Set Width 120066 Set Height 80077 Set Margin 40@@ -20,7 +20,7 @@ Sleep 2s
2020 Ctrl+L2121 Show2222 23-Type "./puzzletea new word-search hard-20x20"23+Type './puzzletea --theme "Solarized Darcula" new word-search hard-20x20 --with-seed "vhs-wordsearch-hard-20x20"'2424 Sleep 500ms2525 Enter2626 Sleep 2s@@ -0,0 +1,150 @@
1+package wordsearch2+3+import (4+ "math/rand/v2"5+ "testing"6+)7+8+func skipBenchmarkInShortMode(b *testing.B) {9+ b.Helper()10+ if testing.Short() {11+ b.Skip("skipping benchmark in short mode")12+ }13+}14+15+type wordSearchBenchCase struct {16+ name string17+ width int18+ height int19+ wordCount int20+ minWordLen int21+ maxWordLen int22+ allowedDirs []Direction23+}24+25+func BenchmarkGenerateWordSearchByMode(b *testing.B) {26+ skipBenchmarkInShortMode(b)27+28+ cases := []wordSearchBenchCase{29+ {30+ name: "easy_10x10",31+ width: 10,32+ height: 10,33+ wordCount: 6,34+ minWordLen: 3,35+ maxWordLen: 5,36+ allowedDirs: []Direction{Right, Down, DownRight},37+ },38+ {39+ name: "medium_15x15",40+ width: 15,41+ height: 15,42+ wordCount: 10,43+ minWordLen: 4,44+ maxWordLen: 7,45+ allowedDirs: []Direction{Right, Down, DownRight, DownLeft, Left, Up},46+ },47+ {48+ name: "hard_20x20",49+ width: 20,50+ height: 20,51+ wordCount: 15,52+ minWordLen: 5,53+ maxWordLen: 10,54+ allowedDirs: []Direction{Right, Down, DownRight, DownLeft, Left, Up, UpRight, UpLeft},55+ },56+ }57+58+ for _, tt := range cases {59+ b.Run(tt.name, func(b *testing.B) {60+ rng := rand.New(rand.NewPCG(1, 2))61+ var aggregate placementStats62+ b.ReportAllocs()63+64+ for range b.N {65+ _, _, stats := generateWordSearchSeededWithStats(66+ tt.width,67+ tt.height,68+ tt.wordCount,69+ tt.minWordLen,70+ tt.maxWordLen,71+ tt.allowedDirs,72+ rng,73+ )74+ aggregate.TargetWords += stats.TargetWords75+ aggregate.PlacedWords += stats.PlacedWords76+ aggregate.FailedWords += stats.FailedWords77+ aggregate.RandomAttempts += stats.RandomAttempts78+ aggregate.FallbackAttempts += stats.FallbackAttempts79+ aggregate.FallbackUsed += stats.FallbackUsed80+ aggregate.FallbackPlaced += stats.FallbackPlaced81+ }82+83+ b.ReportMetric(aggregate.SuccessRate()*100, "place_pct")84+ b.ReportMetric(aggregate.AttemptsPerWord(), "attempts/word")85+ if aggregate.TargetWords > 0 {86+ fallbackRate := float64(aggregate.FallbackUsed) * 100 / float64(aggregate.TargetWords)87+ b.ReportMetric(fallbackRate, "fallback_pct")88+ }89+ })90+ }91+}92+93+func BenchmarkTryPlaceWord(b *testing.B) {94+ skipBenchmarkInShortMode(b)95+96+ cases := []struct {97+ name string98+ width int99+ height int100+ text string101+ allowedDirs []Direction102+ }{103+ {104+ name: "easy_10x10",105+ width: 10,106+ height: 10,107+ text: "HELLO",108+ allowedDirs: []Direction{Right, Down, DownRight},109+ },110+ {111+ name: "medium_15x15",112+ width: 15,113+ height: 15,114+ text: "PUZZLE",115+ allowedDirs: []Direction{Right, Down, DownRight, DownLeft, Left, Up},116+ },117+ {118+ name: "hard_20x20",119+ width: 20,120+ height: 20,121+ text: "WORDSEARCH",122+ allowedDirs: []Direction{Right, Down, DownRight, DownLeft, Left, Up, UpRight, UpLeft},123+ },124+ }125+126+ for _, tt := range cases {127+ b.Run(tt.name, func(b *testing.B) {128+ rng := rand.New(rand.NewPCG(3, 4))129+ g := createEmptyGrid(tt.height, tt.width)130+ b.ReportAllocs()131+132+ for range b.N {133+ w := tryPlaceWordSeeded(g, tt.text, tt.allowedDirs, 100, rng)134+ if w == nil {135+ b.Fatalf("tryPlaceWordSeeded returned nil for %q", tt.text)136+ }137+ clearWord(g, *w)138+ }139+ })140+ }141+}142+143+func clearWord(g grid, w Word) {144+ dx, dy := w.Direction.Vector()145+ for i := range len(w.Text) {146+ x := w.Start.X + dx*i147+ y := w.Start.Y + dy*i148+ g.Set(x, y, ' ')149+ }150+}@@ -2,29 +2,137 @@ package wordsearch
22 33 import (44 "math/rand/v2"5+ "sort"56 "strings"67 )78 9+type placementStats struct {10+ TargetWords int11+ PlacedWords int12+ FailedWords int13+ RandomAttempts int14+ FallbackAttempts int15+ FallbackUsed int16+ FallbackPlaced int17+}18+19+func (s placementStats) SuccessRate() float64 {20+ if s.TargetWords == 0 {21+ return 122+ }23+ return float64(s.PlacedWords) / float64(s.TargetWords)24+}25+26+func (s placementStats) AttemptsPerWord() float64 {27+ if s.TargetWords == 0 {28+ return 029+ }30+ attempts := s.RandomAttempts + s.FallbackAttempts31+ return float64(attempts) / float64(s.TargetWords)32+}33+34+type placementAttemptStats struct {35+ RandomAttempts int36+ FallbackAttempts int37+ UsedFallback bool38+}39+40+type wordBucketSampler struct {41+ words []string42+ remaining int43+ swaps map[int]int44+}45+46+func (s *wordBucketSampler) resolve(pos int) int {47+ if s.swaps == nil {48+ return pos49+ }50+ if mapped, ok := s.swaps[pos]; ok {51+ return mapped52+ }53+ return pos54+}55+56+func (s *wordBucketSampler) draw(rng *rand.Rand) string {57+ drawPos := rng.IntN(s.remaining)58+ wordIndex := s.resolve(drawPos)59+60+ lastPos := s.remaining - 161+ lastIndex := s.resolve(lastPos)62+63+ if drawPos != lastPos {64+ if s.swaps == nil {65+ s.swaps = map[int]int{}66+ }67+ s.swaps[drawPos] = lastIndex68+ }69+70+ if s.swaps != nil {71+ delete(s.swaps, lastPos)72+ }73+74+ s.remaining--75+ return s.words[wordIndex]76+}77+878 func GenerateWordSearch(width, height, wordCount, minLen, maxLen int, allowedDirs []Direction) (grid, []Word) {979 rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))1080 return GenerateWordSearchSeeded(width, height, wordCount, minLen, maxLen, allowedDirs, rng)1181 }1282 1383 func GenerateWordSearchSeeded(width, height, wordCount, minLen, maxLen int, allowedDirs []Direction, rng *rand.Rand) (grid, []Word) {84+ g, placedWords, _ := generateWordSearchSeededWithStats(85+ width,86+ height,87+ wordCount,88+ minLen,89+ maxLen,90+ allowedDirs,91+ rng,92+ )93+ return g, placedWords94+}95+96+func generateWordSearchSeededWithStats(97+ width,98+ height,99+ wordCount,100+ minLen,101+ maxLen int,102+ allowedDirs []Direction,103+ rng *rand.Rand,104+) (grid, []Word, placementStats) {14105 g := createEmptyGrid(height, width)15106 words := selectWordsSeeded(wordCount, minLen, maxLen, rng)107+ orderWordsForPlacement(words)108+16109 placedWords := make([]Word, 0, len(words))110+ stats := placementStats{TargetWords: len(words)}17111 18112 for _, wordText := range words {19- word := tryPlaceWordSeeded(g, wordText, allowedDirs, 100, rng)20- if word != nil {21- placedWords = append(placedWords, *word)113+ word, attemptStats := tryPlaceWordSeededWithFallback(g, wordText, allowedDirs, 100, rng)114+115+ stats.RandomAttempts += attemptStats.RandomAttempts116+ stats.FallbackAttempts += attemptStats.FallbackAttempts117+ if attemptStats.UsedFallback {118+ stats.FallbackUsed++22119 }120+121+ if word == nil {122+ stats.FailedWords++123+ continue124+ }125+126+ stats.PlacedWords++127+ if attemptStats.UsedFallback {128+ stats.FallbackPlaced++129+ }130+ placedWords = append(placedWords, *word)23131 }24132 25133 fillEmptyCellsSeeded(g, rng)26134 27- return g, placedWords135+ return g, placedWords, stats28136 }29137 30138 func selectWords(count, minLen, maxLen int) []string {@@ -33,22 +141,61 @@ func selectWords(count, minLen, maxLen int) []string {
33141 }34142 35143 func selectWordsSeeded(count, minLen, maxLen int, rng *rand.Rand) []string {36- var candidates []string144+ if count <= 0 || minLen > maxLen {145+ return nil146+ }147+148+ buckets := make([]wordBucketSampler, 0, maxLen-minLen+1)149+ totalAvailable := 0150+37151 for length := minLen; length <= maxLen; length++ {38- if words, ok := wordsByLength[length]; ok {39- candidates = append(candidates, words...)152+ words, ok := wordsByLength[length]153+ if !ok || len(words) == 0 {154+ continue40155 }156+157+ buckets = append(buckets, wordBucketSampler{158+ words: words,159+ remaining: len(words),160+ })161+ totalAvailable += len(words)41162 }42163 43- rng.Shuffle(len(candidates), func(i, j int) {44- candidates[i], candidates[j] = candidates[j], candidates[i]45- })164+ if totalAvailable == 0 {165+ return nil166+ }167+168+ if count > totalAvailable {169+ count = totalAvailable170+ }46171 47- if count > len(candidates) {48- count = len(candidates)172+ selected := make([]string, 0, count)173+ for range count {174+ bucketIndex := pickBucketByRemaining(buckets, totalAvailable, rng)175+ selected = append(selected, buckets[bucketIndex].draw(rng))176+ totalAvailable--49177 }50178 51- return candidates[:count]179+ return selected180+}181+182+func pickBucketByRemaining(buckets []wordBucketSampler, totalRemaining int, rng *rand.Rand) int {183+ randomOffset := rng.IntN(totalRemaining)184+185+ for i := range buckets {186+ if randomOffset < buckets[i].remaining {187+ return i188+ }189+ randomOffset -= buckets[i].remaining190+ }191+192+ return len(buckets) - 1193+}194+195+func orderWordsForPlacement(words []string) {196+ sort.SliceStable(words, func(i, j int) bool {197+ return len(words[i]) > len(words[j])198+ })52199 }53200 54201 func tryPlaceWord(g grid, text string, allowedDirs []Direction, maxAttempts int) *Word {@@ -57,56 +204,140 @@ func tryPlaceWord(g grid, text string, allowedDirs []Direction, maxAttempts int)
57204 }58205 59206 func tryPlaceWordSeeded(g grid, text string, allowedDirs []Direction, maxAttempts int, rng *rand.Rand) *Word {207+ word, _ := tryPlaceWordSeededWithFallback(g, text, allowedDirs, maxAttempts, rng)208+ return word209+}210+211+func tryPlaceWordSeededWithFallback(212+ g grid,213+ text string,214+ allowedDirs []Direction,215+ maxAttempts int,216+ rng *rand.Rand,217+) (*Word, placementAttemptStats) {218+ word, randomAttempts := tryPlaceWordSeededRandom(g, text, allowedDirs, maxAttempts, rng)219+ if word != nil {220+ return word, placementAttemptStats{RandomAttempts: randomAttempts}221+ }222+223+ fallbackWord, fallbackAttempts := tryPlaceWordSeededFallback(g, text, allowedDirs, rng)224+ return fallbackWord, placementAttemptStats{225+ RandomAttempts: randomAttempts,226+ FallbackAttempts: fallbackAttempts,227+ UsedFallback: fallbackAttempts > 0,228+ }229+}230+231+func tryPlaceWordSeededRandom(g grid, text string, allowedDirs []Direction, maxAttempts int, rng *rand.Rand) (*Word, int) {60232 text = strings.ToUpper(text)233+ if maxAttempts <= 0 || len(text) == 0 || len(allowedDirs) == 0 || len(g) == 0 || len(g[0]) == 0 {234+ return nil, 0235+ }236+61237 height := len(g)62238 width := len(g[0])63239 64- for range maxAttempts {240+ for attempt := 1; attempt <= maxAttempts; attempt++ {65241 x := rng.IntN(width)66242 y := rng.IntN(height)67243 68244 dir := allowedDirs[rng.IntN(len(allowedDirs))]69245 dx, dy := dir.Vector()70246 71- endX := x + dx*(len(text)-1)72- endY := y + dy*(len(text)-1)73-74- if endX < 0 || endX >= width || endY < 0 || endY >= height {247+ if !wordInBounds(x, y, dx, dy, len(text), width, height) {75248 continue76249 }250+ if !canPlaceWordAt(g, text, x, y, dx, dy) {251+ continue252+ }253+254+ placeWordAt(g, text, x, y, dx, dy)255+ return newWord(text, x, y, dx, dy, dir), attempt256+ }257+258+ return nil, maxAttempts259+}77260 78- valid := true79- for i := range len(text) {80- cx := x + dx*i81- cy := y + dy*i82- existing := g.Get(cx, cy)261+func tryPlaceWordSeededFallback(g grid, text string, allowedDirs []Direction, rng *rand.Rand) (*Word, int) {262+ text = strings.ToUpper(text)263+ if len(text) == 0 || len(allowedDirs) == 0 || len(g) == 0 || len(g[0]) == 0 {264+ return nil, 0265+ }266+267+ height := len(g)268+ width := len(g[0])269+270+ dirs := append([]Direction(nil), allowedDirs...)271+ rng.Shuffle(len(dirs), func(i, j int) {272+ dirs[i], dirs[j] = dirs[j], dirs[i]273+ })274+275+ startX := rng.IntN(width)276+ startY := rng.IntN(height)277+278+ attempts := 0279+ for _, dir := range dirs {280+ dx, dy := dir.Vector()281+ for yStep := range height {282+ y := (startY + yStep) % height283+ for xStep := range width {284+ x := (startX + xStep) % width285+ attempts++286+287+ if !wordInBounds(x, y, dx, dy, len(text), width, height) {288+ continue289+ }290+ if !canPlaceWordAt(g, text, x, y, dx, dy) {291+ continue292+ }83293 84- if existing != ' ' && existing != rune(text[i]) {85- valid = false86- break294+ placeWordAt(g, text, x, y, dx, dy)295+ return newWord(text, x, y, dx, dy, dir), attempts87296 }88297 }298+ }89299 90- if !valid {91- continue92- }300+ return nil, attempts301+}93302 94- for i := range len(text) {95- cx := x + dx*i96- cy := y + dy*i97- g.Set(cx, cy, rune(text[i]))98- }303+func wordInBounds(x, y, dx, dy, wordLen, width, height int) bool {304+ endX := x + dx*(wordLen-1)305+ endY := y + dy*(wordLen-1)306+ return endX >= 0 && endX < width && endY >= 0 && endY < height307+}99308 100- return &Word{101- Text: text,102- Start: Position{X: x, Y: y},103- End: Position{X: endX, Y: endY},104- Direction: dir,105- Found: false,309+func canPlaceWordAt(g grid, text string, x, y, dx, dy int) bool {310+ for i := range len(text) {311+ cx := x + dx*i312+ cy := y + dy*i313+ existing := g.Get(cx, cy)314+ want := rune(text[i])315+ if existing != ' ' && existing != want {316+ return false106317 }107318 }319+ return true320+}108321 109- return nil322+func placeWordAt(g grid, text string, x, y, dx, dy int) {323+ for i := range len(text) {324+ cx := x + dx*i325+ cy := y + dy*i326+ g.Set(cx, cy, rune(text[i]))327+ }328+}329+330+func newWord(text string, x, y, dx, dy int, dir Direction) *Word {331+ endX := x + dx*(len(text)-1)332+ endY := y + dy*(len(text)-1)333+334+ return &Word{335+ Text: text,336+ Start: Position{X: x, Y: y},337+ End: Position{X: endX, Y: endY},338+ Direction: dir,339+ Found: false,340+ }110341 }111342 112343 func fillEmptyCells(g grid) {@@ -2,11 +2,16 @@ package wordsearch
22 33 import (44 "encoding/json"5+ "math/rand/v2"56 "testing"67 78 "github.com/FelineStateMachine/puzzletea/game"89 )910 11+func testRNG(seed uint64) *rand.Rand {12+ return rand.New(rand.NewPCG(seed, seed+1))13+}14+1015 // --- Direction.Vector (P0) ---1116 1217 func TestDirectionVector(t *testing.T) {@@ -462,6 +467,28 @@ func TestTryPlaceWord(t *testing.T) {
462467 })463468 }464469 470+func TestTryPlaceWordSeededWithFallback(t *testing.T) {471+ g := createEmptyGrid(1, 5)472+ rng := testRNG(123)473+474+ word, stats := tryPlaceWordSeededWithFallback(g, "HELLO", []Direction{Right}, 0, rng)475+ if word == nil {476+ t.Fatal("expected fallback to place word")477+ }478+ if !stats.UsedFallback {479+ t.Fatal("expected UsedFallback to be true")480+ }481+ if stats.RandomAttempts != 0 {482+ t.Fatalf("RandomAttempts = %d, want 0", stats.RandomAttempts)483+ }484+ if stats.FallbackAttempts == 0 {485+ t.Fatal("expected fallback attempts to be > 0")486+ }487+ if word.Start != (Position{X: 0, Y: 0}) || word.End != (Position{X: 4, Y: 0}) {488+ t.Fatalf("unexpected placement: start=%v end=%v", word.Start, word.End)489+ }490+}491+465492 // --- selectWords (P2) ---466493 467494 func TestSelectWords(t *testing.T) {@@ -491,6 +518,31 @@ func TestSelectWords(t *testing.T) {
491518 t.Error("expected some words")492519 }493520 })521+522+ t.Run("no duplicates", func(t *testing.T) {523+ rng := testRNG(42)524+ words := selectWordsSeeded(500, 3, 10, rng)525+ seen := make(map[string]struct{}, len(words))526+527+ for _, w := range words {528+ if _, ok := seen[w]; ok {529+ t.Fatalf("duplicate word selected: %q", w)530+ }531+ seen[w] = struct{}{}532+ }533+ })534+}535+536+func TestOrderWordsForPlacement(t *testing.T) {537+ words := []string{"AA", "BBBB", "CCC", "DDDD", "E"}538+ orderWordsForPlacement(words)539+540+ want := []string{"BBBB", "DDDD", "CCC", "AA", "E"}541+ for i := range want {542+ if words[i] != want[i] {543+ t.Fatalf("words[%d]=%q, want %q", i, words[i], want[i])544+ }545+ }494546 }495547 496548 // --- fillEmptyCells (P2) ---@@ -566,6 +618,231 @@ func TestGenerateWordSearch(t *testing.T) {
566618 })567619 }568620 621+// --- generation integrity regression (P0) ---622+623+func TestGenerateWordSearchSeeded_WordCountRegression(t *testing.T) {624+ tests := []struct {625+ name string626+ width int627+ height int628+ wordCount int629+ minWordLen int630+ maxWordLen int631+ allowedDirs []Direction632+ minPlaced int633+ }{634+ {635+ name: "easy_10x10",636+ width: 10,637+ height: 10,638+ wordCount: 6,639+ minWordLen: 3,640+ maxWordLen: 5,641+ allowedDirs: []Direction{Right, Down, DownRight},642+ minPlaced: 6,643+ },644+ {645+ name: "medium_15x15",646+ width: 15,647+ height: 15,648+ wordCount: 10,649+ minWordLen: 4,650+ maxWordLen: 7,651+ allowedDirs: []Direction{Right, Down, DownRight, DownLeft, Left, Up},652+ minPlaced: 10,653+ },654+ {655+ name: "hard_20x20",656+ width: 20,657+ height: 20,658+ wordCount: 15,659+ minWordLen: 5,660+ maxWordLen: 10,661+ allowedDirs: []Direction{Right, Down, DownRight, DownLeft, Left, Up, UpRight, UpLeft},662+ minPlaced: 13,663+ },664+ }665+666+ for _, tt := range tests {667+ t.Run(tt.name, func(t *testing.T) {668+ for seed := range 128 {669+ rng := testRNG(uint64(seed + 1))670+ _, words := GenerateWordSearchSeeded(671+ tt.width,672+ tt.height,673+ tt.wordCount,674+ tt.minWordLen,675+ tt.maxWordLen,676+ tt.allowedDirs,677+ rng,678+ )679+680+ if len(words) > tt.wordCount {681+ t.Fatalf("seed %d: len(words)=%d, want <= %d", seed, len(words), tt.wordCount)682+ }683+ if len(words) < tt.minPlaced {684+ t.Fatalf("seed %d: len(words)=%d, want >= %d", seed, len(words), tt.minPlaced)685+ }686+ }687+ })688+ }689+}690+691+func TestGenerateWordSearchSeeded_PlacementValidityRegression(t *testing.T) {692+ tests := []struct {693+ name string694+ width int695+ height int696+ wordCount int697+ minWordLen int698+ maxWordLen int699+ allowedDirs []Direction700+ }{701+ {702+ name: "easy_10x10",703+ width: 10,704+ height: 10,705+ wordCount: 6,706+ minWordLen: 3,707+ maxWordLen: 5,708+ allowedDirs: []Direction{Right, Down, DownRight},709+ },710+ {711+ name: "medium_15x15",712+ width: 15,713+ height: 15,714+ wordCount: 10,715+ minWordLen: 4,716+ maxWordLen: 7,717+ allowedDirs: []Direction{Right, Down, DownRight, DownLeft, Left, Up},718+ },719+ {720+ name: "hard_20x20",721+ width: 20,722+ height: 20,723+ wordCount: 15,724+ minWordLen: 5,725+ maxWordLen: 10,726+ allowedDirs: []Direction{Right, Down, DownRight, DownLeft, Left, Up, UpRight, UpLeft},727+ },728+ }729+730+ for _, tt := range tests {731+ t.Run(tt.name, func(t *testing.T) {732+ for seed := range 64 {733+ rng := testRNG(uint64(seed + 1))734+ g, words := GenerateWordSearchSeeded(735+ tt.width,736+ tt.height,737+ tt.wordCount,738+ tt.minWordLen,739+ tt.maxWordLen,740+ tt.allowedDirs,741+ rng,742+ )743+ assertWordPlacementsValid(t, g, words)744+ }745+ })746+ }747+}748+749+func assertWordPlacementsValid(t *testing.T, g grid, words []Word) {750+ t.Helper()751+752+ type cell struct {753+ x int754+ y int755+ }756+757+ occupied := map[cell]rune{}758+759+ for wi, w := range words {760+ positions := w.Positions()761+ if len(positions) != len(w.Text) {762+ t.Fatalf("word[%d]=%q positions=%d, want %d", wi, w.Text, len(positions), len(w.Text))763+ }764+ if positions[0] != w.Start {765+ t.Fatalf("word[%d]=%q start=%v, want %v", wi, w.Text, w.Start, positions[0])766+ }767+ if positions[len(positions)-1] != w.End {768+ t.Fatalf("word[%d]=%q end=%v, want %v", wi, w.Text, w.End, positions[len(positions)-1])769+ }770+771+ for i, pos := range positions {772+ if pos.X < 0 || pos.X >= len(g[0]) || pos.Y < 0 || pos.Y >= len(g) {773+ t.Fatalf("word[%d]=%q pos[%d]=(%d,%d) out of bounds", wi, w.Text, i, pos.X, pos.Y)774+ }775+776+ want := rune(w.Text[i])777+ if got := g.Get(pos.X, pos.Y); got != want {778+ t.Fatalf(779+ "word[%d]=%q pos[%d]=(%d,%d) grid=%q, want %q",780+ wi,781+ w.Text,782+ i,783+ pos.X,784+ pos.Y,785+ got,786+ want,787+ )788+ }789+790+ key := cell{x: pos.X, y: pos.Y}791+ if prev, ok := occupied[key]; ok && prev != want {792+ t.Fatalf(793+ "conflicting overlap at (%d,%d): had %q, word[%d]=%q wants %q",794+ pos.X,795+ pos.Y,796+ prev,797+ wi,798+ w.Text,799+ want,800+ )801+ }802+ occupied[key] = want803+ }804+ }805+}806+807+func TestGenerateWordSearchSeeded_PlacementStats(t *testing.T) {808+ rng := testRNG(99)809+ _, words, stats := generateWordSearchSeededWithStats(810+ 20,811+ 20,812+ 15,813+ 5,814+ 10,815+ []Direction{Right, Down, DownRight, DownLeft, Left, Up, UpRight, UpLeft},816+ rng,817+ )818+819+ if stats.TargetWords != 15 {820+ t.Fatalf("TargetWords = %d, want 15", stats.TargetWords)821+ }822+ if stats.PlacedWords != len(words) {823+ t.Fatalf("PlacedWords = %d, want %d", stats.PlacedWords, len(words))824+ }825+ if stats.PlacedWords+stats.FailedWords != stats.TargetWords {826+ t.Fatalf(827+ "placed+failed = %d, want %d",828+ stats.PlacedWords+stats.FailedWords,829+ stats.TargetWords,830+ )831+ }832+ if stats.RandomAttempts < stats.TargetWords {833+ t.Fatalf("RandomAttempts = %d, want >= %d", stats.RandomAttempts, stats.TargetWords)834+ }835+ if stats.FallbackPlaced > stats.FallbackUsed {836+ t.Fatalf("FallbackPlaced = %d, want <= FallbackUsed = %d", stats.FallbackPlaced, stats.FallbackUsed)837+ }838+ if got := stats.SuccessRate(); got <= 0 || got > 1 {839+ t.Fatalf("SuccessRate = %.4f, want (0,1]", got)840+ }841+ if got := stats.AttemptsPerWord(); got <= 0 {842+ t.Fatalf("AttemptsPerWord = %.4f, want > 0", got)843+ }844+}845+569846 // --- screenToGrid (P1) ---570847 571848 func TestScreenToGrid(t *testing.T) {