## 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 <145969603+TheGiraffe3@users.noreply.github.com>
This commit is contained in:
Vivacious Box
2025-10-18 02:09:10 +02:00
committed by GitHub
parent 75ca2fb349
commit f161c94ff4
10 changed files with 235 additions and 6 deletions
+2
View File
@@ -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",
+59
View File
@@ -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 {
</div>
</label>
<label
for="max-timer"
class="option-card ${this.maxTimer ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="max-timer"
@change=${(e: Event) => {
const checked = (e.target as HTMLInputElement).checked;
if (!checked) {
this.maxTimerValue = undefined;
}
this.maxTimer = checked;
this.putGameConfig();
}}
.checked=${this.maxTimer}
/>
${
this.maxTimer === false
? ""
: html`<input
type="number"
id="end-timer-value"
min="0"
max="120"
.value=${String(this.maxTimerValue ?? "")}
style="width: 60px; color: black; text-align: right; border-radius: 8px;"
@input=${this.handleMaxTimerValueChanges}
@keydown=${this.handleMaxTimerValueKeyDown}
/>`
}
<div class="option-card-title">
${translateText("host_modal.max_timer")}
</div>
</label>
<hr style="width: 100%; border-top: 1px solid #444; margin: 16px 0;" />
<!-- Individual disables for structures/weapons -->
@@ -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<GameConfig>),
},
);
+54
View File
@@ -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")}
</div>
</label>
<label
for="end-timer"
class="option-card ${this.maxTimer ? "selected" : ""}"
>
<div class="checkbox-icon"></div>
<input
type="checkbox"
id="end-timer"
@change=${(e: Event) => {
const checked = (e.target as HTMLInputElement).checked;
if (!checked) {
this.maxTimerValue = undefined;
}
this.maxTimer = checked;
}}
.checked=${this.maxTimer}
/>
${this.maxTimer === false
? ""
: html`<input
type="number"
id="end-timer-value"
min="0"
max="120"
.value=${String(this.maxTimerValue ?? "")}
style="width: 60px; color: black; text-align: right; border-radius: 8px;"
@input=${this.handleMaxTimerValueChanges}
@keydown=${this.handleMaxTimerValueKeyDown}
/>`}
<div class="option-card-title">
${translateText("single_modal.max_timer")}
</div>
</label>
</div>
<hr
@@ -395,6 +430,24 @@ export class SinglePlayerModal extends LitElement {
this.compactMap = Boolean((e.target as HTMLInputElement).checked);
}
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;
}
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,
+17 -4
View File
@@ -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 {
<div class="flex justify-center items-center mt-2">
<div
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"
style="${this.game.config().gameConfig().maxTimerValue !==
undefined && this.timer < 60
? "color: #ff8080;"
: ""}"
>
${this.secondsToHms(this.timer)}
</div>
+1
View File
@@ -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(),
});
+13 -2
View File
@@ -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`);
+1
View File
@@ -54,6 +54,7 @@ export class GameManager {
disableNPCs: false,
infiniteGold: false,
infiniteTroops: false,
maxTimerValue: undefined,
instantBuild: false,
gameMode: GameMode.FFA,
bots: 400,
+3
View File
@@ -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;
}
+1
View File
@@ -88,6 +88,7 @@ export class MapPlaylist {
difficulty: Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
maxTimerValue: undefined,
instantBuild: false,
disableNPCs: mode === GameMode.Team,
gameMode: mode,
@@ -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);
});
});