Files
OpenFrontIO/tests/matchmaking/contained.mjs
T
3a5fba2e12 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>
2026-07-13 15:22:52 -07:00

148 lines
5.1 KiB
JavaScript

// Contained matchmaking integration test: drives the real matchmaking modal
// in the real app against a fake matchmaking server (fakeServer.mjs) that
// speaks the documented protocol. Covers the close-code contract:
// - unexpected close (deploy) -> reconnect + rejoin
// - 1008 Invalid session -> reconnect + rejoin (fresh token)
// - 1000 Replaced by newer connection-> message shown, NO retry
// - intentional close (user backs out or assignment received) -> no retry
//
// Prerequisite: the dev app must be running (`npm run dev`, port 9000).
// Run: npm run test:matchmaking
import {
gotoHome,
launch,
} from "../../.claude/skills/run-openfront/driver.mjs";
import { startFakeMatchmakingServer } from "./fakeServer.mjs";
import { isUp, makeChecker, waitFor } from "./util.mjs";
if (!(await isUp("http://localhost:9000"))) {
console.error(
"Dev app is not running on :9000 — start it with `npm run dev`.",
);
process.exit(1);
}
const fake = await startFakeMatchmakingServer();
const control = async (path, body) => {
const res = await fetch(`${fake.controlUrl}/${path}`, {
method: "POST",
body: body ? JSON.stringify(body) : undefined,
});
return res.json();
};
const joins = async () => {
const res = await fetch(`${fake.controlUrl}/state`);
return (await res.json()).joins;
};
const joinCountReaches = (n, timeoutMs) =>
waitFor(async () => (await joins()).length >= n, {
timeoutMs,
label: `join #${n} to reach the fake server`,
});
const { browser, page } = await launch();
const c = makeChecker();
try {
await gotoHome(page);
// Redirect the modal's /matchmaking/join socket to the fake server while
// keeping real browser WebSocket (and close-code) semantics.
await page.evaluate((wsUrl) => {
const Real = window.WebSocket;
window.WebSocket = class extends Real {
constructor(url, protocols) {
const s = String(url);
if (s.includes("/matchmaking/join")) {
super(
`${wsUrl}/matchmaking/join?${s.split("?")[1] ?? ""}`,
protocols,
);
} else {
super(url, protocols);
}
}
};
window.__mmMessages = [];
window.addEventListener("show-message", (e) =>
window.__mmMessages.push(e.detail?.message),
);
}, fake.wsUrl);
const modal = (body) =>
page.evaluate(`(() => {
const el = document.querySelector("matchmaking-modal");
${body}
})()`);
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");
await joinCountReaches(2, 10000);
c.check("unexpected close -> reconnected and rejoined", true);
// 3. Invalid session: next join is closed 1008 -> client retries and rejoins
await control("reject-next");
await control("kill"); // forces the reconnect whose join gets 1008
await joinCountReaches(4, 20000); // join 3 rejected, join 4 accepted
c.check("1008 -> reconnected and rejoined with fresh token", true);
// 4. Assignment: modal records the gameId
await control("assign", { gameId: "FakeGame1" });
await waitFor(() => modal(`return el.gameID === "FakeGame1";`), {
timeoutMs: 5000,
label: "modal to receive match-assignment",
});
c.check("match-assignment received", true);
await modal(`el.onClose();`); // stop the game-exists polling
// 5. Replaced by newer connection: message shown, no retry
await resetAndConnect();
await joinCountReaches(5, 8000);
await control("replace");
await waitFor(() => page.evaluate(() => window.__mmMessages.length > 0), {
timeoutMs: 5000,
label: "replaced message",
});
const msg = await page.evaluate(() => window.__mmMessages.at(-1));
c.check(
`replaced -> message shown ("${msg}")`,
typeof msg === "string" && !msg.includes("matchmaking_modal."),
);
await new Promise((r) => setTimeout(r, 3500));
c.check("replaced -> no retry", (await joins()).length === 5);
// 6. Intentional close (user backs out): no retry, no message
await resetAndConnect();
await joinCountReaches(6, 8000);
const msgsBefore = await page.evaluate(() => window.__mmMessages.length);
await modal(`el.onClose();`);
await new Promise((r) => setTimeout(r, 3500));
c.check("intentional close -> no retry", (await joins()).length === 6);
c.check(
"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();
}
c.finish();