clone

fifteen puzzle games for the terminal, with daily puzzles, weekly ladders and xp, built on bubble tea

refactor/root-tui-model
puzzletea / games / takuzuplus / elo.go
elo.go 6.7K
1package takuzuplus23import (4 "context"5 "crypto/sha256"6 "encoding/binary"7 "math"8 "math/rand/v2"9 "strconv"1011 "github.com/FelineStateMachine/puzzletea/difficulty"12 "github.com/FelineStateMachine/puzzletea/game"13 "github.com/FelineStateMachine/puzzletea/games/takuzu"14)1516var (17 _ game.EloSpawner = TakuzuPlusMode{}18 _ game.CancellableEloSpawner = TakuzuPlusMode{}19)2021type takuzuPlusEloCounterStats struct {22 Nodes int23 Branches int24 MaxDepth int25}2627func (t TakuzuPlusMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {28 return t.SpawnEloContext(context.Background(), seed, elo)29}3031func (t TakuzuPlusMode) SpawnEloContext(ctx context.Context, seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {32 if err := difficulty.ValidateElo(elo); err != nil {33 return nil, difficulty.Report{}, err34 }35 if err := ctx.Err(); err != nil {36 return nil, difficulty.Report{}, err37 }3839 mode := takuzuPlusModeForElo(t, elo)40 rng := takuzuPlusEloRNG(seed, elo)41 complete := generateCompleteSeeded(mode.Size, rng)42 puzzle, provided, rels := generatePuzzleSeeded(complete, mode.Size, mode.Prefilled, mode.profile, rng)4344 gamer, err := New(mode, puzzle, provided, rels)45 if err != nil {46 return nil, difficulty.Report{}, err47 }48 report := scoreTakuzuPlusElo(ctx, elo, puzzle, provided, rels, mode.Prefilled)49 return gamer, report, nil50}5152func takuzuPlusModeForElo(base TakuzuPlusMode, elo difficulty.Elo) TakuzuPlusMode {53 score := difficulty.Score01(elo)54 prefilled := 0.52 - score*0.2455 profileIndex := int(math.Round(score * float64(len(modeProfiles)-1)))56 if profileIndex < 0 {57 profileIndex = 058 }59 if profileIndex >= len(modeProfiles) {60 profileIndex = len(modeProfiles) - 161 }6263 mode := base64 mode.Prefilled = prefilled65 mode.profile = modeProfiles[profileIndex]66 return mode67}6869func takuzuPlusEloRNG(seed string, elo difficulty.Elo) *rand.Rand {70 sum := sha256.Sum256([]byte("takuzuplus\x00" + seed + "\x00" + strconv.Itoa(int(elo))))71 return rand.New(rand.NewPCG(72 binary.LittleEndian.Uint64(sum[0:8]),73 binary.LittleEndian.Uint64(sum[8:16]),74 ))75}7677func scoreTakuzuPlusElo(78 ctx context.Context,79 target difficulty.Elo,80 puzzle grid,81 provided [][]bool,82 rels relations,83 targetPrefilled float64,84) difficulty.Report {85 solutions, stats := countTakuzuPlusSolutionsWithEloStats(ctx, puzzle.clone(), len(puzzle), 2, rels)86 relationMetrics := analyzeRelations(rels, provided, len(puzzle))87 metrics := takuzuPlusDifficultyMetrics(puzzle, provided, rels, targetPrefilled, solutions, stats, relationMetrics)88 confidence := difficulty.ConfidenceHigh89 if solutions < 0 {90 confidence = difficulty.ConfidenceLow91 metrics["solver_limited"] = 192 } else if solutions != 1 {93 confidence = difficulty.ConfidenceLow94 }9596 return difficulty.Report{97 TargetElo: target,98 ActualElo: takuzuPlusActualElo(metrics),99 Confidence: confidence,100 Metrics: metrics,101 }102}103104func takuzuPlusDifficultyMetrics(105 puzzle grid,106 provided [][]bool,107 rels relations,108 targetPrefilled float64,109 solutions int,110 stats takuzuPlusEloCounterStats,111 relationMetrics relationMetrics,112) difficulty.Metrics {113 size := len(puzzle)114 cells := size * size115 clues := 0116 for y := range provided {117 for x := range provided[y] {118 if provided[y][x] {119 clues++120 }121 }122 }123 empties := cells - clues124 relationCount := countRelations(rels)125126 return difficulty.Metrics{127 "size": float64(size),128 "cells": float64(cells),129 "clue_count": float64(clues),130 "empty_count": float64(empties),131 "clue_density": takuzuPlusRatio(clues, cells),132 "target_prefilled": targetPrefilled,133 "solution_count": float64(solutions),134 "solver_nodes": float64(stats.Nodes),135 "branches": float64(stats.Branches),136 "max_depth": float64(stats.MaxDepth),137 "relation_count": float64(relationCount),138 "same_relations": float64(relationMetrics.SameCount),139 "diff_relations": float64(relationMetrics.DifferentCount),140 "horizontal_clues": float64(relationMetrics.HorizontalCount),141 "vertical_clues": float64(relationMetrics.VerticalCount),142 "occupied_regions": float64(relationMetrics.OccupiedRegions),143 "min_relation_gap": float64(relationMetrics.MinExistingGap),144 "one_endpoint_clues": float64(relationMetrics.EndpointCounts[endpointOneProvided]),145 }146}147148func countTakuzuPlusSolutionsWithEloStats(ctx context.Context, g grid, size, limit int, rels relations) (int, takuzuPlusEloCounterStats) {149 if limit <= 0 {150 return 0, takuzuPlusEloCounterStats{}151 }152 var counter takuzuPlusEloCounterStats153 count := countTakuzuPlusSolutionsRecWithEloStats(ctx, g, size, limit, rels, 0, &counter)154 return count, counter155}156157func countTakuzuPlusSolutionsRecWithEloStats(158 ctx context.Context,159 g grid,160 size int,161 limit int,162 rels relations,163 depth int,164 counter *takuzuPlusEloCounterStats,165) int {166 if ctx.Err() != nil {167 return -1168 }169 counter.Nodes++170 if depth > counter.MaxDepth {171 counter.MaxDepth = depth172 }173174 choice := selectMRVCell(g, size, rels)175 if choice.x < 0 {176 if checkSolvedGrid(g, size, rels) {177 return 1178 }179 return 0180 }181 if choice.count == 0 {182 return 0183 }184 if choice.count > 1 {185 counter.Branches++186 }187188 total := 0189 for i := range choice.count {190 val := choice.vals[i]191 g[choice.y][choice.x] = val192 n := countTakuzuPlusSolutionsRecWithEloStats(ctx, g, size, limit-total, rels, depth+1, counter)193 g[choice.y][choice.x] = emptyCell194 if n < 0 {195 return n196 }197 total += n198 if total >= limit {199 return total200 }201 }202 return total203}204205func checkSolvedGrid(g grid, size int, rels relations) bool {206 for y := range size {207 for x := range size {208 if g[y][x] == emptyCell {209 return false210 }211 }212 }213 return takuzu.CheckConstraintsGrid(g, size) &&214 takuzu.HasUniqueLinesGrid(g, size) &&215 checkRelations(g, rels)216}217218func takuzuPlusActualElo(metrics difficulty.Metrics) difficulty.Elo {219 score := 0.30*takuzuPlusNormalize(metrics["size"], 6, 14) +220 0.24*(1-takuzuPlusNormalize(metrics["clue_density"], 0.28, 0.52)) +221 0.16*takuzuPlusNormalize(metrics["max_depth"], 0, metrics["empty_count"]) +222 0.10*takuzuPlusNormalize(metrics["branches"], 0, metrics["empty_count"]) +223 0.08*takuzuPlusNormalize(metrics["solver_nodes"], 1, math.Max(2, metrics["empty_count"]*3)) +224 0.08*takuzuPlusNormalize(metrics["relation_count"], 2, 18) +225 0.04*takuzuPlusNormalize(metrics["occupied_regions"], 1, 4)226227 return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))228}229230func takuzuPlusNormalize(value, low, high float64) float64 {231 if high <= low || value <= low {232 return 0233 }234 if value >= high {235 return 1236 }237 return (value - low) / (high - low)238}239240func takuzuPlusRatio(numerator, denominator int) float64 {241 if denominator == 0 {242 return 0243 }244 return float64(numerator) / float64(denominator)245}