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 <noreply@anthropic.com>
This commit is contained in:
evanpelle
2026-07-06 09:11:13 -07:00
parent 5d21be826d
commit 2a82b01e30
6 changed files with 143 additions and 3 deletions
+66 -1
View File
@@ -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;