clone

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

chore/app-model-decomp
puzzletea / games / hashiwokakero / elo.go
elo.go 4.4K
1package hashiwokakero23import (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 = HashiMode{}1617func (h HashiMode) 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 := hashiModeForElo(h, elo)23 var bestPuzzle Puzzle24 var bestReport difficulty.Report25 haveBest := false26 var lastErr error27 for candidate := range difficulty.CandidateCount(elo) {28 puzzle, err := GeneratePuzzleSeeded(mode, hashiEloRNG(hashiCandidateSeed(seed, candidate), elo))29 if err != nil {30 lastErr = err31 continue32 }33 report := hashiDifficultyReport(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 hashi")43 }44 return nil, difficulty.Report{}, lastErr45 }46 return New(mode, bestPuzzle), bestReport, nil47}4849func hashiModeForElo(base HashiMode, elo difficulty.Elo) HashiMode {50 score := difficulty.Score01(elo)51 density := 0.11 + score*0.2952 cells := base.Width * base.Height53 target := int(math.Round(float64(cells) * density))54 minIslands := max(3, target-2)55 maxIslands := min(cells, target+2)56 if minIslands > maxIslands {57 minIslands = maxIslands58 }5960 mode := base61 mode.MinIslands = minIslands62 mode.MaxIslands = maxIslands63 return mode64}6566func hashiEloRNG(seed string, elo difficulty.Elo) *rand.Rand {67 gameID := string(Definition.ID)68 if gameID == "" {69 gameID = "hashiwokakero"70 }71 sum := sha256.Sum256([]byte(gameID + "\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 hashiCandidateSeed(seed string, candidate int) string {79 if candidate == 0 {80 return seed81 }82 return seed + "\x00candidate:" + strconv.Itoa(candidate)83}8485func hashiDifficultyReport(target difficulty.Elo, mode HashiMode, puzzle Puzzle) difficulty.Report {86 metrics := hashiDifficultyMetrics(mode, puzzle)87 return difficulty.Report{88 TargetElo: target,89 ActualElo: hashiActualElo(metrics),90 Confidence: difficulty.ConfidenceMedium,91 Metrics: metrics,92 }93}9495func hashiDifficultyMetrics(mode HashiMode, puzzle Puzzle) difficulty.Metrics {96 cells := puzzle.Width * puzzle.Height97 islands := len(puzzle.Islands)98 totalRequired := 099 maxRequired := 0100 highRequired := 0101 for _, island := range puzzle.Islands {102 totalRequired += island.Required103 if island.Required > maxRequired {104 maxRequired = island.Required105 }106 if island.Required >= 5 {107 highRequired++108 }109 }110111 connectablePairs := len(connectableIslandPairs(&puzzle))112 return difficulty.Metrics{113 "width": float64(puzzle.Width),114 "height": float64(puzzle.Height),115 "cells": float64(cells),116 "islands": float64(islands),117 "island_density": hashiRatio(islands, cells),118 "target_minlands": float64(mode.MinIslands),119 "target_maxlands": float64(mode.MaxIslands),120 "total_required": float64(totalRequired),121 "avg_required": hashiRatio(totalRequired, islands),122 "max_required": float64(maxRequired),123 "high_required": float64(highRequired),124 "connectable_pairs": float64(connectablePairs),125 "pair_density": hashiRatio(connectablePairs, max(1, islands)),126 }127}128129func hashiActualElo(metrics difficulty.Metrics) difficulty.Elo {130 score := 0.24*normalizeHashi(metrics["width"], 7, 13) +131 0.20*normalizeHashi(metrics["island_density"], 0.14, 0.42) +132 0.17*normalizeHashi(metrics["total_required"]/metrics["cells"], 0.16, 0.58) +133 0.15*normalizeHashi(metrics["avg_required"], 1.6, 5.0) +134 0.13*normalizeHashi(metrics["pair_density"], 1.0, 3.5) +135 0.11*normalizeHashi(metrics["high_required"], 0, metrics["islands"]*0.45)136137 return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))138}139140func normalizeHashi(value, low, high float64) float64 {141 if high <= low || value <= low {142 return 0143 }144 if value >= high {145 return 1146 }147 return (value - low) / (high - low)148}149150func hashiRatio(numerator, denominator int) float64 {151 if denominator == 0 {152 return 0153 }154 return float64(numerator) / float64(denominator)155}