1package hitori23import (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 = HitoriMode{}1617func (h HitoriMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {18 return h.SpawnEloContext(context.Background(), seed, elo)19}2021func (h HitoriMode) 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 := hitoriModeForElo(h, elo)30 var puzzle grid31 var err error32 if elo >= 2400 {33 puzzle = generateFastHitoriEloPuzzle(mode, hitoriEloRNG(seed+"\x00fallback", elo))34 } else {35 puzzle, err = GenerateSeededContext(ctx, mode.Size, mode.BlackRatio, hitoriEloRNG(seed, elo))36 if err != nil {37 if ctx.Err() == nil {38 return nil, difficulty.Report{}, err39 }40 puzzle = generateFastHitoriEloPuzzle(mode, hitoriEloRNG(seed+"\x00fallback", elo))41 }42 }4344 gamer, err := New(mode, puzzle)45 if err != nil {46 return nil, difficulty.Report{}, err47 }48 if elo >= 2400 {49 report := hitoriDifficultyReportWithoutCounting(elo, mode, puzzle)50 return gamer, report, nil51 }52 report := hitoriDifficultyReport(ctx, elo, mode, puzzle)53 return gamer, report, nil54}5556func hitoriModeForElo(base HitoriMode, elo difficulty.Elo) HitoriMode {57 score := difficulty.Score01(elo)58 blackRatio := 0.32 - score*0.045960 mode := base61 mode.BlackRatio = blackRatio62 return mode63}6465func hitoriEloRNG(seed string, elo difficulty.Elo) *rand.Rand {66 sum := sha256.Sum256([]byte("hitori\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 generateFastHitoriEloPuzzle(mode HitoriMode, rng *rand.Rand) grid {74 baseGrid := generateLatinSquareSeeded(mode.Size, rng)75 mask := generateValidMaskSeeded(mode.Size, mode.BlackRatio, rng)76 return constructPuzzleSeeded(baseGrid, mask, rng)77}7879func hitoriDifficultyReport(ctx context.Context, target difficulty.Elo, mode HitoriMode, puzzle grid) difficulty.Report {80 metrics := hitoriDifficultyMetrics(ctx, mode, puzzle)81 confidence := difficulty.ConfidenceMedium82 if metrics["solution_count"] < 0 {83 confidence = difficulty.ConfidenceLow84 }85 return difficulty.Report{86 TargetElo: target,87 ActualElo: hitoriActualElo(metrics),88 Confidence: confidence,89 Metrics: metrics,90 }91}9293func hitoriDifficultyReportWithoutCounting(target difficulty.Elo, mode HitoriMode, puzzle grid) difficulty.Report {94 metrics := hitoriDifficultyMetricsWithoutCounting(mode, puzzle)95 metrics["solution_count"] = -196 metrics["solver_limited"] = 197 return difficulty.Report{98 TargetElo: target,99 ActualElo: hitoriActualElo(metrics),100 Confidence: difficulty.ConfidenceLow,101 Metrics: metrics,102 }103}104105func hitoriDifficultyMetrics(ctx context.Context, mode HitoriMode, puzzle grid) difficulty.Metrics {106 metrics := hitoriDifficultyMetricsWithoutCounting(mode, puzzle)107 metrics["solution_count"] = float64(countPuzzleSolutionsContext(ctx, puzzle, len(puzzle), 2))108 return metrics109}110111func hitoriDifficultyMetricsWithoutCounting(mode HitoriMode, puzzle grid) difficulty.Metrics {112 size := len(puzzle)113 cells := size * size114 duplicateCells, duplicateGroups := hitoriDuplicatePressure(puzzle)115116 return difficulty.Metrics{117 "size": float64(size),118 "cells": float64(cells),119 "target_black_pct": mode.BlackRatio,120 "duplicate_cells": float64(duplicateCells),121 "duplicate_groups": float64(duplicateGroups),122 "duplicate_pct": hitoriRatio(duplicateCells, cells),123 }124}125126func hitoriDuplicatePressure(puzzle grid) (int, int) {127 duplicateCells := 0128 duplicateGroups := 0129130 for _, row := range puzzle {131 cells, groups := hitoriDuplicateLinePressure(row)132 duplicateCells += cells133 duplicateGroups += groups134 }135136 size := len(puzzle)137 for x := range size {138 col := make([]rune, size)139 for y := range size {140 col[y] = puzzle[y][x]141 }142 cells, groups := hitoriDuplicateLinePressure(col)143 duplicateCells += cells144 duplicateGroups += groups145 }146147 return duplicateCells, duplicateGroups148}149150func hitoriDuplicateLinePressure(line []rune) (int, int) {151 counts := make(map[rune]int, len(line))152 for _, value := range line {153 counts[value]++154 }155156 cells := 0157 groups := 0158 for _, count := range counts {159 if count < 2 {160 continue161 }162 cells += count163 groups++164 }165 return cells, groups166}167168func hitoriActualElo(metrics difficulty.Metrics) difficulty.Elo {169 metricScore := 0.50*hitoriNormalize(metrics["size"], 5, 12) +170 0.28*hitoriNormalize(metrics["duplicate_pct"], 0.20, 1.20) +171 0.17*(1-hitoriNormalize(metrics["target_black_pct"], 0.27, 0.32)) +172 0.05*hitoriNormalize(metrics["duplicate_groups"], 4, 36)173 targetScore := 1 - hitoriNormalize(metrics["target_black_pct"], 0.28, 0.32)174 score := 0.50*targetScore + 0.50*metricScore175176 return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))177}178179func hitoriNormalize(value, low, high float64) float64 {180 if high <= low || value <= low {181 return 0182 }183 if value >= high {184 return 1185 }186 return (value - low) / (high - low)187}188189func hitoriRatio(numerator, denominator int) float64 {190 if denominator == 0 {191 return 0192 }193 return float64(numerator) / float64(denominator)194}