Files
ark-stronghold-protocol/public/js/battle/runner.js
T
sganggs 01a44cd288 卫戍协议:盟约 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).
2026-09-29 16:21:31 +08:00

537 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Local battle runner — client-side combat (DESIGN §14, research 09 §3.2 / §6.4).
//
// Like the official client, the browser simulates battles itself with the shared deterministic sim (server/sim,
// served read-only at /sim/). The server sends a JSON BattleSpec (`b.start`); this module builds the Battle with
// /sim/spec.js createBattleFromSpec and
// * steps it at the battle speed (fixed 1/30 s ticks, 2× → 60 ticks per real second; at most
// max(8, 4·speed) ticks per animation frame, bounded fast-forward up to CATCHUP_TICKS per frame when it is far
// behind: a reconnect, observing a running field, a tab back from the background),
// * feeds the render engine every frame through the same wire formats the server used to stream
// (`snap` = b.snap frame from battle.snapshot(), `ev` = b.ev frame from battle.drainEvents()) and publishes the
// field meta (m.field shape) into store.match.field so the game screen enters the battle,
// * when authoritative: reports b.progress (~1 Hz; boss fields 4 Hz with the shared-pool damage and the LP meter)
// and b.result (compactResult) at the end; applies b.pool (LocalBossPool.sync) and b.end (forceEnd / takeover),
// * keeps an authoritative battle running while the tab is hidden (a 250 ms interval pump; browsers throttle it to
// ~1 Hz, the bounded fast-forward absorbs that); the server deadline + takeover cover anything worse.
// Display replicas (a teammate's field after the own battle, 联防 observers, the partner of a boss pair) run the same
// spec fast-forwarded to the server's clock (`elapsed`) and never report.
// A b.result lost with the socket (the request failed DISCONNECTED / OFFLINE, or timed out twice) is kept and sent
// again when the session is back online ('status' → 'online') or when a b.start still names this finished battle
// authoritative (the server is waiting for it); the server takes a duplicate idempotently. A server refusal is final.
// Solo pause (g.pause, DESIGN §14): while `m.public.paused` is true every local battle clock stands still (no ticks, no
// reports); on resume the clocks move on by the paused time, like the server's field clock.
//
// The sim (≈ 0.2–1 ms per tick) runs on the main thread: one battle at a time is stepped for display (plus an
// authoritative one if it is not the one on screen). stats() exposes the measured cost.
//
// import { battleRunner } from './battle/runner.js' (browser singleton wired to net.js + store.js; null in Node)
// battleRunner.on('snap' | 'ev' | 'field' | 'state', fn) → off
// battleRunner.state() → { battleId, fieldId, kind, authoritative, watch, done, own, members, loading, paused } | null
// battleRunner.stats() → { ticks, stepMs, avgTickMs, maxFrameMs, catchups, errors, battles }
//
// createBattleRunner(deps) builds an instance with injectable net / store / clock / frame scheduler / sim loader
// (test/match/runner.test.js drives it under Node).
import { net as appNet } from '../net.js';
import { store as appStore } from '../store.js';
const TICK = 1 / 30;
/** Fast-forward budget per frame (ticks) when far behind. */
export const CATCHUP_TICKS = 240;
/** Silent catch-up slice (ticks) while a new battle is prepared before it is shown. */
const PREPARE_SLICE = 600;
const MAX_ENTRIES = 4;
const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill', 'leak']);
/** Data files the simulation reads (DataSource + content/support gameData()). */
export const SIM_DATA_FILES = Object.freeze(['chess', 'enemies', 'tokens', 'stages', 'waves', 'bonds', 'items', 'garrisons', 'bands', 'effects']);
/** Request failures after which a b.result counts as never delivered (re-sent on resume / b.start). */
export const LOST_RESULT_CODES = Object.freeze(['DISCONNECTED', 'OFFLINE', 'TIMEOUT']);
/** Ticks per frame at a speed (same cap as the server pacing: server/match/fields.js maxTicksPerInterval). */
export const ticksPerFrameCap = (speed) => Math.max(8, Math.ceil((Number(speed) || 2) * 4));
function deepFreeze(root) {
const stack = [root];
while (stack.length) {
const o = stack.pop();
if (o === null || typeof o !== 'object' || Object.isFrozen(o)) continue;
Object.freeze(o);
for (const v of Object.values(o)) if (v !== null && typeof v === 'object') stack.push(v);
}
return root;
}
/**
* Browser sim loader: the /sim/ modules + the data files (own frozen copies — the server's data is frozen too, so a
* content bug that writes into a record fails identically on both sides).
*/
export async function loadBrowserSim({ base = '/sim/', dataBase = '/data/', fetchFn = (...a) => globalThis.fetch(...a) } = {}) {
const [spec, simdata, support] = await Promise.all([
import(`${base}spec.js`), import(`${base}simdata.js`), import(`${base}content/support/index.js`),
]);
const fetchOnce = async (n) => {
try {
const res = await fetchFn(`${dataBase}${n}.json`, { cache: 'no-cache' });
return res && res.ok ? await res.json() : null;
} catch { return null; }
};
const files = await Promise.all(SIM_DATA_FILES.map(async (n) => (await fetchOnce(n)) ?? fetchOnce(n)));
// a battle simulated without part of the data would still produce a plausible result the server accepts: never run
// one — the loader fails (the next b.start retries it; meanwhile the server's deadline takes the field over)
const missing = SIM_DATA_FILES.filter((n, i) => !files[i] || typeof files[i] !== 'object');
if (missing.length) throw new Error(`simulation data unavailable: ${missing.join(', ')}`);
const raw = {};
SIM_DATA_FILES.forEach((n, i) => { raw[n] = deepFreeze(files[i]); });
simdata.setSimData(raw);
if (typeof support.setGameData === 'function') support.setGameData(null); // re-read through the injected data
return { spec, ds: new simdata.DataSource(raw, null) };
}
/** Battle logger: content errors are isolated by the sim; report them as warnings (the server logs its own). */
const SIM_LOGGER = Object.freeze({
error: (...a) => console.warn('[sim]', ...a),
warn: (...a) => console.warn('[sim]', ...a),
info() {},
debug() {},
});
/**
* @param {{ net: any, store: any, loadSim?: () => Promise<{ spec: any, ds: any }>, now?: () => number,
* raf?: (fn: (t: number) => void) => any, caf?: (h: any) => void, setInterval?: Function, clearInterval?: Function,
* doc?: { hidden?: boolean, addEventListener?: Function } | null, logger?: object }} deps
*/
export function createBattleRunner(deps) {
const net = deps.net;
const store = deps.store;
const now = deps.now || (() => (globalThis.performance ? performance.now() : Date.now()));
const raf = deps.raf || ((fn) => globalThis.requestAnimationFrame(fn));
const caf = deps.caf || ((h) => globalThis.cancelAnimationFrame(h));
const setIv = deps.setInterval || ((fn, ms) => globalThis.setInterval(fn, ms));
const clearIv = deps.clearInterval || ((h) => globalThis.clearInterval(h));
const doc = deps.doc !== undefined ? deps.doc : (typeof document !== 'undefined' ? document : null);
const loadSim = deps.loadSim || (() => loadBrowserSim());
const logger = deps.logger || SIM_LOGGER;
const listeners = new Map();
const emit = (type, payload) => {
const set = listeners.get(type);
if (!set) return;
for (const fn of [...set]) {
try { fn(payload); } catch (err) { console.warn(`[runner] ${type} listener failed`, err); }
}
};
/** @type {Map<string, any>} battleId → entry */
const entries = new Map();
let cur = null; // entry on screen
let simP = null;
let startSeq = 0;
let loading = null; // b.start being prepared
let rafH = null;
let ivH = null;
let lastPool = null;
/** solo pause: the runner clock's instant when m.public.paused turned true (null while running) */
let pausedAt = null;
const stats = { ticks: 0, stepMs: 0, maxFrameMs: 0, catchups: 0, errors: 0, battles: 0, frames: 0 };
const hidden = () => !!(doc && doc.hidden);
/** The battle clock: frozen at the pause instant while the solo battle is paused. */
const clock = () => (pausedAt != null ? pausedAt : now());
const bossLike = (e) => e.kind === 'boss' || e.kind === 'hidden';
function ensureSim() {
if (!simP) {
simP = loadSim().catch((err) => { simP = null; throw err; });
}
return simP;
}
function state() {
const e = cur;
if (!e) return loading ? { loading: true, battleId: loading.battleId, fieldId: loading.fieldId, kind: loading.kind } : null;
return {
battleId: e.battleId, fieldId: e.fieldId, kind: e.kind, authoritative: e.authoritative, watch: e.watch,
done: e.done, own: e.own, members: e.members.slice(), loading: !!loading, speed: e.speed, paused: pausedAt != null,
};
}
function publishState() {
const s = state();
try { store.patch('match', { battle: s }); } catch { /* store without a match slice */ }
emit('state', s);
}
/** Target tick of an entry on its clock. */
const targetTick = (e, t) => Math.max(0, Math.floor((((t - e.t0) / 1000) * e.speed) / TICK + 1e-9));
function stepEntry(e, n) {
const b = e.battle;
const t = now();
let k = 0;
try {
for (; k < n && !b.finished; k++) b.step();
} catch (err) {
stats.errors++;
console.warn('[runner] battle step failed', err);
try { b.forceEnd('timeout'); } catch { /* ignore */ }
}
const dt = now() - t;
stats.ticks += k;
stats.stepMs += dt;
return dt;
}
function frameOf(e) {
const snap = e.battle.snapshot();
const { t: gt, ...rest } = snap || {};
return { ...rest, t: 'b.snap', fieldId: e.fieldId, gt: Number.isFinite(gt) ? gt : 0 };
}
function emitFrame(e, catchingUp) {
let ev = [];
try { ev = e.battle.drainEvents() || []; } catch { ev = []; }
const gt = Number(e.battle.time) || 0;
if (ev.length) {
const list = catchingUp ? ev.filter((x) => Array.isArray(x) && STATE_EV.has(x[0])) : ev;
if (list.length) emit('ev', { t: 'b.ev', fieldId: e.fieldId, gt, ev: list });
}
try { emit('snap', frameOf(e)); } catch (err) { console.warn('[runner] snapshot failed', err); }
}
function progress(e, force = false) {
if (!e.authoritative || e.resultSent || !net) return;
const t = now();
const every = bossLike(e) ? 250 : 1000;
if (!force && t - e.lastProgressAt < every) return;
e.lastProgressAt = t;
const p = e.sim.spec.battleProgress(e.battle);
const msg = { battleId: e.battleId, gt: Math.min(1e5, p.gt), killed: Math.min(p.killed, p.total), total: Math.min(1e5, p.total), done: !!p.done };
if (bossLike(e)) {
const pool = e.battle.sharedBoss;
msg.leaks = Math.min(1e6, e.meter.lp);
msg.bossDmg = pool && Number.isFinite(pool.cum) ? pool.cum : 0;
if (pool && pool.byPlayer) {
const by = {};
for (const pid of Object.keys(pool.byPlayer).slice(0, 4)) by[pid] = pool.byPlayer[pid];
msg.by = by;
}
} else {
msg.leaks = Math.min(1e6, p.leaks);
}
try { net.send('b.progress', msg); } catch { /* offline */ }
}
function finished(e) {
if (e.done) return;
e.done = true;
if (e.authoritative && !e.resultSent && net) {
progress(e, true);
e.resultSent = true;
let result = null;
try {
result = e.sim.spec.compactResult(e.battle.result());
// an oversized frame would close the socket at the very end of the battle (64 KB inbound limit)
if (typeof e.sim.spec.fitResult === 'function') result = e.sim.spec.fitResult(result, { bossLike: bossLike(e), battleId: e.battleId });
} catch (err) { console.warn('[runner] result failed', err); }
if (result) {
e.result = result;
deliver(e);
}
}
if (e === cur) publishState();
}
/**
* Send an entry's b.result (retried once on a timeout). `e.delivery`: 'pending' while a request is out, 'delivered'
* once the server answered (ok, or a refusal — final), 'undelivered' when it never got there (LOST_RESULT_CODES): kept
* for redeliver() (session back online) and for an authoritative b.start of the finished battle.
*/
function deliver(e) {
if (!net || !e.result || e.delivery === 'pending') return;
e.delivery = 'pending';
const msg = { battleId: e.battleId, result: e.result };
const send = (tries) => {
let req;
try { req = net.request('b.result', msg, { timeout: 15000 }); } catch (err) { req = Promise.reject(err); }
return Promise.resolve(req).then(() => { e.delivery = 'delivered'; }, (err) => {
const code = err && err.code;
if (code === 'TIMEOUT' && tries > 0) return send(tries - 1);
if (LOST_RESULT_CODES.includes(code)) {
e.delivery = 'undelivered';
console.warn(`[runner] b.result not delivered (${code}) — sent again when the session resumes`);
} else {
e.delivery = 'delivered';
console.warn('[runner] b.result refused', code);
}
return null;
});
};
send(1);
}
/** The session is back: every result lost with the socket goes out again. */
function redeliver() {
for (const e of entries.values()) if (e.delivery === 'undelivered') deliver(e);
}
/** Solo pause (m.public.paused): freeze / resume every local battle clock. */
function setPaused(on) {
if (on === (pausedAt != null)) return;
if (on) {
pausedAt = now();
} else {
const d = Math.max(0, now() - pausedAt);
pausedAt = null;
for (const e of entries.values()) { e.t0 += d; e.lastProgressAt += d; }
}
publishState();
schedule();
}
/** Advance one entry to its clock (bounded); render it when it is on screen. */
function advance(e, t, render) {
if (e.battle.finished) { if (!e.done) finished(e); return; }
const behind = targetTick(e, t) - (e.battle.tickCount || 0);
if (behind <= 0) return;
const cap = ticksPerFrameCap(e.speed);
const catchingUp = behind > cap * 4;
if (catchingUp) stats.catchups++;
const n = Math.min(behind, catchingUp ? CATCHUP_TICKS : cap);
const dt = stepEntry(e, n);
if (dt > stats.maxFrameMs) stats.maxFrameMs = dt;
if (render) emitFrame(e, catchingUp);
else { try { e.battle.drainEvents(); } catch { /* ignore */ } }
progress(e);
if (e.battle.finished) finished(e);
}
function running(e) { return !e.battle.finished && (e === cur || (e.authoritative && !e.resultSent)); }
function frame() {
rafH = null;
stats.frames++;
const t = clock();
for (const e of [...entries.values()]) if (running(e)) advance(e, t, e === cur);
schedule();
}
function pump() {
// hidden tab: no animation frames — keep authoritative battles on their clock (no rendering)
if (!hidden()) return;
for (const e of [...entries.values()]) if (e.authoritative && running(e)) advance(e, clock(), false);
}
function schedule() {
// paused: nothing to step (the view keeps its last frame); resume reschedules
const any = pausedAt == null && [...entries.values()].some(running);
if (any && rafH == null && !hidden()) rafH = raf(frame);
const needPump = pausedAt == null && [...entries.values()].some((e) => e.authoritative && running(e));
if (needPump && ivH == null) ivH = setIv(pump, 250);
else if (!needPump && ivH != null) { clearIv(ivH); ivH = null; }
}
/** Put an entry on screen: field meta into the store (the game screen enters it), then its current frame. */
function show(e) {
cur = e;
let meta = null;
try { meta = e.battle.fieldMeta(); } catch { meta = { units: [] }; }
const field = {
t: 'm.field', ...meta, fieldId: e.fieldId, kind: e.kind, rect: meta.rect ?? e.spec.rect, stageId: meta.stageId ?? e.spec.stageId,
live: !e.done, battleId: e.battleId, players: e.members.slice(), local: true, speed: e.speed,
// which half each player holds (联防: the first helper takes the right half; boss pairs: L / R)
sides: Object.fromEntries((e.spec.players || []).filter((p) => p && p.playerId).map((p) => [p.playerId, p.side === 'R' || Number(p.colOffset) >= 8 ? 'R' : 'L'])),
};
emit('field', field);
try { store.patch('match', { field }); } catch { /* ignore */ }
publishState();
try { e.battle.drainEvents(); } catch { /* the view starts from the meta + this frame */ }
emit('snap', frameOf(e));
schedule();
}
function evict() {
if (entries.size <= MAX_ENTRIES) return;
for (const [id, e] of entries) {
if (entries.size <= MAX_ENTRIES) break;
if (e === cur || (e.authoritative && !e.resultSent) || e.own || e.delivery === 'pending' || e.delivery === 'undelivered') continue;
entries.delete(id);
}
}
function yieldFrame() { return new Promise((resolve) => { if (hidden()) setTimeout(resolve, 0); else raf(() => resolve()); }); }
async function onStart(msg) {
if (!msg || typeof msg !== 'object' || !msg.spec || typeof msg.battleId !== 'string') return;
const speed = Number(msg.speed) > 0 ? Number(msg.speed) : 2;
const existing = entries.get(msg.battleId);
if (existing) {
const was = existing.authoritative;
existing.authoritative = !!msg.authoritative && !existing.resultSent;
existing.watch = !!msg.watch;
// the server still waits for this finished battle's result (lost with the socket, or its answer was): again
if (msg.authoritative && existing.resultSent) deliver(existing);
if (existing.authoritative && !was) {
// handover (the partner left): continue from the field's clock and report from now on
existing.t0 = clock() - ((Number(msg.elapsed) || 0) / speed) * 1000;
existing.lastProgressAt = -Infinity;
if (existing.battle.finished) { existing.done = false; finished(existing); }
}
++startSeq;
loading = null;
// a resend of what is already on screen (reconnect / resync) only updates the state; switching back shows it
if (cur !== existing) show(existing);
else { publishState(); schedule(); }
return;
}
const seq = ++startSeq;
loading = { battleId: msg.battleId, fieldId: msg.fieldId, kind: msg.kind };
publishState();
let sim;
try { sim = await ensureSim(); } catch (err) {
console.warn('[runner] simulation unavailable', err);
if (seq === startSeq) { loading = null; publishState(); }
return;
}
if (seq !== startSeq) return; // superseded by a newer b.start
let battle;
try {
battle = sim.spec.createBattleFromSpec(msg.spec, sim.ds, { logger });
} catch (err) {
console.warn('[runner] battle construction failed', err);
loading = null;
publishState();
return;
}
stats.battles++;
const e = {
battleId: msg.battleId, fieldId: msg.fieldId || msg.spec.fieldId, kind: msg.kind || msg.spec.kind, spec: msg.spec, sim, battle,
authoritative: !!msg.authoritative, watch: !!msg.watch, own: !msg.watch, speed,
members: (msg.spec.players || []).map((p) => p && p.playerId).filter(Boolean),
t0: clock() - ((Number(msg.elapsed) || 0) / speed) * 1000, lastProgressAt: -Infinity, done: false, resultSent: false,
result: null, delivery: null,
meter: sim.spec.attachLpMeter(battle),
};
if (lastPool && battle.sharedBoss && typeof battle.sharedBoss.sync === 'function') {
battle.sharedBoss.sync(lastPool.hp, lastPool.acked ? lastPool.acked[e.fieldId] : undefined);
}
// silent catch-up to the field's clock before it is shown (a reconnect / observing a running field)
while (!battle.finished && targetTick(e, clock()) - battle.tickCount > ticksPerFrameCap(speed)) {
const n = Math.min(PREPARE_SLICE, targetTick(e, clock()) - battle.tickCount);
stepEntry(e, n);
try { battle.drainEvents(); } catch { /* ignore */ }
if (e.authoritative) progress(e);
await yieldFrame();
if (seq !== startSeq) {
// superseded while preparing: an authoritative battle must still finish (it is kept), a replica is dropped
if (e.authoritative) { entries.set(e.battleId, e); evict(); schedule(); }
return;
}
}
entries.set(e.battleId, e);
evict();
loading = null;
show(e);
if (battle.finished) finished(e);
}
function onPool(msg) {
if (!msg || typeof msg !== 'object') return;
lastPool = msg;
for (const e of entries.values()) {
const pool = e.battle.sharedBoss;
if (pool && typeof pool.sync === 'function') pool.sync(msg.hp, msg.acked ? msg.acked[e.fieldId] : undefined);
}
emit('pool', msg);
}
function onEnd(msg) {
const e = msg && entries.get(msg.battleId);
if (!e) return;
if (msg.reason === 'takeover') {
e.authoritative = false;
if (e === cur) publishState();
schedule();
return;
}
if (!e.battle.finished) {
try { e.battle.forceEnd(msg.reason === 'timeout' ? 'timeout' : 'forced'); } catch { /* ignore */ }
}
if (e === cur) emitFrame(e, false);
finished(e);
schedule();
}
/** Drop every battle (a new round's prep, the match ended, the player left). */
function clear() {
++startSeq;
loading = null;
for (const e of entries.values()) {
// never drop an unreported authoritative result (the round already moved on: the server has its own)
if (e.authoritative && !e.resultSent && !e.battle.finished) { try { e.battle.forceEnd('forced'); } catch { /* ignore */ } }
}
entries.clear();
cur = null;
lastPool = null;
if (rafH != null) { caf(rafH); rafH = null; }
if (ivH != null) { clearIv(ivH); ivH = null; }
publishState();
}
const offs = [];
if (net && typeof net.on === 'function') {
offs.push(net.on('b.start', (m) => { onStart(m).catch((err) => console.warn('[runner] b.start failed', err)); }));
offs.push(net.on('b.pool', onPool));
offs.push(net.on('b.end', onEnd));
// the session is back (reconnect / resume): results lost with the old socket go out again
offs.push(net.on('status', (st) => { if (st && st.status === 'online') redeliver(); }));
}
// phase changes: combat fields live until the next prep; leaving the match drops everything
let lastPhase = null;
if (store && typeof store.subscribe === 'function') {
offs.push(store.subscribe((s) => {
const pub = s && s.match && s.match.public ? s.match.public : null;
// solo pause (DESIGN §14): the local battle clocks follow m.public.paused
setPaused(!!(pub && pub.paused));
const phase = pub ? pub.phase : null;
if (phase === lastPhase) return;
lastPhase = phase;
if (!phase || ['PREP', 'ROUND_START', 'SP_DRAFT', 'RESULT', 'LOBBY', 'INFO_CHECK', 'BAND_DRAFT', 'BATTLE_CHECK'].includes(phase)) {
if (entries.size || loading) clear();
}
// warm the simulation up as soon as a match runs (the first b.start then starts at once)
if (phase && phase !== 'LOBBY' && !simP) ensureSim().catch(() => {});
}));
}
if (doc && typeof doc.addEventListener === 'function') {
doc.addEventListener('visibilitychange', () => { if (!hidden()) schedule(); });
}
return {
on(type, fn) {
if (typeof fn !== 'function') return () => {};
if (!listeners.has(type)) listeners.set(type, new Set());
listeners.get(type).add(fn);
return () => listeners.get(type)?.delete(fn);
},
state,
stats() {
return { ...stats, avgTickMs: stats.ticks ? stats.stepMs / stats.ticks : 0, entries: entries.size, loadingSim: !!simP };
},
/** Re-show the current battle (the game screen remounted). */
reshow() { if (cur) show(cur); },
/** Test / debug hooks. */
_entries: entries,
_frame: frame,
_pump: pump,
onStart, onPool, onEnd, clear, ensureSim, redeliver, setPaused,
dispose() { clear(); for (const off of offs) { try { off?.(); } catch { /* ignore */ } } },
};
}
/** The browser runner, wired to the app's socket and store (null outside a browser). */
export const battleRunner = typeof window !== 'undefined' && typeof document !== 'undefined'
? createBattleRunner({ net: appNet, store: appStore })
: null;
if (battleRunner) globalThis.__SP_RUNNER__ = battleRunner; // dev / E2E introspection