package main import ( "bytes" "encoding/json" "fmt" "os" "path/filepath" "strings" ) // categoryOrder defines the set of valid "categories" values in info.json and // the display order of map categories in the generated TypeScript. var categoryOrder = []string{ "featured", "world", "europe", "asia", "north_america", "africa", "south_america", "oceania", "antarctica", "cosmic", "tournament", "other", } // mapInfo is the subset of info.json fields used for code generation. type mapInfo struct { ID string `json:"id"` Name string `json:"name"` TranslationKey string `json:"translation_key"` Categories []string `json:"categories"` // English display name written to en.json. Defaults to Name; set it when // the display name differs from the canonical (wire-format) name. DisplayName string `json:"display_name"` // How many times the map appears in the multiplayer playlist. // 0 (or omitted) keeps the map out of the regular rotation. MultiplayerFrequency int `json:"multiplayer_frequency"` // Position in the featured grid (1 = first). Featured maps without a // rank sort after ranked ones, alphabetically. FeaturedRank int `json:"featured_rank"` // Preferred team count in team/special games (see MapPlaylist). // 0 (or omitted) means no preference. SpecialTeamCount int `json:"special_team_count"` } // hasCategory reports whether the map lists the given category. func (m mapInfo) hasCategory(category string) bool { for _, c := range m.Categories { if c == category { return true } } return false } // displayName returns the English display name for the map. func (m mapInfo) displayName() string { if m.DisplayName != "" { return m.DisplayName } return m.Name } // loadMapInfos reads and validates every non-test map's info.json, in // registry (alphabetical) order. func loadMapInfos() ([]mapInfo, error) { inputDir, err := inputMapDir(false) if err != nil { return nil, err } infos := make([]mapInfo, 0, len(maps)) for _, m := range maps { if m.IsTest { continue } buf, err := os.ReadFile(filepath.Join(inputDir, m.Name, "info.json")) if err != nil { return nil, fmt.Errorf("failed to read info.json for %s: %w", m.Name, err) } var info mapInfo if err := json.Unmarshal(buf, &info); err != nil { return nil, fmt.Errorf("failed to parse info.json for %s: %w", m.Name, err) } if info.ID == "" || strings.ToLower(info.ID) != m.Name { return nil, fmt.Errorf("map %s: info.json \"id\" (%q) must be the folder name in UpperCamelCase", m.Name, info.ID) } if info.Name == "" { return nil, fmt.Errorf("map %s: info.json is missing \"name\"", m.Name) } if info.TranslationKey != "map."+m.Name { return nil, fmt.Errorf("map %s: info.json \"translation_key\" (%q) must be %q", m.Name, info.TranslationKey, "map."+m.Name) } if info.MultiplayerFrequency < 0 { return nil, fmt.Errorf("map %s: info.json \"multiplayer_frequency\" (%d) must be >= 0", m.Name, info.MultiplayerFrequency) } if info.FeaturedRank < 0 { return nil, fmt.Errorf("map %s: info.json \"featured_rank\" (%d) must be >= 1", m.Name, info.FeaturedRank) } if info.FeaturedRank > 0 && !info.hasCategory("featured") { return nil, fmt.Errorf("map %s: info.json sets \"featured_rank\" but \"categories\" does not include \"featured\"", m.Name) } if info.SpecialTeamCount < 0 || info.SpecialTeamCount == 1 { return nil, fmt.Errorf("map %s: info.json \"special_team_count\" (%d) must be >= 2", m.Name, info.SpecialTeamCount) } if len(info.Categories) == 0 { return nil, fmt.Errorf("map %s: info.json \"categories\" must list at least one category", m.Name) } seen := make(map[string]bool) for _, category := range info.Categories { valid := false for _, c := range categoryOrder { if category == c { valid = true break } } if !valid { return nil, fmt.Errorf("map %s: info.json category %q must be one of: %s", m.Name, category, strings.Join(categoryOrder, ", ")) } if seen[category] { return nil, fmt.Errorf("map %s: info.json lists category %q more than once", m.Name, category) } seen[category] = true } infos = append(infos, info) } return infos, nil } // generateMapsTS writes the GameMapType enum, the MapCategory union, the // MapInfo interface, and the maps list to src/core/game/Maps.gen.ts. func generateMapsTS(infos []mapInfo) error { cwd, err := os.Getwd() if err != nil { return fmt.Errorf("failed to get working directory: %w", err) } outPath := filepath.Join(cwd, "..", "src", "core", "game", "Maps.gen.ts") var b strings.Builder b.WriteString("// Code generated by map-generator; DO NOT EDIT.\n") b.WriteString("// Map metadata lives in map-generator/assets/maps//info.json.\n") b.WriteString("// Regenerate with `npm run gen-maps`.\n\n") b.WriteString("export enum GameMapType {\n") for _, info := range infos { // Trailing comment so editors can jump straight to the map's info.json. b.WriteString(fmt.Sprintf(" %s = %q, // map-generator/assets/maps/%s/info.json\n", info.ID, info.Name, strings.ToLower(info.ID))) } b.WriteString("}\n\n") b.WriteString("export type GameMapName = keyof typeof GameMapType;\n\n") b.WriteString("export type MapCategory =\n") for i, category := range categoryOrder { sep := "\n" if i == len(categoryOrder)-1 { sep = ";\n\n" } b.WriteString(fmt.Sprintf(" | %q%s", category, sep)) } b.WriteString("// Category display order in the map picker.\n") b.WriteString("export const mapCategoryOrder: readonly MapCategory[] = [\n") for _, category := range categoryOrder { b.WriteString(fmt.Sprintf(" %q,\n", category)) } b.WriteString("];\n\n") b.WriteString("export interface MapInfo {\n") b.WriteString(" /** GameMapType enum key — the UpperCamelCase folder name. */\n") b.WriteString(" id: GameMapName;\n") b.WriteString(" /** Canonical map name (wire format) — the GameMapType enum value. */\n") b.WriteString(" type: GameMapType;\n") b.WriteString(" /** Key of the map's display name in resources/lang/en.json. */\n") b.WriteString(" translationKey: string;\n") b.WriteString(" /** Map picker categories. */\n") b.WriteString(" categories: MapCategory[];\n") b.WriteString(" /** How many times the map appears in the multiplayer playlist. */\n") b.WriteString(" multiplayerFrequency: number;\n") b.WriteString(" /** Position in the featured grid (1 = first); unranked featured maps sort last. */\n") b.WriteString(" featuredRank?: number;\n") b.WriteString(" /** Preferred team count in team/special games (see MapPlaylist). */\n") b.WriteString(" specialTeamCount?: number;\n") b.WriteString("}\n\n") b.WriteString("export const maps: readonly MapInfo[] = [\n") for _, info := range infos { b.WriteString(" {\n") b.WriteString(fmt.Sprintf(" id: %q,\n", info.ID)) b.WriteString(fmt.Sprintf(" type: GameMapType.%s,\n", info.ID)) b.WriteString(fmt.Sprintf(" translationKey: %q,\n", info.TranslationKey)) b.WriteString(" categories: [") for i, category := range info.Categories { if i > 0 { b.WriteString(", ") } b.WriteString(fmt.Sprintf("%q", category)) } b.WriteString("],\n") b.WriteString(fmt.Sprintf(" multiplayerFrequency: %d,\n", info.MultiplayerFrequency)) if info.FeaturedRank > 0 { b.WriteString(fmt.Sprintf(" featuredRank: %d,\n", info.FeaturedRank)) } if info.SpecialTeamCount > 0 { b.WriteString(fmt.Sprintf(" specialTeamCount: %d,\n", info.SpecialTeamCount)) } b.WriteString(" },\n") } b.WriteString("];\n") if err := os.WriteFile(outPath, []byte(b.String()), 0644); err != nil { return fmt.Errorf("failed to write %s: %w", outPath, err) } return nil } // generateEnJSON rewrites the "map" section of resources/lang/en.json with // each map's display name, keyed by folder name. Existing keys in the // section that are not maps (e.g. "featured", "random") are preserved. // // The whole file is round-tripped through encoding/json, which marshals // object keys in sorted order — a no-op for everything but the map section // because en.json is kept sorted (see tests/EnJsonSorted.test.ts). func generateEnJSON(infos []mapInfo) error { cwd, err := os.Getwd() if err != nil { return fmt.Errorf("failed to get working directory: %w", err) } enPath := filepath.Join(cwd, "..", "resources", "lang", "en.json") content, err := os.ReadFile(enPath) if err != nil { return fmt.Errorf("failed to read en.json: %w", err) } var root map[string]interface{} if err := json.Unmarshal(content, &root); err != nil { return fmt.Errorf("failed to parse en.json: %w", err) } oldSection, ok := root["map"].(map[string]interface{}) if !ok { return fmt.Errorf("en.json has no top-level \"map\" object") } mapFolders := make(map[string]bool, len(infos)) for _, info := range infos { mapFolders[strings.ToLower(info.ID)] = true } section := make(map[string]interface{}, len(oldSection)) for key, value := range oldSection { if !mapFolders[key] { section[key] = value } } for _, info := range infos { section[strings.ToLower(info.ID)] = info.displayName() } root["map"] = section var b bytes.Buffer enc := json.NewEncoder(&b) enc.SetEscapeHTML(false) enc.SetIndent("", " ") if err := enc.Encode(root); err != nil { return fmt.Errorf("failed to serialize en.json: %w", err) } if err := os.WriteFile(enPath, b.Bytes(), 0644); err != nil { return fmt.Errorf("failed to write en.json: %w", err) } return nil }