(Feat): Initial Commit, Termdoku

This commit is contained in:
2025-11-25 21:09:27 +00:00
commit f6933958e2
40 changed files with 5755 additions and 0 deletions

484
internal/generator/api.go Normal file
View File

@@ -0,0 +1,484 @@
package generator
import (
"slices"
"termdoku/internal/solver"
"time"
)
// Puzzle returns a copy of the grid suitable for rendering: 0 means blank.
func (g Grid) Puzzle() [9][9]uint8 {
return g
}
// Clone creates a deep copy of the grid.
func (g Grid) Clone() Grid {
var clone Grid
for r := range 9 {
for c := range 9 {
clone[r][c] = g[r][c]
}
}
return clone
}
// IsValid checks if the current grid state is valid (no conflicts).
func (g Grid) IsValid() bool {
for r := range 9 {
seen := make(map[uint8]bool)
for c := range 9 {
v := g[r][c]
if v == 0 {
continue
}
if seen[v] {
return false
}
seen[v] = true
}
}
// Check columns
for c := range 9 {
seen := make(map[uint8]bool)
for r := range 9 {
v := g[r][c]
if v == 0 {
continue
}
if seen[v] {
return false
}
seen[v] = true
}
}
// Check 3x3 blocks
for br := range 3 {
for bc := range 3 {
seen := make(map[uint8]bool)
for r := br * 3; r < br*3+3; r++ {
for c := bc * 3; c < bc*3+3; c++ {
v := g[r][c]
if v == 0 {
continue
}
if seen[v] {
return false
}
seen[v] = true
}
}
}
}
return true
}
// IsSolved checks if the grid is completely filled and valid.
func (g Grid) IsSolved() bool {
if !g.IsValid() {
return false
}
for r := range 9 {
for c := range 9 {
if g[r][c] == 0 {
return false
}
}
}
return true
}
// CountFilledCells returns the number of non-zero cells.
func (g Grid) CountFilledCells() int {
count := 0
for r := range 9 {
for c := range 9 {
if g[r][c] != 0 {
count++
}
}
}
return count
}
// GetCandidates returns possible values for a given cell.
func (g Grid) GetCandidates(row, col int) []uint8 {
if g[row][col] != 0 {
return nil
}
used := make(map[uint8]bool)
for c := range 9 {
if g[row][c] != 0 {
used[g[row][c]] = true
}
}
for r := range 9 {
if g[r][col] != 0 {
used[g[r][col]] = true
}
}
// Check 3x3 block
r0 := (row / 3) * 3
c0 := (col / 3) * 3
for r := r0; r < r0+3; r++ {
for c := c0; c < c0+3; c++ {
if g[r][c] != 0 {
used[g[r][c]] = true
}
}
}
var candidates []uint8
for v := uint8(1); v <= 9; v++ {
if !used[v] {
candidates = append(candidates, v)
}
}
return candidates
}
// Hint represents a hint for the player.
type Hint struct {
Row int
Col int
Value uint8
Type HintType
}
// HintType categorizes different hint strategies.
type HintType int
const (
HintNakedSingle HintType = iota // Only one candidate for a cell
HintHiddenSingle // Only cell in row/col/box for a value
HintRandom // Random valid cell (fallback)
)
// GenerateHint provides a hint for the puzzle based on solving techniques.
func (g Grid) GenerateHint(solution Grid) *Hint {
// First, try to find a naked single (cell with only one candidate)
for r := range 9 {
for c := range 9 {
if g[r][c] == 0 {
candidates := g.GetCandidates(r, c)
if len(candidates) == 1 {
return &Hint{
Row: r,
Col: c,
Value: candidates[0],
Type: HintNakedSingle,
}
}
}
}
}
// Try to find a hidden single
if hint := g.findHiddenSingle(); hint != nil {
return hint
}
// Fallback: return a random empty cell with its solution
for r := range 9 {
for c := range 9 {
if g[r][c] == 0 {
return &Hint{
Row: r,
Col: c,
Value: solution[r][c],
Type: HintRandom,
}
}
}
}
return nil
}
// findHiddenSingle finds a cell where a value can only go in one place in a row/col/box.
func (g Grid) findHiddenSingle() *Hint {
// Check rows
for r := range 9 {
for v := uint8(1); v <= 9; v++ {
var possibleCols []int
for c := range 9 {
if g[r][c] == 0 {
candidates := g.GetCandidates(r, c)
if slices.Contains(candidates, v) {
possibleCols = append(possibleCols, c)
}
}
}
if len(possibleCols) == 1 {
return &Hint{
Row: r,
Col: possibleCols[0],
Value: v,
Type: HintHiddenSingle,
}
}
}
}
// Check columns
for c := range 9 {
for v := uint8(1); v <= 9; v++ {
var possibleRows []int
for r := range 9 {
if g[r][c] == 0 {
candidates := g.GetCandidates(r, c)
for _, cand := range candidates {
if cand == v {
possibleRows = append(possibleRows, r)
break
}
}
}
}
if len(possibleRows) == 1 {
return &Hint{
Row: possibleRows[0],
Col: c,
Value: v,
Type: HintHiddenSingle,
}
}
}
}
// Check 3x3 blocks
for br := range 3 {
for bc := range 3 {
for v := uint8(1); v <= 9; v++ {
type pos struct{ r, c int }
var possiblePos []pos
for r := br * 3; r < br*3+3; r++ {
for c := bc * 3; c < bc*3+3; c++ {
if g[r][c] == 0 {
candidates := g.GetCandidates(r, c)
for _, cand := range candidates {
if cand == v {
possiblePos = append(possiblePos, pos{r, c})
break
}
}
}
}
}
if len(possiblePos) == 1 {
return &Hint{
Row: possiblePos[0].r,
Col: possiblePos[0].c,
Value: v,
Type: HintHiddenSingle,
}
}
}
}
}
return nil
}
// PuzzleAnalysis contains metrics about puzzle difficulty and characteristics.
type PuzzleAnalysis struct {
FilledCells int
EmptyCells int
MinCandidates int
MaxCandidates int
AvgCandidates float64
HasUniqueSolution bool
EstimatedDifficulty Difficulty
SolvingTechniques []string
SymmetryType SymmetryType
}
// SymmetryType represents the symmetry pattern of the puzzle.
type SymmetryType int
const (
SymmetryNone SymmetryType = iota
SymmetryRotational180
SymmetryRotational90
SymmetryVertical
SymmetryHorizontal
SymmetryDiagonal
)
func (s SymmetryType) String() string {
switch s {
case SymmetryRotational180:
return "180° Rotational"
case SymmetryRotational90:
return "90° Rotational"
case SymmetryVertical:
return "Vertical"
case SymmetryHorizontal:
return "Horizontal"
case SymmetryDiagonal:
return "Diagonal"
default:
return "None"
}
}
// Analyze performs a comprehensive analysis of the puzzle.
func (g Grid) Analyze() PuzzleAnalysis {
analysis := PuzzleAnalysis{
FilledCells: g.CountFilledCells(),
EmptyCells: 81 - g.CountFilledCells(),
}
// Analyze candidates
var totalCandidates int
var emptyCellCount int
analysis.MinCandidates = 9
analysis.MaxCandidates = 0
for r := range 9 {
for c := range 9 {
if g[r][c] == 0 {
emptyCellCount++
candidates := g.GetCandidates(r, c)
count := len(candidates)
totalCandidates += count
if count < analysis.MinCandidates {
analysis.MinCandidates = count
}
if count > analysis.MaxCandidates {
analysis.MaxCandidates = count
}
}
}
}
if emptyCellCount > 0 {
analysis.AvgCandidates = float64(totalCandidates) / float64(emptyCellCount)
}
solverGrid := convertToSolverGrid(g)
analysis.HasUniqueSolution = solver.CountSolutions(solverGrid, 100*time.Millisecond, 2) == 1
analysis.SolvingTechniques = g.detectSolvingTechniques()
analysis.EstimatedDifficulty = g.estimateDifficulty(analysis)
analysis.SymmetryType = g.detectSymmetry()
return analysis
}
// detectSolvingTechniques identifies which solving techniques are needed.
func (g Grid) detectSolvingTechniques() []string {
var techniques []string
hasNakedSingle := false
for r := range 9 {
for c := range 9 {
if g[r][c] == 0 {
if len(g.GetCandidates(r, c)) == 1 {
hasNakedSingle = true
break
}
}
}
}
if hasNakedSingle {
techniques = append(techniques, "Naked Singles")
}
if g.findHiddenSingle() != nil {
techniques = append(techniques, "Hidden Singles")
}
if len(techniques) == 0 {
techniques = append(techniques, "Advanced Techniques Required")
}
return techniques
}
// estimateDifficulty estimates puzzle difficulty based on analysis.
func (g Grid) estimateDifficulty(analysis PuzzleAnalysis) Difficulty {
// Use multiple factors to estimate difficulty
emptyCells := analysis.EmptyCells
avgCandidates := analysis.AvgCandidates
minCandidates := analysis.MinCandidates
// More empty cells generally means harder
if emptyCells >= 58 {
return Lunatic
} else if emptyCells >= 52 {
// Check if it requires advanced techniques
if minCandidates <= 2 || avgCandidates < 3.5 {
return Lunatic
}
return Hard
} else if emptyCells >= 46 {
if minCandidates <= 2 {
return Hard
}
return Normal
} else if emptyCells >= 38 {
return Normal
}
return Easy
}
// detectSymmetry detects the symmetry pattern of empty cells.
func (g Grid) detectSymmetry() SymmetryType {
if g.hasRotational180Symmetry() {
return SymmetryRotational180
}
if g.hasVerticalSymmetry() {
return SymmetryVertical
}
if g.hasHorizontalSymmetry() {
return SymmetryHorizontal
}
return SymmetryNone
}
func (g Grid) hasRotational180Symmetry() bool {
for r := range 9 {
for c := range 9 {
// Check if empty cells are symmetric
if (g[r][c] == 0) != (g[8-r][8-c] == 0) {
return false
}
}
}
return true
}
func (g Grid) hasVerticalSymmetry() bool {
for r := range 9 {
for c := range 9 {
if (g[r][c] == 0) != (g[r][8-c] == 0) {
return false
}
}
}
return true
}
func (g Grid) hasHorizontalSymmetry() bool {
for r := range 9 {
for c := range 9 {
if (g[r][c] == 0) != (g[8-r][c] == 0) {
return false
}
}
}
return true
}

