Files
OpenFrontIO/tests/MapConsistency.test.ts
T
Evan 3de5fb4204 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>
2026-06-11 19:36:53 -07:00

389 lines
13 KiB
TypeScript

import fs from "fs";
import path from "path";
import {
GameMapName,
GameMapType,
mapCategories,
mapTranslationKeys,
multiplayerFrequency,
} from "../src/core/game/Game";
// ── Helpers ──────────────────────────────────────────────────────────────────
/** Converts a GameMapName enum key to its folder name (lowercase key). */
function toFolderName(key: GameMapName): string {
return key.toLowerCase();
}
const ROOT = path.resolve(__dirname, "..");
const MAP_GEN_MAPS = path.join(ROOT, "map-generator", "assets", "maps");
const RESOURCES_MAPS = path.join(ROOT, "resources", "maps");
const EN_JSON = path.join(ROOT, "resources", "lang", "en.json");
const allMapKeys = Object.keys(GameMapType) as GameMapName[];
// Maps excluded from the frequency requirement (not part of regular playlists).
const FREQUENCY_EXEMPTIONS: Set<GameMapName> = new Set([
"GiantWorldMap",
"Oceania",
"BaikalNukeWars",
"Tourney1",
"Tourney2",
"Tourney3",
"Tourney4",
"EuropeClassic",
"BritanniaClassic",
]);
/** Get the en.json map translation keys. */
function getEnJsonMapKeys(): Set<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)));
}
/** 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;
}
/** Read the parsed info.json for a map, or null if missing. */
function readInfoJson(key: GameMapName): Record<string, unknown> | null {
const infoPath = path.join(MAP_GEN_MAPS, toFolderName(key), "info.json");
if (!fs.existsSync(infoPath)) return null;
return JSON.parse(fs.readFileSync(infoPath, "utf8"));
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe("Map consistency", () => {
test("Every GameMapType has map-generator assets (image.png + info.json only)", () => {
const errors: string[] = [];
for (const key of allMapKeys) {
const folder = toFolderName(key);
const dir = path.join(MAP_GEN_MAPS, folder);
if (!fs.existsSync(dir)) {
errors.push(
`${key}: directory "${folder}" missing in map-generator/assets/maps/`,
);
continue;
}
const files = fs.readdirSync(dir).sort();
const expected = ["image.png", "info.json"];
if (
files.length !== expected.length ||
!files.every((f, i) => f === expected[i])
) {
errors.push(
`${key}: expected [${expected.join(", ")}] but found [${files.join(", ")}]`,
);
}
}
if (errors.length > 0) {
throw new Error("Map generator asset violations:\n" + errors.join("\n"));
}
});
test("Every GameMapType is listed in at least one mapCategories group", () => {
const categoryKeys = getMapCategoryKeys();
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 (errors.length > 0) {
throw new Error("mapCategories 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 value = GameMapType[key];
if (info.id !== key) {
errors.push(`${key}: info.json id is "${info.id}", expected "${key}"`);
}
if (info.name !== value) {
errors.push(
`${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]}`,
);
}
}
if (errors.length > 0) {
throw new Error(
"info.json and Maps.gen.ts are out of sync (run `npm run gen-maps`):\n" +
errors.join("\n"),
);
}
});
test("Every GameMapType (except exemptions) has a positive multiplayer_frequency", () => {
const errors: string[] = [];
for (const key of allMapKeys) {
if (FREQUENCY_EXEMPTIONS.has(key)) continue;
const info = readInfoJson(key);
if (info === null) continue; // Other tests catch missing files.
const freq = info.multiplayer_frequency;
if (typeof freq !== "number" || freq <= 0) {
errors.push(
`${key} has multiplayer_frequency ${JSON.stringify(freq)} in info.json (must be > 0, or add the map to FREQUENCY_EXEMPTIONS)`,
);
}
}
if (errors.length > 0) {
throw new Error(
"Maps missing a multiplayer frequency (not exempted):\n" +
errors.join("\n"),
);
}
});
test("Every GameMapType is registered in en.json map translations", () => {
const enKeys = getEnJsonMapKeys();
const errors: string[] = [];
for (const key of allMapKeys) {
const folder = toFolderName(key);
if (!enKeys.has(folder)) {
errors.push(
`${key} (key "${folder}") is missing from en.json map translations`,
);
}
}
if (errors.length > 0) {
throw new Error("Maps missing from en.json:\n" + errors.join("\n"));
}
});
test("Every GameMapType has resources/maps/ with thumbnail.webp, bin files, and manifest.json", () => {
const errors: string[] = [];
const requiredFiles = [
"manifest.json",
"map.bin",
"map4x.bin",
"map16x.bin",
"thumbnail.webp",
];
for (const key of allMapKeys) {
const folder = toFolderName(key);
const dir = path.join(RESOURCES_MAPS, folder);
if (!fs.existsSync(dir)) {
errors.push(`${key}: directory "${folder}" missing in resources/maps/`);
continue;
}
const files = fs.readdirSync(dir);
for (const req of requiredFiles) {
if (!files.includes(req)) {
errors.push(`${key}: missing "${req}" in resources/maps/${folder}/`);
}
}
}
if (errors.length > 0) {
throw new Error("Resource map file violations:\n" + errors.join("\n"));
}
});
test("No excess folders in resources/maps/ or map-generator/assets/maps/", () => {
const expectedFolders = new Set(allMapKeys.map((k) => toFolderName(k)));
const errors: string[] = [];
const resourceDirs = fs
.readdirSync(RESOURCES_MAPS, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
for (const dir of resourceDirs) {
if (!expectedFolders.has(dir)) {
errors.push(`resources/maps/${dir}/ has no matching GameMapType entry`);
}
}
const genDirs = fs
.readdirSync(MAP_GEN_MAPS, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
for (const dir of genDirs) {
if (!expectedFolders.has(dir)) {
errors.push(
`map-generator/assets/maps/${dir}/ has no matching GameMapType entry`,
);
}
}
if (errors.length > 0) {
throw new Error("Excess map folders:\n" + errors.join("\n"));
}
});
test("Nations in info.json and manifest.json should match", () => {
const errors: string[] = [];
for (const key of allMapKeys) {
const folder = toFolderName(key);
const infoPath = path.join(MAP_GEN_MAPS, folder, "info.json");
const manifestPath = path.join(RESOURCES_MAPS, folder, "manifest.json");
if (!fs.existsSync(infoPath) || !fs.existsSync(manifestPath)) {
continue; // Other tests catch missing files.
}
try {
const info = JSON.parse(fs.readFileSync(infoPath, "utf8"));
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
// ── Compare nations ──────────────────────────────────────────────
type NationEntry = {
name: string;
coordinates?: [number, number];
};
function compareNationArrays(
label: string,
infoArr: NationEntry[],
manifestArr: NationEntry[],
): void {
if (infoArr.length !== manifestArr.length) {
errors.push(
`${key}: ${label} count mismatch — info.json has ${infoArr.length}, manifest.json has ${manifestArr.length}`,
);
return;
}
for (let i = 0; i < infoArr.length; i++) {
const inf = infoArr[i];
const man = manifestArr[i];
if (inf.name !== man.name) {
errors.push(
`${key}: ${label}[${i}] name mismatch — info.json "${inf.name}" vs manifest.json "${man.name}"`,
);
continue;
}
const infHasCoords = inf.coordinates !== undefined;
const manHasCoords = man.coordinates !== undefined;
if (infHasCoords !== manHasCoords) {
errors.push(
`${key}: ${label} "${inf.name}" (index ${i}) coordinate presence differs — info.json ${infHasCoords ? "has" : "missing"} coordinates, manifest.json ${manHasCoords ? "has" : "missing"} coordinates`,
);
continue;
}
if (inf.coordinates && man.coordinates) {
const [ix, iy] = inf.coordinates;
const [mx, my] = man.coordinates;
if (ix !== mx || iy !== my) {
errors.push(
`${key}: ${label} "${inf.name}" (index ${i}) coordinates differ — info.json [${ix}, ${iy}] vs manifest.json [${mx}, ${my}]`,
);
}
}
}
}
const toEntry = (n: NationEntry) => ({
name: n.name,
coordinates: n.coordinates,
});
compareNationArrays(
"nation",
(info.nations ?? []).map(toEntry),
(manifest.nations ?? []).map(toEntry),
);
compareNationArrays(
"additionalNation",
(info.additionalNations ?? []).map(toEntry),
(manifest.additionalNations ?? []).map(toEntry),
);
} catch (err) {
errors.push(`${key}: failed to parse JSON — ${(err as Error).message}`);
}
}
if (errors.length > 0) {
throw new Error(
"Nation data mismatches between info.json and manifest.json:\n" +
errors.join("\n"),
);
}
});
test("Map metadata in info.json and manifest.json should match", () => {
const metadataKeys = [
"id",
"name",
"translation_key",
"categories",
"multiplayer_frequency",
"featured_rank",
];
const errors: string[] = [];
for (const key of allMapKeys) {
const info = readInfoJson(key);
const manifestPath = path.join(
RESOURCES_MAPS,
toFolderName(key),
"manifest.json",
);
if (info === null || !fs.existsSync(manifestPath)) {
continue; // Other tests catch missing files.
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
for (const field of metadataKeys) {
if (JSON.stringify(info[field]) !== JSON.stringify(manifest[field])) {
errors.push(
`${key}: "${field}" mismatch — info.json ${JSON.stringify(info[field])} vs manifest.json ${JSON.stringify(manifest[field])}`,
);
}
}
}
if (errors.length > 0) {
throw new Error(
"Metadata mismatches between info.json and manifest.json (run `npm run gen-maps`):\n" +
errors.join("\n"),
);
}
});
});