clone

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

bug/architecture-test-trim-path
puzzletea / catalog / catalog.go
catalog.go 5.2K
1// Package catalog indexes puzzle definitions, aliases, and daily metadata2// without importing concrete game implementations.3package catalog45import (6 "fmt"78 "github.com/FelineStateMachine/puzzletea/difficulty"9 "github.com/FelineStateMachine/puzzletea/puzzle"10)1112type DailyEntry struct {13 GameID puzzle.GameID14 GameName string15 ModeID puzzle.ModeID16 Mode string17}1819type Index struct {20 definitions []puzzle.Definition21 names []string22 dailies []DailyEntry23 byName map[string]puzzle.Definition24 byAlias map[string]string25}2627func MustBuild(definitions []puzzle.Definition) Index {28 idx, err := Build(definitions)29 if err != nil {30 panic(err)31 }32 return idx33}3435func Build(definitions []puzzle.Definition) (Index, error) {36 if err := Validate(definitions); err != nil {37 return Index{}, err38 }3940 idx := Index{41 definitions: append([]puzzle.Definition(nil), definitions...),42 names: make([]string, 0, len(definitions)),43 dailies: make([]DailyEntry, 0, len(definitions)*2),44 byName: make(map[string]puzzle.Definition, len(definitions)),45 byAlias: make(map[string]string),46 }4748 for _, def := range definitions {49 idx.names = append(idx.names, def.Name)50 idx.byName[puzzle.NormalizeName(def.Name)] = def51 for _, alias := range def.Aliases {52 idx.byAlias[puzzle.NormalizeName(alias)] = def.Name53 }54 byModeID := make(map[puzzle.ModeID]puzzle.ModeDef, len(def.Modes))55 for _, mode := range def.Modes {56 byModeID[mode.ID] = mode57 }58 for _, dailyID := range def.DailyModeIDs {59 mode, ok := byModeID[dailyID]60 if !ok {61 continue62 }63 idx.dailies = append(idx.dailies, DailyEntry{64 GameID: def.ID,65 GameName: def.Name,66 ModeID: mode.ID,67 Mode: mode.Title,68 })69 }70 }7172 return idx, nil73}7475func Validate(definitions []puzzle.Definition) error {76 names := make(map[string]string, len(definitions))77 aliases := make(map[string]string)7879 for _, def := range definitions {80 normName := puzzle.NormalizeName(def.Name)81 if normName == "" {82 return fmt.Errorf("game %q has empty canonical name", def.Name)83 }84 if def.ID == "" {85 return fmt.Errorf("game %q has empty id", def.Name)86 }87 if prior, exists := names[normName]; exists {88 return fmt.Errorf("duplicate game name %q conflicts with %q", def.Name, prior)89 }90 names[normName] = def.Name9192 modeTitles := make(map[puzzle.ModeID]struct{}, len(def.Modes))93 for _, mode := range def.Modes {94 if mode.ID == "" {95 return fmt.Errorf("game %q has mode %q with empty id", def.Name, mode.Title)96 }97 modeTitles[mode.ID] = struct{}{}98 }99 variantIDs := make(map[puzzle.VariantID]struct{}, len(def.Variants))100 for _, variant := range def.Variants {101 if variant.ID == "" {102 return fmt.Errorf("game %q has variant %q with empty id", def.Name, variant.Title)103 }104 if err := difficulty.ValidateElo(variant.DefaultElo); err != nil {105 return fmt.Errorf("game %q variant %q default Elo is invalid: %w", def.Name, variant.Title, err)106 }107 if _, exists := variantIDs[variant.ID]; exists {108 return fmt.Errorf("game %q has duplicate variant %q", def.Name, variant.ID)109 }110 variantIDs[variant.ID] = struct{}{}111 }112 for _, alias := range def.LegacyModes {113 if alias.ID == "" {114 return fmt.Errorf("game %q has legacy mode %q with empty id", def.Name, alias.Title)115 }116 if _, ok := variantIDs[alias.TargetVariantID]; !ok {117 return fmt.Errorf("game %q legacy mode %q targets missing variant %q", def.Name, alias.Title, alias.TargetVariantID)118 }119 if err := difficulty.ValidateElo(alias.PresetElo); err != nil {120 return fmt.Errorf("game %q legacy mode %q preset Elo is invalid: %w", def.Name, alias.Title, err)121 }122 if alias.XPWeight < 1 {123 return fmt.Errorf("game %q legacy mode %q XP weight must be positive", def.Name, alias.Title)124 }125 }126127 for _, dailyID := range def.DailyModeIDs {128 if _, ok := modeTitles[dailyID]; !ok {129 return fmt.Errorf("game %q daily mode %q is not present in Modes", def.Name, dailyID)130 }131 }132133 for _, alias := range def.Aliases {134 normAlias := puzzle.NormalizeName(alias)135 if normAlias == "" {136 return fmt.Errorf("game %q has empty alias", def.Name)137 }138 if normAlias == normName {139 return fmt.Errorf("game %q alias %q duplicates the canonical name", def.Name, alias)140 }141 if prior, exists := names[normAlias]; exists {142 return fmt.Errorf("game %q alias %q conflicts with canonical game %q", def.Name, alias, prior)143 }144 if prior, exists := aliases[normAlias]; exists {145 return fmt.Errorf("game %q alias %q conflicts with alias owned by %q", def.Name, alias, prior)146 }147 aliases[normAlias] = def.Name148 }149 }150151 return nil152}153154func (i Index) Names() []string {155 return append([]string(nil), i.names...)156}157158func (i Index) Definitions() []puzzle.Definition {159 return append([]puzzle.Definition(nil), i.definitions...)160}161162func (i Index) DailyEntries() []DailyEntry {163 return append([]DailyEntry(nil), i.dailies...)164}165166func (i Index) Lookup(name string) (puzzle.Definition, bool) {167 def, ok := i.byName[puzzle.NormalizeName(name)]168 return def, ok169}170171func (i Index) Resolve(name string) (puzzle.Definition, bool) {172 norm := puzzle.NormalizeName(name)173 if def, ok := i.byName[norm]; ok {174 return def, true175 }176 canonical, ok := i.byAlias[norm]177 if !ok {178 return puzzle.Definition{}, false179 }180 return i.Lookup(canonical)181}