View File

@@ -0,0 +1,220 @@
package generator
import (
"fmt"
"time"
)
// BenchmarkResult contains statistics from puzzle generation benchmarking.
type BenchmarkResult struct {
Difficulty Difficulty
TotalAttempts int
SuccessfulPuzzles int
FailedPuzzles int
AverageTime time.Duration
MinTime time.Duration
MaxTime time.Duration
AverageRating float64
Ratings []int
}
// BenchmarkGeneration tests puzzle generation performance for a given difficulty.
func BenchmarkGeneration(d Difficulty, attempts int) BenchmarkResult {
result := BenchmarkResult{
Difficulty: d,
TotalAttempts: attempts,
MinTime: time.Hour, // Start with a large value
Ratings: make([]int, 0, attempts),
}
var totalDuration time.Duration
for i := 0; i < attempts; i++ {
seed := fmt.Sprintf("benchmark-%d-%d", time.Now().UnixNano(), i)
start := time.Now()
puzzle, err := Generate(d, seed)
elapsed := time.Since(start)
if err != nil {
result.FailedPuzzles++
continue
}
result.SuccessfulPuzzles++
totalDuration += elapsed
if elapsed < result.MinTime {
result.MinTime = elapsed
}
if elapsed > result.MaxTime {
result.MaxTime = elapsed
}
rating := RatePuzzle(puzzle)
result.Ratings = append(result.Ratings, rating)
}
if result.SuccessfulPuzzles > 0 {
result.AverageTime = totalDuration / time.Duration(result.SuccessfulPuzzles)
var totalRating int
for _, r := range result.Ratings {
totalRating += r
}
result.AverageRating = float64(totalRating) / float64(len(result.Ratings))
}
return result
}
// String returns a formatted string representation of the benchmark result.
func (br BenchmarkResult) String() string {
successRate := float64(br.SuccessfulPuzzles) / float64(br.TotalAttempts) * 100
return fmt.Sprintf(`Benchmark Results for %s:
Total Attempts: %d
Successful: %d (%.1f%%)
Failed: %d
Average Time: %v
Min Time: %v
Max Time: %v
Average Rating: %.1f/100
`,
br.Difficulty.String(),
br.TotalAttempts,
br.SuccessfulPuzzles,
successRate,
br.FailedPuzzles,
br.AverageTime,
br.MinTime,
br.MaxTime,
br.AverageRating,
)
}
// CompareGenerationMethods compares standard vs symmetric generation.
func CompareGenerationMethods(d Difficulty, attempts int) (standard, symmetric BenchmarkResult) {
// Benchmark standard generation
standard = BenchmarkGeneration(d, attempts)
// Benchmark symmetric generation
symmetric = BenchmarkResult{
Difficulty: d,
TotalAttempts: attempts,
MinTime: time.Hour,
Ratings: make([]int, 0, attempts),
}
var totalDuration time.Duration
for i := 0; i < attempts; i++ {
seed := fmt.Sprintf("symmetric-benchmark-%d-%d", time.Now().UnixNano(), i)
start := time.Now()
puzzle, err := GenerateWithSymmetry(d, seed, SymmetryRotational180)
elapsed := time.Since(start)
if err != nil {
symmetric.FailedPuzzles++
continue
}
symmetric.SuccessfulPuzzles++
totalDuration += elapsed
if elapsed < symmetric.MinTime {
symmetric.MinTime = elapsed
}
if elapsed > symmetric.MaxTime {
symmetric.MaxTime = elapsed
}
rating := RatePuzzle(puzzle)
symmetric.Ratings = append(symmetric.Ratings, rating)
}
if symmetric.SuccessfulPuzzles > 0 {
symmetric.AverageTime = totalDuration / time.Duration(symmetric.SuccessfulPuzzles)
var totalRating int
for _, r := range symmetric.Ratings {
totalRating += r
}
symmetric.AverageRating = float64(totalRating) / float64(len(symmetric.Ratings))
}
return standard, symmetric
}
// PuzzleStatistics provides detailed statistics about a generated puzzle.
type PuzzleStatistics struct {
Grid Grid
Analysis PuzzleAnalysis
Rating int
EstimatedSolveTime time.Duration
ComplexityScore float64
}
// GetPuzzleStatistics performs comprehensive analysis on a puzzle.
func GetPuzzleStatistics(g Grid) PuzzleStatistics {
analysis := g.Analyze()
rating := RatePuzzle(g)
// Estimate solve time based on difficulty (rough approximation)
var estimatedTime time.Duration
switch analysis.EstimatedDifficulty {
case Easy:
estimatedTime = 3 * time.Minute
case Normal:
estimatedTime = 8 * time.Minute
case Hard:
estimatedTime = 15 * time.Minute
case Expert:
estimatedTime = 25 * time.Minute
case Lunatic:
estimatedTime = 45 * time.Minute
}
// Calculate complexity score (0-1)
complexityScore := float64(rating) / 100.0
return PuzzleStatistics{
Grid: g,
Analysis: analysis,
Rating: rating,
EstimatedSolveTime: estimatedTime,
ComplexityScore: complexityScore,
}
}
// String returns a formatted string of puzzle statistics.
func (ps PuzzleStatistics) String() string {
return fmt.Sprintf(`Puzzle Statistics:
Difficulty: %s
Rating: %d/100
Filled Cells: %d
Empty Cells: %d
Min Candidates: %d
Max Candidates: %d
Avg Candidates: %.2f
Unique Solution: %v
Symmetry: %s
Techniques: %v
Complexity Score: %.2f
Est. Solve Time: %v
`,
ps.Analysis.EstimatedDifficulty.String(),
ps.Rating,
ps.Analysis.FilledCells,
ps.Analysis.EmptyCells,
ps.Analysis.MinCandidates,
ps.Analysis.MaxCandidates,
ps.Analysis.AvgCandidates,
ps.Analysis.HasUniqueSolution,
ps.Analysis.SymmetryType.String(),
ps.Analysis.SolvingTechniques,
ps.ComplexityScore,
ps.EstimatedSolveTime,
)
}

