1package sudoku23import (4 "crypto/sha256"5 "encoding/binary"6 "math"7 "math/rand/v2"8 "strconv"910 "github.com/FelineStateMachine/puzzletea/difficulty"11 "github.com/FelineStateMachine/puzzletea/game"12)1314var _ game.EloSpawner = SudokuMode{}1516type eloSolveStats struct {17 Nodes int18 Branches int19 MaxDepth int20}2122func (s SudokuMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {23 if err := difficulty.ValidateElo(elo); err != nil {24 return nil, difficulty.Report{}, err25 }2627 mode := sudokuModeForElo(s, elo)28 var bestProvided []cell29 var bestReport difficulty.Report30 haveBest := false31 for candidate := range difficulty.CandidateCount(elo) {32 provided := GenerateProvidedCellsSeeded(mode, sudokuEloRNG(sudokuCandidateSeed(seed, candidate), elo))33 report := scoreSudokuElo(elo, provided)34 if difficulty.BetterCandidate(report, bestReport, elo, haveBest) {35 bestProvided = provided36 bestReport = report37 haveBest = true38 }39 }40 g, err := New(mode, bestProvided)41 if err != nil {42 return nil, difficulty.Report{}, err43 }44 return g, bestReport, nil45}4647func sudokuModeForElo(base SudokuMode, elo difficulty.Elo) SudokuMode {48 score := difficulty.Score01(elo)49 provided := 45 - int(math.Round(score*28))50 if provided < 17 {51 provided = 1752 }53 mode := base54 mode.ProvidedCount = provided55 return mode56}5758func sudokuEloRNG(seed string, elo difficulty.Elo) *rand.Rand {59 sum := sha256.Sum256([]byte("sudoku\x00" + seed + "\x00" + strconv.Itoa(int(elo))))60 return rand.New(rand.NewPCG(61 binary.LittleEndian.Uint64(sum[0:8]),62 binary.LittleEndian.Uint64(sum[8:16]),63 ))64}6566func sudokuCandidateSeed(seed string, candidate int) string {67 if candidate == 0 {68 return seed69 }70 return seed + "\x00candidate:" + strconv.Itoa(candidate)71}7273func scoreSudokuElo(target difficulty.Elo, provided []cell) difficulty.Report {74 g := newGrid(provided)75 solutions, stats := countSolutionsWithEloStats(&g, 2)76 metrics := difficulty.Metrics{77 "clue_count": float64(len(provided)),78 "unknown_count": float64(gridSize*gridSize - len(provided)),79 "solution_count": float64(solutions),80 "solver_nodes": float64(stats.Nodes),81 "branches": float64(stats.Branches),82 "max_depth": float64(stats.MaxDepth),83 }8485 actual := sudokuActualElo(metrics)86 confidence := difficulty.ConfidenceHigh87 if solutions != 1 {88 confidence = difficulty.ConfidenceLow89 }9091 return difficulty.Report{92 TargetElo: target,93 ActualElo: actual,94 Confidence: confidence,95 Metrics: metrics,96 }97}9899func countSolutionsWithEloStats(g *grid, limit int) (int, eloSolveStats) {100 if limit <= 0 {101 return 0, eloSolveStats{}102 }103104 masks, ok := buildMasks(g)105 if !ok {106 return 0, eloSolveStats{}107 }108109 var stats eloSolveStats110 count := countSolutionsRecWithEloStats(g, limit, &masks, 0, &stats)111 return count, stats112}113114func countSolutionsRecWithEloStats(g *grid, limit int, masks *solverMasks, depth int, stats *eloSolveStats) int {115 if limit <= 0 {116 return 0117 }118119 stats.Nodes++120 if depth > stats.MaxDepth {121 stats.MaxDepth = depth122 }123124 x, y, candidateMask, found := findMostConstrainedEmptyCell(g, masks)125 if !found {126 return 1127 }128 if candidateMask == 0 {129 return 0130 }131132 count := 0133 candidateCount := countCandidateBits(candidateMask)134 if candidateCount > 1 {135 stats.Branches++136 }137 for val := 1; val <= gridSize; val++ {138 bit := valueBit(val)139 if candidateMask&bit == 0 {140 continue141 }142143 placeValue(g, masks, val, x, y)144 count += countSolutionsRecWithEloStats(g, limit-count, masks, depth+1, stats)145 clearValue(g, masks, val, x, y)146 if count >= limit {147 return count148 }149 }150 return count151}152153func sudokuActualElo(metrics difficulty.Metrics) difficulty.Elo {154 score := 0.55*normalizeSudokuMetric(metrics["unknown_count"], 36, 64) +155 0.25*normalizeSudokuMetric(math.Log10(metrics["solver_nodes"]+1), 1.2, 4.0) +156 0.15*normalizeSudokuMetric(metrics["branches"], 0, 120) +157 0.05*normalizeSudokuMetric(metrics["max_depth"], 0, 64)158 return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))159}160161func normalizeSudokuMetric(value, low, high float64) float64 {162 if value <= low {163 return 0164 }165 if value >= high {166 return 1167 }168 return (value - low) / (high - low)169}