mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-07 15:06:00 +00:00
feat: subscriber-hosted public lobby listing (#4480)
Part of #4040 (v1 scope: listing + browser + per-subscriber limit; custom lobby name/description left for a follow-up). ## What Subscribers can toggle their **private lobby** to be **publicly listed**; a browsable **"Open Lobbies"** list appears in the Join Lobby modal. Hard limit of **one listed lobby per subscriber**, enforced cluster-wide. ## How **Semantics** — a listed lobby stays `GameType.Private`: the host keeps full control and starts the game manually; the toggle only controls visibility. The `listed` flag lives on `GameServer` (not `GameConfig`), so it cannot be smuggled in through `update_game_config` and never touches core/sim/records. **Distribution** — reuses the existing public-lobby pipeline end to end: a new `"hosted"` `PublicGameType` bucket flows worker → master IPC → `/lobbies` websocket → `PublicLobbySocket`. Master scheduling now iterates only `SCHEDULED_PUBLIC_GAME_TYPES` (`ffa`/`team`/`special`), so it never sets countdowns on or schedules replacements for hosted lobbies. Lobbies delist automatically when the game starts/fills/dies (phase change). The broadcast fingerprint now includes browser-visible config, so host edits (map/mode) refresh the list even though the gameID doesn't change. **Gating** — new authenticated endpoint `POST /api/game/:id/listing`: - creator-only (403), private + not-started only (409) - fresh subscription check via server-side `getUserMe` using the shared `hasActiveSubscription()` helper (`active`/`trialing`); skipped in `GameEnv.Dev` (same precedent as Turnstile) so it's testable locally - one-lobby-per-creator (409): a SHA-256 hash of the creator's persistentID rides worker↔master IPC (`PublicGameInfo.creatorID`); the master dedupes as a race backstop. The hash — and host-only config (whitelist, name reveals) — are **stripped from every client payload** (broadcast + primed snapshot). **Client** — subscriber-gated "List lobby publicly" toggle in the host modal (server rejection reverts the toggle and shows a translated message); "Open Lobbies" rows (map, mode, player count) in the Join Lobby modal that reuse the existing private-join flow. **Compat** — `PublicGames.games` is now a `partialRecord`, so newer clients tolerate servers that don't send every bucket. Note: already-open old clients will fail to parse broadcasts containing the new `hosted` key until refreshed (closed Zod enum) — same class of break as previous wire-schema changes. ## Testing - `tests/server/HostedLobbyListing.test.ts` (15 tests): listed-lobby filtering, flag not settable via config intent, master aggregation + creator dedupe + no scheduling of hosted, creatorID stripping (broadcast + primed snapshot), `creatorHasListedLobby` (broadcast + local), fingerprint refresh on config change - `hasActiveSubscription` cases in `ApiSchemas.test.ts`; hosted counts-delta patch in `LobbySocket.test.ts` - Full suite green (1723 + 141 tests), tsc/eslint/prettier clean - **E2E in the real app** (headless Chromium, two browser contexts): host lists lobby → appears in second browser's Join Lobby list (creatorID absent from payload) → join succeeds (2 players in lobby) → same creator's second lobby rejected 409 with toggle revert → unlist removes it from a fresh browser's list. Curl negatives: missing auth 400, bad token 401, non-creator 403, missing game 404, bad body 400. ## Known follow-ups - Custom lobby name/description in the browser (needs the censor pipeline) — rest of #4040 - A listed lobby whose host closes the tab stays advertised indefinitely (an empty private lobby never leaves the Lobby phase) — pre-existing lifecycle, now more visible; consider delisting on creator disconnect 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+15
-2
@@ -794,13 +794,18 @@
|
||||
"starting_gold": "Starting Gold (Millions)",
|
||||
"starting_gold_placeholder": "5",
|
||||
"starting_in": "Starting in {time}. Click to cancel",
|
||||
"subscription_required_body": "You must be subscribed to make your lobby public.",
|
||||
"subscription_required_title": "Subscription Required",
|
||||
"team_count": "Number of Teams",
|
||||
"teams_Duos": "Duos (teams of 2)",
|
||||
"teams_Humans Vs Nations": "Humans vs Nations",
|
||||
"teams_Quads": "Quads (teams of 4)",
|
||||
"teams_Trios": "Trios (teams of 3)",
|
||||
"title": "Create Private Lobby",
|
||||
"title": "Create Lobby",
|
||||
"toggle_name_reveal": "Toggle whether this player can see real names",
|
||||
"view_subscriptions": "View Subscriptions",
|
||||
"visibility_private": "Private",
|
||||
"visibility_public": "Public",
|
||||
"waiting": "Waiting for players...",
|
||||
"water_nukes": "Water nukes"
|
||||
},
|
||||
@@ -1187,10 +1192,18 @@
|
||||
"host_cheats": "Host Cheats",
|
||||
"join_lobby": "Join Lobby",
|
||||
"joined_waiting": "Lobby joined! Waiting for host to start...",
|
||||
"listing_failed": "Failed to update the lobby listing. Please try again.",
|
||||
"listing_full": "The public lobby list is full. Please try again later.",
|
||||
"listing_host_cheats_enabled": "Disable host cheats to list your lobby publicly.",
|
||||
"listing_limit_reached": "You already have a publicly listed lobby.",
|
||||
"listing_requires_subscription": "An active subscription is required to list a lobby publicly.",
|
||||
"listing_whitelist_enabled": "Disable the join whitelist to list your lobby publicly.",
|
||||
"no_open_lobbies": "No open lobbies right now",
|
||||
"not_found": "Lobby not found. Please check the ID and try again.",
|
||||
"open_lobbies": "Open Lobbies",
|
||||
"pvp_immunity": "PVP immunity duration",
|
||||
"starting_gold": "Starting Gold",
|
||||
"title": "Join Private Lobby",
|
||||
"title": "Join Lobby",
|
||||
"version_mismatch": "This game was created with a different version. Cannot join."
|
||||
},
|
||||
"public_game_modifier": {
|
||||
|
||||
+57
-1
@@ -15,7 +15,8 @@ import {
|
||||
UserMeResponseSchema,
|
||||
} from "../core/ApiSchemas";
|
||||
import { AnalyticsRecord, AnalyticsRecordSchema } from "../core/Schemas";
|
||||
import { getAuthHeader, logOut, userAuth } from "./Auth";
|
||||
import { getAuthHeader, getPlayToken, logOut, userAuth } from "./Auth";
|
||||
import { ClientEnv } from "./ClientEnv";
|
||||
|
||||
export async function fetchPlayerById(
|
||||
playerId: string,
|
||||
@@ -314,6 +315,61 @@ export async function openSubscriptionPortal(): Promise<string | false> {
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/game/:id on the game server (worker) — whether the game is a
|
||||
// publicly listed lobby. False on any failure: callers use this to hide
|
||||
// host powers that the server blocks in listed games anyway, so the safe
|
||||
// default is to change nothing.
|
||||
export async function fetchLobbyListed(gameID: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/${ClientEnv.workerPath(gameID)}/api/game/${gameID}`,
|
||||
{ headers: { Accept: "application/json" } },
|
||||
);
|
||||
if (!res.ok) return false;
|
||||
const json = await res.json();
|
||||
return json?.listed === true;
|
||||
} catch (e) {
|
||||
console.warn("fetchLobbyListed: request failed", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/game/:id/listing on the game server (worker) — toggles whether a
|
||||
// private lobby appears in the public lobby browser. Creator-only and
|
||||
// server-authoritative (subscription, whitelist/cheat and quota checks).
|
||||
// On failure, `error` is the server's rejection code when available (e.g.
|
||||
// "subscription_required", "listing_limit_reached", "listing_full").
|
||||
export async function setLobbyListed(
|
||||
gameID: string,
|
||||
listed: boolean,
|
||||
): Promise<{ ok: true; listed: boolean } | { ok: false; error?: string }> {
|
||||
try {
|
||||
const token = await getPlayToken();
|
||||
const response = await fetch(
|
||||
`/${ClientEnv.workerPath(gameID)}/api/game/${gameID}/listing`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ listed }),
|
||||
},
|
||||
);
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
return { ok: false, error: body?.error };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
listed: typeof body?.listed === "boolean" ? body.listed : listed,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("setLobbyListed: request failed", e);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function getApiBase() {
|
||||
const domainname = getAudience();
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ export class GameModeSelector extends LitElement {
|
||||
translateText("main.join"),
|
||||
this.openJoinLobby,
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]",
|
||||
this.hostedLobbyCount(),
|
||||
)}
|
||||
</div>
|
||||
<!-- iOS Add to Home Screen banner -->
|
||||
@@ -236,6 +237,7 @@ export class GameModeSelector extends LitElement {
|
||||
translateText("main.join"),
|
||||
this.openJoinLobby,
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]",
|
||||
this.hostedLobbyCount(),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,21 +270,34 @@ export class GameModeSelector extends LitElement {
|
||||
(document.querySelector("join-lobby-modal") as JoinLobbyModal)?.open();
|
||||
};
|
||||
|
||||
// Number of open hosted lobbies waiting in the browser; shown as a chip
|
||||
// on the Join button.
|
||||
private hostedLobbyCount(): number {
|
||||
return this.lobbies?.games?.hosted?.length ?? 0;
|
||||
}
|
||||
|
||||
private renderSmallActionCard(
|
||||
title: string,
|
||||
onClick: () => void,
|
||||
bgClass: string = CARD_BG,
|
||||
badge?: number,
|
||||
) {
|
||||
return html`
|
||||
<button
|
||||
@click=${onClick}
|
||||
?disabled=${!this.inputValid}
|
||||
class="flex items-center justify-center w-full h-full rounded-lg ${bgClass} transition-all duration-200 text-sm lg:text-base font-medium text-white uppercase tracking-wider text-center ${!this
|
||||
class="relative flex items-center justify-center w-full h-full rounded-lg ${bgClass} transition-all duration-200 text-sm lg:text-base font-medium text-white uppercase tracking-wider text-center ${!this
|
||||
.inputValid
|
||||
? "opacity-50 cursor-not-allowed pointer-events-none"
|
||||
: ""}"
|
||||
>
|
||||
${title}
|
||||
${badge
|
||||
? html`<span
|
||||
class="absolute -top-2 -right-2 min-w-[1.375rem] h-[1.375rem] px-1.5 flex items-center justify-center rounded-full bg-red-500 text-white text-xs font-bold tracking-normal"
|
||||
>${badge}</span
|
||||
>`
|
||||
: nothing}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
+162
-20
@@ -5,8 +5,11 @@ import {
|
||||
calculateServerTimeOffset,
|
||||
getSecondsUntilServerTimestamp,
|
||||
renderDuration,
|
||||
showToast,
|
||||
translateText,
|
||||
} from "../client/Utils";
|
||||
import { hasActiveSubscription } from "../core/ApiSchemas";
|
||||
import { GameEnv } from "../core/configuration/Config";
|
||||
import { EventBus } from "../core/EventBus";
|
||||
import { DoomsdayClockSpeed } from "../core/game/DoomsdayClock";
|
||||
import {
|
||||
@@ -25,9 +28,11 @@ import {
|
||||
TeamCountConfig,
|
||||
isValidGameID,
|
||||
} from "../core/Schemas";
|
||||
import { getUserMe, setLobbyListed } from "./Api";
|
||||
import { getPlayToken } from "./Auth";
|
||||
import "./components/baseComponents/Modal";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/ConfirmDialog";
|
||||
import { CopyButton } from "./components/CopyButton";
|
||||
import "./components/GameConfigSettings";
|
||||
import "./components/InputCard";
|
||||
@@ -102,6 +107,11 @@ export class HostLobbyModal extends BaseModal {
|
||||
@state() private lobbyCreatorClientID: string = "";
|
||||
@state() private lobbyStartAt: number | null = null;
|
||||
@state() private serverTimeOffset: number = 0;
|
||||
// Whether this user may actually make the lobby public (subscribers, or
|
||||
// anyone in dev). The toggle itself is always shown.
|
||||
@state() private canListPublicly: boolean = false;
|
||||
@state() private publiclyListed: boolean = false;
|
||||
@state() private showSubscriptionRequired: boolean = false;
|
||||
|
||||
@property({ attribute: false }) eventBus: EventBus | null = null;
|
||||
// Timers for debouncing slider changes
|
||||
@@ -112,6 +122,11 @@ export class HostLobbyModal extends BaseModal {
|
||||
|
||||
private leaveLobbyOnClose = true;
|
||||
|
||||
// Guards against overlapping listing requests: rapid Public/Private clicks
|
||||
// could otherwise be applied out of order by the server, leaving the UI
|
||||
// showing the opposite of the real listed state.
|
||||
private listingRequestInFlight = false;
|
||||
|
||||
private readonly handleLobbyInfo = (event: LobbyInfoEvent) => {
|
||||
const lobby = event.lobby;
|
||||
if (!this.lobbyId || lobby.gameID !== this.lobbyId) {
|
||||
@@ -125,6 +140,12 @@ export class HostLobbyModal extends BaseModal {
|
||||
if (lobby.clients) {
|
||||
this.clients = lobby.clients;
|
||||
}
|
||||
// The server can delist on its own (join whitelist enabled, duplicate
|
||||
// creator resolved by the master); follow its state unless our own
|
||||
// toggle request is mid-flight.
|
||||
if (!this.listingRequestInFlight && lobby.listed !== undefined) {
|
||||
this.publiclyListed = lobby.listed;
|
||||
}
|
||||
};
|
||||
|
||||
private getRandomString(): string {
|
||||
@@ -182,7 +203,14 @@ export class HostLobbyModal extends BaseModal {
|
||||
|
||||
protected renderHeaderSlot() {
|
||||
return modalHeader({
|
||||
title: translateText("host_modal.title"),
|
||||
titleContent: html`
|
||||
<span
|
||||
class="text-white text-xl lg:text-2xl font-bold uppercase tracking-widest break-words hyphens-auto"
|
||||
>
|
||||
${translateText("host_modal.title")}
|
||||
</span>
|
||||
${this.renderVisibilityToggle()}
|
||||
`,
|
||||
onBack: () => {
|
||||
this.leaveLobbyOnClose = true;
|
||||
this.close();
|
||||
@@ -198,6 +226,46 @@ export class HostLobbyModal extends BaseModal {
|
||||
});
|
||||
}
|
||||
|
||||
// Private/Public segmented toggle in the header. Shown to everyone;
|
||||
// non-subscribers get a subscription-required dialog instead of a listing
|
||||
// request (the server re-checks the subscription regardless).
|
||||
private renderVisibilityToggle() {
|
||||
const segment = (labelKey: string, isPublic: boolean) => html`
|
||||
<button
|
||||
class="px-3 py-1 text-[10px] font-bold uppercase tracking-widest rounded-full transition-all ${this
|
||||
.publiclyListed === isPublic
|
||||
? "bg-malibu-blue text-white"
|
||||
: "text-white/50 hover:text-white"}"
|
||||
@click=${() => this.handleVisibilitySelect(isPublic)}
|
||||
>
|
||||
${translateText(labelKey)}
|
||||
</button>
|
||||
`;
|
||||
return html`
|
||||
<div
|
||||
class="flex items-center rounded-full border border-white/10 bg-white/5 p-0.5 shrink-0"
|
||||
>
|
||||
${segment("host_modal.visibility_private", false)}
|
||||
${segment("host_modal.visibility_public", true)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleVisibilitySelect(isPublic: boolean) {
|
||||
if (
|
||||
this.listingRequestInFlight ||
|
||||
isPublic === this.publiclyListed ||
|
||||
!this.lobbyId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isPublic && !this.canListPublicly) {
|
||||
this.showSubscriptionRequired = true;
|
||||
return;
|
||||
}
|
||||
void this.handlePublicListingToggle(isPublic);
|
||||
}
|
||||
|
||||
protected renderBody() {
|
||||
const secondsRemaining =
|
||||
this.lobbyStartAt !== null
|
||||
@@ -294,19 +362,26 @@ export class HostLobbyModal extends BaseModal {
|
||||
.onChange=${this.handleStartingGoldValueChanges}
|
||||
.onKeyDown=${this.handleStartingGoldValueKeyDown}
|
||||
></toggle-input-card>`,
|
||||
html`<toggle-input-card
|
||||
.labelKey=${"host_modal.player_whitelist"}
|
||||
.checked=${this.whitelistEnabled}
|
||||
.inputType=${"text"}
|
||||
.inputId=${"allowed-public-ids"}
|
||||
.inputValue=${this.allowedPublicIds}
|
||||
.inputAriaLabel=${translateText("host_modal.player_whitelist")}
|
||||
.inputPlaceholder=${translateText(
|
||||
"host_modal.player_whitelist_placeholder",
|
||||
)}
|
||||
.onToggle=${this.handleWhitelistToggle}
|
||||
.onChange=${this.handleAllowedPublicIdsChange}
|
||||
></toggle-input-card>`,
|
||||
// A join whitelist and public listing are mutually exclusive (the
|
||||
// server rejects both combinations), so the control disappears while
|
||||
// the lobby is listed.
|
||||
...(this.publiclyListed
|
||||
? []
|
||||
: [
|
||||
html`<toggle-input-card
|
||||
.labelKey=${"host_modal.player_whitelist"}
|
||||
.checked=${this.whitelistEnabled}
|
||||
.inputType=${"text"}
|
||||
.inputId=${"allowed-public-ids"}
|
||||
.inputValue=${this.allowedPublicIds}
|
||||
.inputAriaLabel=${translateText("host_modal.player_whitelist")}
|
||||
.inputPlaceholder=${translateText(
|
||||
"host_modal.player_whitelist_placeholder",
|
||||
)}
|
||||
.onToggle=${this.handleWhitelistToggle}
|
||||
.onChange=${this.handleAllowedPublicIdsChange}
|
||||
></toggle-input-card>`,
|
||||
]),
|
||||
];
|
||||
|
||||
const hostCheatInputCards = [
|
||||
@@ -431,16 +506,23 @@ export class HostLobbyModal extends BaseModal {
|
||||
checked: this.doomsdayClock,
|
||||
doomsdayClockSpeed: this.doomsdayClockSpeed,
|
||||
},
|
||||
{
|
||||
labelKey: "host_modal.host_cheats",
|
||||
checked: this.hostCheatsEnabled,
|
||||
},
|
||||
// Host cheats and public listing are mutually exclusive
|
||||
// (the server rejects both combinations), so the controls
|
||||
// disappear while the lobby is listed.
|
||||
...(this.publiclyListed
|
||||
? []
|
||||
: [
|
||||
{
|
||||
labelKey: "host_modal.host_cheats",
|
||||
checked: this.hostCheatsEnabled,
|
||||
},
|
||||
]),
|
||||
],
|
||||
inputCards,
|
||||
},
|
||||
hostCheats: {
|
||||
titleKey: "host_modal.host_cheats",
|
||||
visible: this.hostCheatsEnabled,
|
||||
visible: this.hostCheatsEnabled && !this.publiclyListed,
|
||||
toggles: [
|
||||
{
|
||||
labelKey: "host_modal.infinite_gold",
|
||||
@@ -481,7 +563,9 @@ export class HostLobbyModal extends BaseModal {
|
||||
.currentClientID=${this.lobbyCreatorClientID}
|
||||
.teamCount=${this.teamCount}
|
||||
.nationCount=${this.nations}
|
||||
.onKickPlayer=${(clientID: string) => this.kickPlayer(clientID)}
|
||||
.onKickPlayer=${this.publiclyListed
|
||||
? undefined
|
||||
: (clientID: string) => this.kickPlayer(clientID)}
|
||||
.onToggleNameReveal=${(clientID: string) =>
|
||||
this.toggleNameReveal(clientID)}
|
||||
.nameReveals=${this.nameReveals}
|
||||
@@ -500,12 +584,37 @@ export class HostLobbyModal extends BaseModal {
|
||||
@click=${this.toggleGameStartTimer}
|
||||
></o-button>
|
||||
</div>
|
||||
|
||||
${this.showSubscriptionRequired
|
||||
? html`<confirm-dialog
|
||||
.heading=${translateText(
|
||||
"host_modal.subscription_required_title",
|
||||
)}
|
||||
.message=${translateText("host_modal.subscription_required_body")}
|
||||
variant="warning"
|
||||
.showClose=${true}
|
||||
.buttons=${"confirmOnly"}
|
||||
.confirmText=${translateText("host_modal.view_subscriptions")}
|
||||
@cancel=${() => (this.showSubscriptionRequired = false)}
|
||||
@confirm=${() => {
|
||||
this.showSubscriptionRequired = false;
|
||||
window.location.href = "/#modal=store&tab=subscriptions";
|
||||
}}
|
||||
></confirm-dialog>`
|
||||
: ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected onOpen(): void {
|
||||
this.startLobbyUpdates();
|
||||
void getUserMe().then((userMe) => {
|
||||
// Dev skips the subscription gate (matching the server) so the
|
||||
// listing flow is testable locally.
|
||||
this.canListPublicly =
|
||||
ClientEnv.env() === GameEnv.Dev ||
|
||||
(userMe !== false && hasActiveSubscription(userMe));
|
||||
});
|
||||
// The server mints the game id, so we don't know it until createLobby
|
||||
// resolves. clientID is assigned by the server when we join the lobby.
|
||||
|
||||
@@ -627,6 +736,8 @@ export class HostLobbyModal extends BaseModal {
|
||||
this.hostCheatGoldMultiplierValue = undefined;
|
||||
this.hostCheatStartingGold = false;
|
||||
this.hostCheatStartingGoldValue = undefined;
|
||||
this.publiclyListed = false;
|
||||
this.showSubscriptionRequired = false;
|
||||
|
||||
this.leaveLobbyOnClose = true;
|
||||
}
|
||||
@@ -1050,6 +1161,37 @@ export class HostLobbyModal extends BaseModal {
|
||||
this.putGameConfig();
|
||||
};
|
||||
|
||||
// Server-authoritative: it re-verifies the subscription and enforces the
|
||||
// listing limits, so a failed request reverts the toggle.
|
||||
private async handlePublicListingToggle(checked: boolean) {
|
||||
this.listingRequestInFlight = true;
|
||||
this.publiclyListed = checked;
|
||||
const result = await setLobbyListed(this.lobbyId, checked);
|
||||
if (result.ok) {
|
||||
this.publiclyListed = result.listed;
|
||||
} else {
|
||||
this.publiclyListed = !checked;
|
||||
this.showListingError(result.error);
|
||||
}
|
||||
this.listingRequestInFlight = false;
|
||||
}
|
||||
|
||||
private showListingError(serverError?: string) {
|
||||
let key = "private_lobby.listing_failed";
|
||||
if (serverError === "subscription_required") {
|
||||
key = "private_lobby.listing_requires_subscription";
|
||||
} else if (serverError === "listing_limit_reached") {
|
||||
key = "private_lobby.listing_limit_reached";
|
||||
} else if (serverError === "listing_whitelist_enabled") {
|
||||
key = "private_lobby.listing_whitelist_enabled";
|
||||
} else if (serverError === "listing_host_cheats_enabled") {
|
||||
key = "private_lobby.listing_host_cheats_enabled";
|
||||
} else if (serverError === "listing_full") {
|
||||
key = "private_lobby.listing_full";
|
||||
}
|
||||
showToast(translateText(key), "red", 3000);
|
||||
}
|
||||
|
||||
private handleAllowedPublicIdsChange = (e: Event) => {
|
||||
this.allowedPublicIds = (e.target as HTMLInputElement).value;
|
||||
this.putGameConfig();
|
||||
|
||||
+116
-18
@@ -29,6 +29,7 @@ import {
|
||||
} from "../core/game/Game";
|
||||
import { getApiBase } from "./Api";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import { PublicLobbySocket } from "./LobbySocket";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
|
||||
import { normaliseMapKey } from "./Utils";
|
||||
@@ -55,11 +56,18 @@ export class JoinLobbyModal extends BaseModal {
|
||||
@state() private serverTimeOffset: number = 0;
|
||||
@state() private isConnecting: boolean = true;
|
||||
@state() private lobbyCreatorClientID: string | null = null;
|
||||
// Subscriber-hosted private lobbies listed in the public browser, shown on
|
||||
// the pre-join form.
|
||||
@state() private hostedLobbies: PublicGameInfo[] = [];
|
||||
|
||||
private leaveLobbyOnClose = true;
|
||||
private countdownTimerId: number | null = null;
|
||||
private handledJoinTimeout = false;
|
||||
|
||||
private readonly hostedLobbySocket = new PublicLobbySocket((lobbies) => {
|
||||
this.hostedLobbies = lobbies.games?.hosted ?? [];
|
||||
});
|
||||
|
||||
private isPrivateLobby(): boolean {
|
||||
return this.gameConfig?.gameType === GameType.Private;
|
||||
}
|
||||
@@ -205,7 +213,8 @@ export class JoinLobbyModal extends BaseModal {
|
||||
|
||||
private renderJoinForm() {
|
||||
return html`
|
||||
<form @submit=${this.joinLobbyFromInput} class="custom-scrollbar p-6 space-y-4 mr-1">
|
||||
<div class="custom-scrollbar p-6 space-y-4 mr-1">
|
||||
<form @submit=${this.joinLobbyFromInput}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
@@ -242,12 +251,94 @@ export class JoinLobbyModal extends BaseModal {
|
||||
submit
|
||||
></o-button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
${this.renderHostedLobbies()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderHostedLobbies() {
|
||||
return html`
|
||||
<div class="pt-2">
|
||||
<div
|
||||
class="text-[10px] font-bold uppercase tracking-widest text-white/40 mb-2"
|
||||
>
|
||||
${translateText("private_lobby.open_lobbies")}
|
||||
</div>
|
||||
${this.hostedLobbies.length === 0
|
||||
? html`<p class="text-sm text-white/50">
|
||||
${translateText("private_lobby.no_open_lobbies")}
|
||||
</p>`
|
||||
: html`<div class="flex flex-col gap-2">
|
||||
${this.hostedLobbies.map((lobby) =>
|
||||
this.renderHostedLobbyRow(lobby),
|
||||
)}
|
||||
</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderHostedLobbyRow(lobby: PublicGameInfo) {
|
||||
const c = lobby.gameConfig;
|
||||
const mapName = c ? (getMapName(c.gameMap) ?? c.gameMap) : "";
|
||||
const thumbnailUrl = c
|
||||
? assetUrl(
|
||||
`maps/${encodeURIComponent(normaliseMapKey(c.gameMap))}/thumbnail.webp`,
|
||||
)
|
||||
: "";
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
@click=${() => this.joinHostedLobby(lobby)}
|
||||
class="w-full px-3 py-2 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 active:bg-white/15 transition-all flex items-center gap-3 text-left"
|
||||
>
|
||||
<img
|
||||
src=${thumbnailUrl}
|
||||
alt=${mapName}
|
||||
class="w-12 h-12 rounded-lg object-cover border border-white/10 shrink-0"
|
||||
@error=${(e: Event) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<span class="text-sm font-bold text-white truncate">${mapName}</span>
|
||||
<span class="text-xs text-white/60"
|
||||
>${c ? this.modeSubtitle(c) : ""}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-1 text-white/80 text-xs font-bold shrink-0"
|
||||
>
|
||||
${lobby.numClients}${c?.maxPlayers ? `/${c.maxPlayers}` : ""}
|
||||
<svg class="w-4 h-4" 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>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private async joinHostedLobby(lobby: PublicGameInfo) {
|
||||
const lobbyId = lobby.gameID;
|
||||
this.startTrackingLobby(lobbyId, lobby);
|
||||
try {
|
||||
const gameExists = await this.checkActiveLobby(lobbyId);
|
||||
if (!gameExists) {
|
||||
// The lobby vanished between the broadcast and the click.
|
||||
this.resetTrackingState();
|
||||
this.showMessage(translateText("private_lobby.not_found"), "red");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error joining hosted lobby:", error);
|
||||
this.resetTrackingState();
|
||||
this.showMessage(translateText("private_lobby.error"), "red");
|
||||
}
|
||||
}
|
||||
|
||||
protected onOpen(args?: Record<string, unknown>): void {
|
||||
void this.hostedLobbySocket.start();
|
||||
const lobbyId = typeof args?.lobbyId === "string" ? args.lobbyId : "";
|
||||
const lobbyInfo = args?.lobbyInfo as GameInfo | PublicGameInfo | undefined;
|
||||
if (lobbyId) {
|
||||
@@ -342,6 +433,8 @@ export class JoinLobbyModal extends BaseModal {
|
||||
}
|
||||
|
||||
protected onClose(): void {
|
||||
this.hostedLobbySocket.stop();
|
||||
this.hostedLobbies = [];
|
||||
this.clearCountdownTimer();
|
||||
this.stopLobbyUpdates();
|
||||
|
||||
@@ -364,6 +457,7 @@ export class JoinLobbyModal extends BaseModal {
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.hostedLobbySocket.stop();
|
||||
this.clearCountdownTimer();
|
||||
this.stopLobbyUpdates();
|
||||
super.disconnectedCallback();
|
||||
@@ -393,6 +487,24 @@ export class JoinLobbyModal extends BaseModal {
|
||||
|
||||
// --- Game config rendering ---
|
||||
|
||||
private modeSubtitle(c: GameConfig): string {
|
||||
if (c.gameMode !== GameMode.Team) {
|
||||
return translateText("game_mode.ffa");
|
||||
}
|
||||
if (c.playerTeams === HumansVsNations) {
|
||||
return translateText("host_modal.teams_Humans Vs Nations");
|
||||
}
|
||||
if (typeof c.playerTeams === "string") {
|
||||
return translateText("host_modal.teams_" + c.playerTeams);
|
||||
}
|
||||
if (typeof c.playerTeams === "number") {
|
||||
return translateText("public_lobby.teams", {
|
||||
num: c.playerTeams,
|
||||
});
|
||||
}
|
||||
return translateText("game_mode.ffa");
|
||||
}
|
||||
|
||||
private renderGameConfig(): TemplateResult {
|
||||
if (!this.gameConfig) return html``;
|
||||
|
||||
@@ -403,21 +515,7 @@ export class JoinLobbyModal extends BaseModal {
|
||||
`maps/${encodeURIComponent(normalizedMap)}/thumbnail.webp`,
|
||||
);
|
||||
const isTeam = c.gameMode === GameMode.Team;
|
||||
|
||||
let modeSubtitle: string;
|
||||
if (!isTeam) {
|
||||
modeSubtitle = translateText("game_mode.ffa");
|
||||
} else if (c.playerTeams === HumansVsNations) {
|
||||
modeSubtitle = translateText("host_modal.teams_Humans Vs Nations");
|
||||
} else if (typeof c.playerTeams === "string") {
|
||||
modeSubtitle = translateText("host_modal.teams_" + c.playerTeams);
|
||||
} else if (typeof c.playerTeams === "number") {
|
||||
modeSubtitle = translateText("public_lobby.teams", {
|
||||
num: c.playerTeams,
|
||||
});
|
||||
} else {
|
||||
modeSubtitle = translateText("game_mode.ffa");
|
||||
}
|
||||
const modeSubtitle = this.modeSubtitle(c);
|
||||
|
||||
const pm = c.publicGameModifiers;
|
||||
const cards: TemplateResult[] = [];
|
||||
|
||||
@@ -5,6 +5,7 @@ import { assetUrl } from "../../../core/AssetUrls";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import {
|
||||
AllPlayers,
|
||||
GameType,
|
||||
PlayerActions,
|
||||
PlayerProfile,
|
||||
PlayerType,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
} from "../../../core/game/Game";
|
||||
import { TileRef } from "../../../core/game/GameMap";
|
||||
import { Emoji, flattenedEmojiTable } from "../../../core/Util";
|
||||
import { fetchLobbyListed } from "../../Api";
|
||||
import { actionButton } from "../../components/ui/ActionButton";
|
||||
import "../../components/ui/Divider";
|
||||
import { Controller } from "../../Controller";
|
||||
@@ -73,6 +75,9 @@ export class PlayerPanel extends LitElement implements Controller {
|
||||
@state() private suppressNextHide: boolean = false;
|
||||
@state() private moderationTarget: PlayerView | null = null;
|
||||
@state() private playerRole: string | null = null;
|
||||
// Whether this game is a publicly listed lobby. Kept out of
|
||||
// GameStartInfo (never touches records), so it's fetched from the worker.
|
||||
@state() private gameListed = false;
|
||||
|
||||
setRole(role: string | null): void {
|
||||
this.playerRole = role;
|
||||
@@ -113,6 +118,13 @@ export class PlayerPanel extends LitElement implements Controller {
|
||||
if (!this.ctModal) {
|
||||
console.warn("ChatModal element not found in DOM");
|
||||
}
|
||||
|
||||
// Only private games can be listed.
|
||||
if (this.g.config().gameConfig().gameType === GameType.Private) {
|
||||
void fetchLobbyListed(this.g.gameID()).then((listed) => {
|
||||
this.gameListed = listed;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async tick() {
|
||||
@@ -456,6 +468,9 @@ export class PlayerPanel extends LitElement implements Controller {
|
||||
isAdmin: boolean,
|
||||
) {
|
||||
if (!my.isLobbyCreator() && !isAdmin) return html``;
|
||||
// The host of a publicly listed game cannot kick (server-enforced), so
|
||||
// don't offer the panel; admins keep it for moderation.
|
||||
if (this.gameListed && !isAdmin) return html``;
|
||||
const moderationTitle = translateText("player_panel.moderation");
|
||||
|
||||
return html`
|
||||
|
||||
@@ -139,6 +139,13 @@ export type UserSubscription = NonNullable<
|
||||
NonNullable<UserMeResponse["player"]["subscription"]>
|
||||
>;
|
||||
|
||||
// Whether the player currently has subscriber entitlements (e.g. may list a
|
||||
// private lobby publicly). Trialing counts; past_due/canceled do not.
|
||||
export function hasActiveSubscription(userMe: UserMeResponse): boolean {
|
||||
const status = userMe.player.subscription?.status;
|
||||
return status === "active" || status === "trialing";
|
||||
}
|
||||
|
||||
export const PlayerStatsLeafSchema = z.object({
|
||||
wins: BigIntStringSchema,
|
||||
losses: BigIntStringSchema,
|
||||
|
||||
+38
-3
@@ -150,7 +150,31 @@ export type PublicGames = z.infer<typeof PublicGamesSchema>;
|
||||
export type PublicGameInfo = z.infer<typeof PublicGameInfoSchema>;
|
||||
export type PublicGameType = z.infer<typeof PublicGameTypeSchema>;
|
||||
|
||||
export const PublicGameTypeSchema = z.enum(["ffa", "team", "special"]);
|
||||
export const PublicGameTypeSchema = z.enum([
|
||||
"ffa",
|
||||
"team",
|
||||
"special",
|
||||
"hosted",
|
||||
]);
|
||||
|
||||
// Lobby types the master schedules from the map playlist. "hosted" is
|
||||
// excluded: those are player-created private lobbies that a subscriber has
|
||||
// listed publicly, and the host (not the master) controls their lifecycle.
|
||||
// Derived from PublicGameTypeSchema so a new lobby type is scheduled by
|
||||
// default and opting out is the explicit act.
|
||||
export const ScheduledPublicGameTypeSchema = PublicGameTypeSchema.exclude([
|
||||
"hosted",
|
||||
]);
|
||||
export const SCHEDULED_PUBLIC_GAME_TYPES =
|
||||
ScheduledPublicGameTypeSchema.options;
|
||||
export type ScheduledPublicGameType = z.infer<
|
||||
typeof ScheduledPublicGameTypeSchema
|
||||
>;
|
||||
|
||||
// Cluster-wide cap on subscriber-listed (hosted) lobbies, to prevent listing
|
||||
// spam. Workers reject listings past the cap; the master caps the broadcast
|
||||
// and delists any overflow as the authoritative backstop.
|
||||
export const MAX_HOSTED_LOBBIES = 10;
|
||||
|
||||
export const UsernameSchema = z
|
||||
.string()
|
||||
@@ -178,8 +202,17 @@ export const GameInfoSchema = z.object({
|
||||
serverTime: z.number(),
|
||||
gameConfig: z.lazy(() => GameConfigSchema).optional(),
|
||||
publicGameType: PublicGameTypeSchema.optional(),
|
||||
// Private lobbies only: whether the lobby is publicly listed. Server-owned
|
||||
// (only /api/game/:id/listing sets it); carried in lobby info so the host
|
||||
// UI stays in sync when the server delists (whitelist enabled, duplicate
|
||||
// creator resolved by the master).
|
||||
listed: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Browser-facing lobby info. Master/worker-internal fields (the creator hash
|
||||
// used for the one-listed-lobby-per-creator check) live on
|
||||
// InternalGameInfoSchema in IPCBridgeSchema.ts, so client payloads cannot
|
||||
// carry them by construction.
|
||||
export const PublicGameInfoSchema = z.object({
|
||||
gameID: z.string(),
|
||||
numClients: z.number(),
|
||||
@@ -190,7 +223,9 @@ export const PublicGameInfoSchema = z.object({
|
||||
|
||||
export const PublicGamesSchema = z.object({
|
||||
serverTime: z.number(),
|
||||
games: z.record(PublicGameTypeSchema, z.array(PublicGameInfoSchema)),
|
||||
// partialRecord: every consumer already treats buckets as optional, and it
|
||||
// lets clients tolerate servers that don't send every lobby type.
|
||||
games: z.partialRecord(PublicGameTypeSchema, z.array(PublicGameInfoSchema)),
|
||||
});
|
||||
|
||||
// Wire message sent from server to lobby WebSocket clients.
|
||||
@@ -199,7 +234,7 @@ export const PublicGamesSchema = z.object({
|
||||
export const PublicLobbyFullSchema = z.object({
|
||||
type: z.literal("full"),
|
||||
serverTime: z.number(),
|
||||
games: z.record(PublicGameTypeSchema, z.array(PublicGameInfoSchema)),
|
||||
games: z.partialRecord(PublicGameTypeSchema, z.array(PublicGameInfoSchema)),
|
||||
});
|
||||
|
||||
export const PublicLobbyCountsSchema = z.object({
|
||||
|
||||
@@ -28,6 +28,14 @@ export class GameManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Private lobbies a subscriber has listed in the public lobby browser.
|
||||
// Leaving the Lobby phase (start/fill/expiry) delists them automatically.
|
||||
public listedLobbies(): GameServer[] {
|
||||
return Array.from(this.games.values()).filter(
|
||||
(g) => g.phase() === GamePhase.Lobby && !g.isPublic() && g.isListed(),
|
||||
);
|
||||
}
|
||||
|
||||
joinClient(
|
||||
client: Client,
|
||||
gameID: GameID,
|
||||
|
||||
+128
-28
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "crypto";
|
||||
import ipAnonymize from "ip-anonymize";
|
||||
import { Logger } from "winston";
|
||||
import WebSocket from "ws";
|
||||
@@ -57,6 +58,10 @@ export interface IntentOutcome {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function hashPersistentID(persistentID: string): string {
|
||||
return createHash("sha256").update(persistentID).digest("hex");
|
||||
}
|
||||
|
||||
const KICK_REASON_DUPLICATE_SESSION = "kick_reason.duplicate_session";
|
||||
const KICK_REASON_LOBBY_CREATOR = "kick_reason.lobby_creator";
|
||||
const KICK_REASON_ADMIN = "kick_reason.admin";
|
||||
@@ -64,6 +69,18 @@ const KICK_REASON_HOST_LEFT = "kick_reason.host_left";
|
||||
const KICK_REASON_TOO_MUCH_DATA = "kick_reason.too_much_data";
|
||||
const KICK_REASON_INVALID_MESSAGE = "kick_reason.invalid_message";
|
||||
|
||||
// Whether the host-only cheat block actually grants anything: mere presence
|
||||
// isn't enough, the client can send hostCheats with every field off.
|
||||
function hostCheatsEnabled(hc: GameConfig["hostCheats"]): boolean {
|
||||
return (
|
||||
hc !== undefined &&
|
||||
(hc.infiniteGold === true ||
|
||||
hc.infiniteTroops === true ||
|
||||
typeof hc.goldMultiplier === "number" ||
|
||||
typeof hc.startingGold === "number")
|
||||
);
|
||||
}
|
||||
|
||||
export class GameServer {
|
||||
private sentDesyncMessageClients = new Set<ClientID>();
|
||||
|
||||
@@ -127,6 +144,12 @@ export class GameServer {
|
||||
|
||||
private _hasEnded = false;
|
||||
|
||||
// Whether this private lobby is visible in the public lobby browser.
|
||||
// Deliberately kept out of gameConfig so update_game_config can't set it;
|
||||
// only the authenticated /api/game/:id/listing endpoint may (it verifies
|
||||
// the creator's subscription).
|
||||
private listed = false;
|
||||
|
||||
private lobbyInfoIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
private visibleAt?: number;
|
||||
@@ -248,6 +271,12 @@ export class GameServer {
|
||||
}
|
||||
if (gameConfig.allowedPublicIds !== undefined) {
|
||||
this.gameConfig.allowedPublicIds = gameConfig.allowedPublicIds;
|
||||
// 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.log.info("delisted lobby: join whitelist enabled");
|
||||
}
|
||||
}
|
||||
if (gameConfig.waterNukes !== undefined) {
|
||||
this.gameConfig.waterNukes = gameConfig.waterNukes ?? undefined;
|
||||
@@ -293,6 +322,16 @@ export class GameServer {
|
||||
error: "only the lobby creator or an admin can kick players",
|
||||
};
|
||||
}
|
||||
// A listed lobby recruits strangers from the public browser; letting
|
||||
// the host kick them is a griefing vector. Admins keep the power for
|
||||
// moderation. The listed flag survives game start on purpose, so a
|
||||
// publicly recruited game stays kick-free like a real public game.
|
||||
if (this.isListed() && !actor.isAdmin) {
|
||||
return {
|
||||
status: 403,
|
||||
error: "the host cannot kick players in a publicly listed lobby",
|
||||
};
|
||||
}
|
||||
// Resolve the target to a clientID: an explicit clientID, or an account
|
||||
// publicId matched against allClients (a superset of activeClients that
|
||||
// retains disconnected players), so a disconnected account can still be
|
||||
@@ -340,6 +379,16 @@ export class GameServer {
|
||||
if (stamped.config.gameType === GameType.Public) {
|
||||
return { status: 400, error: "cannot change a game to public" };
|
||||
}
|
||||
// Host cheats give the host an asymmetric advantage over players
|
||||
// recruited from the lobby browser. Listing is likewise rejected
|
||||
// while cheats are on (Worker's listing endpoint), so a listed
|
||||
// lobby can never have them.
|
||||
if (this.isListed() && hostCheatsEnabled(stamped.config.hostCheats)) {
|
||||
return {
|
||||
status: 409,
|
||||
error: "cannot enable host cheats in a publicly listed lobby",
|
||||
};
|
||||
}
|
||||
this.updateGameConfig(stamped.config);
|
||||
return { status: 200 };
|
||||
}
|
||||
@@ -426,6 +475,11 @@ export class GameServer {
|
||||
public joinClient(
|
||||
client: Client,
|
||||
): "joined" | "kicked" | "rejected" | "not_allowlisted" {
|
||||
// e.g. the host left an unstarted lobby and GameManager hasn't pruned
|
||||
// it yet.
|
||||
if (this._hasEnded) {
|
||||
return "rejected";
|
||||
}
|
||||
if (this.kickedPersistentIds.has(client.persistentID)) {
|
||||
return "kicked";
|
||||
}
|
||||
@@ -679,27 +733,7 @@ export class GameServer {
|
||||
clientID: client.clientID,
|
||||
persistentID: client.persistentID,
|
||||
});
|
||||
this.activeClients = this.activeClients.filter(
|
||||
(c) => c.clientID !== client.clientID,
|
||||
);
|
||||
|
||||
if (!this._hasStarted) {
|
||||
// Remove persistentId if the game has not started to prevent going over max players
|
||||
this.persistentIdToClientId.delete(client.persistentID);
|
||||
// Close lobby when host leaves before game starts
|
||||
if (
|
||||
!this.isPublic() &&
|
||||
client.persistentID === this.creatorPersistentID
|
||||
) {
|
||||
this.log.info("Host left, closing lobby", {
|
||||
gameID: this.id,
|
||||
});
|
||||
for (const c of [...this.activeClients]) {
|
||||
this.kickClient(c.clientID, KICK_REASON_HOST_LEFT);
|
||||
}
|
||||
this._hasEnded = true;
|
||||
}
|
||||
}
|
||||
this.handleClientDisconnect(client);
|
||||
});
|
||||
client.ws.on("error", (error: Error) => {
|
||||
if ((error as any).code === "WS_ERR_UNEXPECTED_RSV_1") {
|
||||
@@ -707,19 +741,40 @@ export class GameServer {
|
||||
}
|
||||
});
|
||||
|
||||
// Check if WebSocket already closed before we added the listener (race condition)
|
||||
// Check if WebSocket already closed before we added the listener (race
|
||||
// condition) — the 'close' event has already fired, so the handler above
|
||||
// will never run for this client.
|
||||
if (client.ws.readyState >= 2) {
|
||||
this.log.info("client WebSocket already closing/closed, removing", {
|
||||
clientID: client.clientID,
|
||||
readyState: client.ws.readyState,
|
||||
});
|
||||
this.activeClients = this.activeClients.filter(
|
||||
(c) => c.clientID !== client.clientID,
|
||||
);
|
||||
// Remove persistentId if the game has not started to prevent going over max players
|
||||
if (!this._hasStarted) {
|
||||
this.persistentIdToClientId.delete(client.persistentID);
|
||||
this.handleClientDisconnect(client);
|
||||
}
|
||||
}
|
||||
|
||||
private handleClientDisconnect(client: Client) {
|
||||
this.activeClients = this.activeClients.filter(
|
||||
(c) => c.clientID !== client.clientID,
|
||||
);
|
||||
|
||||
if (this._hasStarted) {
|
||||
return;
|
||||
}
|
||||
// Remove persistentId if the game has not started to prevent going over max players
|
||||
this.persistentIdToClientId.delete(client.persistentID);
|
||||
// Close lobby when host leaves before game starts: without a host it can
|
||||
// never start, and a listed one would haunt the lobby browser and hold
|
||||
// the creator's one-listing quota. phase() reports Finished once ended,
|
||||
// so GameManager's next tick prunes it.
|
||||
if (!this.isPublic() && client.persistentID === this.creatorPersistentID) {
|
||||
this.log.info("Host left, closing lobby", {
|
||||
gameID: this.id,
|
||||
});
|
||||
for (const c of [...this.activeClients]) {
|
||||
this.kickClient(c.clientID, KICK_REASON_HOST_LEFT);
|
||||
}
|
||||
this._hasEnded = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1019,6 +1074,13 @@ export class GameServer {
|
||||
}
|
||||
|
||||
phase(): GamePhase {
|
||||
// An ended game (e.g. an unstarted lobby whose host left) must report
|
||||
// Finished: GameManager prunes on Finished, and a ghost that kept
|
||||
// reporting Lobby would stay advertised in the lobby browser and hold
|
||||
// the creator's one-listing quota until the max-duration cutoff.
|
||||
if (this._hasEnded) {
|
||||
return GamePhase.Finished;
|
||||
}
|
||||
const now = Date.now();
|
||||
const alive: Client[] = [];
|
||||
for (const client of this.activeClients) {
|
||||
@@ -1091,6 +1153,7 @@ export class GameServer {
|
||||
startsAt: this.startsAt,
|
||||
serverTime: Date.now(),
|
||||
publicGameType: this.publicGameType,
|
||||
listed: this.isPublic() ? undefined : this.listed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1115,6 +1178,43 @@ export class GameServer {
|
||||
return this.gameConfig.gameType === GameType.Public;
|
||||
}
|
||||
|
||||
public isListed(): boolean {
|
||||
return this.listed;
|
||||
}
|
||||
|
||||
public setListed(listed: boolean): void {
|
||||
this.listed = listed;
|
||||
}
|
||||
|
||||
// Whether joining is restricted to an allowlist of publicIds. A lobby with
|
||||
// a join whitelist must not be publicly listed (it would advertise a lobby
|
||||
// that rejects every joiner).
|
||||
public hasJoinWhitelist(): boolean {
|
||||
return (this.gameConfig.allowedPublicIds?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
// Whether any host-only cheat is actually granted. A lobby with host
|
||||
// cheats must not be publicly listed.
|
||||
public hasHostCheats(): boolean {
|
||||
return hostCheatsEnabled(this.gameConfig.hostCheats);
|
||||
}
|
||||
|
||||
public isCreator(persistentId: string): boolean {
|
||||
return (
|
||||
this.creatorPersistentID !== undefined &&
|
||||
this.creatorPersistentID === persistentId
|
||||
);
|
||||
}
|
||||
|
||||
// Hash of the creator's persistentID, safe to share between master and
|
||||
// workers (never sent to browsers) for the one-listed-lobby-per-creator
|
||||
// check. The raw persistentID must not leave this class.
|
||||
public hashedCreatorID(): string | undefined {
|
||||
return this.creatorPersistentID === undefined
|
||||
? undefined
|
||||
: hashPersistentID(this.creatorPersistentID);
|
||||
}
|
||||
|
||||
public kickClient(
|
||||
clientID: ClientID,
|
||||
reasonKey: string = KICK_REASON_DUPLICATE_SESSION,
|
||||
|
||||
@@ -2,10 +2,11 @@ import { z } from "zod";
|
||||
import {
|
||||
GameConfigSchema,
|
||||
PublicGameInfoSchema,
|
||||
PublicGamesSchema,
|
||||
PublicGameTypeSchema,
|
||||
} from "../core/Schemas";
|
||||
|
||||
export type InternalGameInfo = z.infer<typeof InternalGameInfoSchema>;
|
||||
export type InternalPublicGames = z.infer<typeof InternalPublicGamesSchema>;
|
||||
export type WorkerLobbyList = z.infer<typeof WorkerLobbyListSchema>;
|
||||
export type WorkerReady = z.infer<typeof WorkerReadySchema>;
|
||||
export type MasterLobbiesBroadcast = z.infer<
|
||||
@@ -17,12 +18,25 @@ export type MasterCreateGame = z.infer<typeof MasterCreateGameSchema>;
|
||||
export type WorkerMessage = z.infer<typeof WorkerMessageSchema>;
|
||||
export type MasterMessage = z.infer<typeof MasterMessageSchema>;
|
||||
|
||||
// Master/worker-internal lobby info: PublicGameInfo plus the hashed creator
|
||||
// ID (hosted lobbies only) used for the one-listed-lobby-per-creator check.
|
||||
// Never sent to browsers — WorkerLobbyService.sanitizeGames converts to plain
|
||||
// PublicGameInfo before anything reaches a client.
|
||||
export const InternalGameInfoSchema = PublicGameInfoSchema.extend({
|
||||
creatorID: z.string().optional(),
|
||||
});
|
||||
|
||||
export const InternalPublicGamesSchema = z.object({
|
||||
serverTime: z.number(),
|
||||
games: z.partialRecord(PublicGameTypeSchema, z.array(InternalGameInfoSchema)),
|
||||
});
|
||||
|
||||
// --- Worker Messages ---
|
||||
|
||||
// Worker tells the master about its lobbies.
|
||||
const WorkerLobbyListSchema = z.object({
|
||||
type: z.literal("lobbyList"),
|
||||
lobbies: z.array(PublicGameInfoSchema),
|
||||
lobbies: z.array(InternalGameInfoSchema),
|
||||
});
|
||||
|
||||
const WorkerReadySchema = z.object({
|
||||
@@ -48,7 +62,12 @@ const MasterUpdateGameSchema = z.object({
|
||||
// it can send it to the client.
|
||||
const MasterLobbiesBroadcastSchema = z.object({
|
||||
type: z.literal("lobbiesBroadcast"),
|
||||
publicGames: PublicGamesSchema,
|
||||
publicGames: InternalPublicGamesSchema,
|
||||
// Hosted lobbies the master wants delisted: a creator got two lobbies
|
||||
// listed concurrently on different workers, and only the dedup winner may
|
||||
// stay advertised. The owning worker clears the loser's listed flag so
|
||||
// worker state, host UI, and the broadcast agree.
|
||||
delistGameIDs: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
// Master sends a message to worker to schedule a new public game/lobby.
|
||||
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
UnitType,
|
||||
} from "../core/game/Game";
|
||||
import { PseudoRandom } from "../core/PseudoRandom";
|
||||
import { GameConfig, PublicGameType, TeamCountConfig } from "../core/Schemas";
|
||||
import {
|
||||
GameConfig,
|
||||
ScheduledPublicGameType,
|
||||
TeamCountConfig,
|
||||
} from "../core/Schemas";
|
||||
import { logger } from "./Logger";
|
||||
import { getMapLandTiles } from "./MapLandTiles";
|
||||
|
||||
@@ -122,13 +126,13 @@ const MUTUALLY_EXCLUSIVE_MODIFIERS: [ModifierKey, ModifierKey][] = [
|
||||
];
|
||||
|
||||
export class MapPlaylist {
|
||||
private playlists: Record<PublicGameType, GameMapType[]> = {
|
||||
private playlists: Record<ScheduledPublicGameType, GameMapType[]> = {
|
||||
ffa: [],
|
||||
special: [],
|
||||
team: [],
|
||||
};
|
||||
|
||||
public async gameConfig(type: PublicGameType): Promise<GameConfig> {
|
||||
public async gameConfig(type: ScheduledPublicGameType): Promise<GameConfig> {
|
||||
if (type === "special") {
|
||||
return this.getSpecialConfig();
|
||||
}
|
||||
@@ -410,7 +414,7 @@ export class MapPlaylist {
|
||||
} satisfies GameConfig;
|
||||
}
|
||||
|
||||
private getNextMap(type: PublicGameType): GameMapType {
|
||||
private getNextMap(type: ScheduledPublicGameType): GameMapType {
|
||||
const playlist = this.playlists[type];
|
||||
if (playlist.length === 0) {
|
||||
playlist.push(...this.generateNewPlaylist(type));
|
||||
@@ -418,7 +422,7 @@ export class MapPlaylist {
|
||||
return playlist.shift()!;
|
||||
}
|
||||
|
||||
private generateNewPlaylist(type: PublicGameType): GameMapType[] {
|
||||
private generateNewPlaylist(type: ScheduledPublicGameType): GameMapType[] {
|
||||
const maps = this.buildMapsList(type);
|
||||
const rand = new PseudoRandom(Date.now());
|
||||
const playlist: GameMapType[] = [];
|
||||
@@ -467,7 +471,7 @@ export class MapPlaylist {
|
||||
return false;
|
||||
}
|
||||
|
||||
private buildMapsList(type: PublicGameType): GameMapType[] {
|
||||
private buildMapsList(type: ScheduledPublicGameType): GameMapType[] {
|
||||
const maps: GameMapType[] = [];
|
||||
allMaps.forEach((mapInfo) => {
|
||||
const map = mapInfo.type;
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Worker } from "cluster";
|
||||
import winston from "winston";
|
||||
import { PublicGameInfo, PublicGameType } from "../core/Schemas";
|
||||
import {
|
||||
MAX_HOSTED_LOBBIES,
|
||||
PublicGameType,
|
||||
SCHEDULED_PUBLIC_GAME_TYPES,
|
||||
} from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import {
|
||||
InternalGameInfo,
|
||||
MasterCreateGame,
|
||||
MasterLobbiesBroadcast,
|
||||
MasterUpdateGame,
|
||||
@@ -21,8 +26,14 @@ export interface MasterLobbyServiceOptions {
|
||||
export class MasterLobbyService {
|
||||
private readonly workers = new Map<number, Worker>();
|
||||
// Worker id => the lobbies it owns.
|
||||
private readonly workerLobbies = new Map<number, PublicGameInfo[]>();
|
||||
private readonly workerLobbies = new Map<number, InternalGameInfo[]>();
|
||||
private readonly readyWorkers = new Set<number>();
|
||||
// gameID => consecutive broadcast cycles a hosted lobby has lost the
|
||||
// per-creator dedup or overflowed the cluster-wide cap. Losing once can be
|
||||
// a stale worker report (a delisted lobby lingers for one report
|
||||
// round-trip); losing twice means the conflict is real, and the loser gets
|
||||
// delisted.
|
||||
private readonly loserStreaks = new Map<string, number>();
|
||||
private started = false;
|
||||
|
||||
constructor(
|
||||
@@ -78,13 +89,17 @@ export class MasterLobbyService {
|
||||
}
|
||||
}
|
||||
|
||||
private getAllLobbies(): Record<PublicGameType, PublicGameInfo[]> {
|
||||
private getAllLobbies(): {
|
||||
games: Record<PublicGameType, InternalGameInfo[]>;
|
||||
losers: string[];
|
||||
} {
|
||||
const lobbies = Array.from(this.workerLobbies.values()).flat();
|
||||
|
||||
const result: Record<PublicGameType, PublicGameInfo[]> = {
|
||||
const result: Record<PublicGameType, InternalGameInfo[]> = {
|
||||
ffa: [],
|
||||
team: [],
|
||||
special: [],
|
||||
hosted: [],
|
||||
};
|
||||
|
||||
for (const lobby of lobbies) {
|
||||
@@ -104,16 +119,71 @@ export class MasterLobbyService {
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
// One listed lobby per creator, cluster-wide. Workers enforce this at
|
||||
// listing time, but two workers can list concurrently between broadcasts;
|
||||
// dropping duplicates here (deterministically, after the sort above)
|
||||
// keeps the extra lobby from ever being advertised. Losers are reported
|
||||
// so broadcastLobbies can tell the owning worker to clear the loser's
|
||||
// listed flag — otherwise it would stay flagged Public on its worker
|
||||
// while never appearing in any browser.
|
||||
const seenCreators = new Set<string>();
|
||||
const losers: string[] = [];
|
||||
result.hosted = result.hosted.filter((lobby) => {
|
||||
if (lobby.creatorID === undefined) return true;
|
||||
if (seenCreators.has(lobby.creatorID)) {
|
||||
losers.push(lobby.gameID);
|
||||
return false;
|
||||
}
|
||||
seenCreators.add(lobby.creatorID);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Cluster-wide cap to prevent listing spam. Workers reject listings past
|
||||
// the cap too, but their view lags by a broadcast round-trip; overflow
|
||||
// (deterministically the sort losers) is delisted like dedup losers.
|
||||
if (result.hosted.length > MAX_HOSTED_LOBBIES) {
|
||||
for (const lobby of result.hosted.slice(MAX_HOSTED_LOBBIES)) {
|
||||
losers.push(lobby.gameID);
|
||||
}
|
||||
result.hosted = result.hosted.slice(0, MAX_HOSTED_LOBBIES);
|
||||
}
|
||||
|
||||
return { games: result, losers };
|
||||
}
|
||||
|
||||
// Losers (creator dedup or cap overflow) are only delisted after losing
|
||||
// two consecutive broadcast cycles: a single loss can be a stale worker
|
||||
// report (a just-delisted lobby lingers for one report round-trip), and
|
||||
// delisting on it would clear a legitimately listed lobby.
|
||||
private delistGameIDs(losers: string[]): string[] {
|
||||
const loserSet = new Set(losers);
|
||||
for (const gameID of this.loserStreaks.keys()) {
|
||||
if (!loserSet.has(gameID)) this.loserStreaks.delete(gameID);
|
||||
}
|
||||
const delist: string[] = [];
|
||||
for (const gameID of losers) {
|
||||
const streak = (this.loserStreaks.get(gameID) ?? 0) + 1;
|
||||
this.loserStreaks.set(gameID, streak);
|
||||
if (streak >= 2) delist.push(gameID);
|
||||
}
|
||||
if (delist.length > 0) {
|
||||
this.log.info(
|
||||
`delisting hosted lobbies (duplicate creator or over cap): ${delist.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return delist;
|
||||
}
|
||||
|
||||
private broadcastLobbies() {
|
||||
const { games, losers } = this.getAllLobbies();
|
||||
const delist = this.delistGameIDs(losers);
|
||||
const msg = {
|
||||
type: "lobbiesBroadcast",
|
||||
publicGames: {
|
||||
serverTime: Date.now(),
|
||||
games: this.getAllLobbies(),
|
||||
games,
|
||||
},
|
||||
delistGameIDs: delist.length > 0 ? delist : undefined,
|
||||
} satisfies MasterLobbiesBroadcast;
|
||||
for (const [workerId, worker] of this.workers.entries()) {
|
||||
worker.send(msg, (e) => {
|
||||
@@ -129,9 +199,11 @@ export class MasterLobbyService {
|
||||
}
|
||||
|
||||
private async maybeScheduleLobby() {
|
||||
const lobbiesByType = this.getAllLobbies();
|
||||
const lobbiesByType = this.getAllLobbies().games;
|
||||
|
||||
for (const type of Object.keys(lobbiesByType) as PublicGameType[]) {
|
||||
// Scheduled types only: hosted lobbies are started by their host, never
|
||||
// given a countdown or replaced by the master.
|
||||
for (const type of SCHEDULED_PUBLIC_GAME_TYPES) {
|
||||
const lobbies = lobbiesByType[type];
|
||||
|
||||
// Always ensure the next lobby has a timer, even if we already have 2+
|
||||
|
||||
@@ -7,10 +7,12 @@ import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import { z } from "zod";
|
||||
import { hasActiveSubscription } from "../core/ApiSchemas";
|
||||
import { GameEnv } from "../core/configuration/Config";
|
||||
import { GameType } from "../core/game/Game";
|
||||
import {
|
||||
ClientMessageSchema,
|
||||
MAX_HOSTED_LOBBIES,
|
||||
PartialGameRecordSchema,
|
||||
ServerErrorMessage,
|
||||
} from "../core/Schemas";
|
||||
@@ -195,6 +197,94 @@ export async function startWorker() {
|
||||
});
|
||||
});
|
||||
|
||||
// Toggle whether a private lobby is visible in the public lobby browser.
|
||||
// Creator-only; listing requires an active subscription (checked fresh
|
||||
// against the API) and is limited to one listed lobby per creator.
|
||||
app.post("/api/game/:id/listing", async (req, res) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return res.status(400).json({ error: "Authorization header required" });
|
||||
}
|
||||
const token = authHeader.substring("Bearer ".length);
|
||||
const auth = await verifyClientToken(token);
|
||||
if (auth.type !== "success") {
|
||||
return res.status(401).json({ error: "Invalid token" });
|
||||
}
|
||||
|
||||
const parsed = z.object({ listed: z.boolean() }).safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: z.prettifyError(parsed.error) });
|
||||
}
|
||||
const { listed } = parsed.data;
|
||||
|
||||
const game = gm.game(req.params.id);
|
||||
if (game === null) {
|
||||
return res.status(404).json({ error: "Game not found" });
|
||||
}
|
||||
if (!game.isCreator(auth.persistentId)) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Only the lobby creator can change its listing" });
|
||||
}
|
||||
if (game.isPublic() || game.hasStarted()) {
|
||||
return res.status(409).json({ error: "Game cannot be listed" });
|
||||
}
|
||||
|
||||
if (listed) {
|
||||
// A whitelisted lobby would be advertised to everyone yet reject every
|
||||
// joiner; the whitelist itself is stripped from the broadcast, so
|
||||
// browsers could not even tell why.
|
||||
if (game.hasJoinWhitelist()) {
|
||||
return res.status(409).json({ error: "listing_whitelist_enabled" });
|
||||
}
|
||||
|
||||
// Host cheats give the host an asymmetric advantage over players
|
||||
// recruited from the lobby browser. Enabling them while listed is
|
||||
// likewise rejected (GameServer's update_game_config handling).
|
||||
if (game.hasHostCheats()) {
|
||||
return res.status(409).json({ error: "listing_host_cheats_enabled" });
|
||||
}
|
||||
|
||||
// Dev has no subscription backend; skip the check so the feature is
|
||||
// testable locally (same precedent as Turnstile).
|
||||
if (ServerEnv.env() !== GameEnv.Dev) {
|
||||
const userMe = await getUserMe(token);
|
||||
if (userMe.type === "error") {
|
||||
log.warn(
|
||||
`listing rejected, user me fetch failed: ${userMe.message}`,
|
||||
{
|
||||
gameID: req.params.id,
|
||||
},
|
||||
);
|
||||
return res.status(403).json({ error: "subscription_required" });
|
||||
}
|
||||
if (!hasActiveSubscription(userMe.response)) {
|
||||
return res.status(403).json({ error: "subscription_required" });
|
||||
}
|
||||
}
|
||||
|
||||
const creatorID = game.hashedCreatorID();
|
||||
if (
|
||||
creatorID !== undefined &&
|
||||
lobbyService.creatorHasListedLobby(creatorID, game.id)
|
||||
) {
|
||||
return res.status(409).json({ error: "listing_limit_reached" });
|
||||
}
|
||||
|
||||
// Cluster-wide cap to prevent listing spam. Approximate here (the
|
||||
// broadcast lags by ~1s); the master's cap is the backstop.
|
||||
if (lobbyService.hostedLobbyCount() >= MAX_HOSTED_LOBBIES) {
|
||||
return res.status(409).json({ error: "listing_full" });
|
||||
}
|
||||
}
|
||||
|
||||
game.setListed(listed);
|
||||
log.info(`lobby listing ${listed ? "enabled" : "disabled"}`, {
|
||||
gameID: game.id,
|
||||
});
|
||||
res.json({ listed });
|
||||
});
|
||||
|
||||
app.get("/api/game/:id/exists", async (req, res) => {
|
||||
const lobbyId = req.params.id;
|
||||
res.json({
|
||||
|
||||
@@ -1,30 +1,46 @@
|
||||
import http from "http";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import {
|
||||
GameConfig,
|
||||
PublicGameInfo,
|
||||
PublicGames,
|
||||
PublicLobbyMessage,
|
||||
} from "../core/Schemas";
|
||||
import { GameManager } from "./GameManager";
|
||||
import {
|
||||
InternalGameInfo,
|
||||
InternalPublicGames,
|
||||
MasterMessageSchema,
|
||||
WorkerLobbyList,
|
||||
WorkerReady,
|
||||
} from "./IPCBridgeSchema";
|
||||
import { logger } from "./Logger";
|
||||
|
||||
// The game config advertised for a listed private lobby: everything the
|
||||
// host configured minus host-only fields. The server already rejects
|
||||
// enabling the whitelist or host cheats while listed; stripping them here
|
||||
// just keeps host-only data off the wire. A new host-only GameConfig field
|
||||
// must be added to this delete list.
|
||||
function publicLobbyGameConfig(gc: GameConfig): GameConfig {
|
||||
const sanitized = { ...gc };
|
||||
delete sanitized.allowedPublicIds;
|
||||
delete sanitized.nameReveals;
|
||||
delete sanitized.nameRevealPublicIds;
|
||||
delete sanitized.hostCheats;
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export class WorkerLobbyService {
|
||||
private readonly lobbiesWss: WebSocketServer;
|
||||
private readonly lobbyClients: Set<WebSocket> = new Set();
|
||||
// Most recent snapshot from master, serialized on demand for new
|
||||
// connections so they don't have to wait for the next broadcast.
|
||||
private lastPublicGames: PublicGames | null = null;
|
||||
// Sorted gameIDs of the last full we broadcast, or null if we've never
|
||||
// broadcast one. When the set changes we send a fresh full; otherwise a
|
||||
// counts-only delta is enough. This relies on master creating a new lobby
|
||||
// whenever it sets startsAt on the previous one, so structural state
|
||||
// (startsAt, gameConfig) rides along with a gameID change. Null (not "")
|
||||
// is used so that an empty-lobby first broadcast still emits a full.
|
||||
private lastPublicGames: InternalPublicGames | null = null;
|
||||
// Fingerprint (sorted per-lobby JSON of everything clients receive except
|
||||
// player counts) of the last full we broadcast, or null if we've never
|
||||
// broadcast one. When it changes we send a fresh full; otherwise a
|
||||
// counts-only delta is enough. Null (not "") is used so that an
|
||||
// empty-lobby first broadcast still emits a full.
|
||||
private lastFullGameIds: string | null = null;
|
||||
|
||||
constructor(
|
||||
@@ -43,62 +59,79 @@ export class WorkerLobbyService {
|
||||
}
|
||||
|
||||
private setupIPCListener() {
|
||||
process.on("message", (raw: unknown) => {
|
||||
const result = MasterMessageSchema.safeParse(raw);
|
||||
if (!result.success) {
|
||||
this.log.error("Invalid IPC message from master:", raw);
|
||||
return;
|
||||
}
|
||||
process.on("message", (raw: unknown) => this.handleMasterMessage(raw));
|
||||
}
|
||||
|
||||
const msg = result.data;
|
||||
switch (msg.type) {
|
||||
case "lobbiesBroadcast":
|
||||
this.lastPublicGames = msg.publicGames;
|
||||
// Forward message to all clients
|
||||
this.broadcastLobbiesToClients(msg.publicGames);
|
||||
// Update master with my lobby info
|
||||
this.sendMyLobbiesToMaster();
|
||||
break;
|
||||
case "createGame": {
|
||||
if (this.gm.game(msg.gameID) !== null) {
|
||||
this.log.warn(`Game ${msg.gameID} already exists, skipping create`);
|
||||
return;
|
||||
// Separate from setupIPCListener so tests can dispatch messages without
|
||||
// touching the real process IPC channel (which vitest forks use).
|
||||
private handleMasterMessage(raw: unknown) {
|
||||
const result = MasterMessageSchema.safeParse(raw);
|
||||
if (!result.success) {
|
||||
this.log.error("Invalid IPC message from master:", raw);
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = result.data;
|
||||
switch (msg.type) {
|
||||
case "lobbiesBroadcast":
|
||||
// The master resolved a duplicate-creator race: clear the loser's
|
||||
// listed flag so this worker's state follows the broadcast. Done
|
||||
// before sendMyLobbiesToMaster so the next report reflects it.
|
||||
for (const gameID of msg.delistGameIDs ?? []) {
|
||||
const game = this.gm.game(gameID);
|
||||
if (game?.isListed()) {
|
||||
game.setListed(false);
|
||||
this.log.info(`delisted by master: duplicate creator`, { gameID });
|
||||
}
|
||||
this.log.info(`Creating public game ${msg.gameID} from master`);
|
||||
const game = this.gm.createGame(
|
||||
msg.gameID,
|
||||
msg.gameConfig,
|
||||
undefined,
|
||||
undefined,
|
||||
msg.publicGameType,
|
||||
);
|
||||
if (game === null) {
|
||||
this.log.warn(`Game ${msg.gameID} already exists, skipping create`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "updateLobby": {
|
||||
const game = this.gm.game(msg.gameID);
|
||||
if (!game) {
|
||||
this.log.warn("cannot update game, not found", {
|
||||
gameID: msg.gameID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
game.setStartsAt(msg.startsAt);
|
||||
break;
|
||||
this.lastPublicGames = msg.publicGames;
|
||||
// Forward message to all clients
|
||||
this.broadcastLobbiesToClients(msg.publicGames);
|
||||
// Update master with my lobby info
|
||||
this.sendMyLobbiesToMaster();
|
||||
break;
|
||||
case "createGame": {
|
||||
if (this.gm.game(msg.gameID) !== null) {
|
||||
this.log.warn(`Game ${msg.gameID} already exists, skipping create`);
|
||||
return;
|
||||
}
|
||||
this.log.info(`Creating public game ${msg.gameID} from master`);
|
||||
const game = this.gm.createGame(
|
||||
msg.gameID,
|
||||
msg.gameConfig,
|
||||
undefined,
|
||||
undefined,
|
||||
msg.publicGameType,
|
||||
);
|
||||
if (game === null) {
|
||||
this.log.warn(`Game ${msg.gameID} already exists, skipping create`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
case "updateLobby": {
|
||||
const game = this.gm.game(msg.gameID);
|
||||
if (!game) {
|
||||
this.log.warn("cannot update game, not found", {
|
||||
gameID: msg.gameID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
game.setStartsAt(msg.startsAt);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendReady(workerId: number) {
|
||||
const msg: WorkerReady = { type: "workerReady", workerId };
|
||||
this.sendToMaster({ type: "workerReady", workerId });
|
||||
}
|
||||
|
||||
private sendToMaster(msg: WorkerReady | WorkerLobbyList) {
|
||||
process.send?.(msg);
|
||||
}
|
||||
|
||||
private sendMyLobbiesToMaster() {
|
||||
const lobbies = this.gm
|
||||
const publicLobbies = this.gm
|
||||
.publicLobbies()
|
||||
.map((g) => g.gameInfo())
|
||||
.map((gi) => {
|
||||
@@ -110,7 +143,87 @@ export class WorkerLobbyService {
|
||||
publicGameType: gi.publicGameType!,
|
||||
} satisfies PublicGameInfo;
|
||||
});
|
||||
process.send?.({ type: "lobbyList", lobbies } satisfies WorkerLobbyList);
|
||||
// Subscriber-listed private lobbies. creatorID (a hash of the creator's
|
||||
// persistentID) rides along for the one-listed-lobby-per-creator check;
|
||||
// sanitizeGames strips it before anything reaches browsers. The config is
|
||||
// reduced to the publicLobbyGameConfig allowlist.
|
||||
const hostedLobbies = this.gm.listedLobbies().map((g) => {
|
||||
const gi = g.gameInfo();
|
||||
return {
|
||||
gameID: gi.gameID,
|
||||
numClients: gi.clients?.length ?? 0,
|
||||
startsAt: gi.startsAt,
|
||||
gameConfig: gi.gameConfig && publicLobbyGameConfig(gi.gameConfig),
|
||||
publicGameType: "hosted",
|
||||
creatorID: g.hashedCreatorID(),
|
||||
} satisfies InternalGameInfo;
|
||||
});
|
||||
this.sendToMaster({
|
||||
type: "lobbyList",
|
||||
lobbies: [...publicLobbies, ...hostedLobbies],
|
||||
} satisfies WorkerLobbyList);
|
||||
}
|
||||
|
||||
// Whether the creator (hashed persistentID) already has a listed lobby
|
||||
// other than `excludeGameID`. Checks the cluster-wide view from the last
|
||||
// master broadcast plus this worker's own lobbies (fresher than the
|
||||
// broadcast interval).
|
||||
public creatorHasListedLobby(
|
||||
hashedCreatorID: string,
|
||||
excludeGameID: string,
|
||||
): boolean {
|
||||
const broadcast = this.lastPublicGames?.games["hosted"] ?? [];
|
||||
if (
|
||||
broadcast.some((l) => {
|
||||
if (l.gameID === excludeGameID || l.creatorID !== hashedCreatorID) {
|
||||
return false;
|
||||
}
|
||||
// Broadcast entries lag a delist by up to two master cycles. For
|
||||
// games this worker owns, local state is authoritative — a
|
||||
// just-delisted lobby must not block the creator from listing a
|
||||
// new one.
|
||||
const local = this.gm.game(l.gameID);
|
||||
return local === null || local.isListed();
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return this.gm
|
||||
.listedLobbies()
|
||||
.some(
|
||||
(g) =>
|
||||
g.id !== excludeGameID && g.hashedCreatorID() === hashedCreatorID,
|
||||
);
|
||||
}
|
||||
|
||||
// Cluster-wide count of listed hosted lobbies: the master broadcast plus
|
||||
// this worker's own listed lobbies that haven't reached it yet. Approximate
|
||||
// by up to a broadcast round-trip; the master's cap is the backstop.
|
||||
public hostedLobbyCount(): number {
|
||||
const broadcast = this.lastPublicGames?.games["hosted"] ?? [];
|
||||
const broadcastIds = new Set(broadcast.map((l) => l.gameID));
|
||||
const localExtra = this.gm
|
||||
.listedLobbies()
|
||||
.filter((g) => !broadcastIds.has(g.id)).length;
|
||||
return broadcast.length + localExtra;
|
||||
}
|
||||
|
||||
// Strips worker/master-internal fields (creatorID) before lobby info is
|
||||
// sent to browser clients, converting InternalGameInfo to the
|
||||
// browser-facing PublicGameInfo.
|
||||
private sanitizeGames(
|
||||
games: InternalPublicGames["games"],
|
||||
): PublicGames["games"] {
|
||||
const sanitized: PublicGames["games"] = {};
|
||||
for (const [type, list] of Object.entries(games) as [
|
||||
keyof PublicGames["games"],
|
||||
InternalGameInfo[],
|
||||
][]) {
|
||||
sanitized[type] = list.map(
|
||||
({ creatorID: _creatorID, ...rest }): PublicGameInfo => rest,
|
||||
);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private setupUpgradeHandler() {
|
||||
@@ -138,7 +251,7 @@ export class WorkerLobbyService {
|
||||
const fullJson = JSON.stringify({
|
||||
type: "full",
|
||||
serverTime: this.lastPublicGames.serverTime,
|
||||
games: this.lastPublicGames.games,
|
||||
games: this.sanitizeGames(this.lastPublicGames.games),
|
||||
} satisfies PublicLobbyMessage);
|
||||
ws.send(fullJson);
|
||||
}
|
||||
@@ -166,15 +279,22 @@ export class WorkerLobbyService {
|
||||
});
|
||||
}
|
||||
|
||||
private broadcastLobbiesToClients(publicGames: PublicGames) {
|
||||
const gameIds: string[] = [];
|
||||
for (const list of Object.values(publicGames.games)) {
|
||||
private broadcastLobbiesToClients(publicGames: InternalPublicGames) {
|
||||
// Per-lobby token is the JSON of exactly what clients receive, minus the
|
||||
// player count: hosted lobbies can change config without a gameID
|
||||
// change, and anything a client could render must trigger a fresh full.
|
||||
// Fingerprinting the sanitized payload keeps "what forces a full" and
|
||||
// "what clients see" from drifting apart.
|
||||
const sanitizedGames = this.sanitizeGames(publicGames.games);
|
||||
const lobbyTokens: string[] = [];
|
||||
for (const list of Object.values(sanitizedGames)) {
|
||||
for (const lobby of list) {
|
||||
gameIds.push(lobby.gameID);
|
||||
// JSON.stringify drops undefined-valued keys, excluding the count.
|
||||
lobbyTokens.push(JSON.stringify({ ...lobby, numClients: undefined }));
|
||||
}
|
||||
}
|
||||
gameIds.sort();
|
||||
const fingerprint = gameIds.join(",");
|
||||
lobbyTokens.sort();
|
||||
const fingerprint = lobbyTokens.join(",");
|
||||
const shouldSendFull = fingerprint !== this.lastFullGameIds;
|
||||
|
||||
let payload: PublicLobbyMessage;
|
||||
@@ -182,7 +302,7 @@ export class WorkerLobbyService {
|
||||
payload = {
|
||||
type: "full",
|
||||
serverTime: publicGames.serverTime,
|
||||
games: publicGames.games,
|
||||
games: sanitizedGames,
|
||||
};
|
||||
this.lastFullGameIds = fingerprint;
|
||||
} else {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import {
|
||||
GoogleUser,
|
||||
GoogleUserSchema,
|
||||
hasActiveSubscription,
|
||||
PlayerGameModeFilterSchema,
|
||||
PlayerGameResultSchema,
|
||||
PlayerGameTypeFilterSchema,
|
||||
PlayerProfileSchema,
|
||||
PublicPlayerGameSchema,
|
||||
PublicPlayerGamesResponseSchema,
|
||||
UserMeResponse,
|
||||
} from "../src/core/ApiSchemas";
|
||||
|
||||
describe("GoogleUserSchema", () => {
|
||||
@@ -200,3 +202,65 @@ describe("PublicPlayerGamesResponseSchema", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasActiveSubscription", () => {
|
||||
function userMeWith(
|
||||
subscription: UserMeResponse["player"]["subscription"],
|
||||
): UserMeResponse {
|
||||
return {
|
||||
user: {},
|
||||
player: {
|
||||
publicId: "p1",
|
||||
adfree: false,
|
||||
achievements: { singleplayerMap: [] },
|
||||
friends: [],
|
||||
subscription,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("is true for an active subscription", () => {
|
||||
expect(
|
||||
hasActiveSubscription(
|
||||
userMeWith({
|
||||
tier: "supporter",
|
||||
status: "active",
|
||||
currentPeriodEnd: null,
|
||||
cancelAtPeriodEnd: false,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is true while trialing", () => {
|
||||
expect(
|
||||
hasActiveSubscription(
|
||||
userMeWith({
|
||||
tier: "supporter",
|
||||
status: "trialing",
|
||||
currentPeriodEnd: null,
|
||||
cancelAtPeriodEnd: false,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for canceled or past_due subscriptions", () => {
|
||||
for (const status of ["canceled", "past_due", "incomplete"]) {
|
||||
expect(
|
||||
hasActiveSubscription(
|
||||
userMeWith({
|
||||
tier: "supporter",
|
||||
status,
|
||||
currentPeriodEnd: null,
|
||||
cancelAtPeriodEnd: false,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("is false without a subscription", () => {
|
||||
expect(hasActiveSubscription(userMeWith(null))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,6 +124,17 @@ describe("PublicLobbySocket.handleMessage", () => {
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("patches counts onto hosted lobbies too", () => {
|
||||
const { callback, dispatch } = makeSocket();
|
||||
dispatch(fullMessage(1000, { hosted: [lobby("h1", 2, "hosted")] }));
|
||||
callback.mockClear();
|
||||
|
||||
dispatch(countsMessage(1500, { h1: 6 }));
|
||||
|
||||
const arg = callback.mock.calls[0][0];
|
||||
expect(arg.games.hosted).toEqual([lobby("h1", 6, "hosted")]);
|
||||
});
|
||||
|
||||
it("does not mutate the previously-delivered snapshot when applying counts", () => {
|
||||
const { callback, dispatch } = makeSocket();
|
||||
dispatch(fullMessage(1000, { ffa: [lobby("g1", 3)] }));
|
||||
|
||||
@@ -0,0 +1,758 @@
|
||||
import EventEmitter from "events";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import {
|
||||
Difficulty,
|
||||
GameMapSize,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
GameType,
|
||||
} from "../../src/core/game/Game";
|
||||
import { MAX_HOSTED_LOBBIES } from "../../src/core/Schemas";
|
||||
import { Client } from "../../src/server/Client";
|
||||
import { GameManager } from "../../src/server/GameManager";
|
||||
import {
|
||||
GamePhase,
|
||||
GameServer,
|
||||
hashPersistentID,
|
||||
} from "../../src/server/GameServer";
|
||||
import {
|
||||
InternalGameInfo,
|
||||
InternalPublicGames,
|
||||
} from "../../src/server/IPCBridgeSchema";
|
||||
import { MasterLobbyService } from "../../src/server/MasterLobbyService";
|
||||
import { ServerEnv } from "../../src/server/ServerEnv";
|
||||
import { WorkerLobbyService } from "../../src/server/WorkerLobbyService";
|
||||
|
||||
vi.mock("../../src/server/Logger", () => ({
|
||||
logger: {
|
||||
child: () => ({
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../src/server/PollingLoop", () => ({
|
||||
startPolling: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogger: any = {
|
||||
child: vi.fn().mockReturnThis(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const CREATOR = "11111111-1111-4111-8111-111111111111";
|
||||
const OTHER_CREATOR = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
function makeGame(
|
||||
id = "test-game",
|
||||
creatorPersistentID: string | undefined = CREATOR,
|
||||
config: Record<string, unknown> = {},
|
||||
) {
|
||||
return new GameServer(
|
||||
id,
|
||||
mockLogger,
|
||||
Date.now(),
|
||||
{ gameType: GameType.Private, ...config } as any,
|
||||
creatorPersistentID,
|
||||
);
|
||||
}
|
||||
|
||||
describe("GameServer listing", () => {
|
||||
it("is unlisted by default and toggles via setListed", () => {
|
||||
const game = makeGame();
|
||||
expect(game.isListed()).toBe(false);
|
||||
game.setListed(true);
|
||||
expect(game.isListed()).toBe(true);
|
||||
game.setListed(false);
|
||||
expect(game.isListed()).toBe(false);
|
||||
});
|
||||
|
||||
it("cannot be listed through update_game_config", () => {
|
||||
const game = makeGame();
|
||||
const result = game.handleIntent(
|
||||
{ type: "update_game_config", config: { listed: true } } as any,
|
||||
{
|
||||
clientID: "c1",
|
||||
isLobbyCreator: true,
|
||||
isAdmin: false,
|
||||
isAdminBot: false,
|
||||
},
|
||||
);
|
||||
expect(result.status).toBe(200);
|
||||
expect(game.isListed()).toBe(false);
|
||||
});
|
||||
|
||||
it("identifies its creator", () => {
|
||||
const game = makeGame();
|
||||
expect(game.isCreator(CREATOR)).toBe(true);
|
||||
expect(game.isCreator(OTHER_CREATOR)).toBe(false);
|
||||
});
|
||||
|
||||
it("never matches a creator when created without one", () => {
|
||||
const game = new GameServer("no-creator", mockLogger, Date.now(), {
|
||||
gameType: GameType.Private,
|
||||
} as any);
|
||||
expect(game.isCreator(CREATOR)).toBe(false);
|
||||
expect(game.hashedCreatorID()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("exposes a stable hash of the creator id, not the raw id", () => {
|
||||
const game = makeGame();
|
||||
const hashed = game.hashedCreatorID();
|
||||
expect(hashed).toBe(hashPersistentID(CREATOR));
|
||||
expect(hashed).not.toContain(CREATOR);
|
||||
expect(hashed).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("reports a join whitelist only when non-empty", () => {
|
||||
expect(makeGame().hasJoinWhitelist()).toBe(false);
|
||||
expect(
|
||||
makeGame("g", CREATOR, { allowedPublicIds: [] }).hasJoinWhitelist(),
|
||||
).toBe(false);
|
||||
expect(
|
||||
makeGame("g", CREATOR, { allowedPublicIds: ["p1"] }).hasJoinWhitelist(),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("delists when a join whitelist is added via config update", () => {
|
||||
const game = makeGame();
|
||||
game.setListed(true);
|
||||
|
||||
game.updateGameConfig({ allowedPublicIds: [] });
|
||||
expect(game.isListed()).toBe(true);
|
||||
|
||||
game.updateGameConfig({ allowedPublicIds: ["p1"] });
|
||||
expect(game.isListed()).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes the listed flag in gameInfo for private lobbies only", () => {
|
||||
const game = makeGame();
|
||||
expect(game.gameInfo().listed).toBe(false);
|
||||
game.setListed(true);
|
||||
expect(game.gameInfo().listed).toBe(true);
|
||||
|
||||
const pub = new GameServer("pub", mockLogger, Date.now(), {
|
||||
gameType: GameType.Public,
|
||||
} as any);
|
||||
expect(pub.gameInfo().listed).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GameManager.listedLobbies", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns only listed private lobbies still in the lobby phase", () => {
|
||||
const gm = new GameManager(mockLogger);
|
||||
const unlisted = gm.createGame("g-unlisted", undefined, CREATOR)!;
|
||||
const listed = gm.createGame("g-listed", undefined, CREATOR)!;
|
||||
listed.setListed(true);
|
||||
|
||||
expect(gm.listedLobbies().map((g) => g.id)).toEqual(["g-listed"]);
|
||||
expect(gm.publicLobbies()).toEqual([]);
|
||||
expect(unlisted.isListed()).toBe(false);
|
||||
});
|
||||
|
||||
it("excludes public games even if flagged listed", () => {
|
||||
const gm = new GameManager(mockLogger);
|
||||
const pub = gm.createGame(
|
||||
"g-public",
|
||||
{ gameType: GameType.Public } as any,
|
||||
undefined,
|
||||
)!;
|
||||
pub.setListed(true);
|
||||
|
||||
expect(gm.listedLobbies()).toEqual([]);
|
||||
expect(gm.publicLobbies().map((g) => g.id)).toEqual(["g-public"]);
|
||||
});
|
||||
|
||||
it("drops a listed lobby once its game starts", () => {
|
||||
const gm = new GameManager(mockLogger);
|
||||
const game = gm.createGame("g-started", undefined, CREATOR)!;
|
||||
game.setListed(true);
|
||||
expect(gm.listedLobbies()).toHaveLength(1);
|
||||
|
||||
(game as any)._hasStarted = true;
|
||||
expect(gm.listedLobbies()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function fakeWs() {
|
||||
const ws = new EventEmitter() as any;
|
||||
ws.readyState = WebSocket.OPEN;
|
||||
ws.send = vi.fn();
|
||||
ws.close = vi.fn();
|
||||
return ws;
|
||||
}
|
||||
|
||||
function makeClient(clientID: string, persistentID: string, ws: any) {
|
||||
return new Client(
|
||||
clientID,
|
||||
persistentID,
|
||||
null,
|
||||
null,
|
||||
undefined,
|
||||
"1.2.3.4",
|
||||
`user_${clientID}`,
|
||||
null,
|
||||
ws,
|
||||
undefined,
|
||||
undefined,
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
describe("host-left lobby teardown", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("ends, delists and prunes an unstarted lobby when the host leaves", () => {
|
||||
const gm = new GameManager(mockLogger);
|
||||
const game = gm.createGame("g-host-leaves", undefined, CREATOR)!;
|
||||
game.setListed(true);
|
||||
|
||||
const hostWs = fakeWs();
|
||||
const guestWs = fakeWs();
|
||||
expect(game.joinClient(makeClient("host", CREATOR, hostWs))).toBe("joined");
|
||||
expect(game.joinClient(makeClient("guest", OTHER_CREATOR, guestWs))).toBe(
|
||||
"joined",
|
||||
);
|
||||
expect(gm.listedLobbies()).toHaveLength(1);
|
||||
|
||||
hostWs.emit("close");
|
||||
|
||||
// Remaining players are kicked and the ghost leaves the listing...
|
||||
expect(guestWs.close).toHaveBeenCalled();
|
||||
expect(game.phase()).toBe(GamePhase.Finished);
|
||||
expect(gm.listedLobbies()).toEqual([]);
|
||||
|
||||
// ...and the next manager tick prunes the game entirely, freeing the
|
||||
// creator's one-listing quota.
|
||||
gm.tick();
|
||||
expect(gm.game("g-host-leaves")).toBeNull();
|
||||
});
|
||||
|
||||
it("tears down even when the host socket was already dead on join", () => {
|
||||
const gm = new GameManager(mockLogger);
|
||||
const game = gm.createGame("g-dead-socket", undefined, CREATOR)!;
|
||||
game.setListed(true);
|
||||
|
||||
const hostWs = fakeWs();
|
||||
hostWs.readyState = WebSocket.CLOSED;
|
||||
game.joinClient(makeClient("host", CREATOR, hostWs));
|
||||
|
||||
expect(game.phase()).toBe(GamePhase.Finished);
|
||||
expect(gm.listedLobbies()).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects joins into an ended lobby before it is pruned", () => {
|
||||
const game = makeGame();
|
||||
(game as any)._hasEnded = true;
|
||||
expect(game.joinClient({} as any)).toBe("rejected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listed lobby host powers", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const asHost = {
|
||||
clientID: "host",
|
||||
isLobbyCreator: true,
|
||||
isAdmin: false,
|
||||
isAdminBot: false,
|
||||
};
|
||||
|
||||
it("reports host cheats only when a cheat is actually granted", () => {
|
||||
expect(makeGame().hasHostCheats()).toBe(false);
|
||||
expect(makeGame("g", CREATOR, { hostCheats: {} }).hasHostCheats()).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
makeGame("g", CREATOR, {
|
||||
hostCheats: { infiniteGold: false, infiniteTroops: false },
|
||||
}).hasHostCheats(),
|
||||
).toBe(false);
|
||||
expect(
|
||||
makeGame("g", CREATOR, {
|
||||
hostCheats: { infiniteGold: true },
|
||||
}).hasHostCheats(),
|
||||
).toBe(true);
|
||||
expect(
|
||||
makeGame("g", CREATOR, {
|
||||
hostCheats: { goldMultiplier: 2 },
|
||||
}).hasHostCheats(),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks host kicks while listed; admins still can", () => {
|
||||
const game = makeGame("g-kick");
|
||||
game.joinClient(makeClient("host", CREATOR, fakeWs()));
|
||||
game.joinClient(makeClient("guest", OTHER_CREATOR, fakeWs()));
|
||||
game.setListed(true);
|
||||
|
||||
const kick = { type: "kick_player", targetClientID: "guest" } as any;
|
||||
expect(game.handleIntent(kick, asHost).status).toBe(403);
|
||||
|
||||
// Unlisting restores the host's kick power.
|
||||
game.setListed(false);
|
||||
expect(game.handleIntent(kick, asHost).status).toBe(200);
|
||||
});
|
||||
|
||||
it("lets admins kick in a listed lobby", () => {
|
||||
const game = makeGame("g-admin-kick");
|
||||
game.joinClient(makeClient("host", CREATOR, fakeWs()));
|
||||
game.joinClient(makeClient("guest", OTHER_CREATOR, fakeWs()));
|
||||
game.setListed(true);
|
||||
|
||||
const asAdmin = {
|
||||
clientID: "admin",
|
||||
isLobbyCreator: false,
|
||||
isAdmin: true,
|
||||
isAdminBot: false,
|
||||
};
|
||||
expect(
|
||||
game.handleIntent(
|
||||
{ type: "kick_player", targetClientID: "guest" } as any,
|
||||
asAdmin,
|
||||
).status,
|
||||
).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects enabling host cheats while listed", () => {
|
||||
const game = makeGame();
|
||||
game.setListed(true);
|
||||
|
||||
const cheats = {
|
||||
type: "update_game_config",
|
||||
config: { hostCheats: { infiniteGold: true } },
|
||||
} as any;
|
||||
expect(game.handleIntent(cheats, asHost).status).toBe(409);
|
||||
expect(game.hasHostCheats()).toBe(false);
|
||||
|
||||
// A neutral hostCheats block still goes through (the client always
|
||||
// sends the field), as do real cheats once the lobby is unlisted.
|
||||
expect(
|
||||
game.handleIntent(
|
||||
{ type: "update_game_config", config: { hostCheats: {} } } as any,
|
||||
asHost,
|
||||
).status,
|
||||
).toBe(200);
|
||||
game.setListed(false);
|
||||
expect(game.handleIntent(cheats, asHost).status).toBe(200);
|
||||
expect(game.hasHostCheats()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function hostedLobby(
|
||||
gameID: string,
|
||||
creatorID: string | undefined,
|
||||
extra: Partial<InternalGameInfo> = {},
|
||||
): InternalGameInfo {
|
||||
return {
|
||||
gameID,
|
||||
numClients: 0,
|
||||
publicGameType: "hosted",
|
||||
creatorID,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MasterLobbyService hosted lobbies", () => {
|
||||
function createService() {
|
||||
vi.spyOn(ServerEnv, "numWorkers").mockReturnValue(2);
|
||||
vi.spyOn(ServerEnv, "workerIndex").mockReturnValue(1);
|
||||
vi.spyOn(ServerEnv, "gameCreationRate").mockReturnValue(60_000);
|
||||
const playlist = {
|
||||
gameConfig: vi.fn().mockResolvedValue({ gameType: GameType.Public }),
|
||||
};
|
||||
const log = { info: vi.fn(), error: vi.fn() } as any;
|
||||
const service = new MasterLobbyService(playlist as any, log);
|
||||
|
||||
const workers = [1, 2].map((id) => {
|
||||
const worker = new EventEmitter();
|
||||
(worker as any).send = vi.fn();
|
||||
service.registerWorker(id, worker as any);
|
||||
return worker;
|
||||
});
|
||||
return { service, workers };
|
||||
}
|
||||
|
||||
function sentMessages(worker: EventEmitter): any[] {
|
||||
return ((worker as any).send as ReturnType<typeof vi.fn>).mock.calls.map(
|
||||
(c) => c[0],
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("aggregates hosted lobbies and dedupes by creator across workers", () => {
|
||||
const { service, workers } = createService();
|
||||
workers[0].emit("message", {
|
||||
type: "lobbyList",
|
||||
lobbies: [
|
||||
hostedLobby("bbb", "creator-a"),
|
||||
hostedLobby("ccc", "creator-b"),
|
||||
],
|
||||
});
|
||||
// Same creator listed a second lobby on another worker before the first
|
||||
// broadcast landed; only the stable-sort winner may survive.
|
||||
workers[1].emit("message", {
|
||||
type: "lobbyList",
|
||||
lobbies: [hostedLobby("aaa", "creator-a")],
|
||||
});
|
||||
|
||||
(service as any).broadcastLobbies();
|
||||
|
||||
const broadcast = sentMessages(workers[0]).find(
|
||||
(m) => m.type === "lobbiesBroadcast",
|
||||
);
|
||||
expect(broadcast).toBeDefined();
|
||||
const hosted = broadcast.publicGames.games.hosted;
|
||||
expect(hosted.map((l: InternalGameInfo) => l.gameID)).toEqual([
|
||||
"aaa",
|
||||
"ccc",
|
||||
]);
|
||||
});
|
||||
|
||||
it("delists a dedup loser only after two consecutive losing broadcasts", () => {
|
||||
const { service, workers } = createService();
|
||||
workers[0].emit("message", {
|
||||
type: "lobbyList",
|
||||
lobbies: [hostedLobby("bbb", "creator-a")],
|
||||
});
|
||||
workers[1].emit("message", {
|
||||
type: "lobbyList",
|
||||
lobbies: [hostedLobby("aaa", "creator-a")],
|
||||
});
|
||||
|
||||
(service as any).broadcastLobbies();
|
||||
(service as any).broadcastLobbies();
|
||||
|
||||
const broadcasts = sentMessages(workers[0]).filter(
|
||||
(m) => m.type === "lobbiesBroadcast",
|
||||
);
|
||||
// First loss could be a stale worker report; only the second in a row
|
||||
// triggers the delist.
|
||||
expect(broadcasts[0].delistGameIDs).toBeUndefined();
|
||||
expect(broadcasts[1].delistGameIDs).toEqual(["bbb"]);
|
||||
});
|
||||
|
||||
it("caps hosted lobbies at MAX_HOSTED_LOBBIES and delists the overflow", () => {
|
||||
const { service, workers } = createService();
|
||||
const lobbies = Array.from({ length: MAX_HOSTED_LOBBIES + 1 }, (_, i) =>
|
||||
hostedLobby(`g${String(i).padStart(2, "0")}`, `creator-${i}`),
|
||||
);
|
||||
workers[0].emit("message", { type: "lobbyList", lobbies });
|
||||
|
||||
(service as any).broadcastLobbies();
|
||||
(service as any).broadcastLobbies();
|
||||
|
||||
const broadcasts = sentMessages(workers[0]).filter(
|
||||
(m) => m.type === "lobbiesBroadcast",
|
||||
);
|
||||
expect(broadcasts[0].publicGames.games.hosted).toHaveLength(
|
||||
MAX_HOSTED_LOBBIES,
|
||||
);
|
||||
// The sort loser (highest gameID) is dropped from the broadcast and,
|
||||
// after two consecutive losing cycles, delisted.
|
||||
expect(broadcasts[0].delistGameIDs).toBeUndefined();
|
||||
expect(broadcasts[1].delistGameIDs).toEqual([`g${MAX_HOSTED_LOBBIES}`]);
|
||||
});
|
||||
|
||||
it("does not delist when the duplicate disappears after one broadcast", () => {
|
||||
const { service, workers } = createService();
|
||||
workers[0].emit("message", {
|
||||
type: "lobbyList",
|
||||
lobbies: [hostedLobby("bbb", "creator-a")],
|
||||
});
|
||||
workers[1].emit("message", {
|
||||
type: "lobbyList",
|
||||
lobbies: [hostedLobby("aaa", "creator-a")],
|
||||
});
|
||||
(service as any).broadcastLobbies();
|
||||
|
||||
// The losing entry was stale: the next report no longer contains it.
|
||||
workers[0].emit("message", { type: "lobbyList", lobbies: [] });
|
||||
(service as any).broadcastLobbies();
|
||||
|
||||
for (const msg of sentMessages(workers[0])) {
|
||||
if (msg.type === "lobbiesBroadcast") {
|
||||
expect(msg.delistGameIDs).toBeUndefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("never schedules or sets countdowns on hosted lobbies", async () => {
|
||||
const { service, workers } = createService();
|
||||
workers[0].emit("message", {
|
||||
type: "lobbyList",
|
||||
lobbies: [hostedLobby("hosted1", "creator-a")],
|
||||
});
|
||||
|
||||
await (service as any).maybeScheduleLobby();
|
||||
|
||||
for (const worker of workers) {
|
||||
for (const msg of sentMessages(worker)) {
|
||||
if (msg.type === "updateLobby") {
|
||||
expect(msg.gameID).not.toBe("hosted1");
|
||||
}
|
||||
if (msg.type === "createGame") {
|
||||
expect(msg.publicGameType).not.toBe("hosted");
|
||||
}
|
||||
}
|
||||
}
|
||||
// The scheduled types still get their replacement lobbies.
|
||||
const created = workers.flatMap((w) =>
|
||||
sentMessages(w).filter((m) => m.type === "createGame"),
|
||||
);
|
||||
expect(created.map((m) => m.publicGameType).sort()).toEqual([
|
||||
"ffa",
|
||||
"special",
|
||||
"team",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkerLobbyService hosted lobbies", () => {
|
||||
let service: WorkerLobbyService;
|
||||
let gm: any;
|
||||
let sendToMaster: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
gm = {
|
||||
publicLobbies: vi.fn().mockReturnValue([]),
|
||||
listedLobbies: vi.fn().mockReturnValue([]),
|
||||
game: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
const server = new EventEmitter();
|
||||
service = new WorkerLobbyService(
|
||||
server as any,
|
||||
{ handleUpgrade: vi.fn() } as any,
|
||||
gm,
|
||||
mockLogger,
|
||||
);
|
||||
// Never touch the real process IPC channel: vitest forks use it.
|
||||
sendToMaster = vi.fn();
|
||||
(service as any).sendToMaster = sendToMaster;
|
||||
});
|
||||
|
||||
function emitBroadcast(
|
||||
games: InternalPublicGames["games"],
|
||||
serverTime = 1000,
|
||||
delistGameIDs?: string[],
|
||||
) {
|
||||
(service as any).handleMasterMessage({
|
||||
type: "lobbiesBroadcast",
|
||||
publicGames: { serverTime, games },
|
||||
delistGameIDs,
|
||||
});
|
||||
}
|
||||
|
||||
function connectClient() {
|
||||
const ws = {
|
||||
send: vi.fn(),
|
||||
on: vi.fn(),
|
||||
readyState: WebSocket.OPEN,
|
||||
};
|
||||
(service as any).lobbiesWss.emit("connection", ws);
|
||||
return ws;
|
||||
}
|
||||
|
||||
function sentPayloads(ws: { send: ReturnType<typeof vi.fn> }): any[] {
|
||||
return ws.send.mock.calls.map((c) => JSON.parse(c[0]));
|
||||
}
|
||||
|
||||
it("reports listed lobbies to master as hosted, with creatorID and without host-only config", () => {
|
||||
const game = makeGame("hosted-g1", CREATOR, {
|
||||
allowedPublicIds: ["p1"],
|
||||
nameReveals: ["c1"],
|
||||
nameRevealPublicIds: ["p2"],
|
||||
hostCheats: { infiniteGold: true },
|
||||
});
|
||||
game.setListed(true);
|
||||
gm.listedLobbies.mockReturnValue([game]);
|
||||
|
||||
emitBroadcast({ ffa: [], team: [], special: [], hosted: [] });
|
||||
|
||||
const lobbyList = sendToMaster.mock.calls
|
||||
.map((c: any[]) => c[0])
|
||||
.find((m: any) => m.type === "lobbyList");
|
||||
expect(lobbyList.lobbies).toHaveLength(1);
|
||||
const reported = lobbyList.lobbies[0];
|
||||
expect(reported.publicGameType).toBe("hosted");
|
||||
expect(reported.creatorID).toBe(hashPersistentID(CREATOR));
|
||||
expect(reported.gameConfig.allowedPublicIds).toBeUndefined();
|
||||
expect(reported.gameConfig.nameReveals).toBeUndefined();
|
||||
expect(reported.gameConfig.nameRevealPublicIds).toBeUndefined();
|
||||
expect(reported.gameConfig.hostCheats).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips creatorID from broadcasts and primed snapshots sent to clients", () => {
|
||||
const ws = connectClient();
|
||||
emitBroadcast({
|
||||
ffa: [],
|
||||
team: [],
|
||||
special: [],
|
||||
hosted: [hostedLobby("g1", "secret-hash")],
|
||||
});
|
||||
|
||||
const full = sentPayloads(ws).find((p) => p.type === "full");
|
||||
expect(full.games.hosted[0].gameID).toBe("g1");
|
||||
expect(full.games.hosted[0].creatorID).toBeUndefined();
|
||||
|
||||
// A client connecting after the broadcast gets the primed snapshot,
|
||||
// which must be sanitized too.
|
||||
const lateWs = connectClient();
|
||||
const primed = sentPayloads(lateWs)[0];
|
||||
expect(primed.type).toBe("full");
|
||||
expect(primed.games.hosted[0].creatorID).toBeUndefined();
|
||||
});
|
||||
|
||||
it("re-sends a full when a hosted lobby's config changes without a gameID change", () => {
|
||||
const ws = connectClient();
|
||||
// A schema-valid config: an invalid one would be rejected by the IPC
|
||||
// message parse and the broadcast silently dropped.
|
||||
const games = (gameMap: GameMapType) => ({
|
||||
ffa: [],
|
||||
team: [],
|
||||
special: [],
|
||||
hosted: [
|
||||
hostedLobby("g1", "hash", {
|
||||
gameConfig: {
|
||||
gameMap,
|
||||
difficulty: Difficulty.Easy,
|
||||
donateGold: false,
|
||||
donateTroops: false,
|
||||
gameType: GameType.Private,
|
||||
gameMode: GameMode.FFA,
|
||||
gameMapSize: GameMapSize.Normal,
|
||||
nations: "default",
|
||||
bots: 0,
|
||||
infiniteGold: false,
|
||||
infiniteTroops: false,
|
||||
instantBuild: false,
|
||||
randomSpawn: false,
|
||||
} as any,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
emitBroadcast(games(GameMapType.World), 1000);
|
||||
emitBroadcast(games(GameMapType.World), 2000); // unchanged -> counts delta
|
||||
emitBroadcast(games(GameMapType.Europe), 3000); // host changed map -> fresh full
|
||||
|
||||
const types = sentPayloads(ws).map((p) => p.type);
|
||||
expect(types).toEqual(["full", "counts", "full"]);
|
||||
});
|
||||
|
||||
describe("creatorHasListedLobby", () => {
|
||||
it("finds other lobbies by the same creator in the master broadcast", () => {
|
||||
emitBroadcast({
|
||||
ffa: [],
|
||||
team: [],
|
||||
special: [],
|
||||
hosted: [hostedLobby("g1", "hash-a")],
|
||||
});
|
||||
|
||||
expect(service.creatorHasListedLobby("hash-a", "other-game")).toBe(true);
|
||||
// The lobby being toggled itself does not count.
|
||||
expect(service.creatorHasListedLobby("hash-a", "g1")).toBe(false);
|
||||
expect(service.creatorHasListedLobby("hash-b", "other-game")).toBe(false);
|
||||
});
|
||||
|
||||
it("also checks this worker's own lobbies, ahead of the broadcast", () => {
|
||||
const game = makeGame("local-g1", CREATOR);
|
||||
game.setListed(true);
|
||||
gm.listedLobbies.mockReturnValue([game]);
|
||||
|
||||
const hash = hashPersistentID(CREATOR);
|
||||
expect(service.creatorHasListedLobby(hash, "other-game")).toBe(true);
|
||||
expect(service.creatorHasListedLobby(hash, "local-g1")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale broadcast entries for own games no longer listed", () => {
|
||||
// The lobby was just delisted on this worker, but the master broadcast
|
||||
// still contains it for a round-trip or two. Local state wins so the
|
||||
// creator can immediately list a new lobby.
|
||||
const game = makeGame("stale-g1", CREATOR);
|
||||
game.setListed(false);
|
||||
gm.game.mockImplementation((id: string) =>
|
||||
id === "stale-g1" ? game : null,
|
||||
);
|
||||
const hash = hashPersistentID(CREATOR);
|
||||
emitBroadcast({
|
||||
ffa: [],
|
||||
team: [],
|
||||
special: [],
|
||||
hosted: [hostedLobby("stale-g1", hash)],
|
||||
});
|
||||
|
||||
expect(service.creatorHasListedLobby(hash, "other-game")).toBe(false);
|
||||
|
||||
game.setListed(true);
|
||||
expect(service.creatorHasListedLobby(hash, "other-game")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("counts broadcast lobbies plus local listed ones not yet reported", () => {
|
||||
const local = makeGame("local-g1", CREATOR);
|
||||
local.setListed(true);
|
||||
gm.listedLobbies.mockReturnValue([local]);
|
||||
|
||||
emitBroadcast({
|
||||
ffa: [],
|
||||
team: [],
|
||||
special: [],
|
||||
hosted: [hostedLobby("g1", "hash-a"), hostedLobby("g2", "hash-b")],
|
||||
});
|
||||
expect(service.hostedLobbyCount()).toBe(3);
|
||||
|
||||
// Once the local lobby reaches the broadcast it only counts once.
|
||||
emitBroadcast({
|
||||
ffa: [],
|
||||
team: [],
|
||||
special: [],
|
||||
hosted: [hostedLobby("g1", "hash-a"), hostedLobby("local-g1", "hash-c")],
|
||||
});
|
||||
expect(service.hostedLobbyCount()).toBe(2);
|
||||
});
|
||||
|
||||
it("clears the listed flag when the master delists a duplicate", () => {
|
||||
const game = makeGame("dup-g1", CREATOR);
|
||||
game.setListed(true);
|
||||
gm.game.mockImplementation((id: string) => (id === "dup-g1" ? game : null));
|
||||
|
||||
emitBroadcast({ ffa: [], team: [], special: [], hosted: [] }, 1000, [
|
||||
"dup-g1",
|
||||
"not-mine",
|
||||
]);
|
||||
|
||||
expect(game.isListed()).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user