249 lines
5.9 KiB
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
|
|
}
|