Files
OpenFrontIO/map-generator/codegen.go
T
Evan 3de5fb4204 Move map metadata into info.json and generate map TypeScript from it (#4227)
**Add approved & assigned issue number here:**

N/A — maintainer refactor.

## Description:

Makes each map's `info.json` the single source of truth for map metadata
— adding a map is now a folder with `image.png` + `info.json`, a
`gen-maps` run, and an en.json display name.

**info.json / manifest.json carry full map metadata.** Every
`map-generator/assets/maps/<map>/info.json` declares `id` (the
`GameMapType` enum key), `name` (the enum value — wire format, unchanged
for all 94 maps), `translation_key`, `categories`, and
`multiplayer_frequency` (the public-playlist weight that used to be the
`FREQUENCY` record in MapPlaylist.ts). The generator validates
everything and mirrors it into `resources/maps/<map>/manifest.json`. 23
stale info.json `name` values were normalized to the canonical enum
value; enum values are byte-identical, so replays and stored game
configs are unaffected.

**The generator emits the TypeScript and discovers maps itself.** New
`map-generator/codegen.go` generates `src/core/game/Maps.gen.ts`
(`GameMapType`, `GameMapName`, `mapCategories`, `mapTranslationKeys`,
`multiplayerFrequency` — now a full `Record<GameMapName, number>`,
killing the old `Partial`) on every run; `Game.ts` re-exports it. The
hardcoded map registry in `main.go` is gone — maps are auto-discovered
from the `assets/maps` / `assets/test_maps` directories. MapConsistency
tests fail with a "run `npm run gen-maps`" message if info.json,
manifest.json, and Maps.gen.ts drift. The tracked
`map-generator/map-generator` binary is rebuilt to match.

**New categories: continents + world/cosmic/tournament/other,
multi-category support.** `continental`/`regional`/`fantasy`/`arcade`
are replaced by `featured`, `world`, `europe`, `asia`, `north_america`,
`africa`, `south_america`, `oceania`, `antarctica`, `cosmic`,
`tournament`, and `other`. Maps can list multiple categories, so
straddlers (Black Sea, Bosphorus, Caucasus, Between Two Seas, Bering
Sea/Strait, Mena, Strait of Gibraltar, Hawaii, Arctic) appear under both
regions. Featured is itself a category (same 7 maps as before).
MapPlaylist keeps its arcade exclusion via an explicit set.

**Map picker UI.** Two tabs: **Featured** (default — featured maps plus
a Favorites section when maps are starred) and **All** (one prominent
collapsible bar per category with a map count, collapsed by default).
The selected map is prepended to the featured grid when it lives
elsewhere. `getMapName()` resolves through the generated
`mapTranslationKeys`, which also fixes tourney maps never resolving a
valid translation key.

## Please complete the following:

- [ ] I have added screenshots for all UI updates (maintainer change —
picker described above)
- [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 19:36:53 -07:00

183 lines
5.7 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"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 TypeScript generation.
type mapInfo struct {
ID string `json:"id"`
Name string `json:"name"`
TranslationKey string `json:"translation_key"`
Categories []string `json:"categories"`
// 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"`
}
// 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
}
// generateMapsTS reads every non-test map's info.json and writes the
// GameMapType enum, mapCategories, and mapTranslationKeys to
// src/core/game/Maps.gen.ts.
// Maps appear in registry (alphabetical) order; categories in categoryOrder.
func generateMapsTS() error {
inputDir, err := inputMapDir(false)
if err != nil {
return err
}
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")
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 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 fmt.Errorf("failed to parse info.json for %s: %w", m.Name, err)
}
if info.ID == "" || strings.ToLower(info.ID) != m.Name {
return fmt.Errorf("map %s: info.json \"id\" (%q) must be the folder name in UpperCamelCase", m.Name, info.ID)
}
if info.Name == "" {
return fmt.Errorf("map %s: info.json is missing \"name\"", m.Name)
}
if info.TranslationKey == "" {
return fmt.Errorf("map %s: info.json is missing \"translation_key\"", m.Name)
}
if info.MultiplayerFrequency < 0 {
return fmt.Errorf("map %s: info.json \"multiplayer_frequency\" (%d) must be >= 0", m.Name, info.MultiplayerFrequency)
}
if info.FeaturedRank < 0 {
return fmt.Errorf("map %s: info.json \"featured_rank\" (%d) must be >= 1", m.Name, info.FeaturedRank)
}
if info.FeaturedRank > 0 && !info.hasCategory("featured") {
return fmt.Errorf("map %s: info.json sets \"featured_rank\" but \"categories\" does not include \"featured\"", m.Name)
}
if len(info.Categories) == 0 {
return 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 fmt.Errorf("map %s: info.json category %q must be one of: %s", m.Name, category, strings.Join(categoryOrder, ", "))
}
if seen[category] {
return fmt.Errorf("map %s: info.json lists category %q more than once", m.Name, category)
}
seen[category] = true
}
infos = append(infos, info)
}
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 const mapCategories: Record<string, GameMapType[]> = {\n")
for _, category := range categoryOrder {
members := make([]mapInfo, 0, len(infos))
for _, info := range infos {
if info.hasCategory(category) {
members = append(members, info)
}
}
if category == "featured" {
sort.SliceStable(members, func(i, j int) bool {
ri, rj := members[i].FeaturedRank, members[j].FeaturedRank
if ri == 0 {
ri = len(infos) + 1
}
if rj == 0 {
rj = len(infos) + 1
}
return ri < rj
})
}
b.WriteString(fmt.Sprintf(" %s: [\n", category))
for _, info := range members {
b.WriteString(fmt.Sprintf(" GameMapType.%s,\n", info.ID))
}
b.WriteString(" ],\n")
}
b.WriteString("};\n\n")
b.WriteString("export const mapTranslationKeys: Record<GameMapType, string> = {\n")
for _, info := range infos {
b.WriteString(fmt.Sprintf(" [GameMapType.%s]: %q,\n", info.ID, info.TranslationKey))
}
b.WriteString("};\n\n")
b.WriteString("// How many times each map appears in the multiplayer playlist.\n")
b.WriteString("export const multiplayerFrequency: Record<GameMapName, number> = {\n")
for _, info := range infos {
b.WriteString(fmt.Sprintf(" %s: %d,\n", info.ID, info.MultiplayerFrequency))
}
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
}