Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,7 @@
"no_heartbeat": "The server stopped hearing from you.",
"not_allowlisted": "You are not on this lobby's allowlist.",
"not_trusted": "This lobby is limited to trusted accounts. You can still join any other lobby.",
"pool_redirect": "This lobby already sent you to another one. Please try again.",
"protocol_error": "The connection was corrupted.",
"ranked_limit_reached": "You are out of free ranked matches for today.",
"turnstile_failed": "The bot check failed. Please reload the page and try again.",
Expand Down
40 changes: 40 additions & 0 deletions src/client/Transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,14 @@ export class SendSpectateEvent implements GameEvent {
constructor(public readonly spectator: boolean) {}
}

// One-shot marker that this lobby has already sent us to a sibling, so a
// redirect can never become a bounce.
const poolRedirectLatch = (gameID: string) => `pool-redirect:${gameID}`;

// The lobby a redirect came FROM, carried across the navigation: the latch is
// keyed by the source, but only the target can see that the redirect worked.
const POOL_REDIRECT_FROM = "pool-redirect-from";

export class Transport {
// Retry budget for a dropped game socket. The first retry is immediate (a
// blip should not cost a second), then exponential from the base to the
Expand Down Expand Up @@ -466,11 +474,24 @@ export class Transport {
new Uint8Array(event.data as ArrayBuffer),
this.zbinCtx ?? undefined,
);
if (msg.type === "redirect") {
this.handlePoolRedirect(msg.gameID);
return;
}
if (msg.type === "start") {
// Seed the dictionary from the same players array, in the same
// order, that the server seeded its own from.
this.zbinCtx = createGameWireContext(msg.gameStartInfo.players);
}
if (!this.isSessionReady) {
// We reached a lobby, so the redirect that sent us here is spent:
// drop the source's latch so it can route this player again later.
const from = sessionStorage.getItem(POOL_REDIRECT_FROM);
if (from !== null) {
sessionStorage.removeItem(poolRedirectLatch(from));
sessionStorage.removeItem(POOL_REDIRECT_FROM);
}
}
this.isSessionReady = true;
this.flushBuffer();
this.onmessage(msg);
Expand Down Expand Up @@ -524,6 +545,25 @@ export class Transport {
};
}

// The lobby we asked for assigned us to a sibling. Getting here twice is
// ordinary — sent to a sibling, found it full, came back — so the latched
// branch falls through to the refusal dialog rather than leaving a dead
// loading screen, the way the WrongWorker recovery below does.
//
// The search string is dropped: it belongs to the lobby we asked for, not
// the one we land on.
private handlePoolRedirect(gameID: string) {
const from = this.lobbyConfig.gameID;
const latch = poolRedirectLatch(from);
if (sessionStorage.getItem(latch) !== null) {
this.handleConnectionRefused(CloseReason.PoolRedirect);
return;
}
sessionStorage.setItem(latch, "1");
sessionStorage.setItem(POOL_REDIRECT_FROM, from);
window.location.href = ClientEnv.gamePath(gameID);
}

