Files
termdoku/internal/generator/generator.go

249 lines
5.9 KiB
Go

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
}