Files
ark-stronghold-protocol/test/match/simClient.js
T
sganggs 66a228c64c feedback7 dober: a boss field's perfect-payout card is paid only where the client's result agrees with its own b.progress (b.progress.leaksBy splits the field's LP per player); golden: unchanged
Seen (review of this branch): the Final Assault / Hidden Core path trusts a client's b.result — validateClientResult skips
the leak multiset for boss fields and nothing re-simulates — while the team LP a field costs arrives through b.progress.leaks.
Commit 05eb2b80 paid the perfect-payout cards from the result's leaked list, so a modified authority could have the server
charge a leak and then report two empty lists (LP 58 -> 57 charged, both seats +2: FORGE_BOTH_EMPTY) or pin the leak on the
partner's list (the forger +2, the partner 0: FORGE_PAY).
Now: b.progress carries `leaksBy` (shared/protocol.js, optional; spec.js battleProgress for a boss / hidden field): per
player, the LP of the leak entries the result will show (compactResult's list, lpr clamped as on the wire); `leaks` minus its
sum is the leader's own "扣除目标生命" effects (boss "Doom" / 斥退 / 萨米 Doom), which are no leak and must not cost a perfect
seat its card. The browser runner and the test SimClient send it; the server keeps the highest value per player (f.leaksBy).
Match._bossLeaksAgree (called by _finishFinal) pays a seat of a client-reported field only when (a) the LP the authority
reported was split at all (leaksBy seen whenever lpReported > 0 — a report without the split explains nothing) and (b) the
seat's leaked list shows at least the leak LP reported for it. A server-run field (no client, a takeover) is the truth itself.
Not stopped, and said so in the docs: an authority that lies consistently (a split that blames the partner, a leak labelled as
the leader's effect) — the boss path has no second source; what is caught is a result that contradicts its own progress.
Tests (test first; the three forge tests failed on the parent with [2, 2] / [2, 0] paid, the runner test on the missing field):
clientCombat.test.js — charged leak + two empty lists -> 0 / 0; the leak moved to the partner -> 0 / 0 (with and without a
split); honest split -> the clean seat paid, the leaker not, a leader's effect LP costs nobody; two honest SimClients end to
end; the protocol field; runner.test.js — the browser runner's boss progress carries leaksBy; docs-consistency pins the
b.progress signature. Grok's two repro scripts now print +0 / +0.
Golden: unchanged (matches 18 / full 283 scenarios match; every golden boss field is server-run).
2026-10-08 22:01:26 +08:00

219 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;
if (p.leaksBy) msg.leaksBy = p.leaksBy; // like public/js/battle/runner.js
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;
}