// test/lobby.test.js — platform tests: static HTTP server, WebSocket session layer, lobby, match wiring, // reconnect, data loader. Boots real servers in-process on random ports (startServer({ port: 0 })). import { describe, test, before, after, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import http from 'node:http'; import zlib from 'node:zlib'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { randomBytes } from 'node:crypto'; import { setTimeout as delay } from 'node:timers/promises'; import { startServer, parseRange, acceptsGzip, pickEncoding, parseTrustProxy } from '../server/index.js'; import { loadData, lookup, getChess, getBond, getBand, getMode, getConfig, INDEXED_FILES } from '../server/data.js'; import * as dataModule from '../server/data.js'; import { CODE_ALPHABET, BOT_NAMES } from '../server/lobby.js'; import { sanitizeName, TokenBucket, SessionRegistry, clientAddress, normalizeIp, isLocalIp, limitKeyOf } from '../server/net.js'; import { StubMatch as Match } from '../server/match/StubMatch.js'; import { Match as RealMatch } from '../server/match/Match.js'; import { TestClient } from './helpers/wsClient.js'; import { ERR, MAX_SEATS, MAX_SPECTATORS, PHASE, EMOTES } from '../shared/constants.js'; const CODE_RE = new RegExp(`^[${CODE_ALPHABET}]{4}$`); const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); // --------------------------------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------------------------------- /** Raw HTTP request (the path is sent verbatim, no normalization). */ function httpReq(port, rawPath, { method = 'GET', headers = {} } = {}) { return new Promise((resolve, reject) => { const req = http.request({ host: '127.0.0.1', port, path: rawPath, method, headers, agent: false }, (res) => { const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) })); res.on('error', reject); }); req.on('error', reject); req.end(); }); } /** Tracks clients per test so they are always closed. */ function clientPool(getUrl) { const open = new Set(); return { async connect(opts) { const c = await TestClient.connect(getUrl(), opts); open.add(c); return c; }, /** Connect + hello; the client gets `.id` and `.token`. */ async player(name, token) { const c = await this.connect(); const w = await c.hello(name, token); c.id = w.playerId; c.token = w.token; c.welcome = w; return c; }, async closeAll() { await Promise.all([...open].map((c) => c.terminate().catch(() => {}))); open.clear(); }, }; } async function createRoom(c, mode = 'coop', difficulty = 'NORMAL') { const r = await c.request({ t: 'room.create', mode, difficulty }); assert.equal(r.t, 'ok', JSON.stringify(r)); return c.waitFor('room.state', (s) => s.hostId === c.id && s.mode === mode); } async function joinRoom(c, code) { const r = await c.request({ t: 'room.join', code }); assert.equal(r.t, 'ok', JSON.stringify(r)); return c.waitFor('room.state', (s) => s.code === code.toUpperCase() && s.seats.some((x) => x && x.playerId === c.id)); } const seatOf = (state, id) => state.seats.find((s) => s && s.playerId === id) || null; const expectError = async (c, msg, code) => { const r = await c.request(msg); assert.equal(r.t, 'error', `expected error ${code} for ${msg.t}, got ${JSON.stringify(r)}`); assert.equal(r.code, code, `${msg.t}: ${JSON.stringify(r)}`); assert.equal(typeof r.msg, 'string'); return r; }; const expectOk = async (c, msg) => { const r = await c.request(msg); assert.equal(r.t, 'ok', `${msg.t}: ${JSON.stringify(r)}`); return r; }; /** Collects log output; errors are asserted empty unless a test expects them. */ function captureLog() { const errors = []; return { errors, log: { info() {}, warn() {}, debug() {}, error: (...a) => errors.push(a.map(String).join(' ')) } }; } // --------------------------------------------------------------------------------------------------- // Static HTTP // --------------------------------------------------------------------------------------------------- describe('static http server', () => { let srv; let tmp; const js = `// app\n${'export const x = 1;\n'.repeat(200)}`; const mp3 = randomBytes(5000); const skel = Buffer.from('skel'.repeat(400)); // over the 8 MB compression cache limit: compressed as a stream const big = `// big\n${Array.from({ length: 360000 }, (_, i) => `export const v${i} = ${i % 97};`).join('\n')}\n`; before(async () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-static-')); const w = (rel, content) => { const p = path.join(tmp, rel); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, content); }; w('secret.txt', 'TOP-SECRET-CONTENT'); w('public/index.html', 't

hello

'); w('public/js/app.js', js); w('public/js/tiny.mjs', 'export default 1;'); w('public/css/a.css', 'body{color:red}'.repeat(60)); w('public/assets/audio/bgm.mp3', mp3); w('public/assets/audio/bgm.ogg', randomBytes(64)); w('public/assets/spine/x.skel', skel); w('public/assets/spine/x.atlas', 'x.png\nsize: 64,64\n'.repeat(40)); w('public/assets/img/a.png', randomBytes(32)); w('public/js/huge.js', big); w('public/assets/img/a.webp', randomBytes(32)); w('public/fonts/f.woff2', randomBytes(32)); w('public/fonts/f.ttf', randomBytes(32)); w('public/fonts/f.otf', randomBytes(32)); w('public/.hidden', 'hidden'); w('public/sub/index.html', '

sub

