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:
Evan
2026-06-11 21:06:48 -07:00
committed by GitHub
parent be177f445a
commit 182d008ddd
60 changed files with 1099 additions and 508 deletions
+2 -3
View File
@@ -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);
}
+48 -29
View File
@@ -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>`;
}
+5 -5
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+11 -25
View File
@@ -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;