diff --git a/resources/lang/en.json b/resources/lang/en.json index dfe6e0721..cecb533a4 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -144,6 +144,7 @@ "infinite_gold": "Infinite gold", "infinite_troops": "Infinite troops", "compact_map": "Mini Map", + "max_timer": "Game length (minutes)", "disable_nukes": "Disable Nukes", "enables_title": "Enable Settings", "start": "Start Game" @@ -236,6 +237,7 @@ "bots": "Bots: ", "bots_disabled": "Disabled", "disable_nations": "Disable Nations", + "max_timer": "Game length (minutes)", "instant_build": "Instant build", "infinite_gold": "Infinite gold", "donate_gold": "Donate gold", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 37f954476..a7a743ff1 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -44,6 +44,8 @@ export class HostLobbyModal extends LitElement { @state() private donateGold: boolean = false; @state() private infiniteTroops: boolean = false; @state() private donateTroops: boolean = false; + @state() private maxTimer: boolean = false; + @state() private maxTimerValue: number | undefined = undefined; @state() private instantBuild: boolean = false; @state() private compactMap: boolean = false; @state() private lobbyId = ""; @@ -442,6 +444,42 @@ export class HostLobbyModal extends LitElement { +
@@ -630,6 +668,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 > 120) { + 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}`); @@ -671,6 +728,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 01e62f928..446dde847 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -40,6 +40,8 @@ export class SinglePlayerModal extends LitElement { @state() private infiniteGold: boolean = false; @state() private infiniteTroops: boolean = false; @state() private compactMap: 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; @@ -315,6 +317,39 @@ export class SinglePlayerModal extends LitElement { ${translateText("single_modal.compact_map")} +
120) { + return; + } + this.maxTimerValue = value; + } + private handleDisableNPCsChange(e: Event) { this.disableNPCs = Boolean((e.target as HTMLInputElement).checked); } @@ -482,6 +535,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, donateGold: true, diff --git a/src/client/graphics/layers/GameRightSidebar.ts b/src/client/graphics/layers/GameRightSidebar.ts index 3ac7ce450..c4215215c 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/Schemas.ts b/src/core/Schemas.ts index b13f8ac17..4110bc2c1 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -164,6 +164,7 @@ export const GameConfigSchema = z.object({ infiniteTroops: z.boolean(), instantBuild: z.boolean(), maxPlayers: z.number().optional(), + maxTimerValue: z.number().int().min(1).max(120).optional(), disabledUnits: z.enum(UnitType).array().optional(), playerTeams: TeamCountConfigSchema.optional(), }); diff --git a/src/core/execution/WinCheckExecution.ts b/src/core/execution/WinCheckExecution.ts index 8c2159a71..be9793cb8 100644 --- a/src/core/execution/WinCheckExecution.ts +++ b/src/core/execution/WinCheckExecution.ts @@ -28,6 +28,7 @@ export class WinCheckExecution implements Execution { return; } if (this.mg === null) throw new Error("Not initialized"); + if (this.mg.config().gameConfig().gameMode === GameMode.FFA) { this.checkWinnerFFA(); } else { @@ -44,11 +45,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.mg.config().percentageTilesOwnedToWin() || + (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`); @@ -75,10 +80,16 @@ 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()) { + if ( + percentage > this.mg.config().percentageTilesOwnedToWin() || + (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()); console.log(`${max[0]} has won the game`); diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 1d4d8f80c..f366a63ec 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -54,6 +54,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 eabef3f11..799c25c88 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -109,6 +109,9 @@ export class GameServer { if (gameConfig.donateTroops !== undefined) { this.gameConfig.donateTroops = gameConfig.donateTroops; } + 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 acb14cce0..11e7b6ecb 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -88,6 +88,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/executions/WinCheckExecution.test.ts b/tests/core/executions/WinCheckExecution.test.ts new file mode 100644 index 000000000..922a5b547 --- /dev/null +++ b/tests/core/executions/WinCheckExecution.test.ts @@ -0,0 +1,84 @@ +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("big_plains", { + infiniteGold: true, + gameMode: GameMode.FFA, + maxTimerValue: 5, + instantBuild: true, + }); + mg.setWinner = jest.fn(); + winCheck = new WinCheckExecution(); + winCheck.init(mg, 0); + }); + + 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", () => { + 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); + 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)); + }); + + 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); + }); +});