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:
## 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,
+ );
+ });
+});