Delayed lobby start (#4184)

Resolves #4169

## Description:

Adds a delayed lobby start option.
Utilizes the same system as for public lobbies.
The default for the option is for lobbies to take 3 seconds to start,
however this can easily be changed.

The current setting is controlled through an enable-disable slider,
however there are multiple other options for how to control this.
For example we could do a slider, an input field, a dropdown etc. And i
dont necessarily know if the currently implemented option is the best.

Furhtermore im not sure if i have used the language file completely
correctly. There is now a duplicate field for both private and public
lobby. However there is not category shared between the two. So i
decided to reuse the field from public for private games, as this
simplified the code a bit.

**Host video**

https://github.com/user-attachments/assets/6f3db6e4-7323-4fad-8544-efb8cef4d969

**Non-host video**

https://github.com/user-attachments/assets/ee02a072-1f42-4dde-a5d9-120fda862eb7

## 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


## Please put your Discord username so you can be contacted if a bug or
regression is found:
FrederikJA
This commit is contained in:
FrederikJA
2026-06-12 21:22:03 +02:00
committed by GitHub
parent d96c055df1
commit 19db66f424
10 changed files with 212 additions and 105 deletions
+3
View File
@@ -704,8 +704,11 @@
"random_spawn": "Random spawn",
"remove_player": "Remove {username}",
"start": "Start Game",
"start_delay": "Start delay (Seconds)",
"start_delay_placeholder": "3",
"starting_gold": "Starting Gold (Millions)",
"starting_gold_placeholder": "5",
"starting_in": "Starting in {time}. Click to cancel",
"team_count": "Number of Teams",
"teams_Duos": "Duos (teams of 2)",
"teams_Humans Vs Nations": "Humans vs Nations",
+70 -8
View File
@@ -1,7 +1,12 @@
import { html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ClientEnv } from "src/client/ClientEnv";
import { translateText } from "../client/Utils";
import {
calculateServerTimeOffset,
getSecondsUntilServerTimestamp,
renderDuration,
translateText,
} from "../client/Utils";
import { EventBus } from "../core/EventBus";
import {
Difficulty,
@@ -25,6 +30,7 @@ import "./components/baseComponents/Modal";
import { BaseModal } from "./components/BaseModal";
import { CopyButton } from "./components/CopyButton";
import "./components/GameConfigSettings";
import "./components/InputCard";
import "./components/LobbyPlayerView";
import "./components/ToggleInputCard";
import { modalHeader } from "./components/ui/ModalHeader";
@@ -65,6 +71,7 @@ export class HostLobbyModal extends BaseModal {
@state() private donateTroops: boolean = false;
@state() private maxTimer: boolean = false;
@state() private maxTimerValue: number | undefined = undefined;
@state() private startDelayValue: number | undefined = 3;
@state() private instantBuild: boolean = false;
@state() private randomSpawn: boolean = false;
@state() private compactMap: boolean = false;
@@ -87,6 +94,8 @@ export class HostLobbyModal extends BaseModal {
@state() private hostCheatStartingGold: boolean = false;
@state() private hostCheatStartingGoldValue: number | undefined = undefined;
@state() private lobbyCreatorClientID: string = "";
@state() private lobbyStartAt: number | null = null;
@state() private serverTimeOffset: number = 0;
@property({ attribute: false }) eventBus: EventBus | null = null;
// Timers for debouncing slider changes
@@ -102,6 +111,10 @@ export class HostLobbyModal extends BaseModal {
if (!this.lobbyId || lobby.gameID !== this.lobbyId) {
return;
}
if ("serverTime" in lobby && typeof lobby.serverTime === "number") {
this.serverTimeOffset = calculateServerTimeOffset(lobby.serverTime);
}
this.lobbyStartAt = lobby.startsAt ?? null;
this.lobbyCreatorClientID = lobby.lobbyCreatorClientID ?? "";
if (lobby.clients) {
this.clients = lobby.clients;
@@ -180,6 +193,22 @@ export class HostLobbyModal extends BaseModal {
}
protected renderBody() {
const secondsRemaining =
this.lobbyStartAt !== null
? getSecondsUntilServerTimestamp(
this.lobbyStartAt,
this.serverTimeOffset,
)
: null;
const statusLabel =
secondsRemaining === null
? this.clients.length === 1
? translateText("host_modal.waiting")
: translateText("host_modal.start")
: translateText("host_modal.starting_in", {
time: renderDuration(secondsRemaining),
});
const inputCards = [
html`<toggle-input-card
.labelKey=${"host_modal.max_timer"}
@@ -195,6 +224,19 @@ export class HostLobbyModal extends BaseModal {
.onInput=${this.handleMaxTimerValueChanges}
.onKeyDown=${this.handleMaxTimerValueKeyDown}
></toggle-input-card>`,
html`<input-card
.labelKey=${"host_modal.start_delay"}
.inputId=${"start-delay-value"}
.inputMin=${0}
.inputMax=${600}
.inputStep=${"1"}
.inputValue=${this.startDelayValue}
.inputAriaLabel=${translateText("host_modal.start_delay")}
.inputPlaceholder=${translateText("host_modal.start_delay_placeholder")}
.defaultInputValue=${3}
.onChange=${this.handleStartDelayValueChanges}
.onKeyDown=${this.handleStartDelayValueKeyDown}
></input-card>`,
html`<toggle-input-card
.labelKey=${"host_modal.player_immunity_duration"}
.checked=${this.spawnImmunity}
@@ -419,11 +461,9 @@ export class HostLobbyModal extends BaseModal {
variant="primary"
width="block"
size="lg"
.title=${this.clients.length === 1
? translateText("host_modal.waiting")
: translateText("host_modal.start")}
?disable=${this.clients.length < 2}
@click=${this.startGame}
.title=${statusLabel}
?disable=${this.lobbyStartAt === null && this.clients.length < 2}
@click=${this.toggleGameStartTimer}
></o-button>
</div>
</div>
@@ -529,6 +569,7 @@ export class HostLobbyModal extends BaseModal {
this.donateTroops = false;
this.maxTimer = false;
this.maxTimerValue = undefined;
this.startDelayValue = 3;
this.instantBuild = false;
this.randomSpawn = false;
this.compactMap = false;
@@ -900,6 +941,26 @@ export class HostLobbyModal extends BaseModal {
this.putGameConfig();
};
private handleStartDelayValueKeyDown = (e: KeyboardEvent) => {
preventDisallowedKeys(e, ["-", "+", "e", "E", "."]);
};
private handleStartDelayValueChanges = (e: Event) => {
const input = e.target as HTMLInputElement;
const value = parseBoundedIntegerFromInput(input, {
min: 0,
max: 600,
});
if (value === undefined) {
this.startDelayValue = undefined;
input.value = "";
} else {
this.startDelayValue = value;
}
this.putGameConfig();
};
private handleNationsChange = (e: Event) => {
const customEvent = e as CustomEvent<{ value: number }>;
const value = customEvent.detail.value;
@@ -967,6 +1028,7 @@ export class HostLobbyModal extends BaseModal {
this.defaultNationCount,
),
maxTimerValue: this.maxTimer === true ? this.maxTimerValue : null,
startDelay: this.startDelayValue,
goldMultiplier:
this.goldMultiplier === true ? this.goldMultiplierValue : null,
startingGold:
@@ -998,7 +1060,7 @@ export class HostLobbyModal extends BaseModal {
);
}
private async startGame() {
private async toggleGameStartTimer() {
await this.putGameConfig();
console.log(
`Starting private game with map: ${GameMapType[this.selectedMap as keyof typeof GameMapType]} ${this.useRandomMap ? " (Randomly selected)" : ""}`,
@@ -1008,7 +1070,7 @@ export class HostLobbyModal extends BaseModal {
this.leaveLobbyOnClose = false;
this.dispatchEvent(
new CustomEvent("start-game", {
new CustomEvent("toggle_game_start_timer", {
bubbles: true,
composed: true,
}),
+33 -48
View File
@@ -112,7 +112,9 @@ export class JoinLobbyModal extends BaseModal {
: null;
const statusLabel =
secondsRemaining === null
? translateText("public_lobby.waiting_for_players")
? this.isPrivateLobby()
? translateText("private_lobby.joined_waiting")
: translateText("public_lobby.waiting_for_players")
: secondsRemaining > 0
? translateText("public_lobby.starting_in", {
time: renderDuration(secondsRemaining),
@@ -162,56 +164,39 @@ export class JoinLobbyModal extends BaseModal {
`}
</div>
${this.isPrivateLobby()
? html`
<div
class="p-6 lg:p-6 border-t border-white/10 bg-black/20 shrink-0"
>
<button
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-malibu-blue hover:bg-aquarius disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-sky-900/20 hover:shadow-sky-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
disabled
${html`
<div class="p-6 lg:p-6 border-t border-white/10 bg-black/20 shrink-0">
<div
class="w-full px-4 py-3 rounded-xl border border-white/10 bg-white/5 flex items-center justify-between gap-3"
>
<div class="flex flex-col">
<span
class="text-[10px] font-bold uppercase tracking-widest text-white/40"
>${translateText("public_lobby.status")}</span
>
${translateText("private_lobby.joined_waiting")}
</button>
<span class="text-sm font-bold text-white">${statusLabel}</span>
</div>
`
: html`
<div
class="p-6 lg:p-6 border-t border-white/10 bg-black/20 shrink-0"
>
<div
class="w-full px-4 py-3 rounded-xl border border-white/10 bg-white/5 flex items-center justify-between gap-3"
>
<div class="flex flex-col">
<span
class="text-[10px] font-bold uppercase tracking-widest text-white/40"
>${translateText("public_lobby.status")}</span
${maxPlayers > 0
? html`
<div
class="flex items-center gap-2 text-white/80 text-xs font-bold uppercase tracking-widest"
>
<span class="text-sm font-bold text-white"
>${statusLabel}</span
>
</div>
${maxPlayers > 0
? html`
<div
class="flex items-center gap-2 text-white/80 text-xs font-bold uppercase tracking-widest"
>
<span>${playerCount}/${maxPlayers}</span>
<svg
class="w-4 h-4 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.972 0 004 15v3H1v-3a3 3 0 013.75-2.906z"
></path>
</svg>
</div>
`
: html``}
</div>
</div>
`}
<span>${playerCount}/${maxPlayers}</span>
<svg
class="w-4 h-4 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.972 0 004 15v3H1v-3a3 3 0 013.75-2.906z"
></path>
</svg>
</div>
`
: html``}
</div>
</div>
`}
</div>
`;
}
+8 -5
View File
@@ -54,7 +54,7 @@ import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
import { TokenLoginModal } from "./TokenLoginModal";
import {
SendKickPlayerIntentEvent,
SendStartGameEvent,
SendToggleGameStartTimer,
SendUpdateGameConfigIntentEvent,
} from "./Transport";
import { UserSettingModal } from "./UserSettingModal";
@@ -219,7 +219,7 @@ declare global {
interface DocumentEventMap {
"join-lobby": CustomEvent<JoinLobbyEvent>;
"kick-player": CustomEvent;
"start-game": CustomEvent;
toggle_game_start_timer: CustomEvent;
"join-changed": CustomEvent;
"open-matchmaking": CustomEvent<undefined>;
userMeResponse: CustomEvent<UserMeResponse | false>;
@@ -376,7 +376,10 @@ class Client {
document.addEventListener("join-lobby", this.handleJoinLobby.bind(this));
document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this));
document.addEventListener("kick-player", this.handleKickPlayer.bind(this));
document.addEventListener("start-game", this.handleStartGame.bind(this));
document.addEventListener(
"toggle_game_start_timer",
this.handleToggleGameStartTimer.bind(this),
);
document.addEventListener(
"update-game-config",
this.handleUpdateGameConfig.bind(this),
@@ -1008,9 +1011,9 @@ class Client {
}
}
private handleStartGame() {
private handleToggleGameStartTimer() {
if (this.eventBus) {
this.eventBus.emit(new SendStartGameEvent());
this.eventBus.emit(new SendToggleGameStartTimer());
}
}
+8 -4
View File
@@ -174,7 +174,9 @@ export class SendUpdateGameConfigIntentEvent implements GameEvent {
constructor(public readonly config: Partial<GameConfig>) {}
}
export class SendStartGameEvent implements GameEvent {}
export class SendToggleGameStartTimer implements GameEvent {
constructor() {}
}
export class Transport {
private socket: WebSocket | null = null;
@@ -266,7 +268,9 @@ export class Transport {
this.onSendUpdateGameConfigIntent(e),
);
this.eventBus.on(SendStartGameEvent, () => this.onSendStartGame());
this.eventBus.on(SendToggleGameStartTimer, (e) =>
this.onSendToggleGameStartTimer(e),
);
}
private startPing() {
@@ -647,8 +651,8 @@ export class Transport {
});
}
private onSendStartGame() {
this.sendIntent({ type: "start_game" });
private onSendToggleGameStartTimer(event: SendToggleGameStartTimer) {
this.sendIntent({ type: "toggle_game_start_timer" });
}
private sendIntent(intent: Intent) {
+57
View File
@@ -0,0 +1,57 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { translateText } from "../Utils";
import { CARD_LABEL_CLASS, cardClass, INPUT_CLASS } from "./InputCardStyles";
@customElement("input-card")
export class InputCard extends LitElement {
@property({ attribute: false }) labelKey = "";
@property({ attribute: false }) inputId?: string;
@property({ attribute: false }) inputType = "number";
@property({ attribute: false }) inputMin?: number | string;
@property({ attribute: false }) inputMax?: number | string;
@property({ attribute: false }) inputStep?: number | string;
@property({ attribute: false }) inputValue?: number | string;
@property({ attribute: false }) inputAriaLabel?: string;
@property({ attribute: false }) inputPlaceholder?: string;
@property({ attribute: false }) onInput?: (e: Event) => void;
@property({ attribute: false }) onChange?: (e: Event) => void;
@property({ attribute: false }) onKeyDown?: (e: KeyboardEvent) => void;
createRenderRoot() {
return this;
}
render() {
return html`
<div class="${cardClass(true)}">
<div
class="w-full h-full p-3 flex flex-col items-center justify-between gap-2"
>
<div class="h-[30px] my-1"></div>
<span class="${CARD_LABEL_CLASS} text-center text-white">
${translateText(this.labelKey)}
</span>
</div>
<div class="absolute left-3 right-3 top-1/2 -translate-y-1/2 z-10">
<input
type=${this.inputType}
id=${this.inputId ?? nothing}
min=${this.inputMin ?? nothing}
max=${this.inputMax ?? nothing}
step=${this.inputStep ?? nothing}
.value=${String(this.inputValue ?? "")}
class=${INPUT_CLASS}
aria-label=${this.inputAriaLabel ?? nothing}
placeholder=${this.inputPlaceholder ?? nothing}
@input=${this.onInput}
@change=${this.onChange}
@keydown=${this.onKeyDown}
/>
</div>
</div>
`;
}
}
+12
View File
@@ -0,0 +1,12 @@
export const ACTIVE_CARD =
"bg-malibu-blue/20 border-malibu-blue/50 shadow-[var(--shadow-malibu-blue)]";
export const INACTIVE_CARD =
"bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20";
export const INPUT_CLASS =
"w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-malibu-blue p-1 my-1";
export const CARD_LABEL_CLASS =
"text-xs uppercase font-bold tracking-wider leading-tight break-words hyphens-auto";
export function cardClass(active: boolean, extra = ""): string {
return `w-full h-full rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 relative overflow-hidden ${extra} ${active ? ACTIVE_CARD : INACTIVE_CARD}`;
}
+2 -14
View File
@@ -1,19 +1,7 @@
import { LitElement, PropertyValues, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { translateText } from "../Utils";
const ACTIVE_CARD =
"bg-malibu-blue/20 border-malibu-blue/50 shadow-[var(--shadow-malibu-blue)]";
const INACTIVE_CARD =
"bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20";
const INPUT_CLASS =
"w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-malibu-blue p-1 my-1";
const CARD_LABEL_CLASS =
"text-xs uppercase font-bold tracking-wider leading-tight break-words hyphens-auto";
function cardClass(active: boolean, extra = ""): string {
return `w-full h-full rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 ${extra} ${active ? ACTIVE_CARD : INACTIVE_CARD}`;
}
import { CARD_LABEL_CLASS, INPUT_CLASS, cardClass } from "./InputCardStyles";
@customElement("toggle-input-card")
export class ToggleInputCard extends LitElement {
@@ -103,7 +91,7 @@ export class ToggleInputCard extends LitElement {
render() {
return html`
<div class="${cardClass(this.checked, "relative overflow-hidden")}">
<div class="${cardClass(this.checked)}">
<button
type="button"
aria-pressed=${this.checked}
+8 -5
View File
@@ -51,7 +51,7 @@ export type Intent =
| KickPlayerIntent
| TogglePauseIntent
| UpdateGameConfigIntent
| StartGameIntent;
| ToggleGameStartTimer;
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
export type CancelAttackIntent = z.infer<typeof CancelAttackIntentSchema>;
@@ -85,7 +85,9 @@ export type TogglePauseIntent = z.infer<typeof TogglePauseIntentSchema>;
export type UpdateGameConfigIntent = z.infer<
typeof UpdateGameConfigIntentSchema
>;
export type StartGameIntent = z.infer<typeof StartGameIntentSchema>;
export type ToggleGameStartTimer = z.infer<
typeof ToggleGameStartTimerIntentSchema
>;
export type Turn = z.infer<typeof TurnSchema>;
export type GameConfig = z.infer<typeof GameConfigSchema>;
@@ -281,6 +283,7 @@ export const GameConfigSchema = z.object({
randomSpawn: z.boolean(),
maxPlayers: z.number().optional(),
maxTimerValue: z.number().int().min(1).max(120).nullable().optional(), // In minutes
startDelay: z.number().int().min(0).max(600).nullable().optional(), // In seconds
spawnImmunityDuration: z.number().int().min(0).nullable().optional(), // In ticks
disabledUnits: z.enum(UnitType).array().optional(),
playerTeams: TeamCountConfigSchema.optional(),
@@ -481,8 +484,8 @@ export const UpdateGameConfigIntentSchema = z.object({
config: GameConfigSchema.partial(),
});
export const StartGameIntentSchema = z.object({
type: z.literal("start_game"),
export const ToggleGameStartTimerIntentSchema = z.object({
type: z.literal("toggle_game_start_timer"),
});
const IntentSchema = z.discriminatedUnion("type", [
@@ -510,7 +513,7 @@ const IntentSchema = z.discriminatedUnion("type", [
KickPlayerIntentSchema,
TogglePauseIntentSchema,
UpdateGameConfigIntentSchema,
StartGameIntentSchema,
ToggleGameStartTimerIntentSchema,
]);
// StampedIntent = Intent with server-stamped clientID (used in turns and execution)
+11 -21
View File
@@ -149,6 +149,9 @@ export class GameServer {
if (gameConfig.maxTimerValue !== undefined) {
this.gameConfig.maxTimerValue = gameConfig.maxTimerValue ?? undefined;
}
if (gameConfig.startDelay !== undefined) {
this.gameConfig.startDelay = gameConfig.startDelay ?? undefined;
}
if (gameConfig.instantBuild !== undefined) {
this.gameConfig.instantBuild = gameConfig.instantBuild;
}
@@ -488,7 +491,7 @@ export class GameServer {
this.updateGameConfig(stampedIntent.config);
return;
}
case "start_game": {
case "toggle_game_start_timer": {
if (client.clientID !== this.lobbyCreatorID) {
this.log.warn(`Only lobby creator can start game`, {
clientID: client.clientID,
@@ -514,7 +517,13 @@ export class GameServer {
creatorID: client.clientID,
gameID: this.id,
});
this.start();
if (this.startsAt) {
this.startsAt = undefined;
} else {
this.setStartsAt(
Date.now() + (this.gameConfig.startDelay ?? 0) * 1000,
);
}
return;
}
case "toggle_pause": {
@@ -931,25 +940,6 @@ export class GameServer {
const noRecentPings = now > this.lastPingUpdate + 20 * 1000;
const noActive = this.activeClients.length === 0;
if (this.gameConfig.gameType !== GameType.Public) {
if (this._hasStarted) {
if (noActive && noRecentPings) {
this.log.info("private game complete", {
gameID: this.id,
});
return GamePhase.Finished;
} else {
return GamePhase.Active;
}
} else if (this._hasEnded) {
return GamePhase.Finished;
} else {
return GamePhase.Lobby;
}
}
// Public Games
const lessThanLifetime = this.startsAt ? Date.now() < this.startsAt : true;
if (
lessThanLifetime &&