Files
OpenFrontIO/tests/Attack.test.ts
T
FloPinguin 86e51ab790 Fix nation spawnkilling 🔧 (#3222)
## Description:

As far as I can remember, in v28 the spawn immunity applied to both
humans and nations.
With the configurable spawn immunity (added for v29) the spawn immunity
no longer applies to nations... Because its called PVP immunity now.
So right now it's possible to spawnkill nations. This is a big problem
for the 5M gold modifier games... And you can "cheat" in singleplayer.

This PR changes two things:
- Nations always have 5 seconds spawn immunity now, no matter whats
configured for the PVP immunity
- Nations attack TerraNullius earlier (Otherwise the easy nations would
sometimes do their first attack after the 5 seconds are over, spawnkills
would still be possible)

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

---------

Co-authored-by: Ryan <7389646+ryanbarlow97@users.noreply.github.com>
2026-02-17 00:19:36 +00:00

482 lines
15 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 not be able to attack nations during nation immunity phase", async () => {
(game.config() as TestConfig).setNationSpawnImmunityDuration(
immunityPhaseTicks,
);
const nationId = "nation_id";
const nation = new PlayerInfo("nation", PlayerType.Nation, null, nationId);
game.addPlayer(nation);
// Player A attacks the nation during nation immunity
const attackExecution = new AttackExecution(null, playerA, nationId, null);
game.addExecution(attackExecution);
game.executeNextTick();
expect(playerA.outgoingAttacks()).toHaveLength(0);
});
test("Should be able to attack nations after nation immunity phase", async () => {
(game.config() as TestConfig).setNationSpawnImmunityDuration(
immunityPhaseTicks,
);
const nationId = "nation_id";
const nation = new PlayerInfo("nation", PlayerType.Nation, null, nationId);
game.addPlayer(nation);
waitForImmunityToEnd();
// Player A attacks the nation after immunity
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);
});
});