1package rippleeffect23import (4 "context"5 "crypto/sha256"6 "encoding/binary"7 "math"8 "math/rand/v2"9 "strconv"1011 "github.com/FelineStateMachine/puzzletea/difficulty"12 "github.com/FelineStateMachine/puzzletea/game"13)1415var _ game.CancellableEloSpawner = Mode{}1617func (m Mode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {18 return m.SpawnEloContext(context.Background(), seed, elo)19}2021func (m Mode) SpawnEloContext(ctx context.Context, seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {22 if err := difficulty.ValidateElo(elo); err != nil {23 return nil, difficulty.Report{}, err24 }25 if err := ctx.Err(); err != nil {26 return nil, difficulty.Report{}, err27 }2829 mode := rippleEffectModeForElo(m, elo)30 puzzle, err := mode.generatePuzzleSeeded(rippleEffectEloRNG(seed, elo))31 if err != nil {32 return nil, difficulty.Report{}, err33 }3435 gamer, err := New(mode, puzzle)36 if err != nil {37 return nil, difficulty.Report{}, err38 }39 report := rippleEffectDifficultyReport(ctx, elo, mode, puzzle)40 return gamer, report, nil41}4243func rippleEffectModeForElo(base Mode, elo difficulty.Elo) Mode {44 score := difficulty.Score01(elo)45 maxCage := min(base.Size, 3+int(math.Round(score)))46 givenRatio := 0.69 - score*0.2147 profile := rippleEffectProfileForElo(score, maxCage)4849 mode := base50 mode.MaxCage = maxCage51 mode.GivenRatio = givenRatio52 mode.profile = profile53 return mode54}5556func rippleEffectProfileForElo(score float64, maxCage int) generationProfile {57 switch {58 case score < 0.20:59 return generationProfile{60 cageWeights: []int{0, 5, 6, 3},61 frontierSamples: 3,62 shapeBias: shapeBiasCompact,63 minGivensByCage: []int{0, 1, 1, 2},64 maxSingletonCages: -1,65 }66 case score < 0.42:67 return generationProfile{68 cageWeights: []int{0, 3, 5, 4},69 frontierSamples: 3,70 shapeBias: shapeBiasCompact,71 minGivensByCage: []int{0, 1, 1, 2},72 maxSingletonCages: -1,73 }74 case score < 0.65:75 return generationProfile{76 cageWeights: growIntSlice([]int{0, 0, 3, 5, 4}, maxCage+1),77 frontierSamples: 2,78 shapeBias: shapeBiasNeutral,79 minGivensByCage: make([]int, maxCage+1),80 maxSingletonCages: 1,81 }82 case score < 0.86:83 return generationProfile{84 cageWeights: growIntSlice([]int{0, 0, 1, 4, 5}, maxCage+1),85 frontierSamples: 3,86 shapeBias: shapeBiasWinding,87 minGivensByCage: make([]int, maxCage+1),88 maxSingletonCages: 1,89 }90 default:91 return generationProfile{92 cageWeights: growIntSlice([]int{0, 0, 1, 2, 4, 5}, maxCage+1),93 frontierSamples: 4,94 shapeBias: shapeBiasWinding,95 minGivensByCage: make([]int, maxCage+1),96 maxSingletonCages: 1,97 }98 }99}100101func rippleEffectEloRNG(seed string, elo difficulty.Elo) *rand.Rand {102 sum := sha256.Sum256([]byte("rippleeffect\x00" + seed + "\x00" + strconv.Itoa(int(elo))))103 return rand.New(rand.NewPCG(104 binary.LittleEndian.Uint64(sum[0:8]),105 binary.LittleEndian.Uint64(sum[8:16]),106 ))107}108109func rippleEffectDifficultyReport(ctx context.Context, target difficulty.Elo, mode Mode, puzzle Puzzle) difficulty.Report {110 geo, err := buildGeometry(puzzle.Width, puzzle.Height, puzzle.Cages)111 if err != nil {112 return difficulty.Report{113 TargetElo: target,114 ActualElo: target,115 Confidence: difficulty.ConfidenceLow,116 Metrics: difficulty.Metrics{117 "width": float64(puzzle.Width),118 "height": float64(puzzle.Height),119 },120 }121 }122123 solutions := countSolutionsContext(ctx, geo, puzzle.Givens, 2)124 metrics := rippleEffectDifficultyMetrics(mode, puzzle, geo, solutions)125 confidence := difficulty.ConfidenceHigh126 if solutions < 0 {127 confidence = difficulty.ConfidenceLow128 metrics["solver_limited"] = 1129 } else if solutions != 1 {130 confidence = difficulty.ConfidenceLow131 }132133 return difficulty.Report{134 TargetElo: target,135 ActualElo: rippleEffectActualElo(metrics),136 Confidence: confidence,137 Metrics: metrics,138 }139}140141func rippleEffectDifficultyMetrics(mode Mode, puzzle Puzzle, geo *geometry, solutions int) difficulty.Metrics {142 cells := puzzle.Width * puzzle.Height143 givens := countFilledCells(puzzle.Givens)144 empty := cells - givens145 cageCount := len(puzzle.Cages)146 singletons := 0147 maxCageSize := 0148 totalCageSize := 0149 for _, cage := range puzzle.Cages {150 size := len(cage.Cells)151 if size == 1 {152 singletons++153 }154 if size > maxCageSize {155 maxCageSize = size156 }157 totalCageSize += size158 }159160 return difficulty.Metrics{161 "width": float64(puzzle.Width),162 "height": float64(puzzle.Height),163 "cells": float64(cells),164 "max_cage": float64(mode.MaxCage),165 "actual_max_cage": float64(maxCageSize),166 "cage_count": float64(cageCount),167 "avg_cage_size": rippleEffectRatio(totalCageSize, cageCount),168 "singleton_cages": float64(singletons),169 "given_count": float64(givens),170 "empty_count": float64(empty),171 "given_density": rippleEffectRatio(givens, cells),172 "target_givens": mode.GivenRatio,173 "solution_count": float64(solutions),174 "geometry_cages": float64(len(geo.cages)),175 }176}177178func countFilledCells(g grid) int {179 count := 0180 for y := range g {181 for x := range g[y] {182 if g[y][x] != 0 {183 count++184 }185 }186 }187 return count188}189190func rippleEffectActualElo(metrics difficulty.Metrics) difficulty.Elo {191 metricScore := 0.26*rippleEffectNormalize(metrics["cells"], 25, 81) +192 0.22*rippleEffectNormalize(1-metrics["given_density"], 0.20, 0.65) +193 0.18*rippleEffectNormalize(metrics["actual_max_cage"], 3, 5) +194 0.14*rippleEffectNormalize(metrics["avg_cage_size"], 1.5, 4.2) +195 0.12*rippleEffectNormalize(metrics["empty_count"], 8, 52) +196 0.08*(1-rippleEffectNormalize(metrics["singleton_cages"], 0, 6))197 targetScore := rippleEffectNormalize(0.69-metrics["target_givens"], 0, 0.21)198 score := 0.80*targetScore + 0.20*metricScore199200 return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))201}202203func rippleEffectRatio(numerator, denominator int) float64 {204 if denominator == 0 {205 return 0206 }207 return float64(numerator) / float64(denominator)208}209210func rippleEffectNormalize(value, low, high float64) float64 {211 if high <= low || value <= low {212 return 0213 }214 if value >= high {215 return 1216 }217 return (value - low) / (high - low)218}