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
+67 -1
View File
@@ -45,6 +45,8 @@ export class HostLobbyModal extends LitElement {
@state() private gameMode: GameMode = GameMode.FFA;
@state() private teamCount: TeamCountConfig = 2;
@state() private bots: number = 400;
@state() private spawnImmunity: boolean = false;
@state() private spawnImmunityDurationMinutes: number | undefined = undefined;
@state() private infiniteGold: boolean = false;
@state() private donateGold: boolean = false;
@state() private infiniteTroops: boolean = false;
@@ -514,7 +516,7 @@ export class HostLobbyModal extends LitElement {
id="end-timer-value"
min="0"
max="120"
.value=${String(this.maxTimerValue ?? "")}
.value=${String(this.maxTimerValue ?? 0)}
style="width: 60px; color: black; text-align: right; border-radius: 8px;"
@input=${this.handleMaxTimerValueChanges}
@keydown=${this.handleMaxTimerValueKeyDown}
@@ -524,6 +526,47 @@ export class HostLobbyModal extends LitElement {
${translateText("host_modal.max_timer")}
</div>
</label>
<label
for="spawn-immunity"
class="option-card ${this.spawnImmunity ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="spawn-immunity"
@change=${(e: Event) => {
const checked = (e.target as HTMLInputElement).checked;
if (!checked) {
this.spawnImmunityDurationMinutes = undefined;
}
this.spawnImmunity = checked;
this.putGameConfig();
}}
.checked=${this.spawnImmunity}
/>
${
this.spawnImmunity === false
? ""
: html`<input
type="number"
id="spawn-immunity-duration"
min="0"
max="120"
step="1"
.value=${String(
this.spawnImmunityDurationMinutes ?? 0,
)}
style="width: 60px; color: black; text-align: right; border-radius: 8px;"
@input=${this.handleSpawnImmunityDurationInput}
@keydown=${this.handleSpawnImmunityDurationKeyDown}
/>`
}
<div class="option-card-title">
<span>${translateText("host_modal.player_immunity_duration")}</span>
</div>
</label>
<hr style="width: 100%; border-top: 1px solid #444; margin: 16px 0;" />
<!-- Individual disables for structures/weapons -->
@@ -691,6 +734,23 @@ export class HostLobbyModal extends LitElement {
this.putGameConfig();
}
private handleSpawnImmunityDurationKeyDown(e: KeyboardEvent) {
if (["-", "+", "e", "E"].includes(e.key)) {
e.preventDefault();
}
}
private handleSpawnImmunityDurationInput(e: Event) {
const input = e.target as HTMLInputElement;
input.value = input.value.replace(/[eE+-]/g, "");
const value = parseInt(input.value, 10);
if (Number.isNaN(value) || value < 0 || value > 120) {
return;
}
this.spawnImmunityDurationMinutes = value;
this.putGameConfig();
}
private handleRandomSpawnChange(e: Event) {
this.randomSpawn = Boolean((e.target as HTMLInputElement).checked);
this.putGameConfig();
@@ -757,6 +817,9 @@ export class HostLobbyModal extends LitElement {
}
private async putGameConfig() {
const spawnImmunityTicks = this.spawnImmunityDurationMinutes
? this.spawnImmunityDurationMinutes * 60 * 10
: 0;
this.dispatchEvent(
new CustomEvent("update-game-config", {
detail: {
@@ -775,6 +838,9 @@ export class HostLobbyModal extends LitElement {
randomSpawn: this.randomSpawn,
gameMode: this.gameMode,
disabledUnits: this.disabledUnits,
spawnImmunityDuration: this.spawnImmunity
? spawnImmunityTicks
: undefined,
playerTeams: this.teamCount,
...(this.gameMode === GameMode.Team &&
this.teamCount === HumansVsNations