clone

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

Merge pull request #30 from FelineStateMachine/feat/daily-puzzles

FelineStateMachine committed 87f57c3
feat: add daily puzzles with seeded generation
commit
87f57c3c7a24fb0f825cbbcacd22e573828d3f04
parent
5e2652b052dda169d9d262172764102795d8db9f
parent
c9705e5c2f3c1126a93e8b9bbaecf7bffc9b2b39
author
FelineStateMachine <dami.etoile@gmail.com>
31 files changed, +870 -103
Adaily.go86++++++++++++++++
Mgame/gamer.go9++
Mhashiwokakero/Gamemode.go21++++-
Mhashiwokakero/generator.go40+++++---
Mhashiwokakero/style.go9++-
Mhitori/Export.go1+
Mhitori/Gamemode.go22++++-
Mhitori/Model.go3+-
Mhitori/generator.go45++++++---
Mhitori/hitori_test.go160++++++++++++++++++++++++++++++
Mhitori/solver.go142+++++++++++++++++++++++++++
Mhitori/style.go31++++--
Mlightsout/Gamemode.go17+++-
Mlightsout/Model.go12++-
Mlightsout/generator.go9+-
Mlightsout/style.go9++-
Mmodel.go106+++++++++++++++++++-
Mnamegen/namegen.go8++
Mnonogram/Gamemode.go18+++-
Mnonogram/generator.go16++-
Mnonogram/style.go18++-
Mstore/store.go29+++++
Msudoku/Gamemode.go17+++-
Msudoku/generator.go22+++-
Mtakuzu/Gamemode.go19+++-
Mtakuzu/generator.go25++++-
Mtakuzu/style.go17++-
Mvhs/help.tape2+-
Mvhs/menu.tape7+-
Mwordsearch/Gamemode.go17+++-
Mwordsearch/generator.go36+++++--
A daily.go +86 -0
@@ -0,0 +1,86 @@
1+package main2+3+import (4+ "hash/fnv"5+ "math/rand/v2"6+ "time"7+8+ "github.com/FelineStateMachine/puzzletea/game"9+ "github.com/FelineStateMachine/puzzletea/hashiwokakero"10+ "github.com/FelineStateMachine/puzzletea/hitori"11+ "github.com/FelineStateMachine/puzzletea/lightsout"12+ "github.com/FelineStateMachine/puzzletea/namegen"13+ "github.com/FelineStateMachine/puzzletea/nonogram"14+ "github.com/FelineStateMachine/puzzletea/sudoku"15+ "github.com/FelineStateMachine/puzzletea/takuzu"16+ "github.com/FelineStateMachine/puzzletea/wordsearch"17+18+ "github.com/charmbracelet/bubbles/list"19+)20+21+// dailyEntry pairs a SeededSpawner with metadata for the eligible daily pool.22+type dailyEntry struct {23+ spawner game.SeededSpawner24+ gameType string25+ mode string26+}27+28+// dailyPool maps game type names to their DailyModes exports.29+// Each package owns which of its modes are eligible for daily rotation.30+var dailyPool = []struct {31+ gameType string32+ modes []list.Item33+}{34+ {"Nonogram", nonogram.DailyModes},35+ {"Sudoku", sudoku.DailyModes},36+ {"Takuzu", takuzu.DailyModes},37+ {"Hashiwokakero", hashiwokakero.DailyModes},38+ {"Hitori", hitori.DailyModes},39+ {"Lights Out", lightsout.DailyModes},40+ {"Word Search", wordsearch.DailyModes},41+}42+43+// eligibleDailyModes is the flattened pool built from each package's DailyModes.44+var eligibleDailyModes = buildEligibleDailyModes()45+46+func buildEligibleDailyModes() []dailyEntry {47+ var entries []dailyEntry48+ for _, p := range dailyPool {49+ for _, item := range p.modes {50+ s := item.(game.SeededSpawner)51+ entries = append(entries, dailyEntry{52+ spawner: s,53+ gameType: p.gameType,54+ mode: item.(game.Mode).Title(),55+ })56+ }57+ }58+ return entries59+}60+61+// dailySeed returns a deterministic int64 seed derived from the date.62+func dailySeed(date time.Time) uint64 {63+ dateStr := date.Format("2006-01-02")64+ h := fnv.New64a()65+ h.Write([]byte(dateStr))66+ return h.Sum64()67+}68+69+// dailyRNG creates a deterministic RNG seeded from the given date.70+func dailyRNG(date time.Time) *rand.Rand {71+ seed := dailySeed(date)72+ return rand.New(rand.NewPCG(seed, seed))73+}74+75+// dailyName generates the daily puzzle name in the format:76+// "Daily Feb 14 26 - amber-falcon"77+func dailyName(date time.Time, rng *rand.Rand) string {78+ return "Daily " + date.Format("Jan _2 06") + " - " + namegen.GenerateSeeded(rng)79+}80+81+// dailyMode selects the daily mode from the eligible pool using the seeded RNG.82+// Returns the spawner, game type name, and mode title.83+func dailyMode(rng *rand.Rand) (game.SeededSpawner, string, string) {84+ entry := eligibleDailyModes[rng.IntN(len(eligibleDailyModes))]85+ return entry.spawner, entry.gameType, entry.mode86+}
M game/gamer.go +9 -0
M hashiwokakero/Gamemode.go +19 -2
@@ -1,6 +1,8 @@
11 package hashiwokakero22 33 import (4+ "math/rand/v2"5+46 "github.com/FelineStateMachine/puzzletea/game"57 "github.com/charmbracelet/bubbles/list"68 )
@@ -21,8 +23,9 @@ type HashiMode struct {
2123 }2224 2325 var (24- _ game.Mode = HashiMode{} // compile-time interface check25- _ game.Spawner = HashiMode{} // compile-time interface check26+ _ game.Mode = HashiMode{} // compile-time interface check27+ _ game.Spawner = HashiMode{} // compile-time interface check28+ _ game.SeededSpawner = HashiMode{} // compile-time interface check2629 )2730 2831 func NewMode(title, description string, width, height, minIslands, maxIslands int) HashiMode {
@@ -43,6 +46,14 @@ func (h HashiMode) Spawn() (game.Gamer, error) {
4346 return New(h, puzzle), nil4447 }4548 49+func (h HashiMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {50+ puzzle, err := GeneratePuzzleSeeded(h, rng)51+ if err != nil {52+ return nil, err53+ }54+ return New(h, puzzle), nil55+}56+4657 var Modes = []list.Item{4758 NewMode("Easy 7x7", "7x7 grid with 8-10 islands.", 7, 7, 8, 10),4859 NewMode("Medium 7x7", "7x7 grid with 12-15 islands.", 7, 7, 12, 15),
@@ -57,3 +68,9 @@ var Modes = []list.Item{
5768 NewMode("Medium 13x13", "13x13 grid with 42-51 islands.", 13, 13, 42, 51),5869 NewMode("Hard 13x13", "13x13 grid with 59-68 islands.", 13, 13, 59, 68),5970 }71+72+// DailyModes is the subset of Modes eligible for daily puzzle rotation.73+var DailyModes = []list.Item{74+ Modes[3], // Easy 9x975+ Modes[1], // Medium 7x776+}
M hashiwokakero/generator.go +25 -15
@@ -9,10 +9,15 @@ const maxGenerateAttempts = 200
99 1010 // GeneratePuzzle creates a solvable hashiwokakero puzzle for the given mode.1111 func GeneratePuzzle(mode HashiMode) (Puzzle, error) {12- islandCount := mode.MinIslands + rand.IntN(mode.MaxIslands-mode.MinIslands+1)12+ rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))13+ return GeneratePuzzleSeeded(mode, rng)14+}15+16+func GeneratePuzzleSeeded(mode HashiMode, rng *rand.Rand) (Puzzle, error) {17+ islandCount := mode.MinIslands + rng.IntN(mode.MaxIslands-mode.MinIslands+1)1318 1419 for range maxGenerateAttempts {15- p := tryGenerate(mode.Width, mode.Height, islandCount)20+ p := tryGenerateSeeded(mode.Width, mode.Height, islandCount, rng)1621 if p != nil {1722 return *p, nil1823 }
@@ -20,8 +25,8 @@ func GeneratePuzzle(mode HashiMode) (Puzzle, error) {
2025 return Puzzle{}, errors.New("failed to generate puzzle after maximum attempts")2126 }2227 23-func tryGenerate(width, height, islandCount int) *Puzzle {24- islands := placeIslands(width, height, islandCount)28+func tryGenerateSeeded(width, height, islandCount int, rng *rand.Rand) *Puzzle {29+ islands := placeIslandsSeeded(width, height, islandCount, rng)2530 if islands == nil {2631 return nil2732 }
@@ -33,12 +38,12 @@ func tryGenerate(width, height, islandCount int) *Puzzle {
3338 }3439 3540 // Build a spanning tree to ensure connectivity36- if !buildSpanningTree(p) {41+ if !buildSpanningTreeSeeded(p, rng) {3742 return nil3843 }3944 4045 // Add extra bridges for complexity41- addExtraBridges(p)46+ addExtraBridgesSeeded(p, rng)4247 4348 // Set required counts from the solution4449 for i := range p.Islands {
@@ -62,6 +67,11 @@ func tryGenerate(width, height, islandCount int) *Puzzle {
6267 // placeIslands randomly places islands on the grid.6368 // Islands must share rows or columns with at least one other island to allow bridges.6469 func placeIslands(width, height, count int) []Island {70+ rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))71+ return placeIslandsSeeded(width, height, count, rng)72+}73+74+func placeIslandsSeeded(width, height, count int, rng *rand.Rand) []Island {6575 if count <= 0 {6676 return nil6777 }
@@ -73,8 +83,8 @@ func placeIslands(width, height, count int) []Island {
7383 for i := range count {7484 placed := false7585 for range 200 {76- x := rand.IntN(width)77- y := rand.IntN(height)86+ x := rng.IntN(width)87+ y := rng.IntN(height)7888 p := pos{x, y}7989 8090 if occupied[p] {
@@ -111,9 +121,9 @@ func placeIslands(width, height, count int) []Island {
111121 return islands112122 }113123 114-// buildSpanningTree connects all islands using a randomized approach.124+// buildSpanningTreeSeeded connects all islands using a randomized approach.115125 // Returns false if unable to connect all islands.116-func buildSpanningTree(p *Puzzle) bool {126+func buildSpanningTreeSeeded(p *Puzzle, rng *rand.Rand) bool {117127 if len(p.Islands) <= 1 {118128 return true119129 }
@@ -139,7 +149,7 @@ func buildSpanningTree(p *Puzzle) bool {
139149 }140150 141151 // Shuffle pairs for randomness142- rand.Shuffle(len(pairs), func(i, j int) {152+ rng.Shuffle(len(pairs), func(i, j int) {143153 pairs[i], pairs[j] = pairs[j], pairs[i]144154 })145155
@@ -181,7 +191,7 @@ func buildSpanningTree(p *Puzzle) bool {
181191 if p.WouldCross(pr.id1, pr.id2) {182192 continue183193 }184- count := 1 + rand.IntN(2) // 1 or 2 bridges194+ count := 1 + rng.IntN(2) // 1 or 2 bridges185195 p.SetBridge(pr.id1, pr.id2, count)186196 union(pr.id1, pr.id2)187197 edgesAdded++
@@ -193,8 +203,8 @@ func buildSpanningTree(p *Puzzle) bool {
193203 return p.IsConnected()194204 }195205 196-// addExtraBridges adds additional bridges beyond the spanning tree for complexity.197-func addExtraBridges(p *Puzzle) {206+// addExtraBridgesSeeded adds additional bridges beyond the spanning tree for complexity.207+func addExtraBridgesSeeded(p *Puzzle, rng *rand.Rand) {198208 var pairs []struct{ id1, id2 int }199209 200210 for i := 0; i < len(p.Islands); i++ {
@@ -219,7 +229,7 @@ func addExtraBridges(p *Puzzle) {
219229 }220230 }221231 222- rand.Shuffle(len(pairs), func(i, j int) {232+ rng.Shuffle(len(pairs), func(i, j int) {223233 pairs[i], pairs[j] = pairs[j], pairs[i]224234 })225235
M hashiwokakero/style.go +8 -1
M hitori/Export.go +1 -0
M hitori/Gamemode.go +19 -3
M hitori/Model.go +2 -1
M hitori/generator.go +30 -15
@@ -11,11 +11,16 @@ type cellPos struct{ x, y int }
1111 // exactly once in each row and column. Uses cyclic shifts with row and1212 // column shuffling.1313 func generateLatinSquare(size int) grid {14+ rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))15+ return generateLatinSquareSeeded(size, rng)16+}17+18+func generateLatinSquareSeeded(size int, rng *rand.Rand) grid {1419 firstRow := make([]rune, size)1520 for i := range size {1621 firstRow[i] = rune('1' + i)1722 }18- rand.Shuffle(len(firstRow), func(i, j int) {23+ rng.Shuffle(len(firstRow), func(i, j int) {1924 firstRow[i], firstRow[j] = firstRow[j], firstRow[i]2025 })2126
@@ -31,7 +36,7 @@ func generateLatinSquare(size int) grid {
3136 for i := range size {3237 cols[i] = i3338 }34- rand.Shuffle(len(cols), func(i, j int) {39+ rng.Shuffle(len(cols), func(i, j int) {3540 cols[i], cols[j] = cols[j], cols[i]3641 })3742
@@ -39,7 +44,7 @@ func generateLatinSquare(size int) grid {
3944 for i := range size {4045 rowOrder[i] = i4146 }42- rand.Shuffle(len(rowOrder), func(i, j int) {47+ rng.Shuffle(len(rowOrder), func(i, j int) {4348 rowOrder[i], rowOrder[j] = rowOrder[j], rowOrder[i]4449 })4550
@@ -57,6 +62,11 @@ func generateLatinSquare(size int) grid {
5762 // generateValidMask determines which cells should be black. The mask5863 // satisfies: no adjacent black cells, and all white cells connected.5964 func generateValidMask(size int, blackRatio float64) [][]bool {65+ rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))66+ return generateValidMaskSeeded(size, blackRatio, rng)67+}68+69+func generateValidMaskSeeded(size int, blackRatio float64, rng *rand.Rand) [][]bool {6070 mask := make([][]bool, size)6171 for y := range size {6272 mask[y] = make([]bool, size)
@@ -70,7 +80,7 @@ func generateValidMask(size int, blackRatio float64) [][]bool {
7080 positions = append(positions, cellPos{x, y})7181 }7282 }73- rand.Shuffle(len(positions), func(i, j int) {83+ rng.Shuffle(len(positions), func(i, j int) {7484 positions[i], positions[j] = positions[j], positions[i]7585 })7686
@@ -158,10 +168,10 @@ func whiteCellsConnected(mask [][]bool, size int) bool {
158168 return visitedCount == whiteCount159169 }160170 161-// constructPuzzle builds the initial puzzle grid from a Latin square and mask.171+// constructPuzzleSeeded builds the initial puzzle grid from a Latin square and mask.162172 // White cells keep their original number. Black cells get a duplicate from163173 // a white cell in the same row.164-func constructPuzzle(baseGrid grid, mask [][]bool) grid {174+func constructPuzzleSeeded(baseGrid grid, mask [][]bool, rng *rand.Rand) grid {165175 size := len(baseGrid)166176 puzzle := baseGrid.clone()167177
@@ -178,7 +188,7 @@ func constructPuzzle(baseGrid grid, mask [][]bool) grid {
178188 }179189 }180190 if len(rowNums) > 0 {181- puzzle[y][x] = rowNums[rand.IntN(len(rowNums))]191+ puzzle[y][x] = rowNums[rng.IntN(len(rowNums))]182192 continue183193 }184194 // Fallback: column numbers.
@@ -189,17 +199,17 @@ func constructPuzzle(baseGrid grid, mask [][]bool) grid {
189199 }190200 }191201 if len(colNums) > 0 {192- puzzle[y][x] = colNums[rand.IntN(len(colNums))]202+ puzzle[y][x] = colNums[rng.IntN(len(colNums))]193203 }194204 }195205 }196206 return puzzle197207 }198208 199-// refinePuzzle tries different number assignments for black cells to achieve209+// refinePuzzleSeeded tries different number assignments for black cells to achieve200210 // a unique solution. It iterates through each black cell trying all possible201211 // duplicate values, checking if the resulting puzzle has exactly one solution.202-func refinePuzzle(puzzle grid, mask [][]bool, size int) (grid, bool) {212+func refinePuzzleSeeded(puzzle grid, mask [][]bool, size int, rng *rand.Rand) (grid, bool) {203213 // Collect black cell positions.204214 var blackCells []cellPos205215 for y := range size {
@@ -219,7 +229,7 @@ func refinePuzzle(puzzle grid, mask [][]bool, size int) (grid, bool) {
219229 for _, bc := range blackCells {220230 // Collect candidate numbers for this black cell.221231 candidates := cellCandidates(refined, mask, size, bc.x, bc.y)222- rand.Shuffle(len(candidates), func(i, j int) {232+ rng.Shuffle(len(candidates), func(i, j int) {223233 candidates[i], candidates[j] = candidates[j], candidates[i]224234 })225235
@@ -358,12 +368,17 @@ func stateToMarks(st [][]solverState, size int) [][]cellMark {
358368 // Generate creates a Hitori puzzle of the given size. Returns the puzzle359369 // numbers grid or an error if generation fails after all attempts.360370 func Generate(size int, blackRatio float64) (grid, error) {371+ rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))372+ return GenerateSeeded(size, blackRatio, rng)373+}374+375+func GenerateSeeded(size int, blackRatio float64, rng *rand.Rand) (grid, error) {361376 const maxAttempts = 200362377 363378 for range maxAttempts {364- baseGrid := generateLatinSquare(size)365- mask := generateValidMask(size, blackRatio)366- puzzle := constructPuzzle(baseGrid, mask)379+ baseGrid := generateLatinSquareSeeded(size, rng)380+ mask := generateValidMaskSeeded(size, blackRatio, rng)381+ puzzle := constructPuzzleSeeded(baseGrid, mask, rng)367382 368383 // First try the random construction.369384 if countPuzzleSolutions(puzzle, size, 2) == 1 {
@@ -371,7 +386,7 @@ func Generate(size int, blackRatio float64) (grid, error) {
371386 }372387 373388 // Refine by trying different values for black cells.374- refined, ok := refinePuzzle(puzzle, mask, size)389+ refined, ok := refinePuzzleSeeded(puzzle, mask, size, rng)375390 if ok {376391 return refined, nil377392 }
M hitori/hitori_test.go +160 -0
@@ -297,6 +297,166 @@ func TestIsValidSolution_InvalidAdjacent(t *testing.T) {
297297 }298298 }299299 300+// --- computeConflicts (P0) ---301+302+func TestComputeConflicts_NoConflicts(t *testing.T) {303+ numbers := makeGrid("123", "231", "312")304+ marks := makeMarks("...", "...", "...")305+ conflicts := computeConflicts(numbers, marks, 3)306+307+ for y := range 3 {308+ for x := range 3 {309+ if conflicts[y][x] {310+ t.Errorf("unexpected conflict at (%d,%d)", x, y)311+ }312+ }313+ }314+}315+316+func TestComputeConflicts_RowDuplicate(t *testing.T) {317+ // Row 0 has two unshaded 1s at (0,0) and (1,0).318+ numbers := makeGrid("113", "231", "312")319+ marks := makeMarks("...", "...", "...")320+ conflicts := computeConflicts(numbers, marks, 3)321+322+ if !conflicts[0][0] {323+ t.Error("expected conflict at (0,0)")324+ }325+ if !conflicts[0][1] {326+ t.Error("expected conflict at (1,0)")327+ }328+ if conflicts[0][2] {329+ t.Error("unexpected conflict at (2,0)")330+ }331+}332+333+func TestComputeConflicts_ColDuplicate(t *testing.T) {334+ // Column 0 has two unshaded 1s at (0,0) and (0,2).335+ numbers := makeGrid("123", "231", "112")336+ marks := makeMarks("...", "...", "...")337+ conflicts := computeConflicts(numbers, marks, 3)338+339+ if !conflicts[0][0] {340+ t.Error("expected conflict at (0,0)")341+ }342+ if !conflicts[2][0] {343+ t.Error("expected conflict at (0,2)")344+ }345+}346+347+func TestComputeConflicts_ShadedRemovesDuplicate(t *testing.T) {348+ // Row 0 has duplicate 1s but one is shaded.349+ numbers := makeGrid("113", "231", "312")350+ marks := makeMarks("X..", "...", "...")351+ conflicts := computeConflicts(numbers, marks, 3)352+353+ if conflicts[0][0] {354+ t.Error("shaded cell should not cause duplicate conflict")355+ }356+ if conflicts[0][2] {357+ t.Error("no duplicate after shading, should not conflict")358+ }359+}360+361+func TestComputeConflicts_AdjacentShaded(t *testing.T) {362+ numbers := makeGrid("123", "231", "312")363+ marks := makeMarks("XX.", "...", "...")364+ conflicts := computeConflicts(numbers, marks, 3)365+366+ if !conflicts[0][0] {367+ t.Error("expected conflict at (0,0) for adjacent shaded")368+ }369+ if !conflicts[0][1] {370+ t.Error("expected conflict at (1,0) for adjacent shaded")371+ }372+}373+374+func TestComputeConflicts_AdjacentShadedVertical(t *testing.T) {375+ numbers := makeGrid("123", "231", "312")376+ marks := makeMarks("X..", "X..", "...")377+ conflicts := computeConflicts(numbers, marks, 3)378+379+ if !conflicts[0][0] {380+ t.Error("expected conflict at (0,0) for vertical adjacent shaded")381+ }382+ if !conflicts[1][0] {383+ t.Error("expected conflict at (0,1) for vertical adjacent shaded")384+ }385+}386+387+func TestComputeConflicts_DiagonalShadedOK(t *testing.T) {388+ numbers := makeGrid("123", "231", "312")389+ marks := makeMarks("X..", ".X.", "..X")390+ conflicts := computeConflicts(numbers, marks, 3)391+392+ // Diagonal shaded cells are fine, but check connectivity.393+ // The white cells form: (1,0),(2,0),(0,1),(2,1),(0,2),(1,2)394+ // These are all connected, so no adjacency or connectivity conflicts.395+ if conflicts[0][0] || conflicts[1][1] || conflicts[2][2] {396+ t.Error("diagonal shaded cells should not conflict with each other")397+ }398+}399+400+func TestComputeConflicts_DisconnectedWhite(t *testing.T) {401+ // Shaded column splits white cells into two groups.402+ // Left side: (0,0),(0,1),(0,2) -- 3 cells403+ // Right side: (2,0),(2,1),(2,2) -- 3 cells404+ numbers := makeGrid("123", "231", "312")405+ marks := makeMarks(".X.", ".X.", ".X.")406+ conflicts := computeConflicts(numbers, marks, 3)407+408+ // One group of 3 is the largest, the other 3 are disconnected.409+ // Both sides are size 3 so the first found (left) is the largest410+ // by discovery order and the right side gets flagged (or vice versa).411+ // At least one group of 3 should be flagged.412+ disconnectedCount := 0413+ for y := range 3 {414+ for x := range 3 {415+ if marks[y][x] != shaded && conflicts[y][x] {416+ disconnectedCount++417+ }418+ }419+ }420+ if disconnectedCount != 3 {421+ t.Errorf("expected 3 disconnected white cells flagged, got %d", disconnectedCount)422+ }423+}424+425+func TestComputeConflicts_MultipleViolations(t *testing.T) {426+ // Adjacent shaded AND row duplicate at the same time.427+ numbers := makeGrid("1123", "2341", "3124", "4213")428+ marks := makeMarks("XX..", "....", "....", "....")429+ conflicts := computeConflicts(numbers, marks, 4)430+431+ // (0,0) and (1,0) are adjacent shaded.432+ if !conflicts[0][0] {433+ t.Error("expected adjacent shaded conflict at (0,0)")434+ }435+ if !conflicts[0][1] {436+ t.Error("expected adjacent shaded conflict at (1,0)")437+ }438+}439+440+func TestComputeConflicts_EmptyBoard(t *testing.T) {441+ // No shaded cells -- the entire board is one connected white region.442+ // There may be duplicate conflicts but no adjacency or connectivity issues.443+ numbers := makeGrid("123", "123", "123")444+ marks := makeMarks("...", "...", "...")445+ conflicts := computeConflicts(numbers, marks, 3)446+447+ // Rows all have unique numbers, but columns have duplicates:448+ // col 0: 1,1,1 -- all three conflict.449+ // col 1: 2,2,2 -- all three conflict.450+ // col 2: 3,3,3 -- all three conflict.451+ for y := range 3 {452+ for x := range 3 {453+ if !conflicts[y][x] {454+ t.Errorf("expected column duplicate conflict at (%d,%d)", x, y)455+ }456+ }457+ }458+}459+300460 // --- Latin square generation (P1) ---301461 302462 func TestGenerateLatinSquare(t *testing.T) {
M hitori/solver.go +142 -0
@@ -120,3 +120,145 @@ func isValidSolution(numbers grid, marks [][]cellMark, size int) bool {
120120 hasNoAdjacentShaded(marks, size) &&121121 allWhiteConnected(marks, size)122122 }123+124+// computeConflicts returns a grid of booleans indicating which cells are125+// involved in a rule violation. A cell is flagged if it participates in a126+// row/column duplicate among unshaded cells, if it is a shaded cell adjacent127+// to another shaded cell, or if it is an unshaded cell disconnected from the128+// largest connected white region.129+func computeConflicts(numbers grid, marks [][]cellMark, size int) [][]bool {130+ conflicts := make([][]bool, size)131+ for y := range size {132+ conflicts[y] = make([]bool, size)133+ }134+135+ markDuplicateConflicts(numbers, marks, conflicts, size)136+ markAdjacentShadedConflicts(marks, conflicts, size)137+ markDisconnectedWhiteConflicts(marks, conflicts, size)138+139+ return conflicts140+}141+142+// markDuplicateConflicts flags unshaded cells that share a number with143+// another unshaded cell in the same row or column.144+func markDuplicateConflicts(numbers grid, marks [][]cellMark, conflicts [][]bool, size int) {145+ // Check rows.146+ for y := range size {147+ seen := map[rune][]int{}148+ for x := range size {149+ if marks[y][x] == shaded {150+ continue151+ }152+ num := numbers[y][x]153+ seen[num] = append(seen[num], x)154+ }155+ for _, positions := range seen {156+ if len(positions) > 1 {157+ for _, x := range positions {158+ conflicts[y][x] = true159+ }160+ }161+ }162+ }163+164+ // Check columns.165+ for x := range size {166+ seen := map[rune][]int{}167+ for y := range size {168+ if marks[y][x] == shaded {169+ continue170+ }171+ num := numbers[y][x]172+ seen[num] = append(seen[num], y)173+ }174+ for _, positions := range seen {175+ if len(positions) > 1 {176+ for _, y := range positions {177+ conflicts[y][x] = true178+ }179+ }180+ }181+ }182+}183+184+// markAdjacentShadedConflicts flags shaded cells that share an edge with185+// another shaded cell.186+func markAdjacentShadedConflicts(marks [][]cellMark, conflicts [][]bool, size int) {187+ for y := range size {188+ for x := range size {189+ if marks[y][x] != shaded {190+ continue191+ }192+ if x > 0 && marks[y][x-1] == shaded {193+ conflicts[y][x] = true194+ conflicts[y][x-1] = true195+ }196+ if y > 0 && marks[y-1][x] == shaded {197+ conflicts[y][x] = true198+ conflicts[y-1][x] = true199+ }200+ }201+ }202+}203+204+// markDisconnectedWhiteConflicts flags unshaded cells that are not part of the205+// largest orthogonally connected white region.206+func markDisconnectedWhiteConflicts(marks [][]cellMark, conflicts [][]bool, size int) {207+ // Find all connected components of unshaded cells via BFS.208+ type pos struct{ x, y int }209+ visited := make([][]bool, size)210+ for y := range size {211+ visited[y] = make([]bool, size)212+ }213+214+ dirs := [4]pos{{0, -1}, {0, 1}, {-1, 0}, {1, 0}}215+ var components [][]pos216+217+ for sy := range size {218+ for sx := range size {219+ if marks[sy][sx] == shaded || visited[sy][sx] {220+ continue221+ }222+ // BFS to find this component.223+ var component []pos224+ queue := []pos{{sx, sy}}225+ visited[sy][sx] = true226+ for len(queue) > 0 {227+ curr := queue[0]228+ queue = queue[1:]229+ component = append(component, curr)230+ for _, d := range dirs {231+ nx, ny := curr.x+d.x, curr.y+d.y232+ if nx >= 0 && nx < size && ny >= 0 && ny < size &&233+ !visited[ny][nx] && marks[ny][nx] != shaded {234+ visited[ny][nx] = true235+ queue = append(queue, pos{nx, ny})236+ }237+ }238+ }239+ components = append(components, component)240+ }241+ }242+243+ if len(components) <= 1 {244+ return245+ }246+247+ // Find the largest component.248+ largest := 0249+ for i, comp := range components {250+ if len(comp) > len(components[largest]) {251+ largest = i252+ }253+ }254+255+ // Mark all cells NOT in the largest component as conflicts.256+ for i, comp := range components {257+ if i == largest {258+ continue259+ }260+ for _, p := range comp {261+ conflicts[p.y][p.x] = true262+ }263+ }264+}
M hitori/style.go +22 -9
@@ -29,6 +29,15 @@ var (
2929 Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "235"}).3030 Background(lipgloss.AdaptiveColor{Light: "130", Dark: "214"})3131 32+ cursorSolvedStyle = baseStyle.33+ Bold(true).34+ Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "235"}).35+ Background(lipgloss.AdaptiveColor{Light: "28", Dark: "28"})36+37+ conflictStyle = baseStyle.38+ Foreground(lipgloss.AdaptiveColor{Light: "160", Dark: "167"}).39+ Background(lipgloss.AdaptiveColor{Light: "224", Dark: "52"})40+3241 crosshairBG = lipgloss.AdaptiveColor{Light: "254", Dark: "237"}3342 solvedBG = lipgloss.AdaptiveColor{Light: "151", Dark: "22"}3443
@@ -41,7 +50,7 @@ var (
4150 4251 const cellWidth = 34352 44-func cellView(num rune, mark cellMark, isCursor, inCursorRow, inCursorCol, solved bool) string {53+func cellView(num rune, mark cellMark, isCursor, inCursorRow, inCursorCol, solved, conflict bool) string {4554 var s lipgloss.Style4655 var display string4756
@@ -57,22 +66,26 @@ func cellView(num rune, mark cellMark, isCursor, inCursorRow, inCursorCol, solve
5766 display = fmt.Sprintf(" %c ", num)5867 }5968 60- if isCursor && !solved {69+ // Priority: cursor+solved > cursor > conflict > crosshair > solved > base.70+ if isCursor && solved {71+ s = cursorSolvedStyle72+ } else if isCursor {6173 s = s.Background(cursorStyle.GetBackground()).6274 Foreground(cursorStyle.GetForeground()).6375 Bold(true)64- } else if !solved && (inCursorRow || inCursorCol) {65- s = s.Background(crosshairBG)66- }67-68- if solved {76+ } else if solved {6977 s = s.Background(solvedBG)78+ } else if conflict {79+ s = s.Background(conflictStyle.GetBackground()).80+ Foreground(conflictStyle.GetForeground())81+ } else if inCursorRow || inCursorCol {82+ s = s.Background(crosshairBG)7083 }7184 7285 return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(display)7386 }7487 75-func gridView(numbers grid, marks [][]cellMark, c game.Cursor, solved bool) string {88+func gridView(numbers grid, marks [][]cellMark, c game.Cursor, solved bool, conflicts [][]bool) string {7689 w := 07790 if len(numbers) > 0 {7891 w = len(numbers[0])
@@ -88,7 +101,7 @@ func gridView(numbers grid, marks [][]cellMark, c game.Cursor, solved bool) stri
88101 for x, num := range row {89102 isCursor := x == c.X && y == c.Y90103 inCursorCol := x == c.X91- cell := cellView(num, marks[y][x], isCursor, inCursorRow, inCursorCol, solved)104+ cell := cellView(num, marks[y][x], isCursor, inCursorRow, inCursorCol, solved, conflicts[y][x])92105 rowCells = append(rowCells, cell)93106 }94107
M lightsout/Gamemode.go +15 -2
M lightsout/Model.go +11 -1
M lightsout/generator.go +7 -2
M lightsout/style.go +8 -1
M model.go +99 -7
@@ -3,6 +3,7 @@ package main
33 import (44 "fmt"55 "log"6+ "math/rand/v2"67 "strconv"78 "time"89
@@ -100,6 +101,12 @@ type model struct {
100101 store *store.Store101102 activeGameID int64102103 completionSaved bool104+105+ // Daily puzzle state106+ dailyPending bool // true while generating a daily puzzle107+ dailyName string // pre-computed daily name108+ dailyGameType string // e.g. "Nonogram"109+ dailyModeTitle string // e.g. "Standard"103110 }104111 105112 func initialModel(s *store.Store) model {
@@ -173,7 +180,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
173180 if m.state == generatingView {174181 switch msg.Type {175182 case tea.KeyEscape:176- m.state = modeSelectView183+ if m.dailyPending {184+ m.dailyPending = false185+ m.state = mainMenuView186+ } else {187+ m.state = modeSelectView188+ }177189 return m, nil178190 case tea.KeyCtrlC:179191 return m, tea.Quit
@@ -257,6 +269,8 @@ func (m model) handleMainMenuEnter() (tea.Model, tea.Cmd) {
257269 return m, nil258270 }259271 switch item.title {272+ case "Daily Puzzle":273+ return m.handleDailyPuzzle()260274 case "Generate":261275 m.state = gameSelectView262276 case "Continue":
@@ -268,6 +282,60 @@ func (m model) handleMainMenuEnter() (tea.Model, tea.Cmd) {
268282 return m, nil269283 }270284 285+func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {286+ today := time.Now()287+ rng := dailyRNG(today)288+ name := dailyName(today, rng)289+290+ // Check if a daily game already exists for today.291+ rec, err := m.store.GetDailyGame(name)292+ if err != nil {293+ log.Printf("failed to check daily game: %v", err)294+ return m, nil295+ }296+297+ if rec != nil {298+ // Daily exists - resume or review it.299+ importFn, ok := game.Registry[rec.GameType]300+ if !ok {301+ log.Printf("unknown game type for daily: %s", rec.GameType)302+ return m, nil303+ }304+ g, err := importFn([]byte(rec.SaveState))305+ if err != nil {306+ log.Printf("failed to import daily game: %v", err)307+ return m, nil308+ }309+ m.game = g.SetTitle(dailyTitle(rec.Name))310+ m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})311+ m.activeGameID = rec.ID312+ m.state = gameView313+ m.completionSaved = rec.Status == store.StatusCompleted314+315+ // Resume abandoned dailies by resetting their status.316+ if rec.Status == store.StatusAbandoned {317+ _ = m.store.UpdateStatus(rec.ID, store.StatusInProgress)318+ }319+ return m, nil320+ }321+322+ // No existing daily - generate a new one.323+ spawner, gameType, modeTitle := dailyMode(rng)324+ m.dailyPending = true325+ m.dailyName = name326+ m.dailyGameType = gameType327+ m.dailyModeTitle = modeTitle328+ m.state = generatingView329+ m.generating = true330+ return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng))331+}332+333+// dailyTitle returns the display title for a daily game.334+// The name already starts with "Daily", so no extra prefix is needed.335+func dailyTitle(name string) string {336+ return name337+}338+271339 func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) {272340 cat, ok := m.gameSelectList.SelectedItem().(game.Category)273341 if !ok {
@@ -299,8 +367,18 @@ func spawnCmd(spawner game.Spawner) tea.Cmd {
299367 }300368 }301369 370+// spawnSeededCmd returns a tea.Cmd that runs SpawnSeeded() off the main goroutine.371+func spawnSeededCmd(spawner game.SeededSpawner, rng *rand.Rand) tea.Cmd {372+ return func() tea.Msg {373+ g, err := spawner.SpawnSeeded(rng)374+ return game.SpawnCompleteMsg{Game: g, Err: err}375+ }376+}377+302378 func (m model) handleSpawnComplete(msg game.SpawnCompleteMsg) (tea.Model, tea.Cmd) {303379 m.generating = false380+ isDaily := m.dailyPending381+ m.dailyPending = false304382 305383 // If the user navigated away while generating, discard the result.306384 if m.state != generatingView {
@@ -309,12 +387,26 @@ func (m model) handleSpawnComplete(msg game.SpawnCompleteMsg) (tea.Model, tea.Cm
309387 310388 if msg.Err != nil {311389 log.Printf("failed to spawn game: %v", msg.Err)312- m.state = modeSelectView390+ if isDaily {391+ m.state = mainMenuView392+ } else {393+ m.state = modeSelectView394+ }313395 return m, nil314396 }315397 316- name := generateUniqueName(m.store)317- m.game = msg.Game.SetTitle(name)398+ var name, gameType, modeTitle string399+ if isDaily {400+ name = m.dailyName401+ gameType = m.dailyGameType402+ modeTitle = m.dailyModeTitle403+ m.game = msg.Game.SetTitle(dailyTitle(name))404+ } else {405+ name = generateUniqueName(m.store)406+ gameType = m.selectedCategory.Name407+ modeTitle = m.mode.Title()408+ m.game = msg.Game.SetTitle(name)409+ }318410 m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})319411 m.state = gameView320412 m.completionSaved = false
@@ -327,8 +419,8 @@ func (m model) handleSpawnComplete(msg game.SpawnCompleteMsg) (tea.Model, tea.Cm
327419 }328420 rec := &store.GameRecord{329421 Name: name,330- GameType: m.selectedCategory.Name,331- Mode: m.mode.Title(),422+ GameType: gameType,423+ Mode: modeTitle,332424 InitialState: string(initialState),333425 SaveState: string(initialState),334426 Status: store.StatusNew,
@@ -467,7 +559,7 @@ func (m *model) initContinueTable() {
467559 m.continueGames = games468560 469561 columns := []table.Column{470- {Title: "Name", Width: 20},562+ {Title: "Name", Width: 34},471563 {Title: "Game", Width: 15},472564 {Title: "Mode", Width: 15},473565 {Title: "Status", Width: 12},
M namegen/namegen.go +8 -0
M nonogram/Gamemode.go +16 -2
M nonogram/generator.go +13 -3
@@ -24,12 +24,17 @@ func cacheKey(length int, hint []int) string {
2424 }2525 2626 func GenerateRandomTomography(mode NonogramMode) Hints {27+ rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))28+ return GenerateRandomTomographySeeded(mode, rng)29+}30+31+func GenerateRandomTomographySeeded(mode NonogramMode, rng *rand.Rand) Hints {2732 maxDim := max(mode.Width, mode.Height)2833 timeout := time.Duration(maxDim) * time.Second2934 3035 for attempt := range maxAttempts {3136 density := lerp(mode.Density, 0.5, float64(attempt)*0.02)32- s := generateRandomState(mode.Height, mode.Width, density)37+ s := generateRandomStateSeeded(mode.Height, mode.Width, density, rng)3338 g := newGrid(s)3439 hints := generateTomography(g)3540 if !isValidPuzzle(hints, mode.Height, mode.Width) {
@@ -50,6 +55,11 @@ func lerp(a, b, t float64) float64 {
5055 }5156 5257 func generateRandomState(h, w int, density float64) state {58+ rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))59+ return generateRandomStateSeeded(h, w, density, rng)60+}61+62+func generateRandomStateSeeded(h, w int, density float64, rng *rand.Rand) state {5363 if h <= 0 || w <= 0 {5464 return ""5565 }
@@ -60,10 +70,10 @@ func generateRandomState(h, w int, density float64) state {
6070 b.Grow((w + 1) * h)6171 6272 for i := range h {63- rowDensity := density + (rand.Float64()-0.5)*0.373+ rowDensity := density + (rng.Float64()-0.5)*0.36474 rowDensity = max(0.05, min(0.95, rowDensity))6575 for range w {66- if rand.Float64() < rowDensity {76+ if rng.Float64() < rowDensity {6777 b.WriteRune(filledTile)6878 } else {6979 b.WriteRune(emptyTile)
M nonogram/style.go +11 -7
M store/store.go +29 -0
M sudoku/Gamemode.go +15 -2
M sudoku/generator.go +18 -4
@@ -2,6 +2,11 @@ package sudoku
22 33 import "math/rand/v2"44 5+// newRNG creates a fresh non-deterministic RNG.6+func newRNG() *rand.Rand {7+ return rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))8+}9+510 // isValid checks whether placing val at (x, y) in the grid is valid.611 func isValid(g *grid, val, x, y int) bool {712 for i := range gridSize {
@@ -25,15 +30,20 @@ func isValid(g *grid, val, x, y int) bool {
2530 2631 // fillGrid fills an empty grid with a random valid sudoku solution using backtracking.2732 func fillGrid(g *grid) bool {33+ rng := newRNG()34+ return fillGridSeeded(g, rng)35+}36+37+func fillGridSeeded(g *grid, rng *rand.Rand) bool {2838 for y := range gridSize {2939 for x := range gridSize {3040 if g[y][x].v == 0 {31- order := rand.Perm(gridSize)41+ order := rng.Perm(gridSize)3242 for _, i := range order {3343 val := i + 13444 if isValid(g, val, x, y) {3545 g[y][x].v = val36- if fillGrid(g) {46+ if fillGridSeeded(g, rng) {3747 return true3848 }3949 g[y][x].v = 0
@@ -85,8 +95,12 @@ func countSolutionsRec(g *grid, limit int) int {
8595 // GenerateProvidedCells generates a random sudoku puzzle with the number of8696 // provided cells determined by the mode's ProvidedCount.8797 func GenerateProvidedCells(m SudokuMode) []cell {98+ return GenerateProvidedCellsSeeded(m, newRNG())99+}100+101+func GenerateProvidedCellsSeeded(m SudokuMode, rng *rand.Rand) []cell {88102 g := newGrid(nil)89- fillGrid(&g)103+ fillGridSeeded(&g, rng)90104 91105 // Build shuffled list of all positions92106 type pos struct{ x, y int }
@@ -96,7 +110,7 @@ func GenerateProvidedCells(m SudokuMode) []cell {
96110 positions = append(positions, pos{x, y})97111 }98112 }99- rand.Shuffle(len(positions), func(i, j int) {113+ rng.Shuffle(len(positions), func(i, j int) {100114 positions[i], positions[j] = positions[j], positions[i]101115 })102116
M takuzu/Gamemode.go +17 -2
M takuzu/generator.go +20 -5
@@ -7,6 +7,11 @@ type cellPos struct{ x, y int }
77 // generateComplete fills an empty grid with a valid Takuzu solution using88 // backtracking with retry on failure.99 func generateComplete(size int) grid {10+ rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))11+ return generateCompleteSeeded(size, rng)12+}13+14+func generateCompleteSeeded(size int, rng *rand.Rand) grid {1015 g := newGrid(createEmptyState(size))1116 1217 const maxRetries = 10
@@ -18,7 +23,7 @@ func generateComplete(size int) grid {
1823 }1924 }2025 21- if fillGrid(g, size) {26+ if fillGridSeeded(g, size, rng) {2227 return g2328 }2429 }
@@ -29,7 +34,7 @@ func generateComplete(size int) grid {
2934 g[y][x] = emptyCell3035 }3136 }32- if fillGrid(g, size) {37+ if fillGridSeeded(g, size, rng) {3338 return g3439 }3540
@@ -37,19 +42,24 @@ func generateComplete(size int) grid {
3742 }3843 3944 func fillGrid(g grid, size int) bool {45+ rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))46+ return fillGridSeeded(g, size, rng)47+}48+49+func fillGridSeeded(g grid, size int, rng *rand.Rand) bool {4050 for y := range size {4151 for x := range size {4252 if g[y][x] != emptyCell {4353 continue4454 }4555 vals := [2]rune{zeroCell, oneCell}46- if rand.IntN(2) == 0 {56+ if rng.IntN(2) == 0 {4757 vals[0], vals[1] = vals[1], vals[0]4858 }4959 for _, v := range vals {5060 if canPlace(g, size, x, y, v) {5161 g[y][x] = v52- if fillGrid(g, size) {62+ if fillGridSeeded(g, size, rng) {5363 return true5464 }5565 g[y][x] = emptyCell
@@ -63,6 +73,11 @@ func fillGrid(g grid, size int) bool {
6373 6474 // generatePuzzle removes cells from a complete grid to create a puzzle with a unique solution.6575 func generatePuzzle(complete grid, size int, prefilled float64) (puzzle grid, provided [][]bool) {76+ rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))77+ return generatePuzzleSeeded(complete, size, prefilled, rng)78+}79+80+func generatePuzzleSeeded(complete grid, size int, prefilled float64, rng *rand.Rand) (puzzle grid, provided [][]bool) {6681 puzzle = complete.clone()6782 provided = make([][]bool, size)6883 for y := range size {
@@ -80,7 +95,7 @@ func generatePuzzle(complete grid, size int, prefilled float64) (puzzle grid, pr
8095 positions = append(positions, cellPos{x, y})8196 }8297 }83- rand.Shuffle(len(positions), func(i, j int) {98+ rng.Shuffle(len(positions), func(i, j int) {8499 positions[i], positions[j] = positions[j], positions[i]85100 })86101
M takuzu/style.go +11 -6
M vhs/help.tape +1 -1
M vhs/menu.tape +5 -2
M wordsearch/Gamemode.go +15 -2
@@ -1,6 +1,8 @@
11 package wordsearch22 33 import (4+ "math/rand/v2"5+46 "github.com/FelineStateMachine/puzzletea/game"57 "github.com/charmbracelet/bubbles/list"68 )
@@ -23,8 +25,9 @@ type WordSearchMode struct {
2325 }2426 2527 var (26- _ game.Mode = WordSearchMode{} // compile-time interface check27- _ game.Spawner = WordSearchMode{} // compile-time interface check28+ _ game.Mode = WordSearchMode{} // compile-time interface check29+ _ game.Spawner = WordSearchMode{} // compile-time interface check30+ _ game.SeededSpawner = WordSearchMode{} // compile-time interface check2831 )2932 3033 // NewMode creates a new WordSearchMode with the given parameters
@@ -45,8 +48,18 @@ func (w WordSearchMode) Spawn() (game.Gamer, error) {
4548 return New(w, grid, words)4649 }4750 51+func (w WordSearchMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {52+ grid, words := GenerateWordSearchSeeded(w.Width, w.Height, w.WordCount, w.MinWordLen, w.MaxWordLen, w.AllowedDirs, rng)53+ return New(w, grid, words)54+}55+4856 var Modes = []list.Item{4957 NewMode("Easy 10x10", "Find 6 words in a 10x10 grid.", 10, 10, 6, 3, 5, []Direction{Right, Down, DownRight}),5058 NewMode("Medium 15x15", "Find 10 words in a 15x15 grid.", 15, 15, 10, 4, 7, []Direction{Right, Down, DownRight, DownLeft, Left, Up}),5159 NewMode("Hard 20x20", "Find 15 words in a 20x20 grid.", 20, 20, 15, 5, 10, []Direction{Right, Down, DownRight, DownLeft, Left, Up, UpRight, UpLeft}),5260 }61+62+// DailyModes is the subset of Modes eligible for daily puzzle rotation.63+var DailyModes = []list.Item{64+ Modes[0], // Easy 10x1065+}
M wordsearch/generator.go +28 -8
@@ -7,26 +7,36 @@ import (
77 88 // GenerateWordSearch creates a new word search grid with the specified parameters99 func GenerateWordSearch(width, height, wordCount, minLen, maxLen int, allowedDirs []Direction) (grid, []Word) {10+ rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))11+ return GenerateWordSearchSeeded(width, height, wordCount, minLen, maxLen, allowedDirs, rng)12+}13+14+func GenerateWordSearchSeeded(width, height, wordCount, minLen, maxLen int, allowedDirs []Direction, rng *rand.Rand) (grid, []Word) {1015 g := createEmptyGrid(height, width)11- words := selectWords(wordCount, minLen, maxLen)16+ words := selectWordsSeeded(wordCount, minLen, maxLen, rng)1217 placedWords := make([]Word, 0, len(words))1318 1419 // Try to place each word1520 for _, wordText := range words {16- word := tryPlaceWord(g, wordText, allowedDirs, 100)21+ word := tryPlaceWordSeeded(g, wordText, allowedDirs, 100, rng)1722 if word != nil {1823 placedWords = append(placedWords, *word)1924 }2025 }2126 2227 // Fill remaining empty cells with random letters23- fillEmptyCells(g)28+ fillEmptyCellsSeeded(g, rng)2429 2530 return g, placedWords2631 }2732 2833 // selectWords randomly selects words from the word list2934 func selectWords(count, minLen, maxLen int) []string {35+ rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))36+ return selectWordsSeeded(count, minLen, maxLen, rng)37+}38+39+func selectWordsSeeded(count, minLen, maxLen int, rng *rand.Rand) []string {3040 // Collect all valid words3141 var candidates []string3242 for length := minLen; length <= maxLen; length++ {
@@ -36,7 +46,7 @@ func selectWords(count, minLen, maxLen int) []string {
3646 }3747 3848 // Shuffle and take first 'count' words39- rand.Shuffle(len(candidates), func(i, j int) {49+ rng.Shuffle(len(candidates), func(i, j int) {4050 candidates[i], candidates[j] = candidates[j], candidates[i]4151 })4252
@@ -49,17 +59,22 @@ func selectWords(count, minLen, maxLen int) []string {
4959 5060 // tryPlaceWord attempts to place a word in the grid5161 func tryPlaceWord(g grid, text string, allowedDirs []Direction, maxAttempts int) *Word {62+ rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))63+ return tryPlaceWordSeeded(g, text, allowedDirs, maxAttempts, rng)64+}65+66+func tryPlaceWordSeeded(g grid, text string, allowedDirs []Direction, maxAttempts int, rng *rand.Rand) *Word {5267 text = strings.ToUpper(text)5368 height := len(g)5469 width := len(g[0])5570 5671 for range maxAttempts {5772 // Random starting position58- x := rand.IntN(width)59- y := rand.IntN(height)73+ x := rng.IntN(width)74+ y := rng.IntN(height)6075 6176 // Random direction62- dir := allowedDirs[rand.IntN(len(allowedDirs))]77+ dir := allowedDirs[rng.IntN(len(allowedDirs))]6378 dx, dy := dir.Vector()6479 6580 // Check if word fits in this direction
@@ -108,12 +123,17 @@ func tryPlaceWord(g grid, text string, allowedDirs []Direction, maxAttempts int)
108123 109124 // fillEmptyCells fills all empty cells with random letters110125 func fillEmptyCells(g grid) {126+ rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))127+ fillEmptyCellsSeeded(g, rng)128+}129+130+func fillEmptyCellsSeeded(g grid, rng *rand.Rand) {111131 letters := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"112132 113133 for y := range g {114134 for x := range g[y] {115135 if g[y][x] == ' ' {116- g[y][x] = rune(letters[rand.IntN(len(letters))])136+ g[y][x] = rune(letters[rng.IntN(len(letters))])117137 }118138 }119139 }