1package wordsearch23import (4 "fmt"5 "hash/fnv"6 "math"7 "math/rand/v2"89 "github.com/FelineStateMachine/puzzletea/difficulty"10 "github.com/FelineStateMachine/puzzletea/game"11)1213var _ game.EloSpawner = WordSearchMode{}1415type wordSearchEloSpec struct {16 width int17 height int18 wordCount int19 minWordLen int20 maxWordLen int21 allowedDirs []Direction22}2324func (w WordSearchMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {25 if err := difficulty.ValidateElo(elo); err != nil {26 return nil, difficulty.Report{}, err27 }2829 spec := wordSearchSpecForElo(w, elo)30 var bestGrid grid31 var bestWords []Word32 var bestReport difficulty.Report33 haveBest := false34 for candidate := range difficulty.CandidateCount(elo) {35 rng := wordSearchEloRNG(wordSearchCandidateSeed(seed, candidate), elo)36 g, words, stats := generateWordSearchSeededWithStats(37 spec.width,38 spec.height,39 spec.wordCount,40 spec.minWordLen,41 spec.maxWordLen,42 spec.allowedDirs,43 rng,44 )45 report := wordSearchDifficultyReport(elo, spec, words, stats)46 if difficulty.BetterCandidate(report, bestReport, elo, haveBest) {47 bestGrid = g48 bestWords = words49 bestReport = report50 haveBest = true51 }52 }5354 mode := w55 mode.Width = spec.width56 mode.Height = spec.height57 mode.WordCount = spec.wordCount58 mode.MinWordLen = spec.minWordLen59 mode.MaxWordLen = spec.maxWordLen60 mode.AllowedDirs = append([]Direction(nil), spec.allowedDirs...)6162 gamer, err := New(mode, bestGrid, bestWords)63 if err != nil {64 return nil, difficulty.Report{}, err65 }6667 return gamer, bestReport, nil68}6970func wordSearchSpecForElo(base WordSearchMode, elo difficulty.Elo) wordSearchEloSpec {71 score := difficulty.Score01(elo)72 dirCount := 3 + int(math.Round(score*5))73 if dirCount > len(selectionDirections) {74 dirCount = len(selectionDirections)75 }7677 minLen := 3 + int(math.Floor(score*3))78 maxLen := 5 + int(math.Round(score*5))79 maxAllowedLen := max(base.Width, base.Height)80 minLen = min(minLen, maxAllowedLen)81 maxLen = min(maxLen, maxAllowedLen)82 if maxLen < minLen {83 maxLen = minLen84 }85 wordCount := 5 + int(math.Round(score*13))86 wordCount = min(wordCount, max(1, base.Width*base.Height/minLen))8788 return wordSearchEloSpec{89 width: base.Width,90 height: base.Height,91 wordCount: wordCount,92 minWordLen: minLen,93 maxWordLen: maxLen,94 allowedDirs: append([]Direction(nil), selectionDirections[:dirCount]...),95 }96}9798func wordSearchEloRNG(seed string, elo difficulty.Elo) *rand.Rand {99 h := fnv.New64a()100 _, _ = h.Write([]byte("wordsearch"))101 _, _ = h.Write([]byte{0})102 _, _ = h.Write([]byte(seed))103 _, _ = h.Write([]byte(fmt.Sprintf("\x00%d", elo)))104 s := h.Sum64()105 return rand.New(rand.NewPCG(s, ^s))106}107108func wordSearchCandidateSeed(seed string, candidate int) string {109 if candidate == 0 {110 return seed111 }112 return seed + fmt.Sprintf("\x00candidate:%d", candidate)113}114115func wordSearchDifficultyReport(116 target difficulty.Elo,117 spec wordSearchEloSpec,118 words []Word,119 stats placementStats,120) difficulty.Report {121 metrics := wordSearchDifficultyMetrics(spec, words, stats)122 actual := wordSearchActualElo(metrics, stats)123124 confidence := difficulty.ConfidenceHigh125 if stats.PlacedWords != stats.TargetWords {126 confidence = difficulty.ConfidenceMedium127 }128129 return difficulty.Report{130 TargetElo: target,131 ActualElo: actual,132 Confidence: confidence,133 Metrics: metrics,134 }135}136137func wordSearchDifficultyMetrics(spec wordSearchEloSpec, words []Word, stats placementStats) difficulty.Metrics {138 occupied := occupiedWordCells(words)139 totalCells := spec.width * spec.height140 avgWordLen, maxWordLen := wordLengthMetrics(words)141 uniqueDirs := uniqueDirectionCount(words)142 fallbackRate := 0.0143 if stats.TargetWords > 0 {144 fallbackRate = float64(stats.FallbackUsed) / float64(stats.TargetWords)145 }146147 return difficulty.Metrics{148 "width": float64(spec.width),149 "height": float64(spec.height),150 "cells": float64(totalCells),151 "target_words": float64(stats.TargetWords),152 "placed_words": float64(stats.PlacedWords),153 "failed_words": float64(stats.FailedWords),154 "success_rate": stats.SuccessRate(),155 "avg_word_length": avgWordLen,156 "max_word_length": float64(maxWordLen),157 "unique_directions": float64(uniqueDirs),158 "occupied_cells": float64(occupied),159 "density": safeRatio(occupied, totalCells),160 "attempts_per_word": stats.AttemptsPerWord(),161 "fallback_rate": fallbackRate,162 }163}164165func wordSearchActualElo(metrics difficulty.Metrics, stats placementStats) difficulty.Elo {166 score := 0.16*normalize(metrics["cells"], 100, 400) +167 0.24*normalize(metrics["placed_words"], 5, 18) +168 0.22*normalize(metrics["avg_word_length"], 3, 10) +169 0.15*normalize(metrics["unique_directions"], 3, 8) +170 0.16*normalize(metrics["density"], 0.08, 0.36) +171 0.07*normalize(metrics["attempts_per_word"], 1, 20)172173 score *= stats.SuccessRate()174 return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))175}176177func occupiedWordCells(words []Word) int {178 seen := make(map[Position]struct{})179 for i := range words {180 for _, pos := range words[i].Positions() {181 seen[pos] = struct{}{}182 }183 }184 return len(seen)185}186187func wordLengthMetrics(words []Word) (float64, int) {188 if len(words) == 0 {189 return 0, 0190 }191192 total := 0193 maxLen := 0194 for _, word := range words {195 length := len(word.Text)196 total += length197 if length > maxLen {198 maxLen = length199 }200 }201 return float64(total) / float64(len(words)), maxLen202}203204func uniqueDirectionCount(words []Word) int {205 seen := make(map[Direction]struct{})206 for _, word := range words {207 seen[word.Direction] = struct{}{}208 }209 return len(seen)210}211212func safeRatio(numerator, denominator int) float64 {213 if denominator == 0 {214 return 0215 }216 return float64(numerator) / float64(denominator)217}218219func normalize(value, min, max float64) float64 {220 if max <= min {221 return 0222 }223 if value <= min {224 return 0225 }226 if value >= max {227 return 1228 }229 return (value - min) / (max - min)230}