clone

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

chore/app-model-decomp
puzzletea / games / fillomino / elo.go
elo.go 4.2K
1package fillomino23import (4 "crypto/sha256"5 "encoding/binary"6 "errors"7 "math"8 "math/rand/v2"9 "strconv"1011 "github.com/FelineStateMachine/puzzletea/difficulty"12 "github.com/FelineStateMachine/puzzletea/game"13)1415var _ game.EloSpawner = Mode{}1617func (m Mode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {18 if err := difficulty.ValidateElo(elo); err != nil {19 return nil, difficulty.Report{}, err20 }2122 mode := fillominoModeForElo(m, elo)23 var bestPuzzle Puzzle24 var bestReport difficulty.Report25 haveBest := false26 var lastErr error27 for candidate := range fillominoEloCandidateCount(elo) {28 puzzle, err := GeneratePuzzleSeeded(mode.Size, mode.Size, mode.MaxRegion, mode.GivenRatio, fillominoEloRNG(fillominoCandidateSeed(seed, candidate), elo))29 if err != nil {30 lastErr = err31 continue32 }33 report := fillominoDifficultyReport(elo, mode, puzzle)34 if difficulty.BetterCandidate(report, bestReport, elo, haveBest) {35 bestPuzzle = puzzle36 bestReport = report37 haveBest = true38 }39 }40 if !haveBest {41 if lastErr == nil {42 lastErr = errors.New("unable to generate Elo fillomino")43 }44 return nil, difficulty.Report{}, lastErr45 }46 g, err := New(mode, bestPuzzle)47 if err != nil {48 return nil, difficulty.Report{}, err49 }50 return g, bestReport, nil51}5253func fillominoModeForElo(base Mode, elo difficulty.Elo) Mode {54 score := difficulty.Score01(elo)55 maxRegion := 5 + int(math.Round(score*3))56 givenRatio := 0.70 - score*0.1457 mode := base58 mode.MaxRegion = min(maxRegion, max(1, mode.Size*mode.Size))59 mode.GivenRatio = givenRatio60 return mode61}6263func fillominoEloCandidateCount(elo difficulty.Elo) int {64 if elo >= 2400 {65 return 166 }67 return difficulty.CandidateCount(elo)68}6970func fillominoEloRNG(seed string, elo difficulty.Elo) *rand.Rand {71 sum := sha256.Sum256([]byte("fillomino\x00" + seed + "\x00" + strconv.Itoa(int(elo))))72 return rand.New(rand.NewPCG(73 binary.LittleEndian.Uint64(sum[0:8]),74 binary.LittleEndian.Uint64(sum[8:16]),75 ))76}7778func fillominoCandidateSeed(seed string, candidate int) string {79 if candidate == 0 {80 return seed81 }82 return seed + "\x00candidate:" + strconv.Itoa(candidate)83}8485func fillominoDifficultyReport(target difficulty.Elo, mode Mode, puzzle Puzzle) difficulty.Report {86 solutions := countSolutions(puzzle.Givens, mode.MaxRegion, 2)87 metrics := fillominoDifficultyMetrics(mode, puzzle, solutions)88 confidence := difficulty.ConfidenceHigh89 if solutions != 1 {90 confidence = difficulty.ConfidenceLow91 }92 return difficulty.Report{93 TargetElo: target,94 ActualElo: fillominoActualElo(metrics),95 Confidence: confidence,96 Metrics: metrics,97 }98}99100func fillominoDifficultyMetrics(mode Mode, puzzle Puzzle, solutions int) difficulty.Metrics {101 cells := puzzle.Width * puzzle.Height102 givens := 0103 maxRegionValue := 0104 for _, row := range puzzle.Givens {105 for _, value := range row {106 if value == 0 {107 continue108 }109 givens++110 if value > maxRegionValue {111 maxRegionValue = value112 }113 }114 }115 return difficulty.Metrics{116 "width": float64(puzzle.Width),117 "height": float64(puzzle.Height),118 "cells": float64(cells),119 "given_count": float64(givens),120 "unknown_count": float64(cells - givens),121 "given_ratio": fillominoRatio(givens, cells),122 "target_ratio": mode.GivenRatio,123 "max_region": float64(mode.MaxRegion),124 "max_given_region": float64(maxRegionValue),125 "solution_count": float64(solutions),126 }127}128129func fillominoActualElo(metrics difficulty.Metrics) difficulty.Elo {130 metricScore := 0.40*fillominoNormalize(metrics["cells"], 25, 144) +131 0.25*(1-fillominoNormalize(metrics["given_ratio"], 0.52, 0.70)) +132 0.20*fillominoNormalize(metrics["max_region"], 5, 9) +133 0.15*fillominoNormalize(metrics["unknown_count"], 8, 70)134 targetScore := 1 - fillominoNormalize(metrics["target_ratio"], 0.56, 0.70)135 score := 0.75*targetScore + 0.25*metricScore136 return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))137}138139func fillominoNormalize(value, low, high float64) float64 {140 if high <= low || value <= low {141 return 0142 }143 if value >= high {144 return 1145 }146 return (value - low) / (high - low)147}148149func fillominoRatio(numerator, denominator int) float64 {150 if denominator == 0 {151 return 0152 }153 return float64(numerator) / float64(denominator)154}