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
+1
View File
@@ -179,6 +179,7 @@ export const GameConfigSchema = z.object({
randomSpawn: z.boolean(),
maxPlayers: z.number().optional(),
maxTimerValue: z.number().int().min(1).max(120).optional(),
spawnImmunityDuration: z.number().int().min(0).optional(), // In ticks
disabledUnits: z.enum(UnitType).array().optional(),
playerTeams: TeamCountConfigSchema.optional(),
});
+1 -1
View File
@@ -246,7 +246,7 @@ export class DefaultConfig implements Config {
return 30 * 10; // 30 seconds
}
spawnImmunityDuration(): Tick {
return 5 * 10;
return this._gameConfig.spawnImmunityDuration ?? 5 * 10; // default to 5 seconds
}
gameConfig(): GameConfig {
+3 -10
View File
@@ -92,16 +92,9 @@ export class AttackExecution implements Execution {
}
}
if (this.target.isPlayer()) {
if (
this.mg.config().numSpawnPhaseTurns() +
this.mg.config().spawnImmunityDuration() >
this.mg.ticks()
) {
console.warn("cannot attack player during immunity phase");
this.active = false;
return;
}
if (this.target.isPlayer() && !this._owner.canAttackPlayer(this.target)) {
this.active = false;
return;
}
this.startTroops ??= this.mg
@@ -93,6 +93,10 @@ export class TransportShipExecution implements Execution {
} else {
this.target = mg.player(this.targetID);
}
if (this.target.isPlayer() && !this.attacker.canAttackPlayer(this.target)) {
this.active = false;
return;
}
this.startTroops ??= this.mg
.config()
+1 -1
View File
@@ -89,7 +89,7 @@ export class WarshipExecution implements Execution {
if (
unit.owner() === this.warship.owner() ||
unit === this.warship ||
unit.owner().isFriendly(this.warship.owner(), true) ||
!this.warship.owner().canAttackPlayer(unit.owner(), true) ||
this.alreadySentShell.has(unit)
) {
continue;
+5
View File
@@ -659,6 +659,8 @@ export interface Player {
// Attacking.
canAttack(tile: TileRef): boolean;
canAttackPlayer(player: Player, treatAFKFriendly?: boolean): boolean;
isImmune(): boolean;
createAttack(
target: Player | TerraNullius,
@@ -713,6 +715,9 @@ export interface Game extends GameMap {
alliances(): MutableAlliance[];
expireAlliance(alliance: Alliance): void;
// Immunity timer
isSpawnImmunityActive(): boolean;
// Game State
ticks(): Tick;
inSpawnPhase(): boolean;
+8
View File
@@ -677,6 +677,14 @@ export class GameImpl implements Game {
});
}
public isSpawnImmunityActive(): boolean {
return (
this.config().numSpawnPhaseTurns() +
this.config().spawnImmunityDuration() >=
this.ticks()
);
}
sendEmojiUpdate(msg: EmojiMessage): void {
this.addUpdate({
type: GameUpdateType.Emoji,
+22 -14
View File
@@ -1010,6 +1010,9 @@ export class PlayerImpl implements Player {
}
nukeSpawn(tile: TileRef): TileRef | false {
if (this.mg.isSpawnImmunityActive()) {
return false;
}
const owner = this.mg.owner(tile);
if (owner.isPlayer()) {
if (this.isOnSameTeam(owner)) {
@@ -1200,31 +1203,36 @@ export class PlayerImpl implements Player {
return this._incomingAttacks;
}
public isImmune(): boolean {
return this.type() === PlayerType.Human && this.mg.isSpawnImmunityActive();
}
public canAttackPlayer(
player: Player,
treatAFKFriendly: boolean = false,
): boolean {
if (this.type() === PlayerType.Human) {
return !player.isImmune() && !this.isFriendly(player, treatAFKFriendly);
}
// Only humans are affected by immunity, bots and nations should be able to attack freely
return !this.isFriendly(player, treatAFKFriendly);
}
public canAttack(tile: TileRef): boolean {
if (
this.mg.hasOwner(tile) &&
this.mg.config().numSpawnPhaseTurns() +
this.mg.config().spawnImmunityDuration() >
this.mg.ticks()
) {
const owner = this.mg.owner(tile);
if (owner === this) {
return false;
}
if (this.mg.owner(tile) === this) {
if (owner.isPlayer() && !this.canAttackPlayer(owner)) {
return false;
}
const other = this.mg.owner(tile);
if (other.isPlayer()) {
if (this.isFriendly(other)) {
return false;
}
}
if (!this.mg.isLand(tile)) {
return false;
}
if (this.mg.hasOwner(tile)) {
return this.sharesBorderWith(other);
return this.sharesBorderWith(owner);
} else {
for (const t of this.mg.bfs(
tile,
+1 -1
View File
@@ -23,7 +23,7 @@ export function canBuildTransportShip(
if (other === player) {
return false;
}
if (other.isPlayer() && player.isFriendly(other)) {
if (other.isPlayer() && !player.canAttackPlayer(other)) {
return false;
}