Commit Graph
2 Commits
Author SHA1 Message Date
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
e76b34be22 Handle matchmaking socket close codes per API contract (#4595)
## What

Implements the close-code contract from the matchmaking API handoff (API
PR #419) in the matchmaking modal, plus two runnable integration
harnesses. Previously `onclose` only logged, so an API deploy while
queued left the player on the "searching" spinner forever (the queue is
in-memory on the API worker).

| Close | Behavior |
| --- | --- |
| 1008 `Invalid session` | Reconnect and re-send `join` —
`getPlayToken()` refreshes an expired token internally, so the rejoin
carries a fresh JWT |
| 1000 `Replaced by newer connection` | Another tab/window took the
queue slot: show a message (new `matchmaking_modal.replaced` string) and
stop. No retry |
| Any other close before assignment | Server restart/deploy: reconnect
and re-send `join`, with exponential backoff (1s doubling to a 15s cap)
|

Intentional closes (user backs out of the modal, assignment received)
don't reconnect. A pending 2s join timer from a previous socket is
cleared before each reconnect so it can't fire on the new socket.

## Test harnesses (`tests/matchmaking/`)

- **`npm run test:matchmaking`** (contained): drives the real modal in a
headless browser against a fake in-process matchmaking server speaking
the documented protocol; covers the whole close-code table over real
WebSockets. Needs only `npm run dev`.
- **`npm run test:matchmaking:e2e`**: real integration against the API
worker on `localhost:8787` — two browser players join the real queue,
the dev game server receives the checkin assignment, and both clients
end up in the same created game.

## Why now

This is required by the matchmaking API's client contract independent of
the upcoming 2v2 work ("clients must already handle unexpected close →
reconnect and rejoin"). The rest of the 2v2 integration is planned for
the next version.

## Verification

- Contained harness: 8/8 checks pass (join, 1006 reconnect+rejoin, 1008
rejoin with fresh token, assignment, replaced → message + no retry,
intentional close → no retry/no message).
- E2E harness against a local worker: 3/3 — both players matched into
the same game, game created via checkin and joinable by both.
- `npm test` (all 2,046 pass), `npx tsc --noEmit`, ESLint clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 10:55:15 -07:00