mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 16:56:36 +00:00
e494f83e8e
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
279 lines
9.4 KiB
Go
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
|
|
}
|