Files
OpenFrontIO/map-generator/codegen.go
T
RickD004 e494f83e8e New and updated categories for maps (#4254)
Resolves #4250

## Description:

Huge update for the map categories:


https://github.com/user-attachments/assets/b7dc6344-efdc-4073-b15a-92b6dccdcc19

**New Categories**

- Re-adds Continental category, with the 7 traditional continents

- Re-adds the category of Arcade along all its maps.

- Renames "Other" to "Fictional", so that tag is more specific and feels
more in-theme with the others. The info.json's of the maps that had the
Other category got changed to Fictional

**Map Category changes**

- **achiran**: adds Europe (while the map is fictional, it is made up of
real islands from ireland. (Since world includes Dyslexdria and
Antarctica has Deglaciated Antarctica, both fictional , i figured for
consistency we could include these mash-up maps too)
- **aegean**: adds Asia category (Turkey is in Asia)
- **arctic**: adds Asia category 
- **choppingblock**: updated "other" to "fictional", added to "new"
- **deglaciatedantarctica**: updated "other" to "fictional"
- **didier**: re-added to Arcade
- **didierfrance**: re-added to Arcade
- **dyslexdria**: updated "other" to "fictional"
- **fourislands**: updated "other" to "fictional"
- **hawaii**: remove north_america tag (while part of the US, hawaii is
geographically only in Oceania)
- **labyrinth**: added to new, re-added to Arcade
- **marenostrum**: added africa and asia tags, the continents which the
mediterranean borders
- **onion**: re-added to Arcade
- **pangaea**: updated "other" to "fictional"
- **passage**: updated "other" to "fictional"
- **sierpinski** re-added to Arcade
- **surrounded**: updated "other" to "fictional"
- **svalmel**: updated "other" to "fictional", added to europe and
north_america (same logic as achiran)
- **thebox**: re-added to Arcade
- **tradersdream**: updated "other" to "fictional"
- **worldinverted**: updated "other" to "fictional", added to "new"
- **africa**: added to Continental
- **antarctica**: added to Continental
- **asia**: added to Continental
- **europe**: added to Continental
- **northamerica**: added to Continental
- **southamerica**: added to Continental
- **oceania**: added to Continental
- **mississippiriver**: added to "new"
- **korea**: added to "new"
- **middleeast**: added to "new"
- **balkans**: added to "new"
- **indiansubcontinent**: added to "new"
- **taiwanstrait**: added to "new"
- **northwestpassage**: added to "new"
- **southeastasia**: added to "new"
- **venice**: added to "new"
- **yellowsea**: added to "new"
- **hongkong**: added to "new"
- **titan**: added to "new"
- **caribbean**: added to "new"
- **juandefucastrait**: added to "new"
- **danishstraits**: added to "new"

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [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:

tri.star1011
2026-06-13 13:42:24 -07:00

279 lines
9.4 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",
"new",
"world",
"continental",
"europe",
"asia",
"north_america",
"africa",
"south_america",
"oceania",
"antarctica",
"cosmic",
"fictional",
"arcade",
"tournament",
}
// 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
}