diff --git a/index.html b/index.html index 2e944fe44..71912e937 100644 --- a/index.html +++ b/index.html @@ -456,6 +456,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/LocalServer.ts b/src/client/LocalServer.ts index 100bbc3f9..2514dc695 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -132,7 +132,10 @@ export class LocalServer { if (!this.lobbyConfig.gameRecord) { if (clientMsg.turnNumber % 100 === 0) { // In singleplayer, only store hash every 100 turns to reduce size of game record. - this.turns[clientMsg.turnNumber].hash = clientMsg.hash; + const turn = this.turns[clientMsg.turnNumber]; + if (turn) { + turn.hash = clientMsg.hash; + } } return; } diff --git a/src/client/components/FluentSlider.ts b/src/client/components/FluentSlider.ts index 56957d49c..cf2dedd6f 100644 --- a/src/client/components/FluentSlider.ts +++ b/src/client/components/FluentSlider.ts @@ -84,7 +84,7 @@ export class FluentSlider extends LitElement { this.dispatchValueChange(); } - private handleNumberChange(e: Event) { + private handleNumberInput(e: Event) { const target = e.target as HTMLInputElement; let val = target.valueAsNumber; if (isNaN(val)) { @@ -93,11 +93,19 @@ export class FluentSlider extends LitElement { if (val < this.min) val = this.min; if (val > this.max) val = this.max; this.value = val; + // Don't dispatch value change on every input - only on blur/enter + } + + private handleNumberComplete() { + // Dispatch the value change when editing is complete this.dispatchValueChange(); } private handleNumberKeyDown(e: KeyboardEvent) { - if (e.key === "Enter") this.isEditing = false; + if (e.key === "Enter") { + this.isEditing = false; + this.handleNumberComplete(); + } } private enableEditing() { @@ -125,8 +133,11 @@ export class FluentSlider extends LitElement { .min=${this.min} .max=${this.max} .valueAsNumber=${this.value} - @input=${this.handleNumberChange} - @blur=${() => (this.isEditing = false)} + @input=${this.handleNumberInput} + @blur=${() => { + this.isEditing = false; + this.handleNumberComplete(); + }} @keydown=${this.handleNumberKeyDown} />` : html`= 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..4cd25161b 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -87,7 +87,7 @@ const numPlayersConfig = { [GameMapType.Lemnos]: [20, 15, 10], [GameMapType.TwoLakes]: [60, 50, 40], [GameMapType.StraitOfHormuz]: [40, 36, 30], - [GameMapType.Surrounded]: [56, 28, 14], // 4, 2, 1 players per island + [GameMapType.Surrounded]: [42, 28, 14], // 3, 2, 1 player(s) per island } as const satisfies Record; export abstract class DefaultServerConfig implements ServerConfig { @@ -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(