diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 8595b3a21..796e5143e 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -58,6 +58,7 @@ export interface NukeMagnitude { export interface Config { spawnImmunityDuration(): Tick; + nationSpawnImmunityDuration(): Tick; hasExtendedSpawnImmunity(): boolean; serverConfig(): ServerConfig; gameConfig(): GameConfig; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 5a672f296..5fef6e332 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -168,6 +168,9 @@ export class DefaultConfig implements Config { this._gameConfig.spawnImmunityDuration ?? DEFAULT_SPAWN_IMMUNITY_TICKS ); } + nationSpawnImmunityDuration(): Tick { + return DEFAULT_SPAWN_IMMUNITY_TICKS; + } hasExtendedSpawnImmunity(): boolean { return this.spawnImmunityDuration() > DEFAULT_SPAWN_IMMUNITY_TICKS; } diff --git a/src/core/execution/NationExecution.ts b/src/core/execution/NationExecution.ts index 45abd3729..858b00974 100644 --- a/src/core/execution/NationExecution.ts +++ b/src/core/execution/NationExecution.ts @@ -94,33 +94,14 @@ export class NationExecution implements Execution { this.warshipBehavior.trackShipsAndRetaliate(); } - 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.player === null) { 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( @@ -155,6 +136,24 @@ export class NationExecution implements Execution { return; } + 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.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; + } + this.emojiBehavior.maybeSendCasualEmoji(); this.updateRelationsFromEmbargos(); this.allianceBehavior.handleAllianceRequests(); diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 3897da573..4fcf1aa84 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -759,6 +759,7 @@ export interface Game extends GameMap { // Immunity timer isSpawnImmunityActive(): boolean; + isNationSpawnImmunityActive(): boolean; // Game State ticks(): Tick; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 27404b948..0f807e3d3 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -727,6 +727,14 @@ export class GameImpl implements Game { ); } + public isNationSpawnImmunityActive(): boolean { + return ( + this.config().numSpawnPhaseTurns() + + this.config().nationSpawnImmunityDuration() > + this.ticks() + ); + } + sendEmojiUpdate(msg: EmojiMessage): void { this.addUpdate({ type: GameUpdateType.Emoji, diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 44b34dfd0..974874ad1 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -824,6 +824,13 @@ export class GameView implements GameMap { this.ticks() ); } + isNationSpawnImmunityActive(): boolean { + return ( + this._config.numSpawnPhaseTurns() + + this._config.nationSpawnImmunityDuration() > + this.ticks() + ); + } config(): Config { return this._config; } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index e2005f248..361c33135 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -1261,18 +1261,25 @@ export class PlayerImpl implements Player { } public isImmune(): boolean { - return this.type() === PlayerType.Human && this.mg.isSpawnImmunityActive(); + if (this.type() === PlayerType.Human) { + return this.mg.isSpawnImmunityActive(); + } + if (this.type() === PlayerType.Nation) { + return this.mg.isNationSpawnImmunityActive(); + } + return false; } public canAttackPlayer( player: Player, treatAFKFriendly: boolean = false, ): boolean { - if (this.type() === PlayerType.Human) { - return !player.isImmune() && !this.isFriendly(player, treatAFKFriendly); + if (this.type() === PlayerType.Bot) { + // Bots are not affected by immunity + return !this.isFriendly(player, treatAFKFriendly); } - // Only humans are affected by immunity, bots and nations should be able to attack freely - return !this.isFriendly(player, treatAFKFriendly); + // Humans and Nations respect immunity + return !player.isImmune() && !this.isFriendly(player, treatAFKFriendly); } public canAttack(tile: TileRef): boolean { diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts index ff875870d..dbf05b7c2 100644 --- a/tests/Attack.test.ts +++ b/tests/Attack.test.ts @@ -426,11 +426,29 @@ describe("Attack immunity", () => { expect(playerA.units(UnitType.TransportShip)).toHaveLength(1); }); - test("Should be able to attack nations during immunity phase", async () => { + test("Should not be able to attack nations during nation immunity phase", async () => { + (game.config() as TestConfig).setNationSpawnImmunityDuration( + immunityPhaseTicks, + ); const nationId = "nation_id"; const nation = new PlayerInfo("nation", PlayerType.Nation, null, nationId); game.addPlayer(nation); - // Player A attacks the nation + // Player A attacks the nation during nation immunity + const attackExecution = new AttackExecution(null, playerA, nationId, null); + game.addExecution(attackExecution); + game.executeNextTick(); + expect(playerA.outgoingAttacks()).toHaveLength(0); + }); + + test("Should be able to attack nations after nation immunity phase", async () => { + (game.config() as TestConfig).setNationSpawnImmunityDuration( + immunityPhaseTicks, + ); + const nationId = "nation_id"; + const nation = new PlayerInfo("nation", PlayerType.Nation, null, nationId); + game.addPlayer(nation); + waitForImmunityToEnd(); + // Player A attacks the nation after immunity const attackExecution = new AttackExecution(null, playerA, nationId, null); game.addExecution(attackExecution); game.executeNextTick(); diff --git a/tests/NationMIRV.test.ts b/tests/NationMIRV.test.ts index 14b493ddc..abaeb60aa 100644 --- a/tests/NationMIRV.test.ts +++ b/tests/NationMIRV.test.ts @@ -1,4 +1,5 @@ import { MirvExecution } from "../src/core/execution/MIRVExecution"; +import { MissileSiloExecution } from "../src/core/execution/MissileSiloExecution"; import { NationExecution } from "../src/core/execution/NationExecution"; import { Cell, @@ -64,6 +65,11 @@ describe("Nation MIRV Retaliation", () => { } } nation.buildUnit(UnitType.MissileSilo, game.ref(50, 50), {}); + // Register MissileSiloExecution so the silo can reload after firing + const nationSilo = nation.units(UnitType.MissileSilo)[0]; + if (nationSilo) { + game.addExecution(new MissileSiloExecution(nationSilo)); + } // Give both players enough gold for MIRVs attacker.addGold(1_000_000_000n); @@ -85,6 +91,8 @@ describe("Nation MIRV Retaliation", () => { let retaliationAttempted = false; for (const gameId of gameIds) { + // Advance game to clear any silo cooldowns from previous iteration + executeTicks(game, 100); const testExecution = new NationExecution(gameId, testExecutionNation); testExecution.init(game); @@ -197,6 +205,11 @@ describe("Nation MIRV Retaliation", () => { const nationTile = Array.from(nation.tiles())[0]; if (nationTile) { nation.buildUnit(UnitType.MissileSilo, nationTile, {}); + // Register MissileSiloExecution so the silo can reload after firing + const silo = nation.units(UnitType.MissileSilo)[0]; + if (silo) { + game.addExecution(new MissileSiloExecution(silo)); + } } // Then give dominant player a large amount of territory @@ -253,6 +266,8 @@ describe("Nation MIRV Retaliation", () => { let victoryDenialSuccessful = false; for (const gameId of gameIds) { + // Advance game to clear any silo cooldowns from previous iteration + executeTicks(game, 100); const testExecution = new NationExecution(gameId, testExecutionNation); testExecution.init(game); @@ -347,6 +362,11 @@ describe("Nation MIRV Retaliation", () => { const nationTile = Array.from(nation.tiles())[0]; if (nationTile) { nation.buildUnit(UnitType.MissileSilo, nationTile, {}); + // Register MissileSiloExecution so the silo can reload after firing + const silo = nation.units(UnitType.MissileSilo)[0]; + if (silo) { + game.addExecution(new MissileSiloExecution(silo)); + } } // Give second player some territory and cities @@ -406,6 +426,8 @@ describe("Nation MIRV Retaliation", () => { let steamrollStopSuccessful = false; for (const gameId of gameIds) { + // Advance game to clear any silo cooldowns from previous iteration + executeTicks(game, 100); const testExecution = new NationExecution(gameId, testExecutionNation); testExecution.init(game); @@ -631,6 +653,11 @@ describe("Nation MIRV Retaliation", () => { const nationTile = Array.from(nation.tiles())[0]; if (nationTile) { nation.buildUnit(UnitType.MissileSilo, nationTile, {}); + // Register MissileSiloExecution so the silo can reload after firing + const silo = nation.units(UnitType.MissileSilo)[0]; + if (silo) { + game.addExecution(new MissileSiloExecution(silo)); + } } // Give team players a large amount of territory to exceed team threshold, @@ -691,6 +718,8 @@ describe("Nation MIRV Retaliation", () => { let teamVictoryDenialSuccessful = false; for (const gameId of gameIds) { + // Advance game to clear any silo cooldowns from previous iteration + executeTicks(game, 100); const testExecution = new NationExecution(gameId, testExecutionNation); testExecution.init(game); diff --git a/tests/util/TestConfig.ts b/tests/util/TestConfig.ts index a007422a1..8681c5b02 100644 --- a/tests/util/TestConfig.ts +++ b/tests/util/TestConfig.ts @@ -13,6 +13,7 @@ export class TestConfig extends DefaultConfig { private _proximityBonusPortsNb: number = 0; private _defaultNukeSpeed: number = 4; private _spawnImmunityDuration: number = 0; + private _nationSpawnImmunityDuration: number = 0; disableNavMesh(): boolean { return this.gameConfig().disableNavMesh ?? true; @@ -67,6 +68,14 @@ export class TestConfig extends DefaultConfig { return this._spawnImmunityDuration; } + setNationSpawnImmunityDuration(duration: Tick) { + this._nationSpawnImmunityDuration = duration; + } + + nationSpawnImmunityDuration(): Tick { + return this._nationSpawnImmunityDuration; + } + attackLogic( gm: Game, attackTroops: number,