From 775ae77e0a2e4c2442070da89d227be82f684252 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:25:29 +0200 Subject: [PATCH] =?UTF-8?q?Fix=20nations=20not=20spawning=20when=20random?= =?UTF-8?q?=20spawn=20is=20enabled=20=F0=9F=A4=96=20(#4117)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: When random spawn is active, human SpawnExecutions are pre-created in GameRunner.init() and fire on the same tick as NationExecution. Because humans were added first, their SpawnExecution ticked first, called endSpawnPhase() (in singleplayer), and NationExecution then saw inSpawnPhase()=false, found the nation not alive, and deactivated it before ever queuing a SpawnExecution. Two changes fix this: 1. GameRunner.init(): Move nationExecutions() before spawnPlayers() so NationExecution ticks first and queues its SpawnExecution before the human SpawnExecution can end the spawn phase. 2. NationExecution.tick(): After the spawn-phase block, add a guard that waits when spawnExecAdded is true but the nation hasn't actually spawned yet. This prevents NationExecution from deactivating on the very next tick (via !isAlive()) before its queued SpawnExecution has had a chance to fire and give the nation territory. I tested it in singleplaye with and without random spawn and also in public lobbies. Nations now always spawn. ## 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 --- src/core/GameRunner.ts | 6 +- src/core/execution/NationExecution.ts | 5 ++ tests/core/GameRunner.test.ts | 105 ++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 tests/core/GameRunner.test.ts diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 702abf110..914152163 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -100,6 +100,9 @@ export class GameRunner { if (this.game.config().gameConfig().gameType !== GameType.Singleplayer) { this.game.addExecution(new SpawnTimerExecution()); } + if (this.game.config().spawnNations()) { + this.game.addExecution(...this.execManager.nationExecutions()); + } if (this.game.config().isRandomSpawn()) { this.game.addExecution(...this.execManager.spawnPlayers()); } @@ -108,9 +111,6 @@ export class GameRunner { ...this.execManager.spawnTribes(this.game.config().bots()), ); } - if (this.game.config().spawnNations()) { - this.game.addExecution(...this.execManager.nationExecutions()); - } this.game.addExecution(new WinCheckExecution()); if (!this.game.config().isUnitDisabled(UnitType.Factory)) { this.game.addExecution( diff --git a/src/core/execution/NationExecution.ts b/src/core/execution/NationExecution.ts index 6a2a53a7d..6cc60cdb3 100644 --- a/src/core/execution/NationExecution.ts +++ b/src/core/execution/NationExecution.ts @@ -161,6 +161,11 @@ export class NationExecution implements Execution { return; } + // Spawn phase already ended but our SpawnExecution hasn't fired yet — wait. + if (this.spawnExecAdded && !this.player.hasSpawned()) { + return; + } + if (!this.player.isAlive()) { //removeOnDeath is called from nation's PlayerExecution this.active = false; diff --git a/tests/core/GameRunner.test.ts b/tests/core/GameRunner.test.ts new file mode 100644 index 000000000..f02b07823 --- /dev/null +++ b/tests/core/GameRunner.test.ts @@ -0,0 +1,105 @@ +import { NationExecution } from "../../src/core/execution/NationExecution"; +import { SpawnExecution } from "../../src/core/execution/SpawnExecution"; +import { Cell, Nation, PlayerInfo, PlayerType } from "../../src/core/game/Game"; +import { GameConfig, GameID } from "../../src/core/Schemas"; +import { setup } from "../util/Setup"; +import { executeTicks } from "../util/utils"; + +const gameID: GameID = "test_game_id"; + +async function createTestGame( + randomSpawn: boolean, + nationCells: { x: number; y: number }[], +) { + const game = await setup( + "plains", + { randomSpawn } as Partial, + [], + undefined, + undefined, + false, + ); + + const humanInfo = new PlayerInfo( + "human", + PlayerType.Human, + "client_1", + "human_id", + ); + game.addPlayer(humanInfo); + + const nations: { info: PlayerInfo; nation: Nation }[] = []; + for (let i = 0; i < nationCells.length; i++) { + const info = new PlayerInfo( + nationCells.length === 1 ? "TestNation" : `Nation${i}`, + PlayerType.Nation, + null, + nationCells.length === 1 ? "nation_id" : `nation_${i}`, + ); + const nation = new Nation( + new Cell(nationCells[i].x, nationCells[i].y), + info, + ); + game.addPlayer(info); + nations.push({ info, nation }); + } + + return { game, humanInfo, nations }; +} + +describe("Nation spawn ordering with random spawn", () => { + test("nation spawns in singleplayer with random spawn", async () => { + const { game, humanInfo, nations } = await createTestGame(true, [ + { x: 50, y: 50 }, + ]); + + // Mirror GameRunner.init() ordering: nation first, then human. + game.addExecution(new NationExecution(gameID, nations[0].nation)); + game.addExecution( + new SpawnExecution(gameID, game.player(humanInfo.id).info()), + ); + + executeTicks(game, 4); + + expect(game.player(humanInfo.id).hasSpawned()).toBe(true); + expect(game.player(nations[0].info.id).hasSpawned()).toBe(true); + expect(game.player(nations[0].info.id).isAlive()).toBe(true); + }); + + test("multiple nations spawn in singleplayer with random spawn", async () => { + const cells = Array.from({ length: 5 }, (_, i) => ({ + x: 20 + i * 15, + y: 20 + i * 15, + })); + const { game, humanInfo, nations } = await createTestGame(true, cells); + + // Nation executions first (mirrors GameRunner.init()). + for (const { nation } of nations) { + game.addExecution(new NationExecution(gameID, nation)); + } + // Human spawn execution second. + game.addExecution( + new SpawnExecution(gameID, game.player(humanInfo.id).info()), + ); + + executeTicks(game, 8); + + expect(game.player(humanInfo.id).hasSpawned()).toBe(true); + for (const { info } of nations) { + const player = game.player(info.id); + expect(player.hasSpawned()).toBe(true); + expect(player.isAlive()).toBe(true); + } + }); + + test("nation spawns in singleplayer without random spawn", async () => { + const { game, nations } = await createTestGame(false, [{ x: 50, y: 50 }]); + + game.addExecution(new NationExecution(gameID, nations[0].nation)); + + executeTicks(game, 4); + + expect(game.player(nations[0].info.id).hasSpawned()).toBe(true); + expect(game.player(nations[0].info.id).isAlive()).toBe(true); + }); +});