Files
OpenFrontIO/map-generator/codegen.go
T
Evan 182d008ddd Generate a single MapInfo list; move SPECIAL_TEAM_MAPS and en.json map names into info.json (#4231)
**Add approved & assigned issue number here:**

N/A — maintainer follow-up to #4227.

## Description:

Follow-up to #4227, finishing the "info.json is the single source of
truth" refactor.

**Maps.gen.ts now generates one `MapInfo` interface and a `maps` list**
instead of parallel lookup records. `mapCategories`,
`mapTranslationKeys`, and `multiplayerFrequency` are gone — consumers
read the list directly (`map.categories`, `map.translationKey`,
`map.multiplayerFrequency`). MapPicker got simpler in the process: it
renders from `MapInfo` objects, so the reverse
`Object.entries(GameMapType)` lookup to recover the enum key is gone.
The featured-rank sort moved out of the Go codegen into the picker,
where the presentation concern belongs.

**`SPECIAL_TEAM_MAPS` moves into info.json** as an optional
`special_team_count` field (set on the same 17 maps with the same
values). MapPlaylist derives its map from the generated list;
`SPECIAL_TEAM_FORCE_CHANCE` and the frequency multiplier behavior are
unchanged.

**The en.json `map` section is now generated.** A new optional
`display_name` field in info.json (defaulting to `name`) is written to
`resources/lang/en.json` by the generator, preserving the section's
non-map UI keys (`map`, `featured`, `all`, `favorites`, `random`). The 8
maps whose English display name intentionally differs from the frozen
enum value (e.g. `MENA`, `Milky Way`, `Europe (Classic)`, `Baikal (Nuke
Wars)`) declare it via `display_name`, so no display text changes. The
section is emitted alphabetically; since #4232 already sorted en.json
and every value matches, regeneration is byte-identical and this PR has
no en.json diff. Other languages remain Crowdin-managed.

The generator also now validates `translation_key` is exactly
`map.<folder>` and `special_team_count >= 2`. MapConsistency tests
compare info.json directly against the generated list and the en.json
section, and fail with a "run `npm run gen-maps`" message on drift. No
behavior changes: enum values, playlist frequencies, special-team
counts, featured order, and display names are all byte-identical.

## Please complete the following:

- [x] I have added screenshots for all UI updates (no UI changes —
internal refactor, rendering output identical)
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evanpelle

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:06:48 -07:00

276 lines
9.3 KiB
Go

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/<map>/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
}