From af0b8a8d50f625f11dbc5bdd209ec92c804c2a5d Mon Sep 17 00:00:00 2001 From: DevelopingTom Date: Sun, 4 Jan 2026 05:04:48 +0100 Subject: [PATCH] Configurable immunity timer (#2763) ## Description: Resolve discussions about stalled PR https://github.com/openfrontio/OpenFrontIO/pull/2460 image 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: image ## 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 --- index.html | 1 + resources/lang/debug.json | 1 + resources/lang/en.json | 1 + src/client/HostLobbyModal.ts | 68 +++++++- src/client/graphics/GameRenderer.ts | 10 ++ src/client/graphics/layers/ImmunityTimer.ts | 93 +++++++++++ src/core/Schemas.ts | 1 + src/core/configuration/DefaultConfig.ts | 2 +- src/core/execution/AttackExecution.ts | 13 +- src/core/execution/TransportShipExecution.ts | 4 + src/core/execution/WarshipExecution.ts | 2 +- src/core/game/Game.ts | 5 + src/core/game/GameImpl.ts | 8 + src/core/game/PlayerImpl.ts | 36 +++-- src/core/game/TransportShipUtils.ts | 2 +- src/server/GameServer.ts | 3 + src/server/MapPlaylist.ts | 1 + tests/Attack.test.ts | 160 ++++++++++++++++++- tests/util/TestConfig.ts | 7 +- 19 files changed, 385 insertions(+), 33 deletions(-) create mode 100644 src/client/graphics/layers/ImmunityTimer.ts diff --git a/index.html b/index.html index a3105a2a9..2ab3f22a6 100644 --- a/index.html +++ b/index.html @@ -451,6 +451,7 @@ + diff --git a/resources/lang/debug.json b/resources/lang/debug.json index 924720ce3..1e7210a03 100644 --- a/resources/lang/debug.json +++ b/resources/lang/debug.json @@ -145,6 +145,7 @@ "options_title": "host_modal.options_title", "bots": "host_modal.bots", "bots_disabled": "host_modal.bots_disabled", + "player_immunity_duration": "host_modal.player_immunity_duration", "disable_nations": "host_modal.disable_nations", "instant_build": "host_modal.instant_build", "random_spawn": "host_modal.random_spawn", diff --git a/resources/lang/en.json b/resources/lang/en.json index a4f363404..0e1e5fd53 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -307,6 +307,7 @@ "options_title": "Options", "bots": "Bots: ", "bots_disabled": "Disabled", + "player_immunity_duration": "PVP immunity duration (minutes)", "nations": "Nations: ", "disable_nations": "Disable Nations", "max_timer": "Game length (minutes)", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index d730a820a..1853d5c28 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -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")} + + +
@@ -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 diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index ffb642ab7..dd4cf64b4 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -18,6 +18,7 @@ import { FxLayer } from "./layers/FxLayer"; import { GameLeftSidebar } from "./layers/GameLeftSidebar"; import { GameRightSidebar } from "./layers/GameRightSidebar"; import { HeadsUpMessage } from "./layers/HeadsUpMessage"; +import { ImmunityTimer } from "./layers/ImmunityTimer"; import { Layer } from "./layers/Layer"; import { Leaderboard } from "./layers/Leaderboard"; import { MainRadialMenu } from "./layers/MainRadialMenu"; @@ -234,6 +235,14 @@ export function createRenderer( spawnTimer.game = game; spawnTimer.transformHandler = transformHandler; + const immunityTimer = document.querySelector( + "immunity-timer", + ) as ImmunityTimer; + if (!(immunityTimer instanceof ImmunityTimer)) { + console.error("immunity timer not found"); + } + immunityTimer.game = game; + // When updating these layers please be mindful of the order. // Try to group layers by the return value of shouldTransform. // Not grouping the layers may cause excessive calls to context.save() and context.restore(). @@ -262,6 +271,7 @@ export function createRenderer( playerPanel, ), spawnTimer, + immunityTimer, leaderboard, gameLeftSidebar, unitDisplay, diff --git a/src/client/graphics/layers/ImmunityTimer.ts b/src/client/graphics/layers/ImmunityTimer.ts new file mode 100644 index 000000000..702a08b38 --- /dev/null +++ b/src/client/graphics/layers/ImmunityTimer.ts @@ -0,0 +1,93 @@ +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { GameMode } from "../../../core/game/Game"; +import { GameView } from "../../../core/game/GameView"; +import { Layer } from "./Layer"; + +@customElement("immunity-timer") +export class ImmunityTimer extends LitElement implements Layer { + public game: GameView; + + private isVisible = false; + private isActive = false; + private progressRatio = 0; + + createRenderRoot() { + this.style.position = "fixed"; + this.style.top = "0"; + this.style.left = "0"; + this.style.width = "100%"; + this.style.height = "7px"; + this.style.zIndex = "1000"; + this.style.pointerEvents = "none"; + return this; + } + + init() { + this.isVisible = true; + } + + tick() { + if (!this.game || !this.isVisible) { + return; + } + + const showTeamOwnershipBar = + this.game.config().gameConfig().gameMode === GameMode.Team && + !this.game.inSpawnPhase(); + + this.style.top = showTeamOwnershipBar ? "7px" : "0px"; + + const immunityDuration = this.game.config().spawnImmunityDuration(); + const spawnPhaseTurns = this.game.config().numSpawnPhaseTurns(); + + if (immunityDuration <= 5 * 10 || this.game.inSpawnPhase()) { + this.setInactive(); + return; + } + + const immunityEnd = spawnPhaseTurns + immunityDuration; + const ticks = this.game.ticks(); + + if (ticks >= immunityEnd || ticks < spawnPhaseTurns) { + this.setInactive(); + return; + } + + const elapsedTicks = Math.max(0, ticks - spawnPhaseTurns); + this.progressRatio = Math.min( + 1, + Math.max(0, elapsedTicks / immunityDuration), + ); + this.isActive = true; + this.requestUpdate(); + } + + private setInactive() { + if (this.isActive) { + this.isActive = false; + this.requestUpdate(); + } + } + + shouldTransform(): boolean { + return false; + } + + render() { + if (!this.isVisible || !this.isActive) { + return html``; + } + + const widthPercent = this.progressRatio * 100; + + return html` +
+
+
+ `; + } +} diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index b9bc2e7a8..fc520af80 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -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(), }); diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index dca944492..64621e0a9 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -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 { diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index d123bd159..3adf24f75 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -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 diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts index 6e37e066d..761058f32 100644 --- a/src/core/execution/TransportShipExecution.ts +++ b/src/core/execution/TransportShipExecution.ts @@ -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() diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index 1ddb064cb..aa2f7522f 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -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; diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index c38b00300..ec54e4a51 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -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; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index ee0a7783a..465eeaa25 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -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, diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 7b1a8210c..09c02c5a7 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -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, diff --git a/src/core/game/TransportShipUtils.ts b/src/core/game/TransportShipUtils.ts index a0af53526..4d60f6bed 100644 --- a/src/core/game/TransportShipUtils.ts +++ b/src/core/game/TransportShipUtils.ts @@ -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; } diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 3aa8d53b6..068253920 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -121,6 +121,9 @@ export class GameServer { if (gameConfig.randomSpawn !== undefined) { this.gameConfig.randomSpawn = gameConfig.randomSpawn; } + if (gameConfig.spawnImmunityDuration !== undefined) { + this.gameConfig.spawnImmunityDuration = gameConfig.spawnImmunityDuration; + } if (gameConfig.gameMode !== undefined) { this.gameConfig.gameMode = gameConfig.gameMode; } diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 7811a7c51..6efdde53d 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -110,6 +110,7 @@ export class MapPlaylist { gameMode: mode, playerTeams, bots: 400, + spawnImmunityDuration: 5 * 10, disabledUnits: [], } satisfies GameConfig; } diff --git a/tests/Attack.test.ts b/tests/Attack.test.ts index 5715565a0..75e536f08 100644 --- a/tests/Attack.test.ts +++ b/tests/Attack.test.ts @@ -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); + }); +}); diff --git a/tests/util/TestConfig.ts b/tests/util/TestConfig.ts index 57d50558a..9e1085bc7 100644 --- a/tests/util/TestConfig.ts +++ b/tests/util/TestConfig.ts @@ -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(