Files
OpenFrontIO/eslint.config.js
T
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

66 lines
1.7 KiB
JavaScript

import { includeIgnoreFile } from "@eslint/compat";
import pluginJs from "@eslint/js";
import eslintConfigPrettier from "eslint-config-prettier/flat";
import globals from "globals";
import path from "node:path";
import { fileURLToPath } from "node:url";
import tseslint from "typescript-eslint";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const gitignorePath = path.resolve(__dirname, ".gitignore");
/** @type {import('eslint').Linter.Config[]} */
export default [
includeIgnoreFile(gitignorePath),
{
ignores: [
"src/server/gatekeeper/**",
"tests/pathfinding/playground/**",
".claude/**",
],
},
{ files: ["**/*.{js,mjs,cjs,ts}"] },
{ languageOptions: { globals: { ...globals.browser, ...globals.node } } },
pluginJs.configs.recommended,
...tseslint.configs.recommended,
eslintConfigPrettier,
{
languageOptions: {
parserOptions: {
projectService: {
allowDefaultProject: [
"__mocks__/fileMock.js",
"eslint.config.js",
"scripts/sync-assets.mjs",
"tests/matchmaking/*.mjs",
],
},
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
// Disable rules that would fail. The failures should be fixed, and the entries here removed.
"@typescript-eslint/no-explicit-any": "off",
"no-unused-vars": "off",
},
},
{
rules: {
// Enable rules
"@typescript-eslint/prefer-nullish-coalescing": "error",
eqeqeq: "error",
"no-case-declarations": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{
args: "none",
caughtErrors: "none",
},
],
},
},
];