Files
ark-stronghold-protocol/test/match/simClient.js
T
sganggs 6464e4b46a Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar
- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player;
  placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call)
- #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿
- #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text
- #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool,
  and the team LP reaching 0 first is a defeat (PRTS)
- #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones
- #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10)
- #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage
  triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range
- #9 official per-enemy model scales (威龙)
- #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding
- #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider
- #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes
- #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20
- #18 炎佑 is 孤立: no heals or ally buffs
- #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators
- DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites
  green; dev recordings regenerated
2026-10-01 19:08:23 +08:00

218 lines
8.9 KiB
JavaScript

// test/match/simClient.js — a scripted "browser" under client-side combat (DESIGN §14) for Match tests: it answers
// b.start (authoritative) by simulating the spec with the shared sim (server/sim/spec.js createBattleFromSpec), reports
// b.progress / b.result through `send` (every frame passes shared/protocol.js validateC2S, like the platform), syncs
// its boss pool with b.pool and obeys b.end. Timers come from the injected scheduler (the harness' VirtualScheduler, or
// real timers for websocket clients), never synchronous with the server call that delivered the frame.
//
// const c = new SimClient('p_0', { send, setTimeout, clearTimeout, ds, BattleClass, pace: 'instant' | 'paced', speed })
// c.onMessage(frame)
//
// pace 'instant' simulates normal / 联防 fields to the end in one timer callback (boss fields are always paced: they
// share the pool with other fields); 'paced' steps every field at `speed` game-s per real second in 250 ms slices.
// Test knobs: `tamper(result, spec) → result` edits the outgoing result, `mute: true` never reports, `stall: true`
// reports progress but no result, `delayMs` delays the first slice. `log` keeps every sent frame; `battles` every
// local battle by battleId.
import assert from 'node:assert/strict';
import { createBattleFromSpec, compactResult, battleProgress, attachLpMeter } from '../../server/sim/spec.js';
import { validateC2S } from '../../shared/protocol.js';
import { TICK } from '../../server/sim/constants.js';
const SLICE_MS = 250;
export class SimClient {
constructor(playerId, o) {
this.playerId = playerId;
this.o = { pace: 'instant', speed: 2, ...o };
this.log = [];
/** @type {Map<string, { battle: any, spec: any, authoritative: boolean, meter: any, timer: any, finished: boolean, sentResult: boolean }>} */
this.battles = new Map();
this.starts = [];
this.ends = [];
this.pools = [];
}
send(msg) {
const bad = validateC2S(msg);
assert.equal(bad, null, `${this.playerId} would send an invalid ${msg.t}: ${bad}`);
this.log.push(msg);
this.o.send(msg);
}
/** Stop every timer (the page closed). */
close() {
this.closed = true;
for (const e of this.battles.values()) if (e.timer) { this.o.clearTimeout(e.timer); e.timer = null; }
}
onMessage(msg) {
if (!msg || typeof msg !== 'object' || this.closed) return;
if (msg.t === 'b.start') this._start(msg);
else if (msg.t === 'b.pool') this._pool(msg);
else if (msg.t === 'b.end') this._end(msg);
}
_start(msg) {
this.starts.push(msg);
const cur = this.battles.get(msg.battleId);
if (cur) { // resend (reconnect / watch back / authority handover): keep the running battle
const was = cur.authoritative;
cur.authoritative = !!msg.authoritative;
if (cur.authoritative && !was) {
// a replica promoted to authority (handover): it ran live in a browser — catch up to the field's clock
this._stepTo(cur, Number(msg.elapsed) || 0);
this._schedule(cur, 0);
}
return;
}
const entry = { spec: msg.spec, battleId: msg.battleId, fieldId: msg.fieldId, authoritative: !!msg.authoritative, finished: false, sentResult: false, timer: null, lastProgress: -Infinity };
entry.battle = createBattleFromSpec(msg.spec, this.o.ds, { BattleClass: this.o.BattleClass, recordEvents: false, quiet: true });
entry.meter = attachLpMeter(entry.battle);
this.battles.set(msg.battleId, entry);
// catch up to the field's clock (reconnect / observing a running field)
const target = Number(msg.elapsed) || 0;
if (target > 0) this._stepTo(entry, target);
if (entry.authoritative) this._schedule(entry, this.o.delayMs || 0);
}
_pool(msg) {
this.pools.push(msg);
for (const e of this.battles.values()) {
const pool = e.battle.sharedBoss;
if (pool && typeof pool.sync === 'function') pool.sync(msg.hp, msg.acked ? msg.acked[e.fieldId] : undefined);
}
}
_end(msg) {
this.ends.push(msg);
const e = this.battles.get(msg.battleId);
if (!e) return;
if (msg.reason === 'takeover') { e.authoritative = false; if (e.timer) this.o.clearTimeout(e.timer); e.timer = null; return; }
try { e.battle.forceEnd(msg.reason === 'timeout' ? 'timeout' : 'forced'); } catch { /* ignore */ }
if (e.authoritative) this._schedule(e, 0);
}
_stepTo(e, gt) {
const b = e.battle;
const cap = Math.ceil(gt / TICK);
while (!b.finished && (b.tickCount ?? Math.round(b.time / TICK)) < cap) b.step();
}
_schedule(e, ms) {
if (e.timer) this.o.clearTimeout(e.timer);
e.timer = this.o.setTimeout(() => { e.timer = null; this._slice(e); }, ms);
}
_slice(e) {
if (this.closed || !e.authoritative || e.sentResult || this.o.mute) return;
const b = e.battle;
const bossLike = e.spec.kind === 'boss' || e.spec.kind === 'hidden';
if (this.o.pace === 'instant' && !bossLike) {
let n = 0;
while (!b.finished && n++ < 200_000) b.step();
} else {
const ticks = Math.max(1, Math.round(((this.o.speed || 2) * SLICE_MS) / 1000 / TICK));
for (let i = 0; i < ticks && !b.finished; i++) b.step();
}
this._progress(e);
if (b.finished) {
if (this.o.stall) return;
let result = compactResult(b.result());
if (this.o.tamper) result = this.o.tamper(result, e.spec);
e.sentResult = true;
this.send({ t: 'b.result', battleId: e.battleId, result });
return;
}
this._schedule(e, SLICE_MS);
}
_progress(e) {
const p = battleProgress(e.battle);
const msg = { t: 'b.progress', battleId: e.battleId, gt: p.gt, killed: Math.min(p.killed, p.total), total: p.total, done: p.done };
const bossLike = e.spec.kind === 'boss' || e.spec.kind === 'hidden';
if (bossLike) {
const pool = e.battle.sharedBoss;
msg.leaks = e.meter.lp;
msg.bossDmg = pool && Number.isFinite(pool.cum) ? pool.cum : 0;
if (pool && pool.byPlayer) msg.by = { ...pool.byPlayer };
} else {
msg.leaks = p.leaks;
// 联防: the leakers' enemies still standing (like public/js/battle/runner.js)
if (p.left) msg.left = Object.fromEntries(Object.entries(p.left).slice(0, 4));
}
this.send(msg);
}
}
/**
* Attach SimClients to a harness match (test/match/harness.js makeMatch): every human gets one; frames sent to a
* disconnected / departed human are dropped (like the platform). Returns Map playerId → SimClient.
*/
export function attachSimClients(h, opts = {}) {
const clients = new Map();
const m = h.m;
for (const ps of m.players.values()) {
if (ps.isBot) continue;
const c = new SimClient(ps.playerId, {
send: (msg) => { const r = m.handle(ps.playerId, msg); if (r && r.error && opts.onError) opts.onError(ps.playerId, msg, r); },
setTimeout: (fn, ms) => h.sched.setTimeout(fn, ms),
clearTimeout: (t) => h.sched.clearTimeout(t),
ds: m.ds,
BattleClass: opts.BattleClass,
speed: m.gameSpeed,
...(opts.perPlayer && opts.perPlayer[ps.playerId] ? opts.perPlayer[ps.playerId] : {}),
pace: (opts.perPlayer && opts.perPlayer[ps.playerId] && opts.perPlayer[ps.playerId].pace) || opts.pace || 'instant',
});
clients.set(ps.playerId, c);
}
const HANDLED = new Set(['b.start', 'b.pool', 'b.end']);
const pending = new Set();
const deliver = (c, ps, msg) => {
// delivered after the server call that produced it (a real socket is never re-entrant)
const t = h.sched.setTimeout(() => { pending.delete(t); if (ps.connected && !ps.left) c.onMessage(msg); }, 0);
pending.add(t);
};
h.onSend.push((pid, msg) => {
const c = clients.get(pid);
const ps = m.players.get(pid);
if (!c || !ps || !ps.connected || ps.left || !HANDLED.has(msg.t)) return;
deliver(c, ps, msg);
});
h.onBroadcast.push((msg) => {
if (!HANDLED.has(msg.t)) return;
for (const [pid, c] of clients) {
const ps = m.players.get(pid);
if (!ps || !ps.connected || ps.left) continue;
deliver(c, ps, msg);
}
});
/** Close every client (pending deliveries and battle timers are dropped). */
clients.closeAll = () => {
for (const t of pending) h.sched.clearTimeout(t);
pending.clear();
for (const c of clients.values()) c.close();
};
return clients;
}
/**
* A SimClient behind a websocket test client (test/helpers/wsClient.js TestClient): frames are read from the socket,
* reports go out without a rid (fire-and-forget, like the browser runner). Real timers.
* @returns {SimClient}
*/
export function attachWsSimClient(tc, opts = {}) {
const sc = new SimClient(tc.id, {
send: (msg) => { if (tc.isOpen) tc.send({ ...msg, rid: null }); },
setTimeout: (fn, ms) => setTimeout(fn, ms),
clearTimeout: (t) => clearTimeout(t),
...opts,
});
tc.ws.on('message', (data, isBinary) => {
if (isBinary) return;
let msg;
try { msg = JSON.parse(String(data)); } catch { return; }
try { sc.onMessage(msg); } catch (e) { sc.error = e; }
});
return sc;
}