卫戍协议:盟约 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:
commit
01a44cd288
408 files changed
+308986
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user