mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 06:10:42 +00:00
Generate a single MapInfo list; move SPECIAL_TEAM_MAPS and en.json map names into info.json (#4231)
**Add approved & assigned issue number here:** N/A — maintainer follow-up to #4227. ## Description: Follow-up to #4227, finishing the "info.json is the single source of truth" refactor. **Maps.gen.ts now generates one `MapInfo` interface and a `maps` list** instead of parallel lookup records. `mapCategories`, `mapTranslationKeys`, and `multiplayerFrequency` are gone — consumers read the list directly (`map.categories`, `map.translationKey`, `map.multiplayerFrequency`). MapPicker got simpler in the process: it renders from `MapInfo` objects, so the reverse `Object.entries(GameMapType)` lookup to recover the enum key is gone. The featured-rank sort moved out of the Go codegen into the picker, where the presentation concern belongs. **`SPECIAL_TEAM_MAPS` moves into info.json** as an optional `special_team_count` field (set on the same 17 maps with the same values). MapPlaylist derives its map from the generated list; `SPECIAL_TEAM_FORCE_CHANCE` and the frequency multiplier behavior are unchanged. **The en.json `map` section is now generated.** A new optional `display_name` field in info.json (defaulting to `name`) is written to `resources/lang/en.json` by the generator, preserving the section's non-map UI keys (`map`, `featured`, `all`, `favorites`, `random`). The 8 maps whose English display name intentionally differs from the frozen enum value (e.g. `MENA`, `Milky Way`, `Europe (Classic)`, `Baikal (Nuke Wars)`) declare it via `display_name`, so no display text changes. The section is emitted alphabetically; since #4232 already sorted en.json and every value matches, regeneration is byte-identical and this PR has no en.json diff. Other languages remain Crowdin-managed. The generator also now validates `translation_key` is exactly `map.<folder>` and `special_team_count >= 2`. MapConsistency tests compare info.json directly against the generated list and the en.json section, and fail with a "run `npm run gen-maps`" message on drift. No behavior changes: enum values, playlist frequencies, special-team counts, featured order, and display names are all byte-identical. ## Please complete the following: - [x] I have added screenshots for all UI updates (no UI changes — internal refactor, rendering output identical) - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: evanpelle 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+14
-7
@@ -49,7 +49,8 @@ Alternatively, `npm run gen-maps` (from the root directory) runs the generator f
|
||||
- `../resources/maps/<map_name>/map4x.bin` - 1/4 scale (half dimensions) binary map data used for mini-maps.
|
||||
- `../resources/maps/<map_name>/map16x.bin` - 1/16 scale (quarter dimensions) binary map data used for mini-maps.
|
||||
- `../resources/maps/<map_name>/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.<map_name>`).
|
||||
`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.<map_name>`.
|
||||
|
||||
`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
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"translation_key": "map.aegean",
|
||||
"categories": ["europe"],
|
||||
"multiplayer_frequency": 6,
|
||||
"special_team_count": 2,
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [786, 1860],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"id": "ArchipelagoSea",
|
||||
"name": "ArchipelagoSea",
|
||||
"display_name": "Archipelago Sea",
|
||||
"translation_key": "map.archipelagosea",
|
||||
"categories": ["europe"],
|
||||
"multiplayer_frequency": 3,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"translation_key": "map.baikal",
|
||||
"categories": ["asia"],
|
||||
"multiplayer_frequency": 5,
|
||||
"special_team_count": 2,
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [695, 665],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"id": "BaikalNukeWars",
|
||||
"name": "Baikal Nuke Wars",
|
||||
"display_name": "Baikal (Nuke Wars)",
|
||||
"translation_key": "map.baikalnukewars",
|
||||
"categories": ["other"],
|
||||
"multiplayer_frequency": 0,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"translation_key": "map.beringsea",
|
||||
"categories": ["asia", "north_america"],
|
||||
"multiplayer_frequency": 5,
|
||||
"special_team_count": 2,
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [1043, 121],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"translation_key": "map.beringstrait",
|
||||
"categories": ["asia", "north_america"],
|
||||
"multiplayer_frequency": 2,
|
||||
"special_team_count": 2,
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [1297, 287],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"translation_key": "map.bosphorusstraits",
|
||||
"categories": ["europe", "asia"],
|
||||
"multiplayer_frequency": 3,
|
||||
"special_team_count": 2,
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [564, 245],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"id": "BritanniaClassic",
|
||||
"name": "Britannia Classic",
|
||||
"display_name": "Britannia (Classic)",
|
||||
"translation_key": "map.britanniaclassic",
|
||||
"categories": ["europe"],
|
||||
"multiplayer_frequency": 0,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"translation_key": "map.choppingblock",
|
||||
"categories": ["other"],
|
||||
"multiplayer_frequency": 5,
|
||||
"special_team_count": 4,
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [230, 230],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"translation_key": "map.conakry",
|
||||
"categories": ["africa"],
|
||||
"multiplayer_frequency": 3,
|
||||
"special_team_count": 2,
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [510, 420],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"id": "DidierFrance",
|
||||
"name": "Didier France",
|
||||
"display_name": "Didier (France)",
|
||||
"translation_key": "map.didierfrance",
|
||||
"categories": ["other"],
|
||||
"multiplayer_frequency": 1,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"id": "EuropeClassic",
|
||||
"name": "Europe Classic",
|
||||
"display_name": "Europe (Classic)",
|
||||
"translation_key": "map.europeclassic",
|
||||
"categories": ["europe"],
|
||||
"multiplayer_frequency": 0,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"translation_key": "map.falklandislands",
|
||||
"categories": ["south_america"],
|
||||
"multiplayer_frequency": 4,
|
||||
"special_team_count": 2,
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [484, 987],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"translation_key": "map.fourislands",
|
||||
"categories": ["other"],
|
||||
"multiplayer_frequency": 4,
|
||||
"special_team_count": 4,
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [403, 1296],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"translation_key": "map.gulfofstlawrence",
|
||||
"categories": ["north_america"],
|
||||
"multiplayer_frequency": 4,
|
||||
"special_team_count": 3,
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [88, 364],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"translation_key": "map.juandefucastrait",
|
||||
"categories": ["north_america"],
|
||||
"multiplayer_frequency": 4,
|
||||
"special_team_count": 3,
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [1812, 445],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"translation_key": "map.luna",
|
||||
"categories": ["cosmic"],
|
||||
"multiplayer_frequency": 6,
|
||||
"special_team_count": 2,
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [265, 662],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"id": "Mena",
|
||||
"name": "Mena",
|
||||
"display_name": "MENA",
|
||||
"translation_key": "map.mena",
|
||||
"categories": ["asia", "africa"],
|
||||
"multiplayer_frequency": 6,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"id": "MilkyWay",
|
||||
"name": "MilkyWay",
|
||||
"display_name": "Milky Way",
|
||||
"translation_key": "map.milkyway",
|
||||
"categories": ["cosmic"],
|
||||
"multiplayer_frequency": 8,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"translation_key": "map.pluto",
|
||||
"categories": ["cosmic"],
|
||||
"multiplayer_frequency": 6,
|
||||
"special_team_count": 2,
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [396, 364],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"id": "SoutheastAsia",
|
||||
"name": "SoutheastAsia",
|
||||
"display_name": "Southeast Asia",
|
||||
"translation_key": "map.southeastasia",
|
||||
"categories": ["asia"],
|
||||
"multiplayer_frequency": 5,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"translation_key": "map.straitofgibraltar",
|
||||
"categories": ["europe", "africa"],
|
||||
"multiplayer_frequency": 5,
|
||||
"special_team_count": 2,
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [1941, 1031],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"translation_key": "map.straitofhormuz",
|
||||
"categories": ["asia"],
|
||||
"multiplayer_frequency": 4,
|
||||
"special_team_count": 2,
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [837, 356],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"translation_key": "map.surrounded",
|
||||
"categories": ["other"],
|
||||
"multiplayer_frequency": 4,
|
||||
"special_team_count": 4,
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [1043, 910],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"translation_key": "map.tradersdream",
|
||||
"categories": ["other"],
|
||||
"multiplayer_frequency": 4,
|
||||
"special_team_count": 2,
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [1010, 120],
|
||||
|
||||
+152
-59
@@ -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<string, GameMapType[]> = {\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<GameMapType, string> = {\n")
|
||||
for _, info := range infos {
|
||||
b.WriteString(fmt.Sprintf(" [GameMapType.%s]: %q,\n", info.ID, info.TranslationKey))
|
||||
}
|
||||
b.WriteString("};\n\n")
|
||||
|
||||
b.WriteString("// How many times each map appears in the multiplayer playlist.\n")
|
||||
b.WriteString("export const multiplayerFrequency: Record<GameMapName, number> = {\n")
|
||||
for _, info := range infos {
|
||||
b.WriteString(fmt.Sprintf(" %s: %d,\n", info.ID, info.MultiplayerFrequency))
|
||||
}
|
||||
b.WriteString("};\n")
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -140,6 +140,7 @@
|
||||
"name": "Carpathos"
|
||||
}
|
||||
],
|
||||
"special_team_count": 2,
|
||||
"teamGameSpawnAreas": {
|
||||
"2": [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"categories": ["europe"],
|
||||
"display_name": "Archipelago Sea",
|
||||
"id": "ArchipelagoSea",
|
||||
"map": {
|
||||
"height": 1508,
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"name": "Listvyanka"
|
||||
}
|
||||
],
|
||||
"special_team_count": 2,
|
||||
"teamGameSpawnAreas": {
|
||||
"2": [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"categories": ["other"],
|
||||
"display_name": "Baikal (Nuke Wars)",
|
||||
"id": "BaikalNukeWars",
|
||||
"map": {
|
||||
"height": 1564,
|
||||
|
||||
@@ -140,6 +140,7 @@
|
||||
"name": "Magadan"
|
||||
}
|
||||
],
|
||||
"special_team_count": 2,
|
||||
"teamGameSpawnAreas": {
|
||||
"2": [
|
||||
{
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"name": "Russia"
|
||||
}
|
||||
],
|
||||
"special_team_count": 2,
|
||||
"teamGameSpawnAreas": {
|
||||
"2": [
|
||||
{
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
"name": "Esenyurt"
|
||||
}
|
||||
],
|
||||
"special_team_count": 2,
|
||||
"teamGameSpawnAreas": {
|
||||
"2": [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"categories": ["europe"],
|
||||
"display_name": "Britannia (Classic)",
|
||||
"id": "BritanniaClassic",
|
||||
"map": {
|
||||
"height": 1396,
|
||||
|
||||
@@ -323,6 +323,7 @@
|
||||
"name": "Keeko"
|
||||
}
|
||||
],
|
||||
"special_team_count": 4,
|
||||
"teamGameSpawnAreas": {
|
||||
"2": [
|
||||
{
|
||||
|
||||
@@ -120,6 +120,7 @@
|
||||
"name": "Dioumaya"
|
||||
}
|
||||
],
|
||||
"special_team_count": 2,
|
||||
"teamGameSpawnAreas": {
|
||||
"2": [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"categories": ["other"],
|
||||
"display_name": "Didier (France)",
|
||||
"id": "DidierFrance",
|
||||
"map": {
|
||||
"height": 2248,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"categories": ["europe"],
|
||||
"display_name": "Europe (Classic)",
|
||||
"id": "EuropeClassic",
|
||||
"map": {
|
||||
"height": 1000,
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"name": "San Carlos"
|
||||
}
|
||||
],
|
||||
"special_team_count": 2,
|
||||
"teamGameSpawnAreas": {
|
||||
"2": [
|
||||
{
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"name": "Myrkwind"
|
||||
}
|
||||
],
|
||||
"special_team_count": 4,
|
||||
"teamGameSpawnAreas": {
|
||||
"2": [
|
||||
{
|
||||
|
||||
@@ -150,6 +150,7 @@
|
||||
"name": "Yarmouth"
|
||||
}
|
||||
],
|
||||
"special_team_count": 3,
|
||||
"teamGameSpawnAreas": {
|
||||
"3": [
|
||||
{
|
||||
|
||||
@@ -322,6 +322,7 @@
|
||||
"name": "Arlington"
|
||||
}
|
||||
],
|
||||
"special_team_count": 3,
|
||||
"teamGameSpawnAreas": {
|
||||
"3": [
|
||||
{
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
"name": "ΜΟΝΟʟΙȚΗ"
|
||||
}
|
||||
],
|
||||
"special_team_count": 2,
|
||||
"teamGameSpawnAreas": {
|
||||
"2": [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"categories": ["asia", "africa"],
|
||||
"display_name": "MENA",
|
||||
"id": "Mena",
|
||||
"map": {
|
||||
"height": 964,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"categories": ["cosmic"],
|
||||
"display_name": "Milky Way",
|
||||
"id": "MilkyWay",
|
||||
"map": {
|
||||
"height": 1500,
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"name": "Free Pluto State"
|
||||
}
|
||||
],
|
||||
"special_team_count": 2,
|
||||
"teamGameSpawnAreas": {
|
||||
"2": [
|
||||
{
|
||||
|
||||
@@ -157,6 +157,7 @@
|
||||
}
|
||||
],
|
||||
"categories": ["asia"],
|
||||
"display_name": "Southeast Asia",
|
||||
"id": "SoutheastAsia",
|
||||
"map": {
|
||||
"height": 1672,
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"name": "Andalusia"
|
||||
}
|
||||
],
|
||||
"special_team_count": 2,
|
||||
"teamGameSpawnAreas": {
|
||||
"2": [
|
||||
{
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
"name": "Bahrain"
|
||||
}
|
||||
],
|
||||
"special_team_count": 2,
|
||||
"teamGameSpawnAreas": {
|
||||
"2": [
|
||||
{
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
"name": "Rugged Islander"
|
||||
}
|
||||
],
|
||||
"special_team_count": 4,
|
||||
"teamGameSpawnAreas": {
|
||||
"2": [
|
||||
{
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
"name": "Harborwick"
|
||||
}
|
||||
],
|
||||
"special_team_count": 2,
|
||||
"teamGameSpawnAreas": {
|
||||
"2": [
|
||||
{
|
||||
|
||||
+2
-3
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
<div
|
||||
@click=${() => this.handleMapSelection(mapValue)}
|
||||
@click=${() => this.handleMapSelection(map.type)}
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<map-display
|
||||
.mapKey=${mapKey}
|
||||
.selected=${!this.useRandomMap && this.selectedMap === mapValue}
|
||||
.mapKey=${map.id}
|
||||
.selected=${!this.useRandomMap && this.selectedMap === map.type}
|
||||
.showMedals=${this.showMedals}
|
||||
.wins=${this.getWins(mapValue)}
|
||||
.favorite=${this.favorites.includes(mapValue)}
|
||||
.onToggleFavorite=${() => 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)}
|
||||
></map-display>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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),
|
||||
)}
|
||||
</div>`;
|
||||
}
|
||||
@@ -108,7 +120,7 @@ export class MapPicker extends LitElement {
|
||||
</h4>`;
|
||||
}
|
||||
|
||||
private renderCategoryBar(categoryKey: string, maps: GameMapType[]) {
|
||||
private renderCategoryBar(categoryKey: MapCategory, mapList: MapInfo[]) {
|
||||
const expanded = this.expandedCategories.has(categoryKey);
|
||||
return html`<div class="w-full">
|
||||
<button
|
||||
@@ -134,19 +146,23 @@ export class MapPicker extends LitElement {
|
||||
</svg>
|
||||
${translateText(`map_categories.${categoryKey}`)}
|
||||
</span>
|
||||
<span class="text-xs font-bold text-white/40">${maps.length}</span>
|
||||
<span class="text-xs font-bold text-white/40">${mapList.length}</span>
|
||||
</button>
|
||||
${expanded
|
||||
? html`<div class="mt-4">${this.renderMapGrid(maps)}</div>`
|
||||
? html`<div class="mt-4">${this.renderMapGrid(mapList)}</div>`
|
||||
: null}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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`<div class="w-full">
|
||||
${this.renderSectionHeading(translateText("map_categories.featured"))}
|
||||
@@ -156,10 +172,10 @@ export class MapPicker extends LitElement {
|
||||
|
||||
private renderAllTab() {
|
||||
return html`<div class="space-y-3">
|
||||
${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)),
|
||||
)}
|
||||
</div>`;
|
||||
}
|
||||
@@ -175,9 +191,12 @@ export class MapPicker extends LitElement {
|
||||
</p>
|
||||
</div>`;
|
||||
}
|
||||
const favoriteMaps = this.favorites
|
||||
.map((favorite) => maps.find((m) => m.type === favorite))
|
||||
.filter((m) => m !== undefined);
|
||||
return html`<div class="w-full">
|
||||
${this.renderSectionHeading(translateText("map_categories.favorites"))}
|
||||
${this.renderMapGrid(this.favorites)}
|
||||
${this.renderMapGrid(favoriteMaps)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -93,16 +93,16 @@ export const ColoredTeams: Record<string, Team> = {
|
||||
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/<map>/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 {
|
||||
|
||||
+729
-323
File diff suppressed because it is too large
Load Diff
+11
-25
@@ -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<GameMapType, TeamCountConfig> = 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<GameMapType, TeamCountConfig> = 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;
|
||||
|
||||
@@ -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<GameMapName> = new Set([
|
||||
"BritanniaClassic",
|
||||
]);
|
||||
|
||||
/** Get the en.json map translation keys. */
|
||||
function getEnJsonMapKeys(): Set<string> {
|
||||
// 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<string, string> {
|
||||
const content = JSON.parse(fs.readFileSync(EN_JSON, "utf8"));
|
||||
const mapSection = content.map as Record<string, string>;
|
||||
// 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<string, string>;
|
||||
}
|
||||
|
||||
/** Map each GameMapType value to the mapCategories keys that contain it. */
|
||||
function getMapCategoryKeys(): Map<GameMapType, string[]> {
|
||||
const result = new Map<GameMapType, string[]>();
|
||||
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<GameMapName, MapInfo>(maps.map((m) => [m.id, m]));
|
||||
|
||||
/** Read the parsed info.json for a map, or null if missing. */
|
||||
function readInfoJson(key: GameMapName): Record<string, unknown> | null {
|
||||
@@ -62,6 +53,12 @@ function readInfoJson(key: GameMapName): Record<string, unknown> | 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[] = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user