mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 06:12:42 +00:00
Add 2v2 ranked matchmaking (#4596)
## Description: Implements 2v2 ranked matchmaking end-to-end against the matchmaking API's 2v2 queues (API PR #419): core team pinning, the server's second checkin loop and game creation, and the client UI. ## Core — deterministic team pinning The matcher's assignment specifies exactly who plays with whom (`teams: [[a,d],[b,c]]`), but team assignment previously only did clan/friend balancing and could scramble the ELO-balanced split. - `PlayerSchema`/`PlayerInfo` gain an optional **`teamIndex`** — a server-stamped index into the game's team list, part of `GameStartInfo` so it's identical on every client (same category as `clanTag`/`friends`, which already feed deterministic team assignment). - `assignTeams` honors pins **unconditionally** — before clan/friend grouping and past `maxTeamSize` (the matcher's balancing is authoritative) — and seeds the counts that balancing of any unpinned players sees. Pinned players still participate in the friend graph, so an unpinned friend is pulled toward a pinned player's team. - publicIds never enter core: the game server resolves publicId → teamIndex per client at game start. ## Server - **One checkin long-poll per mode.** Both loops send `mode` explicitly (the API deployed ahead of the client, so no omit-for-back-compat needed). - **`get2v2Config()`**: Team mode, `playerTeams: 2`, `maxPlayers: 4`, always-compact map, donations enabled (matching public team games), `rankedType: "2v2"` (the API's 2v2 ingestion has shipped; `RankedType` gains `TwoVTwo`). - **The assignment payload is now used** (it was previously discarded): `players` → `allowedPublicIds` so only the matched accounts can take the slots (also hardens 1v1), and `teams` → `teamIndex` stamps at game start. A malformed assignment logs a warning and falls back to creating the game without pins rather than stranding matched players. - The 3-clients-per-IP cap on public games applies to matchmade games too (an allowlist doesn't stop one person multi-tabbing multiple accounts). It is now skipped in dev, where local testing (multi-tab, the 4-player e2e) is inherently same-IP — matching the existing dev/prod gating of Turnstile and the duplicate-account kick. ## Client - Ranked modal's 2v2 card is enabled; it passes the mode through `open-matchmaking` (dispatchers without a detail — homepage button, requeue URL — still mean 1v1). - Matchmaking modal joins with `&mode=1v1`/`&mode=2v2`, shows a 2v2 title (`matchmaking_modal.title_2v2` in en.json), and shows the real 2v2 ELO from the new `leaderboard.twoVtwo` field in `/users/@me` (the ranked modal's 2v2 card does too). - WinModal shows requeue for any ranked game and carries the mode back into the right queue (`/?requeue=2v2`). - 2v2 ranked stats surface in the player stats tree (labeled via `player_stats_tree.ranked_2v2`). ## Harnesses (`tests/matchmaking/`) - Contained: the fake server captures the `mode` query param; asserts each queue sends its mode explicitly. **10/10.** - E2E: `MM_MODE=2v2` runs four real browser players through the real local worker's 2v2 queue and rides the flow into the started game. Asserts same gameId for all four, the 2v2 config, allowlist admission, and a **deterministic 2 vs 2 in-game split read from each client's GameView** (the software-WebGL gate is spoofed in test pages only). **8/8.** 1v1 e2e still **6/6.** ## Verification - `npm test`: 2,053 tests pass, including 7 new (6 `assignTeams` pinning unit tests + a full-game pinned-split test through `setup()`). - `npx tsc --noEmit`, ESLint clean. - Live e2e against a local `wrangler dev` API worker: 1v1 (6/6) and 2v2 (8/8) as above. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory (UI changes — the ranked modal's 1v1/2v2 cards — were verified with before/after screenshots in the live app during development.) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+22
-8
@@ -233,7 +233,7 @@ declare global {
|
||||
"kick-player": CustomEvent;
|
||||
toggle_game_start_timer: CustomEvent;
|
||||
"join-changed": CustomEvent;
|
||||
"open-matchmaking": CustomEvent<undefined>;
|
||||
"open-matchmaking": CustomEvent<{ mode?: "1v1" | "2v2" } | undefined>;
|
||||
userMeResponse: CustomEvent<UserMeResponse | false>;
|
||||
"leave-lobby": CustomEvent;
|
||||
"update-game-config": CustomEvent;
|
||||
@@ -854,16 +854,24 @@ class Client {
|
||||
window.location.href = "/";
|
||||
}
|
||||
|
||||
if (this.consumeRequeueUrl()) {
|
||||
document.dispatchEvent(new CustomEvent("open-matchmaking"));
|
||||
const requeueMode = this.consumeRequeueUrl();
|
||||
if (requeueMode !== null) {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("open-matchmaking", {
|
||||
detail: { mode: requeueMode },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private consumeRequeueUrl(): boolean {
|
||||
// Returns the requeue mode ("/?requeue" = 1v1, "/?requeue=2v2" = 2v2), or
|
||||
// null when the URL has no requeue param.
|
||||
private consumeRequeueUrl(): "1v1" | "2v2" | null {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
if (!searchParams.has("requeue")) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
const mode = searchParams.get("requeue") === "2v2" ? "2v2" : "1v1";
|
||||
|
||||
searchParams.delete("requeue");
|
||||
const newUrl =
|
||||
@@ -871,7 +879,7 @@ class Client {
|
||||
(searchParams.toString() ? `?${searchParams.toString()}` : "") +
|
||||
window.location.hash;
|
||||
history.replaceState(null, "", newUrl);
|
||||
return true;
|
||||
return mode;
|
||||
}
|
||||
|
||||
private async handleJoinLobby(event: CustomEvent<JoinLobbyEvent>) {
|
||||
@@ -1066,8 +1074,14 @@ class Client {
|
||||
crazyGamesSDK.gameplayStop();
|
||||
}
|
||||
|
||||
private handleOpenMatchmaking(_event: CustomEvent<undefined>) {
|
||||
this.matchmakingModal?.open();
|
||||
private handleOpenMatchmaking(
|
||||
event: CustomEvent<{ mode?: "1v1" | "2v2" } | undefined>,
|
||||
) {
|
||||
if (!this.matchmakingModal) return;
|
||||
// Always set the mode: dispatchers without a detail (homepage button,
|
||||
// requeue URL) mean 1v1 and must reset a lingering 2v2 selection.
|
||||
this.matchmakingModal.mode = event.detail?.mode === "2v2" ? "2v2" : "1v1";
|
||||
this.matchmakingModal.open();
|
||||
}
|
||||
|
||||
private handleKickPlayer(event: CustomEvent) {
|
||||
|
||||
@@ -18,6 +18,9 @@ export class MatchmakingModal extends BaseModal {
|
||||
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private intentionalClose = false;
|
||||
// Which queue to join; set by Main from the open-matchmaking event
|
||||
// before the modal opens.
|
||||
public mode: "1v1" | "2v2" = "1v1";
|
||||
@state() private connected = false;
|
||||
@state() private socket: WebSocket | null = null;
|
||||
@state() private gameID: string | null = null;
|
||||
@@ -34,7 +37,11 @@ export class MatchmakingModal extends BaseModal {
|
||||
|
||||
protected renderHeaderSlot() {
|
||||
return modalHeader({
|
||||
title: translateText("matchmaking_modal.title"),
|
||||
title: translateText(
|
||||
this.mode === "2v2"
|
||||
? "matchmaking_modal.title_2v2"
|
||||
: "matchmaking_modal.title",
|
||||
),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
});
|
||||
@@ -80,7 +87,7 @@ export class MatchmakingModal extends BaseModal {
|
||||
this.connectTimeout = null;
|
||||
}
|
||||
this.socket = new WebSocket(
|
||||
`${ClientEnv.jwtIssuer()}/matchmaking/join?instance_id=${encodeURIComponent(ClientEnv.instanceId())}`,
|
||||
`${ClientEnv.jwtIssuer()}/matchmaking/join?instance_id=${encodeURIComponent(ClientEnv.instanceId())}&mode=${this.mode}`,
|
||||
);
|
||||
this.socket.onopen = async () => {
|
||||
console.log("Connected to matchmaking server");
|
||||
@@ -184,9 +191,11 @@ export class MatchmakingModal extends BaseModal {
|
||||
return;
|
||||
}
|
||||
|
||||
this.elo =
|
||||
userMe.player.leaderboard?.oneVone?.elo ??
|
||||
translateText("matchmaking_modal.no_elo");
|
||||
const row =
|
||||
this.mode === "2v2"
|
||||
? userMe.player.leaderboard?.twoVtwo
|
||||
: userMe.player.leaderboard?.oneVone;
|
||||
this.elo = row?.elo ?? translateText("matchmaking_modal.no_elo");
|
||||
|
||||
this.connected = false;
|
||||
this.gameID = null;
|
||||
|
||||
@@ -13,6 +13,7 @@ export class RankedModal extends BaseModal {
|
||||
protected routerName = "ranked";
|
||||
|
||||
@state() private elo: number | string = "...";
|
||||
@state() private elo2v2: number | string = "...";
|
||||
@state() private userMeResponse: UserMeResponse | false = false;
|
||||
@state() private errorMessage: string | null = null;
|
||||
// CrazyGames players authenticate through the SDK, not a linked
|
||||
@@ -56,20 +57,23 @@ export class RankedModal extends BaseModal {
|
||||
private updateElo() {
|
||||
if (this.errorMessage) {
|
||||
this.elo = translateText("map_component.error");
|
||||
this.elo2v2 = translateText("map_component.error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isRankedEligible()) {
|
||||
this.elo =
|
||||
this.userMeResponse &&
|
||||
this.userMeResponse.player.leaderboard?.oneVone?.elo
|
||||
? this.userMeResponse.player.leaderboard.oneVone.elo
|
||||
: translateText("matchmaking_modal.no_elo");
|
||||
const leaderboard = this.userMeResponse
|
||||
? this.userMeResponse.player.leaderboard
|
||||
: undefined;
|
||||
const noElo = translateText("matchmaking_modal.no_elo");
|
||||
this.elo = leaderboard?.oneVone?.elo ?? noElo;
|
||||
this.elo2v2 = leaderboard?.twoVtwo?.elo ?? noElo;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async onOpen(): Promise<void> {
|
||||
this.elo = "...";
|
||||
this.elo2v2 = "...";
|
||||
this.errorMessage = null;
|
||||
|
||||
try {
|
||||
@@ -83,6 +87,7 @@ export class RankedModal extends BaseModal {
|
||||
this.userMeResponse = false;
|
||||
this.errorMessage = translateText("map_component.error");
|
||||
this.elo = translateText("map_component.error");
|
||||
this.elo2v2 = translateText("map_component.error");
|
||||
} finally {
|
||||
this.updateElo();
|
||||
}
|
||||
@@ -110,11 +115,15 @@ export class RankedModal extends BaseModal {
|
||||
(this.isRankedEligible()
|
||||
? translateText("matchmaking_modal.elo", { elo: this.elo })
|
||||
: translateText("mode_selector.ranked_title")),
|
||||
() => this.handleRanked(),
|
||||
() => this.handleRanked("1v1"),
|
||||
)}
|
||||
${this.renderDisabledCard(
|
||||
${this.renderCard(
|
||||
translateText("mode_selector.ranked_2v2_title"),
|
||||
translateText("mode_selector.coming_soon"),
|
||||
this.errorMessage ??
|
||||
(this.isRankedEligible()
|
||||
? translateText("matchmaking_modal.elo", { elo: this.elo2v2 })
|
||||
: translateText("mode_selector.ranked_title")),
|
||||
() => this.handleRanked("2v2"),
|
||||
)}
|
||||
${this.renderDisabledCard(
|
||||
translateText("mode_selector.coming_soon"),
|
||||
@@ -133,7 +142,7 @@ export class RankedModal extends BaseModal {
|
||||
return html`
|
||||
<button
|
||||
@click=${onClick}
|
||||
class="flex flex-col w-full h-28 sm:h-32 rounded-2xl bg-surface border-0 transition-transform hover:scale-[1.02] active:scale-[0.98] p-6 items-center justify-center gap-3"
|
||||
class="flex flex-col w-full h-28 sm:h-32 rounded-2xl bg-malibu-blue border-0 transition-all duration-200 hover:bg-aquarius hover:scale-[1.03] hover:shadow-[var(--shadow-action-card-hover)] active:bg-malibu-blue/80 active:scale-[0.98] p-6 items-center justify-center gap-3"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-1 text-center">
|
||||
<h3
|
||||
@@ -142,7 +151,7 @@ export class RankedModal extends BaseModal {
|
||||
${title}
|
||||
</h3>
|
||||
<p
|
||||
class="text-xs text-white/60 uppercase tracking-wider whitespace-pre-line leading-tight"
|
||||
class="text-xs text-white/80 uppercase tracking-wider whitespace-pre-line leading-tight"
|
||||
>
|
||||
${subtitle}
|
||||
</p>
|
||||
@@ -172,13 +181,15 @@ export class RankedModal extends BaseModal {
|
||||
`;
|
||||
}
|
||||
|
||||
private async handleRanked() {
|
||||
private async handleRanked(mode: "1v1" | "2v2") {
|
||||
if ((await userAuth()) === false) {
|
||||
this.close();
|
||||
window.showPage?.("page-account");
|
||||
return;
|
||||
}
|
||||
|
||||
document.dispatchEvent(new CustomEvent("open-matchmaking"));
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("open-matchmaking", { detail: { mode } }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ export class PlayerStatsTreeView extends LitElement {
|
||||
switch (r) {
|
||||
case RankedType.OneVOne:
|
||||
return translateText("player_stats_tree.ranked_1v1");
|
||||
case RankedType.TwoVTwo:
|
||||
return translateText("player_stats_tree.ranked_2v2");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@ export class WinModal extends LitElement implements Controller {
|
||||
await this.loadPatternContent();
|
||||
// Check if this is a ranked game
|
||||
this.isRankedGame =
|
||||
this.game.config().gameConfig().rankedType === RankedType.OneVOne;
|
||||
this.game.config().gameConfig().rankedType !== undefined;
|
||||
this.isVisible = true;
|
||||
this.requestUpdate();
|
||||
setTimeout(() => {
|
||||
@@ -250,8 +250,12 @@ export class WinModal extends LitElement implements Controller {
|
||||
|
||||
private _handleRequeue() {
|
||||
this.hide();
|
||||
// Navigate to homepage and open matchmaking modal
|
||||
window.location.href = "/?requeue";
|
||||
// Navigate to homepage and open matchmaking modal for the same mode
|
||||
const requeue =
|
||||
this.game.config().gameConfig().rankedType === RankedType.TwoVTwo
|
||||
? "/?requeue=2v2"
|
||||
: "/?requeue";
|
||||
window.location.href = requeue;
|
||||
}
|
||||
|
||||
init() {}
|
||||
|
||||
@@ -128,6 +128,11 @@ export const UserMeResponseSchema = z.object({
|
||||
elo: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
twoVtwo: z
|
||||
.object({
|
||||
elo: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
currency: CurrencyBalancesSchema.optional(),
|
||||
|
||||
@@ -55,6 +55,7 @@ export async function createGameRunner(
|
||||
p.isLobbyCreator ?? false,
|
||||
p.clanTag,
|
||||
p.friends ?? [],
|
||||
p.teamIndex ?? null,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -690,6 +690,10 @@ export const PlayerSchema = z.object({
|
||||
cosmetics: PlayerCosmeticsSchema.optional(),
|
||||
isLobbyCreator: z.boolean().optional(),
|
||||
friends: z.array(ID).optional(),
|
||||
// Server-stamped team slot for matchmade team games (index into the
|
||||
// game's team list). Feeds deterministic team assignment, so it must be
|
||||
// identical for every client (like clanTag/friends).
|
||||
teamIndex: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
export const GameStartInfoSchema = z.object({
|
||||
|
||||
@@ -125,6 +125,7 @@ export enum GameMode {
|
||||
|
||||
export enum RankedType {
|
||||
OneVOne = "1v1",
|
||||
TwoVTwo = "2v2",
|
||||
}
|
||||
|
||||
export const isGameMode = (value: unknown): value is GameMode =>
|
||||
@@ -419,6 +420,9 @@ export class PlayerInfo {
|
||||
public readonly isLobbyCreator: boolean = false,
|
||||
public readonly clanTag: string | null = null,
|
||||
public readonly friends: ClientID[] = [],
|
||||
// Server-pinned team slot (index into the game's team list) for
|
||||
// matchmade team games; null = assign normally.
|
||||
public readonly teamIndex: number | null = null,
|
||||
) {
|
||||
this.displayName = formatPlayerDisplayName(this.name, this.clanTag);
|
||||
}
|
||||
|
||||
@@ -11,12 +11,27 @@ export function assignTeams(
|
||||
const result = new Map<PlayerInfo, Team | "kicked">();
|
||||
const teamPlayerCount = new Map<Team, number>();
|
||||
|
||||
// Matchmade games arrive with a server-pinned team slot (teamIndex). The
|
||||
// matchmaker already balanced those teams, so pins are honored
|
||||
// unconditionally — before and regardless of clan/friend grouping and
|
||||
// maxTeamSize — and seed the counts the balancing below sees.
|
||||
const unpinned: PlayerInfo[] = [];
|
||||
for (const p of players) {
|
||||
const pinnedTeam = p.teamIndex === null ? undefined : teams[p.teamIndex];
|
||||
if (pinnedTeam === undefined) {
|
||||
unpinned.push(p);
|
||||
continue;
|
||||
}
|
||||
result.set(p, pinnedTeam);
|
||||
teamPlayerCount.set(pinnedTeam, (teamPlayerCount.get(pinnedTeam) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// Clans are strict: a clan goes to one team together, and any overflow
|
||||
// members get kicked. (You opted into the clan, so we honor "all or
|
||||
// nothing" for placement.)
|
||||
const clanGroups = new Map<string, PlayerInfo[]>();
|
||||
const nonClanPlayers: PlayerInfo[] = [];
|
||||
for (const p of players) {
|
||||
for (const p of unpinned) {
|
||||
if (p.clanTag) {
|
||||
if (!clanGroups.has(p.clanTag)) clanGroups.set(p.clanTag, []);
|
||||
clanGroups.get(p.clanTag)!.push(p);
|
||||
|
||||
@@ -67,6 +67,7 @@ export class GameManager {
|
||||
creatorPersistentID?: string,
|
||||
startsAt?: number,
|
||||
publicGameType?: PublicGameType,
|
||||
matchmakingTeams?: string[][],
|
||||
): GameServer | null {
|
||||
if (this.games.has(id)) {
|
||||
this.log.warn("cannot create game, id already exists", { gameID: id });
|
||||
@@ -98,6 +99,7 @@ export class GameManager {
|
||||
creatorPersistentID,
|
||||
startsAt,
|
||||
publicGameType,
|
||||
matchmakingTeams,
|
||||
);
|
||||
this.games.set(id, game);
|
||||
return game;
|
||||
|
||||
@@ -173,6 +173,9 @@ export class GameServer {
|
||||
private creatorPersistentID?: string,
|
||||
private startsAt?: number,
|
||||
private publicGameType?: PublicGameType,
|
||||
// Matchmade team split from the matchmaking assignment: publicIds per
|
||||
// team. At start each client is stamped with its team's index.
|
||||
private matchmakingTeams?: string[][],
|
||||
) {
|
||||
this.log = log_.child({ gameID: id });
|
||||
if (startsAt !== undefined) {
|
||||
@@ -537,7 +540,10 @@ export class GameServer {
|
||||
clientIP: ipAnonymize(client.ip),
|
||||
});
|
||||
|
||||
// Skipped in dev: local testing (multi-tab, the matchmaking e2e) is
|
||||
// inherently same-IP.
|
||||
if (
|
||||
ServerEnv.env() !== GameEnv.Dev &&
|
||||
this.gameConfig.gameType === GameType.Public &&
|
||||
this.activeClients.filter(
|
||||
(c) => c.ip === client.ip && c.clientID !== client.clientID,
|
||||
@@ -941,6 +947,7 @@ export class GameServer {
|
||||
cosmetics: c.cosmetics,
|
||||
isLobbyCreator: this.lobbyCreatorID === c.clientID,
|
||||
friends: friendsFor(c),
|
||||
teamIndex: this.matchmakingTeamIndex(c),
|
||||
})),
|
||||
});
|
||||
if (!result.success) {
|
||||
@@ -972,6 +979,20 @@ export class GameServer {
|
||||
});
|
||||
}
|
||||
|
||||
// Resolves a client to its matchmade team slot (index into
|
||||
// matchmakingTeams), or undefined when the game isn't matchmade / the
|
||||
// client isn't in the assignment.
|
||||
private matchmakingTeamIndex(c: Client): number | undefined {
|
||||
const publicId = c.publicId;
|
||||
if (this.matchmakingTeams === undefined || publicId === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const idx = this.matchmakingTeams.findIndex((team) =>
|
||||
team.includes(publicId),
|
||||
);
|
||||
return idx === -1 ? undefined : idx;
|
||||
}
|
||||
|
||||
private addIntent(intent: StampedIntent) {
|
||||
this.intents.push(intent);
|
||||
}
|
||||
|
||||
@@ -441,6 +441,37 @@ export class MapPlaylist {
|
||||
} satisfies GameConfig;
|
||||
}
|
||||
|
||||
public get2v2Config(): GameConfig {
|
||||
const maps = [
|
||||
GameMapType.Australia, // 40%
|
||||
GameMapType.Australia,
|
||||
GameMapType.Iceland, // 20%
|
||||
GameMapType.Asia, // 20%
|
||||
GameMapType.EuropeClassic, // 20%
|
||||
];
|
||||
return {
|
||||
donateGold: true,
|
||||
donateTroops: true,
|
||||
gameMap: maps[Math.floor(Math.random() * maps.length)],
|
||||
maxPlayers: 4,
|
||||
gameType: GameType.Public,
|
||||
gameMapSize: GameMapSize.Compact,
|
||||
difficulty: Difficulty.Medium, // Doesn't matter, nations are disabled
|
||||
rankedType: RankedType.TwoVTwo,
|
||||
infiniteGold: false,
|
||||
infiniteTroops: false,
|
||||
maxTimerValue: 10,
|
||||
instantBuild: false,
|
||||
randomSpawn: false,
|
||||
nations: "disabled",
|
||||
gameMode: GameMode.Team,
|
||||
playerTeams: 2,
|
||||
bots: 100,
|
||||
spawnImmunityDuration: 30 * 10,
|
||||
disabledUnits: [],
|
||||
} satisfies GameConfig;
|
||||
}
|
||||
|
||||
private getNextMap(type: ScheduledPublicGameType): GameMapType {
|
||||
const playlist = this.playlists[type];
|
||||
if (playlist.length === 0) {
|
||||
|
||||
+34
-4
@@ -700,6 +700,21 @@ export async function startWorker() {
|
||||
}
|
||||
|
||||
async function startMatchmakingPolling(gm: GameManager) {
|
||||
// One checkin serves exactly one queue, so a host serving both modes
|
||||
// runs one long-poll loop per mode.
|
||||
startMatchmakingLoop(gm, "1v1");
|
||||
startMatchmakingLoop(gm, "2v2");
|
||||
}
|
||||
|
||||
const MatchmakingAssignmentSchema = z.object({
|
||||
// Flat list of matched players' publicIds.
|
||||
players: z.array(z.string()),
|
||||
// The matcher's team split ([[a],[b]] for 1v1). Optional for tolerance,
|
||||
// but the current API always sends it.
|
||||
teams: z.array(z.array(z.string())).optional(),
|
||||
});
|
||||
|
||||
function startMatchmakingLoop(gm: GameManager, mode: "1v1" | "2v2") {
|
||||
startPolling(
|
||||
async () => {
|
||||
try {
|
||||
@@ -723,6 +738,7 @@ async function startMatchmakingPolling(gm: GameManager) {
|
||||
gameId: gameId,
|
||||
ccu: gm.activeClients(),
|
||||
instanceId: process.env.INSTANCE_ID,
|
||||
mode,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
@@ -731,20 +747,34 @@ async function startMatchmakingPolling(gm: GameManager) {
|
||||
|
||||
if (!response.ok) {
|
||||
log.warn(
|
||||
`Failed to poll lobby: ${response.status} ${response.statusText}`,
|
||||
`Failed to poll ${mode} lobby: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
log.info(`Lobby poll successful:`, data);
|
||||
log.info(`Lobby ${mode} poll successful:`, data);
|
||||
|
||||
if (data.assignment) {
|
||||
const parsed = MatchmakingAssignmentSchema.safeParse(data.assignment);
|
||||
if (!parsed.success) {
|
||||
// Don't strand the matched players: create the game without
|
||||
// the allowlist/team pins rather than dropping the match.
|
||||
log.warn(
|
||||
`Unexpected ${mode} assignment shape: ${z.prettifyError(parsed.error)}`,
|
||||
);
|
||||
}
|
||||
const baseConfig =
|
||||
mode === "2v2" ? playlist.get2v2Config() : playlist.get1v1Config();
|
||||
const game = gm.createGame(
|
||||
gameId,
|
||||
playlist.get1v1Config(),
|
||||
parsed.success
|
||||
? { ...baseConfig, allowedPublicIds: parsed.data.players }
|
||||
: baseConfig,
|
||||
undefined,
|
||||
Date.now() + 7000,
|
||||
undefined,
|
||||
parsed.success ? parsed.data.teams : undefined,
|
||||
);
|
||||
if (game === null) {
|
||||
log.warn(`Failed to create matchmaking game ${gameId}`);
|
||||
@@ -755,7 +785,7 @@ async function startMatchmakingPolling(gm: GameManager) {
|
||||
// Abort is expected if no game is scheduled on this worker.
|
||||
return;
|
||||
}
|
||||
log.error(`Error polling lobby:`, error);
|
||||
log.error(`Error polling ${mode} lobby:`, error);
|
||||
}
|
||||
},
|
||||
5000 + Math.random() * 1000,
|
||||
|
||||
Reference in New Issue
Block a user