clone

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

main
puzzletea / games / lightsout / elo.go
elo.go 4.0K
1package lightsout23import (4 "crypto/sha256"5 "encoding/binary"6 "math"7 "math/rand/v2"8 "strconv"910 "github.com/FelineStateMachine/puzzletea/difficulty"11 "github.com/FelineStateMachine/puzzletea/game"12)1314var _ game.EloSpawner = Mode{}1516type eloSpec struct {17 width int18 height int19}2021func (m Mode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {22 if err := difficulty.ValidateElo(elo); err != nil {23 return nil, difficulty.Report{}, err24 }2526 spec := lightsOutSpecForMode(m)27 rng := lightsOutRandForElo(seed, elo)28 grid, report := generateLightsOutEloGrid(spec, rng, elo)2930 mode := m3132 g := newFromGrid(mode.Width, mode.Height, grid)33 return g, report, nil34}3536func lightsOutSpecForMode(mode Mode) eloSpec {37 return eloSpec{width: mode.Width, height: mode.Height}38}3940func lightsOutRandForElo(seed string, elo difficulty.Elo) *rand.Rand {41 sum := sha256.Sum256([]byte("lightsout\x00" + seed + "\x00" + strconv.Itoa(int(elo))))42 return rand.New(rand.NewPCG(43 binary.LittleEndian.Uint64(sum[0:8]),44 binary.LittleEndian.Uint64(sum[8:16]),45 ))46}4748func generateLightsOutEloGrid(spec eloSpec, rng *rand.Rand, target difficulty.Elo) ([][]bool, difficulty.Report) {49 best := GenerateSeeded(spec.width, spec.height, rng)50 bestReport := scoreLightsOutGrid(target, best)5152 for range 23 {53 candidate := GenerateSeeded(spec.width, spec.height, rng)54 report := scoreLightsOutGrid(target, candidate)55 if eloDistance(report.ActualElo, target) < eloDistance(bestReport.ActualElo, target) {56 best = candidate57 bestReport = report58 }59 }6061 return best, bestReport62}6364func scoreLightsOutGrid(target difficulty.Elo, grid [][]bool) difficulty.Report {65 metrics := lightsOutMetrics(grid)66 score := 0.45*normalizeLightsOut(metrics["cells"], 9, 81) +67 0.35*normalizeLightsOut(metrics["min_solution_moves"], 1, 41) +68 0.20*normalizeLightsOut(metrics["lights_on"], 1, 81)6970 return difficulty.Report{71 TargetElo: target,72 ActualElo: difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo)))),73 Confidence: difficulty.ConfidenceHigh,74 Metrics: metrics,75 }76}7778func lightsOutMetrics(grid [][]bool) difficulty.Metrics {79 height := len(grid)80 width := 081 if height > 0 {82 width = len(grid[0])83 }84 cells := width * height85 lightsOn := 086 for y := range grid {87 for x := range grid[y] {88 if grid[y][x] {89 lightsOn++90 }91 }92 }9394 minMoves, solved := minSolutionMoves(grid)95 solvable := 0.096 if solved {97 solvable = 198 }99100 return difficulty.Metrics{101 "width": float64(width),102 "height": float64(height),103 "cells": float64(cells),104 "lights_on": float64(lightsOn),105 "light_density": ratio(lightsOn, cells),106 "min_solution_moves": float64(minMoves),107 "move_density": ratio(minMoves, cells),108 "solvable": solvable,109 }110}111112func minSolutionMoves(grid [][]bool) (int, bool) {113 height := len(grid)114 if height == 0 {115 return 0, true116 }117 width := len(grid[0])118 if width == 0 {119 return 0, true120 }121122 best := width*height + 1123 for firstRowMask := range 1 << width {124 work := copyBoolGrid(grid)125 moves := 0126127 for x := range width {128 if firstRowMask&(1<<x) != 0 {129 Toggle(work, x, 0)130 moves++131 }132 }133134 for y := 1; y < height; y++ {135 for x := range width {136 if work[y-1][x] {137 Toggle(work, x, y)138 moves++139 }140 }141 }142143 if IsSolved(work) && moves < best {144 best = moves145 }146 }147148 if best == width*height+1 {149 return 0, false150 }151 return best, true152}153154func copyBoolGrid(grid [][]bool) [][]bool {155 copied := make([][]bool, len(grid))156 for y := range grid {157 copied[y] = make([]bool, len(grid[y]))158 copy(copied[y], grid[y])159 }160 return copied161}162163func normalizeLightsOut(value, minValue, maxValue float64) float64 {164 if maxValue <= minValue {165 return 0166 }167 return math.Max(0, math.Min(1, (value-minValue)/(maxValue-minValue)))168}169170func ratio(numerator, denominator int) float64 {171 if denominator == 0 {172 return 0173 }174 return float64(numerator) / float64(denominator)175}176177func eloDistance(a, b difficulty.Elo) difficulty.Elo {178 if a > b {179 return a - b180 }181 return b - a182}