(Feat): Initial Commit, Termdoku
This commit is contained in:
248
internal/generator/generator.go
Normal file
248
internal/generator/generator.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user