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>
This commit is contained in:
DevelopingTom
2026-01-04 05:04:48 +01:00
committed by GitHub
parent ab5b044362
commit af0b8a8d50
19 changed files with 385 additions and 33 deletions
+156 -4
View File
@@ -27,6 +27,13 @@ function sendBoat(target: TileRef, source: TileRef, troops: number) {
);
}
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", {
@@ -185,7 +192,7 @@ describe("Attack race condition with alliance requests", () => {
}
});
it("should not mark attacker as traitor when alliance is formed after attack starts", async () => {
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();
@@ -229,7 +236,7 @@ describe("Attack race condition with alliance requests", () => {
expect(playerB.outgoingAttacks()).toHaveLength(0);
});
it("should prevent player from attacking allied player", async () => {
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) {
@@ -261,7 +268,7 @@ describe("Attack race condition with alliance requests", () => {
expect(playerB.incomingAttacks()).toHaveLength(0);
});
test("should cancel alliance requests if the recipient attacks", async () => {
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();
@@ -285,7 +292,7 @@ describe("Attack race condition with alliance requests", () => {
expect(playerB.incomingAllianceRequests()).toHaveLength(0);
});
test("should cancel the proper alliance request among many", async () => {
test("Should cancel the proper alliance request among many", async () => {
// Add a new player to have more alliance requests
const playerCInfo = new PlayerInfo(
"playerB",
@@ -324,3 +331,148 @@ describe("Attack race condition with alliance requests", () => {
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);
});
});
+6 -1
View File
@@ -12,6 +12,7 @@ import { TileRef } from "../../src/core/game/GameMap";
export class TestConfig extends DefaultConfig {
private _proximityBonusPortsNb: number = 0;
private _defaultNukeSpeed: number = 4;
private _spawnImmunityDuration: number = 0;
radiusPortSpawn(): number {
return 1;
@@ -54,8 +55,12 @@ export class TestConfig extends DefaultConfig {
return 20;
}
setSpawnImmunityDuration(duration: Tick) {
this._spawnImmunityDuration = duration;
}
spawnImmunityDuration(): Tick {
return 0;
return this._spawnImmunityDuration;
}
attackLogic(