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:
FloPinguin
2026-05-11 21:58:34 +02:00
committed by GitHub
parent f1d162825e
commit 19280c0b37
5 changed files with 332 additions and 7 deletions
+1
View File
@@ -59,6 +59,7 @@ export async function createGameRunner(
const nations = createNationsForGame(
gameStart,
gameMap.nations,
gameMap.additionalNations,
humans.length,
random,
);
+7
View File
@@ -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 {
+40 -6
View File
@@ -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(
+21 -1
View File
@@ -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,