Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
59 changes: 59 additions & 0 deletions src/client/HostLobbyModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
Expand Down Expand Up @@ -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 -->
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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>),
},
);
Expand Down
54 changes: 54 additions & 0 deletions src/client/SinglePlayerModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 17 additions & 4 deletions src/client/graphics/layers/GameRightSidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
}
}
}

Expand Down Expand Up @@ -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>
Expand Down
1 change: 1 addition & 0 deletions src/core/Schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand Down
15 changes: 13 additions & 2 deletions src/core/execution/WinCheckExecution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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`);
Expand All @@ -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`);
Expand Down
1 change: 1 addition & 0 deletions src/server/GameManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export class GameManager {
disableNPCs: false,
infiniteGold: false,
infiniteTroops: false,
maxTimerValue: undefined,
instantBuild: false,
gameMode: GameMode.FFA,
bots: 400,
Expand Down
3 changes: 3 additions & 0 deletions src/server/GameServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions src/server/MapPlaylist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export class MapPlaylist {
difficulty: Difficulty.Medium,
infiniteGold: false,
infiniteTroops: false,
maxTimerValue: undefined,
instantBuild: false,
disableNPCs: mode === GameMode.Team,
gameMode: mode,
Expand Down
84 changes: 84 additions & 0 deletions tests/core/executions/WinCheckExecution.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading