From 2a82b01e3092f4a1ecef4846aa76946f5ade8a94 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 6 Jul 2026 09:11:13 -0700 Subject: [PATCH] feat: auto-start listed lobbies after 5 minutes Hosts can't sit on a public listing: setListed(true) records listedAt, and once listedAt + HOSTED_LOBBY_AUTO_START_MS passes, GameManager's tick arms the normal start countdown (same path as the host's Start button, honoring startDelay). Cancelling the countdown re-arms on the next tick; unlisting cancels the deadline and relisting starts a fresh one. Duplicate setListed(true) calls don't extend it. The deadline rides to the host as autoStartAt in lobby info, rendered as an amber countdown next to the Public/Private switch in the host modal header (hidden once the real start countdown takes over). Co-Authored-By: Claude Fable 5 --- resources/lang/en.json | 1 + src/client/HostLobbyModal.ts | 29 ++++++++++- src/core/Schemas.ts | 8 +++ src/server/GameManager.ts | 3 ++ src/server/GameServer.ts | 38 +++++++++++++- tests/server/HostedLobbyListing.test.ts | 67 ++++++++++++++++++++++++- 6 files changed, 143 insertions(+), 3 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index df9ec4f66..eeb64dc1c 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -753,6 +753,7 @@ "host_modal": { "anonymous_players": "Anonymous players", "assigned_teams": "Assigned Teams", + "auto_start_timer": "Time until the game starts automatically", "bots": "Tribes: ", "bots_disabled": "Disabled", "compact_map": "Compact Map", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index d34317c51..d9cf6930a 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -1,4 +1,4 @@ -import { html } from "lit"; +import { html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { ClientEnv } from "src/client/ClientEnv"; import { @@ -112,6 +112,8 @@ export class HostLobbyModal extends BaseModal { @state() private canListPublicly: boolean = false; @state() private publiclyListed: boolean = false; @state() private showSubscriptionRequired: boolean = false; + // Server timestamp when the listed lobby auto-starts (from lobby info). + @state() private autoStartAt: number | null = null; @property({ attribute: false }) eventBus: EventBus | null = null; // Timers for debouncing slider changes @@ -146,6 +148,7 @@ export class HostLobbyModal extends BaseModal { if (!this.listingRequestInFlight && lobby.listed !== undefined) { this.publiclyListed = lobby.listed; } + this.autoStartAt = lobby.autoStartAt ?? null; }; private getRandomString(): string { @@ -248,9 +251,32 @@ export class HostLobbyModal extends BaseModal { ${segment("host_modal.visibility_private", false)} ${segment("host_modal.visibility_public", true)} + ${this.renderAutoStartTimer()} `; } + // Countdown until the listed lobby starts automatically (hosts can't sit + // on a public listing). Hidden once the real start countdown is running — + // the Start button shows that one. + private renderAutoStartTimer() { + if ( + !this.publiclyListed || + this.autoStartAt === null || + this.lobbyStartAt !== null + ) { + return nothing; + } + const seconds = getSecondsUntilServerTimestamp( + this.autoStartAt, + this.serverTimeOffset, + ); + return html`${renderDuration(seconds)}`; + } + private handleVisibilitySelect(isPublic: boolean) { if ( this.listingRequestInFlight || @@ -738,6 +764,7 @@ export class HostLobbyModal extends BaseModal { this.hostCheatStartingGoldValue = undefined; this.publiclyListed = false; this.showSubscriptionRequired = false; + this.autoStartAt = null; this.leaveLobbyOnClose = true; } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 1e3cd9d2f..425f8d994 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -176,6 +176,11 @@ export type ScheduledPublicGameType = z.infer< // and delists any overflow as the authoritative backstop. export const MAX_HOSTED_LOBBIES = 10; +// How long a lobby may stay publicly listed before it starts automatically, +// so hosts can't sit on a listing indefinitely. Unlisting cancels the +// deadline; relisting starts a fresh one. +export const HOSTED_LOBBY_AUTO_START_MS = 5 * 60 * 1000; + export const UsernameSchema = z .string() .regex(/^(?=.*\S)[a-zA-Z0-9_ üÜ.]+$/u) @@ -207,6 +212,9 @@ export const GameInfoSchema = z.object({ // UI stays in sync when the server delists (whitelist enabled, duplicate // creator resolved by the master). listed: z.boolean().optional(), + // Listed lobbies only: server timestamp when the lobby starts + // automatically (hosts can't sit on a public listing indefinitely). + autoStartAt: z.number().optional(), }); // Browser-facing lobby info. Master/worker-internal fields (the creator hash diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 93a137b8a..eba8a711e 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -126,6 +126,9 @@ export class GameManager { const active = new Map(); for (const [id, game] of this.games) { const phase = game.phase(); + if (phase === GamePhase.Lobby) { + game.maybeAutoStartListed(); + } if (phase === GamePhase.Active) { if (!game.hasStarted()) { // Prestart tells clients to start loading the game. diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 2cfea3d07..0afb2f5f3 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -15,6 +15,7 @@ import { GameInfo, GameStartInfo, GameStartInfoSchema, + HOSTED_LOBBY_AUTO_START_MS, Intent, LiveStats, PlayerLiveStats, @@ -149,6 +150,9 @@ export class GameServer { // only the authenticated /api/game/:id/listing endpoint may (it verifies // the creator's subscription). private listed = false; + // When the lobby was listed; drives the auto-start deadline. Cleared on + // delist, so relisting starts a fresh deadline. + private listedAt?: number; private lobbyInfoIntervalId: ReturnType | null = null; @@ -274,7 +278,7 @@ export class GameServer { // A join whitelist and public listing are mutually exclusive: a listed // lobby must be joinable by anyone who finds it in the lobby browser. if (this.listed && this.hasJoinWhitelist()) { - this.listed = false; + this.setListed(false); this.log.info("delisted lobby: join whitelist enabled"); } } @@ -1154,6 +1158,7 @@ export class GameServer { serverTime: Date.now(), publicGameType: this.publicGameType, listed: this.isPublic() ? undefined : this.listed, + autoStartAt: this.autoStartAt(), }; } @@ -1183,7 +1188,38 @@ export class GameServer { } public setListed(listed: boolean): void { + if (this.listed === listed) { + // Duplicate toggles must not extend the auto-start deadline. + return; + } this.listed = listed; + this.listedAt = listed ? Date.now() : undefined; + } + + // Deadline after which a listed lobby starts automatically, so hosts + // can't sit on a public listing indefinitely. + public autoStartAt(): number | undefined { + return this.listed && this.listedAt !== undefined + ? this.listedAt + HOSTED_LOBBY_AUTO_START_MS + : undefined; + } + + // Called from GameManager's tick while in the Lobby phase: once the + // listed deadline passes, arm the normal start countdown (same path as + // the host's Start button). Cancelling the countdown re-arms it on the + // next tick, so the only way out is to unlist. + public maybeAutoStartListed(): void { + if (this.hasStarted() || this.startsAt !== undefined) { + return; + } + const deadline = this.autoStartAt(); + if (deadline === undefined || Date.now() < deadline) { + return; + } + this.log.info("listed lobby reached auto-start deadline, starting", { + gameID: this.id, + }); + this.setStartsAt(Date.now() + (this.gameConfig.startDelay ?? 0) * 1000); } // Whether joining is restricted to an allowlist of publicIds. A lobby with diff --git a/tests/server/HostedLobbyListing.test.ts b/tests/server/HostedLobbyListing.test.ts index c388dcb00..b052308c8 100644 --- a/tests/server/HostedLobbyListing.test.ts +++ b/tests/server/HostedLobbyListing.test.ts @@ -8,7 +8,10 @@ import { GameMode, GameType, } from "../../src/core/game/Game"; -import { MAX_HOSTED_LOBBIES } from "../../src/core/Schemas"; +import { + HOSTED_LOBBY_AUTO_START_MS, + MAX_HOSTED_LOBBIES, +} from "../../src/core/Schemas"; import { Client } from "../../src/server/Client"; import { GameManager } from "../../src/server/GameManager"; import { @@ -188,6 +191,68 @@ describe("GameManager.listedLobbies", () => { }); }); +describe("listed lobby auto-start", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it("tracks the deadline from when the lobby was listed", () => { + const game = makeGame(); + expect(game.autoStartAt()).toBeUndefined(); + + game.setListed(true); + const deadline = Date.now() + HOSTED_LOBBY_AUTO_START_MS; + expect(game.autoStartAt()).toBe(deadline); + expect(game.gameInfo().autoStartAt).toBe(deadline); + + // A duplicate toggle must not extend the deadline... + vi.setSystemTime(Date.now() + 60_000); + game.setListed(true); + expect(game.autoStartAt()).toBe(deadline); + + // ...unlisting cancels it, and relisting starts a fresh one. + game.setListed(false); + expect(game.autoStartAt()).toBeUndefined(); + game.setListed(true); + expect(game.autoStartAt()).toBe(Date.now() + HOSTED_LOBBY_AUTO_START_MS); + }); + + it("arms the start countdown only once the deadline passes", () => { + const gm = new GameManager(mockLogger); + const game = gm.createGame( + "g-auto-start", + { startDelay: 30 } as any, + CREATOR, + )!; + game.setListed(true); + + vi.setSystemTime(Date.now() + HOSTED_LOBBY_AUTO_START_MS - 1000); + gm.tick(); + expect(game.gameInfo().startsAt).toBeUndefined(); + + vi.setSystemTime(Date.now() + 2000); + gm.tick(); + expect(game.gameInfo().startsAt).toBe(Date.now() + 30_000); + // Still listed while the start countdown runs (the phase change on + // start delists). + expect(gm.listedLobbies()).toHaveLength(1); + }); + + it("never auto-starts an unlisted lobby", () => { + const gm = new GameManager(mockLogger); + const game = gm.createGame("g-manual", undefined, CREATOR)!; + + vi.setSystemTime(Date.now() + HOSTED_LOBBY_AUTO_START_MS * 2); + gm.tick(); + expect(game.gameInfo().startsAt).toBeUndefined(); + }); +}); + function fakeWs() { const ws = new EventEmitter() as any; ws.readyState = WebSocket.OPEN;