Skip to content

Commit f161c94

Browse files
Max timer (#1289)
## Description: Adds a max timer setting The timer starts at max timer and goes down, becoming red if reaching < 1 min The player with the biggest territory wins at the end of the timer ![image](https://github.com/user-attachments/assets/888099fc-95ae-4303-8c80-c850e58d36e2) ## 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 - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: Vivacious Box --------- Co-authored-by: Loymdayddaud <[email protected]>
1 parent 75ca2fb commit f161c94

File tree

10 files changed

+235
-6
lines changed

10 files changed

+235
-6
lines changed

resources/lang/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@
144144
"infinite_gold": "Infinite gold",
145145
"infinite_troops": "Infinite troops",
146146
"compact_map": "Mini Map",
147+
"max_timer": "Game length (minutes)",
147148
"disable_nukes": "Disable Nukes",
148149
"enables_title": "Enable Settings",
149150
"start": "Start Game"
@@ -236,6 +237,7 @@
236237
"bots": "Bots: ",
237238
"bots_disabled": "Disabled",
238239
"disable_nations": "Disable Nations",
240+
"max_timer": "Game length (minutes)",
239241
"instant_build": "Instant build",
240242
"infinite_gold": "Infinite gold",
241243
"donate_gold": "Donate gold",

src/client/HostLobbyModal.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export class HostLobbyModal extends LitElement {
4444
@state() private donateGold: boolean = false;
4545
@state() private infiniteTroops: boolean = false;
4646
@state() private donateTroops: boolean = false;
47+
@state() private maxTimer: boolean = false;
48+
@state() private maxTimerValue: number | undefined = undefined;
4749
@state() private instantBuild: boolean = false;
4850
@state() private compactMap: boolean = false;
4951
@state() private lobbyId = "";
@@ -442,6 +444,42 @@ export class HostLobbyModal extends LitElement {
442444
</div>
443445
</label>
444446
447+
<label
448+
for="max-timer"
449+
class="option-card ${this.maxTimer ? "selected" : ""}"
450+
>
451+
<div class="checkbox-icon"></div>
452+
<input
453+
type="checkbox"
454+
id="max-timer"
455+
@change=${(e: Event) => {
456+
const checked = (e.target as HTMLInputElement).checked;
457+
if (!checked) {
458+
this.maxTimerValue = undefined;
459+
}
460+
this.maxTimer = checked;
461+
this.putGameConfig();
462+
}}
463+
.checked=${this.maxTimer}
464+
/>
465+
${
466+
this.maxTimer === false
467+
? ""
468+
: html`<input
469+
type="number"
470+
id="end-timer-value"
471+
min="0"
472+
max="120"
473+
.value=${String(this.maxTimerValue ?? "")}
474+
style="width: 60px; color: black; text-align: right; border-radius: 8px;"
475+
@input=${this.handleMaxTimerValueChanges}
476+
@keydown=${this.handleMaxTimerValueKeyDown}
477+
/>`
478+
}
479+
<div class="option-card-title">
480+
${translateText("host_modal.max_timer")}
481+
</div>
482+
</label>
445483
<hr style="width: 100%; border-top: 1px solid #444; margin: 16px 0;" />
446484
447485
<!-- Individual disables for structures/weapons -->
@@ -630,6 +668,25 @@ export class HostLobbyModal extends LitElement {
630668
this.putGameConfig();
631669
}
632670

671+
private handleMaxTimerValueKeyDown(e: KeyboardEvent) {
672+
if (["-", "+", "e"].includes(e.key)) {
673+
e.preventDefault();
674+
}
675+
}
676+
677+
private handleMaxTimerValueChanges(e: Event) {
678+
(e.target as HTMLInputElement).value = (
679+
e.target as HTMLInputElement
680+
).value.replace(/[e+-]/gi, "");
681+
const value = parseInt((e.target as HTMLInputElement).value);
682+
683+
if (isNaN(value) || value < 0 || value > 120) {
684+
return;
685+
}
686+
this.maxTimerValue = value;
687+
this.putGameConfig();
688+
}
689+
633690
private async handleDisableNPCsChange(e: Event) {
634691
this.disableNPCs = Boolean((e.target as HTMLInputElement).checked);
635692
console.log(`updating disable npcs to ${this.disableNPCs}`);
@@ -671,6 +728,8 @@ export class HostLobbyModal extends LitElement {
671728
gameMode: this.gameMode,
672729
disabledUnits: this.disabledUnits,
673730
playerTeams: this.teamCount,
731+
maxTimerValue:
732+
this.maxTimer === true ? this.maxTimerValue : undefined,
674733
} satisfies Partial<GameConfig>),
675734
},
676735
);

src/client/SinglePlayerModal.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export class SinglePlayerModal extends LitElement {
4040
@state() private infiniteGold: boolean = false;
4141
@state() private infiniteTroops: boolean = false;
4242
@state() private compactMap: boolean = false;
43+
@state() private maxTimer: boolean = false;
44+
@state() private maxTimerValue: number | undefined = undefined;
4345
@state() private instantBuild: boolean = false;
4446
@state() private useRandomMap: boolean = false;
4547
@state() private gameMode: GameMode = GameMode.FFA;
@@ -315,6 +317,39 @@ export class SinglePlayerModal extends LitElement {
315317
${translateText("single_modal.compact_map")}
316318
</div>
317319
</label>
320+
<label
321+
for="end-timer"
322+
class="option-card ${this.maxTimer ? "selected" : ""}"
323+
>
324+
<div class="checkbox-icon"></div>
325+
<input
326+
type="checkbox"
327+
id="end-timer"
328+
@change=${(e: Event) => {
329+
const checked = (e.target as HTMLInputElement).checked;
330+
if (!checked) {
331+
this.maxTimerValue = undefined;
332+
}
333+
this.maxTimer = checked;
334+
}}
335+
.checked=${this.maxTimer}
336+
/>
337+
${this.maxTimer === false
338+
? ""
339+
: html`<input
340+
type="number"
341+
id="end-timer-value"
342+
min="0"
343+
max="120"
344+
.value=${String(this.maxTimerValue ?? "")}
345+
style="width: 60px; color: black; text-align: right; border-radius: 8px;"
346+
@input=${this.handleMaxTimerValueChanges}
347+
@keydown=${this.handleMaxTimerValueKeyDown}
348+
/>`}
349+
<div class="option-card-title">
350+
${translateText("single_modal.max_timer")}
351+
</div>
352+
</label>
318353
</div>
319354
320355
<hr
@@ -395,6 +430,24 @@ export class SinglePlayerModal extends LitElement {
395430
this.compactMap = Boolean((e.target as HTMLInputElement).checked);
396431
}
397432

433+
private handleMaxTimerValueKeyDown(e: KeyboardEvent) {
434+
if (["-", "+", "e"].includes(e.key)) {
435+
e.preventDefault();
436+
}
437+
}
438+
439+
private handleMaxTimerValueChanges(e: Event) {
440+
(e.target as HTMLInputElement).value = (
441+
e.target as HTMLInputElement
442+
).value.replace(/[e+-]/gi, "");
443+
const value = parseInt((e.target as HTMLInputElement).value);
444+
445+
if (isNaN(value) || value < 0 || value > 120) {
446+
return;
447+
}
448+
this.maxTimerValue = value;
449+
}
450+
398451
private handleDisableNPCsChange(e: Event) {
399452
this.disableNPCs = Boolean((e.target as HTMLInputElement).checked);
400453
}
@@ -482,6 +535,7 @@ export class SinglePlayerModal extends LitElement {
482535
playerTeams: this.teamCount,
483536
difficulty: this.selectedDifficulty,
484537
disableNPCs: this.disableNPCs,
538+
maxTimerValue: this.maxTimer ? this.maxTimerValue : undefined,
485539
bots: this.bots,
486540
infiniteGold: this.infiniteGold,
487541
donateGold: true,

src/client/graphics/layers/GameRightSidebar.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,19 @@ export class GameRightSidebar extends LitElement implements Layer {
5757
if (updates) {
5858
this.hasWinner = this.hasWinner || updates[GameUpdateType.Win].length > 0;
5959
}
60-
if (this.game.inSpawnPhase()) {
61-
this.timer = 0;
62-
} else if (!this.hasWinner && this.game.ticks() % 10 === 0) {
63-
this.timer++;
60+
const maxTimerValue = this.game.config().gameConfig().maxTimerValue;
61+
if (maxTimerValue !== undefined) {
62+
if (this.game.inSpawnPhase()) {
63+
this.timer = maxTimerValue * 60;
64+
} else if (!this.hasWinner && this.game.ticks() % 10 === 0) {
65+
this.timer = Math.max(0, this.timer - 1);
66+
}
67+
} else {
68+
if (this.game.inSpawnPhase()) {
69+
this.timer = 0;
70+
} else if (!this.hasWinner && this.game.ticks() % 10 === 0) {
71+
this.timer++;
72+
}
6473
}
6574
}
6675

@@ -140,6 +149,10 @@ export class GameRightSidebar extends LitElement implements Layer {
140149
<div class="flex justify-center items-center mt-2">
141150
<div
142151
class="w-[70px] h-8 lg:w-24 lg:h-10 border border-slate-400 p-0.5 text-xs md:text-sm lg:text-base flex items-center justify-center text-white px-1"
152+
style="${this.game.config().gameConfig().maxTimerValue !==
153+
undefined && this.timer < 60
154+
? "color: #ff8080;"
155+
: ""}"
143156
>
144157
${this.secondsToHms(this.timer)}
145158
</div>

src/core/Schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ export const GameConfigSchema = z.object({
164164
infiniteTroops: z.boolean(),
165165
instantBuild: z.boolean(),
166166
maxPlayers: z.number().optional(),
167+
maxTimerValue: z.number().int().min(1).max(120).optional(),
167168
disabledUnits: z.enum(UnitType).array().optional(),
168169
playerTeams: TeamCountConfigSchema.optional(),
169170
});

src/core/execution/WinCheckExecution.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export class WinCheckExecution implements Execution {
2828
return;
2929
}
3030
if (this.mg === null) throw new Error("Not initialized");
31+
3132
if (this.mg.config().gameConfig().gameMode === GameMode.FFA) {
3233
this.checkWinnerFFA();
3334
} else {
@@ -44,11 +45,15 @@ export class WinCheckExecution implements Execution {
4445
return;
4546
}
4647
const max = sorted[0];
48+
const timeElapsed =
49+
(this.mg.ticks() - this.mg.config().numSpawnPhaseTurns()) / 10;
4750
const numTilesWithoutFallout =
4851
this.mg.numLandTiles() - this.mg.numTilesWithFallout();
4952
if (
5053
(max.numTilesOwned() / numTilesWithoutFallout) * 100 >
51-
this.mg.config().percentageTilesOwnedToWin()
54+
this.mg.config().percentageTilesOwnedToWin() ||
55+
(this.mg.config().gameConfig().maxTimerValue !== undefined &&
56+
timeElapsed - this.mg.config().gameConfig().maxTimerValue! * 60 >= 0)
5257
) {
5358
this.mg.setWinner(max, this.mg.stats().stats());
5459
console.log(`${max.name()} has won the game`);
@@ -75,10 +80,16 @@ export class WinCheckExecution implements Execution {
7580
return;
7681
}
7782
const max = sorted[0];
83+
const timeElapsed =
84+
(this.mg.ticks() - this.mg.config().numSpawnPhaseTurns()) / 10;
7885
const numTilesWithoutFallout =
7986
this.mg.numLandTiles() - this.mg.numTilesWithFallout();
8087
const percentage = (max[1] / numTilesWithoutFallout) * 100;
81-
if (percentage > this.mg.config().percentageTilesOwnedToWin()) {
88+
if (
89+
percentage > this.mg.config().percentageTilesOwnedToWin() ||
90+
(this.mg.config().gameConfig().maxTimerValue !== undefined &&
91+
timeElapsed - this.mg.config().gameConfig().maxTimerValue! * 60 >= 0)
92+
) {
8293
if (max[0] === ColoredTeams.Bot) return;
8394
this.mg.setWinner(max[0], this.mg.stats().stats());
8495
console.log(`${max[0]} has won the game`);

src/server/GameManager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export class GameManager {
5454
disableNPCs: false,
5555
infiniteGold: false,
5656
infiniteTroops: false,
57+
maxTimerValue: undefined,
5758
instantBuild: false,
5859
gameMode: GameMode.FFA,
5960
bots: 400,

src/server/GameServer.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@ export class GameServer {
109109
if (gameConfig.donateTroops !== undefined) {
110110
this.gameConfig.donateTroops = gameConfig.donateTroops;
111111
}
112+
if (gameConfig.maxTimerValue !== undefined) {
113+
this.gameConfig.maxTimerValue = gameConfig.maxTimerValue;
114+
}
112115
if (gameConfig.instantBuild !== undefined) {
113116
this.gameConfig.instantBuild = gameConfig.instantBuild;
114117
}

src/server/MapPlaylist.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export class MapPlaylist {
8888
difficulty: Difficulty.Medium,
8989
infiniteGold: false,
9090
infiniteTroops: false,
91+
maxTimerValue: undefined,
9192
instantBuild: false,
9293
disableNPCs: mode === GameMode.Team,
9394
gameMode: mode,
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { WinCheckExecution } from "../../../src/core/execution/WinCheckExecution";
2+
import { GameMode } from "../../../src/core/game/Game";
3+
import { setup } from "../../util/Setup";
4+
5+
describe("WinCheckExecution", () => {
6+
let mg: any;
7+
let winCheck: WinCheckExecution;
8+
9+
beforeEach(async () => {
10+
mg = await setup("big_plains", {
11+
infiniteGold: true,
12+
gameMode: GameMode.FFA,
13+
maxTimerValue: 5,
14+
instantBuild: true,
15+
});
16+
mg.setWinner = jest.fn();
17+
winCheck = new WinCheckExecution();
18+
winCheck.init(mg, 0);
19+
});
20+
21+
it("should call checkWinnerFFA in FFA mode", () => {
22+
const spy = jest.spyOn(winCheck as any, "checkWinnerFFA");
23+
winCheck.tick(10);
24+
expect(spy).toHaveBeenCalled();
25+
});
26+
27+
it("should call checkWinnerTeam in non-FFA mode", () => {
28+
mg.config = jest.fn(() => ({
29+
gameConfig: jest.fn(() => ({
30+
maxTimerValue: 5,
31+
gameMode: GameMode.Team,
32+
})),
33+
percentageTilesOwnedToWin: jest.fn(() => 50),
34+
}));
35+
winCheck.init(mg, 0);
36+
const spy = jest.spyOn(winCheck as any, "checkWinnerTeam");
37+
winCheck.tick(10);
38+
expect(spy).toHaveBeenCalled();
39+
});
40+
41+
it("should set winner in FFA if percentage is reached", () => {
42+
const player = {
43+
numTilesOwned: jest.fn(() => 81),
44+
name: jest.fn(() => "P1"),
45+
};
46+
mg.players = jest.fn(() => [player]);
47+
mg.numLandTiles = jest.fn(() => 100);
48+
mg.numTilesWithFallout = jest.fn(() => 0);
49+
winCheck.checkWinnerFFA();
50+
expect(mg.setWinner).toHaveBeenCalledWith(player, expect.anything());
51+
});
52+
53+
it("should set winner in FFA if timer is 0", () => {
54+
const player = {
55+
numTilesOwned: jest.fn(() => 10),
56+
name: jest.fn(() => "P1"),
57+
};
58+
mg.players = jest.fn(() => [player]);
59+
mg.numLandTiles = jest.fn(() => 100);
60+
mg.numTilesWithFallout = jest.fn(() => 0);
61+
mg.stats = jest.fn(() => ({ stats: () => ({ mocked: true }) }));
62+
// Advance ticks until timeElapsed (in seconds) >= maxTimerValue * 60
63+
// timeElapsed = (ticks - numSpawnPhaseTurns) / 10 =>
64+
// ticks >= numSpawnPhaseTurns + maxTimerValue * 600
65+
const threshold =
66+
mg.config().numSpawnPhaseTurns() +
67+
(mg.config().gameConfig().maxTimerValue ?? 0) * 600;
68+
while (mg.ticks() < threshold) {
69+
mg.executeNextTick();
70+
}
71+
winCheck.checkWinnerFFA();
72+
expect(mg.setWinner).toHaveBeenCalledWith(player, expect.any(Object));
73+
});
74+
75+
it("should not set winner if no players", () => {
76+
mg.players = jest.fn(() => []);
77+
winCheck.checkWinnerFFA();
78+
expect(mg.setWinner).not.toHaveBeenCalled();
79+
});
80+
81+
it("should return false for activeDuringSpawnPhase", () => {
82+
expect(winCheck.activeDuringSpawnPhase()).toBe(false);
83+
});
84+
});

0 commit comments

Comments
 (0)