feat: add daily puzzles with seeded generation
- commit
- 87f57c3c7a24fb0f825cbbcacd22e573828d3f04
- parent
- 5e2652b052dda169d9d262172764102795d8db9f
- parent
- c9705e5c2f3c1126a93e8b9bbaecf7bffc9b2b39
- author
- FelineStateMachine <dami.etoile@gmail.com>
| A | daily.go | 86 | ++++++++++++++++ |
| M | game/ | 9 | ++ |
| M | hashiwokakero/ | 21 | ++++- |
| M | hashiwokakero/ | 40 | +++++--- |
| M | hashiwokakero/ | 9 | ++- |
| M | hitori/ | 1 | + |
| M | hitori/ | 22 | ++++- |
| M | hitori/ | 3 | +- |
| M | hitori/ | 45 | ++++++--- |
| M | hitori/ | 160 | ++++++++++++++++++++++++++++++ |
| M | hitori/ | 142 | +++++++++++++++++++++++++++ |
| M | hitori/ | 31 | ++++-- |
| M | lightsout/ | 17 | +++- |
| M | lightsout/ | 12 | ++- |
| M | lightsout/ | 9 | +- |
| M | lightsout/ | 9 | ++- |
| M | model.go | 106 | +++++++++++++++++++- |
| M | namegen/ | 8 | ++ |
| M | nonogram/ | 18 | +++- |
| M | nonogram/ | 16 | ++- |
| M | nonogram/ | 18 | ++- |
| M | store/ | 29 | +++++ |
| M | sudoku/ | 17 | +++- |
| M | sudoku/ | 22 | +++- |
| M | takuzu/ | 19 | +++- |
| M | takuzu/ | 25 | ++++- |
| M | takuzu/ | 17 | ++- |
| M | vhs/ | 2 | +- |
| M | vhs/ | 7 | +- |
| M | wordsearch/ | 17 | +++- |
| M | wordsearch/ | 36 | +++++-- |
@@ -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+}@@ -2,6 +2,8 @@
22 package game33 44 import (5+ "math/rand/v2"6+57 "github.com/charmbracelet/bubbles/key"68 "github.com/charmbracelet/bubbles/list"79 tea "github.com/charmbracelet/bubbletea"@@ -36,6 +38,13 @@ type Spawner interface {
3638 Spawn() (Gamer, error)3739 }3840 41+// SeededSpawner creates a new game instance using a deterministic RNG.42+// This enables reproducible puzzle generation for daily puzzles.43+type SeededSpawner interface {44+ Spawner45+ SpawnSeeded(rng *rand.Rand) (Gamer, error)46+}47+3948 // BaseMode provides the common title/description fields and the three4049 // Mode-interface methods so that concrete mode structs can embed it4150 // instead of duplicating boilerplate.@@ -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+}@@ -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 @@ -53,6 +53,11 @@ var (
5353 Background(colorCursorBg).5454 Bold(true)5555 56+ islandCursorSolvedStyle = baseStyle.57+ Foreground(lipgloss.AdaptiveColor{Dark: "255", Light: "235"}).58+ Background(lipgloss.AdaptiveColor{Dark: "28", Light: "28"}).59+ Bold(true)60+5661 islandSelectedStyle = baseStyle.5762 Foreground(colorSelectedFg).5863 Background(colorSelectedBg).@@ -235,7 +240,9 @@ func islandView(m Model, islandID int, solved bool) string {
235240 style := islandDefaultStyle236241 current := m.puzzle.BridgeCount(islandID)237242 238- if solved {243+ if solved && m.cursorIsland == islandID {244+ style = islandCursorSolvedStyle245+ } else if solved {239246 style = islandSatisfiedStyle240247 } else if m.selectedIsland != nil && *m.selectedIsland == islandID {241248 style = islandSelectedStyle@@ -50,6 +50,7 @@ func ImportModel(data []byte) (*Model, error) {
5050 keys: DefaultKeyMap,5151 modeTitle: s.ModeTitle,5252 }53+ m.initialMarks = newMarks(s.Size)5354 m.solved = m.checkSolved()5455 return m, nil5556 }@@ -1,8 +1,9 @@
11 package hitori22 33 import (4- "github.com/FelineStateMachine/puzzletea/game"4+ "math/rand/v2"55 6+ "github.com/FelineStateMachine/puzzletea/game"67 "github.com/charmbracelet/bubbles/list"78 )89 @@ -20,8 +21,9 @@ type HitoriMode struct {
2021 }2122 2223 var (23- _ game.Mode = HitoriMode{}24- _ game.Spawner = HitoriMode{}24+ _ game.Mode = HitoriMode{}25+ _ game.Spawner = HitoriMode{}26+ _ game.SeededSpawner = HitoriMode{}2527 )2628 2729 // NewMode creates a new Hitori game mode.@@ -42,6 +44,14 @@ func (h HitoriMode) Spawn() (game.Gamer, error) {
4244 return New(h, puzzle)4345 }4446 47+func (h HitoriMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {48+ puzzle, err := GenerateSeeded(h.Size, h.BlackRatio, rng)49+ if err != nil {50+ return nil, err51+ }52+ return New(h, puzzle)53+}54+4555 // Modes defines the available difficulty levels.4656 // Difficulty is controlled by grid size and the ratio of black cells.4757 // Smaller grids with fewer black cells are easier to deduce; larger grids@@ -54,3 +64,9 @@ var Modes = []list.Item{
5464 NewMode("Hard", "10\u00d710 grid, advanced logic chains.", 10, 0.30),5565 NewMode("Expert", "12\u00d712 grid, maximum challenge.", 12, 0.28),5666 }67+68+// DailyModes is the subset of Modes eligible for daily puzzle rotation.69+var DailyModes = []list.Item{70+ Modes[1], // Easy 6x671+ Modes[2], // Medium 8x872+}@@ -102,7 +102,8 @@ func (m *Model) toggleMark(mark cellMark) {
102102 103103 func (m Model) View() string {104104 title := game.TitleBarView("Hitori", m.modeTitle, m.solved)105- grid := gridView(m.numbers, m.marks, m.cursor, m.solved)105+ conflicts := computeConflicts(m.numbers, m.marks, m.size)106+ grid := gridView(m.numbers, m.marks, m.cursor, m.solved, conflicts)106107 status := statusBarView(m.showFullHelp)107108 return lipgloss.JoinVertical(lipgloss.Center, title, grid, status)108109 }@@ -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 }@@ -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) {@@ -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+}@@ -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 @@ -1,6 +1,8 @@
11 package lightsout22 33 import (4+ "math/rand/v2"5+46 "github.com/FelineStateMachine/puzzletea/game"57 "github.com/charmbracelet/bubbles/list"68 )@@ -20,8 +22,9 @@ type Mode struct {
2022 2123 // Compile-time assertions2224 var (23- _ game.Mode = Mode{}24- _ game.Spawner = Mode{}25+ _ game.Mode = Mode{}26+ _ game.Spawner = Mode{}27+ _ game.SeededSpawner = Mode{}2528 )2629 2730 // NewMode creates a new game mode.@@ -38,6 +41,10 @@ func (m Mode) Spawn() (game.Gamer, error) {
3841 return New(m.Width, m.Height)3942 }4043 44+func (m Mode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {45+ return NewSeeded(m.Width, m.Height, rng)46+}47+4148 // Modes defines the available difficulty levels.4249 var Modes = []list.Item{4350 NewMode("Easy", "3x3 grid", 3, 3),@@ -45,3 +52,9 @@ var Modes = []list.Item{
4552 NewMode("Hard", "7x7 grid", 7, 7),4653 NewMode("Extreme", "9x9 grid", 9, 9),4754 }55+56+// DailyModes is the subset of Modes eligible for daily puzzle rotation.57+var DailyModes = []list.Item{58+ Modes[1], // Medium 5x559+ Modes[2], // Hard 7x760+}@@ -3,6 +3,7 @@ package lightsout
33 44 import (55 "fmt"6+ "math/rand/v2"67 78 "github.com/FelineStateMachine/puzzletea/game"89 "github.com/charmbracelet/bubbles/key"@@ -25,6 +26,15 @@ var _ game.Gamer = Model{}
2526 2627 func New(w, h int) (Model, error) {2728 grid := Generate(w, h)29+ return newFromGrid(w, h, grid), nil30+}31+32+func NewSeeded(w, h int, rng *rand.Rand) (Model, error) {33+ grid := GenerateSeeded(w, h, rng)34+ return newFromGrid(w, h, grid), nil35+}36+37+func newFromGrid(w, h int, grid [][]bool) Model {2838 initial := make([][]bool, h)2939 for y := range h {3040 initial[y] = make([]bool, w)@@ -37,7 +47,7 @@ func New(w, h int) (Model, error) {
3747 height: h,3848 cursor: game.Cursor{X: w / 2, Y: h / 2},3949 keys: DefaultKeyMap,40- }, nil50+ }4151 }4252 4353 func (m Model) Init() tea.Cmd {@@ -7,6 +7,11 @@ import (
77 // Generate creates a new w x h grid with a solvable puzzle.88 // It starts with all lights off and applies random toggles.99 func Generate(w, h int) [][]bool {10+ rng := rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))11+ return GenerateSeeded(w, h, rng)12+}13+14+func GenerateSeeded(w, h int, rng *rand.Rand) [][]bool {1015 grid := make([][]bool, h)1116 for y := 0; y < h; y++ {1217 grid[y] = make([]bool, w)@@ -17,7 +22,7 @@ func Generate(w, h int) [][]bool {
1722 // iterating once through all cells covers all reachable states from "all off".1823 for y := 0; y < h; y++ {1924 for x := 0; x < w; x++ {20- if rand.IntN(2) == 1 {25+ if rng.IntN(2) == 1 {2126 Toggle(grid, x, y)2227 }2328 }@@ -26,7 +31,7 @@ func Generate(w, h int) [][]bool {
2631 // Ensure the puzzle isn't already solved (all off).2732 if IsSolved(grid) {2833 // Toggle one random cell to ensure at least some lights are on.29- Toggle(grid, rand.IntN(w), rand.IntN(h))34+ Toggle(grid, rng.IntN(w), rng.IntN(h))3035 }3136 3237 return grid@@ -24,6 +24,11 @@ var (
2424 Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "235"}).2525 Background(lipgloss.AdaptiveColor{Light: "130", Dark: "173"})2626 27+ cursorSolvedStyle = baseStyle.28+ Bold(true).29+ Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "235"}).30+ Background(lipgloss.AdaptiveColor{Light: "28", Dark: "28"})31+2732 solvedStyle = baseStyle.2833 Background(lipgloss.AdaptiveColor{Light: "151", Dark: "22"})2934 @@ -53,7 +58,9 @@ func cellView(isOn, isCursor, solved bool) string {
5358 s = styleOn5459 }5560 56- if solved {61+ if isCursor && solved {62+ s = cursorSolvedStyle63+ } else if solved {5764 s = solvedStyle5865 } else if isCursor {5966 s = cursorStyle@@ -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},@@ -61,3 +61,11 @@ func Generate() string {
6161 noun := nouns[rand.IntN(len(nouns))]6262 return adj + "-" + noun6363 }64+65+// GenerateSeeded returns a deterministic "adjective-noun" name using the66+// provided RNG. Two calls with identically-seeded RNGs produce the same name.67+func GenerateSeeded(rng *rand.Rand) string {68+ adj := adjectives[rng.IntN(len(adjectives))]69+ noun := nouns[rng.IntN(len(nouns))]70+ return adj + "-" + noun71+}@@ -1,6 +1,8 @@
11 package nonogram22 33 import (4+ "math/rand/v2"5+46 "github.com/FelineStateMachine/puzzletea/game"57 "github.com/charmbracelet/bubbles/list"68 )@@ -18,8 +20,9 @@ type NonogramMode struct {
1820 }1921 2022 var (21- _ game.Mode = NonogramMode{} // compile-time interface check22- _ game.Spawner = NonogramMode{} // compile-time interface check23+ _ game.Mode = NonogramMode{} // compile-time interface check24+ _ game.Spawner = NonogramMode{} // compile-time interface check25+ _ game.SeededSpawner = NonogramMode{} // compile-time interface check2326 )2427 2528 func NewMode(title, description string, width, height int, density float64) NonogramMode {@@ -36,6 +39,11 @@ func (n NonogramMode) Spawn() (game.Gamer, error) {
3639 return New(n, hints)3740 }3841 42+func (n NonogramMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {43+ hints := GenerateRandomTomographySeeded(n, rng)44+ return New(n, hints)45+}46+3947 var Modes = []list.Item{4048 // 5x54149 NewMode("Mini", "5x5 grid, ~65% filled. Quick puzzle, straightforward hints.", 5, 5, 0.65),@@ -52,3 +60,9 @@ var Modes = []list.Item{
5260 NewMode("Epic", "20x20 grid, ~71% filled. A epic undertaking.", 20, 20, 0.71),5361 NewMode("Massive", "20x20 grid, ~56% filled. Truly massive puzzle.", 20, 20, 0.56),5462 }63+64+// DailyModes is the subset of Modes eligible for daily puzzle rotation.65+var DailyModes = []list.Item{66+ Modes[3], // Standard 10x1067+ Modes[4], // Classic 10x1068+}@@ -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)@@ -28,6 +28,11 @@ var (
2828 Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "235"}).2929 Background(lipgloss.AdaptiveColor{Light: "130", Dark: "214"})3030 31+ cursorSolvedStyle = baseStyle.32+ Bold(true).33+ Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "235"}).34+ Background(lipgloss.AdaptiveColor{Light: "28", Dark: "28"})35+3136 crosshairBG = lipgloss.AdaptiveColor{Light: "254", Dark: "237"}3237 crosshairFilledBG = lipgloss.AdaptiveColor{Light: "223", Dark: "100"}3338 solvedBG = lipgloss.AdaptiveColor{Light: "151", Dark: "22"}@@ -234,9 +239,13 @@ func tileView(val rune, isCursor, inCursorRow, inCursorCol, solved bool) string
234239 s = renderStyleMap[emptyTile]235240 }236241 237- if isCursor && !solved {242+ if isCursor && solved {243+ s = cursorSolvedStyle244+ } else if isCursor {238245 s = cursorStyle239- } else if !solved && (inCursorRow || inCursorCol) {246+ } else if solved {247+ s = s.Background(solvedBG)248+ } else if inCursorRow || inCursorCol {240249 // Apply crosshair background tint — filled cells get a more active color241250 if val == filledTile {242251 s = s.Background(crosshairFilledBG)@@ -245,11 +254,6 @@ func tileView(val rune, isCursor, inCursorRow, inCursorCol, solved bool) string
245254 }246255 }247256 248- if solved {249- // Brighten filled tiles when solved250- s = s.Background(solvedBG)251- }252-253257 r, ok := renderRuneMap[val]254258 if !ok {255259 r = renderRuneMap[emptyTile]@@ -150,6 +150,35 @@ func (s *Store) ListGames() ([]GameRecord, error) {
150150 return games, rows.Err()151151 }152152 153+// GetDailyGame looks up a daily game by name, including abandoned ones.154+// Daily puzzles should always be resumable regardless of status.155+// Returns nil, nil if no matching game is found.156+func (s *Store) GetDailyGame(name string) (*GameRecord, error) {157+ var g GameRecord158+ var completedAt sql.NullTime159+ err := s.db.QueryRow(160+ `SELECT id, name, game_type, mode, initial_state, save_state, status,161+ created_at, updated_at, completed_at162+ FROM games163+ WHERE name = ?`,164+ name,165+ ).Scan(166+ &g.ID, &g.Name, &g.GameType, &g.Mode,167+ &g.InitialState, &g.SaveState, &g.Status,168+ &g.CreatedAt, &g.UpdatedAt, &completedAt,169+ )170+ if err == sql.ErrNoRows {171+ return nil, nil172+ }173+ if err != nil {174+ return nil, fmt.Errorf("querying daily game: %w", err)175+ }176+ if completedAt.Valid {177+ g.CompletedAt = &completedAt.Time178+ }179+ return &g, nil180+}181+153182 // GetGameByName looks up a single non-abandoned game by its unique name.154183 // Returns nil, nil if no matching game is found.155184 func (s *Store) GetGameByName(name string) (*GameRecord, error) {@@ -1,6 +1,8 @@
11 package sudoku22 33 import (4+ "math/rand/v2"5+46 "github.com/FelineStateMachine/puzzletea/game"57 "github.com/charmbracelet/bubbles/list"68 )@@ -17,8 +19,9 @@ type SudokuMode struct {
1719 }1820 1921 var (20- _ game.Mode = SudokuMode{} // compile-time interface check21- _ game.Spawner = SudokuMode{} // compile-time interface check22+ _ game.Mode = SudokuMode{} // compile-time interface check23+ _ game.Spawner = SudokuMode{} // compile-time interface check24+ _ game.SeededSpawner = SudokuMode{} // compile-time interface check2225 )2326 2427 func NewMode(title, description string, providedCount int) SudokuMode {@@ -32,6 +35,10 @@ func (s SudokuMode) Spawn() (game.Gamer, error) {
3235 return New(s, GenerateProvidedCells(s))3336 }3437 38+func (s SudokuMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {39+ return New(s, GenerateProvidedCellsSeeded(s, rng))40+}41+3542 var Modes = []list.Item{3643 NewMode("Beginner", "45–52 clues. Single Candidate / Scanning.", 45),3744 NewMode("Easy", "38–44 clues. Naked Singles.", 38),@@ -40,3 +47,9 @@ var Modes = []list.Item{
4047 NewMode("Expert", "22–26 clues. X-Wing / Y-Wing.", 22),4148 NewMode("Diabolical", "17–21 clues. Swordfish / XY-Chains.", 17),4249 }50+51+// DailyModes is the subset of Modes eligible for daily puzzle rotation.52+var DailyModes = []list.Item{53+ Modes[1], // Easy54+ Modes[2], // Medium55+}@@ -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 @@ -1,6 +1,8 @@
11 package takuzu22 33 import (4+ "math/rand/v2"5+46 "github.com/FelineStateMachine/puzzletea/game"57 "github.com/charmbracelet/bubbles/list"68 )@@ -19,8 +21,9 @@ type TakuzuMode struct {
1921 }2022 2123 var (22- _ game.Mode = TakuzuMode{}23- _ game.Spawner = TakuzuMode{}24+ _ game.Mode = TakuzuMode{}25+ _ game.Spawner = TakuzuMode{}26+ _ game.SeededSpawner = TakuzuMode{}2427 )2528 2629 // NewMode creates a new Takuzu game mode.@@ -39,6 +42,12 @@ func (t TakuzuMode) Spawn() (game.Gamer, error) {
3942 return New(t, puzzle, provided)4043 }4144 45+func (t TakuzuMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {46+ complete := generateCompleteSeeded(t.Size, rng)47+ puzzle, provided := generatePuzzleSeeded(complete, t.Size, t.Prefilled, rng)48+ return New(t, puzzle, provided)49+}50+4251 // Modes defines the available difficulty levels.4352 // Difficulty is controlled by two axes: grid size and clue density.4453 // Smaller grids with more clues need only basic pattern recognition (doubles,@@ -53,3 +62,9 @@ var Modes = []list.Item{
5362 NewMode("Very Hard", "12×12 grid, ~30% clues. Deep logic required.", 12, 0.30),5463 NewMode("Extreme", "14×14 grid, ~28% clues. Maximum challenge.", 14, 0.28),5564 }65+66+// DailyModes is the subset of Modes eligible for daily puzzle rotation.67+var DailyModes = []list.Item{68+ Modes[2], // Medium 8x869+ Modes[3], // Tricky 10x1070+}@@ -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 @@ -25,6 +25,11 @@ var (
2525 Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "235"}).2626 Background(lipgloss.AdaptiveColor{Light: "130", Dark: "214"})2727 28+ cursorSolvedStyle = baseStyle.29+ Bold(true).30+ Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "235"}).31+ Background(lipgloss.AdaptiveColor{Light: "28", Dark: "28"})32+2833 crosshairBG = lipgloss.AdaptiveColor{Light: "254", Dark: "237"}2934 solvedBG = lipgloss.AdaptiveColor{Light: "151", Dark: "22"}3035 @@ -61,14 +66,14 @@ func cellView(val rune, isProvided, isCursor, inCursorRow, inCursorCol, solved b
6166 s = s.Bold(true)6267 }6368 64- if isCursor && !solved {69+ if isCursor && solved {70+ s = cursorSolvedStyle71+ } else if isCursor {6572 s = s.Background(cursorStyle.GetBackground()).Bold(true)66- } else if !solved && (inCursorRow || inCursorCol) {67- s = s.Background(crosshairBG)68- }69-70- if solved {73+ } else if solved {7174 s = s.Background(solvedBG)75+ } else if inCursorRow || inCursorCol {76+ s = s.Background(crosshairBG)7277 }7378 7479 r, ok := renderRuneMap[val]@@ -26,16 +26,19 @@ Sleep 500ms
2626 Enter2727 Sleep 2s2828 29-# Browse main menu29+# Browse main menu (Daily Puzzle, Generate, Continue, Quit)3030 Down3131 Sleep 500ms3232 Down3333 Sleep 500ms34+Down35+Sleep 500ms36+Up3437 Up3538 Up3639 Sleep 500ms3740 38-# Select "Generate"41+# Select "Generate" (second item)3942 Down4043 Enter4144 Sleep 1s@@ -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+}@@ -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 }