From 5a833dd9e48b6cb8b61f55aa9a5f87ad89fc8e6f Mon Sep 17 00:00:00 2001 From: jrouillard Date: Fri, 13 Jun 2025 01:53:53 +0200 Subject: [PATCH 01/11] Add max timer condition --- resources/lang/en.json | 2 + src/client/HostLobbyModal.ts | 55 +++++++++++ src/client/SinglePlayerModal.ts | 51 +++++++++++ src/client/graphics/layers/OptionsMenu.ts | 22 ++++- src/core/Schemas.ts | 1 + src/core/execution/WinCheckExecution.ts | 18 +++- src/server/GameManager.ts | 1 + src/server/GameServer.ts | 3 + src/server/MapPlaylist.ts | 1 + .../core/execution/WinCheckExecution.test.ts | 91 +++++++++++++++++++ 10 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 tests/core/execution/WinCheckExecution.test.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index d3b9502155..8abfe2a574 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -121,6 +121,7 @@ "instant_build": "Instant build", "infinite_gold": "Infinite gold", "infinite_troops": "Infinite troops", + "max_timer": "Max timer (minutes)", "disable_nukes": "Disable Nukes", "enables_title": "Enable Settings", "start": "Start Game" @@ -189,6 +190,7 @@ "bots": "Bots: ", "bots_disabled": "Disabled", "disable_nations": "Disable Nations", + "max_timer": "Max timer (minutes)", "instant_build": "Instant build", "infinite_gold": "Infinite gold", "infinite_troops": "Infinite troops", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 41447146f4..0b9f75e9fa 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -34,6 +34,8 @@ export class HostLobbyModal extends LitElement { @state() private bots: number = 400; @state() private infiniteGold: boolean = false; @state() private infiniteTroops: boolean = false; + @state() private maxTimer: boolean = false; + @state() private maxTimerValue: number | undefined = undefined; @state() private instantBuild: boolean = false; @state() private lobbyId = ""; @state() private copySuccess = false; @@ -303,6 +305,38 @@ export class HostLobbyModal extends LitElement { +
@@ -454,6 +488,25 @@ export class HostLobbyModal extends LitElement { this.putGameConfig(); } + private handleMaxTimerValueKeyDown(e: KeyboardEvent) { + if (["-", "+", "e"].includes(e.key)) { + e.preventDefault(); + } + } + + private handleMaxTimerValueChanges(e: Event) { + (e.target as HTMLInputElement).value = ( + e.target as HTMLInputElement + ).value.replace(/[e\+\-]/gi, ""); + const value = parseInt((e.target as HTMLInputElement).value); + + if (isNaN(value) || value < 0 || value > 400) { + return; + } + this.maxTimerValue = value; + this.putGameConfig(); + } + private async handleDisableNPCsChange(e: Event) { this.disableNPCs = Boolean((e.target as HTMLInputElement).checked); console.log(`updating disable npcs to ${this.disableNPCs}`); @@ -490,6 +543,8 @@ export class HostLobbyModal extends LitElement { gameMode: this.gameMode, disabledUnits: this.disabledUnits, playerTeams: this.teamCount, + maxTimerValue: + this.maxTimer === true ? this.maxTimerValue : undefined, } satisfies Partial), }, ); diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index f633c1e31c..75202fec8f 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -34,6 +34,8 @@ export class SinglePlayerModal extends LitElement { @state() private bots: number = 400; @state() private infiniteGold: boolean = false; @state() private infiniteTroops: boolean = false; + @state() private maxTimer: boolean = false; + @state() private maxTimerValue: number | undefined = undefined; @state() private instantBuild: boolean = false; @state() private useRandomMap: boolean = false; @state() private gameMode: GameMode = GameMode.FFA; @@ -271,6 +273,36 @@ export class SinglePlayerModal extends LitElement { ${translateText("single_modal.infinite_troops")} +
400) { + return; + } + this.maxTimerValue = value; + } + private handleDisableNPCsChange(e: Event) { this.disableNPCs = Boolean((e.target as HTMLInputElement).checked); } @@ -419,6 +469,7 @@ export class SinglePlayerModal extends LitElement { playerTeams: this.teamCount, difficulty: this.selectedDifficulty, disableNPCs: this.disableNPCs, + maxTimerValue: this.maxTimer ? this.maxTimerValue : undefined, bots: this.bots, infiniteGold: this.infiniteGold, infiniteTroops: this.infiniteTroops, diff --git a/src/client/graphics/layers/OptionsMenu.ts b/src/client/graphics/layers/OptionsMenu.ts index 270e10f669..f2bda66fe9 100644 --- a/src/client/graphics/layers/OptionsMenu.ts +++ b/src/client/graphics/layers/OptionsMenu.ts @@ -138,11 +138,21 @@ export class OptionsMenu extends LitElement implements Layer { if (updates) { this.hasWinner = this.hasWinner || updates[GameUpdateType.Win].length > 0; } - if (this.game.inSpawnPhase()) { - this.timer = 0; - } else if (!this.hasWinner && this.game.ticks() % 10 === 0) { - this.timer++; + const maxTimerValue = this.game.config().gameConfig().maxTimerValue; + if (maxTimerValue !== undefined) { + if (this.game.inSpawnPhase()) { + this.timer = maxTimerValue * 60; + } else if (!this.hasWinner && this.game.ticks() % 10 === 0) { + this.timer = Math.max(0, this.timer - 1); + } + } else { + if (this.game.inSpawnPhase()) { + this.timer = 0; + } else if (!this.hasWinner && this.game.ticks() % 10 === 0) { + this.timer++; + } } + this.isVisible = true; this.requestUpdate(); } @@ -170,6 +180,10 @@ export class OptionsMenu extends LitElement implements Layer { class="w-[55px] h-8 lg:w-24 lg:h-10 flex items-center justify-center bg-opacity-50 bg-gray-700 text-opacity-90 text-white rounded text-sm lg:text-xl" + style="${this.game.config().gameConfig().maxTimerValue !== + undefined && this.timer < 60 + ? "color: #ff8080;" + : ""}" > ${secondsToHms(this.timer)} diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 094872c0a5..4d73b25c43 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -131,6 +131,7 @@ export const GameConfigSchema = z.object({ infiniteTroops: z.boolean(), instantBuild: z.boolean(), maxPlayers: z.number().optional(), + maxTimerValue: z.number().optional(), disabledUnits: z.nativeEnum(UnitType).array().optional(), playerTeams: z.union([z.number().optional(), z.literal(Duos)]), }); diff --git a/src/core/execution/WinCheckExecution.ts b/src/core/execution/WinCheckExecution.ts index 8c2159a71c..647e1afb4c 100644 --- a/src/core/execution/WinCheckExecution.ts +++ b/src/core/execution/WinCheckExecution.ts @@ -16,11 +16,16 @@ export class WinCheckExecution implements Execution { private active = true; private mg: Game | null = null; + private timer: number; constructor() {} init(mg: Game, ticks: number) { this.mg = mg; + const maxTimerValue = this.mg.config().gameConfig().maxTimerValue; + if (maxTimerValue !== undefined) { + this.timer = maxTimerValue * 60; + } } tick(ticks: number) { @@ -28,6 +33,11 @@ export class WinCheckExecution implements Execution { return; } if (this.mg === null) throw new Error("Not initialized"); + + if (this.timer !== undefined) { + this.timer = Math.max(0, this.timer - 1); + } + if (this.mg.config().gameConfig().gameMode === GameMode.FFA) { this.checkWinnerFFA(); } else { @@ -48,7 +58,8 @@ export class WinCheckExecution implements Execution { this.mg.numLandTiles() - this.mg.numTilesWithFallout(); if ( (max.numTilesOwned() / numTilesWithoutFallout) * 100 > - this.mg.config().percentageTilesOwnedToWin() + this.mg.config().percentageTilesOwnedToWin() || + this.timer === 0 ) { this.mg.setWinner(max, this.mg.stats().stats()); console.log(`${max.name()} has won the game`); @@ -78,7 +89,10 @@ export class WinCheckExecution implements Execution { const numTilesWithoutFallout = this.mg.numLandTiles() - this.mg.numTilesWithFallout(); const percentage = (max[1] / numTilesWithoutFallout) * 100; - if (percentage > this.mg.config().percentageTilesOwnedToWin()) { + if ( + percentage > this.mg.config().percentageTilesOwnedToWin() || + this.timer === 0 + ) { if (max[0] === ColoredTeams.Bot) return; this.mg.setWinner(max[0], this.mg.stats().stats()); console.log(`${max[0]} has won the game`); diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 23da548506..28989fdf07 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -36,6 +36,7 @@ export class GameManager { disableNPCs: false, infiniteGold: false, infiniteTroops: false, + maxTimerValue: undefined, instantBuild: false, gameMode: GameMode.FFA, bots: 400, diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 57fced752a..19e921dbdc 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -89,6 +89,9 @@ export class GameServer { if (gameConfig.infiniteTroops !== undefined) { this.gameConfig.infiniteTroops = gameConfig.infiniteTroops; } + if (gameConfig.maxTimerValue !== undefined) { + this.gameConfig.maxTimerValue = gameConfig.maxTimerValue; + } if (gameConfig.instantBuild !== undefined) { this.gameConfig.instantBuild = gameConfig.instantBuild; } diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index c6c07c0cee..038179e424 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -56,6 +56,7 @@ export class MapPlaylist { difficulty: Difficulty.Medium, infiniteGold: false, infiniteTroops: false, + maxTimerValue: undefined, instantBuild: false, disableNPCs: mode === GameMode.Team, gameMode: mode, diff --git a/tests/core/execution/WinCheckExecution.test.ts b/tests/core/execution/WinCheckExecution.test.ts new file mode 100644 index 0000000000..96c9a9a5be --- /dev/null +++ b/tests/core/execution/WinCheckExecution.test.ts @@ -0,0 +1,91 @@ +import { WinCheckExecution } from "../../../src/core/execution/WinCheckExecution"; +import { GameMode } from "../../../src/core/game/Game"; +import { setup } from "../../util/Setup"; + +describe("WinCheckExecution", () => { + let mg: any; + let winCheck: WinCheckExecution; + + beforeEach(async () => { + mg = await setup("BigPlains", { + infiniteGold: true, + gameMode: GameMode.FFA, + maxTimerValue: 5, + instantBuild: true, + }); + mg.setWinner = jest.fn(); + winCheck = new WinCheckExecution(); + winCheck.init(mg, 0); + }); + + it("should initialize timer if maxTimerValue is set", () => { + expect((winCheck as any).timer).toBe(300); + }); + + it("should decrement timer every 10 ticks", () => { + const initialTimer = (winCheck as any).timer; + winCheck.tick(10); + expect((winCheck as any).timer).toBe(initialTimer - 1); + }); + + it("should not decrement timer if ticks is not a multiple of 10", () => { + const initialTimer = (winCheck as any).timer; + winCheck.tick(7); + expect((winCheck as any).timer).toBe(initialTimer); + }); + + it("should call checkWinnerFFA in FFA mode", () => { + const spy = jest.spyOn(winCheck as any, "checkWinnerFFA"); + winCheck.tick(10); + expect(spy).toHaveBeenCalled(); + }); + + it("should call checkWinnerTeam in non-FFA mode", () => { + mg.config = jest.fn(() => ({ + gameConfig: jest.fn(() => ({ + maxTimerValue: 5, + gameMode: GameMode.Team, + })), + percentageTilesOwnedToWin: jest.fn(() => 50), + })); + winCheck.init(mg, 0); + const spy = jest.spyOn(winCheck as any, "checkWinnerTeam"); + winCheck.tick(10); + expect(spy).toHaveBeenCalled(); + }); + + it("should set winner in FFA if percentage is reached", () => { + const player = { + numTilesOwned: jest.fn(() => 81), + name: jest.fn(() => "P1"), + }; + mg.players = jest.fn(() => [player]); + mg.numLandTiles = jest.fn(() => 100); + mg.numTilesWithFallout = jest.fn(() => 0); + winCheck.checkWinnerFFA(); + expect(mg.setWinner).toHaveBeenCalledWith(player, expect.anything()); + }); + + it("should set winner in FFA if timer is 0", () => { + (winCheck as any).timer = 0; + const player = { + numTilesOwned: jest.fn(() => 10), + name: jest.fn(() => "P1"), + }; + mg.players = jest.fn(() => [player]); + mg.numLandTiles = jest.fn(() => 100); + mg.numTilesWithFallout = jest.fn(() => 0); + winCheck.checkWinnerFFA(); + expect(mg.setWinner).toHaveBeenCalledWith(player, {}); + }); + + it("should not set winner if no players", () => { + mg.players = jest.fn(() => []); + winCheck.checkWinnerFFA(); + expect(mg.setWinner).not.toHaveBeenCalled(); + }); + + it("should return false for activeDuringSpawnPhase", () => { + expect(winCheck.activeDuringSpawnPhase()).toBe(false); + }); +}); From 7d84ace3658ce8bda67febe05018d28993c8607c Mon Sep 17 00:00:00 2001 From: jrouillard Date: Fri, 13 Jun 2025 02:17:04 +0200 Subject: [PATCH 02/11] Code rabit reviews --- src/client/HostLobbyModal.ts | 9 +++++++-- src/client/SinglePlayerModal.ts | 5 ++++- src/core/Schemas.ts | 2 +- src/core/execution/WinCheckExecution.ts | 3 ++- tests/core/execution/WinCheckExecution.test.ts | 3 ++- 5 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 0b9f75e9fa..7fe0b5744e 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -319,20 +319,25 @@ export class HostLobbyModal extends LitElement { this.maxTimerValue = undefined; } this.maxTimer = checked; + this.putGameConfig(); }} .checked=${this.maxTimer} /> ${ this.maxTimer === false ? "" - : html` ` } + }
${translateText("host_modal.max_timer")}
diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 75202fec8f..2439e32d3f 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -292,9 +292,12 @@ export class SinglePlayerModal extends LitElement { /> ${this.maxTimer === false ? "" - : html` { mg.players = jest.fn(() => [player]); mg.numLandTiles = jest.fn(() => 100); mg.numTilesWithFallout = jest.fn(() => 0); + mg.stats = jest.fn(() => ({ stats: () => ({ mocked: true }) })); winCheck.checkWinnerFFA(); - expect(mg.setWinner).toHaveBeenCalledWith(player, {}); + expect(mg.setWinner).toHaveBeenCalledWith(player, expect.any(Object)); }); it("should not set winner if no players", () => { From d5aaca7b9a654cd9e03cc6b7afd4d1b9138c2b7c Mon Sep 17 00:00:00 2001 From: jrouillard Date: Sat, 28 Jun 2025 00:16:15 +0200 Subject: [PATCH 03/11] change max timer to 2h --- src/client/HostLobbyModal.ts | 2 +- src/client/SinglePlayerModal.ts | 2 +- src/core/Schemas.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 37e2c0b517..d9d8aca1a2 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -330,7 +330,7 @@ export class HostLobbyModal extends LitElement { type="number" id="end-timer-value" min="0" - max="400" + max="120" .value=${String(this.maxTimerValue ?? "")} style="width: 60px; color: black; text-align: right; border-radius: 8px;" @input=${this.handleMaxTimerValueChanges} diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 593eb64a46..8bff9a22f1 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -299,7 +299,7 @@ export class SinglePlayerModal extends LitElement { type="number" id="end-timer-value" min="0" - max="400" + max="120" .value=${String(this.maxTimerValue ?? "")} style="width: 60px; color: black; text-align: right; border-radius: 8px;" @input=${this.handleMaxTimerValueChanges} diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 0dbce1d533..9b57b82c39 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -140,7 +140,7 @@ export const GameConfigSchema = z.object({ infiniteTroops: z.boolean(), instantBuild: z.boolean(), maxPlayers: z.number().optional(), - maxTimerValue: z.number().int().min(1).max(400).optional(), + maxTimerValue: z.number().int().min(1).max(120).optional(), disabledUnits: z.enum(UnitType).array().optional(), playerTeams: z.union([z.number(), z.literal(Duos)]).optional(), }); From eb64a3d51ceb5a9a0f1eabb23503f5f7089f2b31 Mon Sep 17 00:00:00 2001 From: jrouillard Date: Sat, 28 Jun 2025 00:42:31 +0200 Subject: [PATCH 04/11] code reviews --- src/client/HostLobbyModal.ts | 3 +-- src/client/SinglePlayerModal.ts | 2 +- tests/core/{executions => execution}/NukeExecution.test.ts | 0 .../{executions => execution}/SAMLauncherExecution.test.ts | 0 tests/core/execution/WinCheckExecution.test.ts | 4 ++-- 5 files changed, 4 insertions(+), 5 deletions(-) rename tests/core/{executions => execution}/NukeExecution.test.ts (100%) rename tests/core/{executions => execution}/SAMLauncherExecution.test.ts (100%) diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index d9d8aca1a2..d09166b525 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -337,7 +337,6 @@ export class HostLobbyModal extends LitElement { @keydown=${this.handleMaxTimerValueKeyDown} />` } - }
${translateText("host_modal.max_timer")}
@@ -505,7 +504,7 @@ export class HostLobbyModal extends LitElement { ).value.replace(/[e\+\-]/gi, ""); const value = parseInt((e.target as HTMLInputElement).value); - if (isNaN(value) || value < 0 || value > 400) { + if (isNaN(value) || value < 0 || value > 120) { return; } this.maxTimerValue = value; diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 8bff9a22f1..d79feca934 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -397,7 +397,7 @@ export class SinglePlayerModal extends LitElement { ).value.replace(/[e\+\-]/gi, ""); const value = parseInt((e.target as HTMLInputElement).value); - if (isNaN(value) || value < 0 || value > 400) { + if (isNaN(value) || value < 0 || value > 120) { return; } this.maxTimerValue = value; diff --git a/tests/core/executions/NukeExecution.test.ts b/tests/core/execution/NukeExecution.test.ts similarity index 100% rename from tests/core/executions/NukeExecution.test.ts rename to tests/core/execution/NukeExecution.test.ts diff --git a/tests/core/executions/SAMLauncherExecution.test.ts b/tests/core/execution/SAMLauncherExecution.test.ts similarity index 100% rename from tests/core/executions/SAMLauncherExecution.test.ts rename to tests/core/execution/SAMLauncherExecution.test.ts diff --git a/tests/core/execution/WinCheckExecution.test.ts b/tests/core/execution/WinCheckExecution.test.ts index 4691bd5c48..ba28f23d61 100644 --- a/tests/core/execution/WinCheckExecution.test.ts +++ b/tests/core/execution/WinCheckExecution.test.ts @@ -7,7 +7,7 @@ describe("WinCheckExecution", () => { let winCheck: WinCheckExecution; beforeEach(async () => { - mg = await setup("BigPlains", { + mg = await setup("big_plains", { infiniteGold: true, gameMode: GameMode.FFA, maxTimerValue: 5, @@ -67,7 +67,7 @@ describe("WinCheckExecution", () => { }); it("should set winner in FFA if timer is 0", () => { - (winCheck as any).timer = 0; + (winCheck as any).timer = 0; // Simulate timer reaching 0 const player = { numTilesOwned: jest.fn(() => 10), name: jest.fn(() => "P1"), From 2bbf0f3b0ebdc09f87627f72f45ac636afd7b1e9 Mon Sep 17 00:00:00 2001 From: jrouillard Date: Sun, 12 Oct 2025 18:30:45 +0200 Subject: [PATCH 05/11] remove files --- tests/core/execution/PlayerExecution.test.ts | 95 -------------- .../core/execution/TradeShipExecution.test.ts | 122 ------------------ 2 files changed, 217 deletions(-) delete mode 100644 tests/core/execution/PlayerExecution.test.ts delete mode 100644 tests/core/execution/TradeShipExecution.test.ts diff --git a/tests/core/execution/PlayerExecution.test.ts b/tests/core/execution/PlayerExecution.test.ts deleted file mode 100644 index bbb74b32b4..0000000000 --- a/tests/core/execution/PlayerExecution.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { PlayerExecution } from "../../../src/core/execution/PlayerExecution"; -import { - Game, - Player, - PlayerInfo, - PlayerType, - UnitType, -} from "../../../src/core/game/Game"; -import { setup } from "../../util/Setup"; -import { executeTicks } from "../../util/utils"; - -let game: Game; -let player: Player; -let otherPlayer: Player; - -describe("PlayerExecution", () => { - beforeEach(async () => { - game = await setup( - "big_plains", - { - infiniteGold: true, - instantBuild: true, - }, - [ - new PlayerInfo("player", PlayerType.Human, "client_id1", "player_id"), - new PlayerInfo("other", PlayerType.Human, "client_id2", "other_id"), - ], - ); - - while (game.inSpawnPhase()) { - game.executeNextTick(); - } - - player = game.player("player_id"); - otherPlayer = game.player("other_id"); - - game.addExecution(new PlayerExecution(player)); - game.addExecution(new PlayerExecution(otherPlayer)); - }); - - test("DefensePost lv. 1 is destroyed when tile owner changes", () => { - const tile = game.ref(50, 50); - player.conquer(tile); - const defensePost = player.buildUnit(UnitType.DefensePost, tile, {}); - - game.executeNextTick(); - expect(game.unitCount(UnitType.DefensePost)).toBe(1); - expect(defensePost.level()).toBe(1); - - otherPlayer.conquer(tile); - executeTicks(game, 2); - - expect(game.unitCount(UnitType.DefensePost)).toBe(0); - }); - - test("DefensePost lv. 2+ is downgraded when tile owner changes", () => { - const tile = game.ref(50, 50); - player.conquer(tile); - const defensePost = player.buildUnit(UnitType.DefensePost, tile, {}); - defensePost.increaseLevel(); - - expect(defensePost.level()).toBe(2); - expect(game.unitCount(UnitType.DefensePost)).toBe(2); // unitCount sums levels - expect(player.units(UnitType.DefensePost)).toHaveLength(1); - expect(defensePost.isActive()).toBe(true); - - otherPlayer.conquer(tile); - executeTicks(game, 2); - - expect(defensePost.level()).toBe(1); - expect(game.unitCount(UnitType.DefensePost)).toBe(1); - expect(otherPlayer.units(UnitType.DefensePost)).toHaveLength(1); - expect(defensePost.owner()).toBe(otherPlayer); - expect(defensePost.isActive()).toBe(true); - }); - - test("Non-DefensePost structures are transferred (not downgraded) when tile owner changes", () => { - const tile = game.ref(50, 50); - player.conquer(tile); - const city = player.buildUnit(UnitType.City, tile, {}); - - expect(game.unitCount(UnitType.City)).toBe(1); - expect(city.level()).toBe(1); - expect(city.owner()).toBe(player); - expect(city.isActive()).toBe(true); - - otherPlayer.conquer(tile); - executeTicks(game, 2); - - expect(game.unitCount(UnitType.City)).toBe(1); - expect(city.level()).toBe(1); - expect(city.owner()).toBe(otherPlayer); - expect(city.isActive()).toBe(true); - }); -}); diff --git a/tests/core/execution/TradeShipExecution.test.ts b/tests/core/execution/TradeShipExecution.test.ts deleted file mode 100644 index 0aab3fbcee..0000000000 --- a/tests/core/execution/TradeShipExecution.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { TradeShipExecution } from "../../../src/core/execution/TradeShipExecution"; -import { Game, Player, Unit } from "../../../src/core/game/Game"; -import { setup } from "../../util/Setup"; - -describe("TradeShipExecution", () => { - let game: Game; - let origOwner: Player; - let dstOwner: Player; - let pirate: Player; - let srcPort: Unit; - let piratePort: Unit; - let tradeShip: Unit; - let dstPort: Unit; - let tradeShipExecution: TradeShipExecution; - - beforeEach(async () => { - // Mock Game, Player, Unit, and required methods - - game = await setup("ocean_and_land", { - infiniteGold: true, - instantBuild: true, - }); - game.displayMessage = jest.fn(); - origOwner = { - canBuild: jest.fn(() => true), - buildUnit: jest.fn((type, spawn, opts) => tradeShip), - displayName: jest.fn(() => "Origin"), - addGold: jest.fn(), - units: jest.fn(() => [dstPort]), - unitCount: jest.fn(() => 1), - id: jest.fn(() => 1), - canTrade: jest.fn(() => true), - } as any; - - dstOwner = { - id: jest.fn(() => 2), - addGold: jest.fn(), - displayName: jest.fn(() => "Destination"), - units: jest.fn(() => [dstPort]), - unitCount: jest.fn(() => 1), - canTrade: jest.fn(() => true), - } as any; - - pirate = { - id: jest.fn(() => 3), - addGold: jest.fn(), - displayName: jest.fn(() => "Destination"), - units: jest.fn(() => [piratePort]), - unitCount: jest.fn(() => 1), - canTrade: jest.fn(() => true), - } as any; - - piratePort = { - tile: jest.fn(() => 40011), - owner: jest.fn(() => pirate), - isActive: jest.fn(() => true), - } as any; - - srcPort = { - tile: jest.fn(() => 20011), - owner: jest.fn(() => origOwner), - isActive: jest.fn(() => true), - } as any; - - dstPort = { - tile: jest.fn(() => 30015), // 15x15 - owner: jest.fn(() => dstOwner), - isActive: jest.fn(() => true), - } as any; - - tradeShip = { - isActive: jest.fn(() => true), - owner: jest.fn(() => origOwner), - move: jest.fn(), - setTargetUnit: jest.fn(), - setSafeFromPirates: jest.fn(), - delete: jest.fn(), - tile: jest.fn(() => 2001), - } as any; - - tradeShipExecution = new TradeShipExecution(origOwner, srcPort, dstPort); - tradeShipExecution.init(game, 0); - tradeShipExecution["pathFinder"] = { - nextTile: jest.fn(() => ({ type: 0, node: 2001 })), - } as any; - tradeShipExecution["tradeShip"] = tradeShip; - }); - - it("should initialize and tick without errors", () => { - tradeShipExecution.tick(1); - expect(tradeShipExecution.isActive()).toBe(true); - }); - - it("should deactivate if tradeShip is not active", () => { - tradeShip.isActive = jest.fn(() => false); - tradeShipExecution.tick(1); - expect(tradeShipExecution.isActive()).toBe(false); - }); - - it("should delete ship if port owner changes to current owner", () => { - dstPort.owner = jest.fn(() => origOwner); - tradeShipExecution.tick(1); - expect(tradeShip.delete).toHaveBeenCalledWith(false); - expect(tradeShipExecution.isActive()).toBe(false); - }); - - it("should pick another port if ship is captured", () => { - tradeShip.owner = jest.fn(() => pirate); - tradeShipExecution.tick(1); - expect(tradeShip.setTargetUnit).toHaveBeenCalledWith(piratePort); - }); - - it("should complete trade and award gold", () => { - tradeShipExecution["pathFinder"] = { - nextTile: jest.fn(() => ({ type: 2, node: 2001 })), - } as any; - tradeShipExecution.tick(1); - expect(tradeShip.delete).toHaveBeenCalledWith(false); - expect(tradeShipExecution.isActive()).toBe(false); - expect(game.displayMessage).toHaveBeenCalled(); - }); -}); From 014b505849d9db6d5a99acce65162d91940244c3 Mon Sep 17 00:00:00 2001 From: Vivacious Box Date: Sun, 12 Oct 2025 18:34:21 +0200 Subject: [PATCH 06/11] Update resources/lang/en.json Co-authored-by: Loymdayddaud <145969603+TheGiraffe3@users.noreply.github.com> --- resources/lang/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index a4ed2d5738..34de417896 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -143,7 +143,7 @@ "infinite_gold": "Infinite gold", "infinite_troops": "Infinite troops", "compact_map": "Mini Map", - "max_timer": "Max timer (minutes)", + "max_timer": "Game length (minutes)", "disable_nukes": "Disable Nukes", "enables_title": "Enable Settings", "start": "Start Game" From 7bc4b40d57b27e0f2089246887bace5dc96a6fd9 Mon Sep 17 00:00:00 2001 From: jrouillard Date: Sun, 12 Oct 2025 18:34:57 +0200 Subject: [PATCH 07/11] Change label --- resources/lang/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 34de417896..787a1c1298 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -236,7 +236,7 @@ "bots": "Bots: ", "bots_disabled": "Disabled", "disable_nations": "Disable Nations", - "max_timer": "Max timer (minutes)", + "max_timer": "Game length (minutes)", "instant_build": "Instant build", "infinite_gold": "Infinite gold", "donate_gold": "Donate gold", From 873ca13ee5e4849d6971f80145b5e3f1584a97eb Mon Sep 17 00:00:00 2001 From: jrouillard Date: Sun, 12 Oct 2025 19:21:20 +0200 Subject: [PATCH 08/11] Reviews --- .../graphics/layers/GameRightSidebar.ts | 21 +++++++++++++++---- src/core/execution/WinCheckExecution.ts | 19 +++++++---------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/client/graphics/layers/GameRightSidebar.ts b/src/client/graphics/layers/GameRightSidebar.ts index 3ac7ce4500..c4215215c6 100644 --- a/src/client/graphics/layers/GameRightSidebar.ts +++ b/src/client/graphics/layers/GameRightSidebar.ts @@ -57,10 +57,19 @@ export class GameRightSidebar extends LitElement implements Layer { if (updates) { this.hasWinner = this.hasWinner || updates[GameUpdateType.Win].length > 0; } - if (this.game.inSpawnPhase()) { - this.timer = 0; - } else if (!this.hasWinner && this.game.ticks() % 10 === 0) { - this.timer++; + const maxTimerValue = this.game.config().gameConfig().maxTimerValue; + if (maxTimerValue !== undefined) { + if (this.game.inSpawnPhase()) { + this.timer = maxTimerValue * 60; + } else if (!this.hasWinner && this.game.ticks() % 10 === 0) { + this.timer = Math.max(0, this.timer - 1); + } + } else { + if (this.game.inSpawnPhase()) { + this.timer = 0; + } else if (!this.hasWinner && this.game.ticks() % 10 === 0) { + this.timer++; + } } } @@ -140,6 +149,10 @@ export class GameRightSidebar extends LitElement implements Layer {
${this.secondsToHms(this.timer)}
diff --git a/src/core/execution/WinCheckExecution.ts b/src/core/execution/WinCheckExecution.ts index 91dddd7e89..35eaa77173 100644 --- a/src/core/execution/WinCheckExecution.ts +++ b/src/core/execution/WinCheckExecution.ts @@ -17,16 +17,11 @@ export class WinCheckExecution implements Execution { private mg: Game | null = null; /** Remaining seconds – undefined when feature is disabled */ - private timer?: number; constructor() {} init(mg: Game, ticks: number) { this.mg = mg; - const maxTimerValue = this.mg.config().gameConfig().maxTimerValue; - if (maxTimerValue !== undefined) { - this.timer = maxTimerValue * 60; - } } tick(ticks: number) { @@ -35,10 +30,6 @@ export class WinCheckExecution implements Execution { } if (this.mg === null) throw new Error("Not initialized"); - if (this.timer !== undefined) { - this.timer = Math.max(0, this.timer - 1); - } - if (this.mg.config().gameConfig().gameMode === GameMode.FFA) { this.checkWinnerFFA(); } else { @@ -55,12 +46,15 @@ export class WinCheckExecution implements Execution { return; } const max = sorted[0]; + const timeElapsed = + (this.mg.ticks() - this.mg.config().numSpawnPhaseTurns()) / 10; const numTilesWithoutFallout = this.mg.numLandTiles() - this.mg.numTilesWithFallout(); if ( (max.numTilesOwned() / numTilesWithoutFallout) * 100 > this.mg.config().percentageTilesOwnedToWin() || - this.timer === 0 + (this.mg.config().gameConfig().maxTimerValue !== undefined && + timeElapsed - this.mg.config().gameConfig().maxTimerValue! * 60 >= 0) ) { this.mg.setWinner(max, this.mg.stats().stats()); console.log(`${max.name()} has won the game`); @@ -87,12 +81,15 @@ export class WinCheckExecution implements Execution { return; } const max = sorted[0]; + const timeElapsed = + (this.mg.ticks() - this.mg.config().numSpawnPhaseTurns()) / 10; const numTilesWithoutFallout = this.mg.numLandTiles() - this.mg.numTilesWithFallout(); const percentage = (max[1] / numTilesWithoutFallout) * 100; if ( percentage > this.mg.config().percentageTilesOwnedToWin() || - this.timer === 0 + (this.mg.config().gameConfig().maxTimerValue !== undefined && + timeElapsed - this.mg.config().gameConfig().maxTimerValue! * 60 >= 0) ) { if (max[0] === ColoredTeams.Bot) return; this.mg.setWinner(max[0], this.mg.stats().stats()); From f454fca16911d8763e3766d7f18e3bcd95b7dfff Mon Sep 17 00:00:00 2001 From: jrouillard Date: Sun, 12 Oct 2025 19:40:04 +0200 Subject: [PATCH 09/11] fix test --- .../core/executions/WinCheckExecution.test.ts | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/tests/core/executions/WinCheckExecution.test.ts b/tests/core/executions/WinCheckExecution.test.ts index ba28f23d61..922a5b547a 100644 --- a/tests/core/executions/WinCheckExecution.test.ts +++ b/tests/core/executions/WinCheckExecution.test.ts @@ -18,22 +18,6 @@ describe("WinCheckExecution", () => { winCheck.init(mg, 0); }); - it("should initialize timer if maxTimerValue is set", () => { - expect((winCheck as any).timer).toBe(300); - }); - - it("should decrement timer every 10 ticks", () => { - const initialTimer = (winCheck as any).timer; - winCheck.tick(10); - expect((winCheck as any).timer).toBe(initialTimer - 1); - }); - - it("should not decrement timer if ticks is not a multiple of 10", () => { - const initialTimer = (winCheck as any).timer; - winCheck.tick(7); - expect((winCheck as any).timer).toBe(initialTimer); - }); - it("should call checkWinnerFFA in FFA mode", () => { const spy = jest.spyOn(winCheck as any, "checkWinnerFFA"); winCheck.tick(10); @@ -67,7 +51,6 @@ describe("WinCheckExecution", () => { }); it("should set winner in FFA if timer is 0", () => { - (winCheck as any).timer = 0; // Simulate timer reaching 0 const player = { numTilesOwned: jest.fn(() => 10), name: jest.fn(() => "P1"), @@ -76,6 +59,15 @@ describe("WinCheckExecution", () => { mg.numLandTiles = jest.fn(() => 100); mg.numTilesWithFallout = jest.fn(() => 0); mg.stats = jest.fn(() => ({ stats: () => ({ mocked: true }) })); + // Advance ticks until timeElapsed (in seconds) >= maxTimerValue * 60 + // timeElapsed = (ticks - numSpawnPhaseTurns) / 10 => + // ticks >= numSpawnPhaseTurns + maxTimerValue * 600 + const threshold = + mg.config().numSpawnPhaseTurns() + + (mg.config().gameConfig().maxTimerValue ?? 0) * 600; + while (mg.ticks() < threshold) { + mg.executeNextTick(); + } winCheck.checkWinnerFFA(); expect(mg.setWinner).toHaveBeenCalledWith(player, expect.any(Object)); }); From 44b546adf9a945482db0b24032f73d3ab9f25f66 Mon Sep 17 00:00:00 2001 From: jrouillard Date: Sun, 12 Oct 2025 21:41:58 +0200 Subject: [PATCH 10/11] remove useless comment --- src/core/execution/WinCheckExecution.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/execution/WinCheckExecution.ts b/src/core/execution/WinCheckExecution.ts index 35eaa77173..be9793cb80 100644 --- a/src/core/execution/WinCheckExecution.ts +++ b/src/core/execution/WinCheckExecution.ts @@ -16,7 +16,6 @@ export class WinCheckExecution implements Execution { private active = true; private mg: Game | null = null; - /** Remaining seconds – undefined when feature is disabled */ constructor() {} From e0386b57b6c96b5f4306c6217ada181bd844f7b5 Mon Sep 17 00:00:00 2001 From: jrouillard Date: Mon, 13 Oct 2025 23:00:35 +0200 Subject: [PATCH 11/11] remove unecessary characters --- src/client/HostLobbyModal.ts | 2 +- src/client/SinglePlayerModal.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index dee7aae16c..a7a743ff12 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -677,7 +677,7 @@ export class HostLobbyModal extends LitElement { private handleMaxTimerValueChanges(e: Event) { (e.target as HTMLInputElement).value = ( e.target as HTMLInputElement - ).value.replace(/[e\+\-]/gi, ""); + ).value.replace(/[e+-]/gi, ""); const value = parseInt((e.target as HTMLInputElement).value); if (isNaN(value) || value < 0 || value > 120) { diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index f2b094b7d0..446dde847a 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -439,7 +439,7 @@ export class SinglePlayerModal extends LitElement { private handleMaxTimerValueChanges(e: Event) { (e.target as HTMLInputElement).value = ( e.target as HTMLInputElement - ).value.replace(/[e\+\-]/gi, ""); + ).value.replace(/[e+-]/gi, ""); const value = parseInt((e.target as HTMLInputElement).value); if (isNaN(value) || value < 0 || value > 120) {