Files
ark-stronghold-protocol/public/js/battle/runner.js
T
sganggs 0aba602ecf Playtest #3 fixes: lost models, picking, live LP, temp overflow, 蕾缪安 S3, 回环射手, FX, detail card, spinners
- #1 lost operator models: the Spine store no longer hands out a skeleton whose unload is in flight
  (PIXI.Assets.unload is async); eviction runs 1 s after a release and the empty-scene budget only after 3 s
- #7 picking: one shared rule (render/pick.js, pixel probe where bodies overlap) for prep/battle clicks,
  hover, the enemy pen and equipment drops; the drag ghost stands on its drop tile; hover probes capped
  at one per frame
- #2 the top-bar LP drops live as the own battle's enemies enter the blue gate (−N, 联防中), teammates'
  pending LP in the team panel
- #3 temp overflow pieces that arrive after a prep deadline survive through the next prep (tempDue);
  temp-row warning and ready-button reason
- #4 蕾缪安 S3: locks every 0.5 s, then one shell every 0.3 s in lock order (PRTS notes), bombardShell fx
- #5 回环射手: real boomerang (out 15, back 3.75 tiles/s, attack only when every boomerang is caught)
- #6 visible projectiles, muzzle flashes, slashes, skill bursts and auras at unchanged frame cost
- #8 特质 right under the detail card header, with its official type icon
- #9 no spinning busy cursors / spinners next to texts; game data warmed before the match
- docs, recordings and tests updated (2414 unit tests, all browser suites green)
2026-09-29 23:42:55 +08:00

580 lines
26 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.
// Live leaks (user playtest #3 item 2): every normal field simulated here keeps its counted leaks so far — the settle
// rule's count (leaked entries with counted !== false, /sim/spec.js battleProgress) — and publishes them as
// state().leaks { [fieldId]: n } whenever one changes, authoritative or display replica alike; the top bar shows the
// own field's min(lpCapPerRound, n) as the LP about to be lost (ui/hud.js liveLp).
//
// 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, leaks } | 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;
/** a normal field's leak count changed since the last publishState() */
let leaksDirty = false;
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;
}
/** Counted leaks so far of every normal field simulated here: { [fieldId]: n } (user playtest #3 item 2). */
function leakMap() {
const out = {};
for (const e of entries.values()) if (e.kind === 'normal' && e.fieldId) out[e.fieldId] = e.leaks;
return out;
}
function state() {
const e = cur;
if (!e) return loading ? { loading: true, battleId: loading.battleId, fieldId: loading.fieldId, kind: loading.kind, leaks: leakMap() } : 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,
leaks: leakMap(),
};
}
function publishState() {
leaksDirty = false;
const s = state();
try { store.patch('match', { battle: s }); } catch { /* store without a match slice */ }
emit('state', s);
}
/**
* Re-count an entry's leaks when its battle recorded a new one (Battle.leakedCount) or ended (timeout leaks, and the
* final result): the settle rule's count, leaked entries with counted !== false (/sim/spec.js battleProgress).
*/
function noteLeaks(e) {
if (e.kind !== 'normal') return;
const b = e.battle;
const mark = `${Number(b.leakedCount) || 0}:${b.finished ? 1 : 0}`;
if (mark === e.leakMark) return;
e.leakMark = mark;
let n = e.leaks;
try { n = Math.max(0, Math.trunc(Number(e.sim.spec.battleProgress(b).leaks) || 0)); } catch { /* keep the last count */ }
if (n !== e.leaks) { e.leaks = n; leaksDirty = true; }
}
/** Publish the state when a leak count changed since the last publish. */
function flushLeaks() { if (leaksDirty) publishState(); }
/** 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;
noteLeaks(e);
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();
else flushLeaks();
}
/**
* 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 */ } }
noteLeaks(e);
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);
flushLeaks();
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);
flushLeaks();
}
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),
// counted leaks so far (normal fields; noteLeaks) and the Battle state they were counted at
leaks: 0, leakMark: '',
};
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 */ }
noteLeaks(e);
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(); if (e.leaks) leaksDirty = true; schedule(); }
return;
}
}
entries.set(e.battleId, e);
evict();
loading = null;
noteLeaks(e);
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);
flushLeaks();
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