diff --git a/map-generator/README.md b/map-generator/README.md index 2c5c7a584..77a4674de 100644 --- a/map-generator/README.md +++ b/map-generator/README.md @@ -49,7 +49,8 @@ Alternatively, `npm run gen-maps` (from the root directory) runs the generator f - `../resources/maps//map4x.bin` - 1/4 scale (half dimensions) binary map data used for mini-maps. - `../resources/maps//map16x.bin` - 1/16 scale (quarter dimensions) binary map data used for mini-maps. - `../resources/maps//thumbnail.webp` - WebP image thumbnail of the map. -- `../src/core/game/Maps.gen.ts` - Generated TypeScript (`GameMapType`, `mapCategories`, `mapTranslationKeys`, `multiplayerFrequency`) built from every map's info.json. Regenerated on every run, even with `--maps`. +- `../src/core/game/Maps.gen.ts` - Generated TypeScript (the `GameMapType` enum and the `maps` list of `MapInfo` objects) built from every map's info.json. Regenerated on every run, even with `--maps`. +- `../resources/lang/en.json` - The `map` section is rewritten with each map's display name. Regenerated on every run, even with `--maps`. ## Command Line Flags @@ -121,7 +122,9 @@ Example: `name` is the map's canonical name — the `GameMapType` enum value. It must never change once the map ships (it is part of the wire format and stored in game records). -`translation_key` is the key of the map's display name in `../resources/lang/en.json` (`map.`). +`display_name` (optional) is the English display name written to the `map` section of `../resources/lang/en.json`. It defaults to `name` — set it only when the display name should differ from the canonical name (e.g. `MENA`, `Europe (Classic)`). + +`translation_key` is the key of the map's display name in `../resources/lang/en.json`. It must be `map.`. `categories` groups the map in the map picker. Each entry must be one of: `featured`, `world`, `europe`, `asia`, `north_america`, `africa`, `south_america`, `oceania`, `antarctica`, `cosmic`, `tournament`, `other`. Maps that straddle regions (e.g. Black Sea, Bering Strait) can list more than one. Add `featured` to show the map in the featured section of the map picker. @@ -129,6 +132,8 @@ Example: `featured_rank` (optional, featured maps only) is the map's position in the featured grid (1 = first). Featured maps without a rank sort after ranked ones, alphabetically. +`special_team_count` (optional) is the map's preferred team count in team / special games — see `SPECIAL_TEAM_MAPS` in `../src/server/MapPlaylist.ts`. Omit it for no preference. + `flag` is the code for a country - The full list of supported codes can be seen in `../src/client/data/countries.json` - all ISO_3166 codes are supported, with several additions. @@ -148,12 +153,14 @@ The country will need to be added to `../src/client/data/countries.json` ## To Enable In-Game -`GameMapType`, `mapCategories`, `mapTranslationKeys`, and -`multiplayerFrequency` are generated from the info.json files into -`../src/core/game/Maps.gen.ts` when the map-generator runs — do not edit that -file by hand. The only step outside info.json is: +Everything is generated from the info.json files when the map-generator runs — +there are no manual steps: -- Add the map's display name to the `map` translation object in `../resources/lang/en.json` (using your `translation_key`) +- The `GameMapType` enum and the `maps` list (one `MapInfo` per map) are + written to `../src/core/game/Maps.gen.ts`. Do not edit that file by hand. +- The `map` section of `../resources/lang/en.json` is rewritten with each + map's `display_name` (or `name`). Translations to other languages are + managed via Crowdin. ## Notes diff --git a/map-generator/assets/maps/aegean/info.json b/map-generator/assets/maps/aegean/info.json index 48d8e4a20..30bb39946 100644 --- a/map-generator/assets/maps/aegean/info.json +++ b/map-generator/assets/maps/aegean/info.json @@ -4,6 +4,7 @@ "translation_key": "map.aegean", "categories": ["europe"], "multiplayer_frequency": 6, + "special_team_count": 2, "nations": [ { "coordinates": [786, 1860], diff --git a/map-generator/assets/maps/archipelagosea/info.json b/map-generator/assets/maps/archipelagosea/info.json index f4a528e3e..5fd1ad35e 100644 --- a/map-generator/assets/maps/archipelagosea/info.json +++ b/map-generator/assets/maps/archipelagosea/info.json @@ -1,6 +1,7 @@ { "id": "ArchipelagoSea", "name": "ArchipelagoSea", + "display_name": "Archipelago Sea", "translation_key": "map.archipelagosea", "categories": ["europe"], "multiplayer_frequency": 3, diff --git a/map-generator/assets/maps/baikal/info.json b/map-generator/assets/maps/baikal/info.json index 82475bd0a..1c659c6b3 100644 --- a/map-generator/assets/maps/baikal/info.json +++ b/map-generator/assets/maps/baikal/info.json @@ -4,6 +4,7 @@ "translation_key": "map.baikal", "categories": ["asia"], "multiplayer_frequency": 5, + "special_team_count": 2, "nations": [ { "coordinates": [695, 665], diff --git a/map-generator/assets/maps/baikalnukewars/info.json b/map-generator/assets/maps/baikalnukewars/info.json index 13fc1d0be..a5656c2b4 100644 --- a/map-generator/assets/maps/baikalnukewars/info.json +++ b/map-generator/assets/maps/baikalnukewars/info.json @@ -1,6 +1,7 @@ { "id": "BaikalNukeWars", "name": "Baikal Nuke Wars", + "display_name": "Baikal (Nuke Wars)", "translation_key": "map.baikalnukewars", "categories": ["other"], "multiplayer_frequency": 0, diff --git a/map-generator/assets/maps/beringsea/info.json b/map-generator/assets/maps/beringsea/info.json index b59bea066..45061aff9 100644 --- a/map-generator/assets/maps/beringsea/info.json +++ b/map-generator/assets/maps/beringsea/info.json @@ -4,6 +4,7 @@ "translation_key": "map.beringsea", "categories": ["asia", "north_america"], "multiplayer_frequency": 5, + "special_team_count": 2, "nations": [ { "coordinates": [1043, 121], diff --git a/map-generator/assets/maps/beringstrait/info.json b/map-generator/assets/maps/beringstrait/info.json index 4e90a79b1..1849ff023 100644 --- a/map-generator/assets/maps/beringstrait/info.json +++ b/map-generator/assets/maps/beringstrait/info.json @@ -4,6 +4,7 @@ "translation_key": "map.beringstrait", "categories": ["asia", "north_america"], "multiplayer_frequency": 2, + "special_team_count": 2, "nations": [ { "coordinates": [1297, 287], diff --git a/map-generator/assets/maps/bosphorusstraits/info.json b/map-generator/assets/maps/bosphorusstraits/info.json index bca2eae3e..74e2346a1 100644 --- a/map-generator/assets/maps/bosphorusstraits/info.json +++ b/map-generator/assets/maps/bosphorusstraits/info.json @@ -4,6 +4,7 @@ "translation_key": "map.bosphorusstraits", "categories": ["europe", "asia"], "multiplayer_frequency": 3, + "special_team_count": 2, "nations": [ { "coordinates": [564, 245], diff --git a/map-generator/assets/maps/britanniaclassic/info.json b/map-generator/assets/maps/britanniaclassic/info.json index 100d53af9..414b393b8 100644 --- a/map-generator/assets/maps/britanniaclassic/info.json +++ b/map-generator/assets/maps/britanniaclassic/info.json @@ -1,6 +1,7 @@ { "id": "BritanniaClassic", "name": "Britannia Classic", + "display_name": "Britannia (Classic)", "translation_key": "map.britanniaclassic", "categories": ["europe"], "multiplayer_frequency": 0, diff --git a/map-generator/assets/maps/choppingblock/info.json b/map-generator/assets/maps/choppingblock/info.json index c58e8cbf7..df13cfd4f 100644 --- a/map-generator/assets/maps/choppingblock/info.json +++ b/map-generator/assets/maps/choppingblock/info.json @@ -4,6 +4,7 @@ "translation_key": "map.choppingblock", "categories": ["other"], "multiplayer_frequency": 5, + "special_team_count": 4, "nations": [ { "coordinates": [230, 230], diff --git a/map-generator/assets/maps/conakry/info.json b/map-generator/assets/maps/conakry/info.json index 97a9c103f..a8582d09d 100644 --- a/map-generator/assets/maps/conakry/info.json +++ b/map-generator/assets/maps/conakry/info.json @@ -4,6 +4,7 @@ "translation_key": "map.conakry", "categories": ["africa"], "multiplayer_frequency": 3, + "special_team_count": 2, "nations": [ { "coordinates": [510, 420], diff --git a/map-generator/assets/maps/didierfrance/info.json b/map-generator/assets/maps/didierfrance/info.json index ba8eae89f..a6ba4bf20 100644 --- a/map-generator/assets/maps/didierfrance/info.json +++ b/map-generator/assets/maps/didierfrance/info.json @@ -1,6 +1,7 @@ { "id": "DidierFrance", "name": "Didier France", + "display_name": "Didier (France)", "translation_key": "map.didierfrance", "categories": ["other"], "multiplayer_frequency": 1, diff --git a/map-generator/assets/maps/europeclassic/info.json b/map-generator/assets/maps/europeclassic/info.json index 42387231b..f1a2aa6d6 100644 --- a/map-generator/assets/maps/europeclassic/info.json +++ b/map-generator/assets/maps/europeclassic/info.json @@ -1,6 +1,7 @@ { "id": "EuropeClassic", "name": "Europe Classic", + "display_name": "Europe (Classic)", "translation_key": "map.europeclassic", "categories": ["europe"], "multiplayer_frequency": 0, diff --git a/map-generator/assets/maps/falklandislands/info.json b/map-generator/assets/maps/falklandislands/info.json index 10350571b..2693aaa1f 100644 --- a/map-generator/assets/maps/falklandislands/info.json +++ b/map-generator/assets/maps/falklandislands/info.json @@ -4,6 +4,7 @@ "translation_key": "map.falklandislands", "categories": ["south_america"], "multiplayer_frequency": 4, + "special_team_count": 2, "nations": [ { "coordinates": [484, 987], diff --git a/map-generator/assets/maps/fourislands/info.json b/map-generator/assets/maps/fourislands/info.json index bfb49145b..95e0ecf39 100644 --- a/map-generator/assets/maps/fourislands/info.json +++ b/map-generator/assets/maps/fourislands/info.json @@ -4,6 +4,7 @@ "translation_key": "map.fourislands", "categories": ["other"], "multiplayer_frequency": 4, + "special_team_count": 4, "nations": [ { "coordinates": [403, 1296], diff --git a/map-generator/assets/maps/gulfofstlawrence/info.json b/map-generator/assets/maps/gulfofstlawrence/info.json index ce62e045f..aeda2d362 100644 --- a/map-generator/assets/maps/gulfofstlawrence/info.json +++ b/map-generator/assets/maps/gulfofstlawrence/info.json @@ -4,6 +4,7 @@ "translation_key": "map.gulfofstlawrence", "categories": ["north_america"], "multiplayer_frequency": 4, + "special_team_count": 3, "nations": [ { "coordinates": [88, 364], diff --git a/map-generator/assets/maps/juandefucastrait/info.json b/map-generator/assets/maps/juandefucastrait/info.json index 27a96a1df..026787db8 100644 --- a/map-generator/assets/maps/juandefucastrait/info.json +++ b/map-generator/assets/maps/juandefucastrait/info.json @@ -4,6 +4,7 @@ "translation_key": "map.juandefucastrait", "categories": ["north_america"], "multiplayer_frequency": 4, + "special_team_count": 3, "nations": [ { "coordinates": [1812, 445], diff --git a/map-generator/assets/maps/luna/info.json b/map-generator/assets/maps/luna/info.json index e9cda1b40..87b6b8570 100644 --- a/map-generator/assets/maps/luna/info.json +++ b/map-generator/assets/maps/luna/info.json @@ -4,6 +4,7 @@ "translation_key": "map.luna", "categories": ["cosmic"], "multiplayer_frequency": 6, + "special_team_count": 2, "nations": [ { "coordinates": [265, 662], diff --git a/map-generator/assets/maps/mena/info.json b/map-generator/assets/maps/mena/info.json index 3aca2ed33..43bcaff56 100644 --- a/map-generator/assets/maps/mena/info.json +++ b/map-generator/assets/maps/mena/info.json @@ -1,6 +1,7 @@ { "id": "Mena", "name": "Mena", + "display_name": "MENA", "translation_key": "map.mena", "categories": ["asia", "africa"], "multiplayer_frequency": 6, diff --git a/map-generator/assets/maps/milkyway/info.json b/map-generator/assets/maps/milkyway/info.json index 920e3791f..1762c5559 100644 --- a/map-generator/assets/maps/milkyway/info.json +++ b/map-generator/assets/maps/milkyway/info.json @@ -1,6 +1,7 @@ { "id": "MilkyWay", "name": "MilkyWay", + "display_name": "Milky Way", "translation_key": "map.milkyway", "categories": ["cosmic"], "multiplayer_frequency": 8, diff --git a/map-generator/assets/maps/pluto/info.json b/map-generator/assets/maps/pluto/info.json index 5f5ff3496..5392f1289 100644 --- a/map-generator/assets/maps/pluto/info.json +++ b/map-generator/assets/maps/pluto/info.json @@ -4,6 +4,7 @@ "translation_key": "map.pluto", "categories": ["cosmic"], "multiplayer_frequency": 6, + "special_team_count": 2, "nations": [ { "coordinates": [396, 364], diff --git a/map-generator/assets/maps/southeastasia/info.json b/map-generator/assets/maps/southeastasia/info.json index f3eb48188..a62129da0 100644 --- a/map-generator/assets/maps/southeastasia/info.json +++ b/map-generator/assets/maps/southeastasia/info.json @@ -1,6 +1,7 @@ { "id": "SoutheastAsia", "name": "SoutheastAsia", + "display_name": "Southeast Asia", "translation_key": "map.southeastasia", "categories": ["asia"], "multiplayer_frequency": 5, diff --git a/map-generator/assets/maps/straitofgibraltar/info.json b/map-generator/assets/maps/straitofgibraltar/info.json index e39b89f86..033c3599d 100644 --- a/map-generator/assets/maps/straitofgibraltar/info.json +++ b/map-generator/assets/maps/straitofgibraltar/info.json @@ -4,6 +4,7 @@ "translation_key": "map.straitofgibraltar", "categories": ["europe", "africa"], "multiplayer_frequency": 5, + "special_team_count": 2, "nations": [ { "coordinates": [1941, 1031], diff --git a/map-generator/assets/maps/straitofhormuz/info.json b/map-generator/assets/maps/straitofhormuz/info.json index 7ce84a2cf..a85781c04 100644 --- a/map-generator/assets/maps/straitofhormuz/info.json +++ b/map-generator/assets/maps/straitofhormuz/info.json @@ -4,6 +4,7 @@ "translation_key": "map.straitofhormuz", "categories": ["asia"], "multiplayer_frequency": 4, + "special_team_count": 2, "nations": [ { "coordinates": [837, 356], diff --git a/map-generator/assets/maps/surrounded/info.json b/map-generator/assets/maps/surrounded/info.json index 64ab0c5ef..158c8563e 100644 --- a/map-generator/assets/maps/surrounded/info.json +++ b/map-generator/assets/maps/surrounded/info.json @@ -4,6 +4,7 @@ "translation_key": "map.surrounded", "categories": ["other"], "multiplayer_frequency": 4, + "special_team_count": 4, "nations": [ { "coordinates": [1043, 910], diff --git a/map-generator/assets/maps/tradersdream/info.json b/map-generator/assets/maps/tradersdream/info.json index 519b4132f..ee8dba415 100644 --- a/map-generator/assets/maps/tradersdream/info.json +++ b/map-generator/assets/maps/tradersdream/info.json @@ -4,6 +4,7 @@ "translation_key": "map.tradersdream", "categories": ["other"], "multiplayer_frequency": 4, + "special_team_count": 2, "nations": [ { "coordinates": [1010, 120], diff --git a/map-generator/codegen.go b/map-generator/codegen.go index 3ca89d304..d37f829b9 100644 --- a/map-generator/codegen.go +++ b/map-generator/codegen.go @@ -1,11 +1,11 @@ package main import ( + "bytes" "encoding/json" "fmt" "os" "path/filepath" - "sort" "strings" ) @@ -26,18 +26,24 @@ var categoryOrder = []string{ "other", } -// mapInfo is the subset of info.json fields used for TypeScript generation. +// 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. @@ -50,20 +56,21 @@ func (m mapInfo) hasCategory(category string) bool { 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 { +// 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 err + return nil, 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 { @@ -72,32 +79,35 @@ func generateMapsTS() error { } 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) + 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 fmt.Errorf("failed to parse info.json for %s: %w", m.Name, err) + 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 fmt.Errorf("map %s: info.json \"id\" (%q) must be the folder name in UpperCamelCase", m.Name, info.ID) + 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 fmt.Errorf("map %s: info.json is missing \"name\"", m.Name) + return nil, 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.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 fmt.Errorf("map %s: info.json \"multiplayer_frequency\" (%d) must be >= 0", m.Name, info.MultiplayerFrequency) + return nil, 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) + 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 fmt.Errorf("map %s: info.json sets \"featured_rank\" but \"categories\" does not include \"featured\"", m.Name) + 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 fmt.Errorf("map %s: info.json \"categories\" must list at least one category", m.Name) + 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 { @@ -109,15 +119,26 @@ func generateMapsTS() error { } } if !valid { - return fmt.Errorf("map %s: info.json category %q must be one of: %s", m.Name, category, strings.Join(categoryOrder, ", ")) + 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 fmt.Errorf("map %s: info.json lists category %q more than once", m.Name, 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") @@ -134,49 +155,121 @@ func generateMapsTS() error { b.WriteString("export type GameMapName = keyof typeof GameMapType;\n\n") - b.WriteString("export const mapCategories: Record = {\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 { - members := make([]mapInfo, 0, len(infos)) - for _, info := range infos { - if info.hasCategory(category) { - members = append(members, info) + 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)) } - 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("],\n") + b.WriteString(fmt.Sprintf(" multiplayerFrequency: %d,\n", info.MultiplayerFrequency)) + if info.FeaturedRank > 0 { + b.WriteString(fmt.Sprintf(" featuredRank: %d,\n", info.FeaturedRank)) } - b.WriteString(fmt.Sprintf(" %s: [\n", category)) - for _, info := range members { - b.WriteString(fmt.Sprintf(" GameMapType.%s,\n", info.ID)) + if info.SpecialTeamCount > 0 { + b.WriteString(fmt.Sprintf(" specialTeamCount: %d,\n", info.SpecialTeamCount)) } - b.WriteString(" ],\n") + b.WriteString(" },\n") } - b.WriteString("};\n\n") - - b.WriteString("export const mapTranslationKeys: Record = {\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 = {\n") - for _, info := range infos { - b.WriteString(fmt.Sprintf(" %s: %d,\n", info.ID, info.MultiplayerFrequency)) - } - 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 +} diff --git a/map-generator/main.go b/map-generator/main.go index a3997dd2a..454874d71 100644 --- a/map-generator/main.go +++ b/map-generator/main.go @@ -274,9 +274,16 @@ func main() { log.Fatalf("Error generating terrain maps: %v", err) } - if err := generateMapsTS(); err != nil { + infos, err := loadMapInfos() + if err != nil { + log.Fatalf("Error loading map info: %v", err) + } + if err := generateMapsTS(infos); err != nil { log.Fatalf("Error generating Maps.gen.ts: %v", err) } + if err := generateEnJSON(infos); err != nil { + log.Fatalf("Error generating en.json map section: %v", err) + } fmt.Println("Terrain maps generated successfully") } diff --git a/map-generator/map-generator b/map-generator/map-generator index c04cb5d6e..3a3947efc 100755 Binary files a/map-generator/map-generator and b/map-generator/map-generator differ diff --git a/resources/maps/aegean/manifest.json b/resources/maps/aegean/manifest.json index 390f7629c..e46e5dc3a 100644 --- a/resources/maps/aegean/manifest.json +++ b/resources/maps/aegean/manifest.json @@ -140,6 +140,7 @@ "name": "Carpathos" } ], + "special_team_count": 2, "teamGameSpawnAreas": { "2": [ { diff --git a/resources/maps/archipelagosea/manifest.json b/resources/maps/archipelagosea/manifest.json index 53d234682..3d1e43f99 100644 --- a/resources/maps/archipelagosea/manifest.json +++ b/resources/maps/archipelagosea/manifest.json @@ -1,5 +1,6 @@ { "categories": ["europe"], + "display_name": "Archipelago Sea", "id": "ArchipelagoSea", "map": { "height": 1508, diff --git a/resources/maps/baikal/manifest.json b/resources/maps/baikal/manifest.json index ee1223299..a73204f45 100644 --- a/resources/maps/baikal/manifest.json +++ b/resources/maps/baikal/manifest.json @@ -75,6 +75,7 @@ "name": "Listvyanka" } ], + "special_team_count": 2, "teamGameSpawnAreas": { "2": [ { diff --git a/resources/maps/baikalnukewars/manifest.json b/resources/maps/baikalnukewars/manifest.json index d1cff8fdb..fffa4f904 100644 --- a/resources/maps/baikalnukewars/manifest.json +++ b/resources/maps/baikalnukewars/manifest.json @@ -1,5 +1,6 @@ { "categories": ["other"], + "display_name": "Baikal (Nuke Wars)", "id": "BaikalNukeWars", "map": { "height": 1564, diff --git a/resources/maps/beringsea/manifest.json b/resources/maps/beringsea/manifest.json index 7a8d92388..774306da3 100644 --- a/resources/maps/beringsea/manifest.json +++ b/resources/maps/beringsea/manifest.json @@ -140,6 +140,7 @@ "name": "Magadan" } ], + "special_team_count": 2, "teamGameSpawnAreas": { "2": [ { diff --git a/resources/maps/beringstrait/manifest.json b/resources/maps/beringstrait/manifest.json index fafab00f8..81d7939e0 100644 --- a/resources/maps/beringstrait/manifest.json +++ b/resources/maps/beringstrait/manifest.json @@ -30,6 +30,7 @@ "name": "Russia" } ], + "special_team_count": 2, "teamGameSpawnAreas": { "2": [ { diff --git a/resources/maps/bosphorusstraits/manifest.json b/resources/maps/bosphorusstraits/manifest.json index 7ab9d8ce3..87023a3b5 100644 --- a/resources/maps/bosphorusstraits/manifest.json +++ b/resources/maps/bosphorusstraits/manifest.json @@ -130,6 +130,7 @@ "name": "Esenyurt" } ], + "special_team_count": 2, "teamGameSpawnAreas": { "2": [ { diff --git a/resources/maps/britanniaclassic/manifest.json b/resources/maps/britanniaclassic/manifest.json index eb71151c0..c0c7a2714 100644 --- a/resources/maps/britanniaclassic/manifest.json +++ b/resources/maps/britanniaclassic/manifest.json @@ -1,5 +1,6 @@ { "categories": ["europe"], + "display_name": "Britannia (Classic)", "id": "BritanniaClassic", "map": { "height": 1396, diff --git a/resources/maps/choppingblock/manifest.json b/resources/maps/choppingblock/manifest.json index cfe81988a..70df869c4 100644 --- a/resources/maps/choppingblock/manifest.json +++ b/resources/maps/choppingblock/manifest.json @@ -323,6 +323,7 @@ "name": "Keeko" } ], + "special_team_count": 4, "teamGameSpawnAreas": { "2": [ { diff --git a/resources/maps/conakry/manifest.json b/resources/maps/conakry/manifest.json index e661024cd..e77d6ba17 100644 --- a/resources/maps/conakry/manifest.json +++ b/resources/maps/conakry/manifest.json @@ -120,6 +120,7 @@ "name": "Dioumaya" } ], + "special_team_count": 2, "teamGameSpawnAreas": { "2": [ { diff --git a/resources/maps/didierfrance/manifest.json b/resources/maps/didierfrance/manifest.json index b476f6a1a..1166890b1 100644 --- a/resources/maps/didierfrance/manifest.json +++ b/resources/maps/didierfrance/manifest.json @@ -1,5 +1,6 @@ { "categories": ["other"], + "display_name": "Didier (France)", "id": "DidierFrance", "map": { "height": 2248, diff --git a/resources/maps/europeclassic/manifest.json b/resources/maps/europeclassic/manifest.json index 2ad67d031..b215d8b85 100644 --- a/resources/maps/europeclassic/manifest.json +++ b/resources/maps/europeclassic/manifest.json @@ -1,5 +1,6 @@ { "categories": ["europe"], + "display_name": "Europe (Classic)", "id": "EuropeClassic", "map": { "height": 1000, diff --git a/resources/maps/falklandislands/manifest.json b/resources/maps/falklandislands/manifest.json index c01f7dc73..8680d8a67 100644 --- a/resources/maps/falklandislands/manifest.json +++ b/resources/maps/falklandislands/manifest.json @@ -80,6 +80,7 @@ "name": "San Carlos" } ], + "special_team_count": 2, "teamGameSpawnAreas": { "2": [ { diff --git a/resources/maps/fourislands/manifest.json b/resources/maps/fourislands/manifest.json index be2d6fe4b..87f639e07 100644 --- a/resources/maps/fourislands/manifest.json +++ b/resources/maps/fourislands/manifest.json @@ -40,6 +40,7 @@ "name": "Myrkwind" } ], + "special_team_count": 4, "teamGameSpawnAreas": { "2": [ { diff --git a/resources/maps/gulfofstlawrence/manifest.json b/resources/maps/gulfofstlawrence/manifest.json index b6bca43c7..2a4bbc301 100644 --- a/resources/maps/gulfofstlawrence/manifest.json +++ b/resources/maps/gulfofstlawrence/manifest.json @@ -150,6 +150,7 @@ "name": "Yarmouth" } ], + "special_team_count": 3, "teamGameSpawnAreas": { "3": [ { diff --git a/resources/maps/juandefucastrait/manifest.json b/resources/maps/juandefucastrait/manifest.json index c864b97d4..9fdcd2307 100644 --- a/resources/maps/juandefucastrait/manifest.json +++ b/resources/maps/juandefucastrait/manifest.json @@ -322,6 +322,7 @@ "name": "Arlington" } ], + "special_team_count": 3, "teamGameSpawnAreas": { "3": [ { diff --git a/resources/maps/luna/manifest.json b/resources/maps/luna/manifest.json index ed5603e29..af038da7b 100644 --- a/resources/maps/luna/manifest.json +++ b/resources/maps/luna/manifest.json @@ -145,6 +145,7 @@ "name": "ΜΟΝΟʟΙȚΗ" } ], + "special_team_count": 2, "teamGameSpawnAreas": { "2": [ { diff --git a/resources/maps/mena/manifest.json b/resources/maps/mena/manifest.json index 53f1c7d56..147937a4b 100644 --- a/resources/maps/mena/manifest.json +++ b/resources/maps/mena/manifest.json @@ -1,5 +1,6 @@ { "categories": ["asia", "africa"], + "display_name": "MENA", "id": "Mena", "map": { "height": 964, diff --git a/resources/maps/milkyway/manifest.json b/resources/maps/milkyway/manifest.json index e62dbd445..419879821 100644 --- a/resources/maps/milkyway/manifest.json +++ b/resources/maps/milkyway/manifest.json @@ -1,5 +1,6 @@ { "categories": ["cosmic"], + "display_name": "Milky Way", "id": "MilkyWay", "map": { "height": 1500, diff --git a/resources/maps/pluto/manifest.json b/resources/maps/pluto/manifest.json index a982f626c..106186959 100644 --- a/resources/maps/pluto/manifest.json +++ b/resources/maps/pluto/manifest.json @@ -100,6 +100,7 @@ "name": "Free Pluto State" } ], + "special_team_count": 2, "teamGameSpawnAreas": { "2": [ { diff --git a/resources/maps/southeastasia/manifest.json b/resources/maps/southeastasia/manifest.json index 08c5cb92d..996677d2e 100644 --- a/resources/maps/southeastasia/manifest.json +++ b/resources/maps/southeastasia/manifest.json @@ -157,6 +157,7 @@ } ], "categories": ["asia"], + "display_name": "Southeast Asia", "id": "SoutheastAsia", "map": { "height": 1672, diff --git a/resources/maps/straitofgibraltar/manifest.json b/resources/maps/straitofgibraltar/manifest.json index 4c4dcf343..f19a9927b 100644 --- a/resources/maps/straitofgibraltar/manifest.json +++ b/resources/maps/straitofgibraltar/manifest.json @@ -52,6 +52,7 @@ "name": "Andalusia" } ], + "special_team_count": 2, "teamGameSpawnAreas": { "2": [ { diff --git a/resources/maps/straitofhormuz/manifest.json b/resources/maps/straitofhormuz/manifest.json index 3071261f4..ab1374f75 100644 --- a/resources/maps/straitofhormuz/manifest.json +++ b/resources/maps/straitofhormuz/manifest.json @@ -125,6 +125,7 @@ "name": "Bahrain" } ], + "special_team_count": 2, "teamGameSpawnAreas": { "2": [ { diff --git a/resources/maps/surrounded/manifest.json b/resources/maps/surrounded/manifest.json index 20cb4f9af..f29200a0c 100644 --- a/resources/maps/surrounded/manifest.json +++ b/resources/maps/surrounded/manifest.json @@ -60,6 +60,7 @@ "name": "Rugged Islander" } ], + "special_team_count": 4, "teamGameSpawnAreas": { "2": [ { diff --git a/resources/maps/tradersdream/manifest.json b/resources/maps/tradersdream/manifest.json index 34f9cd13e..1b45836ef 100644 --- a/resources/maps/tradersdream/manifest.json +++ b/resources/maps/tradersdream/manifest.json @@ -85,6 +85,7 @@ "name": "Harborwick" } ], + "special_team_count": 2, "teamGameSpawnAreas": { "2": [ { diff --git a/src/client/Utils.ts b/src/client/Utils.ts index d71538c59..d34aa2b44 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -1,10 +1,9 @@ import IntlMessageFormat from "intl-messageformat"; import { Duos, - GameMapType, GameMode, HumansVsNations, - mapTranslationKeys, + maps, MessageType, PublicGameModifiers, Quads, @@ -24,7 +23,7 @@ export function normaliseMapKey(mapName: string): string { export function getMapName(mapName: string | undefined): string | null { if (!mapName) return null; const translationKey = - mapTranslationKeys[mapName as GameMapType] ?? + maps.find((m) => m.type === mapName)?.translationKey ?? `map.${normaliseMapKey(mapName)}`; return translateText(translationKey); } diff --git a/src/client/components/map/MapPicker.ts b/src/client/components/map/MapPicker.ts index e2001306d..705ccef46 100644 --- a/src/client/components/map/MapPicker.ts +++ b/src/client/components/map/MapPicker.ts @@ -5,8 +5,10 @@ import { assetUrl } from "../../../core/AssetUrls"; import { Difficulty, GameMapType, - mapCategories, - mapTranslationKeys, + MapCategory, + mapCategoryOrder, + MapInfo, + maps, } from "../../../core/game/Game"; import { translateText } from "../../Utils"; import "./MapDisplay"; @@ -15,6 +17,19 @@ const randomMap = assetUrl("images/RandomMap.webp"); type MapTab = "featured" | "all" | "favorites"; +// Featured grid order: ranked maps first (1 = first), unranked alphabetical. +const featuredMaps: MapInfo[] = maps + .filter((m) => m.categories.includes("featured")) + .sort( + (a, b) => + (a.featuredRank ?? Number.MAX_SAFE_INTEGER) - + (b.featuredRank ?? Number.MAX_SAFE_INTEGER), + ); + +function mapsInCategory(category: MapCategory): MapInfo[] { + return maps.filter((m) => m.categories.includes(category)); +} + @customElement("map-picker") export class MapPicker extends LitElement { @property({ type: String }) selectedMap: GameMapType = GameMapType.World; @@ -63,29 +78,26 @@ export class MapPicker extends LitElement { return this.mapWins?.get(mapValue) ?? new Set(); } - private renderMapCard(mapValue: GameMapType) { - const mapKey = Object.entries(GameMapType).find( - ([_, value]) => value === mapValue, - )?.[0]; + private renderMapCard(map: MapInfo) { return html`
this.handleMapSelection(mapValue)} + @click=${() => this.handleMapSelection(map.type)} class="cursor-pointer" > this.handleToggleFavorite(mapValue)} - .translation=${translateText(mapTranslationKeys[mapValue])} + .wins=${this.getWins(map.type)} + .favorite=${this.favorites.includes(map.type)} + .onToggleFavorite=${() => this.handleToggleFavorite(map.type)} + .translation=${translateText(map.translationKey)} >
`; } - private renderMapGrid(maps: GameMapType[]) { + private renderMapGrid(mapList: MapInfo[]) { // Keyed by map so cards keep their identity when the list shifts // (e.g. the selected map gets prepended to the featured grid) — // positional reuse would leave stale thumbnails behind. @@ -93,9 +105,9 @@ export class MapPicker extends LitElement { class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4" > ${repeat( - maps, - (mapValue) => mapValue, - (mapValue) => this.renderMapCard(mapValue), + mapList, + (map) => map.id, + (map) => this.renderMapCard(map), )} `; } @@ -108,7 +120,7 @@ export class MapPicker extends LitElement { `; } - private renderCategoryBar(categoryKey: string, maps: GameMapType[]) { + private renderCategoryBar(categoryKey: MapCategory, mapList: MapInfo[]) { const expanded = this.expandedCategories.has(categoryKey); return html`
${expanded - ? html`
${this.renderMapGrid(maps)}
` + ? html`
${this.renderMapGrid(mapList)}
` : null}
`; } private renderFeaturedTab() { - const featured = mapCategories.featured ?? []; - let featuredMapList = featured; - if (!this.useRandomMap && !featured.includes(this.selectedMap)) { - featuredMapList = [this.selectedMap, ...featured]; + let featuredMapList = featuredMaps; + const selected = maps.find((m) => m.type === this.selectedMap); + if ( + !this.useRandomMap && + selected !== undefined && + !featuredMaps.includes(selected) + ) { + featuredMapList = [selected, ...featuredMaps]; } return html`
${this.renderSectionHeading(translateText("map_categories.featured"))} @@ -156,10 +172,10 @@ export class MapPicker extends LitElement { private renderAllTab() { return html`
- ${Object.entries(mapCategories) - .filter(([categoryKey]) => categoryKey !== "featured") - .map(([categoryKey, maps]) => - this.renderCategoryBar(categoryKey, maps), + ${mapCategoryOrder + .filter((categoryKey) => categoryKey !== "featured") + .map((categoryKey) => + this.renderCategoryBar(categoryKey, mapsInCategory(categoryKey)), )}
`; } @@ -175,9 +191,12 @@ export class MapPicker extends LitElement {

`; } + const favoriteMaps = this.favorites + .map((favorite) => maps.find((m) => m.type === favorite)) + .filter((m) => m !== undefined); return html`
${this.renderSectionHeading(translateText("map_categories.favorites"))} - ${this.renderMapGrid(this.favorites)} + ${this.renderMapGrid(favoriteMaps)}
`; } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 3cfe48df9..67d6f66c2 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -93,16 +93,16 @@ export const ColoredTeams: Record = { Nations: "Nations", } as const; -// GameMapType, GameMapName, mapCategories, mapTranslationKeys, and -// multiplayerFrequency are generated from +// GameMapType and the maps list are generated from // map-generator/assets/maps//info.json by the map-generator // (`npm run gen-maps`). export { GameMapType, - mapCategories, - mapTranslationKeys, - multiplayerFrequency, + mapCategoryOrder, + maps, type GameMapName, + type MapCategory, + type MapInfo, } from "./Maps.gen"; export enum GameType { diff --git a/src/core/game/Maps.gen.ts b/src/core/game/Maps.gen.ts index 435d44b61..28c7e2302 100644 --- a/src/core/game/Maps.gen.ts +++ b/src/core/game/Maps.gen.ts @@ -101,328 +101,734 @@ export enum GameMapType { export type GameMapName = keyof typeof GameMapType; -export const mapCategories: Record = { - featured: [ - GameMapType.World, - GameMapType.Europe, - GameMapType.NorthAmerica, - GameMapType.SouthAmerica, - GameMapType.Asia, - GameMapType.Africa, - GameMapType.Japan, - ], - world: [ - GameMapType.Dyslexdria, - GameMapType.GiantWorldMap, - GameMapType.World, - GameMapType.WorldInverted, - ], - europe: [ - GameMapType.Aegean, - GameMapType.Alps, - GameMapType.ArchipelagoSea, - GameMapType.Arctic, - GameMapType.Balkans, - GameMapType.BetweenTwoSeas, - GameMapType.BlackSea, - GameMapType.BosphorusStraits, - GameMapType.Britannia, - GameMapType.BritanniaClassic, - GameMapType.Caucasus, - GameMapType.DanishStraits, - GameMapType.Europe, - GameMapType.EuropeClassic, - GameMapType.FaroeIslands, - GameMapType.GatewayToTheAtlantic, - GameMapType.Halkidiki, - GameMapType.Iceland, - GameMapType.Italia, - GameMapType.Lemnos, - GameMapType.Lisbon, - GameMapType.MareNostrum, - GameMapType.StraitOfGibraltar, - GameMapType.TwoLakes, - GameMapType.Venice, - ], - asia: [ - GameMapType.Asia, - GameMapType.Baikal, - GameMapType.BeringSea, - GameMapType.BeringStrait, - GameMapType.BetweenTwoSeas, - GameMapType.BlackSea, - GameMapType.BosphorusStraits, - GameMapType.Caucasus, - GameMapType.EastAsia, - GameMapType.HongKong, - GameMapType.IndianSubcontinent, - GameMapType.Japan, - GameMapType.Korea, - GameMapType.Mena, - GameMapType.MiddleEast, - GameMapType.SoutheastAsia, - GameMapType.StraitOfHormuz, - GameMapType.StraitOfMalacca, - GameMapType.TaiwanStrait, - GameMapType.YellowSea, - GameMapType.Yenisei, - ], - north_america: [ - GameMapType.Arctic, - GameMapType.BajaCalifornia, - GameMapType.BeringSea, - GameMapType.BeringStrait, - GameMapType.Caribbean, - GameMapType.GreatLakes, - GameMapType.GulfOfStLawrence, - GameMapType.Hawaii, - GameMapType.JuanDeFucaStrait, - GameMapType.LosAngeles, - GameMapType.Manicouagan, - GameMapType.MississippiRiver, - GameMapType.Montreal, - GameMapType.NewYorkCity, - GameMapType.NorthAmerica, - GameMapType.NorthwestPassage, - GameMapType.SanFrancisco, - ], - africa: [ - GameMapType.Africa, - GameMapType.Conakry, - GameMapType.Mena, - GameMapType.NileDelta, - GameMapType.StraitOfGibraltar, - ], - south_america: [ - GameMapType.AmazonRiver, - GameMapType.FalklandIslands, - GameMapType.SouthAmerica, - ], - oceania: [GameMapType.Australia, GameMapType.Hawaii, GameMapType.Oceania], - antarctica: [GameMapType.Antarctica, GameMapType.DeglaciatedAntarctica], - cosmic: [ - GameMapType.Luna, - GameMapType.Mars, - GameMapType.MilkyWay, - GameMapType.Pluto, - GameMapType.Titan, - ], - tournament: [ - GameMapType.Tourney1, - GameMapType.Tourney2, - GameMapType.Tourney3, - GameMapType.Tourney4, - ], - other: [ - GameMapType.Achiran, - GameMapType.BaikalNukeWars, - GameMapType.ChoppingBlock, - GameMapType.Didier, - GameMapType.DidierFrance, - GameMapType.FourIslands, - GameMapType.Labyrinth, - GameMapType.Onion, - GameMapType.Pangaea, - GameMapType.Passage, - GameMapType.Sierpinski, - GameMapType.Surrounded, - GameMapType.Svalmel, - GameMapType.TheBox, - GameMapType.TradersDream, - ], -}; +export type MapCategory = + | "featured" + | "world" + | "europe" + | "asia" + | "north_america" + | "africa" + | "south_america" + | "oceania" + | "antarctica" + | "cosmic" + | "tournament" + | "other"; -export const mapTranslationKeys: Record = { - [GameMapType.Achiran]: "map.achiran", - [GameMapType.Aegean]: "map.aegean", - [GameMapType.Africa]: "map.africa", - [GameMapType.Alps]: "map.alps", - [GameMapType.AmazonRiver]: "map.amazonriver", - [GameMapType.Antarctica]: "map.antarctica", - [GameMapType.ArchipelagoSea]: "map.archipelagosea", - [GameMapType.Arctic]: "map.arctic", - [GameMapType.Asia]: "map.asia", - [GameMapType.Australia]: "map.australia", - [GameMapType.Baikal]: "map.baikal", - [GameMapType.BaikalNukeWars]: "map.baikalnukewars", - [GameMapType.BajaCalifornia]: "map.bajacalifornia", - [GameMapType.Balkans]: "map.balkans", - [GameMapType.BeringSea]: "map.beringsea", - [GameMapType.BeringStrait]: "map.beringstrait", - [GameMapType.BetweenTwoSeas]: "map.betweentwoseas", - [GameMapType.BlackSea]: "map.blacksea", - [GameMapType.BosphorusStraits]: "map.bosphorusstraits", - [GameMapType.Britannia]: "map.britannia", - [GameMapType.BritanniaClassic]: "map.britanniaclassic", - [GameMapType.Caribbean]: "map.caribbean", - [GameMapType.Caucasus]: "map.caucasus", - [GameMapType.ChoppingBlock]: "map.choppingblock", - [GameMapType.Conakry]: "map.conakry", - [GameMapType.DanishStraits]: "map.danishstraits", - [GameMapType.DeglaciatedAntarctica]: "map.deglaciatedantarctica", - [GameMapType.Didier]: "map.didier", - [GameMapType.DidierFrance]: "map.didierfrance", - [GameMapType.Dyslexdria]: "map.dyslexdria", - [GameMapType.EastAsia]: "map.eastasia", - [GameMapType.Europe]: "map.europe", - [GameMapType.EuropeClassic]: "map.europeclassic", - [GameMapType.FalklandIslands]: "map.falklandislands", - [GameMapType.FaroeIslands]: "map.faroeislands", - [GameMapType.FourIslands]: "map.fourislands", - [GameMapType.GatewayToTheAtlantic]: "map.gatewaytotheatlantic", - [GameMapType.GiantWorldMap]: "map.giantworldmap", - [GameMapType.GreatLakes]: "map.greatlakes", - [GameMapType.GulfOfStLawrence]: "map.gulfofstlawrence", - [GameMapType.Halkidiki]: "map.halkidiki", - [GameMapType.Hawaii]: "map.hawaii", - [GameMapType.HongKong]: "map.hongkong", - [GameMapType.Iceland]: "map.iceland", - [GameMapType.IndianSubcontinent]: "map.indiansubcontinent", - [GameMapType.Italia]: "map.italia", - [GameMapType.Japan]: "map.japan", - [GameMapType.JuanDeFucaStrait]: "map.juandefucastrait", - [GameMapType.Korea]: "map.korea", - [GameMapType.Labyrinth]: "map.labyrinth", - [GameMapType.Lemnos]: "map.lemnos", - [GameMapType.Lisbon]: "map.lisbon", - [GameMapType.LosAngeles]: "map.losangeles", - [GameMapType.Luna]: "map.luna", - [GameMapType.Manicouagan]: "map.manicouagan", - [GameMapType.MareNostrum]: "map.marenostrum", - [GameMapType.Mars]: "map.mars", - [GameMapType.Mena]: "map.mena", - [GameMapType.MiddleEast]: "map.middleeast", - [GameMapType.MilkyWay]: "map.milkyway", - [GameMapType.MississippiRiver]: "map.mississippiriver", - [GameMapType.Montreal]: "map.montreal", - [GameMapType.NewYorkCity]: "map.newyorkcity", - [GameMapType.NileDelta]: "map.niledelta", - [GameMapType.NorthAmerica]: "map.northamerica", - [GameMapType.NorthwestPassage]: "map.northwestpassage", - [GameMapType.Oceania]: "map.oceania", - [GameMapType.Onion]: "map.onion", - [GameMapType.Pangaea]: "map.pangaea", - [GameMapType.Passage]: "map.passage", - [GameMapType.Pluto]: "map.pluto", - [GameMapType.SanFrancisco]: "map.sanfrancisco", - [GameMapType.Sierpinski]: "map.sierpinski", - [GameMapType.SouthAmerica]: "map.southamerica", - [GameMapType.SoutheastAsia]: "map.southeastasia", - [GameMapType.StraitOfGibraltar]: "map.straitofgibraltar", - [GameMapType.StraitOfHormuz]: "map.straitofhormuz", - [GameMapType.StraitOfMalacca]: "map.straitofmalacca", - [GameMapType.Surrounded]: "map.surrounded", - [GameMapType.Svalmel]: "map.svalmel", - [GameMapType.TaiwanStrait]: "map.taiwanstrait", - [GameMapType.TheBox]: "map.thebox", - [GameMapType.Titan]: "map.titan", - [GameMapType.Tourney1]: "map.tourney1", - [GameMapType.Tourney2]: "map.tourney2", - [GameMapType.Tourney3]: "map.tourney3", - [GameMapType.Tourney4]: "map.tourney4", - [GameMapType.TradersDream]: "map.tradersdream", - [GameMapType.TwoLakes]: "map.twolakes", - [GameMapType.Venice]: "map.venice", - [GameMapType.World]: "map.world", - [GameMapType.WorldInverted]: "map.worldinverted", - [GameMapType.YellowSea]: "map.yellowsea", - [GameMapType.Yenisei]: "map.yenisei", -}; +// Category display order in the map picker. +export const mapCategoryOrder: readonly MapCategory[] = [ + "featured", + "world", + "europe", + "asia", + "north_america", + "africa", + "south_america", + "oceania", + "antarctica", + "cosmic", + "tournament", + "other", +]; -// How many times each map appears in the multiplayer playlist. -export const multiplayerFrequency: Record = { - Achiran: 5, - Aegean: 6, - Africa: 7, - Alps: 4, - AmazonRiver: 3, - Antarctica: 1, - ArchipelagoSea: 3, - Arctic: 6, - Asia: 6, - Australia: 4, - Baikal: 5, - BaikalNukeWars: 0, - BajaCalifornia: 4, - Balkans: 6, - BeringSea: 5, - BeringStrait: 2, - BetweenTwoSeas: 5, - BlackSea: 6, - BosphorusStraits: 3, - Britannia: 5, - BritanniaClassic: 0, - Caribbean: 5, - Caucasus: 5, - ChoppingBlock: 5, - Conakry: 3, - DanishStraits: 5, - DeglaciatedAntarctica: 4, - Didier: 1, - DidierFrance: 1, - Dyslexdria: 8, - EastAsia: 5, - Europe: 7, - EuropeClassic: 0, - FalklandIslands: 4, - FaroeIslands: 4, - FourIslands: 4, - GatewayToTheAtlantic: 5, - GiantWorldMap: 0, - GreatLakes: 6, - GulfOfStLawrence: 4, - Halkidiki: 4, - Hawaii: 4, - HongKong: 6, - Iceland: 4, - IndianSubcontinent: 8, - Italia: 6, - Japan: 6, - JuanDeFucaStrait: 4, - Korea: 5, - Labyrinth: 6, - Lemnos: 3, - Lisbon: 4, - LosAngeles: 8, - Luna: 6, - Manicouagan: 4, - MareNostrum: 6, - Mars: 3, - Mena: 6, - MiddleEast: 8, - MilkyWay: 8, - MississippiRiver: 3, - Montreal: 6, - NewYorkCity: 3, - NileDelta: 4, - NorthAmerica: 5, - NorthwestPassage: 5, - Oceania: 0, - Onion: 2, - Pangaea: 5, - Passage: 4, - Pluto: 6, - SanFrancisco: 3, - Sierpinski: 10, - SouthAmerica: 5, - SoutheastAsia: 5, - StraitOfGibraltar: 5, - StraitOfHormuz: 4, - StraitOfMalacca: 4, - Surrounded: 4, - Svalmel: 8, - TaiwanStrait: 5, - TheBox: 3, - Titan: 3, - Tourney1: 0, - Tourney2: 0, - Tourney3: 0, - Tourney4: 0, - TradersDream: 4, - TwoLakes: 6, - Venice: 6, - World: 20, - WorldInverted: 8, - YellowSea: 5, - Yenisei: 6, -}; +export interface MapInfo { + /** GameMapType enum key — the UpperCamelCase folder name. */ + id: GameMapName; + /** Canonical map name (wire format) — the GameMapType enum value. */ + type: GameMapType; + /** Key of the map's display name in resources/lang/en.json. */ + translationKey: string; + /** Map picker categories. */ + categories: MapCategory[]; + /** How many times the map appears in the multiplayer playlist. */ + multiplayerFrequency: number; + /** Position in the featured grid (1 = first); unranked featured maps sort last. */ + featuredRank?: number; + /** Preferred team count in team/special games (see MapPlaylist). */ + specialTeamCount?: number; +} + +export const maps: readonly MapInfo[] = [ + { + id: "Achiran", + type: GameMapType.Achiran, + translationKey: "map.achiran", + categories: ["other"], + multiplayerFrequency: 5, + }, + { + id: "Aegean", + type: GameMapType.Aegean, + translationKey: "map.aegean", + categories: ["europe"], + multiplayerFrequency: 6, + specialTeamCount: 2, + }, + { + id: "Africa", + type: GameMapType.Africa, + translationKey: "map.africa", + categories: ["featured", "africa"], + multiplayerFrequency: 7, + featuredRank: 6, + }, + { + id: "Alps", + type: GameMapType.Alps, + translationKey: "map.alps", + categories: ["europe"], + multiplayerFrequency: 4, + }, + { + id: "AmazonRiver", + type: GameMapType.AmazonRiver, + translationKey: "map.amazonriver", + categories: ["south_america"], + multiplayerFrequency: 3, + }, + { + id: "Antarctica", + type: GameMapType.Antarctica, + translationKey: "map.antarctica", + categories: ["antarctica"], + multiplayerFrequency: 1, + }, + { + id: "ArchipelagoSea", + type: GameMapType.ArchipelagoSea, + translationKey: "map.archipelagosea", + categories: ["europe"], + multiplayerFrequency: 3, + }, + { + id: "Arctic", + type: GameMapType.Arctic, + translationKey: "map.arctic", + categories: ["europe", "north_america"], + multiplayerFrequency: 6, + }, + { + id: "Asia", + type: GameMapType.Asia, + translationKey: "map.asia", + categories: ["featured", "asia"], + multiplayerFrequency: 6, + featuredRank: 5, + }, + { + id: "Australia", + type: GameMapType.Australia, + translationKey: "map.australia", + categories: ["oceania"], + multiplayerFrequency: 4, + }, + { + id: "Baikal", + type: GameMapType.Baikal, + translationKey: "map.baikal", + categories: ["asia"], + multiplayerFrequency: 5, + specialTeamCount: 2, + }, + { + id: "BaikalNukeWars", + type: GameMapType.BaikalNukeWars, + translationKey: "map.baikalnukewars", + categories: ["other"], + multiplayerFrequency: 0, + }, + { + id: "BajaCalifornia", + type: GameMapType.BajaCalifornia, + translationKey: "map.bajacalifornia", + categories: ["north_america"], + multiplayerFrequency: 4, + }, + { + id: "Balkans", + type: GameMapType.Balkans, + translationKey: "map.balkans", + categories: ["europe"], + multiplayerFrequency: 6, + }, + { + id: "BeringSea", + type: GameMapType.BeringSea, + translationKey: "map.beringsea", + categories: ["asia", "north_america"], + multiplayerFrequency: 5, + specialTeamCount: 2, + }, + { + id: "BeringStrait", + type: GameMapType.BeringStrait, + translationKey: "map.beringstrait", + categories: ["asia", "north_america"], + multiplayerFrequency: 2, + specialTeamCount: 2, + }, + { + id: "BetweenTwoSeas", + type: GameMapType.BetweenTwoSeas, + translationKey: "map.betweentwoseas", + categories: ["europe", "asia"], + multiplayerFrequency: 5, + }, + { + id: "BlackSea", + type: GameMapType.BlackSea, + translationKey: "map.blacksea", + categories: ["europe", "asia"], + multiplayerFrequency: 6, + }, + { + id: "BosphorusStraits", + type: GameMapType.BosphorusStraits, + translationKey: "map.bosphorusstraits", + categories: ["europe", "asia"], + multiplayerFrequency: 3, + specialTeamCount: 2, + }, + { + id: "Britannia", + type: GameMapType.Britannia, + translationKey: "map.britannia", + categories: ["europe"], + multiplayerFrequency: 5, + }, + { + id: "BritanniaClassic", + type: GameMapType.BritanniaClassic, + translationKey: "map.britanniaclassic", + categories: ["europe"], + multiplayerFrequency: 0, + }, + { + id: "Caribbean", + type: GameMapType.Caribbean, + translationKey: "map.caribbean", + categories: ["north_america"], + multiplayerFrequency: 5, + }, + { + id: "Caucasus", + type: GameMapType.Caucasus, + translationKey: "map.caucasus", + categories: ["europe", "asia"], + multiplayerFrequency: 5, + }, + { + id: "ChoppingBlock", + type: GameMapType.ChoppingBlock, + translationKey: "map.choppingblock", + categories: ["other"], + multiplayerFrequency: 5, + specialTeamCount: 4, + }, + { + id: "Conakry", + type: GameMapType.Conakry, + translationKey: "map.conakry", + categories: ["africa"], + multiplayerFrequency: 3, + specialTeamCount: 2, + }, + { + id: "DanishStraits", + type: GameMapType.DanishStraits, + translationKey: "map.danishstraits", + categories: ["europe"], + multiplayerFrequency: 5, + }, + { + id: "DeglaciatedAntarctica", + type: GameMapType.DeglaciatedAntarctica, + translationKey: "map.deglaciatedantarctica", + categories: ["antarctica"], + multiplayerFrequency: 4, + }, + { + id: "Didier", + type: GameMapType.Didier, + translationKey: "map.didier", + categories: ["other"], + multiplayerFrequency: 1, + }, + { + id: "DidierFrance", + type: GameMapType.DidierFrance, + translationKey: "map.didierfrance", + categories: ["other"], + multiplayerFrequency: 1, + }, + { + id: "Dyslexdria", + type: GameMapType.Dyslexdria, + translationKey: "map.dyslexdria", + categories: ["world"], + multiplayerFrequency: 8, + }, + { + id: "EastAsia", + type: GameMapType.EastAsia, + translationKey: "map.eastasia", + categories: ["asia"], + multiplayerFrequency: 5, + }, + { + id: "Europe", + type: GameMapType.Europe, + translationKey: "map.europe", + categories: ["featured", "europe"], + multiplayerFrequency: 7, + featuredRank: 2, + }, + { + id: "EuropeClassic", + type: GameMapType.EuropeClassic, + translationKey: "map.europeclassic", + categories: ["europe"], + multiplayerFrequency: 0, + }, + { + id: "FalklandIslands", + type: GameMapType.FalklandIslands, + translationKey: "map.falklandislands", + categories: ["south_america"], + multiplayerFrequency: 4, + specialTeamCount: 2, + }, + { + id: "FaroeIslands", + type: GameMapType.FaroeIslands, + translationKey: "map.faroeislands", + categories: ["europe"], + multiplayerFrequency: 4, + }, + { + id: "FourIslands", + type: GameMapType.FourIslands, + translationKey: "map.fourislands", + categories: ["other"], + multiplayerFrequency: 4, + specialTeamCount: 4, + }, + { + id: "GatewayToTheAtlantic", + type: GameMapType.GatewayToTheAtlantic, + translationKey: "map.gatewaytotheatlantic", + categories: ["europe"], + multiplayerFrequency: 5, + }, + { + id: "GiantWorldMap", + type: GameMapType.GiantWorldMap, + translationKey: "map.giantworldmap", + categories: ["world"], + multiplayerFrequency: 0, + }, + { + id: "GreatLakes", + type: GameMapType.GreatLakes, + translationKey: "map.greatlakes", + categories: ["north_america"], + multiplayerFrequency: 6, + }, + { + id: "GulfOfStLawrence", + type: GameMapType.GulfOfStLawrence, + translationKey: "map.gulfofstlawrence", + categories: ["north_america"], + multiplayerFrequency: 4, + specialTeamCount: 3, + }, + { + id: "Halkidiki", + type: GameMapType.Halkidiki, + translationKey: "map.halkidiki", + categories: ["europe"], + multiplayerFrequency: 4, + }, + { + id: "Hawaii", + type: GameMapType.Hawaii, + translationKey: "map.hawaii", + categories: ["north_america", "oceania"], + multiplayerFrequency: 4, + }, + { + id: "HongKong", + type: GameMapType.HongKong, + translationKey: "map.hongkong", + categories: ["asia"], + multiplayerFrequency: 6, + }, + { + id: "Iceland", + type: GameMapType.Iceland, + translationKey: "map.iceland", + categories: ["europe"], + multiplayerFrequency: 4, + }, + { + id: "IndianSubcontinent", + type: GameMapType.IndianSubcontinent, + translationKey: "map.indiansubcontinent", + categories: ["asia"], + multiplayerFrequency: 8, + }, + { + id: "Italia", + type: GameMapType.Italia, + translationKey: "map.italia", + categories: ["europe"], + multiplayerFrequency: 6, + }, + { + id: "Japan", + type: GameMapType.Japan, + translationKey: "map.japan", + categories: ["featured", "asia"], + multiplayerFrequency: 6, + featuredRank: 7, + }, + { + id: "JuanDeFucaStrait", + type: GameMapType.JuanDeFucaStrait, + translationKey: "map.juandefucastrait", + categories: ["north_america"], + multiplayerFrequency: 4, + specialTeamCount: 3, + }, + { + id: "Korea", + type: GameMapType.Korea, + translationKey: "map.korea", + categories: ["asia"], + multiplayerFrequency: 5, + }, + { + id: "Labyrinth", + type: GameMapType.Labyrinth, + translationKey: "map.labyrinth", + categories: ["other"], + multiplayerFrequency: 6, + }, + { + id: "Lemnos", + type: GameMapType.Lemnos, + translationKey: "map.lemnos", + categories: ["europe"], + multiplayerFrequency: 3, + }, + { + id: "Lisbon", + type: GameMapType.Lisbon, + translationKey: "map.lisbon", + categories: ["europe"], + multiplayerFrequency: 4, + }, + { + id: "LosAngeles", + type: GameMapType.LosAngeles, + translationKey: "map.losangeles", + categories: ["north_america"], + multiplayerFrequency: 8, + }, + { + id: "Luna", + type: GameMapType.Luna, + translationKey: "map.luna", + categories: ["cosmic"], + multiplayerFrequency: 6, + specialTeamCount: 2, + }, + { + id: "Manicouagan", + type: GameMapType.Manicouagan, + translationKey: "map.manicouagan", + categories: ["north_america"], + multiplayerFrequency: 4, + }, + { + id: "MareNostrum", + type: GameMapType.MareNostrum, + translationKey: "map.marenostrum", + categories: ["europe"], + multiplayerFrequency: 6, + }, + { + id: "Mars", + type: GameMapType.Mars, + translationKey: "map.mars", + categories: ["cosmic"], + multiplayerFrequency: 3, + }, + { + id: "Mena", + type: GameMapType.Mena, + translationKey: "map.mena", + categories: ["asia", "africa"], + multiplayerFrequency: 6, + }, + { + id: "MiddleEast", + type: GameMapType.MiddleEast, + translationKey: "map.middleeast", + categories: ["asia"], + multiplayerFrequency: 8, + }, + { + id: "MilkyWay", + type: GameMapType.MilkyWay, + translationKey: "map.milkyway", + categories: ["cosmic"], + multiplayerFrequency: 8, + }, + { + id: "MississippiRiver", + type: GameMapType.MississippiRiver, + translationKey: "map.mississippiriver", + categories: ["north_america"], + multiplayerFrequency: 3, + }, + { + id: "Montreal", + type: GameMapType.Montreal, + translationKey: "map.montreal", + categories: ["north_america"], + multiplayerFrequency: 6, + }, + { + id: "NewYorkCity", + type: GameMapType.NewYorkCity, + translationKey: "map.newyorkcity", + categories: ["north_america"], + multiplayerFrequency: 3, + }, + { + id: "NileDelta", + type: GameMapType.NileDelta, + translationKey: "map.niledelta", + categories: ["africa"], + multiplayerFrequency: 4, + }, + { + id: "NorthAmerica", + type: GameMapType.NorthAmerica, + translationKey: "map.northamerica", + categories: ["featured", "north_america"], + multiplayerFrequency: 5, + featuredRank: 3, + }, + { + id: "NorthwestPassage", + type: GameMapType.NorthwestPassage, + translationKey: "map.northwestpassage", + categories: ["north_america"], + multiplayerFrequency: 5, + }, + { + id: "Oceania", + type: GameMapType.Oceania, + translationKey: "map.oceania", + categories: ["oceania"], + multiplayerFrequency: 0, + }, + { + id: "Onion", + type: GameMapType.Onion, + translationKey: "map.onion", + categories: ["other"], + multiplayerFrequency: 2, + }, + { + id: "Pangaea", + type: GameMapType.Pangaea, + translationKey: "map.pangaea", + categories: ["other"], + multiplayerFrequency: 5, + }, + { + id: "Passage", + type: GameMapType.Passage, + translationKey: "map.passage", + categories: ["other"], + multiplayerFrequency: 4, + }, + { + id: "Pluto", + type: GameMapType.Pluto, + translationKey: "map.pluto", + categories: ["cosmic"], + multiplayerFrequency: 6, + specialTeamCount: 2, + }, + { + id: "SanFrancisco", + type: GameMapType.SanFrancisco, + translationKey: "map.sanfrancisco", + categories: ["north_america"], + multiplayerFrequency: 3, + }, + { + id: "Sierpinski", + type: GameMapType.Sierpinski, + translationKey: "map.sierpinski", + categories: ["other"], + multiplayerFrequency: 10, + }, + { + id: "SouthAmerica", + type: GameMapType.SouthAmerica, + translationKey: "map.southamerica", + categories: ["featured", "south_america"], + multiplayerFrequency: 5, + featuredRank: 4, + }, + { + id: "SoutheastAsia", + type: GameMapType.SoutheastAsia, + translationKey: "map.southeastasia", + categories: ["asia"], + multiplayerFrequency: 5, + }, + { + id: "StraitOfGibraltar", + type: GameMapType.StraitOfGibraltar, + translationKey: "map.straitofgibraltar", + categories: ["europe", "africa"], + multiplayerFrequency: 5, + specialTeamCount: 2, + }, + { + id: "StraitOfHormuz", + type: GameMapType.StraitOfHormuz, + translationKey: "map.straitofhormuz", + categories: ["asia"], + multiplayerFrequency: 4, + specialTeamCount: 2, + }, + { + id: "StraitOfMalacca", + type: GameMapType.StraitOfMalacca, + translationKey: "map.straitofmalacca", + categories: ["asia"], + multiplayerFrequency: 4, + }, + { + id: "Surrounded", + type: GameMapType.Surrounded, + translationKey: "map.surrounded", + categories: ["other"], + multiplayerFrequency: 4, + specialTeamCount: 4, + }, + { + id: "Svalmel", + type: GameMapType.Svalmel, + translationKey: "map.svalmel", + categories: ["other"], + multiplayerFrequency: 8, + }, + { + id: "TaiwanStrait", + type: GameMapType.TaiwanStrait, + translationKey: "map.taiwanstrait", + categories: ["asia"], + multiplayerFrequency: 5, + }, + { + id: "TheBox", + type: GameMapType.TheBox, + translationKey: "map.thebox", + categories: ["other"], + multiplayerFrequency: 3, + }, + { + id: "Titan", + type: GameMapType.Titan, + translationKey: "map.titan", + categories: ["cosmic"], + multiplayerFrequency: 3, + }, + { + id: "Tourney1", + type: GameMapType.Tourney1, + translationKey: "map.tourney1", + categories: ["tournament"], + multiplayerFrequency: 0, + }, + { + id: "Tourney2", + type: GameMapType.Tourney2, + translationKey: "map.tourney2", + categories: ["tournament"], + multiplayerFrequency: 0, + }, + { + id: "Tourney3", + type: GameMapType.Tourney3, + translationKey: "map.tourney3", + categories: ["tournament"], + multiplayerFrequency: 0, + }, + { + id: "Tourney4", + type: GameMapType.Tourney4, + translationKey: "map.tourney4", + categories: ["tournament"], + multiplayerFrequency: 0, + }, + { + id: "TradersDream", + type: GameMapType.TradersDream, + translationKey: "map.tradersdream", + categories: ["other"], + multiplayerFrequency: 4, + specialTeamCount: 2, + }, + { + id: "TwoLakes", + type: GameMapType.TwoLakes, + translationKey: "map.twolakes", + categories: ["europe"], + multiplayerFrequency: 6, + }, + { + id: "Venice", + type: GameMapType.Venice, + translationKey: "map.venice", + categories: ["europe"], + multiplayerFrequency: 6, + }, + { + id: "World", + type: GameMapType.World, + translationKey: "map.world", + categories: ["featured", "world"], + multiplayerFrequency: 20, + featuredRank: 1, + }, + { + id: "WorldInverted", + type: GameMapType.WorldInverted, + translationKey: "map.worldinverted", + categories: ["world"], + multiplayerFrequency: 8, + }, + { + id: "YellowSea", + type: GameMapType.YellowSea, + translationKey: "map.yellowsea", + categories: ["asia"], + multiplayerFrequency: 5, + }, + { + id: "Yenisei", + type: GameMapType.Yenisei, + translationKey: "map.yenisei", + categories: ["asia"], + multiplayerFrequency: 6, + }, +]; diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 59efe1c30..c0d492f6c 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -1,14 +1,13 @@ import { SAM_CONSTRUCTION_TICKS } from "../core/configuration/Config"; import { + maps as allMaps, Difficulty, Duos, - GameMapName, GameMapSize, GameMapType, GameMode, GameType, HumansVsNations, - multiplayerFrequency, PublicGameModifiers, Quads, RankedType, @@ -50,30 +49,17 @@ const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [ { config: HumansVsNations, weight: 20 }, ]; -// Maps with a preferred team count in team / special games. +// Maps with a preferred team count in team / special games, declared via +// "special_team_count" in each map's info.json. // For these maps: team-playlist frequency is doubled, and the preferred // team count overrides the random TEAM_WEIGHTS roll with SPECIAL_TEAM_FORCE_CHANCE. const SPECIAL_TEAM_FORCE_CHANCE = 0.75; const SPECIAL_TEAM_FREQ_MULTIPLIER = 2; -const SPECIAL_TEAM_MAPS: ReadonlyMap = new Map([ - [GameMapType.Baikal, 2], - [GameMapType.FourIslands, 4], - [GameMapType.Luna, 2], - [GameMapType.StraitOfGibraltar, 2], - [GameMapType.StraitOfHormuz, 2], - [GameMapType.Aegean, 2], - [GameMapType.BeringSea, 2], - [GameMapType.BeringStrait, 2], - [GameMapType.BosphorusStraits, 2], - [GameMapType.Conakry, 2], - [GameMapType.Pluto, 2], - [GameMapType.FalklandIslands, 2], - [GameMapType.TradersDream, 2], - [GameMapType.Surrounded, 4], - [GameMapType.GulfOfStLawrence, 3], - [GameMapType.ChoppingBlock, 4], - [GameMapType.JuanDeFucaStrait, 3], -]); +const SPECIAL_TEAM_MAPS: ReadonlyMap = new Map( + allMaps + .filter((m) => m.specialTeamCount !== undefined) + .map((m) => [m.type, m.specialTeamCount!]), +); type ModifierKey = | "isRandomSpawn" @@ -498,15 +484,15 @@ export class MapPlaylist { private buildMapsList(type: PublicGameType): GameMapType[] { const maps: GameMapType[] = []; - (Object.keys(GameMapType) as GameMapName[]).forEach((key) => { - const map = GameMapType[key]; + allMaps.forEach((mapInfo) => { + const map = mapInfo.type; if ( type !== "special" && (ARCADE_MAPS.has(map) || SPECIAL_ONLY_MAPS.has(map)) ) { return; } - let freq = multiplayerFrequency[key]; + let freq = mapInfo.multiplayerFrequency; // Boost frequency for special team maps in the team playlist if (type === "team" && SPECIAL_TEAM_MAPS.has(map)) { freq *= SPECIAL_TEAM_FREQ_MULTIPLIER; diff --git a/tests/MapConsistency.test.ts b/tests/MapConsistency.test.ts index a835286ae..0806e8530 100644 --- a/tests/MapConsistency.test.ts +++ b/tests/MapConsistency.test.ts @@ -1,12 +1,6 @@ import fs from "fs"; import path from "path"; -import { - GameMapName, - GameMapType, - mapCategories, - mapTranslationKeys, - multiplayerFrequency, -} from "../src/core/game/Game"; +import { GameMapName, GameMapType, MapInfo, maps } from "../src/core/game/Game"; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -35,25 +29,22 @@ const FREQUENCY_EXEMPTIONS: Set = new Set([ "BritanniaClassic", ]); -/** Get the en.json map translation keys. */ -function getEnJsonMapKeys(): Set { +// Keys in the en.json "map" section that are UI strings, not map names. +const EN_JSON_META_KEYS = new Set([ + "map", + "featured", + "all", + "favorites", + "random", +]); + +/** Get the en.json "map" section. */ +function getEnJsonMapSection(): Record { const content = JSON.parse(fs.readFileSync(EN_JSON, "utf8")); - const mapSection = content.map as Record; - // Exclude meta keys that aren't actual maps. - const metaKeys = new Set(["map", "featured", "all", "random"]); - return new Set(Object.keys(mapSection).filter((k) => !metaKeys.has(k))); + return content.map as Record; } -/** Map each GameMapType value to the mapCategories keys that contain it. */ -function getMapCategoryKeys(): Map { - const result = new Map(); - for (const [categoryKey, maps] of Object.entries(mapCategories)) { - for (const map of maps) { - result.set(map, [...(result.get(map) ?? []), categoryKey]); - } - } - return result; -} +const mapsById = new Map(maps.map((m) => [m.id, m])); /** Read the parsed info.json for a map, or null if missing. */ function readInfoJson(key: GameMapName): Record | null { @@ -62,6 +53,12 @@ function readInfoJson(key: GameMapName): Record | null { return JSON.parse(fs.readFileSync(infoPath, "utf8")); } +/** The generator treats falsy info.json values (0, "") as "omitted". */ +function orOmitted(value: unknown): unknown { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return value || undefined; +} + // ── Tests ──────────────────────────────────────────────────────────────────── describe("Map consistency", () => { @@ -94,29 +91,35 @@ describe("Map consistency", () => { } }); - test("Every GameMapType is listed in at least one mapCategories group", () => { - const categoryKeys = getMapCategoryKeys(); + test("The maps list and GameMapType match one-to-one", () => { const errors: string[] = []; for (const key of allMapKeys) { - const categories = categoryKeys.get(GameMapType[key]) ?? []; - if (categories.length === 0) { - errors.push(`${key} is not listed in any mapCategories group`); + if (!mapsById.has(key)) { + errors.push(`${key} has no entry in the generated maps list`); } } + for (const m of maps) { + if (!(m.id in GameMapType)) { + errors.push(`maps list entry "${m.id}" is not a GameMapType key`); + } + } + if (maps.length !== mapsById.size) { + errors.push("maps list contains duplicate ids"); + } if (errors.length > 0) { - throw new Error("mapCategories violations:\n" + errors.join("\n")); + throw new Error("maps list violations:\n" + errors.join("\n")); } }); // Maps.gen.ts is generated from the info.json files by the map-generator. // If this test fails, run `npm run gen-maps` to regenerate it. test("info.json metadata matches the generated Maps.gen.ts", () => { - const categoryKeys = getMapCategoryKeys(); const errors: string[] = []; for (const key of allMapKeys) { const info = readInfoJson(key); - if (info === null) { - continue; // Other tests catch missing files. + const map = mapsById.get(key); + if (info === null || map === undefined) { + continue; // Other tests catch missing files/entries. } const value = GameMapType[key]; if (info.id !== key) { @@ -127,26 +130,27 @@ describe("Map consistency", () => { `${key}: info.json name is "${info.name}", but GameMapType.${key} is "${value}"`, ); } - const expectedCategories = [...(categoryKeys.get(value) ?? [])].sort(); - const infoCategories = Array.isArray(info.categories) - ? [...info.categories].sort() - : []; - if ( - JSON.stringify(infoCategories) !== JSON.stringify(expectedCategories) - ) { - errors.push( - `${key}: info.json categories are ${JSON.stringify(info.categories)}, but mapCategories has it under ${JSON.stringify(expectedCategories)}`, - ); - } - if (info.translation_key !== mapTranslationKeys[value]) { - errors.push( - `${key}: info.json translation_key is "${info.translation_key}", but mapTranslationKeys has "${mapTranslationKeys[value]}"`, - ); - } - if ((info.multiplayer_frequency ?? 0) !== multiplayerFrequency[key]) { - errors.push( - `${key}: info.json multiplayer_frequency is ${JSON.stringify(info.multiplayer_frequency)}, but multiplayerFrequency has ${multiplayerFrequency[key]}`, - ); + const fields: [string, unknown, unknown][] = [ + ["categories", info.categories, map.categories], + ["translation_key", info.translation_key, map.translationKey], + [ + "multiplayer_frequency", + info.multiplayer_frequency ?? 0, + map.multiplayerFrequency, + ], + ["featured_rank", orOmitted(info.featured_rank), map.featuredRank], + [ + "special_team_count", + orOmitted(info.special_team_count), + map.specialTeamCount, + ], + ]; + for (const [field, infoValue, mapValue] of fields) { + if (JSON.stringify(infoValue) !== JSON.stringify(mapValue)) { + errors.push( + `${key}: info.json ${field} is ${JSON.stringify(infoValue)}, but the maps list has ${JSON.stringify(mapValue)}`, + ); + } } } if (errors.length > 0) { @@ -178,19 +182,37 @@ describe("Map consistency", () => { } }); - test("Every GameMapType is registered in en.json map translations", () => { - const enKeys = getEnJsonMapKeys(); + // The en.json "map" section is generated from the info.json files. + // If this test fails, run `npm run gen-maps` to regenerate it. + test("en.json map translations match info.json display names", () => { + const enMapSection = getEnJsonMapSection(); const errors: string[] = []; for (const key of allMapKeys) { const folder = toFolderName(key); - if (!enKeys.has(folder)) { + const info = readInfoJson(key); + if (info === null) continue; // Other tests catch missing files. + const expected = orOmitted(info.display_name) ?? info.name; + if (enMapSection[folder] === undefined) { errors.push( `${key} (key "${folder}") is missing from en.json map translations`, ); + } else if (enMapSection[folder] !== expected) { + errors.push( + `${key}: en.json map.${folder} is "${enMapSection[folder]}", but info.json says "${expected}"`, + ); + } + } + const validKeys = new Set(allMapKeys.map((k) => toFolderName(k))); + for (const enKey of Object.keys(enMapSection)) { + if (!EN_JSON_META_KEYS.has(enKey) && !validKeys.has(enKey)) { + errors.push(`en.json map.${enKey} does not match any map`); } } if (errors.length > 0) { - throw new Error("Maps missing from en.json:\n" + errors.join("\n")); + throw new Error( + "en.json map section is out of sync (run `npm run gen-maps`):\n" + + errors.join("\n"), + ); } }); @@ -351,10 +373,12 @@ describe("Map consistency", () => { const metadataKeys = [ "id", "name", + "display_name", "translation_key", "categories", "multiplayer_frequency", "featured_rank", + "special_team_count", ]; const errors: string[] = [];