mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-22 10:56:51 +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:
@@ -2,6 +2,7 @@ import {
|
||||
ColoredTeams,
|
||||
Game,
|
||||
GameMode,
|
||||
PlayerInfo,
|
||||
PlayerType,
|
||||
} from "../src/core/game/Game";
|
||||
import { playerInfo, setup } from "./util/Setup";
|
||||
@@ -39,4 +40,33 @@ describe("Teams", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("humans with pinned teamIndex get the matcher's exact split", async () => {
|
||||
// Matchmade 2v2: the server stamps teamIndex per player; the split
|
||||
// [h1,h4] vs [h2,h3] must survive full game construction.
|
||||
const pinned = (name: string, teamIndex: number) =>
|
||||
new PlayerInfo(
|
||||
name,
|
||||
PlayerType.Human,
|
||||
name,
|
||||
name,
|
||||
false,
|
||||
null,
|
||||
[],
|
||||
teamIndex,
|
||||
);
|
||||
game = await setup(
|
||||
"plains",
|
||||
{
|
||||
gameMode: GameMode.Team,
|
||||
playerTeams: 2,
|
||||
},
|
||||
[pinned("h1", 0), pinned("h2", 1), pinned("h3", 1), pinned("h4", 0)],
|
||||
);
|
||||
|
||||
expect(game.player("h1").team()).toBe(ColoredTeams.Red);
|
||||
expect(game.player("h2").team()).toBe(ColoredTeams.Blue);
|
||||
expect(game.player("h3").team()).toBe(ColoredTeams.Blue);
|
||||
expect(game.player("h4").team()).toBe(ColoredTeams.Red);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -291,6 +291,115 @@ describe("assignTeams", () => {
|
||||
expect(result.get(players[3])).not.toEqual(result.get(players[0]));
|
||||
});
|
||||
|
||||
// Matchmade games: the server pins each player to a team slot via
|
||||
// PlayerInfo.teamIndex and the matcher's split must be honored verbatim.
|
||||
const createPinnedPlayer = (
|
||||
id: string,
|
||||
teamIndex: number | null,
|
||||
clan?: string,
|
||||
friends: string[] = [],
|
||||
): PlayerInfo => {
|
||||
return new PlayerInfo(
|
||||
`Player ${id}`,
|
||||
PlayerType.Human,
|
||||
id, // clientID
|
||||
id,
|
||||
false,
|
||||
clan,
|
||||
friends,
|
||||
teamIndex,
|
||||
);
|
||||
};
|
||||
|
||||
it("should honor pinned teamIndex exactly (matchmade 2v2)", () => {
|
||||
const players = [
|
||||
createPinnedPlayer("1", 0),
|
||||
createPinnedPlayer("2", 1),
|
||||
createPinnedPlayer("3", 1),
|
||||
createPinnedPlayer("4", 0),
|
||||
];
|
||||
|
||||
const result = assignTeams(players, teams);
|
||||
|
||||
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
|
||||
expect(result.get(players[1])).toEqual(ColoredTeams.Blue);
|
||||
expect(result.get(players[2])).toEqual(ColoredTeams.Blue);
|
||||
expect(result.get(players[3])).toEqual(ColoredTeams.Red);
|
||||
});
|
||||
|
||||
it("should let pins override clan grouping", () => {
|
||||
// Two clanmates matched onto opposite teams stay split: the matcher's
|
||||
// balancing is authoritative over the clan all-on-one-team rule.
|
||||
const players = [
|
||||
createPinnedPlayer("1", 0, "CLANA"),
|
||||
createPinnedPlayer("2", 1, "CLANA"),
|
||||
createPinnedPlayer("3", 1),
|
||||
createPinnedPlayer("4", 0),
|
||||
];
|
||||
|
||||
const result = assignTeams(players, teams);
|
||||
|
||||
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
|
||||
expect(result.get(players[1])).toEqual(ColoredTeams.Blue);
|
||||
});
|
||||
|
||||
it("should balance unpinned players around pinned ones", () => {
|
||||
// Red already holds two pinned players (at capacity for 4 players /
|
||||
// 2 teams), so the unpinned player must land on Blue.
|
||||
const players = [
|
||||
createPinnedPlayer("1", 0),
|
||||
createPinnedPlayer("2", 0),
|
||||
createPinnedPlayer("3", 1),
|
||||
createPinnedPlayer("4", null),
|
||||
];
|
||||
|
||||
const result = assignTeams(players, teams);
|
||||
|
||||
expect(result.get(players[3])).toEqual(ColoredTeams.Blue);
|
||||
});
|
||||
|
||||
it("should treat an out-of-range teamIndex as unpinned", () => {
|
||||
const players = [
|
||||
createPinnedPlayer("1", 7),
|
||||
createPinnedPlayer("2", null),
|
||||
createPinnedPlayer("3", null),
|
||||
createPinnedPlayer("4", null),
|
||||
];
|
||||
|
||||
const result = assignTeams(players, teams);
|
||||
|
||||
for (const p of players) {
|
||||
expect([ColoredTeams.Red, ColoredTeams.Blue]).toContain(result.get(p));
|
||||
}
|
||||
});
|
||||
|
||||
it("should pull an unpinned friend toward a pinned player's team", () => {
|
||||
const players = [
|
||||
createPinnedPlayer("1", 0),
|
||||
createPinnedPlayer("2", null, undefined, ["1"]),
|
||||
createPinnedPlayer("3", null),
|
||||
createPinnedPlayer("4", null),
|
||||
];
|
||||
|
||||
const result = assignTeams(players, teams);
|
||||
|
||||
expect(result.get(players[1])).toEqual(ColoredTeams.Red);
|
||||
});
|
||||
|
||||
it("should honor pins even past maxTeamSize (trust the matcher)", () => {
|
||||
const players = [
|
||||
createPinnedPlayer("1", 0),
|
||||
createPinnedPlayer("2", 0),
|
||||
createPinnedPlayer("3", 0),
|
||||
];
|
||||
|
||||
const result = assignTeams(players, teams, 1);
|
||||
|
||||
expect(result.get(players[0])).toEqual(ColoredTeams.Red);
|
||||
expect(result.get(players[1])).toEqual(ColoredTeams.Red);
|
||||
expect(result.get(players[2])).toEqual(ColoredTeams.Red);
|
||||
});
|
||||
|
||||
it("should still kick when every team is at capacity", () => {
|
||||
// 5 friends in a clique, 2 teams, maxTeamSize = ceil(5/2) = 3.
|
||||
// Total capacity is 6, so we have slack — nobody should get kicked.
|
||||
|
||||
@@ -27,8 +27,16 @@ Prerequisite: `npm run dev` (app on :9000). No API worker needed.
|
||||
Real integration against the **API worker on `localhost:8787`**. Two browser
|
||||
players join the real queue through the real modal; the dev game server's
|
||||
`/matchmaking/checkin` long-poll receives the assignment and creates the
|
||||
game; the test asserts both players get the same `gameId` and dispatch
|
||||
`join-lobby` once the game exists.
|
||||
game; the test asserts all players get the same `gameId`, dispatch
|
||||
`join-lobby` once the game exists, that the game carries the mode's config,
|
||||
and that the `allowedPublicIds` allowlist admits every matched player.
|
||||
|
||||
Modes:
|
||||
|
||||
- default: 1v1 with two players
|
||||
- `MM_MODE=2v2 npm run test:matchmaking:e2e`: four players into the 2v2
|
||||
queue; additionally rides the real flow into the started game and asserts
|
||||
the in-game team split is 2 vs 2 and identical on every client.
|
||||
|
||||
Prerequisites:
|
||||
|
||||
|
||||
@@ -74,16 +74,18 @@ try {
|
||||
const el = document.querySelector("matchmaking-modal");
|
||||
${body}
|
||||
})()`);
|
||||
const resetAndConnect = () =>
|
||||
const resetAndConnect = (mode = "1v1") =>
|
||||
modal(`el.gameID = null;
|
||||
el.intentionalClose = false;
|
||||
el.reconnectAttempts = 0;
|
||||
el.mode = ${JSON.stringify(mode)};
|
||||
el.connect();`);
|
||||
|
||||
// 1. Joining the queue: connect -> join arrives (after the modal's 2s delay)
|
||||
await resetAndConnect();
|
||||
await joinCountReaches(1, 8000);
|
||||
c.check("join sent after connect", true);
|
||||
c.check("1v1 join sends mode=1v1", (await joins())[0].mode === "1v1");
|
||||
|
||||
// 2. Deploy/restart: server drops the socket abruptly -> reconnect + rejoin
|
||||
await control("kill");
|
||||
@@ -132,6 +134,12 @@ try {
|
||||
"intentional close -> no message",
|
||||
(await page.evaluate(() => window.__mmMessages.length)) === msgsBefore,
|
||||
);
|
||||
|
||||
// 7. 2v2 queue: join carries mode=2v2
|
||||
await resetAndConnect("2v2");
|
||||
await joinCountReaches(7, 8000);
|
||||
c.check("2v2 join sends mode=2v2", (await joins())[6].mode === "2v2");
|
||||
await modal(`el.onClose();`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await fake.close();
|
||||
|
||||
+189
-21
@@ -3,12 +3,16 @@
|
||||
// - the dev app running on localhost:9000 (`npm run dev` — its game server
|
||||
// long-polls the worker's /matchmaking/checkin out of the box in dev)
|
||||
//
|
||||
// Drives two real browser players through the real matchmaking modal:
|
||||
// both join the queue on the worker, the matcher pairs them, the local game
|
||||
// server receives the checkin assignment and creates the game, and both
|
||||
// clients see the game exist and dispatch join-lobby.
|
||||
// Drives real browser players through the real matchmaking modal: all join
|
||||
// the queue on the worker, the matcher groups them, the local game server
|
||||
// receives the checkin assignment and creates the game, and every client
|
||||
// gets into it (which also proves the allowedPublicIds allowlist accepts
|
||||
// the matched players).
|
||||
//
|
||||
// Run: npm run test:matchmaking:e2e
|
||||
// Run: npm run test:matchmaking:e2e (1v1: two players)
|
||||
// MM_MODE=2v2 npm run test:matchmaking:e2e (2v2: four players,
|
||||
// also verifies the in-game team split is 2 vs 2 and identical on
|
||||
// every client)
|
||||
|
||||
import {
|
||||
gotoHome,
|
||||
@@ -16,6 +20,9 @@ import {
|
||||
} from "../../.claude/skills/run-openfront/driver.mjs";
|
||||
import { isUp, makeChecker, waitFor } from "./util.mjs";
|
||||
|
||||
const MODE = process.env.MM_MODE === "2v2" ? "2v2" : "1v1";
|
||||
const PLAYER_COUNT = MODE === "2v2" ? 4 : 2;
|
||||
|
||||
if (!(await isUp("http://localhost:9000"))) {
|
||||
console.error(
|
||||
"Dev app is not running on :9000 — start it with `npm run dev`.",
|
||||
@@ -29,13 +36,56 @@ if (!(await isUp("http://localhost:8787"))) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { browser, page: page1 } = await launch();
|
||||
const context2 = await browser.newContext({
|
||||
viewport: { width: 1400, height: 1000 },
|
||||
});
|
||||
const page2 = await context2.newPage();
|
||||
// Throttle rAF like the driver does for in-game work: software WebGL frames
|
||||
// are expensive and an unthrottled loop starves the sim/timers.
|
||||
const rafThrottle = (interval) => {
|
||||
let last = 0;
|
||||
window.requestAnimationFrame = (cb) => {
|
||||
const now = performance.now();
|
||||
const wait = Math.max(0, interval - (now - last));
|
||||
return setTimeout(() => {
|
||||
last = performance.now();
|
||||
cb(last);
|
||||
}, wait);
|
||||
};
|
||||
window.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||
};
|
||||
|
||||
// The app refuses software WebGL (initGL.ts gates on the renderer string and
|
||||
// failIfMajorPerformanceCaveat), but headless browsers only have SwiftShader.
|
||||
// We're verifying matchmaking/teams, not rendering, so let the context
|
||||
// through in the test pages.
|
||||
const glSpoof = () => {
|
||||
const origGetContext = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = function (type, attrs) {
|
||||
if (type === "webgl2" && attrs) {
|
||||
const rest = { ...attrs };
|
||||
delete rest.failIfMajorPerformanceCaveat;
|
||||
return origGetContext.call(this, type, rest);
|
||||
}
|
||||
return origGetContext.call(this, type, attrs);
|
||||
};
|
||||
const origGetParameter = WebGL2RenderingContext.prototype.getParameter;
|
||||
WebGL2RenderingContext.prototype.getParameter = function (p) {
|
||||
if (p === 0x9246 /* UNMASKED_RENDERER_WEBGL */) {
|
||||
return "Harness Spoofed GPU";
|
||||
}
|
||||
return origGetParameter.call(this, p);
|
||||
};
|
||||
};
|
||||
|
||||
const { browser } = await launch();
|
||||
const pages = [];
|
||||
for (let i = 0; i < PLAYER_COUNT; i++) {
|
||||
// One context per player: separate localStorage = distinct players.
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1400, height: 1000 },
|
||||
});
|
||||
await context.addInitScript(rafThrottle, 2000);
|
||||
await context.addInitScript(glSpoof);
|
||||
pages.push(await context.newPage());
|
||||
}
|
||||
|
||||
const pages = [page1, page2];
|
||||
const consoles = pages.map(() => []);
|
||||
pages.forEach((p, i) => p.on("console", (msg) => consoles[i].push(msg.text())));
|
||||
|
||||
@@ -51,20 +101,34 @@ const dumpConsoles = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// The dev game server runs NUM_WORKERS=2; the worker path is derived from
|
||||
// the gameId, so just probe both.
|
||||
const fetchGameInfo = async (gameId) => {
|
||||
for (const worker of ["w0", "w1"]) {
|
||||
const res = await fetch(
|
||||
`http://localhost:9000/${worker}/api/game/${gameId}`,
|
||||
);
|
||||
if (res.ok) return res.json();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const c = makeChecker();
|
||||
try {
|
||||
// Two contexts = separate localStorage = two distinct players.
|
||||
for (const p of pages) {
|
||||
await gotoHome(p);
|
||||
await p.evaluate(() => {
|
||||
await p.evaluate((mode) => {
|
||||
window.__joinLobby = null;
|
||||
document.addEventListener(
|
||||
"join-lobby",
|
||||
(e) => (window.__joinLobby = e.detail ?? {}),
|
||||
);
|
||||
document.querySelector("matchmaking-modal").connect();
|
||||
});
|
||||
const el = document.querySelector("matchmaking-modal");
|
||||
el.mode = mode;
|
||||
el.connect();
|
||||
}, MODE);
|
||||
}
|
||||
console.log(`${PLAYER_COUNT} players queued (${MODE})...`);
|
||||
|
||||
const gameIdOf = (p) =>
|
||||
p.evaluate(() => document.querySelector("matchmaking-modal").gameID);
|
||||
@@ -79,15 +143,22 @@ try {
|
||||
{
|
||||
timeoutMs: 90000,
|
||||
intervalMs: 1000,
|
||||
label: "both players to receive a match-assignment",
|
||||
label: "every player to receive a match-assignment",
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
dumpConsoles();
|
||||
throw err;
|
||||
}
|
||||
c.check(`both players received an assignment (${gameIds[0]})`, true);
|
||||
c.check("both players got the same gameId", gameIds[0] === gameIds[1]);
|
||||
const gameId = gameIds[0];
|
||||
c.check(
|
||||
`all ${PLAYER_COUNT} players received an assignment (${gameId})`,
|
||||
true,
|
||||
);
|
||||
c.check(
|
||||
"all players got the same gameId",
|
||||
gameIds.every((id) => id === gameId),
|
||||
);
|
||||
|
||||
// The modal polls the game server until the assigned game exists, then
|
||||
// dispatches join-lobby — this proves the checkin/creation side worked.
|
||||
@@ -97,19 +168,116 @@ try {
|
||||
const details = await Promise.all(
|
||||
pages.map((p) => p.evaluate(() => window.__joinLobby)),
|
||||
);
|
||||
return details.every((d) => d?.gameID === gameIds[0]);
|
||||
return details.every((d) => d?.gameID === gameId);
|
||||
},
|
||||
{
|
||||
timeoutMs: 45000,
|
||||
intervalMs: 1000,
|
||||
label: "both players to dispatch join-lobby for the created game",
|
||||
label: "every player to dispatch join-lobby for the created game",
|
||||
},
|
||||
);
|
||||
c.check("game created on the game server and joinable by both", true);
|
||||
c.check("game created on the game server", true);
|
||||
} catch (err) {
|
||||
dumpConsoles();
|
||||
throw err;
|
||||
}
|
||||
|
||||
// The game must carry the mode's config and admit exactly the matched
|
||||
// players (allowedPublicIds).
|
||||
const info = await fetchGameInfo(gameId);
|
||||
if (MODE === "2v2") {
|
||||
c.check(
|
||||
"2v2 game config: Team mode, 2 teams, 4 max players, ranked 2v2",
|
||||
info?.gameConfig?.gameMode === "Team" &&
|
||||
info?.gameConfig?.playerTeams === 2 &&
|
||||
info?.gameConfig?.maxPlayers === 4 &&
|
||||
info?.gameConfig?.rankedType === "2v2",
|
||||
);
|
||||
} else {
|
||||
c.check(
|
||||
"1v1 game config: FFA, 2 max players",
|
||||
info?.gameConfig?.gameMode === "Free For All" &&
|
||||
info?.gameConfig?.maxPlayers === 2,
|
||||
);
|
||||
}
|
||||
c.check(
|
||||
`assignment allowlist has ${PLAYER_COUNT} publicIds`,
|
||||
info?.gameConfig?.allowedPublicIds?.length === PLAYER_COUNT,
|
||||
);
|
||||
|
||||
try {
|
||||
await waitFor(
|
||||
async () => {
|
||||
const now = await fetchGameInfo(gameId);
|
||||
return (now?.clients?.length ?? 0) === PLAYER_COUNT;
|
||||
},
|
||||
{
|
||||
timeoutMs: 60000,
|
||||
intervalMs: 1000,
|
||||
label: "all matched players to pass the allowlist and join the game",
|
||||
},
|
||||
);
|
||||
c.check("all matched players admitted past the allowlist", true);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"game info at failure:",
|
||||
JSON.stringify(await fetchGameInfo(gameId)),
|
||||
);
|
||||
for (const p of pages) {
|
||||
console.error("page:", p.url());
|
||||
}
|
||||
dumpConsoles();
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (MODE === "2v2") {
|
||||
// Ride the real flow into the game and read the ground-truth team split
|
||||
// from each client's GameView (see run-openfront skill notes).
|
||||
const splitOf = (p) =>
|
||||
p.evaluate(() => {
|
||||
const g = document.querySelector("build-menu")?.game;
|
||||
if (!g) return null;
|
||||
try {
|
||||
const humans = g.players().filter((pl) => pl.type() === "HUMAN");
|
||||
if (humans.length < 4) return null;
|
||||
return humans.map((pl) => `${pl.clientID()}:${pl.team()}`).sort();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
let splits;
|
||||
try {
|
||||
splits = await waitFor(
|
||||
async () => {
|
||||
const all = await Promise.all(pages.map(splitOf));
|
||||
return all.every(Boolean) ? all : null;
|
||||
},
|
||||
{
|
||||
timeoutMs: 120000,
|
||||
intervalMs: 2000,
|
||||
label: "every client to reach the started game with 4 humans",
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
dumpConsoles();
|
||||
throw err;
|
||||
}
|
||||
|
||||
const teamCounts = {};
|
||||
for (const entry of splits[0]) {
|
||||
const team = entry.split(":")[1];
|
||||
teamCounts[team] = (teamCounts[team] ?? 0) + 1;
|
||||
}
|
||||
c.check(
|
||||
`in-game team split is 2 vs 2 (${JSON.stringify(teamCounts)})`,
|
||||
Object.values(teamCounts).sort().join(",") === "2,2",
|
||||
);
|
||||
c.check(
|
||||
"team split is identical on every client (deterministic)",
|
||||
splits.every((s) => JSON.stringify(s) === JSON.stringify(splits[0])),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
@@ -57,12 +57,21 @@ export async function startFakeMatchmakingServer() {
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
server,
|
||||
// Real worker rejects a missing instance_id with HTTP 400 pre-upgrade.
|
||||
verifyClient: ({ req }) =>
|
||||
new URL(req.url, "http://localhost").searchParams.has("instance_id"),
|
||||
// Real worker rejects a missing instance_id or an unknown mode with
|
||||
// HTTP 400 pre-upgrade. mode is optional; omitted means 1v1.
|
||||
verifyClient: ({ req }) => {
|
||||
const params = new URL(req.url, "http://localhost").searchParams;
|
||||
const mode = params.get("mode");
|
||||
return (
|
||||
params.has("instance_id") &&
|
||||
(mode === null || mode === "1v1" || mode === "2v2")
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
wss.on("connection", (ws) => {
|
||||
wss.on("connection", (ws, req) => {
|
||||
// Raw mode param (null when omitted) so tests can assert 1v1 omits it.
|
||||
const mode = new URL(req.url, "http://localhost").searchParams.get("mode");
|
||||
ws.on("message", (raw) => {
|
||||
let msg;
|
||||
try {
|
||||
@@ -71,7 +80,7 @@ export async function startFakeMatchmakingServer() {
|
||||
return;
|
||||
}
|
||||
if (msg.type !== "join") return;
|
||||
state.joins.push({ jwt: msg.jwt, at: Date.now() });
|
||||
state.joins.push({ jwt: msg.jwt, mode, at: Date.now() });
|
||||
if (state.rejectNextJoin) {
|
||||
state.rejectNextJoin = false;
|
||||
ws.close(1008, "Invalid session");
|
||||
|
||||
Reference in New Issue
Block a user