The owner's decisions of 2026-10-05 (picks, slots, shop per player, bans); research 0.2.0 §2. Golden unchanged (no scenario slots a DIY pick). Tests: test/match/diy-shop.test.js (9), test/lobby-diy.test.js (4).
157 lines
7.9 KiB
JavaScript
157 lines
7.9 KiB
JavaScript
// test/lobby-diy.test.js — room.diy end to end over WebSocket (0.2.0 自选编队): lenient validation (only malformed picks
|
|
// are refused; illegal ones are dropped), storage on the session (follows the player into rooms, survives a resume) and
|
|
// on the seat, seats[].diy handed to the Match when it starts (bots: none), never to a running match (ROOM_STARTED:
|
|
// stored for the next one — the next match takes it), every `welcome` carrying `diyKitted`, and the real match exposing
|
|
// the picks as m.private.diy.
|
|
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 { KITTED_CHARS } from '../server/sim/content/kits/index.js';
|
|
import { TestClient } from './helpers/wsClient.js';
|
|
import { ERR, PHASE } from '../shared/constants.js';
|
|
|
|
const T5A = 'chess_char_5_diy1_a';
|
|
const T6A = 'chess_char_6_diy1_a';
|
|
const T6B = 'chess_char_6_diy2_a';
|
|
const SIEGE = { charId: 'char_112_siege', skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
|
|
const SHARP = { charId: 'char_609_acguad' };
|
|
const SHARP_FULL = { charId: 'char_609_acguad', skillIndex: 2, uniEquipId: 'uniequip_002_acguad' };
|
|
|
|
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;
|
|
c.welcome = w;
|
|
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.diy (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: 12, heavyPerSec: 4 });
|
|
pool = clientPool(() => `ws://127.0.0.1:${srv.port}/ws`);
|
|
});
|
|
afterEach(async () => { RecordingStub.instances = []; await pool.closeAll(); });
|
|
after(async () => { await srv?.close(); });
|
|
|
|
test('welcome lists the operators a 自选 slot may field (the kit registry)', async () => {
|
|
const c = await pool.player('Kit');
|
|
assert.deepEqual(c.welcome.diyKitted, [...KITTED_CHARS]);
|
|
assert.ok(c.welcome.diyKitted.includes(SIEGE.charId));
|
|
});
|
|
|
|
test('lenient: malformed picks are BAD_MSG; illegal ones dropped; stored per session and seat; bots none; the next match takes a change', async () => {
|
|
const host = await pool.player('Host');
|
|
await err(host, { t: 'room.diy', picks: [SIEGE] }, ERR.BAD_MSG);
|
|
await err(host, { t: 'room.diy', picks: { [T5A]: { skillIndex: 1 } } }, ERR.BAD_MSG);
|
|
// outside a room: accepted (a prototype off its locked skill, a second slot of the owned operator, junk dropped)
|
|
await ok(host, { t: 'room.diy', picks: { [T5A]: SIEGE, [T6A]: { charId: 'char_609_acguad', skillIndex: 0 }, [T6B]: { charId: SIEGE.charId, skillIndex: 1 }, nope: SHARP } });
|
|
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.diy', picks: { [T6A]: SHARP } });
|
|
await ok(guest, { t: 'room.diy', picks: { [T6A]: SHARP, [T5A]: SHARP } }); // re-sent: the latest wins
|
|
await ok(host, { t: 'room.addBot' });
|
|
// the guest drops and resumes: the session keeps its picks
|
|
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');
|
|
let seats = RecordingStub.instances.at(-1).opts.seats;
|
|
const by = (id) => seats.find((s) => s.playerId === id);
|
|
assert.deepEqual(by(host.id).diy, { [T5A]: SIEGE });
|
|
assert.ok(Object.isFrozen(by(host.id).diy) && Object.isFrozen(by(host.id).diy[T5A]));
|
|
assert.deepEqual(by(guest.id).diy, { [T5A]: SHARP_FULL, [T6A]: SHARP_FULL }, 'a prototype may fill a tier-5 and a tier-6 slot');
|
|
assert.equal(seats.find((s) => s.isBot).diy, null, 'bots field no 自选 piece');
|
|
// during the match: refused for this match (ROOM_STARTED) but stored for the next one
|
|
const r = await err(host, { t: 'room.diy', picks: { [T6B]: SIEGE } }, ERR.ROOM_STARTED);
|
|
assert.match(r.detail || '', /next match/);
|
|
// the stub ends once every human confirmed the briefing; the room is back in its lobby — the next match takes it
|
|
await ok(host, { t: 'g.infoReady' });
|
|
await ok(back, { t: 'g.infoReady' });
|
|
await host.waitFor('room.state', (s) => !s.inMatch, 5000);
|
|
await ok(back, { t: 'room.ready', ready: true });
|
|
await ok(host, { t: 'room.start' });
|
|
await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
|
assert.equal(RecordingStub.instances.length, 2);
|
|
seats = RecordingStub.instances.at(-1).opts.seats;
|
|
assert.deepEqual(by(host.id).diy, { [T6B]: SIEGE });
|
|
await back.terminate();
|
|
assert.deepEqual(cap.errors, []);
|
|
});
|
|
|
|
test('a spectator\'s picks stay on its session (never on a seat, never handed to the match)', async () => {
|
|
const host = await pool.player('Host2');
|
|
const st = await createRoom(host);
|
|
const spec = await pool.player('Watcher');
|
|
await ok(spec, { t: 'room.spectate', code: st.code });
|
|
await spec.waitFor('room.state', (s) => (s.spectators || []).some((x) => x.playerId === spec.id));
|
|
await ok(spec, { t: 'room.diy', picks: { [T5A]: SIEGE } });
|
|
await ok(host, { t: 'room.start' });
|
|
await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
|
const seats = RecordingStub.instances.at(-1).opts.seats;
|
|
assert.ok(!seats.some((s) => s.playerId === spec.id));
|
|
assert.equal(seats[0].diy, null);
|
|
});
|
|
});
|
|
|
|
describe('room.diy (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('the picks the seat had at the start are the match\'s m.private.diy; a change during the briefing waits for the next match', async () => {
|
|
const a = await pool.player('Solo');
|
|
await ok(a, { t: 'room.diy', picks: { [T5A]: SIEGE, [T6A]: SHARP } });
|
|
await createRoom(a, 'solo');
|
|
await ok(a, { t: 'room.start' });
|
|
await a.waitFor('m.public', (p) => p.phase === PHASE.INFO_CHECK, 5000);
|
|
const priv = await a.waitFor('m.private', (p) => p.diy && typeof p.diy === 'object', 3000);
|
|
assert.deepEqual(priv.diy, { [T5A]: SIEGE, [T6A]: SHARP_FULL });
|
|
assert.deepEqual(priv.diyBanned, []);
|
|
await err(a, { t: 'room.diy', picks: {} }, ERR.ROOM_STARTED);
|
|
await ok(a, { t: 'g.infoReady' });
|
|
await a.waitFor('m.public', (p) => p.phase === PHASE.BAND_DRAFT, 3000);
|
|
assert.deepEqual(cap.errors, []);
|
|
});
|
|
});
|