Move map metadata into info.json and generate map TypeScript from it (#4227)

**Add approved & assigned issue number here:**

N/A — maintainer refactor.

## Description:

Makes each map's `info.json` the single source of truth for map metadata
— adding a map is now a folder with `image.png` + `info.json`, a
`gen-maps` run, and an en.json display name.

**info.json / manifest.json carry full map metadata.** Every
`map-generator/assets/maps/<map>/info.json` declares `id` (the
`GameMapType` enum key), `name` (the enum value — wire format, unchanged
for all 94 maps), `translation_key`, `categories`, and
`multiplayer_frequency` (the public-playlist weight that used to be the
`FREQUENCY` record in MapPlaylist.ts). The generator validates
everything and mirrors it into `resources/maps/<map>/manifest.json`. 23
stale info.json `name` values were normalized to the canonical enum
value; enum values are byte-identical, so replays and stored game
configs are unaffected.

**The generator emits the TypeScript and discovers maps itself.** New
`map-generator/codegen.go` generates `src/core/game/Maps.gen.ts`
(`GameMapType`, `GameMapName`, `mapCategories`, `mapTranslationKeys`,
`multiplayerFrequency` — now a full `Record<GameMapName, number>`,
killing the old `Partial`) on every run; `Game.ts` re-exports it. The
hardcoded map registry in `main.go` is gone — maps are auto-discovered
from the `assets/maps` / `assets/test_maps` directories. MapConsistency
tests fail with a "run `npm run gen-maps`" message if info.json,
manifest.json, and Maps.gen.ts drift. The tracked
`map-generator/map-generator` binary is rebuilt to match.

**New categories: continents + world/cosmic/tournament/other,
multi-category support.** `continental`/`regional`/`fantasy`/`arcade`
are replaced by `featured`, `world`, `europe`, `asia`, `north_america`,
`africa`, `south_america`, `oceania`, `antarctica`, `cosmic`,
`tournament`, and `other`. Maps can list multiple categories, so
straddlers (Black Sea, Bosphorus, Caucasus, Between Two Seas, Bering
Sea/Strait, Mena, Strait of Gibraltar, Hawaii, Arctic) appear under both
regions. Featured is itself a category (same 7 maps as before).
MapPlaylist keeps its arcade exclusion via an explicit set.

**Map picker UI.** Two tabs: **Featured** (default — featured maps plus
a Favorites section when maps are starred) and **All** (one prominent
collapsible bar per category with a map count, collapsed by default).
The selected map is prepended to the featured grid when it lives
elsewhere. `getMapName()` resolves through the generated
`mapTranslationKeys`, which also fixes tourney maps never resolving a
valid translation key.

## Please complete the following:

- [ ] I have added screenshots for all UI updates (maintainer change —
picker described above)
- [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 19:36:53 -07:00
committed by GitHub
parent 8da2291a49
commit 3de5fb4204
205 changed files with 2404 additions and 825 deletions
+14
View File
@@ -49,6 +49,20 @@ export class MapDisplay extends LitElement {
super.disconnectedCallback();
}
updated(changedProperties: Map<string, unknown>) {
// If this element is reused for a different map, reload its data —
// otherwise it keeps showing the previous map's thumbnail.
const previousMapKey = changedProperties.get("mapKey");
if (
changedProperties.has("mapKey") &&
previousMapKey !== undefined &&
previousMapKey !== this.mapKey &&
this.dataLoaded
) {
this.loadMapData();
}
}
private async loadMapData() {
if (!this.mapKey) return;
+92 -57
View File
@@ -1,10 +1,12 @@
import { LitElement, html } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
import { assetUrl } from "../../../core/AssetUrls";
import {
Difficulty,
GameMapType,
mapCategories,
mapTranslationKeys,
} from "../../../core/game/Game";
import { translateText } from "../../Utils";
import "./MapDisplay";
@@ -13,16 +15,6 @@ const randomMap = assetUrl("images/RandomMap.webp");
type MapTab = "featured" | "all" | "favorites";
const featuredMaps: GameMapType[] = [
GameMapType.World,
GameMapType.Europe,
GameMapType.NorthAmerica,
GameMapType.SouthAmerica,
GameMapType.Asia,
GameMapType.Africa,
GameMapType.Japan,
];
@customElement("map-picker")
export class MapPicker extends LitElement {
@property({ type: String }) selectedMap: GameMapType = GameMapType.World;
@@ -34,6 +26,7 @@ export class MapPicker extends LitElement {
@property({ attribute: false }) onSelectMap?: (map: GameMapType) => void;
@property({ attribute: false }) onSelectRandom?: () => void;
@state() private activeTab: MapTab = "featured";
@state() private expandedCategories: Set<string> = new Set();
@state() private favorites: GameMapType[] = getFavoriteMaps();
createRenderRoot() {
@@ -52,6 +45,16 @@ export class MapPicker extends LitElement {
this.onSelectRandom?.();
};
private toggleCategory(categoryKey: string) {
const expanded = new Set(this.expandedCategories);
if (expanded.has(categoryKey)) {
expanded.delete(categoryKey);
} else {
expanded.add(categoryKey);
}
this.expandedCategories = expanded;
}
private preventImageDrag(event: DragEvent) {
event.preventDefault();
}
@@ -76,50 +79,92 @@ export class MapPicker extends LitElement {
.wins=${this.getWins(mapValue)}
.favorite=${this.favorites.includes(mapValue)}
.onToggleFavorite=${() => this.handleToggleFavorite(mapValue)}
.translation=${translateText(`map.${mapKey?.toLowerCase()}`)}
.translation=${translateText(mapTranslationKeys[mapValue])}
></map-display>
</div>
`;
}
private renderAllMaps() {
const mapCategoryEntries = Object.entries(mapCategories);
return html`<div class="space-y-8">
${mapCategoryEntries.map(
([categoryKey, maps]) => html`
<div class="w-full">
<h4
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
>
${translateText(`map_categories.${categoryKey}`)}
</h4>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
${maps.map((mapValue) => this.renderMapCard(mapValue))}
</div>
</div>
`,
private renderMapGrid(maps: GameMapType[]) {
// 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.
return html`<div
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
>
${repeat(
maps,
(mapValue) => mapValue,
(mapValue) => this.renderMapCard(mapValue),
)}
</div>`;
}
private renderFeaturedMaps() {
let featuredMapList = featuredMaps;
if (!this.useRandomMap && !featuredMapList.includes(this.selectedMap)) {
featuredMapList = [this.selectedMap, ...featuredMaps];
}
private renderSectionHeading(label: string) {
return html`<h4
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
>
${label}
</h4>`;
}
private renderCategoryBar(categoryKey: string, maps: GameMapType[]) {
const expanded = this.expandedCategories.has(categoryKey);
return html`<div class="w-full">
<h4
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
<button
type="button"
aria-expanded=${expanded}
@click=${() => this.toggleCategory(categoryKey)}
class="w-full flex items-center justify-between gap-3 px-4 py-3 rounded-xl border transition-all duration-200 active:scale-[0.99] ${expanded
? "bg-malibu-blue/20 border-malibu-blue/50"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"}"
>
${translateText("map_categories.featured")}
</h4>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
${featuredMapList.map((mapValue) => this.renderMapCard(mapValue))}
</div>
<span
class="flex items-center gap-3 text-sm font-bold text-white uppercase tracking-wider"
>
<svg
class="w-3.5 h-3.5 shrink-0 transition-transform duration-200 ${expanded
? "rotate-90"
: ""}"
viewBox="0 0 12 12"
fill="currentColor"
aria-hidden="true"
>
<path d="M4 2l5 4-5 4z" />
</svg>
${translateText(`map_categories.${categoryKey}`)}
</span>
<span class="text-xs font-bold text-white/40">${maps.length}</span>
</button>
${expanded
? html`<div class="mt-4">${this.renderMapGrid(maps)}</div>`
: null}
</div>`;
}
private renderFavoriteMaps() {
private renderFeaturedTab() {
const featured = mapCategories.featured ?? [];
let featuredMapList = featured;
if (!this.useRandomMap && !featured.includes(this.selectedMap)) {
featuredMapList = [this.selectedMap, ...featured];
}
return html`<div class="w-full">
${this.renderSectionHeading(translateText("map_categories.featured"))}
${this.renderMapGrid(featuredMapList)}
</div>`;
}
private renderAllTab() {
return html`<div class="space-y-3">
${Object.entries(mapCategories)
.filter(([categoryKey]) => categoryKey !== "featured")
.map(([categoryKey, maps]) =>
this.renderCategoryBar(categoryKey, maps),
)}
</div>`;
}
private renderFavoritesTab() {
if (this.favorites.length === 0) {
return html`<div
class="w-full flex flex-col items-center justify-center gap-3 py-12 px-4 text-center rounded-xl border border-dashed border-white/10 bg-black/20"
@@ -131,25 +176,19 @@ export class MapPicker extends LitElement {
</div>`;
}
return html`<div class="w-full">
<h4
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
>
${translateText("map_categories.favorites")}
</h4>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
${this.favorites.map((mapValue) => this.renderMapCard(mapValue))}
</div>
${this.renderSectionHeading(translateText("map_categories.favorites"))}
${this.renderMapGrid(this.favorites)}
</div>`;
}
private renderActiveTab() {
switch (this.activeTab) {
case "all":
return this.renderAllMaps();
return this.renderAllTab();
case "favorites":
return this.renderFavoriteMaps();
return this.renderFavoritesTab();
default:
return this.renderFeaturedMaps();
return this.renderFeaturedTab();
}
}
@@ -188,11 +227,7 @@ export class MapPicker extends LitElement {
? "pt-4 border-t border-white/5"
: ""}"
>
<h4
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
>
${translateText("map_categories.special")}
</h4>
${this.renderSectionHeading(translateText("map_categories.special"))}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<button
type="button"