mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:10:42 +00:00
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  ## 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:
@@ -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",
|
||||
|
||||
@@ -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>),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -54,6 +54,7 @@ export class GameManager {
|
||||
disableNPCs: false,
|
||||
infiniteGold: false,
|
||||
infiniteTroops: false,
|
||||
maxTimerValue: undefined,
|
||||
instantBuild: false,
|
||||
gameMode: GameMode.FFA,
|
||||
bots: 400,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user