private handleConnectionRefused(reason: string) {
if (this.connectionRefused) {
return;
Expand Down
3 changes: 3 additions & 0 deletions src/core/CloseCodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export const CloseReason = {
RankedLimitReached: "close_reason.ranked_limit_reached",
InvalidClan: "close_reason.invalid_clan",
ClanVerificationFailed: "close_reason.clan_verification_failed",
// Rendered in one case only: a second redirect from the same lobby, which
// the client refuses rather than following. The redirect itself is silent.
PoolRedirect: "close_reason.pool_redirect",
// Shown for a terminal close whose reason is not one of ours.
Unknown: "close_reason.unknown",
} as const;
Expand Down
46 changes: 45 additions & 1 deletion src/core/Schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@ export type ServerMessage =
| ServerPrestartMessage
| ServerErrorMessage
| ServerLobbyInfoMessage
| ServerNewLobbyMessage;
| ServerNewLobbyMessage
| ServerRedirectMessage;

export type ServerTurnMessage = z.infer<typeof ServerTurnMessageSchema>;
export type ServerStartGameMessage = z.infer<
Expand All @@ -129,6 +130,7 @@ export type ServerLobbyInfoMessage = z.infer<
typeof ServerLobbyInfoMessageSchema
>;
export type ServerNewLobbyMessage = z.infer<typeof ServerNewLobbyMessageSchema>;
export type ServerRedirectMessage = z.infer<typeof ServerRedirectMessageSchema>;
export type ClientSendWinnerMessage = z.infer<typeof ClientSendWinnerSchema>;
export type ClientSendLiveStatsMessage = z.infer<
typeof ClientSendLiveStatsSchema
Expand Down Expand Up @@ -475,6 +477,34 @@ export const OvertimeConfigSchema = z.object({
startMinutes: zb.uint({ min: 1, max: 120 }).optional(),
});

// A lobby pool: several lobbies that arriving players are spread across, so
// one advertised entry point can absorb more players than a single lobby
// holds. Assignment is a hash of the joiner's identity (server/PoolRouting.ts),
// so members need no shared state. Every member carries this same config and
// recognises itself by its own game id.
//
// The advertised entry point is itself a member rather than an empty router:
// a lobby nobody plays in would start, leave the Lobby phase, drop out of the
// listing and be reaped, taking the entry point with it.
export const PoolConfigSchema = z
.object({
id: z.string().min(1).max(64),
// z.lazy because ID is declared further down this file, and GameConfigSchema
// — which embeds this — is evaluated before that point.
siblings: z
.lazy(() => ID)
.array()
.min(1)
.max(64),
})
// Rejected rather than deduped: a repeated id holds more than one slot and
// draws proportionally more players than the rest.
.refine((pool) => new Set(pool.siblings).size === pool.siblings.length, {
error: "pool siblings must be unique",
path: ["siblings"],
});
export type PoolConfig = z.infer<typeof PoolConfigSchema>;

export const GameConfigSchema = z.object({
gameMap: z.enum(GameMapType),
difficulty: z.enum(Difficulty),
Expand Down Expand Up @@ -553,6 +583,9 @@ export const GameConfigSchema = z.object({
startingGold: zb.uint({ max: 1000000000 }).nullable().optional(),
})
.optional(),
// Stripped from gameStartInfo and from the advertised lobby config: sibling
// ids are private lobby ids, which are join secrets.
pool: PoolConfigSchema.optional(),
});

export const TeamSchema = z.string();
Expand Down Expand Up @@ -1030,6 +1063,15 @@ export const ServerNewLobbyMessageSchema = z.object({
gameID: ID,
});

// Sent to a joiner this lobby's pool assigns elsewhere, immediately before the
// close. A close frame's reason is a fixed enum and cannot carry an id, so the
// target needs a frame of its own; the id is all the client needs, since it
// resolves the hosting worker from the id itself.
export const ServerRedirectMessageSchema = z.object({
type: z.literal("redirect"),
gameID: ID,
});

export const ServerMessageSchema = zb.discriminatedUnion("type", [
ServerTurnMessageSchema,
ServerPrestartMessageSchema,
Expand All @@ -1039,6 +1081,8 @@ export const ServerMessageSchema = zb.discriminatedUnion("type", [
ServerErrorSchema,
ServerLobbyInfoMessageSchema,
ServerNewLobbyMessageSchema,
// Appended, never inserted: variant order is the wire tag (zbin/README.md).
ServerRedirectMessageSchema,
]);

//
Expand Down
4 changes: 3 additions & 1 deletion src/core/WorkerSchemas.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { z } from "zod";
import { GameConfigSchema } from "./Schemas";

export const CreateGameInputSchema = GameConfigSchema.or(
// `pool` points joiners at other lobbies, so only the authenticated admin-bot
// route (which parses GameConfigSchema directly) may set one.
export const CreateGameInputSchema = GameConfigSchema.omit({ pool: true }).or(
z
.object({})
.strict()
Expand Down
Loading
Loading