// test/lobby-loadout.test.js — room.loadout end to end over WebSocket (DESIGN §16): strict validation, storage on the // session (follows the player into rooms, survives a resume) and on the seat, seats[].loadout handed to the Match // (bots: none), re-sendable until the match leaves INFO_CHECK (real Match: accepted in the briefing, WRONG_PHASE after), // the heavy-intent rate limit. import { describe, test, before, after, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import { startServer } from '../server/index.js'; import { StubMatch } from '../server/match/StubMatch.js'; import { getData } from '../server/data.js'; import { TestClient } from './helpers/wsClient.js'; import { ERR, PHASE } from '../shared/constants.js'; const DATA = getData({ log: { warn() {}, error() {}, info() {} } }); const C = (id) => DATA.chess[id]; const INSIDE = 'chess_char_1_01_a'; // 隐现: S1 / S2 (default S2), module MAR-X const SWIRE = 'chess_char_3_04_a'; // 琳琅诗怀雅: three modules const SWIRE_ALT = C(C(SWIRE).goldenId).modules.find((m) => !m.isDefault).uniEquipId; /** Stub match that records its constructor options (the seats) — it has no setLoadout. */ class RecordingStub extends StubMatch { static instances = []; constructor(opts) { super(opts); this.opts = opts; RecordingStub.instances.push(this); } } function clientPool(getUrl) { const open = new Set(); return { async player(name, token) { const c = await TestClient.connect(getUrl()); open.add(c); const w = await c.hello(name, token); c.id = w.playerId; c.token = w.token; return c; }, async closeAll() { await Promise.all([...open].map((c) => c.terminate().catch(() => {}))); open.clear(); }, }; } const quietLog = () => { const errors = []; return { errors, log: { info() {}, warn() {}, debug() {}, error: (...a) => errors.push(a.map(String).join(' ')) } }; }; const ok = async (c, msg) => { const r = await c.request(msg); assert.equal(r.t, 'ok', `${msg.t}: ${JSON.stringify(r)}`); return r; }; const err = async (c, msg, code) => { const r = await c.request(msg); assert.equal(r.t, 'error', JSON.stringify(r)); assert.equal(r.code, code, JSON.stringify(r)); return r; }; async function createRoom(c, mode = 'coop', difficulty = 'NORMAL') { await ok(c, { t: 'room.create', mode, difficulty }); return c.waitFor('room.state', (s) => s.hostId === c.id && s.mode === mode); } describe('room.loadout (lobby, stub match)', () => { let srv; let pool; const cap = quietLog(); before(async () => { srv = await startServer({ port: 0, host: '127.0.0.1', log: cap.log, MatchClass: RecordingStub, heavyBurst: 6, heavyPerSec: 2 }); pool = clientPool(() => `ws://127.0.0.1:${srv.port}/ws`); }); afterEach(async () => { RecordingStub.instances = []; await pool.closeAll(); }); after(async () => { await srv?.close(); }); test('strict validation: structure (BAD_MSG) and data (BAD_TARGET); a refusal keeps the stored loadout', async () => { const a = await pool.player('A'); await err(a, { t: 'room.loadout', entries: [] }, ERR.BAD_MSG); await err(a, { t: 'room.loadout', entries: { [INSIDE]: { skill: 0, extra: true } } }, ERR.BAD_MSG); await err(a, { t: 'room.loadout', entries: { [INSIDE]: { skill: 2 } } }, ERR.BAD_TARGET); // no S3 await err(a, { t: 'room.loadout', entries: { [C(INSIDE).goldenId]: { skill: 0 } } }, ERR.BAD_TARGET); // elite id await err(a, { t: 'room.loadout', entries: { chess_char_9_99_a: { skill: 0 } } }, ERR.BAD_TARGET); await err(a, { t: 'room.loadout', entries: { [INSIDE]: { module: SWIRE_ALT } } }, ERR.BAD_TARGET); // another character's module // outside a room: accepted, kept on the session await ok(a, { t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } } }); await err(a, { t: 'room.loadout', entries: { [INSIDE]: { skill: 7 } } }, ERR.BAD_TARGET); const st = await createRoom(a, 'solo'); await ok(a, { t: 'room.start' }); await a.waitFor('m.public', (p) => p.phase === 'INFO_CHECK'); const m = RecordingStub.instances.at(-1); assert.equal(m.opts.roomCode, st.code); assert.deepEqual(m.opts.seats[0].loadout, { [INSIDE]: { skill: 0, module: 'uniequip_002_inside' } }, 'the last valid loadout'); assert.deepEqual(cap.errors, []); }); test('stored per session and seat: follows into rooms, latest wins until start, bots get none, resume keeps it', async () => { const host = await pool.player('Host'); await ok(host, { t: 'room.loadout', entries: { [SWIRE]: { module: SWIRE_ALT } } }); const st = await createRoom(host); const guest = await pool.player('Guest'); await ok(guest, { t: 'room.join', code: st.code }); await guest.waitFor('room.state', (s) => s.seats.some((x) => x && x.playerId === guest.id)); await ok(guest, { t: 'room.loadout', entries: { [INSIDE]: { skill: 0, module: 'none' } } }); await ok(guest, { t: 'room.loadout', entries: { [INSIDE]: { module: 'none' } } }); // re-sent: the latest wins await ok(host, { t: 'room.addBot' }); // the guest drops and resumes: the session keeps its loadout await guest.terminate(); const back = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`); await back.hello('Guest', guest.token); await ok(back, { t: 'room.ready', ready: true }); await ok(host, { t: 'room.start' }); await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK'); const seats = RecordingStub.instances.at(-1).opts.seats; const by = (id) => seats.find((s) => s.playerId === id); assert.deepEqual(by(host.id).loadout, { [SWIRE]: { skill: C(SWIRE).skill.index, module: SWIRE_ALT } }); assert.deepEqual(by(guest.id).loadout, { [INSIDE]: { skill: 1, module: 'none' } }); const bot = seats.find((s) => s.isBot); assert.equal(bot.loadout, null, 'bots fight with the defaults'); assert.ok(Object.isFrozen(by(host.id).loadout)); // during a match without setLoadout: refused (ROOM_STARTED) but stored for the next match await err(host, { t: 'room.loadout', entries: {} }, ERR.ROOM_STARTED); await back.terminate(); }); test('heavy-intent limit: a burst of room.loadout beyond the bucket is rate-limited', async () => { const a = await pool.player('Spam'); const replies = await Promise.all(Array.from({ length: 10 }, () => a.request({ t: 'room.loadout', entries: {} }))); const codes = replies.map((r) => (r.t === 'ok' ? 'ok' : r.code)); assert.ok(codes.includes('ok')); assert.ok(codes.includes(ERR.RATE), codes.join(',')); }); }); describe('room.loadout (lobby + real match)', () => { let srv; let pool; const cap = quietLog(); before(async () => { srv = await startServer({ port: 0, host: '127.0.0.1', log: cap.log, seedFn: () => 4242 }); pool = clientPool(() => `ws://127.0.0.1:${srv.port}/ws`); }); afterEach(async () => { await pool.closeAll(); }); after(async () => { await srv?.close(); }); test('accepted during INFO_CHECK (the briefing entry), locked afterwards; the solo briefing has no deadline', async () => { const a = await pool.player('Solo'); await createRoom(a, 'solo'); await ok(a, { t: 'room.start' }); const pub = await a.waitFor('m.public', (p) => p.phase === PHASE.INFO_CHECK, 5000); assert.equal(pub.deadline, 0, 'solo INFO_CHECK is untimed'); await ok(a, { t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } } }); const priv = await a.waitFor('m.private', (p) => p.loadout && p.loadout[INSIDE], 3000); assert.deepEqual(priv.loadout, { [INSIDE]: { skill: 0, module: 'uniequip_002_inside' } }); await ok(a, { t: 'g.infoReady' }); await a.waitFor('m.public', (p) => p.phase === PHASE.BAND_DRAFT, 3000); const r = await err(a, { t: 'room.loadout', entries: {} }, ERR.WRONG_PHASE); assert.match(r.detail || '', /locked/); assert.deepEqual(cap.errors, []); }); });