feedback7 G-TOOLS: SP_VERIFY re-runs slice work in the existing 8 ms budget instead of blocking the event loop (PR #352 by @siruimei07)

Applied as-is. All-mode verification runs the existing HeadlessJob in headlessSliceMs slices (8 ms on a real scheduler; synchronous when that value is not finite, including the virtual scheduler). The field still settles with the server digest. Sample mode still accepts the client result immediately and logs afterwards. A comparison error in all mode falls back to the server result. Dispose cancels the pending slice timer.

Tests: node --test test/match/verification-slices.test.js — 11 pass, 0 fail; node --test test/match/clientCombat.test.js test/match/clientCombat-review.test.js — 39 pass, 0 fail.
This commit is contained in:
siruimei07 authored and sganggs committed 2026-10-08 18:47:53 +08:00
1 parent 8597b66df3
commit a2a3f52418
4 files changed
+317 -32

No files matched your search

+2 -2
View File
@@ -106,7 +106,7 @@
// (g.bandFocus → timeoutBand); g.unitStats answers m.unitStats: the stats the board's units start their next battle with.
// opts.clientCombat default true (env SP_COMBAT=server → false: the legacy server-run + snapshot streaming mode)
// opts.verify 'off' | 'sample' | 'all' (env SP_VERIFY, default 'off'): re-simulate accepted client results
// ('sample': ~1 in 8, in a later callback, mismatches logged; 'all': before accepting — the
// ('sample': ~1 in 8, in later slices, mismatches logged; 'all': before accepting — the
// server's result wins on a mismatch)
//
// Engine-only extra options (tests / tools; the lobby never passes them):
@@ -121,7 +121,7 @@
// opts.botSliceMs wall-clock ms of rehearsal per scheduler callback (default 8 with a real scheduler, unbounded
// with a virtual one); the rest runs in later callbacks (scheduleBotPrep)
// opts.headlessSliceMs wall-clock ms per callback of a server-run normal / 联防 field (client-side combat: bots,
// takeovers; default 8 with a real scheduler, at once with a virtual one)
// takeovers, result verification; default 8 with a real scheduler, at once with a virtual one)
// Seats may be all bots (tools/matchrun.mjs); the lobby always has ≥ 1 human.
//
// Diagnostics: m.errors / m.errorCount (engine), m.dispatcher.errors / .errorsByKey (meta handlers), m.simErrors
+5 -3
View File
@@ -24,8 +24,9 @@ export class MatchClientCombat {
_clearFieldTimers(f) {
if (!f || !f.cc) return;
for (const k of ['deadlineTimer', 'doneTimer', 'waitTimer', 'sliceTimer']) if (f[k]) { this.cancel(f[k]); f[k] = null; }
for (const k of ['deadlineTimer', 'doneTimer', 'waitTimer', 'sliceTimer', 'verifyTimer']) if (f[k]) { this.cancel(f[k]); f[k] = null; }
f.job = null;
f.verifyJob = null;
}
/** A client-combat field record: the JSON BattleSpec of its Battle options plus the authority / result state. */
@@ -84,7 +85,7 @@ export class MatchClientCombat {
return {
t: 'b.start', battleId: f.battleId, fieldId: f.fieldId, kind: f.kind,
spec: this.spectators.has(pid) ? this._spectatorSpec(f) : f.spec,
authoritative: !!(!f.done && f.mode === 'client' && f.authority === pid && !watch),
authoritative: !!(!f.done && !f.verifyJob && f.mode === 'client' && f.authority === pid && !watch),
startAt: f.startAt, serverNow: this.sched.now(), elapsed: Math.round(this._fieldElapsed(f) * 1000) / 1000,
speed: this.gameSpeed, watch: !!watch, done: !!f.done,
};
@@ -121,6 +122,7 @@ export class MatchClientCombat {
/** A client field's result deadline: its time limit on the field clock + RESULT_GRACE_MS (then the server takes over). */
_armDeadline(f) {
if (f.deadlineTimer) { this.cancel(f.deadlineTimer); f.deadlineTimer = null; }
if (f.verifyJob) return;
const lim = f.spec.timeLimit > 0 ? f.spec.timeLimit : 60;
const at = f.startAt + Math.round((lim / this.gameSpeed) * 1000) + RESULT_GRACE_MS;
f.deadlineTimer = this.later(Math.max(0, at - this.sched.now()), () => {
@@ -225,7 +227,7 @@ export class MatchClientCombat {
/** An authoritative human disconnected / left: normal & 联防 fields → server takeover; boss → the partner or the server. */
_authorityLost(ps, why) {
for (const f of this.fields) {
if (!f.cc || f.done || f.mode !== 'client' || f.authority !== ps.playerId) continue;
if (!f.cc || f.done || f.verifyJob || f.mode !== 'client' || f.authority !== ps.playerId) continue;
if (f.kind === 'boss' || f.kind === 'hidden') this._bossHandover(f, why);
else this._runOnServer(f, why);
}
+61 -27
View File
@@ -5,7 +5,7 @@
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
import { ERR } from '../../../shared/constants.js';
import { runHeadless, syntheticResult, validateClientResult, HARD_CAP_SECONDS } from '../fields.js';
import { HeadlessJob, syntheticResult, validateClientResult, HARD_CAP_SECONDS } from '../fields.js';
import { resultDigest, compactResult as compactForVerify } from '../../sim/spec.js';
import { OK, fail } from './common.js';
@@ -18,7 +18,7 @@ export class MatchReports {
_onProgress(ps, msg) {
if (!this.clientCombat) return fail(ERR.WRONG_PHASE, 'server-run combat');
const f = this._fieldByBattle(msg.battleId);
if (!f || f.done || f.mode !== 'client' || f.authority !== ps.playerId) return OK;
if (!f || f.done || f.verifyJob || f.mode !== 'client' || f.authority !== ps.playerId) return OK;
const p = f.progress;
const maxTotal = Math.max(p.total, (f.spec.spawns.length + 1) * 400);
p.gt = Math.max(p.gt, Math.min(Number(msg.gt) || 0, HARD_CAP_SECONDS));
@@ -46,7 +46,7 @@ export class MatchReports {
_onResult(ps, msg) {
if (!this.clientCombat) return fail(ERR.WRONG_PHASE, 'server-run combat');
const f = this._fieldByBattle(msg.battleId);
if (!f || f.done || f.heldResult || f.mode !== 'client' || f.authority !== ps.playerId) return OK;
if (!f || f.done || f.heldResult || f.verifyJob || f.mode !== 'client' || f.authority !== ps.playerId) return OK;
const bossLike = f.kind === 'boss' || f.kind === 'hidden';
const v = validateClientResult(f.spec, msg.result, { gd: this.gd });
if (!v.ok) {
@@ -57,7 +57,7 @@ export class MatchReports {
else this._runOnServer(f, 'invalid');
return OK;
}
let result = v.result;
const result = v.result;
if (bossLike) {
// the final b.progress normally carried everything; the result's per-player damage is a lower bound
const by = {};
@@ -91,13 +91,15 @@ export class MatchReports {
return OK;
}
this._bossResultDamage(f, result);
} else {
result = this._verifyResult(f, result);
}
f.result = result;
f.resultSource = 'client';
this._fieldDone(f);
if (bossLike) { this._checkFinalEnd(); this._broadcastPool(false); }
const accept = (verified) => {
f.result = verified;
f.resultSource = 'client';
this._fieldDone(f);
if (bossLike) { this._checkFinalEnd(); this._broadcastPool(false); }
};
if (bossLike) accept(result);
else this._verifyResult(f, result, accept);
return OK;
}
@@ -107,27 +109,59 @@ export class MatchReports {
}
/**
* SP_VERIFY: re-simulate an accepted client result ('all': now, the server's result wins on a mismatch; 'sample':
* ~1 battle in 8 in a later callback, mismatches are only logged).
* SP_VERIFY: re-simulate in headless slices. 'all' waits before accepting (the server wins on a mismatch);
* 'sample' accepts immediately and only logs differences for ~1 battle in 8, even after the field's phase ends.
*/
_verifyResult(f, result) {
if (this.verifyMode === 'off') return result;
const check = () => {
const run = runHeadless(this._specBattle(f.spec), { players: f.players });
const mine = validateClientResult(f.spec, compactForVerify(run.result), { gd: this.gd });
const server = mine.ok ? mine.result : run.result;
this.verifyStats.checked++;
if (resultDigest(server).hash !== resultDigest(result).hash) {
this.verifyStats.mismatches++;
this.log.warn?.(`[match ${this.roomCode}] ${f.fieldId}: client result differs from the server's simulation`);
return server;
_verifyResult(f, result, accept) {
if (this.verifyMode === 'off') { accept(result); return; }
const required = this.verifyMode === 'all';
const start = () => {
const job = new HeadlessJob(this._specBattle(f.spec), { players: f.players, onError: (e) => this.reportError('verify', e) });
if (required) {
// The result has arrived: duplicate reports, disconnects and the old deadline must not start another run.
f.verifyJob = job;
this.cancel(f.deadlineTimer);
f.deadlineTimer = null;
f.rearmDeadline = false;
}
return null;
const schedule = () => {
const timer = this.later(0, slice);
if (required) f.verifyTimer = timer;
};
const slice = () => {
if (required) f.verifyTimer = null;
if (this.disposed || this.ended) return;
if (required && (f.verifyJob !== job || f.done || f.mode !== 'client' || !this.fields.includes(f))) return;
let verified = result;
try {
if (!job.run(this.headlessSliceMs)) { schedule(); return; }
const run = job.output();
const mine = validateClientResult(f.spec, compactForVerify(run.result), { gd: this.gd });
const server = mine.ok ? mine.result : run.result;
this.verifyStats.checked++;
if (resultDigest(server).hash !== resultDigest(result).hash) {
this.verifyStats.mismatches++;
this.log.warn?.(`[match ${this.roomCode}] ${f.fieldId}: client result differs from the server's simulation`);
verified = server;
}
} catch (e) {
this.reportError('verify', e);
if (required) {
this._clearFieldTimers(f);
this._runOnServer(f, 'verify-error');
}
return;
}
if (required) { f.verifyJob = null; accept(verified); }
};
if (Number.isFinite(this.headlessSliceMs)) schedule();
else slice(); // Virtual schedulers retain their synchronous fast path.
};
if (this.verifyMode === 'all') return check() || result;
if (required) { start(); return; }
let h = 0;
for (let i = 0; i < f.battleId.length; i++) h = (h * 31 + f.battleId.charCodeAt(i)) >>> 0;
if (h % 8 === 0) this.later(0, () => { try { check(); } catch (e) { this.reportError('verify', e); } });
return result;
// Sample jobs use only match-level timers: field completion must not cancel the diagnostic work.
if (h % 8 === 0) this.later(0, start);
accept(result);
}
}