1package resolve23import (4 "errors"5 "fmt"6 "hash/fnv"7 "math/rand/v2"8 "strings"910 "github.com/FelineStateMachine/puzzletea/difficulty"11 "github.com/FelineStateMachine/puzzletea/game"12 "github.com/FelineStateMachine/puzzletea/puzzle"13 "github.com/FelineStateMachine/puzzletea/registry"14)1516// Normalize lowercases and replaces hyphens/underscores with spaces for17// fuzzy matching of CLI arguments to mode names.18func Normalize(s string) string {19 return puzzle.NormalizeName(s)20}2122func ModeEntry(entry registry.Entry, name string) (registry.ModeEntry, error) {23 if len(entry.Modes) == 0 {24 return registry.ModeEntry{}, fmt.Errorf("game %q has no available modes", entry.Definition.Name)25 }2627 if name == "" {28 mode := entry.Modes[0]29 return mode, nil30 }3132 norm := Normalize(name)33 for _, mode := range entry.Modes {34 if Normalize(mode.Definition.Title) == norm {35 return mode, nil36 }37 }3839 return registry.ModeEntry{}, fmt.Errorf("unknown mode %q for %s\n\nAvailable modes:\n %s",40 name, entry.Definition.Name, strings.Join(ModeNames(entry), "\n "))41}4243type VariantSelection struct {44 Variant registry.VariantEntry45 ExplicitElo *difficulty.Elo46 LegacyAlias *puzzle.LegacyModeAlias47 DisplayTitle string48}4950func VariantEntry(entry registry.Entry, name string) (VariantSelection, error) {51 if len(entry.Variants) == 0 {52 return VariantSelection{}, fmt.Errorf("game %q has no available variants", entry.Definition.Name)53 }5455 if name == "" {56 variant := entry.Variants[0]57 return VariantSelection{58 Variant: variant,59 DisplayTitle: variant.Definition.Title,60 }, nil61 }6263 norm := Normalize(name)64 for _, variant := range entry.Variants {65 if Normalize(variant.Definition.Title) == norm || Normalize(string(variant.Definition.ID)) == norm {66 return VariantSelection{67 Variant: variant,68 DisplayTitle: variant.Definition.Title,69 }, nil70 }71 }7273 for _, alias := range entry.LegacyModes {74 if legacyAliasMatches(alias, norm) {75 variant, ok := variantByID(entry, alias.TargetVariantID)76 if !ok {77 return VariantSelection{}, fmt.Errorf("legacy mode %q targets missing variant %q", alias.Title, alias.TargetVariantID)78 }79 elo := alias.PresetElo80 aliasCopy := alias81 return VariantSelection{82 Variant: variant,83 ExplicitElo: &elo,84 LegacyAlias: &aliasCopy,85 DisplayTitle: variant.Definition.Title,86 }, nil87 }88 }8990 return VariantSelection{}, fmt.Errorf("unknown variant or legacy mode %q for %s\n\nAvailable variants:\n %s",91 name, entry.Definition.Name, strings.Join(VariantNames(entry), "\n "))92}9394func legacyAliasMatches(alias puzzle.LegacyModeAlias, norm string) bool {95 if Normalize(alias.Title) == norm || Normalize(string(alias.ID)) == norm {96 return true97 }98 for _, cliAlias := range alias.CLIAliases {99 if Normalize(cliAlias) == norm {100 return true101 }102 }103 return false104}105106func variantByID(entry registry.Entry, id puzzle.VariantID) (registry.VariantEntry, bool) {107 for _, variant := range entry.Variants {108 if variant.Definition.ID == id {109 return variant, true110 }111 }112 return registry.VariantEntry{}, false113}114115// ModeNames returns the display names of all modes in an entry.116func ModeNames(entry registry.Entry) []string {117 names := make([]string, 0, len(entry.Modes))118 for _, mode := range entry.Modes {119 names = append(names, mode.Definition.Title)120 }121 return names122}123124func VariantNames(entry registry.Entry) []string {125 names := make([]string, 0, len(entry.Variants))126 for _, variant := range entry.Variants {127 names = append(names, variant.Definition.Title)128 }129 return names130}131132// RNGFromString creates a deterministic RNG seeded from an arbitrary string.133// The string is hashed with FNV-64a to produce a uint64 seed.134func RNGFromString(seed string) *rand.Rand {135 h := fnv.New64a()136 h.Write([]byte(seed))137 s := h.Sum64()138 return rand.New(rand.NewPCG(s, ^s))139}140141// seededEntry pairs a SeededSpawner with its category and mode metadata.142type seededEntry struct {143 spawner game.SeededSpawner144 gameType string145 mode string146}147148func seededModeForDefinition(seed string, entry registry.Entry) (seededEntry, bool) {149 var best seededEntry150 var bestHash uint64151 found := false152153 for _, variant := range entry.Variants {154 if variant.Seeded == nil {155 continue156 }157 h := fnv.New64a()158 h.Write([]byte(seed))159 h.Write([]byte{0})160 h.Write([]byte(entry.Definition.Name))161 h.Write([]byte{0})162 h.Write([]byte(variant.Definition.Title))163 score := h.Sum64()164 if !found || score > bestHash {165 bestHash = score166 best = seededEntry{167 spawner: variant.Seeded,168 gameType: entry.Definition.Name,169 mode: variant.Definition.Title,170 }171 found = true172 }173 }174175 return best, found176}177178// SeededMode selects a game type and mode from all available modes across179// all categories using rendezvous hashing (highest random weight). Each180// eligible mode is scored by hashing the seed string together with its181// (gameType, modeTitle) pair; the highest score wins.182//183// This is resilient to changes in the category/mode lists: adding or184// removing a mode only affects seeds where the changed entry would have185// been the winner.186func SeededMode(seed string, entries []registry.Entry) (game.SeededSpawner, string, string, error) {187 var best seededEntry188 var bestHash uint64189 found := false190 for _, entry := range entries {191 selected, ok := seededModeForDefinition(seed, entry)192 if !ok {193 continue194 }195 h := fnv.New64a()196 h.Write([]byte(seed))197 h.Write([]byte{0})198 h.Write([]byte(selected.gameType))199 h.Write([]byte{0})200 h.Write([]byte(selected.mode))201 score := h.Sum64()202 if !found || score > bestHash {203 bestHash = score204 best = selected205 found = true206 }207 }208 if !found {209 return nil, "", "", errors.New("no seeded modes available")210 }211 return best.spawner, best.gameType, best.mode, nil212}213214// SeededModeForGame deterministically selects a seeded mode within a single215// game definition, so the same seed and game name always produce the same216// puzzle for that game.217func SeededModeForGame(seed, gameType string, entries []registry.Entry) (game.SeededSpawner, string, string, error) {218 norm := Normalize(gameType)219 for _, entry := range entries {220 if Normalize(entry.Definition.Name) != norm {221 continue222 }223224 selected, ok := seededModeForDefinition(seed, entry)225 if !ok {226 return nil, "", "", fmt.Errorf("game %q has no seeded modes", entry.Definition.Name)227 }228 return selected.spawner, selected.gameType, selected.mode, nil229 }230231 return nil, "", "", fmt.Errorf("unknown game %q", gameType)232}