From 895a4bf6dc9310e74dac8c511b6c3fe06ac96710 Mon Sep 17 00:00:00 2001 From: frederikja Date: Sun, 7 Jun 2026 11:17:17 +0200 Subject: [PATCH 01/10] 4169 Delayed lobby start Fixes #4169 Adds a delayed lobby start option. --- resources/lang/en.json | 3 + src/client/HostLobbyModal.ts | 102 +++++++++++++++++++++++++++++-- src/client/JoinLobbyModal.ts | 81 ++++++++++-------------- src/client/Main.ts | 5 +- src/client/Transport.ts | 10 +-- src/core/Schemas.ts | 1 + src/server/GameServer.ts | 21 +------ src/server/WorkerLobbyService.ts | 2 +- 8 files changed, 146 insertions(+), 79 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 2ddf13e150..38111c8d1b 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -629,6 +629,7 @@ "game_length": "Game length", "pvp_immunity": "PVP immunity duration", "starting_gold": "Starting Gold", + "status": "Status", "host_cheats": "Host Cheats" }, "public_lobby": { @@ -707,6 +708,8 @@ "gold_multiplier_placeholder": "2.0x", "starting_gold": "Starting Gold (Millions)", "starting_gold_placeholder": "5", + "start_delay": "Start delay (Seconds)", + "start_delay_placeholder": "60", "host_cheats": "Host Cheats", "leave_confirmation": "Are you sure you want to leave the lobby?" }, diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index b3991e0004..3e815297fe 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -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, @@ -72,6 +77,8 @@ export class HostLobbyModal extends BaseModal { @state() private goldMultiplierValue: number | undefined = undefined; @state() private startingGold: boolean = false; @state() private startingGoldValue: number | undefined = undefined; + @state() private startDelay: boolean = true; + @state() private startDelayValue: number | undefined = 3; @state() private disableAlliances: boolean = false; @state() private waterNukes: boolean = false; @state() private lobbyId = ""; @@ -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 + ? null + : secondsRemaining > 0 + ? translateText("public_lobby.starting_in", { + time: renderDuration(secondsRemaining), + }) + : translateText("public_lobby.started"); + const inputCards = [ html``, + html``, ]; const hostCheatInputCards = [ @@ -412,18 +457,35 @@ export class HostLobbyModal extends BaseModal { + ${statusLabel !== null + ? html` +
+ +
+ ` + : html`
+ `} `; } @@ -737,6 +799,15 @@ export class HostLobbyModal extends BaseModal { this.putGameConfig(); }; + private handleStartDelayToggle = ( + checked: boolean, + value: number | string | undefined, + ) => { + this.startDelay = checked; + this.startDelayValue = toOptionalNumber(value); + this.putGameConfig(); + }; + private handleSpawnImmunityDurationKeyDown = (e: KeyboardEvent) => { preventDisallowedKeys(e, ["-", "+", "e", "E"]); }; @@ -772,6 +843,10 @@ export class HostLobbyModal extends BaseModal { preventDisallowedKeys(e, ["-", "+", "e", "E"]); }; + private handleStartDelayValueKeyDown = (e: KeyboardEvent) => { + preventDisallowedKeys(e, ["-", "+", "e", "E"]); + }; + private handleStartingGoldValueChanges = (e: Event) => { const input = e.target as HTMLInputElement; const value = parseBoundedFloatFromInput(input, { @@ -788,6 +863,22 @@ export class HostLobbyModal extends BaseModal { this.putGameConfig(); }; + private handleStartDelayValueChanges = (e: Event) => { + const input = e.target as HTMLInputElement; + const value = parseBoundedFloatFromInput(input, { + min: 0.1, + max: 1000, + }); + + if (value === undefined) { + this.startDelayValue = undefined; + input.value = ""; + } else { + this.startDelayValue = value; + } + this.putGameConfig(); + }; + private handleHostCheatGoldMultiplierToggle = ( checked: boolean, value: number | string | undefined, @@ -1009,6 +1100,9 @@ export class HostLobbyModal extends BaseModal { new CustomEvent("start-game", { bubbles: true, composed: true, + detail: { + startDelay: this.startDelayValue, + }, }), ); } diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts index 115bff0fa4..4ced7c3184 100644 --- a/src/client/JoinLobbyModal.ts +++ b/src/client/JoinLobbyModal.ts @@ -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 { `} - ${this.isPrivateLobby() - ? html` -
- + ${statusLabel}
- ` - : html` -
-
-
- ${translateText("public_lobby.status")} - ${statusLabel} 0 + ? html` +
-
- ${maxPlayers > 0 - ? html` -
- ${playerCount}/${maxPlayers} - - - -
- ` - : html``} -
-
- `} + ${playerCount}/${maxPlayers} + + + +
+ ` + : html``} + + + `} `; } diff --git a/src/client/Main.ts b/src/client/Main.ts index bd73b5f46c..aba8c5a320 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -1007,9 +1007,10 @@ class Client { } } - private handleStartGame() { + private handleStartGame(event: CustomEvent) { + const { startDelay } = event.detail; if (this.eventBus) { - this.eventBus.emit(new SendStartGameEvent()); + this.eventBus.emit(new SendStartGameEvent(startDelay)); } } diff --git a/src/client/Transport.ts b/src/client/Transport.ts index fee60b966c..40e9108689 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -174,7 +174,9 @@ export class SendUpdateGameConfigIntentEvent implements GameEvent { constructor(public readonly config: Partial) {} } -export class SendStartGameEvent implements GameEvent {} +export class SendStartGameEvent implements GameEvent { + constructor(public readonly startDelay: number) {} +} export class Transport { private socket: WebSocket | null = null; @@ -266,7 +268,7 @@ export class Transport { this.onSendUpdateGameConfigIntent(e), ); - this.eventBus.on(SendStartGameEvent, () => this.onSendStartGame()); + this.eventBus.on(SendStartGameEvent, (e) => this.onSendStartGame(e)); } private startPing() { @@ -647,8 +649,8 @@ export class Transport { }); } - private onSendStartGame() { - this.sendIntent({ type: "start_game" }); + private onSendStartGame(event: SendStartGameEvent) { + this.sendIntent({ type: "start_game", startDelay: event.startDelay }); } private sendIntent(intent: Intent) { diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index f14a39c76a..4f2c6af1ec 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -459,6 +459,7 @@ export const UpdateGameConfigIntentSchema = z.object({ export const StartGameIntentSchema = z.object({ type: z.literal("start_game"), + startDelay: z.number(), }); const IntentSchema = z.discriminatedUnion("type", [ diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 3ae18efab5..184db57689 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -510,7 +510,7 @@ export class GameServer { creatorID: client.clientID, gameID: this.id, }); - this.start(); + this.setStartsAt(Date.now() + stampedIntent.startDelay * 1000); return; } case "toggle_pause": { @@ -918,25 +918,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 && diff --git a/src/server/WorkerLobbyService.ts b/src/server/WorkerLobbyService.ts index 3c5dab1d52..7ec2ba9e25 100644 --- a/src/server/WorkerLobbyService.ts +++ b/src/server/WorkerLobbyService.ts @@ -70,7 +70,7 @@ export class WorkerLobbyService { }); return; } - game.setStartsAt(msg.startsAt); + game.setStartsAt(msg.startsAt + 1000000); break; } } From da21d3417632f6ed735d0a0a9e048f3ee78d56d5 Mon Sep 17 00:00:00 2001 From: frederikja Date: Sun, 7 Jun 2026 11:49:17 +0200 Subject: [PATCH 02/10] Fix changes suggested by code rabbit, and remove duplicate status key from lang file --- resources/lang/en.json | 1 - src/client/HostLobbyModal.ts | 12 ++++++------ src/core/Schemas.ts | 2 +- src/server/WorkerLobbyService.ts | 2 +- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index e0a7303429..6dd3d695b0 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -638,7 +638,6 @@ "game_length": "Game length", "pvp_immunity": "PVP immunity duration", "starting_gold": "Starting Gold", - "status": "Status", "host_cheats": "Host Cheats" }, "public_lobby": { diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 4314100dd2..ddf341bbfd 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -279,13 +279,13 @@ export class HostLobbyModal extends BaseModal { .labelKey=${"host_modal.start_delay"} .checked=${this.startDelay} .inputId=${"start-delay-value"} - .inputMin=${1} + .inputMin=${0} .inputMax=${600} .inputStep=${"any"} .inputValue=${this.startDelayValue} .inputAriaLabel=${translateText("host_modal.start_delay")} .inputPlaceholder=${translateText("host_modal.start_delay_placeholder")} - .defaultInputValue=${5} + .defaultInputValue=${3} .minValidOnEnable=${1} .onToggle=${this.handleStartDelayToggle} .onChange=${this.handleStartDelayValueChanges} @@ -867,9 +867,9 @@ export class HostLobbyModal extends BaseModal { private handleStartDelayValueChanges = (e: Event) => { const input = e.target as HTMLInputElement; - const value = parseBoundedFloatFromInput(input, { - min: 0.1, - max: 1000, + const value = parseBoundedIntegerFromInput(input, { + min: 0, + max: 600, }); if (value === undefined) { @@ -1103,7 +1103,7 @@ export class HostLobbyModal extends BaseModal { bubbles: true, composed: true, detail: { - startDelay: this.startDelayValue, + startDelay: this.startDelayValue ?? 0, }, }), ); diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index c895c5d19c..41c496ff0a 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -483,7 +483,7 @@ export const UpdateGameConfigIntentSchema = z.object({ export const StartGameIntentSchema = z.object({ type: z.literal("start_game"), - startDelay: z.number(), + startDelay: z.number().min(0).max(600), }); const IntentSchema = z.discriminatedUnion("type", [ diff --git a/src/server/WorkerLobbyService.ts b/src/server/WorkerLobbyService.ts index 77f76e6329..6e7f405a10 100644 --- a/src/server/WorkerLobbyService.ts +++ b/src/server/WorkerLobbyService.ts @@ -85,7 +85,7 @@ export class WorkerLobbyService { }); return; } - game.setStartsAt(msg.startsAt + 1000000); + game.setStartsAt(msg.startsAt); break; } } From c2c4f7c449a0af01f2d5940dc46bd63a62b4a2ec Mon Sep 17 00:00:00 2001 From: FrederikJA Date: Tue, 9 Jun 2026 01:11:27 +0200 Subject: [PATCH 03/10] Modify startDelay logic in HostLobbyModal --- src/client/HostLobbyModal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index ddf341bbfd..39cbed21ef 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -1103,7 +1103,7 @@ export class HostLobbyModal extends BaseModal { bubbles: true, composed: true, detail: { - startDelay: this.startDelayValue ?? 0, + startDelay: this.startDelay ? (this.startDelayValue ?? 0) : 0, }, }), ); From 6ad45e39a768cb298a18597e8cb1849c913af56d Mon Sep 17 00:00:00 2001 From: frederikja Date: Thu, 11 Jun 2026 00:03:12 +0200 Subject: [PATCH 04/10] Move start delay to game config, this is in preparation for #4185 and to make it more similar to other settings --- src/client/HostLobbyModal.ts | 4 +--- src/client/Main.ts | 5 ++--- src/client/Transport.ts | 4 ++-- src/core/Schemas.ts | 2 +- src/server/GameServer.ts | 7 ++++++- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 39cbed21ef..ed82ce669a 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -1058,6 +1058,7 @@ export class HostLobbyModal extends BaseModal { this.defaultNationCount, ), maxTimerValue: this.maxTimer === true ? this.maxTimerValue : null, + startDelay: this.startDelay === true ? this.startDelayValue : null, goldMultiplier: this.goldMultiplier === true ? this.goldMultiplierValue : null, startingGold: @@ -1102,9 +1103,6 @@ export class HostLobbyModal extends BaseModal { new CustomEvent("start-game", { bubbles: true, composed: true, - detail: { - startDelay: this.startDelay ? (this.startDelayValue ?? 0) : 0, - }, }), ); } diff --git a/src/client/Main.ts b/src/client/Main.ts index 5cbdaac929..d63f691c6a 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -1008,10 +1008,9 @@ class Client { } } - private handleStartGame(event: CustomEvent) { - const { startDelay } = event.detail; + private handleStartGame() { if (this.eventBus) { - this.eventBus.emit(new SendStartGameEvent(startDelay)); + this.eventBus.emit(new SendStartGameEvent()); } } diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 40e9108689..1ff49b3e09 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -175,7 +175,7 @@ export class SendUpdateGameConfigIntentEvent implements GameEvent { } export class SendStartGameEvent implements GameEvent { - constructor(public readonly startDelay: number) {} + constructor() {} } export class Transport { @@ -650,7 +650,7 @@ export class Transport { } private onSendStartGame(event: SendStartGameEvent) { - this.sendIntent({ type: "start_game", startDelay: event.startDelay }); + this.sendIntent({ type: "start_game" }); } private sendIntent(intent: Intent) { diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 41c496ff0a..84b7486814 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -281,6 +281,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(), @@ -483,7 +484,6 @@ export const UpdateGameConfigIntentSchema = z.object({ export const StartGameIntentSchema = z.object({ type: z.literal("start_game"), - startDelay: z.number().min(0).max(600), }); const IntentSchema = z.discriminatedUnion("type", [ diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index eb09a321d9..c274219969 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -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; } @@ -514,7 +517,9 @@ export class GameServer { creatorID: client.clientID, gameID: this.id, }); - this.setStartsAt(Date.now() + stampedIntent.startDelay * 1000); + this.setStartsAt( + Date.now() + (this.gameConfig.startDelay ?? 0) * 1000, + ); return; } case "toggle_pause": { From 98994e43d698c191a05ca39abd3c3d85de279a40 Mon Sep 17 00:00:00 2001 From: frederikja Date: Thu, 11 Jun 2026 00:41:55 +0200 Subject: [PATCH 05/10] Turn game start button into a toggle that can cancel again. --- resources/lang/en.json | 1 + src/client/HostLobbyModal.ts | 45 +++++++++++------------------------- src/client/Main.ts | 13 +++++++---- src/client/Transport.ts | 10 ++++---- src/core/Schemas.ts | 12 ++++++---- src/server/GameServer.ts | 12 ++++++---- 6 files changed, 44 insertions(+), 49 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 6dd3d695b0..b86a027104 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -638,6 +638,7 @@ "game_length": "Game length", "pvp_immunity": "PVP immunity duration", "starting_gold": "Starting Gold", + "starting_in": "Starting in {time}. Click to cancel", "host_cheats": "Host Cheats" }, "public_lobby": { diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index ed82ce669a..17ce168f9c 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -202,12 +202,12 @@ export class HostLobbyModal extends BaseModal { : null; const statusLabel = secondsRemaining === null - ? null - : secondsRemaining > 0 - ? translateText("public_lobby.starting_in", { - time: renderDuration(secondsRemaining), - }) - : translateText("public_lobby.started"); + ? this.clients.length === 1 + ? translateText("host_modal.waiting") + : translateText("host_modal.start") + : translateText("private_lobby.starting_in", { + time: renderDuration(secondsRemaining), + }); const inputCards = [ html` @@ -459,35 +460,17 @@ export class HostLobbyModal extends BaseModal { - ${statusLabel !== null - ? html` -
- -
- ` - : html`
- `} + `; } @@ -1090,7 +1073,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)" : ""}`, @@ -1100,7 +1083,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, }), diff --git a/src/client/Main.ts b/src/client/Main.ts index d63f691c6a..08c22f192a 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -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; "kick-player": CustomEvent; - "start-game": CustomEvent; + toggle_game_start_timer: CustomEvent; "join-changed": CustomEvent; "open-matchmaking": CustomEvent; userMeResponse: CustomEvent; @@ -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()); } } diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 1ff49b3e09..8115f32417 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -174,7 +174,7 @@ export class SendUpdateGameConfigIntentEvent implements GameEvent { constructor(public readonly config: Partial) {} } -export class SendStartGameEvent implements GameEvent { +export class SendToggleGameStartTimer implements GameEvent { constructor() {} } @@ -268,7 +268,9 @@ export class Transport { this.onSendUpdateGameConfigIntent(e), ); - this.eventBus.on(SendStartGameEvent, (e) => this.onSendStartGame(e)); + this.eventBus.on(SendToggleGameStartTimer, (e) => + this.onSendToggleGameStartTimer(e), + ); } private startPing() { @@ -649,8 +651,8 @@ export class Transport { }); } - private onSendStartGame(event: SendStartGameEvent) { - this.sendIntent({ type: "start_game" }); + private onSendToggleGameStartTimer(event: SendToggleGameStartTimer) { + this.sendIntent({ type: "toggle_game_start_timer" }); } private sendIntent(intent: Intent) { diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 84b7486814..68bd7f7241 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -51,7 +51,7 @@ export type Intent = | KickPlayerIntent | TogglePauseIntent | UpdateGameConfigIntent - | StartGameIntent; + | ToggleGameStartTimer; export type AttackIntent = z.infer; export type CancelAttackIntent = z.infer; @@ -85,7 +85,9 @@ export type TogglePauseIntent = z.infer; export type UpdateGameConfigIntent = z.infer< typeof UpdateGameConfigIntentSchema >; -export type StartGameIntent = z.infer; +export type ToggleGameStartTimer = z.infer< + typeof ToggleGameStartTimerIntentSchema +>; export type Turn = z.infer; export type GameConfig = z.infer; @@ -482,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", [ @@ -511,7 +513,7 @@ const IntentSchema = z.discriminatedUnion("type", [ KickPlayerIntentSchema, TogglePauseIntentSchema, UpdateGameConfigIntentSchema, - StartGameIntentSchema, + ToggleGameStartTimerIntentSchema, ]); // StampedIntent = Intent with server-stamped clientID (used in turns and execution) diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index c274219969..6b82990082 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -491,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, @@ -517,9 +517,13 @@ export class GameServer { creatorID: client.clientID, gameID: this.id, }); - this.setStartsAt( - Date.now() + (this.gameConfig.startDelay ?? 0) * 1000, - ); + if (this.startsAt) { + this.startsAt = undefined; + } else { + this.setStartsAt( + Date.now() + (this.gameConfig.startDelay ?? 0) * 1000, + ); + } return; } case "toggle_pause": { From 415e11b28c321478ca570ec424b01c283aefba21 Mon Sep 17 00:00:00 2001 From: frederikja Date: Thu, 11 Jun 2026 01:01:37 +0200 Subject: [PATCH 06/10] Fix coderabbitai suggestions --- resources/lang/en.json | 4 ++-- src/client/HostLobbyModal.ts | 36 +++++++++++++++++++----------------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index b86a027104..2d19cbcfb4 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -687,6 +687,8 @@ "nations_disabled": "Disabled", "player_immunity_duration": "PVP immunity duration (minutes)", "max_timer": "Game length (minutes)", + "start_delay": "Start delay (Seconds)", + "start_delay_placeholder": "3", "mins_placeholder": "Mins", "instant_build": "Instant build", "infinite_gold": "Infinite gold", @@ -719,8 +721,6 @@ "gold_multiplier_placeholder": "2.0x", "starting_gold": "Starting Gold (Millions)", "starting_gold_placeholder": "5", - "start_delay": "Start delay (Seconds)", - "start_delay_placeholder": "60", "host_cheats": "Host Cheats", "leave_confirmation": "Are you sure you want to leave the lobby?" }, diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 17ce168f9c..723faa4974 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -224,6 +224,22 @@ export class HostLobbyModal extends BaseModal { .onInput=${this.handleMaxTimerValueChanges} .onKeyDown=${this.handleMaxTimerValueKeyDown} >
`, + html``, html``, - html``, ]; const hostCheatInputCards = [ @@ -466,7 +466,7 @@ export class HostLobbyModal extends BaseModal { width="block" size="lg" .title=${statusLabel} - ?disable=${this.clients.length < 2} + ?disable=${secondsRemaining ?? this.clients.length < 2} @click=${this.toggleGameStartTimer} > @@ -574,6 +574,8 @@ export class HostLobbyModal extends BaseModal { this.donateTroops = false; this.maxTimer = false; this.maxTimerValue = undefined; + this.startDelay = true; + this.startDelayValue = 3; this.instantBuild = false; this.randomSpawn = false; this.compactMap = false; From e26d6cef24d9262bf224e3095ed5ea02f59e4d17 Mon Sep 17 00:00:00 2001 From: frederikja Date: Thu, 11 Jun 2026 01:04:56 +0200 Subject: [PATCH 07/10] Reorder start-delay to be just after maxtimer consistently --- src/client/HostLobbyModal.ts | 62 ++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 723faa4974..27f7271c56 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -70,6 +70,8 @@ export class HostLobbyModal extends BaseModal { @state() private donateTroops: boolean = false; @state() private maxTimer: boolean = false; @state() private maxTimerValue: number | undefined = undefined; + @state() private startDelay: boolean = true; + @state() private startDelayValue: number | undefined = 3; @state() private instantBuild: boolean = false; @state() private randomSpawn: boolean = false; @state() private compactMap: boolean = false; @@ -77,8 +79,6 @@ export class HostLobbyModal extends BaseModal { @state() private goldMultiplierValue: number | undefined = undefined; @state() private startingGold: boolean = false; @state() private startingGoldValue: number | undefined = undefined; - @state() private startDelay: boolean = true; - @state() private startDelayValue: number | undefined = 3; @state() private disableAlliances: boolean = false; @state() private waterNukes: boolean = false; @state() private lobbyId = ""; @@ -759,6 +759,15 @@ export class HostLobbyModal extends BaseModal { this.putGameConfig(); }; + private handleStartDelayToggle = ( + checked: boolean, + value: number | string | undefined, + ) => { + this.startDelay = checked; + this.startDelayValue = toOptionalNumber(value); + this.putGameConfig(); + }; + private handleSpawnImmunityToggle = ( checked: boolean, value: number | string | undefined, @@ -786,15 +795,6 @@ export class HostLobbyModal extends BaseModal { this.putGameConfig(); }; - private handleStartDelayToggle = ( - checked: boolean, - value: number | string | undefined, - ) => { - this.startDelay = checked; - this.startDelayValue = toOptionalNumber(value); - this.putGameConfig(); - }; - private handleSpawnImmunityDurationKeyDown = (e: KeyboardEvent) => { preventDisallowedKeys(e, ["-", "+", "e", "E"]); }; @@ -830,10 +830,6 @@ export class HostLobbyModal extends BaseModal { preventDisallowedKeys(e, ["-", "+", "e", "E"]); }; - private handleStartDelayValueKeyDown = (e: KeyboardEvent) => { - preventDisallowedKeys(e, ["-", "+", "e", "E"]); - }; - private handleStartingGoldValueChanges = (e: Event) => { const input = e.target as HTMLInputElement; const value = parseBoundedFloatFromInput(input, { @@ -850,22 +846,6 @@ export class HostLobbyModal extends BaseModal { this.putGameConfig(); }; - 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 handleHostCheatGoldMultiplierToggle = ( checked: boolean, value: number | string | undefined, @@ -976,6 +956,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; From 10b7b7aca83717fd3654e6f5cefac693e432d6d0 Mon Sep 17 00:00:00 2001 From: frederikja Date: Thu, 11 Jun 2026 01:10:47 +0200 Subject: [PATCH 08/10] Fix coderabbitai suggestion --- src/client/HostLobbyModal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 27f7271c56..a32740951f 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -466,7 +466,7 @@ export class HostLobbyModal extends BaseModal { width="block" size="lg" .title=${statusLabel} - ?disable=${secondsRemaining ?? this.clients.length < 2} + ?disable=${this.lobbyStartAt === null && this.clients.length < 2} @click=${this.toggleGameStartTimer} > From 6338489d91fa8560fbd2053d82bffffa7782f697 Mon Sep 17 00:00:00 2001 From: frederikja Date: Fri, 12 Jun 2026 18:14:38 +0200 Subject: [PATCH 09/10] Order lang-en.json --- resources/lang/en.json | 6 +++--- src/client/HostLobbyModal.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 67c08cbc43..f9ac4f2f81 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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", @@ -1106,7 +1109,6 @@ "sams_disabled_label": "SAMs", "starting_gold": "{amount}M Starting Gold", "starting_gold_label": "Starting Gold", - "starting_in": "Starting in {time}. Click to cancel", "water_nukes": "Water Nukes", "water_nukes_label": "Water Nukes" }, @@ -1117,8 +1119,6 @@ "started": "Started", "starting_game": "Starting…", "starting_in": "Starting in {time}", - "start_delay": "Start delay (Seconds)", - "start_delay_placeholder": "3", "status": "Status", "teams": "{num} teams", "teams_hvn": "Humans vs Nations", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index a32740951f..efc49464d0 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -205,7 +205,7 @@ export class HostLobbyModal extends BaseModal { ? this.clients.length === 1 ? translateText("host_modal.waiting") : translateText("host_modal.start") - : translateText("private_lobby.starting_in", { + : translateText("host_modal.starting_in", { time: renderDuration(secondsRemaining), }); From 797161925a8c0dd20dbc97f458e5117a0a87c9ca Mon Sep 17 00:00:00 2001 From: frederikja Date: Fri, 12 Jun 2026 20:10:00 +0200 Subject: [PATCH 10/10] Add input card and use this for startdelay --- src/client/HostLobbyModal.ts | 21 ++------- src/client/components/InputCard.ts | 57 ++++++++++++++++++++++++ src/client/components/InputCardStyles.ts | 12 +++++ src/client/components/ToggleInputCard.ts | 16 +------ 4 files changed, 75 insertions(+), 31 deletions(-) create mode 100644 src/client/components/InputCard.ts create mode 100644 src/client/components/InputCardStyles.ts diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index efc49464d0..29a0bd9010 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -30,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"; @@ -70,7 +71,6 @@ export class HostLobbyModal extends BaseModal { @state() private donateTroops: boolean = false; @state() private maxTimer: boolean = false; @state() private maxTimerValue: number | undefined = undefined; - @state() private startDelay: boolean = true; @state() private startDelayValue: number | undefined = 3; @state() private instantBuild: boolean = false; @state() private randomSpawn: boolean = false; @@ -224,9 +224,8 @@ export class HostLobbyModal extends BaseModal { .onInput=${this.handleMaxTimerValueChanges} .onKeyDown=${this.handleMaxTimerValueKeyDown} >`, - html``, + >`, html` { - this.startDelay = checked; - this.startDelayValue = toOptionalNumber(value); - this.putGameConfig(); - }; - private handleSpawnImmunityToggle = ( checked: boolean, value: number | string | undefined, @@ -1043,7 +1030,7 @@ export class HostLobbyModal extends BaseModal { this.defaultNationCount, ), maxTimerValue: this.maxTimer === true ? this.maxTimerValue : null, - startDelay: this.startDelay === true ? this.startDelayValue : null, + startDelay: this.startDelayValue, goldMultiplier: this.goldMultiplier === true ? this.goldMultiplierValue : null, startingGold: diff --git a/src/client/components/InputCard.ts b/src/client/components/InputCard.ts new file mode 100644 index 0000000000..74e834b3c9 --- /dev/null +++ b/src/client/components/InputCard.ts @@ -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` +
+
+
+ + + ${translateText(this.labelKey)} + +
+ +
+ +
+
+ `; + } +} diff --git a/src/client/components/InputCardStyles.ts b/src/client/components/InputCardStyles.ts new file mode 100644 index 0000000000..18dac1685f --- /dev/null +++ b/src/client/components/InputCardStyles.ts @@ -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}`; +} diff --git a/src/client/components/ToggleInputCard.ts b/src/client/components/ToggleInputCard.ts index b6b816db20..58bd77f3fd 100644 --- a/src/client/components/ToggleInputCard.ts +++ b/src/client/components/ToggleInputCard.ts @@ -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` -
+