mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-11 03:24:16 +00:00
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:
@@ -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)}
|
||||
</div>
|
||||
${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`<span
|
||||
class="text-amber-300 text-xs font-bold tabular-nums shrink-0"
|
||||
title=${translateText("host_modal.auto_start_timer")}
|
||||
>${renderDuration(seconds)}</span
|
||||
>`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -126,6 +126,9 @@ export class GameManager {
|
||||
const active = new Map<GameID, GameServer>();
|
||||
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.
|
||||
|
||||
@@ -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<typeof setInterval> | 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
|
||||
|
||||
Reference in New Issue
Block a user