clone

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

bug/architecture-test-trim-path
puzzletea / games / netwalk / elo.go
elo.go 4.7K
1package netwalk23import (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)1415func (m NetwalkMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {16 if err := difficulty.ValidateElo(elo); err != nil {17 return nil, difficulty.Report{}, err18 }1920 mode := netwalkModeForElo(m, elo)21 var bestPuzzle Puzzle22 var bestReport difficulty.Report23 haveBest := false24 var lastErr error25 for candidate := range difficulty.CandidateCount(elo) {26 puzzle, err := GenerateSeededWithDensity(mode.Size, mode.FillRatio, mode.Profile, netwalkEloRNG(netwalkCandidateSeed(seed, candidate), elo))27 if err != nil {28 lastErr = err29 continue30 }31 report := netwalkDifficultyReport(elo, mode, puzzle)32 if difficulty.BetterCandidate(report, bestReport, elo, haveBest) {33 bestPuzzle = puzzle34 bestReport = report35 haveBest = true36 }37 }38 if !haveBest {39 if lastErr == nil {40 lastErr = errors.New("unable to generate Elo netwalk")41 }42 return nil, difficulty.Report{}, lastErr43 }4445 gamer, err := New(mode, bestPuzzle)46 if err != nil {47 return nil, difficulty.Report{}, err48 }49 return gamer, bestReport, nil50}5152func netwalkModeForElo(base NetwalkMode, elo difficulty.Elo) NetwalkMode {53 score := difficulty.Score01(elo)54 fillRatio := 0.34 + score*0.4455 profile := netwalkProfileForElo(score)5657 mode := base58 mode.FillRatio = fillRatio59 mode.Profile = profile60 return mode61}6263func netwalkProfileForElo(score float64) generateProfile {64 switch {65 case score < 0.20:66 return miniProfile67 case score < 0.42:68 return easyProfile69 case score < 0.65:70 return mediumProfile71 case score < 0.86:72 return hardProfile73 default:74 return expertProfile75 }76}7778func netwalkEloRNG(seed string, elo difficulty.Elo) *rand.Rand {79 sum := sha256.Sum256([]byte("netwalk\x00" + seed + "\x00" + strconv.Itoa(int(elo))))80 return rand.New(rand.NewPCG(81 binary.LittleEndian.Uint64(sum[0:8]),82 binary.LittleEndian.Uint64(sum[8:16]),83 ))84}8586func netwalkCandidateSeed(seed string, candidate int) string {87 if candidate == 0 {88 return seed89 }90 return seed + "\x00candidate:" + strconv.Itoa(candidate)91}9293func netwalkDifficultyReport(target difficulty.Elo, mode NetwalkMode, puzzle Puzzle) difficulty.Report {94 metrics := netwalkDifficultyMetrics(mode, puzzle)95 return difficulty.Report{96 TargetElo: target,97 ActualElo: netwalkActualElo(metrics),98 Confidence: difficulty.ConfidenceMedium,99 Metrics: metrics,100 }101}102103func netwalkDifficultyMetrics(mode NetwalkMode, puzzle Puzzle) difficulty.Metrics {104 state := analyzePuzzle(puzzle)105 metrics := difficulty.Metrics{106 "size": float64(puzzle.Size),107 "cells": float64(puzzle.Size * puzzle.Size),108 "active_tiles": float64(state.nonEmpty),109 "density": safeFloatRatio(state.nonEmpty, puzzle.Size*puzzle.Size),110 "target_density": mode.FillRatio,111 "connected_tiles": float64(state.connected),112 "dangling_edges": float64(state.dangling),113 "locked_tiles": float64(state.locked),114 }115116 rotationOptions := 0117 for y := range puzzle.Size {118 for x := range puzzle.Size {119 t := puzzle.Tiles[y][x]120 if !isActive(t) {121 continue122 }123 rotationOptions += len(uniqueRotations(t.BaseMask))124 switch degree(t.BaseMask) {125 case 1:126 metrics["leaf_tiles"]++127 case 2:128 if t.BaseMask == north|south || t.BaseMask == east|west {129 metrics["straight_tiles"]++130 } else {131 metrics["elbow_tiles"]++132 }133 case 3:134 metrics["tee_tiles"]++135 case 4:136 metrics["cross_tiles"]++137 }138 if t.Rotation != 0 {139 metrics["initially_rotated_tiles"]++140 }141 }142 }143 metrics["rotation_options"] = float64(rotationOptions)144 metrics["avg_rotation_options"] = safeFloatRatio(rotationOptions, state.nonEmpty)145 return metrics146}147148func netwalkActualElo(metrics difficulty.Metrics) difficulty.Elo {149 metricScore := 0.24*normalizeFloat(metrics["size"], 5, 13) +150 0.20*normalizeFloat(metrics["density"], 0.20, 0.80) +151 0.18*normalizeFloat(metrics["tee_tiles"]+metrics["cross_tiles"], 0, 18) +152 0.14*normalizeFloat(metrics["elbow_tiles"], 0, 32) +153 0.14*normalizeFloat(metrics["avg_rotation_options"], 1, 4) +154 0.10*normalizeFloat(metrics["initially_rotated_tiles"], 0, metrics["active_tiles"])155 targetScore := normalizeFloat(metrics["target_density"], 0.34, 0.78)156 score := 0.45*targetScore + 0.55*metricScore157158 return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))159}160161func normalizeFloat(value, low, high float64) float64 {162 if high <= low || value <= low {163 return 0164 }165 if value >= high {166 return 1167 }168 return (value - low) / (high - low)169}170171func safeFloatRatio(numerator, denominator int) float64 {172 if denominator == 0 {173 return 0174 }175 return float64(numerator) / float64(denominator)176}