clone

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

refactor/root-tui-model
puzzletea / games / spellpuzzle / elo.go
elo.go 5.1K
1package spellpuzzle23import (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 SpellPuzzleMode) 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 := spellPuzzleModeForElo(m, elo)21 var bestPuzzle GeneratedPuzzle22 var bestReport difficulty.Report23 haveBest := false24 var lastErr error25 for candidate := range difficulty.CandidateCount(elo) {26 puzzle, err := GeneratePuzzleSeeded(mode, randForElo(spellPuzzleCandidateSeed(seed, candidate), elo))27 if err != nil {28 lastErr = err29 continue30 }31 report := scoreEloPuzzle(elo, 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 spell puzzle")41 }42 return nil, difficulty.Report{}, lastErr43 }4445 g, err := New(mode, bestPuzzle)46 if err != nil {47 return nil, difficulty.Report{}, err48 }49 return g, bestReport, nil50}5152func spellPuzzleModeForElo(base SpellPuzzleMode, elo difficulty.Elo) SpellPuzzleMode {53 score := difficulty.Score01(elo)54 bankSize := 5 + int(math.Floor(score*4))55 boardWords := 3 + int(math.Floor(score*6))56 minBonusWords := int(math.Round(score * 8))5758 mode := base59 mode.BankSize = bankSize60 mode.BoardWordCount = boardWords61 mode.MinBonusWords = minBonusWords62 return mode63}6465func randForElo(seed string, elo difficulty.Elo) *rand.Rand {66 sum := sha256.Sum256([]byte("spellpuzzle\x00" + seed + "\x00" + strconv.Itoa(int(elo))))67 return rand.New(rand.NewPCG(68 binary.LittleEndian.Uint64(sum[0:8]),69 binary.LittleEndian.Uint64(sum[8:16]),70 ))71}7273func spellPuzzleCandidateSeed(seed string, candidate int) string {74 if candidate == 0 {75 return seed76 }77 return seed + "\x00candidate:" + strconv.Itoa(candidate)78}7980func scoreEloPuzzle(target difficulty.Elo, puzzle GeneratedPuzzle) difficulty.Report {81 metrics := spellPuzzleMetrics(puzzle)82 score := normalizedMetric(metrics["bank_size"], 5, 9)*0.20 +83 normalizedMetric(metrics["board_word_count"], 3, 9)*0.20 +84 normalizedMetric(metrics["bonus_word_count"], 0, 16)*0.15 +85 normalizedMetric(metrics["average_word_length"], 3, 9)*0.15 +86 normalizedMetric(metrics["crossing_count"], 1, 8)*0.15 +87 normalizedMetric(metrics["allowed_word_count"], 10, 80)*0.158889 return difficulty.Report{90 TargetElo: target,91 ActualElo: difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))),92 Confidence: difficulty.ConfidenceMedium,93 Metrics: metrics,94 }95}9697func spellPuzzleMetrics(puzzle GeneratedPuzzle) difficulty.Metrics {98 minLength, maxLength, averageLength := wordLengthStats(puzzle.Placements)99 repeatedLetters, entropy := bankLetterStats(puzzle.Bank)100 board := buildBoard(puzzle.Placements)101102 return difficulty.Metrics{103 "bank_size": float64(len([]rune(puzzle.Bank))),104 "board_word_count": float64(len(puzzle.Placements)),105 "bonus_word_count": float64(len(puzzle.BonusWords)),106 "allowed_word_count": float64(len(puzzle.AllowedWord)),107 "min_word_length": float64(minLength),108 "max_word_length": float64(maxLength),109 "average_word_length": averageLength,110 "crossing_count": float64(crossingCount(puzzle.Placements)),111 "repeated_letters": float64(repeatedLetters),112 "letter_entropy": entropy,113 "board_width": float64(board.Width),114 "board_height": float64(board.Height),115 "occupied_cell_count": float64(occupiedCellCount(board)),116 }117}118119func wordLengthStats(placements []WordPlacement) (minLength, maxLength int, average float64) {120 if len(placements) == 0 {121 return 0, 0, 0122 }123124 minLength = len(placements[0].Text)125 total := 0126 for _, placement := range placements {127 length := len(placement.Text)128 minLength = min(minLength, length)129 maxLength = max(maxLength, length)130 total += length131 }132 return minLength, maxLength, float64(total) / float64(len(placements))133}134135func bankLetterStats(bank string) (repeatedLetters int, entropy float64) {136 counts := make(map[rune]int)137 for _, letter := range bank {138 counts[letter]++139 }140 for _, count := range counts {141 if count > 1 {142 repeatedLetters += count - 1143 }144 probability := float64(count) / float64(len([]rune(bank)))145 entropy -= probability * math.Log2(probability)146 }147 return repeatedLetters, entropy148}149150func crossingCount(placements []WordPlacement) int {151 counts := make(map[Position]int)152 for _, placement := range placements {153 for _, pos := range placement.Positions() {154 counts[pos]++155 }156 }157158 crossings := 0159 for _, count := range counts {160 if count > 1 {161 crossings++162 }163 }164 return crossings165}166167func occupiedCellCount(board board) int {168 count := 0169 for y := range board.Cells {170 for x := range board.Cells[y] {171 if board.Cells[y][x].Occupied {172 count++173 }174 }175 }176 return count177}178179func normalizedMetric(value, low, high float64) float64 {180 if value <= low {181 return 0182 }183 if value >= high {184 return 1185 }186 return (value - low) / (high - low)187}