mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 10:10:55 +00:00
Nations now counter warship infestations 🚢 (#2658)
## 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: <img width="779" height="670" alt="Screenshot 2025-12-20 160600" src="https://github.com/user-attachments/assets/25040077-e7db-4720-aea4-7c230afe05ea" /> ## 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
This commit is contained in:
@@ -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<PlayerID>();
|
||||
|
||||
// Track our transport ships we currently own
|
||||
private trackedTransportShips: Set<Unit> = new Set();
|
||||
// Track our trade ships we currently own
|
||||
private trackedTradeShips: Set<Unit> = 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),
|
||||
);
|
||||
|
||||
@@ -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<Unit> = new Set();
|
||||
// Track our trade ships we currently own
|
||||
private trackedTradeShips: Set<Unit> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user