275
internal/generator/core.go Normal file
View File

@@ -0,0 +1,275 @@
package generator
import (
"math/rand"
"time"
"termdoku/internal/solver"
)
// randomizedFullSolution builds a complete valid Sudoku solution using randomized DFS.
func randomizedFullSolution(seed string, timeout time.Duration) (Grid, error) {
var rng *rand.Rand
if seed == "" {
rng = rand.New(rand.NewSource(time.Now().UnixNano()))
} else {
rng = rand.New(rand.NewSource(int64(hashStringToUint64(seed))))
}
deadline := time.Now().Add(timeout)
var g Grid
if fillCellRandom(&g, 0, 0, rng, deadline) {
return g, nil
}
return Grid{}, ErrTimeout
}
func fillCellRandom(g *Grid, row, col int, rng *rand.Rand, deadline time.Time) bool {
if time.Now().After(deadline) {
return false
}
nextRow, nextCol := row, col+1
if nextCol == 9 {
nextRow++
nextCol = 0
}
if row == 9 {
return true
}
vals := []uint8{1, 2, 3, 4, 5, 6, 7, 8, 9}
rng.Shuffle(len(vals), func(i, j int) { vals[i], vals[j] = vals[j], vals[i] })
for _, v := range vals {
if isSafe(*g, row, col, v) {
g[row][col] = v
if fillCellRandom(g, nextRow, nextCol, rng, deadline) {
return true
}
g[row][col] = 0
}
}
return false
}
func isSafe(g Grid, row, col int, v uint8) bool {
for i := 0; i < 9; i++ {
if g[row][i] == v || g[i][col] == v {
return false
}
}
r0 := (row / 3) * 3
c0 := (col / 3) * 3
for r := r0; r < r0+3; r++ {
for c := c0; c < c0+3; c++ {
if g[r][c] == v {
return false
}
}
}
return true
}
// carveCellsUnique removes cells while trying to keep a single solution.
func carveCellsUnique(full Grid, targetRemoved int, seed string, timeout time.Duration) (Grid, error) {
puzzle := full
var rng *rand.Rand
if seed == "" {
rng = rand.New(rand.NewSource(time.Now().UnixNano()))
} else {
rng = rand.New(rand.NewSource(int64(hashStringToUint64(seed) + 0x9e3779b97f4a7c15)))
}
deadline := time.Now().Add(timeout)
cells := make([]int, 81)
for i := 0; i < 81; i++ {
cells[i] = i
}
rng.Shuffle(len(cells), func(i, j int) { cells[i], cells[j] = cells[j], cells[i] })
removed := 0
for _, idx := range cells {
if time.Now().After(deadline) {
break
}
r := idx / 9
c := idx % 9
backup := puzzle[r][c]
puzzle[r][c] = 0
// Check uniqueness using solver.CountSolutions up to 2
if solver.CountSolutions(convertToSolverGrid(puzzle), 50*time.Millisecond, 2) != 1 {
puzzle[r][c] = backup
continue
}
removed++
if removed >= targetRemoved {
break
}
}
return puzzle, nil
}
// carveCellsSymmetric removes cells with symmetry pattern while maintaining uniqueness.
func carveCellsSymmetric(full Grid, targetRemoved int, seed string, timeout time.Duration, symmetry SymmetryType) (Grid, error) {
puzzle := full
var rng *rand.Rand
if seed == "" {
rng = rand.New(rand.NewSource(time.Now().UnixNano()))
} else {
rng = rand.New(rand.NewSource(int64(hashStringToUint64(seed) + 0x9e3779b97f4a7c15)))
}
deadline := time.Now().Add(timeout)
// Generate cell pairs based on symmetry type
var cellPairs [][]struct{ r, c int }
switch symmetry {
case SymmetryRotational180:
cellPairs = generateRotational180Pairs()
case SymmetryVertical:
cellPairs = generateVerticalPairs()
case SymmetryHorizontal:
cellPairs = generateHorizontalPairs()
default:
// Fallback to non-symmetric
return carveCellsUnique(full, targetRemoved, seed, timeout)
}
rng.Shuffle(len(cellPairs), func(i, j int) { cellPairs[i], cellPairs[j] = cellPairs[j], cellPairs[i] })
removed := 0
for _, pair := range cellPairs {
if time.Now().After(deadline) {
break
}
// Try removing all cells in the pair
backups := make([]uint8, len(pair))
for i, pos := range pair {
backups[i] = puzzle[pos.r][pos.c]
puzzle[pos.r][pos.c] = 0
}
// Check if still unique
if solver.CountSolutions(convertToSolverGrid(puzzle), 50*time.Millisecond, 2) != 1 {
// Restore if not unique
for i, pos := range pair {
puzzle[pos.r][pos.c] = backups[i]
}
continue
}
removed += len(pair)
if removed >= targetRemoved {
break
}
}
return puzzle, nil
}
// generateRotational180Pairs creates cell pairs with 180° rotational symmetry.
func generateRotational180Pairs() [][]struct{ r, c int } {
var pairs [][]struct{ r, c int }
used := make(map[int]bool)
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
idx := r*9 + c
if used[idx] {
continue
}
r2 := 8 - r
c2 := 8 - c
idx2 := r2*9 + c2
if idx == idx2 {
// Center cell (4,4)
pairs = append(pairs, []struct{ r, c int }{{r, c}})
} else {
pairs = append(pairs, []struct{ r, c int }{{r, c}, {r2, c2}})
used[idx2] = true
}
used[idx] = true
}
}
return pairs
}
// generateVerticalPairs creates cell pairs with vertical symmetry.
func generateVerticalPairs() [][]struct{ r, c int } {
var pairs [][]struct{ r, c int }
used := make(map[int]bool)
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
idx := r*9 + c
if used[idx] {
continue
}
c2 := 8 - c
idx2 := r*9 + c2
if c == c2 {
// Center column
pairs = append(pairs, []struct{ r, c int }{{r, c}})
} else {
pairs = append(pairs, []struct{ r, c int }{{r, c}, {r, c2}})
used[idx2] = true
}
used[idx] = true
}
}
return pairs
}
// generateHorizontalPairs creates cell pairs with horizontal symmetry.
func generateHorizontalPairs() [][]struct{ r, c int } {
var pairs [][]struct{ r, c int }
used := make(map[int]bool)
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
idx := r*9 + c
if used[idx] {
continue
}
r2 := 8 - r
idx2 := r2*9 + c
if r == r2 {
// Center row
pairs = append(pairs, []struct{ r, c int }{{r, c}})
} else {
pairs = append(pairs, []struct{ r, c int }{{r, c}, {r2, c}})
used[idx2] = true
}
used[idx] = true
}
}
return pairs
}
func convertToSolverGrid(g Grid) solver.Grid {
var s solver.Grid
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
s[r][c] = g[r][c]
}
}
return s
}
// Simple FNV-1a 64-bit hash for seed strings.
func hashStringToUint64(s string) uint64 {
const (
offset64 = 1469598103934665603
prime64 = 1099511628211
)
h := uint64(offset64)
for i := 0; i < len(s); i++ {
h ^= uint64(s[i])
h *= prime64
}
return h
}

