diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 01d0f6c79..474dcc3c5 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -59,6 +59,7 @@ export async function createGameRunner( const nations = createNationsForGame( gameStart, gameMap.nations, + gameMap.additionalNations, humans.length, random, ); diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index bd1e97a21..44bc83a85 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -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 { diff --git a/src/core/game/NationCreation.ts b/src/core/game/NationCreation.ts index 259ef85c8..527b98161 100644 --- a/src/core/game/NationCreation.ts +++ b/src/core/game/NationCreation.ts @@ -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( diff --git a/src/core/game/TerrainMapLoader.ts b/src/core/game/TerrainMapLoader.ts index 8899b94f8..5b9423a55 100644 --- a/src/core/game/TerrainMapLoader.ts +++ b/src/core/game/TerrainMapLoader.ts @@ -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, diff --git a/tests/NationCreation.test.ts b/tests/NationCreation.test.ts new file mode 100644 index 000000000..ebac8eb2d --- /dev/null +++ b/tests/NationCreation.test.ts @@ -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(); + 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); + }); +});