mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 13:30:43 +00:00
74b3bd275b
## Description: Previously, every nation in a map's manifest.json required explicit coordinates. Additional nations already supported optional coordinates to trigger random spawn placement, but regular nations did not. Idea from PlaysBadly. Reasoning (copied off discord): > I've been working on World Inverted by adding realistic 'nations' in the form sunken ship names with their flags and location. However after searching around for other possible nation locations that are ocean related I realised that I might not have enough info for proper 'realisitc' coverage of the map. Currently Im at ~170 nations with cordinates. This is not including the additional nations with no locations. This will be reduced to ~62 as the default with the rest turning into additional nations. > > The problem is the end process is proving difficult. Trying to blance the nation placment on the map is a little much at this volume. So being able to add a few no-cordinate nations would be a great way to fill in the map. This PR also improves the MapConsistency test to check the additional nations too. ## 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
372 lines
12 KiB
TypeScript
372 lines
12 KiB
TypeScript
import fs from "fs";
|
|
import path from "path";
|
|
import { GameMapName, GameMapType, mapCategories } 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 MAIN_GO = path.join(ROOT, "map-generator", "main.go");
|
|
const EN_JSON = path.join(ROOT, "resources", "lang", "en.json");
|
|
const MAP_PLAYLIST = path.join(ROOT, "src", "server", "MapPlaylist.ts");
|
|
|
|
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",
|
|
]);
|
|
|
|
/** Parse the main.go maps registry and return the set of non-test map folder names. */
|
|
function getMainGoMaps(): Set<string> {
|
|
const content = fs.readFileSync(MAIN_GO, "utf8");
|
|
const names = new Set<string>();
|
|
// Match lines like {Name: "africa"} or {Name: "africa", IsTest: true}
|
|
const re = /\{Name:\s*"([^"]+)"(?:,\s*IsTest:\s*true)?\}/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(content)) !== null) {
|
|
// Check if it's a test map
|
|
if (!m[0].includes("IsTest: true")) {
|
|
names.add(m[1]);
|
|
}
|
|
}
|
|
return names;
|
|
}
|
|
|
|
/** 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)));
|
|
}
|
|
|
|
/** Get all maps listed in the mapCategories from Game.ts. */
|
|
function getCategorizedMaps(): Set<string> {
|
|
const result = new Set<string>();
|
|
for (const maps of Object.values(mapCategories)) {
|
|
for (const map of maps) {
|
|
result.add(map as string);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Parse the frequency record keys from MapPlaylist.ts. */
|
|
function getFrequencyKeys(): Set<string> {
|
|
const content = fs.readFileSync(MAP_PLAYLIST, "utf8");
|
|
// Extract the frequency block
|
|
const freqMatch = content.match(/const FREQUENCY[\s\S]*?\{([\s\S]*?)\};/);
|
|
if (!freqMatch) {
|
|
throw new Error(
|
|
`Failed to parse frequency record from MapPlaylist.ts (first 200 chars: ${content.slice(0, 200)})`,
|
|
);
|
|
}
|
|
const keys = new Set<string>();
|
|
const re = /(\w+):/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(freqMatch[1])) !== null) {
|
|
keys.add(m[1]);
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
describe("Map consistency", () => {
|
|
test("Every GameMapType is registered in main.go", () => {
|
|
const mainGoMaps = getMainGoMaps();
|
|
const errors: string[] = [];
|
|
for (const key of allMapKeys) {
|
|
const folder = toFolderName(key);
|
|
if (!mainGoMaps.has(folder)) {
|
|
errors.push(`${key} (folder "${folder}") is missing from main.go`);
|
|
}
|
|
}
|
|
if (errors.length > 0) {
|
|
throw new Error("Maps missing from main.go:\n" + errors.join("\n"));
|
|
}
|
|
});
|
|
|
|
test("Every main.go map has a GameMapType entry", () => {
|
|
const mainGoMaps = getMainGoMaps();
|
|
const folderToKey = new Map(allMapKeys.map((k) => [toFolderName(k), k]));
|
|
const errors: string[] = [];
|
|
for (const folder of mainGoMaps) {
|
|
if (!folderToKey.has(folder)) {
|
|
errors.push(
|
|
`main.go map "${folder}" has no matching GameMapType entry`,
|
|
);
|
|
}
|
|
}
|
|
if (errors.length > 0) {
|
|
throw new Error(
|
|
"main.go maps missing from GameMapType:\n" + errors.join("\n"),
|
|
);
|
|
}
|
|
});
|
|
|
|
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 categorized = getCategorizedMaps();
|
|
const errors: string[] = [];
|
|
for (const key of allMapKeys) {
|
|
const value = GameMapType[key];
|
|
if (!categorized.has(value)) {
|
|
errors.push(
|
|
`${key} ("${value}") is not listed in any mapCategories group`,
|
|
);
|
|
}
|
|
}
|
|
if (errors.length > 0) {
|
|
throw new Error("Maps missing from mapCategories:\n" + errors.join("\n"));
|
|
}
|
|
});
|
|
|
|
test("Every GameMapType (except exemptions) has a frequency entry", () => {
|
|
const freqKeys = getFrequencyKeys();
|
|
const errors: string[] = [];
|
|
for (const key of allMapKeys) {
|
|
if (FREQUENCY_EXEMPTIONS.has(key)) continue;
|
|
if (!freqKeys.has(key)) {
|
|
errors.push(
|
|
`${key} is missing from the frequency record in MapPlaylist.ts`,
|
|
);
|
|
}
|
|
}
|
|
if (errors.length > 0) {
|
|
throw new Error(
|
|
"Maps missing from frequency (not exempted):\n" + errors.join("\n"),
|
|
);
|
|
}
|
|
});
|
|
|
|
test("No unknown keys in frequency record", () => {
|
|
const freqKeys = getFrequencyKeys();
|
|
const validKeys = new Set(allMapKeys);
|
|
const errors: string[] = [];
|
|
for (const key of freqKeys) {
|
|
if (!validKeys.has(key as GameMapName)) {
|
|
errors.push(`"${key}" in frequency is not a valid GameMapName`);
|
|
}
|
|
}
|
|
if (errors.length > 0) {
|
|
throw new Error(
|
|
"Unknown keys in frequency record:\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"),
|
|
);
|
|
}
|
|
});
|
|
});
|