View File

@@ -0,0 +1,248 @@
package generator
import (
"errors"
"termdoku/internal/solver"
"time"
)
// Difficulty represents puzzle difficulty tiers.
type Difficulty int
const (
Easy Difficulty = iota
Normal
Hard
Expert
Lunatic
)
// String returns the string representation of the difficulty.
func (d Difficulty) String() string {
switch d {
case Easy:
return "Easy"
case Normal:
return "Normal"
case Hard:
return "Hard"
case Expert:
return "Expert"
case Lunatic:
return "Lunatic"
default:
return "Unknown"
}
}
// DailySeed returns a stable seed based on UTC date (YYYY-MM-DD).
func DailySeed(t time.Time) string {
utc := t.UTC()
return utc.Format("2006-01-02")
}
// Params controls generation knobs derived from difficulty.
type Params struct {
// number of blanks/removed cells; higher -> harder
RemovedCells int
// backtracking timeout to avoid worst-cases
Timeout time.Duration
// symmetry pattern to use (optional)
Symmetry SymmetryType
// whether to enforce symmetry
UseSymmetry bool
}
// paramsFor maps Difficulty to generation parameters.
func paramsFor(d Difficulty) Params {
switch d {
case Easy:
return Params{
RemovedCells: 38,
Timeout: 150 * time.Millisecond,
UseSymmetry: false,
}
case Normal:
return Params{
RemovedCells: 46,
Timeout: 150 * time.Millisecond,
UseSymmetry: false,
}
case Hard:
return Params{
RemovedCells: 52,
Timeout: 200 * time.Millisecond,
UseSymmetry: false,
}
case Expert:
return Params{
RemovedCells: 56,
Timeout: 250 * time.Millisecond,
UseSymmetry: false,
}
case Lunatic:
return Params{
RemovedCells: 60,
Timeout: 300 * time.Millisecond,
UseSymmetry: false,
}
default:
return Params{
RemovedCells: 46,
Timeout: 150 * time.Millisecond,
UseSymmetry: false,
}
}
}
// Grid is a 9x9 Sudoku grid. 0 represents empty.
type Grid [9][9]uint8
// ErrTimeout is returned when generation exceeds the configured timeout.
var ErrTimeout = errors.New("generation timed out")
// Generate creates a Sudoku puzzle with the given difficulty and seed.
// - If seed is empty, uses current time for randomness.
// - For Daily mode, pass seed from DailySeed(date).
// Returns a puzzle grid with 0 as blanks, aimed at single-solution.
func Generate(d Difficulty, seed string) (Grid, error) {
p := paramsFor(d)
return generateWithParams(p, seed)
}
// GenerateDaily creates a daily puzzle based on UTC date.
func GenerateDaily(date time.Time) (Grid, error) {
return Generate(Normal, DailySeed(date))
}
// GenerateWithSymmetry creates a puzzle with a specific symmetry pattern.
func GenerateWithSymmetry(d Difficulty, seed string, symmetry SymmetryType) (Grid, error) {
p := paramsFor(d)
p.UseSymmetry = true
p.Symmetry = symmetry
return generateWithParams(p, seed)
}
// GenerateCustom creates a puzzle with custom parameters.
func GenerateCustom(removedCells int, seed string, useSymmetry bool, symmetry SymmetryType) (Grid, error) {
p := Params{
RemovedCells: removedCells,
Timeout: 300 * time.Millisecond,
UseSymmetry: useSymmetry,
Symmetry: symmetry,
}
return generateWithParams(p, seed)
}
// PuzzleWithSolution represents a puzzle along with its solution.
type PuzzleWithSolution struct {
Puzzle Grid
Solution Grid
Analysis PuzzleAnalysis
}
// GenerateWithAnalysis creates a puzzle and returns it with its solution and analysis.
func GenerateWithAnalysis(d Difficulty, seed string) (PuzzleWithSolution, error) {
puzzle, err := Generate(d, seed)
if err != nil {
return PuzzleWithSolution{}, err
}
// Solve to get the solution
solution := puzzle.Clone()
solverGrid := convertToSolverGrid(solution)
if !solver.Solve(&solverGrid, 500*time.Millisecond) {
return PuzzleWithSolution{}, errors.New("failed to solve generated puzzle")
}
// Convert back to Grid
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
solution[r][c] = solverGrid[r][c]
}
}
// Analyze the puzzle
analysis := puzzle.Analyze()
return PuzzleWithSolution{
Puzzle: puzzle,
Solution: solution,
Analysis: analysis,
}, nil
}
// RatePuzzle provides a difficulty rating from 0-100 based on puzzle characteristics.
func RatePuzzle(g Grid) int {
analysis := g.Analyze()
// Base score from empty cells (0-40 points)
emptyScore := (analysis.EmptyCells * 40) / 81
// Candidate complexity (0-30 points)
candidateScore := 0
if analysis.AvgCandidates > 0 {
// Lower average candidates = harder
candidateScore = int((9.0 - analysis.AvgCandidates) * 3.3)
if candidateScore < 0 {
candidateScore = 0
}
if candidateScore > 30 {
candidateScore = 30
}
}
// Technique complexity (0-30 points)
techniqueScore := 0
for _, tech := range analysis.SolvingTechniques {
if tech == "Advanced Techniques Required" {
techniqueScore = 30
break
} else if tech == "Hidden Singles" {
techniqueScore = 15
} else if tech == "Naked Singles" {
techniqueScore = 5
}
}
totalScore := emptyScore + candidateScore + techniqueScore
if totalScore > 100 {
totalScore = 100
}
return totalScore
}
// DifficultyFromRating converts a rating (0-100) to a Difficulty level.
func DifficultyFromRating(rating int) Difficulty {
if rating >= 85 {
return Lunatic
} else if rating >= 70 {
return Expert
} else if rating >= 50 {
return Hard
} else if rating >= 30 {
return Normal
}
return Easy
}
// generateWithParams contains the core generation pipeline.
func generateWithParams(p Params, seed string) (Grid, error) {
// 1) Create a full valid solution via randomized backtracking
full, err := randomizedFullSolution(seed, p.Timeout)
if err != nil {
return Grid{}, err
}
// 2) Remove cells according to difficulty while keeping uniqueness if possible
var puzzle Grid
if p.UseSymmetry {
puzzle, err = carveCellsSymmetric(full, p.RemovedCells, seed, p.Timeout, p.Symmetry)
} else {
puzzle, err = carveCellsUnique(full, p.RemovedCells, seed, p.Timeout)
}
if err != nil {
return Grid{}, err
}
return puzzle, nil
}

