Add PVP immunity to 5M starting gold modifier games 🔧 (#3180)

## Description:

Adds 30 seconds of PVP immunity to 5M starting gold modifier games.
So you cannot insta-nuke other players.

Because I'm sure people would be confused "I cannot attack!!!!" I added
a HeadsUpMessage which informs about the PVP immunity.
We already have a ImmunityTimer progress bar but I don't think its
enough.

<img width="1270" height="745" alt="image"
src="https://github.com/user-attachments/assets/0ee23dc4-1c7b-4d62-8b3d-8de214f03c2b"
/>

I had a second count in the HeadsUpMessage (seconds until PVP immunity
is over) but it felt too busy. So I removed it. You can tell when PVP
immunity is over by looking at the progress bar.

## 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: Evan <evanpelle@gmail.com>
This commit is contained in:
FloPinguin
2026-02-12 19:57:18 +01:00
committed by GitHub
parent f8c14398c8
commit 6cc0ef7d14
9 changed files with 45 additions and 9 deletions
+2 -1
View File
@@ -845,7 +845,8 @@
"choose_spawn": "Choose a starting location",
"random_spawn": "Random spawn is enabled. Selecting starting location for you...",
"singleplayer_game_paused": "Game paused",
"multiplayer_game_paused": "Game paused by Lobby Creator"
"multiplayer_game_paused": "Game paused by Lobby Creator",
"pvp_immunity_active": "PVP immunity active for {seconds}s"
},
"territory_patterns": {
"title": "Skins",
+20 -1
View File
@@ -16,6 +16,9 @@ export class HeadsUpMessage extends LitElement implements Layer {
@state()
private isPaused = false;
@state()
private isImmunityActive = false;
@state()
private toastMessage: string | import("lit").TemplateResult | null = null;
@state()
@@ -79,7 +82,18 @@ export class HeadsUpMessage extends LitElement implements Layer {
this.isPaused = pauseUpdate.paused;
}
this.isVisible = this.game.inSpawnPhase() || this.isPaused;
const showImmunityHudDuration = 10 * 10;
const spawnEnd = this.game.config().numSpawnPhaseTurns();
const ticksSinceSpawnEnd = this.game.ticks() - spawnEnd;
this.isImmunityActive =
this.game.config().hasExtendedSpawnImmunity() &&
!this.game.inSpawnPhase() &&
this.game.isSpawnImmunityActive() &&
ticksSinceSpawnEnd < showImmunityHudDuration;
this.isVisible =
this.game.inSpawnPhase() || this.isPaused || this.isImmunityActive;
this.requestUpdate();
}
@@ -91,6 +105,11 @@ export class HeadsUpMessage extends LitElement implements Layer {
return translateText("heads_up_message.multiplayer_game_paused");
}
}
if (this.isImmunityActive) {
return translateText("heads_up_message.pvp_immunity_active", {
seconds: Math.round(this.game.config().spawnImmunityDuration() / 10),
});
}
return this.game.config().isRandomSpawn()
? translateText("heads_up_message.random_spawn")
: translateText("heads_up_message.choose_spawn");
+4 -1
View File
@@ -41,7 +41,10 @@ export class ImmunityTimer extends LitElement implements Layer {
const immunityDuration = this.game.config().spawnImmunityDuration();
const spawnPhaseTurns = this.game.config().numSpawnPhaseTurns();
if (immunityDuration <= 5 * 10 || this.game.inSpawnPhase()) {
if (
!this.game.config().hasExtendedSpawnImmunity() ||
this.game.inSpawnPhase()
) {
this.setInactive();
return;
}
+1
View File
@@ -58,6 +58,7 @@ export interface NukeMagnitude {
export interface Config {
spawnImmunityDuration(): Tick;
hasExtendedSpawnImmunity(): boolean;
serverConfig(): ServerConfig;
gameConfig(): GameConfig;
theme(): Theme;
+7 -1
View File
@@ -28,6 +28,7 @@ import { PastelThemeDark } from "./PastelThemeDark";
const DEFENSE_DEBUFF_MIDPOINT = 150_000;
const DEFENSE_DEBUFF_DECAY_RATE = Math.LN2 / 50000;
const DEFAULT_SPAWN_IMMUNITY_TICKS = 5 * 10;
const JwksSchema = z.object({
keys: z
@@ -163,7 +164,12 @@ export class DefaultConfig implements Config {
return 30 * 10; // 30 seconds
}
spawnImmunityDuration(): Tick {
return this._gameConfig.spawnImmunityDuration ?? 5 * 10; // default to 5 seconds
return (
this._gameConfig.spawnImmunityDuration ?? DEFAULT_SPAWN_IMMUNITY_TICKS
);
}
hasExtendedSpawnImmunity(): boolean {
return this.spawnImmunityDuration() > DEFAULT_SPAWN_IMMUNITY_TICKS;
}
gameConfig(): GameConfig {
+1 -1
View File
@@ -722,7 +722,7 @@ export class GameImpl implements Game {
public isSpawnImmunityActive(): boolean {
return (
this.config().numSpawnPhaseTurns() +
this.config().spawnImmunityDuration() >=
this.config().spawnImmunityDuration() >
this.ticks()
);
}
+6
View File
@@ -813,6 +813,12 @@ export class GameView implements GameMap {
inSpawnPhase(): boolean {
return this.ticks() <= this._config.numSpawnPhaseTurns();
}
isSpawnImmunityActive(): boolean {
return (
this._config.numSpawnPhaseTurns() + this._config.spawnImmunityDuration() >
this.ticks()
);
}
config(): Config {
return this._config;
}
+1 -1
View File
@@ -160,7 +160,7 @@ export class MapPlaylist {
gameMode: mode,
playerTeams,
bots: isCompact ? 100 : 400,
spawnImmunityDuration: 5 * 10,
spawnImmunityDuration: startingGold ? 30 * 10 : 5 * 10,
disabledUnits: [],
} satisfies GameConfig;
}
+3 -3
View File
@@ -391,17 +391,17 @@ describe("Attack immunity", () => {
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++) {
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 here
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 + 1
game.executeNextTick(); // ticks === immunityPhaseTicks
game.addExecution(new AttackExecution(null, playerA, playerB.id(), null));
game.executeNextTick();
// Attack is now possible right after