卫戍协议:盟约 web remake — initial import

Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+314
View File
@@ -0,0 +1,314 @@
// test/helpers/battleHarness.js — convenience harness for sim-core and content tests (see docs/SIM.md §Testing).
//
// import { makeBattle, flatStage, chessRec, enemyRec } from '../helpers/battleHarness.js';
// const h = makeBattle({ units: [{ chessId: 'chess_char_1_01_a', row: 10, col: 5 }],
// enemies: [{ key: 'enemy_1422_lrsldr', time: 0, route: 0 }], seed: 7 });
// h.runUntil(() => h.enemies().length === 0, 60);
// assert.equal(h.result().reason, 'cleared');
//
// makeBattle(opts):
// stageId real stage id (data/stages.json) or 'flat' (default: synthetic open lane stage, see flatStage())
// stage explicit stage object (overrides stageId)
// kind 'normal' | 'unite' | 'boss' | 'hidden' (default 'normal')
// seed uint32 (default 1)
// units [{ chessId, row, col, items?, uid?, carryState?, abs? } | { kind:'token', tokenId, row, col }]
// board coordinates (rows 9–12, cols 2–10) for player 'p1'
// players full PlayerBattleInput[] (overrides `units`)
// enemies [{ key, time=0, route=0 | RouteSpec, pos?, count?, interval?, mods?, tag?, bounty?, sourcePlayerId? }]
// waveTemplate wave id (data/waves.json) or template object → routes + spawns + timeLimit
// routes RouteSpec[] (default: template routes, or flat-stage routes 0..3 = walk low, walk high, fly low, fly high)
// timeLimit seconds (default: template maxPlayTime, else 60; boss/hidden: Infinity)
// content 'full' | 'generic' | 'none' (default 'full')
// kits { [baseChessId]: (bb, chess, def) => Kit } injected kits (take precedence over kits/tier*.js)
// extraContent [{ install(battle) }] extra content modules installed after the domain modules (content tests)
// defs { chess: {id: record}, enemies: {key: record}, tokens: {id: record} } extra/override data records
// flags, sharedBoss, modeId, round, setup(battle), recordEvents (default true)
// autoFinish end when all enemies are dead (default: true when any spawn is scheduled, else false so a
// battle without enemies keeps running until its time limit)
// hooks list of hook names to capture (default: all DESIGN events)
// Returned harness: { battle, b, step(n), run(seconds), runUntil(pred|seconds, maxSeconds), runToEnd(maxSeconds),
// unit(idOrChessIdOrUid), allies(), enemies(), enemy(key), spawn(key, opts), events, eventsOf(kind),
// hooks, hooksOf(name), invariants(), result(), snapshot() }
import { Battle } from '../../server/sim/Battle.js';
import { DataSource, getDefaultSource, spawnsFromTemplate } from '../../server/sim/simdata.js';
import { TICK } from '../../server/sim/constants.js';
export const ALL_HOOKS = Object.freeze([
'battleStart', 'deploy', 'tick', 'beforeAttack', 'attack', 'hit', 'damaged', 'heal', 'kill', 'death',
'skillStart', 'skillEnd', 'ammoUsed', 'spGain', 'statusApplied', 'blocked', 'enemySpawn', 'enemyLeak', 'battleEnd',
'fatal', 'layerGain', 'elementBurst', 'dodge',
]);
const NOISY = new Set(['tick', 'spGain', 'hit', 'damaged', 'beforeAttack', 'attack', 'heal']);
/**
* Synthetic 19×21 stage: open lanes on the normal field (rows 9–12) and the boss field (rows 1–5).
* Normal: gates S (9,10) & (12,10), goal E (9,2); partner half mirrors with gates at col 18.
* `opts.crates` = [[r,c]…] adds crate devices; `opts.rows` overrides specific rows ({ [row]: string }).
*/
export function flatStage(opts = {}) {
const rows = [];
for (let r = 0; r < 19; r++) rows.push('#'.repeat(21));
rows[0] = 'aaaaaaaaaa#aaaaaaaaaa';
rows[1] = '###IAAAAA###AAAAAI###';
rows[2] = '##ErrrrrrrOrrrrrrrE##';
rows[3] = '##hrrrrrrrfrrrrrrrh##';
rows[4] = '##hrrrrrrrfrrrrrrrh##';
rows[5] = '##hrrrrrrrOrrrrrrrh##';
rows[6] = 'X'.repeat(21);
rows[7] = 'aaaaaaaaaa###########';
rows[8] = '####AAAAA##I#########';
rows[9] = '##ErrrrrrrSrrrrrrrS##';
rows[10] = '##hrrrrrrrfrrrrrrrf##';
rows[11] = '##hrrrrrrrfrrrrrrrf##';
rows[12] = '##hrrrrrrrSrrrrrrrS##';
rows[13] = 'X'.repeat(21);
for (const [k, v] of Object.entries(opts.rows || {})) rows[+k] = v;
const devices = (opts.crates || []).map(([r, c], i) => ({ key: 'trap_1105_accrate', name: '阻隔工事', pos: [r, c], hidden: false, role: 'crate', active: true, alias: `crate#${i}` }));
return { id: opts.id ?? 'flat', name: 'flat', rows, devices };
}
/** Default routes for the flat stage (and any stage with the standard gates). */
export function flatRoutes(kind = 'normal') {
if (kind === 'boss' || kind === 'hidden') {
return [
{ motion: 'WALK', start: [2, 10], end: [2, 2], checkpoints: [] },
{ motion: 'WALK', start: [5, 10], end: [2, 2], checkpoints: [] },
{ motion: 'FLY', start: [2, 10], end: [2, 2], checkpoints: [] },
{ motion: 'WALK', start: [2, 10], end: [2, 18], checkpoints: [] },
{ motion: 'WALK', start: [5, 10], end: [2, 18], checkpoints: [] },
];
}
if (kind === 'unite') {
return [
{ motion: 'WALK', start: [9, 18], end: [9, 2], checkpoints: [] },
{ motion: 'WALK', start: [12, 18], end: [9, 2], checkpoints: [] },
{ motion: 'FLY', start: [9, 18], end: [9, 2], checkpoints: [] },
{ motion: 'WALK', start: [9, 10], end: [9, 2], checkpoints: [] },
];
}
return [
{ motion: 'WALK', start: [9, 10], end: [9, 2], checkpoints: [] },
{ motion: 'WALK', start: [12, 10], end: [9, 2], checkpoints: [] },
{ motion: 'FLY', start: [9, 10], end: [9, 2], checkpoints: [] },
{ motion: 'FLY', start: [12, 10], end: [9, 2], checkpoints: [] },
];
}
/**
* Build a chess record in data/chess.json shape (for synthetic test units).
* Minimal: chessRec({ id: 'test_guard', profession: 'WARRIOR', stats: { atk: 500 } })
*/
export function chessRec(o = {}) {
const id = o.id ?? o.chessId ?? 'test_chess_a';
const stats = {
maxHp: 2000, atk: 500, def: 200, res: 0, cost: 10, blockCnt: 2, bat: 1, aspd: 100, respawnTime: 20,
spRecovery: 1, hpRecoveryPerSec: 0, moveSpeed: 1, tauntLevel: 0, massLevel: 0, ...(o.stats || {}),
};
const rec = {
chessId: id, baseId: o.baseId ?? id, isGolden: !!o.golden, tier: o.tier ?? 1, name: o.name ?? id, charId: o.charId ?? id,
profession: o.profession ?? 'WARRIOR', subProfessionId: o.subProfessionId ?? o.subProf ?? null,
position: o.position ?? (['SNIPER', 'CASTER', 'MEDIC', 'SUPPORT'].includes(o.profession) ? 'RANGED' : 'MELEE'),
bonds: o.bonds ?? [], stats, rangeGrid: o.rangeGrid ?? [[0, 0], [0, 1]],
dmgType: o.dmgType ?? null, attackKind: o.attackKind ?? null, projectile: o.projectile ?? null,
canHitFly: o.canHitFly ?? null, targetPriority: o.targetPriority ?? null,
trait: { desc: o.trait ?? '', bb: {} }, talents: o.talents ?? [], tokens: o.tokens ?? [],
immunities: o.immunities ?? {},
skill: o.skill === null ? null : {
skillId: 'sk_test', name: 'test skill', skillType: 'MANUAL', durationType: 'NONE', duration: 10,
spType: 'INCREASE_WITH_TIME', spCost: 10, initSp: 0, maxChargeTime: 1, bb: {}, trigger: { rule: 'DEFAULT' }, index: 1,
...(o.skill || {}),
},
};
return rec;
}
/** Build an enemy record in data/enemies.json shape. */
export function enemyRec(o = {}) {
const key = o.key ?? 'enemy_test';
return {
key, name: o.name ?? key, rank: o.rank ?? 'NORMAL', applyWay: o.applyWay ?? ((o.range ?? 0) > 0 ? 'RANGED' : 'MELEE'),
stats: {
maxHp: o.hp ?? 5000, atk: o.atk ?? 0, def: o.def ?? 0, res: o.res ?? 0, moveSpeed: o.speed ?? 1, bat: o.bat ?? 2,
aspd: o.aspd ?? 100, rangeRadius: o.range ?? 0, blockCnt: o.blockCnt ?? 1, massLevel: o.mass ?? 1, lpr: o.lpr ?? 1,
hpRecoveryPerSec: 0, elementRes: 0, elementDmgRes: 0, dmgType: o.dmgType ?? 'phys', motion: o.motion ?? 'WALK',
immunities: o.immunities ?? {}, otherImmunities: o.otherImmunities ?? [], tauntLevel: o.taunt ?? 0,
},
notCountInTotal: !!o.notCountInTotal, tags: [], abilities: [], talents: { bb: {} }, skills: [],
};
}
function buildData(defs) {
if (!defs) return getDefaultSource();
return new DataSource({ chess: defs.chess ?? {}, enemies: defs.enemies ?? {}, tokens: defs.tokens ?? {}, stages: defs.stages ?? {}, waves: defs.waves ?? {} }, getDefaultSource());
}
/** Create a battle + harness. See header for options. */
export function makeBattle(opts = {}) {
const kind = opts.kind ?? 'normal';
const data = opts.data ?? buildData(opts.defs);
let stage = opts.stage ?? null;
if (!stage) stage = !opts.stageId || opts.stageId === 'flat' ? flatStage(opts.flat || {}) : data.getStage(opts.stageId);
if (!stage) throw new Error(`unknown stage ${opts.stageId}`);
let routes = opts.routes ?? null;
let spawns = [];
let timeLimit = opts.timeLimit;
if (opts.waveTemplate) {
const tpl = typeof opts.waveTemplate === 'string' ? data.getWave(opts.waveTemplate) : opts.waveTemplate;
if (!tpl) throw new Error(`unknown wave template ${opts.waveTemplate}`);
const conv = spawnsFromTemplate(tpl, { mods: opts.mods ?? null });
routes = routes ?? conv.routes;
spawns = conv.spawns;
if (timeLimit === undefined && conv.maxPlayTime && kind !== 'boss' && kind !== 'hidden') timeLimit = conv.maxPlayTime;
}
routes = routes ?? flatRoutes(kind);
const extraRoutes = [];
for (const e of opts.enemies ?? []) {
let routeIndex = 0;
let route = null;
if (typeof e.route === 'number') routeIndex = e.route;
else if (e.route && typeof e.route === 'object') route = e.route;
else if (e.routeIndex != null) routeIndex = e.routeIndex;
spawns.push({
time: e.time ?? 0, enemyKey: e.key, routeIndex, route, count: e.count ?? 1, interval: e.interval ?? 0,
mods: e.mods ?? null, tag: e.tag ?? null, bounty: e.bounty ?? null, sourcePlayerId: e.sourcePlayerId ?? null,
ownerPlayerId: e.ownerPlayerId ?? null, pos: e.pos ?? null, countInTotal: e.countInTotal,
});
}
if (extraRoutes.length) routes = routes.concat(extraRoutes);
const players = opts.players ?? [{
playerId: 'p1', seat: 0, side: 'L', colOffset: 0,
units: (opts.units ?? []).map((u, i) => ({ uid: u.uid ?? i + 1, kind: u.kind ?? 'chess', ...u })),
bonds: opts.bonds ?? {}, bandId: opts.bandId ?? null, playerEffects: opts.playerEffects ?? [],
}];
const hookNames = opts.hooks ?? ALL_HOOKS;
const captured = Object.create(null);
const userSetup = opts.setup;
const battle = new Battle({
seed: opts.seed ?? 1, kind, modeId: opts.modeId ?? 'mode_multi_normal', round: opts.round ?? 1, stage, rect: opts.rect,
timeLimit: timeLimit ?? (kind === 'boss' || kind === 'hidden' ? Infinity : 60), players, spawns, routes,
sharedBoss: opts.sharedBoss ?? null, flags: opts.flags, fieldId: opts.fieldId ?? 'test', data,
content: opts.content ?? 'full', recordEvents: opts.recordEvents !== false, logger: opts.logger ?? quietLogger(opts.verbose),
quiet: opts.verbose ? false : true, devices: opts.devices,
autoFinish: opts.autoFinish ?? spawns.length > 0, kits: opts.kits, extraContent: opts.extraContent,
setup: (b) => {
for (const name of hookNames) {
captured[name] = [];
b.on(name, (ctx) => {
if (NOISY.has(name) && !opts.captureNoisy) { captured[name].length++; return; }
captured[name].push({ ...ctx, t: b.time });
}, { priority: -1000 });
}
if (typeof userSetup === 'function') userSetup(b);
},
});
const events = [];
const drain = () => { for (const e of battle.drainEvents()) events.push(e); };
const h = {
battle,
b: battle,
events,
hooks: captured,
step(n = 1) { for (let i = 0; i < n && !battle.finished; i++) battle.step(); drain(); return h; },
run(seconds) {
const target = battle.time + seconds - 1e-9;
while (!battle.finished && battle.time < target) battle.step();
drain();
return h;
},
/** Step until pred() is true (or `pred` seconds elapsed when a number). Returns true if pred became true. */
runUntil(pred, maxSeconds = 300) {
if (typeof pred === 'number') { h.run(pred); return true; }
const limit = battle.time + maxSeconds;
while (!battle.finished && battle.time < limit) {
if (pred(battle)) { drain(); return true; }
battle.step();
}
drain();
return !!pred(battle);
},
runToEnd(maxSeconds = 4000) {
const limit = battle.time + maxSeconds;
while (!battle.finished && battle.time < limit) battle.step();
drain();
return battle.result();
},
/** Ally lookup: board uid first, then chessId / baseId, then engine id. */
unit(q) {
const L = battle.allyUnits;
return L.find((u) => u.uid != null && u.uid === q) ?? L.find((u) => u.defId === q || u.def?.baseId === q) ?? L.find((u) => u.id === q) ?? null;
},
allies() { return battle.allyUnits.filter((u) => u.alive && u.deployed && u.kind !== 'device'); },
enemies() { return battle.enemies.filter((e) => e.alive); },
enemy(key) { return battle.units.find((u) => u.side === 'enemy' && (key == null || u.defId === key)) ?? null; },
spawn(key, o = {}) { const e = battle.spawnEnemy(key, o); drain(); return e; },
eventsOf(kind) { drain(); return events.filter((e) => e[0] === kind); },
hooksOf(name) { return captured[name] ?? []; },
result() { return battle.result(); },
snapshot() { return battle.snapshot(); },
invariants() { return checkInvariants(battle); },
TICK,
};
return h;
}
function quietLogger(verbose) {
if (verbose) return console;
return { warn() {}, error() {}, info() {}, log() {} };
}
/** Throws on any invariant violation (no NaN/Infinity, hp ∈ [0,maxHp], positions inside rect, DP bounds). */
export function checkInvariants(b) {
const R = b.rect;
const bad = (msg, u) => { throw new Error(`invariant: ${msg} ${u ? `${u.kind}#${u.id} ${u.defId}` : ''} t=${b.time.toFixed(3)}`); };
for (const u of b.units) {
for (const k of ['x', 'y', 'hp', 'atkCd']) {
const v = u[k];
if (typeof v !== 'number' || Number.isNaN(v) || (!Number.isFinite(v) && k !== 'atkCd')) bad(`${k}=${v}`, u);
}
const s = u.s;
for (const k of ['maxHp', 'atk', 'def', 'res', 'aspd', 'interval', 'moveSpeed']) {
if (!Number.isFinite(s[k])) bad(`stat ${k}=${s[k]}`, u);
}
if (u.alive) {
if (u.hp < 0 || u.hp > s.maxHp + 1e-6) bad(`hp ${u.hp} / ${s.maxHp}`, u);
if (u.deployed && !u.hidden) {
if (u.x < R.c0 - 0.5 - 1e-6 || u.x > R.c1 + 0.5 + 1e-6 || u.y < R.r0 - 0.5 - 1e-6 || u.y > R.r1 + 0.5 + 1e-6) bad(`pos ${u.x},${u.y} outside rect`, u);
}
if (u.skill && !u.skill.noSkill) {
const sk = u.skill;
if (!Number.isFinite(sk.sp) || sk.sp < -1e-9 || sk.sp > sk.spCost + 1e-6) bad(`sp ${sk.sp}/${sk.spCost}`, u);
if (!(sk.charges >= 0 && sk.charges <= sk.maxCharges)) bad(`charges ${sk.charges}/${sk.maxCharges}`, u);
if (sk.active && sk.kind === 'duration' && !Number.isFinite(sk.timeLeft)) bad(`skill timeLeft ${sk.timeLeft}`, u);
if (sk.active && sk.kind === 'ammo' && !Number.isFinite(sk.ammoLeft)) bad(`skill ammoLeft ${sk.ammoLeft}`, u);
}
// occupancy map consistency: a living deployed ally is what its tile says it is
if (u.side === 'ally' && u.deployed && b._occ[u.tileR * 21 + u.tileC] !== u) bad(`occupancy map out of sync at ${u.tileR},${u.tileC}`, u);
} else if (u.removeReason === 'killed' && u.hp !== 0 && !u.bossPool) bad(`killed with hp ${u.hp}`, u);
else if (u.hp < 0) bad(`negative hp ${u.hp}`, u);
if (u.side === 'enemy' && u.blockedBy && !u.blockedBy.blocking.includes(u)) bad('block link broken', u);
}
for (const a of b.allyUnits) {
if (!a.alive && a.blocking.length) bad('dead blocker still blocking', a);
let used = 0;
for (const e of a.blocking) used += e.blockWeight ?? 1;
if (a.alive && used > Math.max(a.s.blockCnt, 0) && !a.s.flags.blockFly) {
// capacity may drop below current load only transiently (buff removal) — engine releases on next check
}
}
for (const p of b.players) if (!(p.dp >= 0 && p.dp <= b.flags.dpMax + 1e-9) || Number.isNaN(p.dp)) bad(`dp ${p.dp}`);
for (const pr of b.projectiles.list) if (![pr.x, pr.y, pr.tx, pr.ty].every(Number.isFinite)) bad(`projectile at ${pr.x},${pr.y} → ${pr.tx},${pr.ty}`);
if (b._emitDepth) bad(`hook emit depth ${b._emitDepth} between steps`);
for (const pp of Object.values(b._perPlayer ?? {})) {
for (const k of ['killed', 'total', 'coins', 'damageDealt', 'bossDamage', 'healingDone', 'deaths']) if (!Number.isFinite(pp[k])) bad(`result ${k}=${pp[k]}`);
}
return true;
}
/** Stable hash of a value (for determinism tests). */
export function hashOf(v) {
const s = JSON.stringify(v);
let h = 2166136261;
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
return (h >>> 0).toString(16);
}
+167
View File
@@ -0,0 +1,167 @@
// test/helpers/wsClient.js — promise-based WebSocket test client for server tests.
//
// Every inbound JSON frame is appended to an inbox. `waitFor(type, predicate)` consumes the first matching
// frame already in the inbox or the next one to arrive (so there is no race between an action and the
// wait for its effect). `request(msg)` assigns a `rid` and resolves with the server's direct reply
// (`ok` / `error` / `welcome` / `pong` carrying that rid).
//
// const c = await TestClient.connect(`ws://127.0.0.1:${port}/ws`);
// const welcome = await c.hello('Doctor');
// const reply = await c.request({ t: 'room.create', mode: 'coop', difficulty: 'NORMAL' });
// const state = await c.waitFor('room.state');
// await c.close();
import WebSocket from 'ws';
const REPLY_TYPES = new Set(['ok', 'error', 'welcome', 'pong']);
export class TestClient {
/**
* Open a socket and resolve once connected.
* @param {string} url ws://host:port/ws
* @param {{ timeout?: number, wsOptions?: object }} [opts] wsOptions are passed to `new WebSocket`
* @returns {Promise<TestClient>}
*/
static connect(url, { timeout = 3000, wsOptions = {} } = {}) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url, wsOptions);
const timer = setTimeout(() => { ws.terminate(); reject(new Error(`connect timeout ${url}`)); }, timeout);
ws.once('open', () => { clearTimeout(timer); resolve(new TestClient(ws)); });
ws.once('error', (e) => { clearTimeout(timer); reject(e); });
});
}
/** @param {WebSocket} ws */
constructor(ws) {
this.ws = ws;
/** @type {any[]} unconsumed frames */
this.inbox = [];
/** @type {any[]} every frame ever received (never consumed) */
this.log = [];
/** @type {{ match: (m: any) => boolean, resolve: Function }[]} */
this.waiters = [];
this.nextRid = 1;
/** @type {{ code: number, reason: string } | null} */
this.closeInfo = null;
this.closed = new Promise((resolve) => {
ws.on('close', (code, reason) => {
this.closeInfo = { code, reason: reason.toString() };
resolve(this.closeInfo);
});
});
ws.on('error', () => {});
ws.on('message', (data, isBinary) => {
if (isBinary) return;
let msg;
try { msg = JSON.parse(data.toString()); } catch { return; }
this.log.push(msg);
const i = this.waiters.findIndex((w) => w.match(msg));
if (i >= 0) {
const [w] = this.waiters.splice(i, 1);
w.resolve(msg);
} else {
this.inbox.push(msg);
}
});
}
/** True while the socket is OPEN. */
get isOpen() { return this.ws.readyState === WebSocket.OPEN; }
/**
* Send a message; assigns `rid` unless the message already has one (use `rid: null` to omit it).
* @param {object} msg
* @returns {number | undefined} the rid used
*/
send(msg) {
const out = { ...msg };
if (out.rid === undefined) out.rid = this.nextRid++;
if (out.rid === null) delete out.rid;
this.ws.send(JSON.stringify(out));
return out.rid;
}
/** Send a raw frame (string or Buffer). @param {string | Buffer} data @param {{ binary?: boolean }} [opts] */
sendRaw(data, { binary = false } = {}) {
this.ws.send(data, { binary });
}
/**
* Wait for a frame of `type` (or any type when null) matching `predicate`.
* @param {string | null} type
* @param {(m: any) => boolean} [predicate]
* @param {number} [timeout] ms
* @returns {Promise<any>}
*/
waitFor(type, predicate = () => true, timeout = 2000) {
const match = (m) => (type == null || m.t === type) && predicate(m);
const i = this.inbox.findIndex(match);
if (i >= 0) return Promise.resolve(this.inbox.splice(i, 1)[0]);
return new Promise((resolve, reject) => {
const waiter = { match, resolve: (m) => { clearTimeout(timer); resolve(m); } };
const timer = setTimeout(() => {
const k = this.waiters.indexOf(waiter);
if (k >= 0) this.waiters.splice(k, 1);
const recent = this.log.slice(-8).map((m) => m.t + (m.code ? `:${m.code}` : '')).join(', ');
reject(new Error(`timeout waiting for ${type ?? 'any'} (recent: ${recent || 'none'})`));
}, timeout);
this.waiters.push(waiter);
});
}
/**
* Resolve after `ms` if no matching frame arrived (and none is in the inbox); reject otherwise.
* @param {string | null} type @param {(m: any) => boolean} [predicate] @param {number} [ms]
*/
async expectNone(type, predicate = () => true, ms = 150) {
const got = await this.waitFor(type, predicate, ms).catch(() => null);
if (got) throw new Error(`unexpected ${got.t}: ${JSON.stringify(got).slice(0, 200)}`);
}
/**
* Send with a fresh rid and wait for the direct reply (ok / error / welcome / pong with that rid).
* @param {object} msg @param {number} [timeout]
* @returns {Promise<any>}
*/
request(msg, timeout = 2000) {
const rid = this.send({ ...msg, rid: this.nextRid++ });
return this.waitFor(null, (m) => REPLY_TYPES.has(m.t) && m.rid === rid, timeout);
}
/**
* Say hello and resolve with the `welcome` frame (rejects on `error`).
* @param {string} name @param {string} [token] @param {object} [extra]
*/
async hello(name, token, extra = {}) {
const reply = await this.request({ t: 'hello', name, version: 1, ...(token ? { token } : {}), ...extra });
if (reply.t !== 'welcome') throw new Error(`hello failed: ${JSON.stringify(reply)}`);
return reply;
}
/** Drop every unconsumed frame. */
clearInbox() { this.inbox.length = 0; }
/** Close gracefully and wait for the close event. */
async close() {
if (this.ws.readyState === WebSocket.CLOSED) return this.closeInfo;
this.ws.close();
return this.closed;
}
/** Kill the TCP connection without a close handshake (simulates a network drop). */
async terminate() {
this.ws.terminate();
return this.closed;
}
}
/**
* Connect and say hello in one step.
* @param {string} url @param {string} name @param {string} [token]
* @returns {Promise<{ client: TestClient, welcome: any }>}
*/
export async function connectAs(url, name, token) {
const client = await TestClient.connect(url);
const welcome = await client.hello(name, token);
return { client, welcome };
}