mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-27 06:14:36 +00:00
4c55f82e87
## Description: Adds map of the continental USA. This map was made for the brand new impassable terrain feature: https://github.com/openfrontio/OpenFrontIO/pull/4340 Only the territory of the US is playable, with canada and mexico being impassable terrain. Also, adds a new category called "countries" for Country maps like this map, that use Impassable Terrain. 49 default nations (Lower 48 + D.C.) , with additional nations (native nations and proposed states) for a total of 62, for private games and Human Vs Nations gamemode. Also standarizes many of the flags of the US states, since they did not have the black border like the other flags in the game <img width="857" height="567" alt="Captura de pantalla 2026-06-24 165158" src="https://github.com/user-attachments/assets/70a8d760-851f-40ed-ad79-d3e210dadb90" /> <img width="872" height="510" alt="Captura de pantalla 2026-06-24 165510" src="https://github.com/user-attachments/assets/c998cc10-ee89-41a7-b5e9-091be5e90da0" /> ## 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
280 lines
9.4 KiB
Go
280 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",
|
|
"countries",
|
|
"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
|
|
}
|