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).
537 lines
24 KiB
JavaScript
537 lines
24 KiB
JavaScript
// 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
|