'); w('data/config.json', JSON.stringify({ hello: 'world', nested: { a: [1, 2] } })); w('shared/x.js', 'export const y = 2;'); srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true, publicDir: path.join(tmp, 'public'), dataDir: path.join(tmp, 'data'), sharedDir: path.join(tmp, 'shared'), assetIndex: { delayMs: 0 }, }); // the content hashes of public/assets + public/fonts (the ETags of those files): ready before the first request await srv.assetIndex.whenReady(); }); after(async () => { await srv?.close(); fs.rmSync(tmp, { recursive: true, force: true }); }); test('serves index.html at / with no-cache', async () => { const r = await httpReq(srv.port, '/'); assert.equal(r.status, 200); assert.match(r.headers['content-type'], /^text\/html/); assert.equal(r.headers['cache-control'], 'no-cache'); assert.match(r.body.toString(), /hello/); assert.equal(r.headers['x-content-type-options'], 'nosniff'); const sub = await httpReq(srv.port, '/sub/'); assert.equal(sub.status, 200); assert.match(sub.body.toString(), /sub/); const redirect = await httpReq(srv.port, '/sub?x=1'); assert.equal(redirect.status, 301); assert.equal(redirect.headers.location, '/sub/?x=1'); }); test('javascript: correct MIME, brotli when named, gzip when accepted, identity otherwise', async () => { const br = await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip, deflate, br' } }); assert.equal(br.status, 200); assert.equal(br.headers['content-type'], 'text/javascript; charset=utf-8'); assert.equal(br.headers['content-encoding'], 'br', 'a browser on an HTTPS page (or localhost) names br'); assert.equal(br.headers.vary, 'Accept-Encoding'); assert.equal(Number(br.headers['content-length']), br.body.length); assert.equal(zlib.brotliDecompressSync(br.body).toString(), js); const gz = await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip, deflate' } }); assert.equal(gz.headers['content-encoding'], 'gzip', 'a LAN http:// page: gzip'); assert.equal(zlib.gunzipSync(gz.body).toString(), js); // cached second responses are identical assert.deepEqual((await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip' } })).body, gz.body); assert.deepEqual((await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'br' } })).body, br.body); const plain = await httpReq(srv.port, '/js/app.js'); assert.equal(plain.headers['content-encoding'], undefined); assert.equal(plain.body.toString(), js); const refused = await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip;q=0, identity' } }); assert.equal(refused.headers['content-encoding'], undefined); const mjs = await httpReq(srv.port, '/js/tiny.mjs'); assert.equal(mjs.headers['content-type'], 'text/javascript; charset=utf-8'); }); test('MIME types for every asset kind; .skel/.atlas gzip, media not', async () => { const cases = [ ['/css/a.css', 'text/css; charset=utf-8', true], ['/data/config.json', 'application/json; charset=utf-8', false], ['/assets/img/a.png', 'image/png', false], ['/assets/img/a.webp', 'image/webp', false], ['/assets/audio/bgm.mp3', 'audio/mpeg', false], ['/assets/audio/bgm.ogg', 'audio/ogg', false], ['/fonts/f.woff2', 'font/woff2', false], ['/fonts/f.ttf', 'font/ttf', false], ['/fonts/f.otf', 'font/otf', false], ['/assets/spine/x.skel', 'application/octet-stream', true], ['/assets/spine/x.atlas', 'text/plain; charset=utf-8', true], ['/shared/x.js', 'text/javascript; charset=utf-8', false], ]; for (const [p, type, gzipped] of cases) { const r = await httpReq(srv.port, p, { headers: { 'accept-encoding': 'gzip' } }); assert.equal(r.status, 200, p); assert.equal(r.headers['content-type'], type, p); assert.equal(r.headers['content-encoding'] === 'gzip', gzipped, `${p} gzip=${r.headers['content-encoding']}`); } const skelGz = await httpReq(srv.port, '/assets/spine/x.skel', { headers: { 'accept-encoding': 'gzip' } }); assert.deepEqual(zlib.gunzipSync(skelGz.body), skel); const skelBr = await httpReq(srv.port, '/assets/spine/x.skel', { headers: { 'accept-encoding': 'gzip, br' } }); assert.equal(skelBr.headers['content-encoding'], 'br'); assert.deepEqual(zlib.brotliDecompressSync(skelBr.body), skel); // media is never compressed, whatever the client names assert.equal((await httpReq(srv.port, '/assets/audio/bgm.mp3', { headers: { 'accept-encoding': 'br, gzip' } })).headers['content-encoding'], undefined); assert.equal((await httpReq(srv.port, '/assets/img/a.png', { headers: { 'accept-encoding': 'br' } })).headers['content-encoding'], undefined); }); test('a file over the compression cache limit is compressed as a stream (brotli and gzip); HEAD sends no body', async () => { const br = await httpReq(srv.port, '/js/huge.js', { headers: { 'accept-encoding': 'br' } }); assert.equal(br.headers['content-encoding'], 'br'); assert.equal(br.headers['content-length'], undefined, 'streamed: no length up front'); assert.equal(zlib.brotliDecompressSync(br.body).toString(), big); assert.ok(br.body.length < big.length / 4); const gz = await httpReq(srv.port, '/js/huge.js', { headers: { 'accept-encoding': 'gzip' } }); assert.equal(gz.headers['content-encoding'], 'gzip'); assert.equal(zlib.gunzipSync(gz.body).toString(), big); const head = await httpReq(srv.port, '/js/huge.js', { method: 'HEAD', headers: { 'accept-encoding': 'br' } }); assert.equal(head.status, 200); assert.equal(head.body.length, 0); assert.equal(head.headers['content-encoding'], 'br'); }); test('ETag / Last-Modified revalidation and Cache-Control policy', async () => { const first = await httpReq(srv.port, '/js/app.js'); const { etag } = first.headers; assert.match(etag, /^"[0-9a-f]+-[0-9a-f]+"$/); assert.ok(first.headers['last-modified']); assert.equal(first.headers['cache-control'], 'no-cache'); const revalidated = await httpReq(srv.port, '/js/app.js', { headers: { 'if-none-match': etag } }); assert.equal(revalidated.status, 304); assert.equal(revalidated.body.length, 0); const weak = await httpReq(srv.port, '/js/app.js', { headers: { 'if-none-match': `"nope", W/${etag}` } }); assert.equal(weak.status, 304); const ims = await httpReq(srv.port, '/js/app.js', { headers: { 'if-modified-since': first.headers['last-modified'] } }); assert.equal(ims.status, 304); const stale = await httpReq(srv.port, '/js/app.js', { headers: { 'if-none-match': '"other"' } }); assert.equal(stale.status, 200); const gzEtag = (await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip' } })).headers.etag; assert.notEqual(gzEtag, etag, 'gzip variant has its own ETag'); const brEtag = (await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'br' } })).headers.etag; assert.equal(brEtag, etag.replace(/"$/, '-br"'), 'so has the brotli variant'); assert.equal((await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'br', 'if-none-match': brEtag } })).status, 304); assert.equal((await httpReq(srv.port, '/js/app.js', { headers: { 'accept-encoding': 'gzip', 'if-none-match': brEtag } })).status, 200, 'not the gzip variant'); // unversioned art/fonts/vendor: cached for a day (files can be rewritten in place by the asset tools); the art and // fonts carry their content hash as ETag (server/http/assetIndex.js), so a rewrite with the same bytes is a 304 const asset = await httpReq(srv.port, '/assets/img/a.png'); assert.equal(asset.headers['cache-control'], 'public, max-age=86400'); assert.match(asset.headers.etag, /^"[0-9a-f]{16}"$/); const font = await httpReq(srv.port, '/fonts/f.woff2'); assert.equal(font.headers['cache-control'], 'public, max-age=86400'); const assetV = await httpReq(srv.port, '/assets/img/a.png?v=abc'); assert.match(assetV.headers['cache-control'], /immutable/); const assetRoot = await httpReq(srv.port, '/assets'); assert.equal(assetRoot.status, 301, 'directory redirect, not cached content'); const versioned = await httpReq(srv.port, '/js/app.js?v=123'); assert.match(versioned.headers['cache-control'], /immutable/); const html = await httpReq(srv.port, '/index.html?v=1'); assert.equal(html.headers['cache-control'], 'no-cache'); }); test('byte ranges for audio (206 / 416 / If-Range)', async () => { const full = await httpReq(srv.port, '/assets/audio/bgm.mp3'); assert.equal(full.headers['accept-ranges'], 'bytes'); assert.deepEqual(full.body, mp3); const head = await httpReq(srv.port, '/assets/audio/bgm.mp3', { headers: { range: 'bytes=0-9' } }); assert.equal(head.status, 206); assert.equal(head.headers['content-range'], `bytes 0-9/${mp3.length}`); assert.equal(Number(head.headers['content-length']), 10); assert.deepEqual(head.body, mp3.subarray(0, 10)); const open = await httpReq(srv.port, '/assets/audio/bgm.mp3', { headers: { range: 'bytes=4990-' } }); assert.equal(open.status, 206); assert.deepEqual(open.body, mp3.subarray(4990)); const suffix = await httpReq(srv.port, '/assets/audio/bgm.mp3', { headers: { range: 'bytes=-100' } }); assert.equal(suffix.status, 206); assert.deepEqual(suffix.body, mp3.subarray(mp3.length - 100)); const clamp = await httpReq(srv.port, '/assets/audio/bgm.mp3', { headers: { range: 'bytes=4000-999999' } }); assert.equal(clamp.headers['content-range'], `bytes 4000-4999/${mp3.length}`); const bad = await httpReq(srv.port, '/assets/audio/bgm.mp3', { headers: { range: 'bytes=9000-9100' } }); assert.equal(bad.status, 416); assert.equal(bad.headers['content-range'], `bytes */${mp3.length}`); const multi = await httpReq(srv.port, '/assets/audio/bgm.mp3', { headers: { range: 'bytes=0-1,5-6' } }); assert.equal(multi.status, 200, 'multi-range falls back to the full body'); const ifRangeOk = await httpReq(srv.port, '/assets/audio/bgm.mp3', { headers: { range: 'bytes=0-1', 'if-range': full.headers.etag } }); assert.equal(ifRangeOk.status, 206); const ifRangeStale = await httpReq(srv.port, '/assets/audio/bgm.mp3', { headers: { range: 'bytes=0-1', 'if-range': '"stale"' } }); assert.equal(ifRangeStale.status, 200); const textRange = await httpReq(srv.port, '/js/app.js', { headers: { range: 'bytes=0-1', 'accept-encoding': 'gzip' } }); assert.equal(textRange.status, 206, 'ranges are served identity-encoded'); assert.equal(textRange.headers['content-encoding'], undefined); assert.equal(textRange.body.toString(), js.slice(0, 2)); }); test('extension-less /media audio route (download managers sniff .mp3 URLs)', async () => { const direct = await httpReq(srv.port, '/assets/audio/bgm.mp3'); const media = await httpReq(srv.port, '/media/bgm'); assert.equal(media.status, 200); assert.equal(media.headers['content-type'], 'audio/mpeg', 'resolved from the real .mp3 on disk'); assert.equal(media.headers['accept-ranges'], 'bytes'); assert.deepEqual(media.body, mp3, 'same bytes as the direct URL'); assert.equal(media.headers['cache-control'], direct.headers['cache-control'], 'same 1-day policy as /assets/…'); assert.equal(media.headers.etag, direct.headers.etag, 'ETag is the file validator, not the URL'); const range = await httpReq(srv.port, '/media/bgm', { headers: { range: 'bytes=0-9' } }); assert.equal(range.status, 206); assert.equal(range.headers['content-range'], `bytes 0-9/${mp3.length}`); assert.deepEqual(range.body, mp3.subarray(0, 10)); const head = await httpReq(srv.port, '/media/bgm', { method: 'HEAD' }); assert.equal(head.status, 200); assert.equal(head.body.length, 0); // The extension may still be given, and the other audio extension resolves too. assert.equal((await httpReq(srv.port, '/media/bgm.mp3')).status, 200); const ogg = await httpReq(srv.port, '/media/bgm.ogg'); assert.equal(ogg.status, 200); assert.equal(ogg.headers['content-type'], 'audio/ogg'); assert.equal(ogg.body.length, 64); // …and it only ever reaches public/assets/audio. for (const p of ['/media/nope', '/media/bgm/', '/media/', '/media/.hidden', '/media/bgm.mp3/nope', '/media/js/app']) { const r = await httpReq(srv.port, p); assert.equal(r.status, 404, `${p} → 404`); assert.doesNotMatch(r.body.toString(), /TOP-SECRET-CONTENT/, `${p} must not leak files outside public/assets/audio`); } for (const p of ['/media/..%2f..%2fsecret.txt', '/media/bgm/../..%2f..%2fsecret.txt']) { const r = await httpReq(srv.port, p); assert.equal(r.status, 403, `${p} → 403 (same as the rest of the server)`); assert.doesNotMatch(r.body.toString(), /TOP-SECRET-CONTENT/); } }); test('path traversal and dotfiles are blocked', async () => { const attempts = [ '/../secret.txt', '/%2e%2e/secret.txt', '/..%2fsecret.txt', '/%2e%2e%2fsecret.txt', '/data/../../secret.txt', '/shared/..%2f..%2fsecret.txt', '/%2e%2e%5csecret.txt', '/assets/../../secret.txt', '/..', '/./../secret.txt', '/%252e%252e/secret.txt', '/.hidden', '/%2ehidden', '/data/%00config.json', '/%E0%A4%A', '//secret.txt', ]; for (const p of attempts) { const r = await httpReq(srv.port, p); assert.ok([400, 403, 404].includes(r.status), `${p} → ${r.status}`); assert.ok(!r.body.toString().includes('TOP-SECRET'), `${p} leaked`); assert.ok(!r.body.toString().includes('hidden { const nf = await httpReq(srv.port, '/nope.js'); assert.equal(nf.status, 404); assert.match(nf.headers['content-type'], /^text\/html/); assert.match(nf.body.toString(), /404/); const nfData = await httpReq(srv.port, '/data/missing.json'); assert.equal(nfData.status, 404); const head = await httpReq(srv.port, '/js/app.js', { method: 'HEAD', headers: { 'accept-encoding': 'gzip' } }); assert.equal(head.status, 200); assert.equal(head.body.length, 0); assert.ok(Number(head.headers['content-length']) > 0); const post = await httpReq(srv.port, '/', { method: 'POST' }); assert.equal(post.status, 405); assert.equal(post.headers.allow, 'GET, HEAD'); const bare = await httpReq(srv.port, '/data'); assert.equal(bare.status, 301); assert.equal(bare.headers.location, '/data/'); const fileAsDir = await httpReq(srv.port, '/js/app.js/'); assert.equal(fileAsDir.status, 404); const indexAsDir = await httpReq(srv.port, '/index.html/'); assert.equal(indexAsDir.status, 404); const dirNoIndex = await httpReq(srv.port, '/data/'); assert.equal(dirNoIndex.status, 404, 'no directory listings'); const health = await httpReq(srv.port, '/healthz'); assert.equal(health.status, 200); const h = JSON.parse(health.body.toString()); assert.equal(h.ok, true); assert.equal(typeof h.rooms, 'number'); assert.equal(health.headers['cache-control'], 'no-store'); }); test('WebSocket upgrade only on /ws', async () => { await assert.rejects(TestClient.connect(`ws://127.0.0.1:${srv.port}/other`)); const ok = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws?x=1`); await ok.close(); }); test('range / accept-encoding parsers', () => { assert.deepEqual(parseRange('bytes=0-0', 10), { start: 0, end: 0 }); assert.deepEqual(parseRange('bytes=-3', 10), { start: 7, end: 9 }); assert.deepEqual(parseRange('bytes=-30', 10), { start: 0, end: 9 }); assert.equal(parseRange('bytes=-0', 10), 'unsatisfiable'); assert.equal(parseRange('bytes=10-', 10), 'unsatisfiable'); assert.equal(parseRange('bytes=0-', 0), 'unsatisfiable'); assert.equal(parseRange('bytes=5-2', 10), null); assert.equal(parseRange('items=0-1', 10), null); assert.equal(parseRange('bytes=-', 10), null); assert.equal(acceptsGzip('gzip'), true); assert.equal(acceptsGzip('deflate, gzip;q=0.5'), true); assert.equal(acceptsGzip('gzip;q=0'), false); assert.equal(acceptsGzip('*'), true); assert.equal(acceptsGzip('*;q=0'), false); assert.equal(acceptsGzip('br'), false); assert.equal(acceptsGzip(undefined), false); // brotli when named at least as high as gzip; `*` alone never picks it assert.equal(pickEncoding('gzip, deflate, br, zstd'), 'br'); assert.equal(pickEncoding('gzip, deflate'), 'gzip'); assert.equal(pickEncoding('br;q=0, gzip'), 'gzip'); assert.equal(pickEncoding('br;q=0.5, gzip'), 'gzip'); assert.equal(pickEncoding('gzip;q=0.5, br'), 'br'); assert.equal(pickEncoding('br'), 'br'); assert.equal(pickEncoding('*'), 'gzip'); assert.equal(pickEncoding('*;q=0'), null); assert.equal(pickEncoding('identity'), null); assert.equal(pickEncoding(undefined), null); }); }); describe('static http server (real repository roots)', () => { let srv; before(async () => { srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true }); }); after(async () => { await srv?.close(); }); test('serves shared/ ESM and never exposes server code or package.json', async () => { for (const p of ['/shared/constants.js', '/shared/protocol.js']) { const r = await httpReq(srv.port, p); assert.equal(r.status, 200, p); assert.equal(r.headers['content-type'], 'text/javascript; charset=utf-8'); } for (const p of ['/../package.json', '/%2e%2e/package.json', '/shared/../package.json', '/server/index.js', '/node_modules/ws/package.json', '/package.json']) { const r = await httpReq(srv.port, p); assert.ok([400, 403, 404].includes(r.status), `${p} → ${r.status}`); assert.ok(!r.body.toString().includes('"dependencies"'), `${p} leaked package.json`); } }); }); // --------------------------------------------------------------------------------------------------- // WebSocket session layer + lobby (default timers) // --------------------------------------------------------------------------------------------------- describe('websocket lobby', () => { let srv; let pool; const cap = captureLog(); before(async () => { srv = await startServer({ port: 0, host: '127.0.0.1', log: cap.log, MatchClass: Match }); pool = clientPool(() => `ws://127.0.0.1:${srv.port}/ws`); }); afterEach(async () => { await pool.closeAll(); }); after(async () => { await srv?.close(); assert.deepEqual(cap.errors, [], 'no server errors logged'); }); test('hello → welcome with playerId, 128-bit token, sanitized name; ping → pong', async () => { const c = await pool.connect(); const w = await c.hello(' 博士 Doctor '); assert.equal(w.t, 'welcome'); assert.match(w.playerId, /^p_[0-9a-f]{10}$/); assert.match(w.token, /^[0-9a-f]{32}$/); assert.equal(w.name, '博士 Doctor'); assert.equal(w.resumed, false); assert.ok(Math.abs(w.serverNow - Date.now()) < 5000); const pong = await c.request({ t: 'ping', c: 12.5 }); assert.equal(pong.t, 'pong'); assert.equal(pong.c, 12.5); assert.equal(typeof pong.s, 'number'); const other = await pool.player('Other'); assert.notEqual(other.id, w.playerId); assert.notEqual(other.token, w.token); await expectError(c, { t: 'hello', name: ' ' }, ERR.BAD_MSG); await expectError(c, { t: 'hello', name: 'x', version: 999 }, ERR.BAD_MSG); await expectError(c, { t: 'hello', name: 'x'.repeat(13) }, ERR.BAD_MSG); // repeated hello updates the name, keeps identity const again = await c.hello('Renamed'); assert.equal(again.playerId, w.playerId); assert.equal(again.name, 'Renamed'); }); test('bad messages → BAD_MSG and the connection stays alive', async () => { const c = await pool.connect(); await expectError(c, { t: 'room.create', mode: 'coop', difficulty: 'NORMAL' }, ERR.BAD_MSG); // before hello c.id = (await c.hello('Fuzzer')).playerId; c.sendRaw('{not json'); let e = await c.waitFor('error'); assert.equal(e.code, ERR.BAD_MSG); assert.equal(e.rid, undefined); c.sendRaw(Buffer.from([1, 2, 3]), { binary: true }); e = await c.waitFor('error'); assert.equal(e.code, ERR.BAD_MSG); for (const raw of ['[1,2]', 'null', '42', '"str"', '{}', '{"t":5}']) { c.sendRaw(raw); e = await c.waitFor('error'); assert.equal(e.code, ERR.BAD_MSG, raw); } for (const t of ['nope', '__proto__', 'constructor', 'toString', 'hasOwnProperty', 'g.', 'room.']) { const r = await expectError(c, { t }, ERR.BAD_MSG); assert.ok(Number.isInteger(r.rid), 'rid echoed'); } await expectError(c, { t: 'room.create', mode: 'pvp', difficulty: 'NORMAL' }, ERR.BAD_MSG); await expectError(c, { t: 'room.create', mode: 'coop', difficulty: 'EASY' }, ERR.BAD_MSG); await expectError(c, { t: 'room.create', mode: 'coop' }, ERR.BAD_MSG); await expectError(c, { t: 'room.join', code: 'AB CD' }, ERR.BAD_MSG); await expectError(c, { t: 'room.join', code: { $gt: '' } }, ERR.BAD_MSG); await expectError(c, { t: 'room.ready', ready: 'yes' }, ERR.BAD_MSG); await expectError(c, { t: 'room.removeBot', seat: 9 }, ERR.BAD_MSG); await expectError(c, { t: 'g.buy', slot: -1 }, ERR.BAD_MSG); await expectError(c, { t: 'g.move', uid: 1, to: { area: 'moon' } }, ERR.BAD_MSG); await expectError(c, { t: 'ping', c: 'x' }, ERR.BAD_MSG); c.send({ t: 'ping', c: 1, rid: -5 }); e = await c.waitFor('error'); assert.equal(e.code, ERR.BAD_MSG); assert.equal(e.rid, undefined, 'invalid rid is not echoed'); // still alive and functional assert.ok(c.isOpen); const pong = await c.request({ t: 'ping', c: 1 }); assert.equal(pong.t, 'pong'); await createRoom(c); }); test('oversized frame (> 64 KB) closes only that socket', async () => { const c = await pool.player('Big'); const bystander = await pool.player('Bystander'); c.sendRaw(JSON.stringify({ t: 'ping', c: 1, pad: 'x'.repeat(70 * 1024) })); const info = await c.closed; assert.equal(info.code, 1009); const pong = await bystander.request({ t: 'ping', c: 2 }); assert.equal(pong.t, 'pong'); }); test('rate limit: bursts beyond 40 msg/s get RATE, connection survives, bucket refills', async () => { const c = await pool.player('Spammer'); const N = 100; const rids = []; for (let i = 0; i < N; i++) rids.push(c.send({ t: 'ping', c: i })); const replies = []; for (const rid of rids) replies.push(await c.waitFor(null, (m) => m.rid === rid, 3000)); const pongs = replies.filter((m) => m.t === 'pong').length; const limited = replies.filter((m) => m.t === 'error' && m.code === ERR.RATE).length; assert.equal(pongs + limited, N); assert.ok(pongs >= 39 && pongs <= 45, `pongs ${pongs}`); // hello used one token assert.ok(limited >= 55, `limited ${limited}`); assert.ok(c.isOpen); await delay(1100); const ok = await c.request({ t: 'ping', c: 0 }); assert.equal(ok.t, 'pong'); }); test('flooding far beyond the limit closes the socket (1008)', async () => { const c = await pool.player('Flood'); // bursts until the server closes it: the close needs more than abuseDropsPerSec drops inside one 1 s window, and on // a heavily loaded machine a single burst of 600 can be read over more than a second — the old single burst then // never closed the socket and `await c.closed` (no timeout) hung the whole file const t0 = Date.now(); while (c.isOpen && Date.now() - t0 < 20000) { for (let i = 0; i < 600 && c.isOpen; i++) c.send({ t: 'ping', c: i, rid: null }); await delay(50); } const info = await Promise.race([c.closed, delay(10000).then(() => null)]); assert.ok(info, 'the server closed the flooding socket'); assert.equal(info.code, 1008); }); test('create / join / leave', async () => { const host = await pool.player('Host'); const st = await createRoom(host, 'coop', 'HARD'); assert.match(st.code, CODE_RE); assert.equal(st.mode, 'coop'); assert.equal(st.difficulty, 'HARD'); assert.equal(st.inMatch, false); assert.equal(st.seats.length, MAX_SEATS); assert.deepEqual(st.seats[0], { seat: 0, playerId: host.id, name: 'Host', isBot: false, ready: false, connected: true }); assert.deepEqual(st.seats.slice(1), [null, null, null]); const guest = await pool.player('Guest'); const joined = await joinRoom(guest, st.code.toLowerCase()); assert.equal(joined.hostId, host.id); assert.equal(seatOf(joined, guest.id).seat, 1); const hostView = await host.waitFor('room.state', (s) => !!seatOf(s, guest.id)); assert.equal(hostView.seats[1].name, 'Guest'); // join is idempotent for members await expectOk(guest, { t: 'room.join', code: st.code }); await guest.waitFor('room.state', (s) => s.code === st.code); await expectOk(guest, { t: 'room.ready', ready: true }); const readyView = await host.waitFor('room.state', (s) => seatOf(s, guest.id)?.ready === true); assert.ok(readyView); await expectOk(guest, { t: 'room.leave' }); const afterLeave = await host.waitFor('room.state', (s) => s.seats[1] === null); assert.equal(afterLeave.hostId, host.id); await expectError(guest, { t: 'room.leave' }, ERR.NOT_IN_ROOM); await expectError(guest, { t: 'room.ready', ready: true }, ERR.NOT_IN_ROOM); await expectError(guest, { t: 'g.ready', ready: true }, ERR.NOT_IN_ROOM); await expectError(guest, { t: 'room.join', code: 'ZZZZ' === st.code ? 'YYYY' : 'ZZZZ' }, ERR.ROOM_NOT_FOUND); await expectError(guest, { t: 'room.join', code: 'ABC' }, ERR.ROOM_NOT_FOUND); await expectError(guest, { t: 'room.join', code: 'ABCDEF' }, ERR.ROOM_NOT_FOUND); // creating a room while in another lobby room moves you const g2 = await joinRoom(guest, st.code); assert.equal(seatOf(g2, guest.id).seat, 1); const own = await createRoom(guest); assert.notEqual(own.code, st.code); await host.waitFor('room.state', (s) => s.seats[1] === null); // last human leaving disposes the room await expectOk(guest, { t: 'room.leave' }); await expectError(host, { t: 'room.join', code: own.code }, ERR.ROOM_NOT_FOUND); }); test('room full, solo rooms admit one human and no AI', async () => { const host = await pool.player('H'); const st = await createRoom(host); const guests = []; for (let i = 0; i < 2; i++) { const g = await pool.player(`G${i}`); await joinRoom(g, st.code); guests.push(g); } await expectOk(host, { t: 'room.addBot' }); await host.waitFor('room.state', (s) => s.seats.every(Boolean)); const late = await pool.player('Late'); await expectError(late, { t: 'room.join', code: st.code }, ERR.ROOM_FULL); await expectError(host, { t: 'room.addBot' }, ERR.ROOM_FULL); const solo = await pool.player('Solo'); const soloState = await createRoom(solo, 'solo', 'FUNNY'); assert.equal(soloState.mode, 'solo'); await expectError(late, { t: 'room.join', code: soloState.code }, ERR.ROOM_FULL); await expectError(solo, { t: 'room.addBot' }, ERR.ROOM_FULL); }); // community report #26 (a remake feature): spectator seats — server/lobby.js header test('spectator seats: join / cap / leave / host removal; never a player, never acting, never keeping a room', async () => { const host = await pool.player('Host'); const st = await createRoom(host); assert.deepEqual(st.spectators, []); const guest = await pool.player('Guest'); await joinRoom(guest, st.code); const s1 = await pool.player('Watcher1'); await expectOk(s1, { t: 'room.spectate', code: st.code.toLowerCase() }); const v1 = await s1.waitFor('room.state', (s) => s.spectators.some((x) => x.playerId === s1.id)); assert.deepEqual(v1.spectators, [{ playerId: s1.id, name: 'Watcher1', connected: true }]); assert.ok(!v1.seats.some((x) => x && x.playerId === s1.id), 'never in a player seat'); await host.waitFor('room.state', (s) => s.spectators.length === 1); await expectOk(s1, { t: 'room.spectate', code: st.code }); // idempotent await expectError(guest, { t: 'room.spectate', code: st.code }, ERR.ALREADY); // a player never switches in place // a spectator may not act await expectError(s1, { t: 'room.ready', ready: true }, ERR.SPECTATOR); for (const msg of [{ t: 'room.start' }, { t: 'room.addBot' }, { t: 'room.setDifficulty', difficulty: 'HARD' }, { t: 'room.removeSpectator', playerId: s1.id }]) { await expectError(s1, msg, ERR.NOT_HOST); } await expectError(s1, { t: 'g.ready', ready: true }, ERR.WRONG_PHASE); // the cap; spectators never count toward the players: two player seats still free const s2 = await pool.player('Watcher2'); await expectOk(s2, { t: 'room.spectate', code: st.code }); assert.equal(MAX_SPECTATORS, 2); const s3 = await pool.player('Watcher3'); await expectError(s3, { t: 'room.spectate', code: st.code }, ERR.ROOM_FULL); await joinRoom(s3, st.code); await expectOk(host, { t: 'room.addBot' }); const full = await host.waitFor('room.state', (s) => s.seats.every(Boolean)); assert.equal(full.spectators.length, 2); await expectOk(s3, { t: 'room.ready', ready: true }); await expectOk(guest, { t: 'room.ready', ready: true }); // a spectator in the lobby takes a free player seat with room.join of the same code await expectError(s2, { t: 'room.join', code: st.code }, ERR.ROOM_FULL); await expectOk(host, { t: 'room.removeBot', seat: 3 }); const sat = await joinRoom(s2, st.code); assert.equal(seatOf(sat, s2.id).seat, 3); assert.deepEqual(sat.spectators.map((x) => x.playerId), [s1.id]); // leave host.clearInbox(); await expectOk(s1, { t: 'room.leave' }); await host.waitFor('room.state', (s) => s.spectators.length === 0); await expectError(s1, { t: 'room.leave' }, ERR.NOT_IN_ROOM); await expectError(s1, { t: 'room.ready', ready: true }, ERR.NOT_IN_ROOM); // host removal: room.closed {kicked} const s4 = await pool.player('Watcher4'); await expectOk(s4, { t: 'room.spectate', code: st.code }); await host.waitFor('room.state', (s) => s.spectators.length === 1); await expectError(host, { t: 'room.removeSpectator', playerId: guest.id }, ERR.BAD_TARGET); await expectError(guest, { t: 'room.removeSpectator', playerId: s4.id }, ERR.NOT_HOST); await expectOk(host, { t: 'room.removeSpectator', playerId: s4.id }); assert.equal((await s4.waitFor('room.closed')).reason, 'kicked'); await host.waitFor('room.state', (s) => s.spectators.length === 0); await expectError(s4, { t: 'room.leave' }, ERR.NOT_IN_ROOM); // an offline spectator removed: freed at once, told on its next resume const s5 = await pool.player('Watcher5'); await expectOk(s5, { t: 'room.spectate', code: st.code }); await s5.terminate(); await host.waitFor('room.state', (s) => s.spectators.find((x) => x.playerId === s5.id)?.connected === false); await expectOk(host, { t: 'room.removeSpectator', playerId: s5.id }); const s5back = await pool.connect(); await s5back.hello('Watcher5', s5.token); assert.equal((await s5back.waitFor('room.closed')).reason, 'kicked'); await s5back.expectNone('room.state'); // solo rooms have no spectator seat; unknown codes const solo = await pool.player('Solo'); const soloSt = await createRoom(solo, 'solo', 'FUNNY'); await expectError(s4, { t: 'room.spectate', code: soloSt.code }, ERR.ROOM_FULL); await expectError(s4, { t: 'room.spectate', code: st.code === 'ZZZZ' ? 'YYYY' : 'ZZZZ' }, ERR.ROOM_NOT_FOUND); // a spectator never keeps a room alive: its last human leaving closes it for the spectator const lone = await pool.player('Lone'); const loneSt = await createRoom(lone); await expectOk(s4, { t: 'room.spectate', code: loneSt.code }); await lone.waitFor('room.state', (s) => s.spectators.length === 1); await expectOk(lone, { t: 'room.leave' }); assert.equal((await s4.waitFor('room.closed')).reason, 'empty'); await expectError(s4, { t: 'room.spectate', code: loneSt.code }, ERR.ROOM_NOT_FOUND); }); test('spectators during a match: broadcasts but no private view, join mid-match, only watching, reconnect gets the seat back', async () => { const host = await pool.player('Host'); const st = await createRoom(host); const spec = await pool.player('Spec'); await expectOk(spec, { t: 'room.spectate', code: st.code }); await expectOk(host, { t: 'room.start' }); // the spectator is no player to wait for const pub = await spec.waitFor('m.public'); assert.deepEqual(pub.players.map((p) => p.playerId), [host.id], 'never one of the match\'s players'); const late = await pool.player('Late'); await expectError(late, { t: 'room.join', code: st.code }, ERR.ROOM_STARTED); await expectOk(late, { t: 'room.spectate', code: st.code }); // a running match: a spectator seat is still free const lateState = await late.waitFor('room.state', (s) => s.inMatch); assert.deepEqual(lateState.spectators.map((x) => x.playerId), [spec.id, late.id]); for (const msg of [{ t: 'g.infoReady' }, { t: 'g.buy', slot: 0 }, { t: 'g.ready', ready: true }, { t: 'g.emote', id: EMOTES[0] }, { t: 'g.autoplay', on: true }]) { await expectError(spec, msg, ERR.SPECTATOR); } await expectOk(spec, { t: 'room.loadout', entries: {} }); // kept for its session, never handed to the match // drop and resume with the token: the seat is kept meanwhile and given back await late.terminate(); await host.waitFor('room.state', (s) => s.spectators.find((x) => x.playerId === late.id)?.connected === false); const back = await pool.connect(); const w = await back.hello('Late', late.token); assert.equal(w.resumed, true); const rs = await back.waitFor('room.state'); assert.deepEqual(rs.spectators.find((x) => x.playerId === late.id), { playerId: late.id, name: 'Late', connected: true }); await expectError(back, { t: 'g.infoReady' }, ERR.SPECTATOR); // the match ends: the spectators see the result (a broadcast) and stay in the room, which returns to its lobby await expectOk(host, { t: 'g.infoReady' }); await spec.waitFor('m.result'); const lobby = await spec.waitFor('room.state', (s) => !s.inMatch && s.spectators.length === 2); assert.deepEqual(lobby.spectators.map((x) => x.playerId), [spec.id, late.id]); assert.equal(spec.log.filter((x) => x.t === 'm.private').length, 0, 'no private view, ever'); assert.equal(back.log.filter((x) => x.t === 'm.private').length, 0); }); test('host-only commands, difficulty change un-readies guests, host migration on leave', async () => { const host = await pool.player('Host'); const st = await createRoom(host, 'coop', 'NORMAL'); const a = await pool.player('A'); const b = await pool.player('B'); await joinRoom(a, st.code); await joinRoom(b, st.code); for (const msg of [{ t: 'room.setDifficulty', difficulty: 'HARD' }, { t: 'room.addBot' }, { t: 'room.removeBot', seat: 0 }, { t: 'room.start' }]) { await expectError(a, msg, ERR.NOT_HOST); } await expectOk(a, { t: 'room.ready', ready: true }); await host.waitFor('room.state', (s) => seatOf(s, a.id)?.ready); await expectOk(host, { t: 'room.setDifficulty', difficulty: 'ABYSS' }); const changed = await a.waitFor('room.state', (s) => s.difficulty === 'ABYSS'); assert.equal(seatOf(changed, a.id).ready, false, 'difficulty change resets ready'); await expectOk(host, { t: 'room.leave' }); const migrated = await a.waitFor('room.state', (s) => s.hostId !== host.id); assert.equal(migrated.hostId, a.id, 'lowest remaining seat becomes host'); assert.equal(migrated.seats[0], null); await b.waitFor('room.state', (s) => s.hostId === a.id); // new joiner takes the lowest free seat (0); host stays with A const c = await pool.player('C'); const joined = await joinRoom(c, st.code); assert.equal(seatOf(joined, c.id).seat, 0); assert.equal(joined.hostId, a.id); await expectOk(a, { t: 'room.addBot' }); const withBot = await c.waitFor('room.state', (s) => s.seats[3]?.isBot); assert.equal(withBot.hostId, a.id); await expectError(b, { t: 'room.addBot' }, ERR.NOT_HOST); await expectError(host, { t: 'room.addBot' }, ERR.NOT_IN_ROOM); }); test('add / remove AI; a room with only AI is disposed', async () => { const host = await pool.player('Host'); const st = await createRoom(host); await expectOk(host, { t: 'room.addBot' }); const s1 = await host.waitFor('room.state', (s) => s.seats[1]?.isBot); assert.equal(s1.seats[1].ready, true); assert.equal(s1.seats[1].connected, true); assert.equal(s1.seats[1].name, BOT_NAMES[0]); assert.match(s1.seats[1].playerId, /^ai_[0-9a-f]{8}$/); await expectOk(host, { t: 'room.addBot' }); const s2 = await host.waitFor('room.state', (s) => s.seats[2]?.isBot); assert.equal(s2.seats[2].name, BOT_NAMES[1]); await expectError(host, { t: 'room.removeBot', seat: 0 }, ERR.BAD_TARGET); // human seat await expectError(host, { t: 'room.removeBot', seat: 3 }, ERR.BAD_TARGET); // empty seat await expectOk(host, { t: 'room.removeBot', seat: 1 }); const s3 = await host.waitFor('room.state', (s) => s.seats[1] === null && s.seats[2]?.isBot); assert.ok(s3); await expectOk(host, { t: 'room.addBot' }); const s4 = await host.waitFor('room.state', (s) => s.seats[1]?.isBot); assert.equal(s4.seats[1].name, BOT_NAMES[0], 'freed bot name is reused'); await expectOk(host, { t: 'room.leave' }); const probe = await pool.player('Probe'); await expectError(probe, { t: 'room.join', code: st.code }, ERR.ROOM_NOT_FOUND); }); test('start flow: readiness gate → stub INFO_CHECK → end → back to LOBBY → play again', async () => { const host = await pool.player('Host'); const st = await createRoom(host, 'coop', 'HARD'); const guest = await pool.player('Guest'); await joinRoom(guest, st.code); await expectOk(host, { t: 'room.addBot' }); await expectError(host, { t: 'g.infoReady' }, ERR.WRONG_PHASE); // no match yet await expectError(guest, { t: 'room.start' }, ERR.NOT_HOST); await expectError(host, { t: 'room.start' }, ERR.NOT_READY); await expectOk(guest, { t: 'room.ready', ready: true }); await expectOk(host, { t: 'room.start' }); const inMatch = await guest.waitFor('room.state', (s) => s.inMatch); assert.equal(inMatch.code, st.code); for (const c of [host, guest]) { const pub = await c.waitFor('m.public'); assert.equal(pub.phase, 'INFO_CHECK'); assert.equal(pub.modeId, 'mode_multi_hard'); assert.equal(typeof pub.message, 'string'); assert.ok(pub.deadline > pub.serverNow); assert.equal(pub.players.length, 3); assert.equal(pub.players.filter((p) => p.isBot).length, 1); const priv = await c.waitFor('m.private'); assert.equal(priv.playerId, c.id); } // match intents route to the match; lobby intents are refused await expectOk(guest, { t: 'g.buy', slot: 0 }); await expectOk(guest, { t: 'g.emote', id: 'autochess_battle_happy' }); const emote = await host.waitFor('m.emote'); assert.deepEqual([emote.playerId, emote.id], [guest.id, 'autochess_battle_happy']); await expectError(guest, { t: 'room.ready', ready: false }, ERR.ROOM_STARTED); await expectError(host, { t: 'room.start' }, ERR.ROOM_STARTED); await expectError(host, { t: 'room.addBot' }, ERR.ROOM_STARTED); await expectError(host, { t: 'room.setDifficulty', difficulty: 'FUNNY' }, ERR.ROOM_STARTED); await expectError(host, { t: 'room.create', mode: 'coop', difficulty: 'FUNNY' }, ERR.ROOM_STARTED); const outsider = await pool.player('Outsider'); await expectError(outsider, { t: 'room.join', code: st.code }, ERR.ROOM_STARTED); await expectError(outsider, { t: 'g.buy', slot: 0 }, ERR.NOT_IN_ROOM); const other = await createRoom(outsider); await expectError(host, { t: 'room.join', code: other.code }, ERR.ROOM_STARTED); // stub ends once every human confirmed the briefing host.clearInbox(); await expectOk(host, { t: 'g.infoReady' }); await expectOk(guest, { t: 'g.infoReady' }); const result = await host.waitFor('m.result'); assert.equal(result.stub, true); const back = await host.waitFor('room.state', (s) => !s.inMatch); assert.equal(back.hostId, host.id); assert.equal(seatOf(back, guest.id).ready, false, 'humans un-ready after a match'); assert.equal(back.seats.filter((s) => s?.isBot).length, 1, 'bots stay'); await expectError(guest, { t: 'g.infoReady' }, ERR.WRONG_PHASE); // play again await expectOk(guest, { t: 'room.ready', ready: true }); guest.clearInbox(); await expectOk(host, { t: 'room.start' }); const again = await guest.waitFor('m.public', (m) => m.phase === 'INFO_CHECK'); assert.ok(again.players.every((p) => p.isBot || !p.ready), 'fresh match state'); }); test('solo room: host starts alone', async () => { const solo = await pool.player('Solo'); await createRoom(solo, 'solo', 'FUNNY'); await expectOk(solo, { t: 'room.start' }); const pub = await solo.waitFor('m.public'); assert.equal(pub.phase, 'INFO_CHECK'); assert.equal(pub.modeId, 'mode_single_funny'); assert.equal(pub.lastRound, 9); await expectOk(solo, { t: 'g.infoReady' }); await solo.waitFor('m.result'); await solo.waitFor('room.state', (s) => !s.inMatch && s.mode === 'solo'); }); test('reconnect with token in LOBBY restores the seat', async () => { const host = await pool.player('Host'); const st = await createRoom(host); const guest = await pool.player('Guest'); await joinRoom(guest, st.code); await expectOk(guest, { t: 'room.ready', ready: true }); await host.waitFor('room.state', (s) => seatOf(s, guest.id)?.ready); await guest.terminate(); const dropped = await host.waitFor('room.state', (s) => seatOf(s, guest.id)?.connected === false); assert.equal(seatOf(dropped, guest.id).seat, 1); await expectError(host, { t: 'room.start' }, ERR.NOT_READY); // disconnected humans block the start host.clearInbox(); const back = await pool.connect(); const w = await back.hello('Guest2', guest.token); assert.equal(w.resumed, true); assert.equal(w.playerId, guest.id); assert.equal(w.token, guest.token); const restored = await back.waitFor('room.state'); assert.equal(restored.code, st.code); assert.deepEqual(seatOf(restored, guest.id), { seat: 1, playerId: guest.id, name: 'Guest2', isBot: false, ready: true, connected: true }); await host.waitFor('room.state', (s) => seatOf(s, guest.id)?.connected === true); // an unknown token just creates a new identity const stranger = await pool.connect(); const sw = await stranger.hello('Stranger', 'f'.repeat(32)); assert.equal(sw.resumed, false); assert.notEqual(sw.playerId, guest.id); }); test('reconnect with token during a match restores seat and resends match state', async () => { const host = await pool.player('Host'); const st = await createRoom(host); const guest = await pool.player('Guest'); await joinRoom(guest, st.code); await expectOk(guest, { t: 'room.ready', ready: true }); await expectOk(host, { t: 'room.start' }); await guest.waitFor('m.public'); await host.waitFor('m.public'); await guest.terminate(); await host.waitFor('room.state', (s) => s.inMatch && seatOf(s, guest.id)?.connected === false); const pubDrop = await host.waitFor('m.public', (m) => m.players.some((p) => p.playerId === guest.id && !p.connected)); assert.ok(pubDrop); const back = await pool.connect(); const w = await back.hello('Guest', guest.token); assert.equal(w.resumed, true); assert.equal(w.playerId, guest.id); const rs = await back.waitFor('room.state'); assert.equal(rs.inMatch, true); assert.equal(seatOf(rs, guest.id).connected, true); const pub = await back.waitFor('m.public'); assert.equal(pub.phase, 'INFO_CHECK'); const priv = await back.waitFor('m.private'); assert.equal(priv.playerId, guest.id); await expectOk(back, { t: 'g.refresh' }); }); test('same token on a second socket replaces the first (close 4001)', async () => { const a = await pool.player('Twin'); const st = await createRoom(a); const b = await pool.connect(); const w = await b.hello('Twin', a.token); assert.equal(w.resumed, true); const info = await a.closed; assert.equal(info.code, 4001); const rs = await b.waitFor('room.state'); assert.equal(rs.code, st.code); assert.equal(seatOf(rs, a.id).connected, true, 'old socket closing does not mark the session disconnected'); await delay(50); await expectOk(b, { t: 'room.ready', ready: true }); const ready = await b.waitFor('room.state', (s) => seatOf(s, a.id)?.ready); assert.equal(seatOf(ready, a.id).connected, true); }); test('g.leave / room.leave during a match departs permanently; seat freed when the match ends', async () => { const host = await pool.player('Host'); const st = await createRoom(host); const guest = await pool.player('Guest'); await joinRoom(guest, st.code); await expectOk(guest, { t: 'room.ready', ready: true }); await expectOk(host, { t: 'room.start' }); await guest.waitFor('m.public'); await expectOk(guest, { t: 'g.leave' }); const st2 = await host.waitFor('room.state', (s) => s.inMatch && seatOf(s, guest.id)?.connected === false); assert.equal(seatOf(st2, guest.id).seat, 1, 'seat kept during the match'); await expectError(guest, { t: 'g.infoReady' }, ERR.NOT_IN_ROOM); await expectError(guest, { t: 'room.join', code: st.code }, ERR.ROOM_STARTED); // the departed player is free to do other things await createRoom(guest); // host finishes; stub counts departed players as ready host.clearInbox(); await expectOk(host, { t: 'g.infoReady' }); const back = await host.waitFor('room.state', (s) => !s.inMatch); assert.equal(seatOf(back, guest.id), null, 'departed seat freed'); // host leaving a running match with nobody else disposes the room host.clearInbox(); await expectOk(host, { t: 'room.start' }); await host.waitFor('m.public', (m) => m.phase === 'INFO_CHECK'); await expectOk(host, { t: 'room.leave' }); const probe = await pool.player('Probe'); await expectError(probe, { t: 'room.join', code: st.code }, ERR.ROOM_NOT_FOUND); }); test('protocol fuzz: random intents never crash the server or corrupt lobby invariants', async () => { let seed = 0x5eed1234; const rnd = () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; const pick = (a) => a[Math.floor(rnd() * a.length)]; const clients = []; for (let i = 0; i < 5; i++) clients.push(await pool.player(`F${i}`)); const codes = () => [...srv.lobby.rooms.keys()]; const gTypes = ['g.infoReady', 'g.band', 'g.bandSkip', 'g.buy', 'g.refresh', 'g.freeze', 'g.levelUp', 'g.sell', 'g.move', 'g.equip', 'g.art', 'g.destroy', 'g.reward', 'g.choice', 'g.ready', 'g.emote', 'g.watch', 'g.autoplay', 'g.leave']; const junk = [1, -1, 1.5, 'x', '', null, true, [], {}, { area: 'board', row: 1, col: 1 }, 'A'.repeat(70), 2 ** 40]; const gen = () => { const r = rnd(); if (r < 0.12) return { t: 'room.create', mode: pick(['solo', 'coop', 'coop', 'x']), difficulty: pick(['FUNNY', 'NORMAL', 'HARD', 'ABYSS', 'nope']) }; if (r < 0.25) return { t: 'room.join', code: pick([...codes(), 'ZZZZ', 'abc', ...codes().map((c) => c.toLowerCase())]) || 'QQQQ' }; if (r < 0.32) return { t: 'room.leave' }; if (r < 0.42) return { t: 'room.ready', ready: pick([true, false, 'x']) }; if (r < 0.47) return { t: 'room.setDifficulty', difficulty: pick(['FUNNY', 'ABYSS', 1]) }; if (r < 0.55) return { t: 'room.addBot' }; if (r < 0.60) return { t: 'room.removeBot', seat: pick([0, 1, 2, 3, 4, -1, 'x']) }; if (r < 0.68) return { t: 'room.start' }; if (r < 0.90) { const m = { t: pick(gTypes) }; for (const k of ['bandId', 'slot', 'uid', 'to', 'itemUid', 'targetUid', 'row', 'col', 'idx', 'ready', 'id', 'fieldId', 'on']) if (rnd() < 0.3) m[k] = pick(junk); if (m.t === 'g.emote' && rnd() < 0.5) m.id = 'autochess_battle_happy'; return m; } // spectator seats (community report #26) if (r < 0.95) { return rnd() < 0.7 ? { t: 'room.spectate', code: pick([...codes(), 'ZZZZ', ...codes().map((c) => c.toLowerCase())]) || 'QQQQ' } : { t: 'room.removeSpectator', playerId: pick([...clients.map((x) => x.id), 'p_nope', 1]) }; } return pick([{ t: 'hello', name: pick(['Re', '', 'x'.repeat(20)]) }, { t: 'ping', c: pick([1, 'x']) }, { t: pick(['nope', '__proto__', 'toString']) }, { t: 'room.join', code: pick(junk) }]); }; const okCounts = {}; for (let i = 0; i < 500; i++) { const k = Math.floor(rnd() * clients.length); let c = clients[k]; if (rnd() < 0.04) { // drop & resume await c.terminate(); const back = await pool.connect(); const w = await back.hello(`F${k}`, c.token); Object.assign(back, { id: w.playerId, token: w.token }); clients[k] = c = back; continue; } const msg = gen(); const reply = await c.request(msg, 3000); assert.ok(['ok', 'error', 'welcome', 'pong'].includes(reply.t)); if (reply.t === 'ok') okCounts[msg.t] = (okCounts[msg.t] || 0) + 1; if (reply.t === 'error') assert.ok(Object.hasOwn(ERR, reply.code), reply.code); if (i % 50 === 0) await delay(30); // stay well under the rate limit } for (const t of ['room.create', 'room.join', 'room.start', 'room.addBot', 'g.infoReady']) assert.ok(okCounts[t] > 0, `fuzz never succeeded at ${t}`); // invariants const seen = new Set(); for (const room of srv.lobby.rooms.values()) { const humans = room.activeHumans(); assert.ok(humans.length >= 1, `room ${room.code} has no humans`); assert.ok(humans.some((h) => h.playerId === room.hostId), `room ${room.code} host is not an active human`); if (room.mode === 'solo') assert.ok(room.seats.filter(Boolean).length === 1); room.seats.forEach((s, i) => { if (s) assert.equal(s.seat, i); }); for (const h of humans) { assert.ok(!seen.has(h.playerId), 'player seated twice'); seen.add(h.playerId); const sess = srv.registry.byId(h.playerId); assert.ok(sess, 'seated session exists'); assert.equal(sess.roomCode, room.code); } // spectator seats: capped, none in solo rooms, never also a player (here or elsewhere), never the host assert.ok(room.spectators.length <= MAX_SPECTATORS, `room ${room.code} spectator cap`); if (room.mode === 'solo') assert.equal(room.spectators.length, 0, 'no spectator seat in a solo room'); for (const sp of room.spectators) { assert.ok(!seen.has(sp.playerId), 'a spectator is seated (or spectating) twice'); seen.add(sp.playerId); assert.ok(!room.seatOf(sp.playerId) && room.hostId !== sp.playerId); assert.equal(srv.registry.byId(sp.playerId)?.roomCode, room.code, 'the spectator\'s session points at its room'); } } if (okCounts['room.spectate'] == null) assert.fail('fuzz never took a spectator seat'); for (const sess of srv.registry.all()) { const r = sess.roomCode ? srv.lobby.rooms.get(sess.roomCode) : null; if (sess.roomCode) assert.ok(r?.seatOf(sess.playerId) || r?.spectatorOf(sess.playerId), 'session points at its seat'); } const h = JSON.parse((await httpReq(srv.port, '/healthz')).body.toString()); assert.equal(h.ok, true); }); test('frames still in flight from a replaced socket are ignored (no orphan sessions)', async () => { for (let i = 0; i < 5; i++) { const a = await pool.player('Twin'); const before = srv.registry.size; const b = await pool.connect(); b.send({ t: 'hello', name: 'Twin', token: a.token, version: 1 }); for (let k = 0; k < 5; k++) a.send({ t: 'hello', name: 'Ghost', version: 1 }); a.send({ t: 'room.create', mode: 'coop', difficulty: 'NORMAL' }); const info = await a.closed; assert.equal(info.code, 4001); const w = await b.waitFor('welcome'); assert.equal(w.playerId, a.id); await delay(30); assert.equal(srv.registry.size, before, 'no session minted by the replaced socket'); assert.ok(![...srv.registry.all()].some((x) => x.name === 'Ghost')); } }); test('repeated hello resyncs only the requester unless the seat visibly changed', async () => { const host = await pool.player('Host'); const st = await createRoom(host); const guest = await pool.player('Guest'); await joinRoom(guest, st.code); await host.waitFor('room.state', (s) => !!seatOf(s, guest.id)); host.clearInbox(); guest.clearInbox(); for (let i = 0; i < 5; i++) await guest.hello('Guest'); for (let i = 0; i < 5; i++) await guest.waitFor('room.state', (s) => s.code === st.code); await host.expectNone('room.state'); await guest.hello('Renamed'); const renamed = await host.waitFor('room.state', (s) => seatOf(s, guest.id)?.name === 'Renamed'); assert.equal(renamed.hostId, host.id); }); test('resend-heavy intents (g.watch) have their own small bucket: RATE, socket survives, bucket refills', async () => { const c = await pool.player('Watcher'); await createRoom(c, 'solo', 'NORMAL'); await expectOk(c, { t: 'room.start' }); await c.waitFor('m.public'); const replies = []; for (let i = 0; i < 20; i++) replies.push(c.request({ t: 'g.watch', fieldId: 'f0' })); const got = await Promise.all(replies); const oks = got.filter((r) => r.t === 'ok').length; const rated = got.filter((r) => r.t === 'error' && r.code === ERR.RATE).length; assert.equal(oks + rated, 20); assert.ok(oks >= 6 && oks <= 8, `burst of ~6 accepted, got ${oks}`); await expectOk(c, { t: 'g.emote', id: 'autochess_battle_happy' }); // other intents are unaffected await delay(600); await expectOk(c, { t: 'g.watch', fieldId: 'f0' }); assert.ok(c.isOpen); }); test('healthz counts rooms and sockets', async () => { const c = await pool.player('Counter'); await createRoom(c); const r = await httpReq(srv.port, '/healthz'); const h = JSON.parse(r.body.toString()); assert.ok(h.rooms >= 1); assert.ok(h.sockets >= 1); assert.ok(h.sessions >= 1); }); }); // --------------------------------------------------------------------------------------------------- // Short timers + instrumented match // --------------------------------------------------------------------------------------------------- class RecordingMatch extends Match { static instances = []; static failStart = false; constructor(opts) { super(opts); this.opts = opts; this.calls = []; RecordingMatch.instances.push(this); } start() { this.calls.push(['start']); if (RecordingMatch.failStart) throw new Error('intentional start failure'); super.start(); } handle(playerId, msg) { this.calls.push(['handle', playerId, msg.t]); if (msg.t === 'g.buy' && msg.slot === 13) throw new Error('intentional handler crash'); if (msg.t === 'g.sell') return { error: ERR.NO_FUNDS }; if (msg.t === 'g.freeze') return { error: 'NOT_A_REAL_CODE' }; if (msg.t === 'g.destroy') return { error: 'constructor' }; if (msg.t === 'g.levelUp') return undefined; if (msg.t === 'g.autoplay') return Promise.reject(new Error('async handler rejection')); return super.handle(playerId, msg); } onDisconnect(playerId) { this.calls.push(['onDisconnect', playerId]); super.onDisconnect(playerId); } onReconnect(playerId) { this.calls.push(['onReconnect', playerId]); super.onReconnect(playerId); } onLeave(playerId) { this.calls.push(['onLeave', playerId]); super.onLeave(playerId); } addSpectator(playerId) { this.calls.push(['addSpectator', playerId]); } removeSpectator(playerId) { this.calls.push(['removeSpectator', playerId]); } dispose() { this.calls.push(['dispose']); super.dispose(); } } describe('lobby timers and match interface', () => { let srv; let pool; const cap = captureLog(); const GRACE = 300; const WINDOW = 1200; before(async () => { srv = await startServer({ port: 0, host: '127.0.0.1', log: cap.log, MatchClass: RecordingMatch, seedFn: () => 123456789, lobbyGraceMs: GRACE, reconnectWindowMs: WINDOW, }); pool = clientPool(() => `ws://127.0.0.1:${srv.port}/ws`); }); afterEach(async () => { RecordingMatch.failStart = false; await pool.closeAll(); }); after(async () => { await srv?.close(); }); test('lobby grace: disconnected guest is removed, then told room.closed{timeout} on resume', async () => { const host = await pool.player('Host'); const st = await createRoom(host); const guest = await pool.player('Guest'); await joinRoom(guest, st.code); await guest.terminate(); await host.waitFor('room.state', (s) => seatOf(s, guest.id)?.connected === false); const removed = await host.waitFor('room.state', (s) => s.seats[1] === null, GRACE + 1000); assert.equal(removed.hostId, host.id); const back = await pool.connect(); const w = await back.hello('Guest', guest.token); assert.equal(w.resumed, true); const closed = await back.waitFor('room.closed'); assert.equal(closed.reason, 'timeout'); await back.expectNone('room.state'); }); test('lobby grace: host removed → host migrates to the connected guest', async () => { const host = await pool.player('Host'); const st = await createRoom(host); const guest = await pool.player('Guest'); await joinRoom(guest, st.code); await host.terminate(); const migrated = await guest.waitFor('room.state', (s) => s.hostId === guest.id, GRACE + 1000); assert.equal(migrated.seats[0], null); await expectOk(guest, { t: 'room.addBot' }); }); test('spectator seats: lobby grace like a player; never the host; the match hooks (opts.spectators, addSpectator / removeSpectator, only g.watch)', async () => { const host = await pool.player('Host'); const st = await createRoom(host); const spec = await pool.player('Spec'); await expectOk(spec, { t: 'room.spectate', code: st.code }); // a dropped spectator keeps its seat for the lobby grace, then is freed and told room.closed {timeout} on resume const gone = await pool.player('Gone'); await expectOk(gone, { t: 'room.spectate', code: st.code }); await gone.terminate(); await host.waitFor('room.state', (s) => s.spectators.find((x) => x.playerId === gone.id)?.connected === false); host.clearInbox(); await host.waitFor('room.state', (s) => s.spectators.length === 1, GRACE + 1000); const goneBack = await pool.connect(); await goneBack.hello('Gone', gone.token); assert.equal((await goneBack.waitFor('room.closed')).reason, 'timeout'); // the match: the spectator is passed apart from the seats RecordingMatch.instances.length = 0; await expectOk(host, { t: 'room.start' }); const m = RecordingMatch.instances[0]; assert.deepEqual(m.opts.spectators, [spec.id]); assert.deepEqual(m.opts.seats.map((s) => s.playerId), [host.id]); // only g.watch of a spectator reaches the match await expectError(spec, { t: 'g.levelUp' }, ERR.SPECTATOR); await spec.request({ t: 'g.watch', fieldId: `n:${host.id}` }); assert.deepEqual(m.calls.filter((c) => c[0] === 'handle' && c[1] === spec.id).map((c) => c[2]), ['g.watch']); // joining mid-match, a drop (the match is not told), a resume (resync) → addSpectator; leaving → removeSpectator const late = await pool.player('Late'); await expectOk(late, { t: 'room.spectate', code: st.code }); await late.terminate(); await host.waitFor('room.state', (s) => s.spectators.find((x) => x.playerId === late.id)?.connected === false); host.clearInbox(); const back = await pool.connect(); await back.hello('Late', late.token); await host.waitFor('room.state', (s) => s.spectators.find((x) => x.playerId === late.id)?.connected === true); await expectOk(back, { t: 'g.leave' }); await host.waitFor('room.state', (s) => s.spectators.length === 1); const hooks = m.calls.filter((c) => c[1] === late.id).map((c) => c[0]); assert.deepEqual(hooks, ['addSpectator', 'addSpectator', 'removeSpectator'], 'never onDisconnect / onReconnect / onLeave'); // the host leaves mid-match: a spectator never becomes the host and never keeps the room await expectOk(host, { t: 'room.leave' }); assert.equal((await spec.waitFor('room.closed')).reason, 'empty'); }); test('match number: every match of a room gets the next matchNo (battleIds stay unique even with the same seed)', async () => { const solo = await pool.player('Solo'); await createRoom(solo, 'solo', 'FUNNY'); RecordingMatch.instances.length = 0; for (let i = 0; i < 2; i++) { solo.clearInbox(); await expectOk(solo, { t: 'room.start' }); await solo.waitFor('m.public', (p) => p.phase === 'INFO_CHECK'); await expectOk(solo, { t: 'g.infoReady' }); // the stub ends once every human confirmed the briefing await solo.waitFor('room.state', (s) => !s.inMatch); } assert.deepEqual(RecordingMatch.instances.map((m) => [m.opts.seed, m.opts.matchNo]), [[123456789, 1], [123456789, 2]]); await expectOk(solo, { t: 'room.leave' }); }); test('match hooks: seats/seed passed, onDisconnect/onReconnect, handler results, crash → INTERNAL', async () => { const host = await pool.player('Host'); const st = await createRoom(host, 'coop', 'ABYSS'); const guest = await pool.player('Guest'); await joinRoom(guest, st.code); await expectOk(host, { t: 'room.addBot' }); await expectOk(guest, { t: 'room.ready', ready: true }); RecordingMatch.instances.length = 0; await expectOk(host, { t: 'room.start' }); const m = RecordingMatch.instances[0]; assert.ok(m); assert.equal(m.opts.roomCode, st.code); assert.equal(m.opts.mode, 'coop'); assert.equal(m.opts.difficulty, 'ABYSS'); assert.equal(m.opts.modeId, 'mode_multi_abyss'); assert.equal(m.opts.seed, 123456789); assert.equal(typeof m.opts.data, 'object'); assert.deepEqual(m.opts.seats.map((s) => [s.seat, s.isBot, s.connected]), [[0, false, true], [1, false, true], [2, true, true]]); assert.equal(m.opts.seats[0].playerId, host.id); await expectError(guest, { t: 'g.sell', uid: 1 }, ERR.NO_FUNDS); await expectError(guest, { t: 'g.freeze' }, ERR.INTERNAL); await expectError(guest, { t: 'g.destroy', uid: 5 }, ERR.INTERNAL); await expectOk(guest, { t: 'g.levelUp' }); await expectError(guest, { t: 'g.buy', slot: 13 }, ERR.INTERNAL); assert.ok(guest.isOpen); await expectOk(guest, { t: 'g.autoplay', on: true }); // contract violation (async) is logged, never unhandled await delay(10); assert.ok(cap.errors.some((e) => e.includes('returned a Promise'))); assert.ok(cap.errors.some((e) => e.includes('async handler rejection'))); await expectOk(guest, { t: 'g.buy', slot: 1 }); assert.ok(!m.calls.some((c) => c[0] === 'handle' && c[2] === 'g.leave')); await guest.terminate(); await host.waitFor('room.state', (s) => seatOf(s, guest.id)?.connected === false); assert.deepEqual(m.calls.filter((c) => c[0] === 'onDisconnect'), [['onDisconnect', guest.id]]); const back = await pool.connect(); await back.hello('Guest', guest.token); await back.waitFor('m.private'); assert.deepEqual(m.calls.filter((c) => c[0] === 'onReconnect'), [['onReconnect', guest.id]]); // repeated hello = cheap resync await back.hello('Guest'); await back.waitFor('m.private'); assert.equal(m.calls.filter((c) => c[0] === 'onReconnect').length, 2); await expectOk(host, { t: 'g.infoReady' }); await expectOk(back, { t: 'g.infoReady' }); await host.waitFor('room.state', (s) => !s.inMatch); await delay(20); assert.equal(m.calls.filter((c) => c[0] === 'dispose').length, 1, 'disposed exactly once after onEnd'); assert.ok(cap.errors.some((e) => e.includes('intentional handler crash'))); }); test('repeated hellos during a match coalesce into at most one match resync per window', async () => { const host = await pool.player('Host'); await createRoom(host, 'solo', 'NORMAL'); RecordingMatch.instances.length = 0; await expectOk(host, { t: 'room.start' }); await host.waitFor('m.private'); const m = RecordingMatch.instances[0]; const resyncs = () => m.calls.filter((c) => c[0] === 'onReconnect').length; host.clearInbox(); for (let i = 0; i < 12; i++) await host.hello('Host'); for (let i = 0; i < 12; i++) await host.waitFor('room.state'); // the cheap part answers every hello assert.equal(resyncs(), 1, 'first repeated hello resyncs at once, the rest wait for the window'); await host.waitFor('m.private'); await host.waitFor('m.private', () => true, 1500); // one coalesced resync answers the other 11 assert.equal(resyncs(), 2); await delay(1100); assert.equal(resyncs(), 2, 'nothing else was queued'); // a (re)connect is never delayed await host.terminate(); const back = await pool.connect(); await back.hello('Host', host.token); await back.waitFor('m.private'); assert.equal(resyncs(), 3); }); test('match start failure → INTERNAL, room stays in LOBBY', async () => { const host = await pool.player('Host'); await createRoom(host, 'solo', 'NORMAL'); RecordingMatch.failStart = true; RecordingMatch.instances.length = 0; await expectError(host, { t: 'room.start' }, ERR.INTERNAL); const st = await host.waitFor('room.state', (s) => !s.inMatch && RecordingMatch.instances.length === 1); assert.equal(st.inMatch, false); await delay(10); assert.deepEqual(RecordingMatch.instances[0].calls.map((c) => c[0]), ['start', 'dispose']); RecordingMatch.failStart = false; await expectOk(host, { t: 'room.start' }); await host.waitFor('m.public'); }); test('reconnect window expiry during a match → onLeave; old token no longer resumes; empty room disposed', async () => { const host = await pool.player('Host'); const st = await createRoom(host); const guest = await pool.player('Guest'); await joinRoom(guest, st.code); await expectOk(guest, { t: 'room.ready', ready: true }); RecordingMatch.instances.length = 0; await expectOk(host, { t: 'room.start' }); const m = RecordingMatch.instances[0]; await guest.terminate(); await host.waitFor('room.state', (s) => seatOf(s, guest.id)?.connected === false); await delay(WINDOW + 700); assert.deepEqual(m.calls.filter((c) => c[0] === 'onLeave'), [['onLeave', guest.id]]); const back = await pool.connect(); const w = await back.hello('Guest', guest.token); assert.equal(w.resumed, false); assert.notEqual(w.playerId, guest.id); // host drops too and never returns → room disposed after the window await host.terminate(); await delay(WINDOW + 700); assert.ok(m.calls.some((c) => c[0] === 'dispose')); await expectError(back, { t: 'room.join', code: st.code }, ERR.ROOM_NOT_FOUND); }); test('match ending while a human is disconnected gives that human the lobby grace', async () => { const host = await pool.player('Host'); const st = await createRoom(host); const guest = await pool.player('Guest'); await joinRoom(guest, st.code); await expectOk(guest, { t: 'room.ready', ready: true }); await expectOk(host, { t: 'room.start' }); await expectOk(guest, { t: 'g.infoReady' }); await guest.terminate(); await host.waitFor('room.state', (s) => seatOf(s, guest.id)?.connected === false); host.clearInbox(); await expectOk(host, { t: 'g.infoReady' }); // disconnected guest already ready → match ends const lobbyState = await host.waitFor('room.state', (s) => !s.inMatch); assert.equal(seatOf(lobbyState, guest.id)?.connected, false); const freed = await host.waitFor('room.state', (s) => !s.inMatch && s.seats[1] === null, GRACE + 1000); assert.ok(freed); }); test('a dropped solo run keeps the official singleReconnectTime (24 h), not the 10-minute window', () => { assert.equal(srv.lobby.soloResumeWindowMs(), 86_400 * 1000, 'data config.constants.singleReconnectTime'); assert.ok(srv.lobby.soloResumeWindowMs() > WINDOW); }); }); // --------------------------------------------------------------------------------------------------- // Solo runs: 下半 solo has no prep / 机变 timer and may be resumed within singleReconnectTime (24 h, research 01 §1, // 06 §17); only co-op sessions expire after the reconnect window (real Match: the untimed solo match waits). // --------------------------------------------------------------------------------------------------- describe('solo resume window', () => { const WINDOW = 300; // stands in for the 10-minute reconnect window const SOLO_WINDOW = 1500; // stands in for singleReconnectTime (24 h) let srv; let url; const open = []; const cap = captureLog(); before(async () => { srv = await startServer({ port: 0, host: '127.0.0.1', log: cap.log, MatchClass: RealMatch, seedFn: () => 20260929, reconnectWindowMs: WINDOW, soloReconnectWindowMs: SOLO_WINDOW, lobbyGraceMs: 200, }); url = `ws://127.0.0.1:${srv.port}/ws`; }); after(async () => { await Promise.all(open.map((c) => c.terminate().catch(() => {}))); await srv?.close(); assert.deepEqual(cap.errors, [], 'no server errors logged'); }); const connect = async () => { const c = await TestClient.connect(url); open.push(c); return c; }; test('solo: resumable past the reconnect window, the match waits; expires only after the solo window', async () => { const a = await connect(); const w = await a.hello('Solo'); a.id = w.playerId; const st = await createRoom(a, 'solo', 'NORMAL'); await expectOk(a, { t: 'room.start' }); await a.waitFor('m.public', (p) => p.phase === PHASE.INFO_CHECK && p.deadline === 0); const room = srv.lobby.getRoom(st.code); const match = room.match; await a.terminate(); await delay(WINDOW + 600); // several sweeps past the co-op window assert.equal(room.match, match, 'the solo match still runs'); assert.equal(match.players.get(a.id).left, false, 'not treated as a quit'); const b = await connect(); const w2 = await b.hello('Solo', w.token); assert.equal(w2.resumed, true); assert.equal(w2.playerId, a.id); assert.equal((await b.waitFor('room.state')).inMatch, true); await b.waitFor('m.public', (p) => p.phase === PHASE.INFO_CHECK); await b.waitFor('m.private'); // a reconnect re-evaluates the window: the extension is set at every drop, and still ends after the solo window await b.terminate(); await delay(SOLO_WINDOW + 700); assert.equal(match.ended, true, 'expiry after the solo window → onLeave → abandoned'); assert.equal(srv.lobby.getRoom(st.code), null, 'empty room disposed'); const c = await connect(); const w3 = await c.hello('Solo', w.token); assert.equal(w3.resumed, false); assert.notEqual(w3.playerId, a.id); }); test('co-op sessions keep the plain reconnect window; a solo player back in the lobby too', async () => { const a = await connect(); const w = await a.hello('Coop'); a.id = w.playerId; const st = await createRoom(a, 'coop', 'NORMAL'); await expectOk(a, { t: 'room.addBot' }); await expectOk(a, { t: 'room.start' }); await a.waitFor('m.public', (p) => p.phase === PHASE.INFO_CHECK); const match = srv.lobby.getRoom(st.code).match; await a.terminate(); await delay(WINDOW + 600); assert.equal(match.players.get(a.id).left, true, 'co-op: the window expired → onLeave'); const b = await connect(); assert.equal((await b.hello('Coop', w.token)).resumed, false); // solo room without a running match: no extension (the lobby grace frees the seat anyway) const s = await connect(); const ws = await s.hello('Idle'); s.id = ws.playerId; await createRoom(s, 'solo', 'NORMAL'); await s.terminate(); await delay(WINDOW + 600); const s2 = await connect(); assert.equal((await s2.hello('Idle', ws.token)).resumed, false); }); }); // --------------------------------------------------------------------------------------------------- // Result replay: humans who miss m.result (dropped socket, reloaded tab) still get the result screen // --------------------------------------------------------------------------------------------------- /** Stub match that, like the real Match, sends m.result per player (unicast) when `unicast` is set. */ class ReplayMatch extends Match { static unicast = false; static instances = []; constructor(opts) { super(opts); ReplayMatch.instances.push(this); } maybeFinish(deadlinePassed) { if (!ReplayMatch.unicast) { super.maybeFinish(deadlinePassed); return; } if (this.ended || this.disposed) return; if (!deadlinePassed) for (const p of this.players.values()) if (!p.isBot && !p.left && !p.ready) return; if (this.timer) { clearTimeout(this.timer); this.timer = null; } this.ended = true; this.phase = PHASE.RESULT; this.broadcastFn(this.publicView()); for (const p of this.players.values()) { if (!p.isBot && !p.left) this.sendFn(p.playerId, { t: 'm.result', victory: true, roundsPassed: 7, playerId: p.playerId, players: [] }); } this.onEndFn({ victory: true }); } } describe('match result replay', () => { let srv; let pool; const cap = captureLog(); const GRACE = 400; before(async () => { srv = await startServer({ port: 0, host: '127.0.0.1', log: cap.log, MatchClass: ReplayMatch, lobbyGraceMs: GRACE, resyncMinGapMs: 200 }); pool = clientPool(() => `ws://127.0.0.1:${srv.port}/ws`); }); afterEach(async () => { ReplayMatch.unicast = false; await pool.closeAll(); }); after(async () => { await srv?.close(); assert.deepEqual(cap.errors, [], 'no server errors logged'); }); /** Host + guest co-op match; the guest drops, then the host's infoReady ends it. */ async function endWhileGuestAway() { const host = await pool.player('Host'); const st = await createRoom(host); const guest = await pool.player('Guest'); await joinRoom(guest, st.code); await expectOk(guest, { t: 'room.ready', ready: true }); await expectOk(host, { t: 'room.start' }); await expectOk(guest, { t: 'g.infoReady' }); await guest.terminate(); await host.waitFor('room.state', (s) => seatOf(s, guest.id)?.connected === false); await expectOk(host, { t: 'g.infoReady' }); const hostResult = await host.waitFor('m.result'); await host.waitFor('room.state', (s) => !s.inMatch); return { host, guest, st, hostResult }; } /** Frames of a resumed socket in arrival order (types + phase), after welcome. */ const order = (c) => c.log.filter((m) => m.t !== 'welcome' && m.t !== 'pong').map((m) => m.t + (m.t === 'm.public' ? `:${m.phase}` : '')); for (const unicast of [true, false]) { test(`a human disconnected when the match ends gets room.state, the final m.public and m.result on resume (${unicast ? 'per-player' : 'broadcast'} result)`, async () => { ReplayMatch.unicast = unicast; const { guest, hostResult } = await endWhileGuestAway(); if (unicast) assert.notEqual(hostResult.playerId, guest.id); const back = await pool.connect(); const w = await back.hello('Guest', guest.token); assert.equal(w.resumed, true); const res = await back.waitFor('m.result'); if (unicast) assert.equal(res.playerId, guest.id, 'the player gets their own result'); else assert.equal(res.reason, hostResult.reason); assert.deepEqual(order(back), ['room.state', `m.public:${PHASE.RESULT}`, 'm.result']); assert.equal(back.log.find((m) => m.t === 'room.state').inMatch, false); }); } test('reloading on the result screen replays it until the player acts in the room; a new match clears it', async () => { ReplayMatch.unicast = true; const host = await pool.player('Solo'); await createRoom(host, 'solo', 'NORMAL'); await expectOk(host, { t: 'room.start' }); await expectOk(host, { t: 'g.infoReady' }); await host.waitFor('m.result'); await host.waitFor('room.state', (s) => !s.inMatch); // reload #1 (new socket, same token) → result screen again await host.terminate(); const r1 = await pool.connect(); await r1.hello('Solo', host.token); assert.equal((await r1.waitFor('m.result')).playerId, host.id); // a repeated hello (live socket) is a resync too await delay(250); await r1.hello('Solo'); await r1.waitFor('m.result'); // the player moved on (acted in the room) → no more replays await expectOk(r1, { t: 'room.ready', ready: true }); await r1.terminate(); const r2 = await pool.connect(); await r2.hello('Solo', host.token); await r2.waitFor('room.state'); await r2.expectNone('m.result', () => true, 300); await r2.expectNone('m.public', () => true, 50); // a new match replaces any pending replay with the live match state await expectOk(r2, { t: 'room.start' }); await expectOk(r2, { t: 'g.infoReady' }); await r2.waitFor('m.result'); await r2.waitFor('room.state', (s) => !s.inMatch); await expectOk(r2, { t: 'room.start' }); await r2.waitFor('m.public', (p) => p.phase === PHASE.INFO_CHECK); await r2.terminate(); const r3 = await pool.connect(); await r3.hello('Solo', host.token); const pub = await r3.waitFor('m.public'); assert.equal(pub.phase, PHASE.INFO_CHECK); await r3.expectNone('m.result', () => true, 200); }); test('a human removed by the lobby grace after the match still gets the result after room.closed{timeout}', async () => { ReplayMatch.unicast = true; const { host, guest } = await endWhileGuestAway(); await host.waitFor('room.state', (s) => !s.inMatch && !seatOf(s, guest.id), GRACE + 1000); const back = await pool.connect(); await back.hello('Guest', guest.token); assert.equal((await back.waitFor('room.closed')).reason, 'timeout'); assert.equal((await back.waitFor('m.result')).playerId, guest.id); assert.deepEqual(order(back), ['room.closed', `m.public:${PHASE.RESULT}`, 'm.result']); // delivered once await back.terminate(); const again = await pool.connect(); await again.hello('Guest', guest.token); await again.expectNone(null, (m) => m.t !== 'welcome', 300); }); test('real Match: a human who is away when the match finishes gets its m.result on resume', async () => { const real = await startServer({ port: 0, host: '127.0.0.1', log: cap.log, MatchClass: RealMatch, seedFn: () => 1312887319 }); const matches = []; try { const url = `ws://127.0.0.1:${real.port}/ws`; const a = await TestClient.connect(url); const w = await a.hello('A'); a.id = w.playerId; await createRoom(a, 'coop', 'FUNNY'); for (let i = 0; i < 3; i++) await expectOk(a, { t: 'room.addBot' }); await expectOk(a, { t: 'room.start' }); await a.waitFor('m.public', (p) => p.phase === PHASE.INFO_CHECK); const room = real.lobby.getRoom([...real.lobby.rooms.keys()][0]); const m = room.match; matches.push(m); await a.terminate(); await delay(30); m.finish({ victory: true, reason: 'victory' }); assert.equal(room.match, null, 'room back in LOBBY'); const b = await TestClient.connect(url); await b.hello('A', w.token); const res = await b.waitFor('m.result', () => true, 3000); assert.equal(res.playerId, a.id); assert.equal(res.victory, true); const pub = b.log.find((x) => x.t === 'm.public'); assert.equal(pub.phase, PHASE.RESULT); await b.terminate(); } finally { for (const m of matches) m.dispose?.(); await real.close(); } }); }); // --------------------------------------------------------------------------------------------------- // Per-network limits (clients behind a local reverse proxy, e.g. cloudflared, identified by forwarding headers) // --------------------------------------------------------------------------------------------------- describe('per-network limits', () => { let srv; let url; const open = new Set(); const cap = captureLog(); before(async () => { srv = await startServer({ port: 0, host: '127.0.0.1', log: cap.log, MatchClass: Match, maxRoomsPerAddr: 3, maxMatchesPerAddr: 2, maxConnectionsPerAddr: 4, }); url = `ws://127.0.0.1:${srv.port}/ws`; }); afterEach(async () => { await Promise.all([...open].map((c) => c.terminate().catch(() => {}))); open.clear(); await delay(20); }); after(async () => { await srv?.close(); assert.deepEqual(cap.errors, [], 'no server errors logged'); }); /** Connect as a client whose address the local proxy forwards (null = a local peer without proxy). */ async function netPlayer(name, ip) { const c = await TestClient.connect(url, { wsOptions: ip ? { headers: { 'X-Forwarded-For': `10.9.9.9, ${ip}` } } : {} }); open.add(c); const w = await c.hello(name); c.id = w.playerId; c.token = w.token; return c; } /** The orphan loop: create a co-op room with AI, start it, walk away. Returns the room.start reply. */ async function orphan(ip, i) { const c = await netPlayer(`orphan${i}`, ip); const r = await c.request({ t: 'room.create', mode: 'coop', difficulty: 'FUNNY' }); if (r.t !== 'ok') { await c.terminate(); return { create: r }; } await expectOk(c, { t: 'room.addBot' }); const start = await c.request({ t: 'room.start' }); await c.terminate(); return { create: r, start }; } test('a socket loop cannot keep more than maxMatchesPerAddr unattended matches or maxRoomsPerAddr rooms', async () => { const A = '203.0.113.7'; const keeper = await netPlayer('keeper', A); // stays connected in its running match await createRoom(keeper, 'solo', 'FUNNY'); await expectOk(keeper, { t: 'room.start' }); const first = await orphan(A, 1); assert.equal(first.start.t, 'ok'); const second = await orphan(A, 2); assert.equal(second.create.t, 'ok', 'third room from A still allowed'); assert.equal(second.start.t, 'error'); assert.equal(second.start.code, ERR.RATE, 'third concurrent match from A refused'); const third = await orphan(A, 3); assert.equal(third.create.code, ERR.RATE, 'fourth room from A refused'); assert.match(third.create.detail, /rooms/); const h1 = JSON.parse((await httpReq(srv.port, '/healthz')).body.toString()); // other networks and local peers are unaffected const other = await orphan('198.51.100.20', 4); assert.equal(other.start.t, 'ok'); for (let i = 0; i < 4; i++) assert.equal((await orphan(null, 10 + i)).start.t, 'ok', 'local peers are not limited'); const h2 = JSON.parse((await httpReq(srv.port, '/healthz')).body.toString()); assert.equal(h2.matches, h1.matches + 5); // a match from A ends → capacity is back await expectOk(keeper, { t: 'g.leave' }); const again = await netPlayer('again', A); const st = await again.request({ t: 'room.create', mode: 'solo', difficulty: 'FUNNY' }); assert.equal(st.t, 'ok', JSON.stringify(st)); await expectOk(again, { t: 'room.start' }); }); test('creating a room from your only room does not count the room being left', async () => { const B = '203.0.113.99'; const c = await netPlayer('hopper', B); for (let i = 0; i < 6; i++) await expectOk(c, { t: 'room.create', mode: 'coop', difficulty: 'NORMAL' }); }); test('per-network socket cap is enforced at upgrade (429); other networks and local peers are unaffected', async () => { const C = '2001:db8:1:2::10'; const socks = []; for (let i = 0; i < 4; i++) socks.push(await netPlayer(`s${i}`, `2001:db8:1:2::${i + 1}`)); // same /64 await assert.rejects(TestClient.connect(url, { wsOptions: { headers: { 'X-Forwarded-For': C } } }), /429/); const other = await netPlayer('other', '2001:db8:1:3::1'); const local = await netPlayer('local', null); assert.ok(other.isOpen && local.isOpen); await socks[0].close(); await delay(50); const ok = await netPlayer('s4', C); assert.ok(ok.isOpen, 'a slot freed up'); }); }); // --------------------------------------------------------------------------------------------------- // Heartbeat, hello timeout, shutdown // --------------------------------------------------------------------------------------------------- describe('heartbeat and shutdown', () => { test('dead sockets are terminated by the heartbeat; silent sockets hit the hello timeout', async () => { const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true, heartbeatMs: 80, helloTimeoutMs: 150 }); try { const url = `ws://127.0.0.1:${srv.port}/ws`; const deaf = await TestClient.connect(url, { wsOptions: { autoPong: false } }); await deaf.hello('Deaf'); const healthy = await TestClient.connect(url); await healthy.hello('Healthy'); const info = await Promise.race([deaf.closed, delay(2000).then(() => null)]); assert.ok(info, 'deaf socket closed'); assert.equal(info.code, 1006, 'terminated without a close frame'); const silent = await TestClient.connect(url); const silentInfo = await Promise.race([silent.closed, delay(2000).then(() => null)]); assert.equal(silentInfo?.code, 4002); assert.ok(healthy.isOpen, 'healthy socket survives heartbeats'); await healthy.close(); } finally { await srv.close(); } }); test('close() notifies rooms (room.closed shutdown) and closes sockets with 1001', async () => { const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true }); const c = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`); const w = await c.hello('Last'); c.id = w.playerId; await createRoom(c); await srv.close(); const closed = await c.waitFor('room.closed'); assert.equal(closed.reason, 'shutdown'); const info = await c.closed; assert.equal(info.code, 1001); await assert.rejects(httpReq(srv.port, '/healthz')); }); }); // --------------------------------------------------------------------------------------------------- // Units: data loader, name sanitizer, token bucket, registry // --------------------------------------------------------------------------------------------------- describe('match stub (server/match/Match.js)', () => { const STATUSES = ['acting', 'ready', 'deciding', 'combat', 'done', 'helping', 'left', 'dead']; const mk = (over = {}) => { const out = { sent: [], bc: [], ended: [] }; const m = new Match({ roomCode: 'ABCD', mode: 'coop', difficulty: 'HARD', modeId: 'mode_multi_hard', seed: 7, seats: [ { seat: 0, playerId: 'p_a', name: 'A', isBot: false, connected: true }, { seat: 1, playerId: 'p_b', name: 'B', isBot: false, connected: true }, { seat: 3, playerId: 'ai_1', name: 'AI', isBot: true, connected: true }, ], data: { config: { modes: { mode_multi_hard: { lastRound: 14 } }, timers: { infoCheck: 0.08 } } }, send: (id, msg) => { out.sent.push([id, msg]); return true; }, broadcast: (msg) => out.bc.push(msg), onEnd: (summary) => out.ended.push([summary, out.bc.length]), ...over, }); return { m, out }; }; test('m.public / m.private follow the DESIGN §8.2 / §8.3 shapes', () => { const { m, out } = mk(); m.start(); const pub = out.bc[0]; assert.equal(pub.t, 'm.public'); for (const k of ['phase', 'round', 'lastRound', 'deadline', 'serverNow', 'modeId', 'difficulty', 'stageId', 'factions', 'disabledBonds', 'bannedChess', 'bossId', 'players', 'fields']) { assert.ok(k in pub, `m.public.${k}`); } assert.equal(pub.phase, 'INFO_CHECK'); assert.equal(pub.lastRound, 14); assert.ok(pub.deadline > pub.serverNow && pub.deadline - pub.serverNow <= 80); for (const p of pub.players) { for (const k of ['playerId', 'seat', 'name', 'isBot', 'connected', 'alive', 'lp', 'bandId', 'shopLevel', 'boardCount', 'ready', 'bonds', 'fieldId', 'status']) { assert.ok(k in p, `players[].${k}`); } assert.ok(STATUSES.includes(p.status), p.status); assert.ok(Array.isArray(p.bonds)); } assert.equal(out.sent.length, 2, 'one m.private per human, none for bots'); for (const [id, priv] of out.sent) { assert.equal(priv.t, 'm.private'); assert.equal(priv.playerId, id); for (const k of ['seat', 'alive', 'lp', 'funds', 'bandId', 'ready', 'canReady', 'shop', 'hand', 'temp', 'board', 'deployCap', 'deployCount', 'bonds', 'effects', 'nextEnemies', 'stats']) { assert.ok(k in priv, `m.private.${k}`); } for (const k of ['level', 'maxLevel', 'upgradePrice', 'refreshPrice', 'freeRefreshes', 'frozen', 'slots', 'rewardOffer']) assert.ok(k in priv.shop, `shop.${k}`); assert.equal(priv.hand.length, 10); assert.equal(priv.temp.length, 5); assert.ok(Array.isArray(priv.bonds) && Array.isArray(priv.board) && Array.isArray(priv.effects)); } m.dispose(); }); test('lastRound comes from config (fallback 9 for solo FUNNY, else 14)', () => { assert.equal(mk({ data: {}, mode: 'solo', difficulty: 'FUNNY', modeId: 'mode_single_funny' }).m.lastRound, 9); assert.equal(mk({ data: undefined }).m.lastRound, 14); assert.equal(mk({ data: { config: { modes: { mode_multi_hard: { lastRound: 11 } } } } }).m.lastRound, 11); const real = loadData(path.join(ROOT, 'data'), { log: { warn() {}, error() {} } }); if (real.config?.modes) { assert.equal(mk({ data: real, mode: 'solo', difficulty: 'FUNNY', modeId: 'mode_single_funny' }).m.lastRound, 9); assert.equal(mk({ data: real }).m.lastRound, 14); } }); test('INFO_CHECK deadline ends the stub once (m.result before onEnd); ready/leave also end it', async () => { const { m, out } = mk(); m.start(); assert.deepEqual(m.handle('p_a', { t: 'g.infoReady' }), { ok: true }); assert.equal(out.ended.length, 0); await delay(200); assert.equal(out.ended.length, 1); assert.equal(out.ended[0][0].reason, 'timeout'); assert.ok(out.bc.slice(0, out.ended[0][1]).some((x) => x.t === 'm.result'), 'm.result sent before onEnd'); assert.deepEqual(m.handle('p_a', { t: 'g.infoReady' }), { error: ERR.WRONG_PHASE }); m.dispose(); m.dispose(); const b = mk(); b.m.start(); b.m.handle('p_a', { t: 'g.infoReady' }); b.m.onLeave('p_b'); assert.equal(b.out.ended.length, 1); assert.equal(b.out.ended[0][0].reason, 'confirmed'); b.m.dispose(); await delay(150); assert.equal(b.out.ended.length, 1, 'no second onEnd from the cleared deadline timer'); const c = mk(); c.m.start(); c.m.dispose(); await delay(150); assert.equal(c.out.ended.length, 0, 'dispose clears the deadline timer'); assert.deepEqual(c.m.handle('ai_1', { t: 'g.ready', ready: true }), { error: ERR.NOT_IN_ROOM }); assert.throws(() => new Match({ seats: [] }), TypeError); }); }); describe('platform units', () => { test('loadData: reads *.json by basename, deep-freezes, tolerates missing/broken files', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-data-')); try { fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ modes: { a: { rounds: [1, 2] } } })); fs.writeFileSync(path.join(dir, 'chess.json'), JSON.stringify([{ id: 'x', stats: { hp: 1 } }])); fs.writeFileSync(path.join(dir, 'broken.json'), '{oops'); fs.writeFileSync(path.join(dir, 'notes.txt'), 'ignored'); const warnings = []; const errors = []; const data = loadData(dir, { log: { warn: (m) => warnings.push(m), error: (m) => errors.push(m) } }); assert.deepEqual(Object.keys(data).sort(), ['chess', 'config']); assert.equal(data.config.modes.a.rounds[1], 2); assert.ok(Object.isFrozen(data)); assert.ok(Object.isFrozen(data.config.modes.a.rounds)); assert.ok(Object.isFrozen(data.chess[0].stats)); assert.throws(() => { data.config.modes.a.rounds.push(3); }, TypeError); assert.throws(() => { data.chess[0].stats.hp = 9; }, TypeError); assert.equal(errors.length, 1); assert.match(errors[0], /broken\.json/); assert.ok(warnings.some((w) => /missing data files/.test(w) && /bonds\.json/.test(w))); const none = loadData(path.join(dir, 'does-not-exist'), { log: { warn() {}, error() {} } }); assert.deepEqual(Object.keys(none), []); assert.ok(Object.isFrozen(none)); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test('data index getters: own-property lookups only, null for unknown ids / missing files', () => { const data = loadData('/nonexistent-dir', { log: { warn() {}, error() {} } }); assert.equal(getChess('chess_char_1_01_a', data), null, 'missing file'); const fake = Object.freeze({ chess: { x_a: { chessId: 'x_a' }, n: 5 }, bonds: [{ bondId: 'arr' }], config: { modes: { mode_multi_hard: { lastRound: 14 } } }, }); assert.deepEqual(getChess('x_a', fake), { chessId: 'x_a' }); assert.equal(getChess('n', fake), null, 'non-object record'); for (const bad of ['constructor', '__proto__', 'toString', 'hasOwnProperty', '', null, undefined, 5, {}]) { assert.equal(getChess(bad, fake), null, String(bad)); assert.equal(getMode(bad, fake), null, String(bad)); } assert.equal(getBond('0', fake), null, 'arrays are not id maps'); assert.equal(lookup('constructor', 'x', fake), null); assert.equal(lookup('chess', 'x_a', null), null); assert.equal(getMode('mode_multi_hard', fake).lastRound, 14); assert.equal(getConfig({ config: [] }), null); for (const fn of Object.values(INDEXED_FILES)) assert.equal(typeof dataModule[fn], 'function', fn); // real generated data (when present): every record of every id-keyed file resolves to itself const real = loadData(path.join(ROOT, 'data'), { log: { warn() {}, error() {} } }); let checked = 0; for (const [file, fn] of Object.entries(INDEXED_FILES)) { if (!real[file]) continue; for (const [id, rec] of Object.entries(real[file])) { assert.equal(dataModule[fn](id, real), rec, `${file}/${id}`); checked++; } } if (real.bands) assert.equal(getBand('band_bldsk', real)?.name, '华法琳'); if (real.config) assert.equal(getMode('mode_single_funny', real)?.lastRound, 9); assert.ok(checked === 0 || checked > 500, `checked ${checked}`); }); test('sanitizeName strips control / invisible characters and caps length', () => { assert.equal(sanitizeName(' Amiya '), 'Amiya'); assert.equal(sanitizeName('a\tb\nc'), 'a b c'); assert.equal(sanitizeName(`x${String.fromCharCode(0x202e)}y${String.fromCharCode(0x200b)}z`), 'xyz'); assert.equal(sanitizeName(String.fromCharCode(0xd800)), null); assert.equal(sanitizeName(''), null); assert.equal(sanitizeName(42), null); assert.equal([...sanitizeName('😀'.repeat(20))].length, 12); }); test('TokenBucket refills continuously up to burst', () => { const b = new TokenBucket(40, 40, 0); let ok = 0; for (let i = 0; i < 100; i++) if (b.take(0)) ok++; assert.equal(ok, 40); assert.equal(b.take(10), false); // 10 ms → 0.4 token assert.equal(b.take(30), true); // 30 ms → 1.2 tokens assert.equal(b.take(100000), true); let burst = 0; for (let i = 0; i < 100; i++) if (b.take(100000)) burst++; assert.equal(burst, 39); }); test('clientAddress: proxy headers only from local peers, IPv6 grouped by /64, local peers unlimited', () => { const req = (remoteAddress, headers = {}) => ({ socket: { remoteAddress }, headers }); assert.equal(normalizeIp('::ffff:127.0.0.1'), '127.0.0.1'); assert.equal(normalizeIp('::ffff:0102:0304'), '1.2.3.4'); assert.equal(normalizeIp('[FE80::1%en0]'), 'fe80::1'); assert.equal(normalizeIp('not-an-ip'), ''); assert.equal(normalizeIp('203.0.113.4:5678'), '203.0.113.4'); assert.equal(normalizeIp('[2001:db8::1]:443'), '2001:db8::1'); for (const ip of ['127.0.0.1', '::1', '10.0.0.8', '172.16.4.4', '192.168.65.1', '100.100.1.1', 'fd12::1', 'fe80::2']) assert.equal(isLocalIp(ip), true, ip); for (const ip of ['8.8.8.8', '172.32.0.1', '2001:db8::1']) assert.equal(isLocalIp(ip), false, ip); assert.equal(limitKeyOf('2001:db8:1:2:3:4:5:6'), '2001:db8:1:2::/64'); assert.equal(limitKeyOf('2001:db8:1:2::9'), '2001:db8:1:2::/64'); assert.equal(limitKeyOf('203.0.113.1'), '203.0.113.1'); // local reverse proxy (cloudflared): CF-Connecting-IP > X-Real-IP > rightmost X-Forwarded-For assert.deepEqual(clientAddress(req('::ffff:127.0.0.1', { 'x-forwarded-for': '6.6.6.6, 203.0.113.7' })), { ip: '203.0.113.7', key: '203.0.113.7' }); assert.deepEqual(clientAddress(req('127.0.0.1', { 'cf-connecting-ip': '203.0.113.8', 'x-forwarded-for': '6.6.6.6' })), { ip: '203.0.113.8', key: '203.0.113.8' }); assert.deepEqual(clientAddress(req('127.0.0.1', { 'x-real-ip': '2001:db8:a:b::5' })), { ip: '2001:db8:a:b::5', key: '2001:db8:a:b::/64' }); // local peers without a proxy header and LAN clients forwarded by a LAN proxy are not limited assert.deepEqual(clientAddress(req('127.0.0.1')), { ip: '127.0.0.1', key: null }); assert.deepEqual(clientAddress(req('192.168.1.20', { 'x-forwarded-for': '192.168.1.21' })), { ip: '192.168.1.21', key: null }); // internet peers: headers are client-controlled → ignored unless trustProxy is true assert.deepEqual(clientAddress(req('198.51.100.3', { 'x-forwarded-for': '203.0.113.7' })), { ip: '198.51.100.3', key: '198.51.100.3' }); assert.deepEqual(clientAddress(req('198.51.100.3', { 'x-forwarded-for': '203.0.113.7' }), true), { ip: '203.0.113.7', key: '203.0.113.7' }); assert.deepEqual(clientAddress(req('127.0.0.1', { 'x-forwarded-for': '203.0.113.7' }), false), { ip: '127.0.0.1', key: null }); assert.deepEqual(clientAddress(req('127.0.0.1', { 'x-forwarded-for': 'garbage' })), { ip: '127.0.0.1', key: null }); assert.deepEqual(clientAddress(undefined), { ip: '?', key: null }); assert.equal(parseTrustProxy('1'), true); assert.equal(parseTrustProxy('off'), false); assert.equal(parseTrustProxy(undefined), 'auto'); assert.equal(parseTrustProxy('auto'), 'auto'); }); test('SessionRegistry: token lookup, expiry sweep, eviction', () => { let now = 1000; const reg = new SessionRegistry({ reconnectWindowMs: 100, maxSessions: 2, now: () => now }); const a = reg.create('A'); const b = reg.create('B'); assert.equal(reg.byToken(a.token), a); assert.equal(reg.byToken('nope'), null); assert.equal(reg.byToken(undefined), null); a.connected = true; a.disconnectedAt = null; b.connected = false; b.disconnectedAt = now; b.roomCode = 'ABCD'; assert.equal(reg.create('C'), null, 'full of active/roomed sessions'); b.roomCode = null; const c = reg.create('C'); assert.ok(c, 'evicted the idle session'); assert.equal(reg.byId(b.playerId), null); c.connected = false; c.disconnectedAt = now; now += 101; assert.equal(reg.byToken(c.token), null, 'expired token no longer resumes'); const swept = reg.sweep(); assert.deepEqual(swept, [c]); assert.equal(reg.size, 1); }); test('SessionRegistry: a session may extend (never shorten) its own reconnect window', () => { let now = 1000; const reg = new SessionRegistry({ reconnectWindowMs: 100, now: () => now }); const solo = reg.create('Solo'); const coop = reg.create('Coop'); const short = reg.create('Short'); solo.resumeWindowMs = 5000; short.resumeWindowMs = 10; for (const s of [solo, coop, short]) { s.connected = false; s.disconnectedAt = now; } assert.equal(reg.windowOf(solo), 5000); assert.equal(reg.windowOf(coop), 100); assert.equal(reg.windowOf(short), 100, 'a shorter override is ignored'); now += 101; assert.equal(reg.byToken(solo.token), solo, 'extended session still resumes'); assert.equal(reg.byToken(short.token), null); assert.deepEqual(reg.sweep().map((s) => s.name).sort(), ['Coop', 'Short']); now += 5000; assert.deepEqual(reg.sweep(), [solo]); assert.equal(reg.size, 0); }); });