clone

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

refactor/root-tui-model
puzzletea / gameentry / gameentry.go
gameentry.go 7.8K
1// Package gameentry builds concrete runtime game entries from puzzle definitions2// and runtime mode implementations.3package gameentry45import (6 "context"7 "fmt"8 "math"9 "math/rand/v2"1011 "github.com/FelineStateMachine/puzzletea/difficulty"12 "github.com/FelineStateMachine/puzzletea/export/pdf"13 "github.com/FelineStateMachine/puzzletea/game"14 "github.com/FelineStateMachine/puzzletea/puzzle"15)1617type ModeEntry struct {18 Definition puzzle.ModeDef19 Spawner game.Spawner20 Seeded game.SeededSpawner21 Elo game.EloSpawner22}2324type VariantEntry struct {25 Definition puzzle.VariantDef26 Elo game.EloSpawner27 Seeded game.SeededSpawner28}2930type Entry struct {31 Definition puzzle.Definition32 Help string33 Import func([]byte) (game.Gamer, error)34 Variants []VariantEntry35 LegacyModes []puzzle.LegacyModeAlias36 Modes []ModeEntry37 Print pdfexport.PrintAdapter38}3940type EntrySpec struct {41 Definition puzzle.Definition42 Help string43 Import func([]byte) (game.Gamer, error)44 Modes []game.Mode45 Print pdfexport.PrintAdapter46}4748func BuildModeDefs(modes []game.Mode) []puzzle.ModeDef {49 defs := make([]puzzle.ModeDef, 0, len(modes))50 for _, mode := range modes {51 def := puzzle.NewModeDef(puzzle.ModeSpec{52 Title: mode.Title(),53 Description: mode.Description(),54 })55 if _, ok := mode.(game.SeededSpawner); ok {56 def.Seeded = true57 }58 defs = append(defs, def)59 }60 return defs61}6263func BuildDefaultVariantDef(title, description string, defaultElo difficulty.Elo) puzzle.VariantDef {64 return puzzle.NewVariantDef(puzzle.VariantSpec{65 Title: title,66 Description: description,67 DefaultElo: defaultElo,68 })69}7071func BuildLegacyModeAliases(modes []puzzle.ModeDef, target puzzle.VariantID) []puzzle.LegacyModeAlias {72 aliases := make([]puzzle.LegacyModeAlias, 0, len(modes))73 for i, mode := range modes {74 if mode.PresetElo == nil {75 continue76 }77 aliases = append(aliases, puzzle.NewLegacyModeAlias(puzzle.LegacyModeAliasSpec{78 ID: mode.ID,79 Title: mode.Title,80 Description: mode.Description,81 TargetVariantID: target,82 PresetElo: *mode.PresetElo,83 XPWeight: legacyXPWeight(i, len(modes)),84 }))85 }86 return aliases87}8889func legacyXPWeight(index, count int) int {90 if count <= 0 {91 return 192 }93 return max(1, int(math.Round(float64(index)/float64(count)*10)))94}9596func NewEntry(spec EntrySpec) Entry {97 definition := spec.Definition98 if len(definition.Variants) == 0 && hasEloMode(definition.Modes) {99 definition.Variants = []puzzle.VariantDef{100 BuildDefaultVariantDef(definition.Name, definition.Description, defaultEloForModes(definition.Modes)),101 }102 }103 if len(definition.LegacyModes) == 0 && len(definition.Variants) == 1 {104 definition.LegacyModes = BuildLegacyModeAliases(definition.Modes, definition.Variants[0].ID)105 }106107 if len(definition.Modes) != len(spec.Modes) {108 panic(fmt.Sprintf(109 "gameentry: definition %q has %d mode definitions but %d runtime modes",110 definition.Name,111 len(definition.Modes),112 len(spec.Modes),113 ))114 }115116 entries := make([]ModeEntry, 0, len(spec.Modes))117 for i, mode := range spec.Modes {118 spawner, ok := mode.(game.Spawner)119 if !ok {120 panic(fmt.Sprintf(121 "gameentry: definition %q mode %q does not implement game.Spawner",122 definition.Name,123 mode.Title(),124 ))125 }126127 modeDef := definition.Modes[i]128 if modeDef.Title != mode.Title() || modeDef.Description != mode.Description() {129 panic(fmt.Sprintf(130 "gameentry: definition %q mode %d metadata does not match runtime mode %q",131 definition.Name,132 i,133 mode.Title(),134 ))135 }136137 entry := ModeEntry{138 Definition: modeDef,139 Spawner: spawner,140 }141 if seeded, ok := mode.(game.SeededSpawner); ok {142 entry.Seeded = seeded143 }144 if elo, ok := mode.(game.EloSpawner); ok {145 entry.Elo = elo146 }147 if entry.Definition.Seeded != (entry.Seeded != nil) {148 panic(fmt.Sprintf(149 "gameentry: definition %q mode %q seeded flag does not match runtime mode",150 definition.Name,151 modeDef.Title,152 ))153 }154 if entry.Definition.PresetElo != nil {155 if err := difficulty.ValidateElo(*entry.Definition.PresetElo); err != nil {156 panic(fmt.Sprintf(157 "gameentry: definition %q mode %q preset Elo is invalid: %v",158 definition.Name,159 modeDef.Title,160 err,161 ))162 }163 if entry.Elo == nil {164 panic(fmt.Sprintf(165 "gameentry: definition %q mode %q has preset Elo but runtime mode does not implement game.EloSpawner",166 definition.Name,167 modeDef.Title,168 ))169 }170 }171 entries = append(entries, entry)172 }173174 variants := buildVariantEntries(definition, entries)175176 return Entry{177 Definition: definition,178 Help: spec.Help,179 Import: spec.Import,180 Variants: variants,181 LegacyModes: append([]puzzle.LegacyModeAlias(nil), definition.LegacyModes...),182 Modes: entries,183 Print: spec.Print,184 }185}186187func hasEloMode(modes []puzzle.ModeDef) bool {188 for _, mode := range modes {189 if mode.PresetElo != nil {190 return true191 }192 }193 return false194}195196func defaultEloForModes(modes []puzzle.ModeDef) difficulty.Elo {197 presets := make([]difficulty.Elo, 0, len(modes))198 for _, mode := range modes {199 if mode.PresetElo != nil {200 presets = append(presets, *mode.PresetElo)201 }202 }203 if len(presets) == 0 {204 return 1200205 }206 return presets[len(presets)/2]207}208209func buildVariantEntries(def puzzle.Definition, modes []ModeEntry) []VariantEntry {210 if len(def.Variants) == 0 {211 return nil212 }213 result := make([]VariantEntry, 0, len(def.Variants))214 for _, variant := range def.Variants {215 spawners := make([]ModeEntry, 0, len(modes))216 for _, mode := range modes {217 if mode.Elo != nil {218 spawners = append(spawners, mode)219 }220 }221 if len(spawners) == 0 {222 panic(fmt.Sprintf(223 "gameentry: definition %q variant %q has no Elo-capable legacy modes",224 def.Name,225 variant.Title,226 ))227 }228 elo := variantEloSpawner{229 defaultElo: variant.DefaultElo,230 modes: spawners,231 }232 result = append(result, VariantEntry{233 Definition: variant,234 Elo: elo,235 Seeded: variantSeededSpawner{236 elo: elo,237 defaultElo: variant.DefaultElo,238 },239 })240 }241 return result242}243244type variantEloSpawner struct {245 defaultElo difficulty.Elo246 modes []ModeEntry247}248249func (s variantEloSpawner) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {250 return s.SpawnEloContext(context.Background(), seed, elo)251}252253func (s variantEloSpawner) SpawnEloContext(ctx context.Context, seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {254 if err := difficulty.ValidateElo(elo); err != nil {255 return nil, difficulty.Report{}, err256 }257 mode := s.modeForElo(elo)258 if cancellable, ok := mode.Elo.(game.CancellableEloSpawner); ok {259 return cancellable.SpawnEloContext(ctx, seed, elo)260 }261 return mode.Elo.SpawnElo(seed, elo)262}263264func (s variantEloSpawner) modeForElo(elo difficulty.Elo) ModeEntry {265 if len(s.modes) == 1 {266 return s.modes[0]267 }268 band := int(elo) * len(s.modes) / (int(difficulty.SoftCapElo) + 1)269 if band < 0 {270 band = 0271 }272 if band >= len(s.modes) {273 band = len(s.modes) - 1274 }275 return s.modes[band]276}277278type variantSeededSpawner struct {279 elo game.EloSpawner280 defaultElo difficulty.Elo281}282283func (s variantSeededSpawner) Spawn() (game.Gamer, error) {284 return s.SpawnSeeded(rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())))285}286287func (s variantSeededSpawner) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {288 return s.SpawnSeededContext(context.Background(), rng)289}290291func (s variantSeededSpawner) SpawnSeededContext(ctx context.Context, rng *rand.Rand) (game.Gamer, error) {292 if rng == nil {293 return nil, fmt.Errorf("nil RNG")294 }295 seed := fmt.Sprintf("variant-seeded:%016x:%016x", rng.Uint64(), rng.Uint64())296 var (297 g game.Gamer298 err error299 )300 if cancellable, ok := s.elo.(game.CancellableEloSpawner); ok {301 g, _, err = cancellable.SpawnEloContext(ctx, seed, s.defaultElo)302 } else {303 g, _, err = s.elo.SpawnElo(seed, s.defaultElo)304 }305 return g, err306}