dynamic lobby sizes

This commit is contained in:
evanpelle
2026-03-12 10:33:25 -07:00
parent 4cfefdfb02
commit 8f05065f84
5 changed files with 178 additions and 49 deletions
+79 -43
View File
@@ -88,9 +88,12 @@ export class GameModeSelector extends LitElement {
);
this.requestUpdate();
const allGames = Object.values(lobbies.games ?? {}).flat();
const allGames =
lobbies.mode === "multi"
? Object.values(lobbies.games).flat()
: [lobbies.lobby];
for (const game of allGames) {
const mapType = game.gameConfig?.gameMap as GameMapType;
const mapType = game?.gameConfig?.gameMap as GameMapType;
if (mapType && !this.mapAspectRatios.has(mapType)) {
// New Map reference triggers Lit reactivity; placeholder ratio 1 lets
// has() guard against duplicate in-flight fetches.
@@ -114,9 +117,15 @@ export class GameModeSelector extends LitElement {
}
render() {
const ffa = this.lobbies?.games?.["ffa"]?.[0];
const teams = this.lobbies?.games?.["team"]?.[0];
const special = this.lobbies?.games?.["special"]?.[0];
const lobbies = this.lobbies;
const isSingle = lobbies?.mode === "single";
const singleLobby = lobbies?.mode === "single" ? lobbies.lobby : undefined;
const ffa =
lobbies?.mode === "multi" ? lobbies.games?.["ffa"]?.[0] : undefined;
const teams =
lobbies?.mode === "multi" ? lobbies.games?.["team"]?.[0] : undefined;
const special =
lobbies?.mode === "multi" ? lobbies.games?.["special"]?.[0] : undefined;
return html`
<div class="flex flex-col gap-4 w-full px-4 sm:px-0 mx-auto pb-4 sm:pb-0">
@@ -148,47 +157,74 @@ export class GameModeSelector extends LitElement {
</div>
<!-- Game cards grid -->
<div
class="grid grid-cols-1 sm:grid-cols-[2fr_1fr] gap-4 sm:h-[min(24rem,40vh)]"
class="grid grid-cols-1 gap-4 sm:h-[min(24rem,40vh)] ${isSingle
? ""
: "sm:grid-cols-[2fr_1fr]"}"
>
<!-- Left col: main card (desktop only) -->
${special
? html`<div class="hidden sm:block">
${this.renderSpecialLobbyCard(special)}
</div>`
: ffa
? html`<div class="hidden sm:block">
${this.renderLobbyCard(ffa, this.getLobbyTitle(ffa))}
</div>`
: nothing}
${isSingle && singleLobby
? html`
<!-- Single lobby mode: one full-width card -->
<div class="hidden sm:block">
${singleLobby.publicGameType === "special"
? this.renderSpecialLobbyCard(singleLobby)
: this.renderLobbyCard(
singleLobby,
this.getLobbyTitle(singleLobby),
)}
</div>
<div class="sm:hidden">
${singleLobby.publicGameType === "special"
? this.renderSpecialLobbyCard(singleLobby)
: this.renderLobbyCard(
singleLobby,
this.getLobbyTitle(singleLobby),
)}
</div>
`
: html`
<!-- Left col: main card (desktop only) -->
${special
? html`<div class="hidden sm:block">
${this.renderSpecialLobbyCard(special)}
</div>`
: ffa
? html`<div class="hidden sm:block">
${this.renderLobbyCard(ffa, this.getLobbyTitle(ffa))}
</div>`
: nothing}
<!-- Right col: FFA + teams (desktop only) -->
<div class="hidden sm:flex sm:flex-col sm:gap-4">
${special && ffa
? html`<div class="flex-1 min-h-0">
${this.renderLobbyCard(ffa, this.getLobbyTitle(ffa))}
</div>`
: nothing}
${teams
? html`<div class="flex-1 min-h-0">
${this.renderLobbyCard(teams, this.getLobbyTitle(teams))}
</div>`
: nothing}
</div>
<!-- Right col: FFA + teams (desktop only) -->
<div class="hidden sm:flex sm:flex-col sm:gap-4">
${special && ffa
? html`<div class="flex-1 min-h-0">
${this.renderLobbyCard(ffa, this.getLobbyTitle(ffa))}
</div>`
: nothing}
${teams
? html`<div class="flex-1 min-h-0">
${this.renderLobbyCard(
teams,
this.getLobbyTitle(teams),
)}
</div>`
: nothing}
</div>
<!-- Mobile: special, ffa, teams inline -->
<div class="sm:hidden">
${special ? this.renderSpecialLobbyCard(special) : nothing}
</div>
<div class="sm:hidden">
${ffa
? this.renderLobbyCard(ffa, this.getLobbyTitle(ffa))
: nothing}
</div>
<div class="sm:hidden">
${teams
? this.renderLobbyCard(teams, this.getLobbyTitle(teams))
: nothing}
</div>
<!-- Mobile: special, ffa, teams inline -->
<div class="sm:hidden">
${special ? this.renderSpecialLobbyCard(special) : nothing}
</div>
<div class="sm:hidden">
${ffa
? this.renderLobbyCard(ffa, this.getLobbyTitle(ffa))
: nothing}
</div>
<div class="sm:hidden">
${teams
? this.renderLobbyCard(teams, this.getLobbyTitle(teams))
: nothing}
</div>
`}
</div>
<!-- Solo: full width, desktop only -->
+13 -1
View File
@@ -164,11 +164,23 @@ export const PublicGameInfoSchema = z.object({
publicGameType: PublicGameTypeSchema,
});
export const PublicGamesSchema = z.object({
const SingleLobbyGamesSchema = z.object({
mode: z.literal("single"),
serverTime: z.number(),
lobby: PublicGameInfoSchema,
});
const MultiLobbyGamesSchema = z.object({
mode: z.literal("multi"),
serverTime: z.number(),
games: z.record(PublicGameTypeSchema, z.array(PublicGameInfoSchema)),
});
export const PublicGamesSchema = z.discriminatedUnion("mode", [
SingleLobbyGamesSchema,
MultiLobbyGamesSchema,
]);
export class LobbyInfoEvent implements GameEvent {
constructor(
public lobby: GameInfo,
+1
View File
@@ -23,6 +23,7 @@ export type MasterMessage = z.infer<typeof MasterMessageSchema>;
const WorkerLobbyListSchema = z.object({
type: z.literal("lobbyList"),
lobbies: z.array(PublicGameInfoSchema),
clientCount: z.number(),
});
const WorkerReadySchema = z.object({
+80 -4
View File
@@ -19,12 +19,17 @@ export interface MasterLobbyServiceOptions {
log: typeof logger;
}
const CCU_SINGLE_LOBBY_THRESHOLD = 1500;
const SINGLE_LOBBY_ROTATION: PublicGameType[] = ["special", "team", "ffa"];
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 workerClientCounts = new Map<number, number>();
private readonly readyWorkers = new Set<number>();
private started = false;
private singleLobbyRotationIndex = 0;
constructor(
private config: ServerConfig,
@@ -49,6 +54,7 @@ export class MasterLobbyService {
break;
case "lobbyList":
this.workerLobbies.set(workerId, msg.lobbies);
this.workerClientCounts.set(workerId, msg.clientCount);
break;
}
});
@@ -109,13 +115,27 @@ export class MasterLobbyService {
return result;
}
private getTotalCCU(): number {
let total = 0;
for (const count of this.workerClientCounts.values()) {
total += count;
}
return total;
}
private broadcastLobbies() {
const singleLobbyMode = this.getTotalCCU() < CCU_SINGLE_LOBBY_THRESHOLD;
const publicGames = singleLobbyMode
? this.buildSingleLobbyBroadcast()
: {
mode: "multi" as const,
serverTime: Date.now(),
games: this.getAllLobbies(),
};
const msg = {
type: "lobbiesBroadcast",
publicGames: {
serverTime: Date.now(),
games: this.getAllLobbies(),
},
publicGames,
} satisfies MasterLobbiesBroadcast;
for (const worker of this.workers.values()) {
worker.send(msg, (e) => {
@@ -126,7 +146,63 @@ export class MasterLobbyService {
}
}
private buildSingleLobbyBroadcast() {
const allLobbies = this.getAllLobbies();
const type = SINGLE_LOBBY_ROTATION[this.singleLobbyRotationIndex];
const lobby = allLobbies[type][0];
// If the active rotation type has no lobby yet, fall back to any available lobby.
const activeLobby =
lobby ?? SINGLE_LOBBY_ROTATION.map((t) => allLobbies[t][0]).find(Boolean);
return {
mode: "single" as const,
serverTime: Date.now(),
lobby: activeLobby,
};
}
private async maybeScheduleLobby() {
const singleLobbyMode = this.getTotalCCU() < CCU_SINGLE_LOBBY_THRESHOLD;
if (singleLobbyMode) {
await this.maybeScheduleSingleLobby();
} else {
await this.maybeScheduleAllLobbies();
}
}
private async maybeScheduleSingleLobby() {
const allLobbies = this.getAllLobbies();
const type = SINGLE_LOBBY_ROTATION[this.singleLobbyRotationIndex];
const lobbies = allLobbies[type];
// Advance rotation when the current lobby has started.
if (lobbies[0]?.startsAt !== undefined) {
this.singleLobbyRotationIndex =
(this.singleLobbyRotationIndex + 1) % SINGLE_LOBBY_ROTATION.length;
}
// Clean up any stale lobbies from the other two types (don't schedule more of them).
// We only need to manage the active type.
if (lobbies.length >= 2) return;
const nextLobby = lobbies[0];
if (nextLobby && nextLobby.startsAt === undefined) {
this.sendMessageToWorker({
type: "updateLobby",
gameID: nextLobby.gameID,
startsAt: Date.now() + this.config.gameCreationRate(),
});
}
this.sendMessageToWorker({
type: "createGame",
gameID: generateID(),
gameConfig: await this.playlist.gameConfig(type),
publicGameType: type,
} satisfies MasterCreateGame);
}
private async maybeScheduleAllLobbies() {
const lobbiesByType = this.getAllLobbies();
for (const type of Object.keys(lobbiesByType) as PublicGameType[]) {
+5 -1
View File
@@ -88,7 +88,11 @@ export class WorkerLobbyService {
publicGameType: gi.publicGameType!,
} satisfies PublicGameInfo;
});
process.send?.({ type: "lobbyList", lobbies } satisfies WorkerLobbyList);
process.send?.({
type: "lobbyList",
lobbies,
clientCount: this.gm.activeClients(),
} satisfies WorkerLobbyList);
}
private setupUpgradeHandler() {