mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
Allow mappers to define additionalNations 🗺️ (#3902)
## Description: Adds an optional `additionalNations` array to map manifests (info.json / manifest.json), used as a name pool when a game requests more nations than the map defines (HvN, private lobbies, solo games). Suggested by mapmaker PatrickPlaysBadly. When the requested nation count exceeds `nations.length`: 1. The deficit is filled by random picks from `additionalNations` (collisions with manifest names are skipped). 2. If `additionalNations` still does not cover the deficit, the remainder is generated procedurally as before. Each entry supports `name`, optional `flag` and optional `coordinates`. If `coordinates` are provided, the picked nation gets a spawn cell (otherwise it spawns like the procedurally generated ones, with no fixed location). `Nation.flag` is also relaxed to optional, since many existing manifest entries already omit it. ## 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 - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin
This commit is contained in:
@@ -59,6 +59,7 @@ export async function createGameRunner(
|
||||
const nations = createNationsForGame(
|
||||
gameStart,
|
||||
gameMap.nations,
|
||||
gameMap.additionalNations,
|
||||
humans.length,
|
||||
random,
|
||||
);
|
||||
|
||||
@@ -715,6 +715,13 @@ export class GameView implements GameMap {
|
||||
flag: nation.flag ? `/flags/${nation.flag}.svg` : undefined,
|
||||
} satisfies PlayerCosmetics);
|
||||
}
|
||||
for (const extra of this._mapData.additionalNations) {
|
||||
// Only set if not already provided by a manifest nation with the same name.
|
||||
if (this._cosmetics.has(extra.name)) continue;
|
||||
this._cosmetics.set(extra.name, {
|
||||
flag: extra.flag ? `/flags/${extra.flag}.svg` : undefined,
|
||||
} satisfies PlayerCosmetics);
|
||||
}
|
||||
}
|
||||
|
||||
isOnEdgeOfMap(ref: TileRef): boolean {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
PlayerInfo,
|
||||
PlayerType,
|
||||
} from "./Game";
|
||||
import { Nation as ManifestNation } from "./TerrainMapLoader";
|
||||
import { AdditionalNation, Nation as ManifestNation } from "./TerrainMapLoader";
|
||||
|
||||
/**
|
||||
* Creates the nations array for a game.
|
||||
@@ -21,10 +21,14 @@ import { Nation as ManifestNation } from "./TerrainMapLoader";
|
||||
* - Public HumansVsNations: matches nation count to human player count
|
||||
* - Public compact maps: uses 25% of manifest nations
|
||||
* - Otherwise: uses all manifest nations
|
||||
*
|
||||
* When more nations are needed than the manifest defines, names are first
|
||||
* drawn from `additionalNations`; any remainder is generated procedurally.
|
||||
*/
|
||||
export function createNationsForGame(
|
||||
gameStart: GameStartInfo,
|
||||
manifestNations: ManifestNation[],
|
||||
additionalNations: AdditionalNation[],
|
||||
numHumans: number,
|
||||
random: PseudoRandom,
|
||||
): Nation[] {
|
||||
@@ -49,6 +53,7 @@ export function createNationsForGame(
|
||||
return createRandomNations(
|
||||
configNations,
|
||||
manifestNations,
|
||||
additionalNations,
|
||||
toNation,
|
||||
random,
|
||||
);
|
||||
@@ -57,7 +62,13 @@ export function createNationsForGame(
|
||||
if (gameStart.config.gameType === GameType.Public) {
|
||||
// For HvN, balance nation count to match human count
|
||||
if (isHumansVsNations) {
|
||||
return createRandomNations(numHumans, manifestNations, toNation, random);
|
||||
return createRandomNations(
|
||||
numHumans,
|
||||
manifestNations,
|
||||
additionalNations,
|
||||
toNation,
|
||||
random,
|
||||
);
|
||||
}
|
||||
|
||||
// For compact maps, use only 25% of nations (minimum 1)
|
||||
@@ -77,11 +88,14 @@ export function createNationsForGame(
|
||||
|
||||
/**
|
||||
* Creates the requested number of nations from manifest data.
|
||||
* If more nations are needed than available in the manifest, generates additional ones with random names.
|
||||
* If more nations are needed than available in the manifest, fills the gap
|
||||
* first with random picks from `additionalNations`, then with procedurally
|
||||
* generated names if still short.
|
||||
*/
|
||||
function createRandomNations(
|
||||
targetCount: number,
|
||||
manifestNations: ManifestNation[],
|
||||
additionalNations: AdditionalNation[],
|
||||
toNation: (n: ManifestNation) => Nation,
|
||||
random: PseudoRandom,
|
||||
): Nation[] {
|
||||
@@ -89,11 +103,31 @@ function createRandomNations(
|
||||
if (targetCount <= manifestNations.length) {
|
||||
return shuffled.slice(0, targetCount).map(toNation);
|
||||
}
|
||||
// Need more nations than defined in manifest, create additional ones
|
||||
const nations: Nation[] = shuffled.map(toNation);
|
||||
const usedNames = new Set(nations.map((n) => n.playerInfo.name));
|
||||
const additionalCount = targetCount - manifestNations.length;
|
||||
for (let i = 0; i < additionalCount; i++) {
|
||||
let remaining = targetCount - manifestNations.length;
|
||||
|
||||
if (remaining > 0 && additionalNations.length > 0) {
|
||||
const candidates = additionalNations.filter((n) => !usedNames.has(n.name));
|
||||
const shuffledExtras = random.shuffleArray(candidates);
|
||||
const picked = shuffledExtras.slice(0, remaining);
|
||||
for (const extra of picked) {
|
||||
const spawnCell =
|
||||
extra.coordinates !== undefined
|
||||
? new Cell(extra.coordinates[0], extra.coordinates[1])
|
||||
: undefined;
|
||||
nations.push(
|
||||
new Nation(
|
||||
spawnCell,
|
||||
new PlayerInfo(extra.name, PlayerType.Nation, null, random.nextID()),
|
||||
),
|
||||
);
|
||||
usedNames.add(extra.name);
|
||||
}
|
||||
remaining -= picked.length;
|
||||
}
|
||||
|
||||
for (let i = 0; i < remaining; i++) {
|
||||
const name = generateUniqueNationName(random, usedNames);
|
||||
usedNames.add(name);
|
||||
nations.push(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { GameMapLoader } from "./GameMapLoader";
|
||||
|
||||
export type TerrainMapData = {
|
||||
nations: Nation[];
|
||||
additionalNations: AdditionalNation[];
|
||||
gameMap: GameMap;
|
||||
miniGameMap: GameMap;
|
||||
teamGameSpawnAreas?: TeamGameSpawnAreas;
|
||||
@@ -23,12 +24,22 @@ export interface MapManifest {
|
||||
map4x: MapMetadata;
|
||||
map16x: MapMetadata;
|
||||
nations: Nation[];
|
||||
// Optional pool of fallback nation names used when a game requests more
|
||||
// nations than the manifest defines. Picked at random; if still not enough,
|
||||
// the remainder is generated procedurally.
|
||||
additionalNations?: AdditionalNation[];
|
||||
teamGameSpawnAreas?: TeamGameSpawnAreas;
|
||||
}
|
||||
|
||||
export interface Nation {
|
||||
coordinates: [number, number];
|
||||
flag: string;
|
||||
flag?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AdditionalNation {
|
||||
coordinates?: [number, number];
|
||||
flag?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@@ -63,6 +74,14 @@ export async function loadTerrainMap(
|
||||
Math.floor(nation.coordinates[1] / 2),
|
||||
];
|
||||
});
|
||||
manifest.additionalNations?.forEach((nation) => {
|
||||
if (nation.coordinates !== undefined) {
|
||||
nation.coordinates = [
|
||||
Math.floor(nation.coordinates[0] / 2),
|
||||
Math.floor(nation.coordinates[1] / 2),
|
||||
];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Scale spawn areas for compact maps
|
||||
@@ -82,6 +101,7 @@ export async function loadTerrainMap(
|
||||
|
||||
const result = {
|
||||
nations: manifest.nations,
|
||||
additionalNations: manifest.additionalNations ?? [],
|
||||
gameMap: gameMap,
|
||||
miniGameMap: miniMap,
|
||||
teamGameSpawnAreas,
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import {
|
||||
Difficulty,
|
||||
GameMapSize,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
GameType,
|
||||
Nation,
|
||||
} from "../src/core/game/Game";
|
||||
import { createNationsForGame } from "../src/core/game/NationCreation";
|
||||
import {
|
||||
AdditionalNation,
|
||||
Nation as ManifestNation,
|
||||
} from "../src/core/game/TerrainMapLoader";
|
||||
import { PseudoRandom } from "../src/core/PseudoRandom";
|
||||
import { GameConfig, GameStartInfo } from "../src/core/Schemas";
|
||||
|
||||
function makeManifestNations(count: number): ManifestNation[] {
|
||||
const result: ManifestNation[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
result.push({
|
||||
coordinates: [i, i],
|
||||
flag: "",
|
||||
name: `Manifest${i}`,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function makeAdditionalNations(names: string[]): AdditionalNation[] {
|
||||
return names.map((name) => ({ name }));
|
||||
}
|
||||
|
||||
function makeGameStart(targetNations: number): GameStartInfo {
|
||||
const config: GameConfig = {
|
||||
gameMap: GameMapType.World,
|
||||
gameMapSize: GameMapSize.Normal,
|
||||
gameMode: GameMode.FFA,
|
||||
gameType: GameType.Singleplayer,
|
||||
difficulty: Difficulty.Medium,
|
||||
nations: targetNations,
|
||||
donateGold: false,
|
||||
donateTroops: false,
|
||||
bots: 0,
|
||||
infiniteGold: false,
|
||||
infiniteTroops: false,
|
||||
instantBuild: false,
|
||||
randomSpawn: false,
|
||||
};
|
||||
return {
|
||||
gameID: "test1234",
|
||||
lobbyCreatedAt: 0,
|
||||
config,
|
||||
players: [],
|
||||
};
|
||||
}
|
||||
|
||||
function nationNames(nations: Nation[]): string[] {
|
||||
return nations.map((n) => n.playerInfo.name);
|
||||
}
|
||||
|
||||
describe("createNationsForGame: additionalNations pool", () => {
|
||||
test("does not draw from the pool when manifest already covers the count", () => {
|
||||
const manifest = makeManifestNations(4);
|
||||
const extras = makeAdditionalNations(["ExtraA", "ExtraB", "ExtraC"]);
|
||||
const random = new PseudoRandom(1);
|
||||
|
||||
const nations = createNationsForGame(
|
||||
makeGameStart(3),
|
||||
manifest,
|
||||
extras,
|
||||
0,
|
||||
random,
|
||||
);
|
||||
|
||||
expect(nations).toHaveLength(3);
|
||||
for (const name of nationNames(nations)) {
|
||||
expect(name.startsWith("Manifest")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("fills the deficit entirely from the pool when it is large enough", () => {
|
||||
const manifest = makeManifestNations(2);
|
||||
const extras = makeAdditionalNations([
|
||||
"ExtraA",
|
||||
"ExtraB",
|
||||
"ExtraC",
|
||||
"ExtraD",
|
||||
"ExtraE",
|
||||
]);
|
||||
const random = new PseudoRandom(7);
|
||||
|
||||
const nations = createNationsForGame(
|
||||
makeGameStart(5),
|
||||
manifest,
|
||||
extras,
|
||||
0,
|
||||
random,
|
||||
);
|
||||
|
||||
expect(nations).toHaveLength(5);
|
||||
const names = nationNames(nations);
|
||||
|
||||
expect(names.filter((n) => n.startsWith("Manifest"))).toHaveLength(2);
|
||||
|
||||
const fromPool = names.filter((n) => n.startsWith("Extra"));
|
||||
expect(fromPool).toHaveLength(3);
|
||||
for (const name of fromPool) {
|
||||
expect(extras.some((e) => e.name === name)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("randomly selects from the pool when it has more entries than needed", () => {
|
||||
const manifest = makeManifestNations(2);
|
||||
const pool = [
|
||||
"ExtraA",
|
||||
"ExtraB",
|
||||
"ExtraC",
|
||||
"ExtraD",
|
||||
"ExtraE",
|
||||
"ExtraF",
|
||||
"ExtraG",
|
||||
"ExtraH",
|
||||
];
|
||||
const extras = makeAdditionalNations(pool);
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (let seed = 1; seed <= 25; seed++) {
|
||||
const random = new PseudoRandom(seed);
|
||||
const nations = createNationsForGame(
|
||||
makeGameStart(4),
|
||||
manifest,
|
||||
extras,
|
||||
0,
|
||||
random,
|
||||
);
|
||||
expect(nations).toHaveLength(4);
|
||||
|
||||
const fromPool = nationNames(nations).filter((n) =>
|
||||
n.startsWith("Extra"),
|
||||
);
|
||||
expect(fromPool).toHaveLength(2);
|
||||
for (const name of fromPool) {
|
||||
expect(pool).toContain(name);
|
||||
}
|
||||
fromPool.forEach((n) => seen.add(n));
|
||||
}
|
||||
expect(seen.size).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
test("falls back to generated names when the pool is too small", () => {
|
||||
const manifest = makeManifestNations(1);
|
||||
const extras = makeAdditionalNations(["ExtraA", "ExtraB"]);
|
||||
const random = new PseudoRandom(42);
|
||||
|
||||
const nations = createNationsForGame(
|
||||
makeGameStart(5),
|
||||
manifest,
|
||||
extras,
|
||||
0,
|
||||
random,
|
||||
);
|
||||
|
||||
expect(nations).toHaveLength(5);
|
||||
const names = nationNames(nations);
|
||||
|
||||
expect(names.filter((n) => n.startsWith("Manifest"))).toHaveLength(1);
|
||||
|
||||
const fromPool = names.filter((n) => extras.some((e) => e.name === n));
|
||||
expect(fromPool).toHaveLength(2);
|
||||
|
||||
const generated = names.filter(
|
||||
(n) => !n.startsWith("Manifest") && !extras.some((e) => e.name === n),
|
||||
);
|
||||
expect(generated).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("falls back to generated names when the pool is empty", () => {
|
||||
const manifest = makeManifestNations(2);
|
||||
const random = new PseudoRandom(11);
|
||||
|
||||
const nations = createNationsForGame(
|
||||
makeGameStart(4),
|
||||
manifest,
|
||||
[],
|
||||
0,
|
||||
random,
|
||||
);
|
||||
|
||||
expect(nations).toHaveLength(4);
|
||||
expect(
|
||||
nationNames(nations).filter((n) => n.startsWith("Manifest")),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("skips pool entries whose name collides with a manifest nation", () => {
|
||||
const manifest = makeManifestNations(2);
|
||||
const extras = makeAdditionalNations([
|
||||
"Manifest0",
|
||||
"Manifest1",
|
||||
"UniqueExtra",
|
||||
]);
|
||||
const random = new PseudoRandom(3);
|
||||
|
||||
const nations = createNationsForGame(
|
||||
makeGameStart(3),
|
||||
manifest,
|
||||
extras,
|
||||
0,
|
||||
random,
|
||||
);
|
||||
|
||||
const names = nationNames(nations);
|
||||
expect(names).toHaveLength(3);
|
||||
expect(new Set(names).size).toBe(3);
|
||||
expect(names).toContain("UniqueExtra");
|
||||
});
|
||||
|
||||
test("uses coordinates from additional nations when provided", () => {
|
||||
const manifest = makeManifestNations(1);
|
||||
const extras: AdditionalNation[] = [
|
||||
{ name: "WithCoords", coordinates: [42, 99] },
|
||||
{ name: "WithoutCoords" },
|
||||
];
|
||||
const random = new PseudoRandom(5);
|
||||
|
||||
const nations = createNationsForGame(
|
||||
makeGameStart(3),
|
||||
manifest,
|
||||
extras,
|
||||
0,
|
||||
random,
|
||||
);
|
||||
|
||||
const withCoords = nations.find((n) => n.playerInfo.name === "WithCoords");
|
||||
const withoutCoords = nations.find(
|
||||
(n) => n.playerInfo.name === "WithoutCoords",
|
||||
);
|
||||
|
||||
expect(withCoords).toBeDefined();
|
||||
expect(withoutCoords).toBeDefined();
|
||||
expect(withCoords!.spawnCell?.x).toBe(42);
|
||||
expect(withCoords!.spawnCell?.y).toBe(99);
|
||||
expect(withoutCoords!.spawnCell).toBeUndefined();
|
||||
});
|
||||
|
||||
test("produces unique nation names overall", () => {
|
||||
const manifest = makeManifestNations(3);
|
||||
const extras = makeAdditionalNations(["Ex1", "Ex2", "Ex3"]);
|
||||
const random = new PseudoRandom(99);
|
||||
|
||||
const nations = createNationsForGame(
|
||||
makeGameStart(8),
|
||||
manifest,
|
||||
extras,
|
||||
0,
|
||||
random,
|
||||
);
|
||||
|
||||
const names = nationNames(nations);
|
||||
expect(names).toHaveLength(8);
|
||||
expect(new Set(names).size).toBe(8);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user