From 86d1ac6c62a6f28e629c426f464b12b52d56149c Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Wed, 24 Dec 2025 19:07:44 +0100 Subject: [PATCH] =?UTF-8?q?Nations=20now=20counter=20warship=20infestation?= =?UTF-8?q?s=20=F0=9F=9A=A2=20(#2658)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Relevant for singleplayer and HumansVsNations: Humans sometimes try to flood the entire ocean with warships. The goal is to dominate the trade and to block transport ships. The already existing `trackTransportShipsAndRetaliate` and `trackTradeShipsAndRetaliate` methods can't stop these large scale infestations, the nations are completely helpless. The new `counterWarshipInfestation` method checks if a nation is one of the top 3 richest players (Enough money for warships) and if any enemy (or enemy team) has accumulated more than 10 (for teams total 15) warships, then builds a counter-warship targeting that threat. This feature only activates on Hard or Impossible difficulty. Thats how it can look, nations send out a warship every couple of seconds, until the infestation threat is gone: Screenshot 2025-12-20 160600 ## 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 --- src/core/execution/NationExecution.ts | 86 +---- .../execution/nation/NationWarshipBehavior.ts | 264 +++++++++++++++ ...test.ts => NationAllianceBehavior.test.ts} | 0 tests/NationCounterWarshipInfestation.test.ts | 318 ++++++++++++++++++ 4 files changed, 594 insertions(+), 74 deletions(-) create mode 100644 src/core/execution/nation/NationWarshipBehavior.ts rename tests/{AllianceBehaviour.test.ts => NationAllianceBehavior.test.ts} (100%) create mode 100644 tests/NationCounterWarshipInfestation.test.ts diff --git a/src/core/execution/NationExecution.ts b/src/core/execution/NationExecution.ts index 53a39ffb9..ce7491505 100644 --- a/src/core/execution/NationExecution.ts +++ b/src/core/execution/NationExecution.ts @@ -28,6 +28,7 @@ import { ConstructionExecution } from "./ConstructionExecution"; import { NationAllianceBehavior } from "./nation/NationAllianceBehavior"; import { NationEmojiBehavior } from "./nation/NationEmojiBehavior"; import { NationMIRVBehavior } from "./nation/NationMIRVBehavior"; +import { NationWarshipBehavior } from "./nation/NationWarshipBehavior"; import { structureSpawnTileValue } from "./nation/structureSpawnTileValue"; import { NukeExecution } from "./NukeExecution"; import { SpawnExecution } from "./SpawnExecution"; @@ -42,6 +43,7 @@ export class NationExecution implements Execution { private mirvBehavior: NationMIRVBehavior | null = null; private attackBehavior: AiAttackBehavior | null = null; private allianceBehavior: NationAllianceBehavior | null = null; + private warshipBehavior: NationWarshipBehavior | null = null; private mg: Game; private player: Player | null = null; @@ -54,11 +56,6 @@ export class NationExecution implements Execution { private readonly lastNukeSent: [Tick, TileRef][] = []; private readonly embargoMalusApplied = new Set(); - // Track our transport ships we currently own - private trackedTransportShips: Set = new Set(); - // Track our trade ships we currently own - private trackedTradeShips: Set = new Set(); - constructor( private gameID: GameID, private nation: Nation, // Nation contains PlayerInfo with PlayerType.Nation @@ -102,12 +99,12 @@ export class NationExecution implements Execution { tick(ticks: number) { // Ship tracking if ( + this.warshipBehavior !== null && this.player !== null && this.player.isAlive() && this.mg.config().gameConfig().difficulty !== Difficulty.Easy ) { - this.trackTransportShipsAndRetaliate(); - this.trackTradeShipsAndRetaliate(); + this.warshipBehavior.trackShipsAndRetaliate(); } if (ticks % this.attackRate !== this.attackTick) { @@ -141,7 +138,8 @@ export class NationExecution implements Execution { if ( this.mirvBehavior === null || this.attackBehavior === null || - this.allianceBehavior === null + this.allianceBehavior === null || + this.warshipBehavior === null ) { // Player is unavailable during init() this.emojiBehavior = new NationEmojiBehavior( @@ -160,6 +158,11 @@ export class NationExecution implements Execution { this.mg, this.player, ); + this.warshipBehavior = new NationWarshipBehavior( + this.random, + this.mg, + this.player, + ); this.attackBehavior = new AiAttackBehavior( this.random, this.mg, @@ -183,70 +186,7 @@ export class NationExecution implements Execution { this.handleEmbargoesToHostileNations(); this.mirvBehavior.considerMIRV(); this.maybeAttack(); - } - - // Send out a warship if our transport ship got captured - private trackTransportShipsAndRetaliate(): void { - if (this.player === null) return; - - // Add any currently owned transport ships to our tracking set - this.player - .units(UnitType.TransportShip) - .forEach((u) => this.trackedTransportShips.add(u)); - - // Iterate tracked transport ships; if it got destroyed by an enemy: retaliate - for (const ship of Array.from(this.trackedTransportShips)) { - if (!ship.isActive()) { - // Distinguish between arrival/retreat and enemy destruction - if (ship.wasDestroyedByEnemy()) { - this.maybeRetaliateWithWarship(ship.tile()); - } - this.trackedTransportShips.delete(ship); - } - } - } - - // Send out a warship if our trade ship got captured - private trackTradeShipsAndRetaliate(): void { - if (this.player === null) return; - - // Add any currently owned trade ships to our tracking map - this.player - .units(UnitType.TradeShip) - .forEach((u) => this.trackedTradeShips.add(u)); - - // Iterate tracked trade ships; if we no longer own it, it was captured: retaliate - for (const ship of Array.from(this.trackedTradeShips)) { - if (!ship.isActive()) { - this.trackedTradeShips.delete(ship); - continue; - } - if (ship.owner().id() !== this.player.id()) { - // Ship was ours and is now owned by someone else -> captured - this.maybeRetaliateWithWarship(ship.tile()); - this.trackedTradeShips.delete(ship); - } - } - } - - private maybeRetaliateWithWarship(tile: TileRef): void { - if (this.player === null) return; - - const { difficulty } = this.mg.config().gameConfig(); - // In Easy never retaliate. In Medium retaliate with 15% chance. Hard with 50%, Impossible with 80%. - if ( - (difficulty === Difficulty.Medium && this.random.nextInt(0, 100) < 15) || - (difficulty === Difficulty.Hard && this.random.nextInt(0, 100) < 50) || - (difficulty === Difficulty.Impossible && this.random.nextInt(0, 100) < 80) - ) { - const canBuild = this.player.canBuild(UnitType.Warship, tile); - if (canBuild === false) { - return; - } - this.mg.addExecution( - new ConstructionExecution(this.player, UnitType.Warship, tile), - ); - } + this.warshipBehavior.counterWarshipInfestation(); } private randomSpawnLand(): TileRef | null { @@ -505,9 +445,7 @@ export class NationExecution implements Execution { } this.attackBehavior.assistAllies(); - this.attackBehavior.attackBestTarget(borderingFriends, borderingEnemies); - this.maybeSendNuke( this.attackBehavior.findBestNukeTarget(borderingEnemies), ); diff --git a/src/core/execution/nation/NationWarshipBehavior.ts b/src/core/execution/nation/NationWarshipBehavior.ts new file mode 100644 index 000000000..e98a490eb --- /dev/null +++ b/src/core/execution/nation/NationWarshipBehavior.ts @@ -0,0 +1,264 @@ +import { + Difficulty, + Game, + Gold, + Player, + PlayerType, + Unit, + UnitType, +} from "../../game/Game"; +import { TileRef } from "../../game/GameMap"; +import { PseudoRandom } from "../../PseudoRandom"; +import { ConstructionExecution } from "../ConstructionExecution"; + +export class NationWarshipBehavior { + // Track our transport ships we currently own + private trackedTransportShips: Set = new Set(); + // Track our trade ships we currently own + private trackedTradeShips: Set = new Set(); + + constructor( + private random: PseudoRandom, + private game: Game, + private player: Player, + ) {} + + trackShipsAndRetaliate(): void { + this.trackTransportShipsAndRetaliate(); + this.trackTradeShipsAndRetaliate(); + } + + // Send out a warship if our transport ship got captured + private trackTransportShipsAndRetaliate(): void { + // Add any currently owned transport ships to our tracking set + this.player + .units(UnitType.TransportShip) + .forEach((u) => this.trackedTransportShips.add(u)); + + // Iterate tracked transport ships; if it got destroyed by an enemy: retaliate + for (const ship of Array.from(this.trackedTransportShips)) { + if (!ship.isActive()) { + // Distinguish between arrival/retreat and enemy destruction + if (ship.wasDestroyedByEnemy()) { + this.maybeRetaliateWithWarship(ship.tile()); + } + this.trackedTransportShips.delete(ship); + } + } + } + + // Send out a warship if our trade ship got captured + private trackTradeShipsAndRetaliate(): void { + // Add any currently owned trade ships to our tracking map + this.player + .units(UnitType.TradeShip) + .forEach((u) => this.trackedTradeShips.add(u)); + + // Iterate tracked trade ships; if we no longer own it, it was captured: retaliate + for (const ship of Array.from(this.trackedTradeShips)) { + if (!ship.isActive()) { + this.trackedTradeShips.delete(ship); + continue; + } + if (ship.owner().id() !== this.player.id()) { + // Ship was ours and is now owned by someone else -> captured + this.maybeRetaliateWithWarship(ship.tile()); + this.trackedTradeShips.delete(ship); + } + } + } + + private maybeRetaliateWithWarship(tile: TileRef): void { + const { difficulty } = this.game.config().gameConfig(); + // In Easy never retaliate. In Medium retaliate with 15% chance. Hard with 50%, Impossible with 80%. + if ( + (difficulty === Difficulty.Medium && this.random.nextInt(0, 100) < 15) || + (difficulty === Difficulty.Hard && this.random.nextInt(0, 100) < 50) || + (difficulty === Difficulty.Impossible && this.random.nextInt(0, 100) < 80) + ) { + const canBuild = this.player.canBuild(UnitType.Warship, tile); + if (canBuild === false) { + return; + } + this.game.addExecution( + new ConstructionExecution(this.player, UnitType.Warship, tile), + ); + } + } + + // Prevent warship infestations: if current player is one of the 3 richest and an enemy has too many warships, send a counter-warship. + // What is a warship infestation? A player tries to dominate the entire ocean to block all trade and transport boats. + counterWarshipInfestation(): void { + if (!this.shouldCounterWarshipInfestation()) { + return; + } + + const isTeamGame = this.player.team() !== null; + + if (!this.isRichPlayer(isTeamGame)) { + return; + } + + const target = this.findWarshipInfestationCounterTarget(isTeamGame); + if (target !== null) { + this.buildCounterWarship(target); + } + } + + private shouldCounterWarshipInfestation(): boolean { + // Only the smart nations can do this + const { difficulty } = this.game.config().gameConfig(); + if ( + difficulty !== Difficulty.Hard && + difficulty !== Difficulty.Impossible + ) { + return false; + } + + // Quit early if there aren't many warships in the game + if (this.game.unitCount(UnitType.Warship) <= 10) { + return false; + } + + // Quit early if we can't afford a warship + if (this.cost(UnitType.Warship) > this.player.gold()) { + return false; + } + + // Quit early if we don't have a port to send warships from + if (this.player.units(UnitType.Port).length === 0) { + return false; + } + + // Don't send too many warships + if (this.player.units(UnitType.Warship).length >= 10) { + return false; + } + + return true; + } + + // Check if current player is one of the 3 richest (We don't want poor nations to use their precious gold on this) + private isRichPlayer(isTeamGame: boolean): boolean { + const players = this.game.players().filter((p) => { + if (p.type() === PlayerType.Human) return false; + return isTeamGame ? p.team() === this.player.team() : true; + }); + const topThree = players + .sort((a, b) => Number(b.gold() - a.gold())) + .slice(0, 3); + return topThree.some((p) => p.id() === this.player.id()); + } + + private findWarshipInfestationCounterTarget( + isTeamGame: boolean, + ): { player: Player; warship: Unit } | null { + return isTeamGame + ? this.findTeamGameWarshipTarget() + : this.findFreeForAllWarshipTarget(); + } + + private findTeamGameWarshipTarget(): { + player: Player; + warship: Unit; + } | null { + const enemyTeamWarships = new Map< + string, + { count: number; team: string; players: Player[] } + >(); + + for (const p of this.game.players()) { + // Skip friendly players (our team and allies) + if (this.player.isFriendly(p) || p.id() === this.player.id()) { + continue; + } + + const team = p.team(); + if (team === null) continue; + + const teamKey = team.toString(); + const warshipCount = p.units(UnitType.Warship).length; + + if (!enemyTeamWarships.has(teamKey)) { + enemyTeamWarships.set(teamKey, { + count: 0, + team: teamKey, + players: [], + }); + } + const teamData = enemyTeamWarships.get(teamKey)!; + teamData.count += warshipCount; + teamData.players.push(p); + } + + // Find team with more than 15 warships + for (const [, teamData] of enemyTeamWarships.entries()) { + if (teamData.count > 15) { + // Find player in that team with most warships + const playerWithMostWarships = teamData.players.reduce( + (max, p) => { + const count = p.units(UnitType.Warship).length; + const maxCount = max ? max.units(UnitType.Warship).length : 0; + return count > maxCount ? p : max; + }, + null as Player | null, + ); + + if (playerWithMostWarships) { + const warships = playerWithMostWarships.units(UnitType.Warship); + if (warships.length > 3) { + return { + player: playerWithMostWarships, + warship: this.random.randElement(warships), + }; + } + } + } + } + + return null; + } + + private findFreeForAllWarshipTarget(): { + player: Player; + warship: Unit; + } | null { + const enemies = this.game + .players() + .filter((p) => !this.player.isFriendly(p) && p.id() !== this.player.id()); + + for (const enemy of enemies) { + const enemyWarships = enemy.units(UnitType.Warship); + if (enemyWarships.length > 10) { + return { + player: enemy, + warship: this.random.randElement(enemyWarships), + }; + } + } + + return null; + } + + private buildCounterWarship(target: { player: Player; warship: Unit }): void { + const canBuild = this.player.canBuild( + UnitType.Warship, + target.warship.tile(), + ); + if (canBuild === false) { + return; + } + + this.game.addExecution( + new ConstructionExecution( + this.player, + UnitType.Warship, + target.warship.tile(), + ), + ); + } + + private cost(type: UnitType): Gold { + return this.game.unitInfo(type).cost(this.game, this.player); + } +} diff --git a/tests/AllianceBehaviour.test.ts b/tests/NationAllianceBehavior.test.ts similarity index 100% rename from tests/AllianceBehaviour.test.ts rename to tests/NationAllianceBehavior.test.ts diff --git a/tests/NationCounterWarshipInfestation.test.ts b/tests/NationCounterWarshipInfestation.test.ts new file mode 100644 index 000000000..ecb9858bb --- /dev/null +++ b/tests/NationCounterWarshipInfestation.test.ts @@ -0,0 +1,318 @@ +import { NationExecution } from "../src/core/execution/NationExecution"; +import { + Cell, + Difficulty, + GameMode, + Nation, + PlayerInfo, + PlayerType, + UnitType, +} from "../src/core/game/Game"; +import { setup } from "./util/Setup"; + +// The half_land_half_ocean map is 16x16: +// - x=0-7 is land +// - x=8-15 is ocean +// Coast is at x=7 + +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", { + infiniteGold: true, + instantBuild: true, + difficulty: Difficulty.Hard, // Required for counter-warship logic + }); + + // Create players: a rich nation and an enemy with many warships + const nationInfo = new PlayerInfo( + "defender_nation", + PlayerType.Nation, + null, + "nation_id", + ); + const enemyInfo = new PlayerInfo( + "warship_spammer", + PlayerType.Human, + null, + "enemy_id", + ); + + game.addPlayer(nationInfo); + game.addPlayer(enemyInfo); + + // Skip spawn phase + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + const nation = game.player("nation_id"); + const enemy = game.player("enemy_id"); + + // Give nation territory on land (x=0-6, y=0-7) + for (let x = 0; x < 7; x++) { + for (let y = 0; y < 8; y++) { + const tile = game.ref(x, y); + if (game.map().isLand(tile) && !game.map().hasOwner(tile)) { + nation.conquer(tile); + } + } + } + + // Give enemy territory on land (x=0-6, y=8-15) + for (let x = 0; x < 7; x++) { + for (let y = 8; y < 16; y++) { + const tile = game.ref(x, y); + if (game.map().isLand(tile) && !game.map().hasOwner(tile)) { + enemy.conquer(tile); + } + } + } + + // Build a port for the nation on the coast (x=7 is ocean shore) + // Need to find an ocean shore tile in nation's territory + const coastTile = game.ref(6, 4); // Should be land next to ocean + nation.buildUnit(UnitType.Port, coastTile, {}); + + // Give nation plenty of gold to be one of the richest + nation.addGold(10_000_000_000n); + + // Build 11+ warships for the enemy on ocean tiles (x=8-15) + // Each warship needs a unique ocean tile + for (let i = 0; i < 12; i++) { + const oceanX = 8 + (i % 8); + const oceanY = i < 8 ? 4 : 12; + const oceanTile = game.ref(oceanX, oceanY); + if (game.map().isOcean(oceanTile)) { + enemy.buildUnit(UnitType.Warship, oceanTile, { + patrolTile: oceanTile, + }); + } + } + + // Verify preconditions + expect(nation.units(UnitType.Port)).toHaveLength(1); + expect(enemy.units(UnitType.Warship).length).toBeGreaterThan(10); + expect(game.unitCount(UnitType.Warship)).toBeGreaterThan(10); + expect(nation.gold()).toBeGreaterThan(0n); + expect(game.inSpawnPhase()).toBe(false); + expect(nation.isAlive()).toBe(true); + + // Track warships before nation counters + const warshipCountBefore = nation.units(UnitType.Warship).length; + + // Initialize nation with NationExecution to enable counter-warship logic + const testExecutionNation = new Nation(new Cell(3, 4), nation.info()); + + // Try different game IDs to account for randomness in attackRate/attackTick + const gameIds = Array.from({ length: 50 }, (_, i) => `game_ffa_${i}`); + let counterWarshipBuilt = false; + + for (const gameId of gameIds) { + const testExecution = new NationExecution(gameId, testExecutionNation); + testExecution.init(game); + + // Execute nation's tick logic - run many ticks to ensure we hit the attackRate/attackTick timing + // attackRate is 40-80, so we need to run at least 160 ticks (2 cycles) to ensure we hit it twice + // (first hit initializes behaviors, second hit runs counterWarshipInfestation) + for (let tick = 0; tick < 300; tick++) { + testExecution.tick(tick); + // Allow the game to process executions periodically + game.executeNextTick(); + + // Check if nation built a counter-warship + if (nation.units(UnitType.Warship).length > warshipCountBefore) { + counterWarshipBuilt = true; + break; + } + } + + if (counterWarshipBuilt) break; + } + + // Assert that counter-warship was built + expect(counterWarshipBuilt).toBe(true); + + // Verify nation now has a warship + expect(nation.units(UnitType.Warship).length).toBeGreaterThan( + warshipCountBefore, + ); + }); + + test("rich nation sends counter-warship in Team game when enemy team has too many warships", async () => { + // Create players with team setup - use clan tags to group players + const nationInfo = new PlayerInfo( + "[ALPHA]defender_nation", + PlayerType.Nation, + null, + "nation_id", + ); + const allyInfo = new PlayerInfo( + "[ALPHA]ally_player", + PlayerType.Human, + null, + "ally_id", + ); + const enemy1Info = new PlayerInfo( + "[BETA]enemy_player_1", + PlayerType.Human, + null, + "enemy1_id", + ); + const enemy2Info = new PlayerInfo( + "[BETA]enemy_player_2", + PlayerType.Human, + null, + "enemy2_id", + ); + + const game = await setup( + "half_land_half_ocean", + { + infiniteGold: true, + instantBuild: true, + difficulty: Difficulty.Hard, // Required for counter-warship logic + gameMode: GameMode.Team, + playerTeams: 2, + }, + [nationInfo, allyInfo, enemy1Info, enemy2Info], + ); + + // Skip spawn phase + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + const nation = game.player("nation_id"); + const ally = game.player("ally_id"); + const enemy1 = game.player("enemy1_id"); + const enemy2 = game.player("enemy2_id"); + + // Verify team setup + expect(nation.team()).not.toBeNull(); + expect(nation.isOnSameTeam(ally)).toBe(true); + expect(nation.isOnSameTeam(enemy1)).toBe(false); + expect(enemy1.isOnSameTeam(enemy2)).toBe(true); + + // Give nation territory on land (x=0-3, y=0-7) + for (let x = 0; x < 4; x++) { + for (let y = 0; y < 8; y++) { + const tile = game.ref(x, y); + if (game.map().isLand(tile) && !game.map().hasOwner(tile)) { + nation.conquer(tile); + } + } + } + + // Give ally territory on land (x=4-6, y=0-7) + for (let x = 4; x < 7; x++) { + for (let y = 0; y < 8; y++) { + const tile = game.ref(x, y); + if (game.map().isLand(tile) && !game.map().hasOwner(tile)) { + ally.conquer(tile); + } + } + } + + // Give enemies territory on land (x=0-6, y=8-15) + for (let x = 0; x < 4; x++) { + for (let y = 8; y < 16; y++) { + const tile = game.ref(x, y); + if (game.map().isLand(tile) && !game.map().hasOwner(tile)) { + enemy1.conquer(tile); + } + } + } + for (let x = 4; x < 7; x++) { + for (let y = 8; y < 16; y++) { + const tile = game.ref(x, y); + if (game.map().isLand(tile) && !game.map().hasOwner(tile)) { + enemy2.conquer(tile); + } + } + } + + // Build a port for the nation on the coast + const coastTile = game.ref(3, 4); + nation.buildUnit(UnitType.Port, coastTile, {}); + + // Give nation plenty of gold to be one of the richest + nation.addGold(10_000_000_000n); + + // Build warships for enemy team on ocean tiles: total > 15 to trigger team threshold + // Enemy1 gets 10 warships (more than 3, which is required for targeting) + for (let i = 0; i < 10; i++) { + const oceanX = 8 + (i % 8); + const oceanY = 2 + Math.floor(i / 8); + const oceanTile = game.ref(oceanX, oceanY); + if (game.map().isOcean(oceanTile)) { + enemy1.buildUnit(UnitType.Warship, oceanTile, { + patrolTile: oceanTile, + }); + } + } + // Enemy2 gets 6 warships (so total = 16 > 15) + for (let i = 0; i < 6; i++) { + const oceanX = 8 + i; + const oceanY = 10; + const oceanTile = game.ref(oceanX, oceanY); + if (game.map().isOcean(oceanTile)) { + enemy2.buildUnit(UnitType.Warship, oceanTile, { + patrolTile: oceanTile, + }); + } + } + + // Verify preconditions + expect(nation.units(UnitType.Port)).toHaveLength(1); + expect(enemy1.units(UnitType.Warship).length).toBe(10); + expect(enemy2.units(UnitType.Warship).length).toBe(6); + const totalEnemyTeamWarships = + enemy1.units(UnitType.Warship).length + + enemy2.units(UnitType.Warship).length; + expect(totalEnemyTeamWarships).toBeGreaterThan(15); + expect(game.unitCount(UnitType.Warship)).toBeGreaterThan(10); + expect(nation.gold()).toBeGreaterThan(0n); + expect(game.inSpawnPhase()).toBe(false); + expect(nation.isAlive()).toBe(true); + + // Track warships before nation counters + const warshipCountBefore = nation.units(UnitType.Warship).length; + + // Initialize nation with NationExecution to enable counter-warship logic + const testExecutionNation = new Nation(new Cell(2, 4), nation.info()); + + // Try different game IDs to account for randomness in attackRate/attackTick + const gameIds = Array.from({ length: 50 }, (_, i) => `game_team_${i}`); + let counterWarshipBuilt = false; + + for (const gameId of gameIds) { + const testExecution = new NationExecution(gameId, testExecutionNation); + testExecution.init(game); + + // Execute nation's tick logic - run many ticks to ensure we hit the attackRate/attackTick timing + // attackRate is 40-80, so we need to run at least 160 ticks (2 cycles) to ensure we hit it twice + // (first hit initializes behaviors, second hit runs counterWarshipInfestation) + for (let tick = 0; tick < 300; tick++) { + testExecution.tick(tick); + // Allow the game to process executions periodically + game.executeNextTick(); + + // Check if nation built a counter-warship + if (nation.units(UnitType.Warship).length > warshipCountBefore) { + counterWarshipBuilt = true; + break; + } + } + + if (counterWarshipBuilt) break; + } + + // Assert that counter-warship was built + expect(counterWarshipBuilt).toBe(true); + + // Verify nation now has a warship + expect(nation.units(UnitType.Warship).length).toBeGreaterThan( + warshipCountBefore, + ); + }); +});