1package nurikabe23import (4 "context"5 "crypto/sha256"6 "encoding/binary"7 "errors"8 "math"9 "math/rand/v2"10 "strconv"1112 "github.com/FelineStateMachine/puzzletea/difficulty"13 "github.com/FelineStateMachine/puzzletea/game"14)1516var (17 _ game.EloSpawner = NurikabeMode{}18 _ game.CancellableEloSpawner = NurikabeMode{}19)2021func (n NurikabeMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {22 return n.SpawnEloContext(context.Background(), seed, elo)23}2425func (n NurikabeMode) SpawnEloContext(ctx context.Context, seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {26 if err := difficulty.ValidateElo(elo); err != nil {27 return nil, difficulty.Report{}, err28 }2930 mode := nurikabeModeForElo(n, elo)31 puzzle, err := GenerateSeededWithContext(ctx, mode, nurikabeEloRNG(seed, elo))32 if err != nil {33 return nil, difficulty.Report{}, err34 }3536 gamer, err := New(mode, puzzle)37 if err != nil {38 return nil, difficulty.Report{}, err39 }40 return gamer, scoreNurikabeElo(ctx, elo, puzzle), nil41}4243func nurikabeModeForElo(base NurikabeMode, elo difficulty.Elo) NurikabeMode {44 score := difficulty.Score01(elo)45 clueDensity := 0.28 - score*0.0846 maxIslandSize := max(1, min(base.Width*base.Height, max(base.Width, base.Height)+int(math.Round(score*5))))4748 mode := base49 mode.ClueDensity = clueDensity50 mode.MaxIslandSize = maxIslandSize51 return mode52}5354func nurikabeEloRNG(seed string, elo difficulty.Elo) *rand.Rand {55 sum := sha256.Sum256([]byte("nurikabe\x00" + seed + "\x00" + strconv.Itoa(int(elo))))56 return rand.New(rand.NewPCG(57 binary.LittleEndian.Uint64(sum[0:8]),58 binary.LittleEndian.Uint64(sum[8:16]),59 ))60}6162func scoreNurikabeElo(ctx context.Context, target difficulty.Elo, puzzle Puzzle) difficulty.Report {63 metrics := nurikabeDifficultyMetrics(puzzle)64 metrics["target_clue_density"] = puzzleClueDensity(puzzle)6566 solutions, stats, err := CountSolutionsContext(ctx, puzzle, 2, 200000)67 metrics["solution_count"] = float64(solutions)68 metrics["solver_nodes"] = float64(stats.Nodes)69 metrics["branches"] = float64(stats.Branches)70 metrics["max_depth"] = float64(stats.MaxDepth)7172 confidence := difficulty.ConfidenceHigh73 if err != nil {74 confidence = difficulty.ConfidenceLow75 if errors.Is(err, errNodeLimit) ||76 errors.Is(err, context.Canceled) ||77 errors.Is(err, context.DeadlineExceeded) {78 metrics["solver_limited"] = 179 }80 } else if solutions != 1 {81 confidence = difficulty.ConfidenceMedium82 }8384 return difficulty.Report{85 TargetElo: target,86 ActualElo: nurikabeActualElo(metrics),87 Confidence: confidence,88 Metrics: metrics,89 }90}9192func nurikabeDifficultyMetrics(puzzle Puzzle) difficulty.Metrics {93 metrics := difficulty.Metrics{94 "width": float64(puzzle.Width),95 "height": float64(puzzle.Height),96 "cells": float64(puzzle.Width * puzzle.Height),97 "unknown_count": float64(puzzle.Width * puzzle.Height),98 }99100 sizes := make([]int, 0, puzzle.Width*puzzle.Height)101 for y := range puzzle.Height {102 for x := range puzzle.Width {103 clue := puzzle.Clues[y][x]104 if clue <= 0 {105 continue106 }107 metrics["clue_count"]++108 metrics["island_cells"] += float64(clue)109 sizes = append(sizes, clue)110 if clue == 1 {111 metrics["singleton_clues"]++112 }113 if float64(clue) > metrics["largest_island"] {114 metrics["largest_island"] = float64(clue)115 }116 }117 }118119 if metrics["clue_count"] > 0 {120 metrics["average_island"] = metrics["island_cells"] / metrics["clue_count"]121 metrics["singleton_ratio"] = metrics["singleton_clues"] / metrics["clue_count"]122 metrics["size_entropy"] = componentSizeEntropy(sizes)123 metrics["normalized_spread"] = normalizedSpread(sizes)124 }125 metrics["sea_cells"] = metrics["cells"] - metrics["island_cells"]126 metrics["clue_density"] = metrics["clue_count"] / metrics["cells"]127 metrics["sea_ratio"] = metrics["sea_cells"] / metrics["cells"]128 metrics["unknown_count"] = metrics["cells"] - metrics["clue_count"]129130 return metrics131}132133func nurikabeActualElo(metrics difficulty.Metrics) difficulty.Elo {134 metricScore := 0.25*normalizeNurikabeMetric(metrics["cells"], 25, 144) +135 0.20*normalizeNurikabeMetric(metrics["unknown_count"], 18, 130) +136 0.20*normalizeNurikabeMetric(math.Log10(metrics["solver_nodes"]+1), 1.2, 5.0) +137 0.15*normalizeNurikabeMetric(metrics["branches"], 0, 180) +138 0.10*normalizeNurikabeMetric(metrics["max_depth"], 0, 80) +139 0.10*normalizeNurikabeMetric(metrics["normalized_spread"], 0.2, 1.8)140 targetScore := 1 - normalizeNurikabeMetric(metrics["target_clue_density"], 0.20, 0.28)141 score := 0.35*targetScore + 0.65*metricScore142143 if metrics["solution_count"] != 1 && metrics["solver_limited"] == 0 {144 score *= 0.85145 }146 return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))147}148149func puzzleClueDensity(puzzle Puzzle) float64 {150 cells := puzzle.Width * puzzle.Height151 if cells == 0 {152 return 0153 }154 clues := 0155 for y := range puzzle.Height {156 for x := range puzzle.Width {157 if puzzle.Clues[y][x] > 0 {158 clues++159 }160 }161 }162 return float64(clues) / float64(cells)163}164165func normalizeNurikabeMetric(value, low, high float64) float64 {166 if value <= low {167 return 0168 }169 if value >= high {170 return 1171 }172 return (value - low) / (high - low)173}