298
internal/generator/utils.go Normal file
View File

@@ -0,0 +1,298 @@
package generator
import (
"fmt"
"strings"
"termdoku/internal/solver"
"time"
)
// PrintGrid prints the grid in a human-readable format.
func PrintGrid(g Grid) string {
var sb strings.Builder
sb.WriteString("┌───────┬───────┬───────┐\n")
for r := range 9 {
sb.WriteString("│ ")
for c := range 9 {
if g[r][c] == 0 {
sb.WriteString(".")
} else {
sb.WriteString(fmt.Sprintf("%d", g[r][c]))
}
if c%3 == 2 {
sb.WriteString(" │ ")
} else {
sb.WriteString(" ")
}
}
sb.WriteString("\n")
if r%3 == 2 && r != 8 {
sb.WriteString("├───────┼───────┼───────┤\n")
}
}
sb.WriteString("└───────┴───────┴───────┘\n")
return sb.String()
}
// GridToString converts a grid to a compact string representation.
func GridToString(g Grid) string {
var sb strings.Builder
for r := range 9 {
for c := range 9 {
if g[r][c] == 0 {
sb.WriteString(".")
} else {
sb.WriteString(fmt.Sprintf("%d", g[r][c]))
}
}
}
return sb.String()
}
// StringToGrid converts a string representation back to a grid.
// The string should be 81 characters long, with '0' or '.' for empty cells.
func StringToGrid(s string) (Grid, error) {
if len(s) != 81 {
return Grid{}, fmt.Errorf("invalid string length: expected 81, got %d", len(s))
}
var g Grid
for i, ch := range s {
r := i / 9
c := i % 9
if ch == '.' || ch == '0' {
g[r][c] = 0
} else if ch >= '1' && ch <= '9' {
g[r][c] = uint8(ch - '0')
} else {
return Grid{}, fmt.Errorf("invalid character at position %d: %c", i, ch)
}
}
return g, nil
}
// CompareGrids returns the differences between two grids.
func CompareGrids(g1, g2 Grid) []struct {
Row, Col int
Val1, Val2 uint8
} {
var diffs []struct {
Row, Col int
Val1, Val2 uint8
}
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
if g1[r][c] != g2[r][c] {
diffs = append(diffs, struct {
Row, Col int
Val1, Val2 uint8
}{
Row: r,
Col: c,
Val1: g1[r][c],
Val2: g2[r][c],
})
}
}
}
return diffs
}
// GetEmptyCells returns a list of all empty cell positions.
func GetEmptyCells(g Grid) []struct{ Row, Col int } {
var empty []struct{ Row, Col int }
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
if g[r][c] == 0 {
empty = append(empty, struct{ Row, Col int }{r, c})
}
}
}
return empty
}
// GetFilledCells returns a list of all filled cell positions with their values.
func GetFilledCells(g Grid) []struct {
Row, Col int
Value uint8
} {
var filled []struct {
Row, Col int
Value uint8
}
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
if g[r][c] != 0 {
filled = append(filled, struct {
Row, Col int
Value uint8
}{r, c, g[r][c]})
}
}
}
return filled
}
// GetRegionCells returns all cell positions in a 3x3 region.
func GetRegionCells(blockRow, blockCol int) []struct{ Row, Col int } {
if blockRow < 0 || blockRow > 2 || blockCol < 0 || blockCol > 2 {
return nil
}
var cells []struct{ Row, Col int }
startRow := blockRow * 3
startCol := blockCol * 3
for r := startRow; r < startRow+3; r++ {
for c := startCol; c < startCol+3; c++ {
cells = append(cells, struct{ Row, Col int }{r, c})
}
}
return cells
}
// ValidateGridStructure checks if a grid has valid structure (no duplicates).
func ValidateGridStructure(g Grid) []string {
var errors []string
// Check rows
for r := range 9 {
seen := make(map[uint8]bool)
for c := 0; c < 9; c++ {
v := g[r][c]
if v == 0 {
continue
}
if seen[v] {
errors = append(errors, fmt.Sprintf("Duplicate %d in row %d", v, r+1))
}
seen[v] = true
}
}
// Check columns
for c := range 9 {
seen := make(map[uint8]bool)
for r := range 9 {
v := g[r][c]
if v == 0 {
continue
}
if seen[v] {
errors = append(errors, fmt.Sprintf("Duplicate %d in column %d", v, c+1))
}
seen[v] = true
}
}
// Check 3x3 blocks
for br := range 3 {
for bc := range 3 {
seen := make(map[uint8]bool)
for r := br * 3; r < br*3+3; r++ {
for c := bc * 3; c < bc*3+3; c++ {
v := g[r][c]
if v == 0 {
continue
}
if seen[v] {
errors = append(errors, fmt.Sprintf("Duplicate %d in block (%d,%d)", v, br+1, bc+1))
}
seen[v] = true
}
}
}
}
return errors
}
// GetCandidateMap returns a map of all candidates for all empty cells.
func GetCandidateMap(g Grid) map[struct{ Row, Col int }][]uint8 {
candidateMap := make(map[struct{ Row, Col int }][]uint8)
for r := range 9 {
for c := range 9 {
if g[r][c] == 0 {
candidates := g.GetCandidates(r, c)
if len(candidates) > 0 {
candidateMap[struct{ Row, Col int }{r, c}] = candidates
}
}
}
}
return candidateMap
}
// CalculateCompletionPercentage returns the percentage of filled cells.
func CalculateCompletionPercentage(g Grid) float64 {
filled := g.CountFilledCells()
return (float64(filled) / 81.0) * 100.0
}
// GetMostConstrainedCell returns the empty cell with the fewest candidates.
// This is useful for implementing solving strategies.
func GetMostConstrainedCell(g Grid) (row, col int, candidates []uint8, found bool) {
minCandidates := 10
found = false
for r := range 9 {
for c := range 9 {
if g[r][c] == 0 {
cands := g.GetCandidates(r, c)
if len(cands) < minCandidates {
minCandidates = len(cands)
row = r
col = c
candidates = cands
found = true
}
}
}
}
return
}
// IsMinimalPuzzle checks if removing any filled cell would result in multiple solutions.
// This is computationally expensive and should be used sparingly.
func IsMinimalPuzzle(g Grid) bool {
// For each filled cell, try removing it and check if puzzle still has unique solution
for r := range 9 {
for c := range 9 {
if g[r][c] != 0 {
// Try removing this cell
backup := g[r][c]
g[r][c] = 0
// Check if still unique
solverGrid := convertToSolverGrid(g)
solutionCount := solver.CountSolutions(solverGrid, 100*time.Millisecond, 2)
// Restore the cell
g[r][c] = backup
// If removing this cell doesn't maintain uniqueness, it's not minimal
if solutionCount != 1 {
return false
}
}
}
}
return true
}