From 6cc0ef7d148c2189442da77c7f53cc635b9595e0 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:57:18 +0100 Subject: [PATCH] =?UTF-8?q?Add=20PVP=20immunity=20to=205M=20starting=20gol?= =?UTF-8?q?d=20modifier=20games=20=F0=9F=94=A7=20(#3180)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. image 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 --- resources/lang/en.json | 3 ++- src/client/graphics/layers/HeadsUpMessage.ts | 21 +++++++++++++++++++- src/client/graphics/layers/ImmunityTimer.ts | 5 ++++- src/core/configuration/Config.ts | 1 + src/core/configuration/DefaultConfig.ts | 8 +++++++- src/core/game/GameImpl.ts | 2 +- src/core/game/GameView.ts | 6 ++++++ src/server/MapPlaylist.ts | 2 +- tests/Attack.test.ts | 6 +++--- 9 files changed, 45 insertions(+), 9 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index a29df60a2..546323c80 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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", diff --git a/src/client/graphics/layers/HeadsUpMessage.ts b/src/client/graphics/layers/HeadsUpMessage.ts index 0239e2f15..a0b0937ce 100644 --- a/src/client/graphics/layers/HeadsUpMessage.ts +++ b/src/client/graphics/layers/HeadsUpMessage.ts @@ -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"); diff --git a/src/client/graphics/layers/ImmunityTimer.ts b/src/client/graphics/layers/ImmunityTimer.ts index 29f66fbac..cfb44ae2b 100644 --- a/src/client/graphics/layers/ImmunityTimer.ts +++ b/src/client/graphics/layers/ImmunityTimer.ts @@ -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; } diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index ac1d9ee4a..8595b3a21 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -58,6 +58,7 @@ export interface NukeMagnitude { export interface Config { spawnImmunityDuration(): Tick; + hasExtendedSpawnImmunity(): boolean; serverConfig(): ServerConfig; gameConfig(): GameConfig; theme(): Theme; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 3cb2e1af8..5a672f296 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -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 { diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 6dd76beff..cab7e6061 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -722,7 +722,7 @@ export class GameImpl implements Game { public isSpawnImmunityActive(): boolean { return ( this.config().numSpawnPhaseTurns() + - this.config().spawnImmunityDuration() >= + this.config().spawnImmunityDuration() > this.ticks() ); } diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 65a5b74d8..d66b9361b 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -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; } diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index ed80175c1..0abbf4ed2 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -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; } diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts index e2bf619dc..8659a809d 100644 --- a/tests/Attack.test.ts +++ b/tests/Attack.test.ts @@ -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