Files
OpenFrontIO/tests/Attack.test.ts
T
VariableVince 07e13b3479 Fix: remove alliances on death (#3168)
## Description:

- Remove alliances on death: after death, alliances would stay active
including countdown timers and (when dead player kept spectating) icons.
Now remove them when player becomes inActive.

- Moved code to private method within PlayerExecution + added comments
in NationExecution and BotExecution for more clarity as to where
removals are performed from at death

- Remove renewal request from Events Display when Alliance doesn't exist
anymore (after death or otherwise).

- Also cleanup this.alliancesCheckedAt when alliance doesn't exist
anymore. Before, old/broken alliance id's would accumulate in it during
a game.

- Removed now-redundant isAlive check in EventsDisplay. Both the
alliances array as the isAlive are updated in the same tick from
PlayerUpdates so now alliance is removed from alliances array on player
death, the other.isAlive() check is no longer needed. Of course we could
keep it in just to be very safe, so just let me know when you're
doubtful about this.

- Attack.test.ts: fix failing test. Player B dies because of the attack,
meaning the alliance now gets removed. Prevent this by gving both a
different, adjecent, starting tile. And to be more clear about what is
needed for the test to pass, add isAlive check for both of them after
the attacks.

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

tryout33
2026-02-12 11:01:08 -08:00

464 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, troops: number) {
game.addExecution(new TransportShipExecution(defender, target, troops));
}
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), 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), 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, 11));
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();
}
expect(playerA.isAlive()).toBe(true);
expect(playerB.isAlive()).toBe(true);
// 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(7, 15));
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 - 2; i++) {
game.executeNextTick();
}
// Player A attacks Player B
game.addExecution(new AttackExecution(null, playerA, playerB.id(), null));
game.executeNextTick(); // ticks === immunityPhaseTicks - 1 here
// Attack is not possible during immunity
expect(playerA.outgoingAttacks()).toHaveLength(0);
// Retry after the immunity is over
game.executeNextTick(); // ticks === immunityPhaseTicks
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, game.ref(7, 15), 10));
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, game.ref(7, 15), 10));
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);
});
});