Files
OpenFrontIO/tests/MapConsistency.test.ts
T
FloPinguinandGitHub d9976babbd Add tribe name themes system with custom tribes support 🏰 (#4647)
## Description:

Adds a theme system for bot tribe names that maps can customize via
their info.json. Instead of all bots using the same global name pool,
maps can now specify one or more themed name sets and define custom
tribe names with higher priority.

Key changes:
- New `src/core/execution/utils/tribeNameThemes.json` containing 17
themed name sets (default, north_america, south_america, europe, africa,
asia, oceania, space, fantasy, war, western, under_ocean, tournament,
funny, scary, weird, vs). The default theme preserves the original
`TribeNames.ts` values. **Mappers are encouraged to change that json
file, the new themes just serve as an example and are currently not used
by any map.**
- Maps can specify `"themes": ["europe", "war"]` in info.json to merge
prefixes/suffixes from multiple themes into one name pool.
- Maps can specify `"custom_tribes": ["Holy Roman Empire", "Kalmar
Union"]` for exact tribe names that take priority over theme-generated
names. Custom tribes are used first (random selection, no duplicates
until exhausted), then theme-based prefix+suffix combos.
- `TribeNameResolver` resolves themes per-map at runtime, with fallback
to the default theme and a console warning for unknown theme names.
- Go codegen (`codegen.go`) updated to propagate `themes` and
`custom_tribes` from info.json to Maps.gen.ts.
- Germany map now has 404 custom tribes (all German Landkreise and
kreisfreie Städte) to showcase the new feature.
- Fixed a bug where tribe bots could inherit nation flags if their
random name coincidentally matched a nation name (name-based cosmetics
lookup now only applies to actual Nations, not Bots).

## Please complete the following:

- [X] I have added screenshots for all UI updates
- [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:

FloPinguin
2026-07-19 18:46:51 -07:00

415 lines
14 KiB
TypeScript

import fs from "fs";
import path from "path";
import { GameMapName, GameMapType, MapInfo, maps } 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",
]);
// 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"));
return content.map as Record<string, string>;
}
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 {
const infoPath = path.join(MAP_GEN_MAPS, toFolderName(key), "info.json");
if (!fs.existsSync(infoPath)) return 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", () => {
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("The maps list and GameMapType match one-to-one", () => {
const errors: string[] = [];
for (const key of allMapKeys) {
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("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 errors: string[] = [];
for (const key of allMapKeys) {
const info = readInfoJson(key);
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) {
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 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,
],
["themes", orOmitted(info.themes), map.themes],
["custom_tribes", orOmitted(info.custom_tribes), map.customTribes],
];
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) {
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"),
);
}
});
// 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);
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(
"en.json map section is out of sync (run `npm run gen-maps`):\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",
"display_name",
"translation_key",
"categories",
"multiplayer_frequency",
"featured_rank",
"special_team_count",
];
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"),
);
}
});
});