mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:40:43 +00:00
182d008ddd
**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>
276 lines
9.3 KiB
Go
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
|
|
}
|