Files
OpenFrontIO/tests/Attack.test.ts
DevelopingTom af0b8a8d50 Configurable immunity timer (#2763)
## Description:

Resolve discussions about stalled PR
https://github.com/openfrontio/OpenFrontIO/pull/2460

<img width="724" height="348" alt="image"
src="https://github.com/user-attachments/assets/c2c9fa79-cace-431a-9ca4-b3656612fa9d"
/>

Changes:
- Added a `Player::canAttackPlayer(other)` function to determine whether
a player can be attacked.
- This function is now used in most places where a fight can occur:
    - AttackExecution (land attacks)
    - Naval invasion
    - Warship fight
- Nukes can't be thrown during the truce
- Immunity only affect human players. Nations and bot will fight as
usual, and can be fought against.
- The immunity timer uses minutes in the modal window.

UI:

- The immunity phase is displayed with a timer bar at the top. This is
from the original PR, to be discussed if it's not deemed visible enough:

<img width="632" height="215" alt="image"
src="https://github.com/user-attachments/assets/f5ab9aa0-bd4f-4503-b8d6-b40b121fba65"
/>


## 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:

IngloriousTom

---------

Co-authored-by: newyearnewphil <git@nynp.dev>
2026-01-03 20:04:48 -08:00

479 lines
14 KiB
TypeScript

import { AttackExecution } from "../src/core/execution/AttackExecution";
import { SpawnExecution } from "../src/core/execution/SpawnExecution";
import { TransportShipExecution } from "../src/core/execution/TransportShipExecution";
import {
Game,
Player,
PlayerInfo,
PlayerType,
UnitType,
} from "../src/core/game/Game";
import { TileRef } from "../src/core/game/GameMap";
import { GameID } from "../src/core/Schemas";
import { setup } from "./util/Setup";
import { TestConfig } from "./util/TestConfig";
import { constructionExecution } from "./util/utils";
let game: Game;
const gameID: GameID = "game_id";
let attacker: Player;
let defender: Player;
let defenderSpawn: TileRef;
let attackerSpawn: TileRef;
function sendBoat(target: TileRef, source: TileRef, troops: number) {
game.addExecution(
new TransportShipExecution(defender, null, target, troops, source),
);
}
const immunityPhaseTicks = 10;
function waitForImmunityToEnd() {
for (let i = 0; i < immunityPhaseTicks + 1; i++) {
game.executeNextTick();
}
}
describe("Attack", () => {
beforeEach(async () => {
game = await setup("ocean_and_land", {
infiniteGold: true,
instantBuild: true,
infiniteTroops: true,
});
const attackerInfo = new PlayerInfo(
"attacker dude",
PlayerType.Human,
null,
"attacker_id",
);
game.addPlayer(attackerInfo);
const defenderInfo = new PlayerInfo(
"defender dude",
PlayerType.Human,
null,
"defender_id",
);
game.addPlayer(defenderInfo);
defenderSpawn = game.ref(0, 15);
attackerSpawn = game.ref(0, 10);
game.addExecution(
new SpawnExecution(
gameID,
game.player(attackerInfo.id).info(),
attackerSpawn,
),
new SpawnExecution(
gameID,
game.player(defenderInfo.id).info(),
defenderSpawn,
),
);
while (game.inSpawnPhase()) {
game.executeNextTick();
}
attacker = game.player(attackerInfo.id);
defender = game.player(defenderInfo.id);
game.addExecution(
new AttackExecution(100, defender, game.terraNullius().id()),
);
game.executeNextTick();
while (defender.outgoingAttacks().length > 0) {
game.executeNextTick();
}
(game.config() as TestConfig).setDefaultNukeSpeed(50);
});
test("Nuke reduce attacking troop counts", async () => {
// Not building exactly spawn to it's better protected from attacks (but still
// on defender territory)
constructionExecution(game, defender, 1, 1, UnitType.MissileSilo);
expect(defender.units(UnitType.MissileSilo)).toHaveLength(1);
game.addExecution(new AttackExecution(100, attacker, defender.id()));
constructionExecution(game, defender, 0, 15, UnitType.AtomBomb, 3);
const nuke = defender.units(UnitType.AtomBomb)[0];
expect(nuke.isActive()).toBe(true);
expect(attacker.outgoingAttacks()).toHaveLength(1);
expect(attacker.outgoingAttacks()[0].troops()).toBe(98);
// Make the nuke go kaboom
game.executeNextTick();
expect(nuke.isActive()).toBe(false);
expect(attacker.outgoingAttacks()[0].troops()).not.toBe(97);
expect(attacker.outgoingAttacks()[0].troops()).toBeLessThan(90);
});
test("Nuke reduce attacking boat troop count", async () => {
constructionExecution(game, defender, 1, 1, UnitType.MissileSilo);
expect(defender.units(UnitType.MissileSilo)).toHaveLength(1);
sendBoat(game.ref(15, 8), game.ref(10, 5), 100);
constructionExecution(game, defender, 0, 15, UnitType.AtomBomb, 3);
const nuke = defender.units(UnitType.AtomBomb)[0];
expect(nuke.isActive()).toBe(true);
const ship = defender.units(UnitType.TransportShip)[0];
expect(ship.troops()).toBe(100);
game.executeNextTick();
expect(nuke.isActive()).toBe(false);
expect(defender.units(UnitType.TransportShip)[0].troops()).toBeLessThan(90);
});
test("Boat penalty on retreat Transport Ship arrival", async () => {
const player_start_troops = defender.troops();
const boat_troops = player_start_troops * 0.5;
sendBoat(game.ref(15, 8), game.ref(10, 5), boat_troops);
game.executeNextTick();
const ship = defender.units(UnitType.TransportShip)[0];
expect(ship.troops()).toBe(boat_troops);
expect(ship.isActive()).toBe(true);
ship.orderBoatRetreat();
game.executeNextTick();
expect(ship.isActive()).toBe(false);
expect(boat_troops).toBeLessThan(defender.troops());
expect(defender.troops()).toBeLessThan(player_start_troops);
});
});
let playerA: Player;
let playerB: Player;
function addPlayerToGame(
playerInfo: PlayerInfo,
game: Game,
tile: TileRef,
): Player {
game.addPlayer(playerInfo);
game.addExecution(new SpawnExecution(gameID, playerInfo, tile));
return game.player(playerInfo.id);
}
describe("Attack race condition with alliance requests", () => {
beforeEach(async () => {
game = await setup("ocean_and_land", {
infiniteGold: true,
instantBuild: true,
infiniteTroops: true,
});
const playerAInfo = new PlayerInfo(
"playerA",
PlayerType.Human,
null,
"playerA_id",
);
playerA = addPlayerToGame(playerAInfo, game, game.ref(0, 10));
const playerBInfo = new PlayerInfo(
"playerB",
PlayerType.Human,
null,
"playerB_id",
);
playerB = addPlayerToGame(playerBInfo, game, game.ref(0, 10));
while (game.inSpawnPhase()) {
game.executeNextTick();
}
});
it("Should not mark attacker as traitor when alliance is formed after attack starts", async () => {
// Player A sends alliance request to Player B
const allianceRequest = playerA.createAllianceRequest(playerB);
expect(allianceRequest).not.toBeNull();
// Player A attacks Player B
const attackExecution = new AttackExecution(
null,
playerA,
playerB.id(),
null,
);
game.addExecution(attackExecution);
// Player B counter-attacks Player A
const counterAttackExecution = new AttackExecution(
null,
playerB,
playerA.id(),
null,
);
// Player B accepts the alliance request
if (allianceRequest) {
allianceRequest.accept();
}
game.addExecution(counterAttackExecution);
// Execute a few ticks to process the attacks
for (let i = 0; i < 5; i++) {
game.executeNextTick();
}
// Player A should not be marked as traitor because the alliance was formed after the attack started
expect(playerA.isTraitor()).toBe(false);
expect(playerA.isAlliedWith(playerB)).toBe(true);
expect(playerB.isAlliedWith(playerA)).toBe(true);
// The attacks should have retreated due to the alliance being formed
expect(playerA.outgoingAttacks()).toHaveLength(0);
expect(playerB.outgoingAttacks()).toHaveLength(0);
});
it("Should prevent player from attacking allied player", async () => {
// Create an alliance between Player A and Player B
const allianceRequest = playerA.createAllianceRequest(playerB);
if (allianceRequest) {
allianceRequest.accept();
}
// Verify alliance exists
expect(playerA.isAlliedWith(playerB)).toBe(true);
expect(playerB.isAlliedWith(playerA)).toBe(true);
// Player A tries to attack Player B (should be blocked)
const attackExecution = new AttackExecution(
null,
playerA,
playerB.id(),
null,
);
game.addExecution(attackExecution);
// Execute a few ticks to process the attack
for (let i = 0; i < 10; i++) {
game.executeNextTick();
}
// No ongoing attacks should exist for either side
expect(playerA.outgoingAttacks()).toHaveLength(0);
expect(playerB.outgoingAttacks()).toHaveLength(0);
expect(playerA.incomingAttacks()).toHaveLength(0);
expect(playerB.incomingAttacks()).toHaveLength(0);
});
test("Should cancel alliance requests if the recipient attacks", async () => {
// Player A sends alliance request to Player B
const allianceRequest = playerA.createAllianceRequest(playerB);
expect(allianceRequest).not.toBeNull();
expect(playerB.incomingAllianceRequests()).toHaveLength(1);
// Player B attacks Player A
const attackExecution = new AttackExecution(
null,
playerB,
playerA.id(),
null,
);
game.addExecution(attackExecution);
// Execute a few ticks to process the attacks
for (let i = 0; i < 5; i++) {
game.executeNextTick();
}
// Alliance request should be denied since player B attacked
expect(playerA.outgoingAllianceRequests()).toHaveLength(0);
expect(playerB.incomingAllianceRequests()).toHaveLength(0);
});
test("Should cancel the proper alliance request among many", async () => {
// Add a new player to have more alliance requests
const playerCInfo = new PlayerInfo(
"playerB",
PlayerType.Human,
null,
"playerB_id",
);
const playerC = addPlayerToGame(playerCInfo, game, game.ref(10, 10));
// Player A sends alliance request to Player B
const allianceRequestAtoB = playerA.createAllianceRequest(playerB);
expect(allianceRequestAtoB).not.toBeNull();
// Player C also sends alliance request to Player B
const allianceRequestCtoB = playerC.createAllianceRequest(playerB);
expect(allianceRequestCtoB).not.toBeNull();
expect(playerB.incomingAllianceRequests()).toHaveLength(2);
// Player B attacks Player A
const attackExecution = new AttackExecution(
null,
playerB,
playerA.id(),
null,
);
game.addExecution(attackExecution);
// Execute a few ticks to process the attacks
for (let i = 0; i < 5; i++) {
game.executeNextTick();
}
// Alliance request A->B should be denied since player B attacked
expect(playerA.outgoingAllianceRequests()).toHaveLength(0);
// However C->B should remain
expect(playerB.incomingAllianceRequests()).toHaveLength(1);
});
});
describe("Attack immunity", () => {
beforeEach(async () => {
game = await setup("ocean_and_land", {
infiniteGold: true,
instantBuild: true,
infiniteTroops: true,
});
(game.config() as TestConfig).setSpawnImmunityDuration(immunityPhaseTicks);
const playerAInfo = new PlayerInfo(
"playerA",
PlayerType.Human,
null,
"playerA_id",
);
// close to the water to send boats
playerA = addPlayerToGame(playerAInfo, game, game.ref(7, 0));
const playerBInfo = new PlayerInfo(
"playerB",
PlayerType.Human,
null,
"playerB_id",
);
playerB = addPlayerToGame(playerBInfo, game, game.ref(0, 11));
while (game.inSpawnPhase()) {
game.executeNextTick();
}
});
test("Should not be able to attack during immunity phase", async () => {
// Player A attacks Player B
const attackExecution = new AttackExecution(
null,
playerA,
playerB.id(),
null,
);
game.addExecution(attackExecution);
game.executeNextTick();
expect(playerA.outgoingAttacks()).toHaveLength(0);
});
test("Should be able to attack after immunity phase", async () => {
waitForImmunityToEnd();
// Player A attacks Player B
const attackExecution = new AttackExecution(
null,
playerA,
playerB.id(),
null,
);
game.addExecution(attackExecution);
game.executeNextTick();
expect(playerA.outgoingAttacks()).toHaveLength(1);
});
test("Ensure a player can't attack during all the immunity phase", async () => {
// Execute a few ticks but stop right before the immunity phase is over
for (let i = 0; i < immunityPhaseTicks - 1; i++) {
game.executeNextTick();
}
// Player A attacks Player B
game.addExecution(new AttackExecution(null, playerA, playerB.id(), null));
game.executeNextTick(); // ticks === immunityPhaseTicks here
// Attack is not possible during immunity
expect(playerA.outgoingAttacks()).toHaveLength(0);
// Retry after the immunity is over
game.executeNextTick(); // ticks === immunityPhaseTicks + 1
game.addExecution(new AttackExecution(null, playerA, playerB.id(), null));
game.executeNextTick();
// Attack is now possible right after
expect(playerA.outgoingAttacks()).toHaveLength(1);
});
test("Should not be able to send a boat during immunity phase", async () => {
// Player A sends a boat targeting Player B
game.addExecution(
new TransportShipExecution(
playerA,
playerB.id(),
game.ref(15, 8),
10,
game.ref(10, 5),
),
);
game.executeNextTick();
expect(playerA.units(UnitType.TransportShip)).toHaveLength(0);
});
test("Should be able to send a boat after immunity phase", async () => {
waitForImmunityToEnd();
// Player A sends a boat targeting Player B
game.addExecution(
new TransportShipExecution(
playerA,
playerB.id(),
game.ref(15, 8),
10,
game.ref(7, 0),
),
);
game.executeNextTick();
expect(playerA.units(UnitType.TransportShip)).toHaveLength(1);
});
test("Should be able to attack nations during immunity phase", async () => {
const nationId = "nation_id";
const nation = new PlayerInfo("nation", PlayerType.Nation, null, nationId);
game.addPlayer(nation);
// Player A attacks the nation
const attackExecution = new AttackExecution(null, playerA, nationId, null);
game.addExecution(attackExecution);
game.executeNextTick();
expect(playerA.outgoingAttacks()).toHaveLength(1);
});
test("Should be able to attack bots during immunity phase", async () => {
const botId = "bot_id";
const bot = new PlayerInfo("bot", PlayerType.Bot, null, botId);
game.addPlayer(bot);
// Player A attacks the bot
const attackExecution = new AttackExecution(null, playerA, botId, null);
game.addExecution(attackExecution);
game.executeNextTick();
expect(playerA.outgoingAttacks()).toHaveLength(1);
});
test("Can't send nuke during immunity phase", async () => {
constructionExecution(game, playerA, 7, 0, UnitType.MissileSilo);
expect(playerA.units(UnitType.MissileSilo)).toHaveLength(1);
// Player A sends a bomb to player B
constructionExecution(game, playerA, 0, 11, UnitType.AtomBomb, 3);
expect(playerA.units(UnitType.AtomBomb)).toHaveLength(0);
// Now wait for immunity to end
waitForImmunityToEnd();
// And send the exact same order
constructionExecution(game, playerA, 0, 11, UnitType.AtomBomb, 3);
expect(playerA.units(UnitType.AtomBomb)).toHaveLength(1);
});
});