diff --git a/src/client/graphics/layers/GameRightSidebar.ts b/src/client/graphics/layers/GameRightSidebar.ts index 2d2f788914..10277dc5af 100644 --- a/src/client/graphics/layers/GameRightSidebar.ts +++ b/src/client/graphics/layers/GameRightSidebar.ts @@ -39,6 +39,7 @@ export class GameRightSidebar extends LitElement implements Layer { private hasWinner = false; private isLobbyCreator = false; + private singleplayerStartTick: number | null = null; private spawnBarVisible = false; private immunityBarVisible = false; @@ -85,14 +86,24 @@ export class GameRightSidebar extends LitElement implements Layer { const maxTimerValue = this.game.config().gameConfig().maxTimerValue; const spawnPhaseTurns = this.game.config().numSpawnPhaseTurns(); const ticks = this.game.ticks(); - const gameTicks = Math.max(0, ticks - spawnPhaseTurns); - const elapsedSeconds = Math.floor(gameTicks / 10); // 10 ticks per second + const isSingleplayer = + this.game.config().gameConfig().gameType === GameType.Singleplayer; if (this.game.inSpawnPhase()) { + this.singleplayerStartTick = null; this.timer = maxTimerValue !== undefined ? maxTimerValue * 60 : 0; return; } + if (isSingleplayer && this.singleplayerStartTick === null) { + this.singleplayerStartTick = ticks; + } + + const gameStartTick = isSingleplayer + ? (this.singleplayerStartTick ?? ticks) + : spawnPhaseTurns; + const elapsedSeconds = Math.floor(Math.max(0, ticks - gameStartTick) / 10); // 10 ticks per second + if (this.hasWinner) { return; } diff --git a/src/client/graphics/layers/SpawnTimer.ts b/src/client/graphics/layers/SpawnTimer.ts index 7f44eaa87d..be923db38c 100644 --- a/src/client/graphics/layers/SpawnTimer.ts +++ b/src/client/graphics/layers/SpawnTimer.ts @@ -1,7 +1,7 @@ import { LitElement, html } from "lit"; import { customElement } from "lit/decorators.js"; import { EventBus, GameEvent } from "../../../core/EventBus"; -import { GameMode, Team } from "../../../core/game/Game"; +import { GameMode, GameType, Team } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; @@ -38,6 +38,17 @@ export class SpawnTimer extends LitElement implements Layer { } tick() { + if ( + this.game.config().gameConfig().gameType === GameType.Singleplayer && + this.game.inSpawnPhase() + ) { + // Singleplayer has no spawn countdown. + this.ratios = []; + this.colors = []; + this.requestUpdate(); + return; + } + if (this.game.inSpawnPhase()) { // During spawn phase, only one segment filling full width this.ratios = [ diff --git a/src/core/execution/NationExecution.ts b/src/core/execution/NationExecution.ts index 858b009749..3ea9555fa9 100644 --- a/src/core/execution/NationExecution.ts +++ b/src/core/execution/NationExecution.ts @@ -2,9 +2,11 @@ import { Difficulty, Execution, Game, + GameType, Nation, Player, PlayerID, + PlayerType, Relation, TerrainType, } from "../game/Game"; @@ -42,6 +44,7 @@ export class NationExecution implements Execution { private expandRatio: number; private readonly embargoMalusApplied = new Set(); + private spawnQueuedAtTick: number | null = null; constructor( private gameID: GameID, @@ -84,6 +87,30 @@ export class NationExecution implements Execution { } tick(ticks: number) { + if (this.player !== null && this.player.hasSpawned()) { + this.spawnQueuedAtTick = null; + } else if ( + this.spawnQueuedAtTick !== null && + ticks > this.spawnQueuedAtTick + 1 + ) { + // Give queued SpawnExecution one full tick to run, then allow retries. + this.spawnQueuedAtTick = null; + } + + if (this.player === null) { + return; + } + + if (this.shouldSpawnNationNow()) { + if (this.spawnQueuedAtTick !== null) { + return; + } + if (this.queueSpawnExecution()) { + this.spawnQueuedAtTick = ticks; + } + return; + } + // Ship tracking if ( this.behaviorsInitialized && @@ -94,33 +121,40 @@ export class NationExecution implements Execution { this.warshipBehavior.trackShipsAndRetaliate(); } - if (this.player === null) { + if (ticks % this.attackRate !== this.attackTick) { + // Call handleStructures twice between regular attack ticks (at 1/3 and 2/3 of the interval) + // Otherwise it is possible that we earn more gold than we can spend + // The alternative is placing multiple structures in handleStructures, but that causes problems + if ( + this.behaviorsInitialized && + this.player !== null && + this.player.isAlive() + ) { + const offset = ticks % this.attackRate; + const oneThird = + (this.attackTick + Math.floor(this.attackRate / 3)) % this.attackRate; + const twoThirds = + (this.attackTick + Math.floor((this.attackRate * 2) / 3)) % + this.attackRate; + if (offset === oneThird || offset === twoThirds) { + this.structureBehavior.handleStructures(); + } + } return; } if (this.mg.inSpawnPhase()) { - if (ticks % this.attackRate !== this.attackTick) { - return; - } - // Place nations without a spawn cell (Dynamically created for HumansVsNations) randomly by SpawnExecution - if (this.nation.spawnCell === undefined) { - this.mg.addExecution( - new SpawnExecution(this.gameID, this.nation.playerInfo), - ); + // In singleplayer nations should spawn only after the human spawns. + if (this.mg.config().gameConfig().gameType === GameType.Singleplayer) { return; } - // Select a tile near the position defined in the map manifest - const rl = this.randomSpawnLand(); - - if (rl === null) { - console.warn(`cannot spawn ${this.nation.playerInfo.name}`); + if (this.spawnQueuedAtTick !== null) { return; } - - this.mg.addExecution( - new SpawnExecution(this.gameID, this.nation.playerInfo, rl), - ); + if (this.queueSpawnExecution()) { + this.spawnQueuedAtTick = ticks; + } return; } @@ -218,6 +252,49 @@ export class NationExecution implements Execution { this.behaviorsInitialized = true; } + private shouldSpawnNationNow(): boolean { + if (this.player === null || this.player.hasSpawned()) { + return false; + } + + const isSingleplayer = + this.mg.config().gameConfig().gameType === GameType.Singleplayer; + if (!isSingleplayer) { + return false; + } + + return this.allHumansSpawned(); + } + + private allHumansSpawned(): boolean { + return this.mg + .allPlayers() + .filter((p) => p.type() === PlayerType.Human) + .every((p) => p.hasSpawned()); + } + + private queueSpawnExecution(): boolean { + // Place nations without a spawn cell (dynamically created for HumansVsNations) randomly by SpawnExecution. + if (this.nation.spawnCell === undefined) { + this.mg.addExecution( + new SpawnExecution(this.gameID, this.nation.playerInfo), + ); + return true; + } + + // Select a tile near the position defined in the map manifest. + const rl = this.randomSpawnLand(); + if (rl === null) { + console.warn(`cannot spawn ${this.nation.playerInfo.name}`); + return false; + } + + this.mg.addExecution( + new SpawnExecution(this.gameID, this.nation.playerInfo, rl), + ); + return true; + } + private randomSpawnLand(): TileRef | null { if (this.nation.spawnCell === undefined) throw new Error("not initialized"); diff --git a/src/core/execution/SpawnExecution.ts b/src/core/execution/SpawnExecution.ts index 4162e85fc8..cc24258b11 100644 --- a/src/core/execution/SpawnExecution.ts +++ b/src/core/execution/SpawnExecution.ts @@ -1,4 +1,11 @@ -import { Execution, Game, Player, PlayerInfo, PlayerType } from "../game/Game"; +import { + Execution, + Game, + GameType, + Player, + PlayerInfo, + PlayerType, +} from "../game/Game"; import { TileRef } from "../game/GameMap"; import { PseudoRandom } from "../PseudoRandom"; import { GameID } from "../Schemas"; @@ -30,11 +37,6 @@ export class SpawnExecution implements Execution { tick(ticks: number) { this.active = false; - if (!this.mg.inSpawnPhase()) { - this.active = false; - return; - } - let player: Player | null = null; if (this.mg.hasPlayer(this.playerInfo.id)) { player = this.mg.player(this.playerInfo.id); @@ -42,6 +44,23 @@ export class SpawnExecution implements Execution { player = this.mg.addPlayer(this.playerInfo); } + const isSingleplayer = + this.mg.config().gameConfig().gameType === GameType.Singleplayer; + const isSingleplayerNationSpawn = + this.playerInfo.playerType === PlayerType.Nation && isSingleplayer; + const isSingleplayerInitialHumanSpawn = + isSingleplayer && + this.playerInfo.playerType === PlayerType.Human && + !player.hasSpawned(); + + if ( + !this.mg.inSpawnPhase() && + !isSingleplayerNationSpawn && + !isSingleplayerInitialHumanSpawn + ) { + return; + } + // Security: If random spawn is enabled, prevent players from re-rolling their spawn location if (this.mg.config().isRandomSpawn() && player.hasSpawned()) { return; @@ -67,6 +86,10 @@ export class SpawnExecution implements Execution { } player.setSpawnTile(this.tile); + + if (isSingleplayerInitialHumanSpawn) { + this.mg.endSpawnPhase(); + } } isActive(): boolean { diff --git a/src/core/execution/WinCheckExecution.ts b/src/core/execution/WinCheckExecution.ts index 700d14fada..3be0d47ffc 100644 --- a/src/core/execution/WinCheckExecution.ts +++ b/src/core/execution/WinCheckExecution.ts @@ -60,8 +60,7 @@ export class WinCheckExecution implements Execution { } const max = sorted[0]; - const timeElapsed = - (this.mg.ticks() - this.mg.config().numSpawnPhaseTurns()) / 10; + const timeElapsed = this.mg.elapsedGameSeconds(); const numTilesWithoutFallout = this.mg.numLandTiles() - this.mg.numTilesWithFallout(); if ( @@ -95,8 +94,7 @@ export class WinCheckExecution implements Execution { return; } const max = sorted[0]; - const timeElapsed = - (this.mg.ticks() - this.mg.config().numSpawnPhaseTurns()) / 10; + const timeElapsed = this.mg.elapsedGameSeconds(); const numTilesWithoutFallout = this.mg.numLandTiles() - this.mg.numTilesWithFallout(); const percentage = (max[1] / numTilesWithoutFallout) * 100; diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 4fcf1aa841..a38ab64b9b 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -760,10 +760,12 @@ export interface Game extends GameMap { // Immunity timer isSpawnImmunityActive(): boolean; isNationSpawnImmunityActive(): boolean; + elapsedGameSeconds(): number; // Game State ticks(): Tick; inSpawnPhase(): boolean; + endSpawnPhase(): void; executeNextTick(): GameUpdates; setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void; getWinner(): Player | Team | null; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 0f807e3d31..ce4c17c2e5 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -21,6 +21,7 @@ import { Execution, Game, GameMode, + GameType, GameUpdates, HumansVsNations, MessageType, @@ -65,6 +66,9 @@ export type CellString = string; export class GameImpl implements Game { private _ticks = 0; + private singleplayerStartTick: number | null = null; + private singleplayerSpawnPhaseCompleted = false; + private spawnPhaseLockedValue: boolean | null = null; private unInitExecs: Execution[] = []; @@ -369,15 +373,63 @@ export class GameImpl implements Game { this.addUpdate({ type: GameUpdateType.GamePaused, paused }); } - inSpawnPhase(): boolean { + private computeInSpawnPhase(): boolean { + if (this.config().gameConfig().gameType === GameType.Singleplayer) { + if (this.singleplayerSpawnPhaseCompleted) { + return false; + } + // Singleplayer spawn phase stays active until the first human spawn completes. + // When no humans exist yet, fall back to tick-based spawn phase for compatibility. + const hasHumans = this.allPlayers().some( + (player) => player.type() === PlayerType.Human, + ); + if (hasHumans) { + return true; + } + } return this._ticks <= this.config().numSpawnPhaseTurns(); } + inSpawnPhase(): boolean { + if (this.spawnPhaseLockedValue !== null) { + return this.spawnPhaseLockedValue; + } + return this.computeInSpawnPhase(); + } + + endSpawnPhase(): void { + if (this.config().gameConfig().gameType !== GameType.Singleplayer) { + return; + } + this.singleplayerSpawnPhaseCompleted = true; + } + + private updateSingleplayerStartTick(): void { + if (this.config().gameConfig().gameType !== GameType.Singleplayer) { + return; + } + + const humanPlayers = this.allPlayers().filter( + (player) => player.type() === PlayerType.Human, + ); + const hasSpawnedHuman = humanPlayers.some((player) => player.hasSpawned()); + + if (this.inSpawnPhase() || !hasSpawnedHuman) { + this.singleplayerStartTick = null; + return; + } + + this.singleplayerStartTick ??= this.ticks(); + } + ticks(): number { return this._ticks; } executeNextTick(): GameUpdates { + this.spawnPhaseLockedValue = this.computeInSpawnPhase(); + this.updateSingleplayerStartTick(); + this.updates = createGameUpdatesMap(); this.execs.forEach((e) => { if ( @@ -413,6 +465,10 @@ export class GameImpl implements Game { hash: this.hash(), }); } + + this.spawnPhaseLockedValue = null; + this.updateSingleplayerStartTick(); + this._ticks++; return this.updates; } @@ -720,6 +776,15 @@ export class GameImpl implements Game { } public isSpawnImmunityActive(): boolean { + if (this.config().gameConfig().gameType === GameType.Singleplayer) { + this.updateSingleplayerStartTick(); + if (this.inSpawnPhase()) { + return true; + } + const startTick = this.singleplayerStartTick ?? this.ticks(); + return startTick + this.config().spawnImmunityDuration() > this.ticks(); + } + return ( this.config().numSpawnPhaseTurns() + this.config().spawnImmunityDuration() > @@ -727,7 +792,31 @@ export class GameImpl implements Game { ); } + public elapsedGameSeconds(): number { + if (this.config().gameConfig().gameType === GameType.Singleplayer) { + this.updateSingleplayerStartTick(); + if (this.inSpawnPhase()) { + return 0; + } + const startTick = this.singleplayerStartTick ?? this.ticks(); + return Math.max(0, this.ticks() - startTick) / 10; + } + + return Math.max(0, this.ticks() - this.config().numSpawnPhaseTurns()) / 10; + } + public isNationSpawnImmunityActive(): boolean { + if (this.config().gameConfig().gameType === GameType.Singleplayer) { + this.updateSingleplayerStartTick(); + if (this.inSpawnPhase()) { + return true; + } + const startTick = this.singleplayerStartTick ?? this.ticks(); + return ( + startTick + this.config().nationSpawnImmunityDuration() > this.ticks() + ); + } + return ( this.config().numSpawnPhaseTurns() + this.config().nationSpawnImmunityDuration() > diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 974874ad12..523a900035 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -9,6 +9,7 @@ import { WorkerClient } from "../worker/WorkerClient"; import { Cell, EmojiMessage, + GameType, GameUpdates, Gold, NameViewData, @@ -584,6 +585,7 @@ export class PlayerView { export class GameView implements GameMap { private lastUpdate: GameUpdateViewData | null; + private singleplayerStartTick: Tick | null = null; private smallIDToID = new Map(); private _players = new Map(); private _units = new Map(); @@ -816,15 +818,58 @@ export class GameView implements GameMap { return this.lastUpdate.tick; } inSpawnPhase(): boolean { + if (this._config.gameConfig().gameType === GameType.Singleplayer) { + // Singleplayer has no fixed spawn countdown; game starts once the human spawns. + return !this.myPlayer()?.hasSpawned(); + } return this.ticks() <= this._config.numSpawnPhaseTurns(); } + + private updateSingleplayerStartTick(): void { + if (this._config.gameConfig().gameType !== GameType.Singleplayer) { + return; + } + + const humanPlayers = this.players().filter( + (player) => player.type() === PlayerType.Human, + ); + const hasSpawnedHuman = humanPlayers.some((player) => player.hasSpawned()); + + if (this.inSpawnPhase() || !hasSpawnedHuman) { + this.singleplayerStartTick = null; + return; + } + + this.singleplayerStartTick ??= this.ticks(); + } + isSpawnImmunityActive(): boolean { + if (this._config.gameConfig().gameType === GameType.Singleplayer) { + this.updateSingleplayerStartTick(); + if (this.inSpawnPhase()) { + return true; + } + const startTick = this.singleplayerStartTick ?? this.ticks(); + return startTick + this._config.spawnImmunityDuration() > this.ticks(); + } + return ( this._config.numSpawnPhaseTurns() + this._config.spawnImmunityDuration() > this.ticks() ); } isNationSpawnImmunityActive(): boolean { + if (this._config.gameConfig().gameType === GameType.Singleplayer) { + this.updateSingleplayerStartTick(); + if (this.inSpawnPhase()) { + return true; + } + const startTick = this.singleplayerStartTick ?? this.ticks(); + return ( + startTick + this._config.nationSpawnImmunityDuration() > this.ticks() + ); + } + return ( this._config.numSpawnPhaseTurns() + this._config.nationSpawnImmunityDuration() > diff --git a/tests/AiAttackBehavior.test.ts b/tests/AiAttackBehavior.test.ts index 4c04983db6..c053518c32 100644 --- a/tests/AiAttackBehavior.test.ts +++ b/tests/AiAttackBehavior.test.ts @@ -1,5 +1,11 @@ import { AiAttackBehavior } from "../src/core/execution/utils/AiAttackBehavior"; -import { Game, Player, PlayerInfo, PlayerType } from "../src/core/game/Game"; +import { + Game, + GameType, + Player, + PlayerInfo, + PlayerType, +} from "../src/core/game/Game"; import { PseudoRandom } from "../src/core/PseudoRandom"; import { setup } from "./util/Setup"; @@ -12,6 +18,7 @@ describe("Ai Attack Behavior", () => { // Helper function for basic test setup async function setupTestEnvironment() { const testGame = await setup("big_plains", { + gameType: GameType.Public, infiniteGold: true, instantBuild: true, infiniteTroops: true, diff --git a/tests/AllianceAcceptNukes.test.ts b/tests/AllianceAcceptNukes.test.ts index c77b153ad2..d1a0a45004 100644 --- a/tests/AllianceAcceptNukes.test.ts +++ b/tests/AllianceAcceptNukes.test.ts @@ -3,6 +3,7 @@ import { GameUpdateType } from "src/core/game/GameUpdates"; import { NukeExecution } from "../src/core/execution/NukeExecution"; import { Game, + GameType, Player, PlayerInfo, PlayerType, @@ -21,6 +22,7 @@ describe("Alliance acceptance immediately destroys in-flight nukes", () => { game = await setup( "plains", { + gameType: GameType.Public, infiniteGold: true, instantBuild: true, infiniteTroops: true, diff --git a/tests/AllianceDonation.test.ts b/tests/AllianceDonation.test.ts index 39da2f77ac..0f0d5f2f22 100644 --- a/tests/AllianceDonation.test.ts +++ b/tests/AllianceDonation.test.ts @@ -1,6 +1,6 @@ import { AllianceRequestExecution } from "../src/core/execution/alliance/AllianceRequestExecution"; import { DonateGoldExecution } from "../src/core/execution/DonateGoldExecution"; -import { Game, Player, PlayerType } from "../src/core/game/Game"; +import { Game, GameType, Player, PlayerType } from "../src/core/game/Game"; import { playerInfo, setup } from "./util/Setup"; let game: Game; @@ -12,6 +12,7 @@ describe("Alliance Donation", () => { game = await setup( "plains", { + gameType: GameType.Public, infiniteGold: false, instantBuild: true, infiniteTroops: false, diff --git a/tests/AllianceExtensionExecution.test.ts b/tests/AllianceExtensionExecution.test.ts index 1bc1b699ce..1cddc89e15 100644 --- a/tests/AllianceExtensionExecution.test.ts +++ b/tests/AllianceExtensionExecution.test.ts @@ -1,6 +1,12 @@ import { AllianceExtensionExecution } from "../src/core/execution/alliance/AllianceExtensionExecution"; import { AllianceRequestExecution } from "../src/core/execution/alliance/AllianceRequestExecution"; -import { Game, MessageType, Player, PlayerType } from "../src/core/game/Game"; +import { + Game, + GameType, + MessageType, + Player, + PlayerType, +} from "../src/core/game/Game"; import { playerInfo, setup } from "./util/Setup"; let game: Game; @@ -13,6 +19,7 @@ describe("AllianceExtensionExecution", () => { game = await setup( "ocean_and_land", { + gameType: GameType.Public, infiniteGold: true, instantBuild: true, infiniteTroops: true, diff --git a/tests/AllianceRequestExecution.test.ts b/tests/AllianceRequestExecution.test.ts index 8ced166b7e..8d8cc73640 100644 --- a/tests/AllianceRequestExecution.test.ts +++ b/tests/AllianceRequestExecution.test.ts @@ -1,7 +1,13 @@ import { AllianceRejectExecution } from "../src/core/execution/alliance/AllianceRejectExecution"; import { AllianceRequestExecution } from "../src/core/execution/alliance/AllianceRequestExecution"; import { NukeExecution } from "../src/core/execution/NukeExecution"; -import { Game, Player, PlayerType, UnitType } from "../src/core/game/Game"; +import { + Game, + GameType, + Player, + PlayerType, + UnitType, +} from "../src/core/game/Game"; import { playerInfo, setup } from "./util/Setup"; import { constructionExecution } from "./util/utils"; @@ -14,6 +20,7 @@ describe("AllianceRequestExecution", () => { game = await setup( "plains", { + gameType: GameType.Public, infiniteGold: true, instantBuild: true, infiniteTroops: true, diff --git a/tests/NationCounterWarshipInfestation.test.ts b/tests/NationCounterWarshipInfestation.test.ts index ecb9858bbf..8c0d91b19e 100644 --- a/tests/NationCounterWarshipInfestation.test.ts +++ b/tests/NationCounterWarshipInfestation.test.ts @@ -3,6 +3,7 @@ import { Cell, Difficulty, GameMode, + GameType, Nation, PlayerInfo, PlayerType, @@ -18,6 +19,7 @@ import { setup } from "./util/Setup"; describe("Counter Warship Infestation", () => { test("rich nation sends counter-warship in FFA when enemy has too many warships", async () => { const game = await setup("half_land_half_ocean", { + gameType: GameType.Public, infiniteGold: true, instantBuild: true, difficulty: Difficulty.Hard, // Required for counter-warship logic @@ -168,6 +170,7 @@ describe("Counter Warship Infestation", () => { const game = await setup( "half_land_half_ocean", { + gameType: GameType.Public, infiniteGold: true, instantBuild: true, difficulty: Difficulty.Hard, // Required for counter-warship logic diff --git a/tests/NationMIRV.test.ts b/tests/NationMIRV.test.ts index abaeb60aa7..b37056046a 100644 --- a/tests/NationMIRV.test.ts +++ b/tests/NationMIRV.test.ts @@ -4,6 +4,7 @@ import { NationExecution } from "../src/core/execution/NationExecution"; import { Cell, GameMode, + GameType, Nation, PlayerInfo, PlayerType, @@ -15,6 +16,7 @@ import { executeTicks } from "./util/utils"; describe("Nation MIRV Retaliation", () => { test("nation retaliates with MIRV when attacked by MIRV", async () => { const game = await setup("big_plains", { + gameType: GameType.Public, infiniteGold: true, instantBuild: true, }); @@ -145,6 +147,7 @@ describe("Nation MIRV Retaliation", () => { test("nation launches MIRV to prevent victory when player approaches win condition", async () => { // Setup game const game = await setup("big_plains", { + gameType: GameType.Public, infiniteGold: true, instantBuild: true, }); @@ -313,6 +316,7 @@ describe("Nation MIRV Retaliation", () => { test("nation launches MIRV to stop steamrolling player with excessive cities", async () => { // Setup game const game = await setup("big_plains", { + gameType: GameType.Public, infiniteGold: true, instantBuild: true, }); @@ -473,6 +477,7 @@ describe("Nation MIRV Retaliation", () => { test("nation does not launch MIRV for steamroll when leader has <= 10 cities", async () => { // Setup game const game = await setup("big_plains", { + gameType: GameType.Public, infiniteGold: true, instantBuild: true, }); @@ -622,6 +627,7 @@ describe("Nation MIRV Retaliation", () => { const game = await setup( "big_plains", { + gameType: GameType.Public, infiniteGold: true, instantBuild: true, gameMode: GameMode.Team, diff --git a/tests/PlayerImpl.test.ts b/tests/PlayerImpl.test.ts index 71d022f228..aafd3f41d5 100644 --- a/tests/PlayerImpl.test.ts +++ b/tests/PlayerImpl.test.ts @@ -1,5 +1,6 @@ import { Game, + GameType, Player, PlayerInfo, PlayerType, @@ -16,6 +17,7 @@ describe("PlayerImpl", () => { game = await setup( "plains", { + gameType: GameType.Public, instantBuild: true, }, [ diff --git a/tests/PortExecution.test.ts b/tests/PortExecution.test.ts index 287a0f022f..d99ad310d5 100644 --- a/tests/PortExecution.test.ts +++ b/tests/PortExecution.test.ts @@ -1,6 +1,7 @@ import { PortExecution } from "../src/core/execution/PortExecution"; import { Game, + GameType, Player, PlayerInfo, PlayerType, @@ -17,6 +18,7 @@ describe("PortExecution", () => { game = await setup( "half_land_half_ocean", { + gameType: GameType.Public, instantBuild: true, }, [ diff --git a/tests/ShellRandom.test.ts b/tests/ShellRandom.test.ts index 19ec5ed529..834d3b6c5a 100644 --- a/tests/ShellRandom.test.ts +++ b/tests/ShellRandom.test.ts @@ -3,6 +3,7 @@ import { ShellExecution } from "../src/core/execution/ShellExecution"; import { WarshipExecution } from "../src/core/execution/WarshipExecution"; import { Game, + GameType, Player, PlayerInfo, PlayerType, @@ -20,6 +21,7 @@ describe("Shell Random Damage", () => { game = await setup( "half_land_half_ocean", { + gameType: GameType.Public, infiniteGold: true, instantBuild: true, }, diff --git a/tests/Stats.test.ts b/tests/Stats.test.ts index 4cab4b2fe5..7db5b3a01b 100644 --- a/tests/Stats.test.ts +++ b/tests/Stats.test.ts @@ -1,5 +1,6 @@ import { Game, + GameType, Player, PlayerInfo, PlayerType, @@ -18,7 +19,7 @@ let player2: Player; describe("Stats", () => { beforeEach(async () => { stats = new StatsImpl(); - game = await setup("half_land_half_ocean", {}, [ + game = await setup("half_land_half_ocean", { gameType: GameType.Public }, [ new PlayerInfo("boat dude", PlayerType.Human, "client1", "player_1_id"), new PlayerInfo("boat dude", PlayerType.Human, "client2", "player_2_id"), ]); diff --git a/tests/Warship.test.ts b/tests/Warship.test.ts index ee6c556de4..c137a3ae9b 100644 --- a/tests/Warship.test.ts +++ b/tests/Warship.test.ts @@ -2,6 +2,7 @@ import { MoveWarshipExecution } from "../src/core/execution/MoveWarshipExecution import { WarshipExecution } from "../src/core/execution/WarshipExecution"; import { Game, + GameType, Player, PlayerInfo, PlayerType, @@ -20,6 +21,7 @@ describe("Warship", () => { game = await setup( "half_land_half_ocean", { + gameType: GameType.Public, infiniteGold: true, instantBuild: true, }, diff --git a/tests/core/execution/SpawnExecution.test.ts b/tests/core/execution/SpawnExecution.test.ts index d9108aceae..633edd07ac 100644 --- a/tests/core/execution/SpawnExecution.test.ts +++ b/tests/core/execution/SpawnExecution.test.ts @@ -1,5 +1,5 @@ import { SpawnExecution } from "../../../src/core/execution/SpawnExecution"; -import { PlayerInfo, PlayerType } from "../../../src/core/game/Game"; +import { GameType, PlayerInfo, PlayerType } from "../../../src/core/game/Game"; import { setup } from "../../util/Setup"; describe("Spawn execution", () => { @@ -27,7 +27,7 @@ describe("Spawn execution", () => { spawnExecutions.push(new SpawnExecution("game_id", playerInfo)); } - const game = await setup(mapName, undefined, players); + const game = await setup(mapName, { gameType: GameType.Public }, players); game.addExecution(...spawnExecutions); @@ -73,7 +73,11 @@ describe("Spawn execution", () => { spawnExecutions.push(new SpawnExecution("game_id", playerInfo)); } - const game = await setup("half_land_half_ocean", undefined, players); + const game = await setup( + "half_land_half_ocean", + { gameType: GameType.Public }, + players, + ); game.addExecution(...spawnExecutions); @@ -96,7 +100,11 @@ describe("Spawn execution", () => { `player_id`, ); - const game = await setup("half_land_half_ocean", undefined, [playerInfo]); + const game = await setup( + "half_land_half_ocean", + { gameType: GameType.Public }, + [playerInfo], + ); game.addExecution(new SpawnExecution("game_id", playerInfo, 50)); game.addExecution(new SpawnExecution("game_id", playerInfo, 60)); diff --git a/tests/core/executions/MIRVExecution.test.ts b/tests/core/executions/MIRVExecution.test.ts index 5fe26bc874..5950ccb57f 100644 --- a/tests/core/executions/MIRVExecution.test.ts +++ b/tests/core/executions/MIRVExecution.test.ts @@ -1,6 +1,7 @@ import { MirvExecution } from "../../../src/core/execution/MIRVExecution"; import { Game, + GameType, MessageType, Player, PlayerInfo, @@ -19,6 +20,7 @@ describe("MIRVExecution", () => { game = await setup( "big_plains", { + gameType: GameType.Public, infiniteGold: true, instantBuild: true, }, diff --git a/tests/core/executions/NukeExecution.test.ts b/tests/core/executions/NukeExecution.test.ts index bfc8955471..26d59363ed 100644 --- a/tests/core/executions/NukeExecution.test.ts +++ b/tests/core/executions/NukeExecution.test.ts @@ -1,6 +1,7 @@ import { NukeExecution } from "../../../src/core/execution/NukeExecution"; import { Game, + GameType, Player, PlayerInfo, PlayerType, @@ -19,6 +20,7 @@ describe("NukeExecution", () => { game = await setup( "big_plains", { + gameType: GameType.Public, infiniteGold: true, instantBuild: true, }, diff --git a/tests/core/executions/PlayerExecution.test.ts b/tests/core/executions/PlayerExecution.test.ts index bbb74b32b4..fc479f99a1 100644 --- a/tests/core/executions/PlayerExecution.test.ts +++ b/tests/core/executions/PlayerExecution.test.ts @@ -1,6 +1,7 @@ import { PlayerExecution } from "../../../src/core/execution/PlayerExecution"; import { Game, + GameType, Player, PlayerInfo, PlayerType, @@ -18,6 +19,7 @@ describe("PlayerExecution", () => { game = await setup( "big_plains", { + gameType: GameType.Public, infiniteGold: true, instantBuild: true, }, diff --git a/tests/core/executions/WinCheckExecution.test.ts b/tests/core/executions/WinCheckExecution.test.ts index 6b5b07d09e..6671044665 100644 --- a/tests/core/executions/WinCheckExecution.test.ts +++ b/tests/core/executions/WinCheckExecution.test.ts @@ -2,6 +2,7 @@ import { WinCheckExecution } from "../../../src/core/execution/WinCheckExecution import { ColoredTeams, GameMode, + GameType, PlayerInfo, PlayerType, RankedType, @@ -14,6 +15,7 @@ describe("WinCheckExecution", () => { beforeEach(async () => { mg = await setup("big_plains", { + gameType: GameType.Public, infiniteGold: true, gameMode: GameMode.FFA, maxTimerValue: 5, @@ -93,6 +95,7 @@ describe("WinCheckExecution - Nation Winners", () => { test("should set Nation as winner when reaching 80% territory", async () => { // Setup game const game = await setup("big_plains", { + gameType: GameType.Public, infiniteGold: true, gameMode: GameMode.FFA, instantBuild: true, @@ -145,6 +148,7 @@ describe("WinCheckExecution - Nation Winners", () => { test("should set Nation as winner when timer expires with most territory", async () => { // Setup game with timer const game = await setup("big_plains", { + gameType: GameType.Public, infiniteGold: true, gameMode: GameMode.FFA, instantBuild: true, @@ -224,6 +228,7 @@ describe("WinCheckExecution - Nation Winners", () => { test("should set correct Nation as winner among multiple Nations", async () => { // Setup game const game = await setup("big_plains", { + gameType: GameType.Public, infiniteGold: true, gameMode: GameMode.FFA, instantBuild: true, @@ -307,6 +312,7 @@ describe("WinCheckExecution - Nation Winners", () => { test("should not set winner for bot team in Team mode", async () => { // Setup Team mode game const game = await setup("big_plains", { + gameType: GameType.Public, infiniteGold: true, gameMode: GameMode.Team, instantBuild: true, @@ -377,6 +383,7 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => { const game = await setup( "big_plains", { + gameType: GameType.Public, infiniteGold: true, gameMode: GameMode.FFA, instantBuild: true, @@ -432,6 +439,7 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => { const game = await setup( "big_plains", { + gameType: GameType.Public, infiniteGold: true, gameMode: GameMode.FFA, instantBuild: true, @@ -488,6 +496,7 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => { const game = await setup( "big_plains", { + gameType: GameType.Public, infiniteGold: true, gameMode: GameMode.FFA, instantBuild: true, @@ -530,6 +539,7 @@ describe("WinCheckExecution - 1v1 Ranked Mode", () => { const game = await setup( "big_plains", { + gameType: GameType.Public, infiniteGold: true, gameMode: GameMode.FFA, instantBuild: true, diff --git a/tests/core/game/GameImpl.test.ts b/tests/core/game/GameImpl.test.ts index 7b2b5b32ce..8bdf3bd045 100644 --- a/tests/core/game/GameImpl.test.ts +++ b/tests/core/game/GameImpl.test.ts @@ -5,6 +5,7 @@ import { SpawnExecution } from "../../../src/core/execution/SpawnExecution"; import { AllianceRequestExecution } from "../../../src/core/execution/alliance/AllianceRequestExecution"; import { Game, + GameType, Player, PlayerInfo, PlayerType, @@ -132,4 +133,35 @@ describe("GameImpl", () => { expect(attacker.isTraitor()).toBe(true); expect(attacker.allianceWith(defender)).toBeFalsy(); }); + + test("Singleplayer late human spawn gets spawn immunity", async () => { + const singleplayerGame = await setup("plains", { + gameType: GameType.Singleplayer, + }); + (singleplayerGame.config() as any).setSpawnImmunityDuration(100); + + const pastSpawnCountdown = + singleplayerGame.config().numSpawnPhaseTurns() + 20; + for (let i = 0; i < pastSpawnCountdown; i++) { + singleplayerGame.executeNextTick(); + } + + const lateHumanInfo = new PlayerInfo( + "late human", + PlayerType.Human, + "late_client_id", + "late_player_id", + ); + + singleplayerGame.addExecution( + new SpawnExecution(gameID, lateHumanInfo, singleplayerGame.ref(5, 5)), + ); + + // First tick initializes the execution, second tick applies the spawn. + singleplayerGame.executeNextTick(); + singleplayerGame.executeNextTick(); + + expect(singleplayerGame.player(lateHumanInfo.id).hasSpawned()).toBe(true); + expect(singleplayerGame.isSpawnImmunityActive()).toBe(true); + }); });