- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently). - Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision). - 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified). - Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage. - Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue. - Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia). - golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
908 lines
45 KiB
JavaScript
908 lines
45 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.
|
||
// A frame that fast-forwards (catch-up) passes on only the state-bearing events (keepsState: spawns, deaths, deploys,
|
||
// statuses, skills, leaks and the fx that change an enemy's model form — shared/protocol.js fxForm); while the tab is
|
||
// hidden the battle on screen keeps the same events (compacted past HELD_MAX; a backlog still past it makes the view
|
||
// re-enter from the field meta) and the first frame back delivers them — or, for a battle that ended while hidden, the
|
||
// visibilitychange itself — so the view still knows every unit and every enemy's current form (player report #5 after
|
||
// 0.1.0: a 转译基底·α that changed form during a stall or in a background tab died in its first-form model). Such a
|
||
// frame or step covers seconds of play, so it drains the events every EV_SLICE ticks and each batch keeps its own game
|
||
// time (`gt`): the render engine then knows how late a form fx is and skips a change clip that has already ended.
|
||
// 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.start is registered as pending the moment it arrives — before the sim module has loaded, while the Battle is built
|
||
// and caught up silently — so what comes for that battle meanwhile is applied to it in order (PR #266): a b.end takeover
|
||
// stops its reports; a forced / timeout b.end (the first end counts) ends it as soon as the Battle exists, without
|
||
// catching up; a second b.start of it updates its flags (never a second Battle); b.pool and the solo pause reach it. A
|
||
// b.start of another field supersedes it: an authority, or a battle the server already ended, is still built and kept, a
|
||
// display replica is dropped. The next prep's clear() drops it.
|
||
// 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). A 联防 field keeps each leaker's
|
||
// enemies still standing (/sim/spec.js uniteLeft; user playtest #6 item 7) as state().uniteLeft { [playerId]: n },
|
||
// published whenever it changes (it falls as the helpers kill them), and its authority reports it as b.progress `left`.
|
||
// Live bond layers (DESIGN §20.15): every battle simulated here keeps the bonds whose layers grew in it (IN_BATTLE gains,
|
||
// Battle.addLayers — normal battles only) as absolute counts per player, published as state().bondLayers
|
||
// { [playerId]: { [bondId]: n } } (every battle of the round: the own one, the teammates' replicas) whenever one grows;
|
||
// the bond strip of the player on screen — the own one, or a watched teammate's — shows them live (ui/watchBonds.js).
|
||
//
|
||
// 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,
|
||
// uniteLeft, bondLayers } | null
|
||
// battleRunner.stats() → { ticks, stepMs, avgTickMs, maxFrameMs, catchups, errors, battles }
|
||
// battleRunner.unitStats(unitId, fieldId?) → the live stats of a unit of the battle on screen (shared/protocol.js
|
||
// unitStatsEntry: current HP, effective max HP / ATK / DEF / RES / interval / block / move
|
||
// speed next to its base; an ally also its live attack range `range` — unit.liveRangeGrid,
|
||
// facing RIGHT, never a kit's target-selection grid) | null — the detail card reads it a few
|
||
// times a second (user playtest #4 item 7; the range mini-map, community report E1 after
|
||
// 0.1.0). Read-only: it takes the stats the sim computed last (`unit._s`) and the range grid it
|
||
// keeps, and never makes the unit recompute them, so looking never changes the battle's floats.
|
||
// battleRunner.unitIdOf(uid, ownerId, fieldId?) → the id of an own board piece's unit in that battle | null
|
||
// battleRunner.ownerOps(ownerId, fieldId?) → [{ kind: 'op', ownerId, defId, items?, standInFor?, diy? }] that player's
|
||
// operators in the battle on screen with their equipment (a teammate's bond popup: the members in
|
||
// play, DESIGN §20.15, 变形同构体 wearers included; a 补位 stand-in names the replaced charId,
|
||
// a 自选 operator carries its pick like UnitInfo) | []
|
||
//
|
||
// createBattleRunner(deps) builds an instance with injectable net / store / clock / frame scheduler / sim loader
|
||
// (test/match/runner.test.js and test/match/runner-pending.test.js drive it under Node).
|
||
|
||
import { net as appNet } from '../net.js';
|
||
import { store as appStore } from '../store.js';
|
||
import { unitStatsEntry, fxForm } from '../../../shared/protocol.js';
|
||
import { spectateEffects } from './observe.js';
|
||
import { recordError, setBattleSource } from '../diag.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']);
|
||
/**
|
||
* The b.ev tuples a catch-up frame or the hidden-tab backlog keeps: the state-bearing kinds, and every fx that sets an
|
||
* enemy's model form (shared/protocol.js fxForm — dropped, the view kept the old model: player report #5 after 0.1.0).
|
||
*/
|
||
export const keepsState = (x) => Array.isArray(x) && (STATE_EV.has(x[0]) || fxForm(x) !== undefined);
|
||
/**
|
||
* Hidden-tab backlog cap (tuples) of the battle on screen. Past it the backlog is compacted (compactHeld: only the last
|
||
* 'status' per unit and status, the last 'skill' per unit — what the view ends up showing); only a backlog still past it
|
||
* (thousands of spawns) makes the view re-enter from the field meta.
|
||
*/
|
||
export const HELD_MAX = 3000;
|
||
/**
|
||
* Ticks per event batch of a step that covers more than one frame of play (a catch-up frame of up to CATCHUP_TICKS, a
|
||
* hidden-tab step): its events are drained every EV_SLICE ticks and stamped with that game time, not the frame's.
|
||
*/
|
||
export const EV_SLICE = 15;
|
||
|
||
/**
|
||
* A hidden-tab backlog without the superseded toggles: of the 'status' tuples only the last per (unit, status), of the
|
||
* 'skill' tuples only the last per unit, every other tuple (spawn / die / deploy / leak / form fx — bounded by the units)
|
||
* kept; order preserved. A long hidden boss fight toggles statuses thousands of times, the rest stays small.
|
||
*/
|
||
export function compactHeld(list) {
|
||
const last = new Map();
|
||
list.forEach((x, i) => {
|
||
if (x[0] === 'status') last.set(`s:${x[1]}:${x[2]}`, i);
|
||
else if (x[0] === 'skill') last.set(`k:${x[1]}`, i);
|
||
});
|
||
return list.filter((x, i) => (x[0] === 'status' ? last.get(`s:${x[1]}:${x[2]}`) === i : x[0] === 'skill' ? last.get(`k:${x[1]}`) === i : true));
|
||
}
|
||
/** 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', 'backups']);
|
||
|
||
/** 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));
|
||
|
||
/**
|
||
* What the runner compares between two looks at a 联防 battle (noteUniteLeft) before it reads the leakers' enemies still
|
||
* standing again: a knock-out, a leak, a spawn, the end. `total` stands for the spawns no longer — a split child or a summon
|
||
* is outside the capsule's denominator (DESIGN §14 顶栏胶囊) — so the length of the enemy list does.
|
||
* @param {any} b the battle
|
||
*/
|
||
export const uniteLeftMark = (b) => `${Number(b.killed) || 0}:${Number(b.leakedCount) || 0}:${Number(b.total) || 0}:${Array.isArray(b.enemies) ? b.enemies.length : 0}:${b.finished ? 1 : 0}`;
|
||
|
||
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) and keep
|
||
* them for the diagnostics a player copies (diag.js).
|
||
*/
|
||
const SIM_LOGGER = Object.freeze({
|
||
error: (...a) => { console.warn('[sim]', ...a); recordError('sim', a.length === 1 ? a[0] : a.map(String).join(' ')); },
|
||
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();
|
||
/** @type {Map<string, any>} battleId → entry of a b.start still being prepared (prepare(); `battle` null until built) */
|
||
const pending = new Map();
|
||
let cur = null; // entry on screen
|
||
let simP = null;
|
||
let startSeq = 0; // counts the b.starts: the latest one is the view asked for
|
||
let loading = null; // the pending entry of the latest b.start (shown when 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 (or a battle's bond layers) changed since the last publishState() */
|
||
let leaksDirty = false;
|
||
const stats = { ticks: 0, stepMs: 0, maxFrameMs: 0, catchups: 0, errors: 0, battles: 0, frames: 0 };
|
||
/** Hidden-tab backlog tuple → the game time it was drained at (emitFrame batches the backlog by it). */
|
||
const heldAt = new WeakMap();
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Live bond layers of every battle simulated here (DESIGN §20.15): { [playerId]: { [bondId]: n } } — only the bonds
|
||
* whose layers grew in this round's battle, as absolute counts (the battle's live copy, ≤ BOND_LAYER_CAP).
|
||
*/
|
||
function layerMap() {
|
||
const out = {};
|
||
for (const e of entries.values()) {
|
||
if (!e.live) continue;
|
||
for (const [pid, m] of Object.entries(e.live)) out[pid] = { ...(out[pid] || {}), ...m };
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* The 联防 field on screen (never an older round's kept entry, nor while a new battle is being prepared): each
|
||
* leaker's enemies still standing { [playerId]: n } (absent = none left), else null.
|
||
*/
|
||
function uniteLeftMap() {
|
||
const e = cur;
|
||
return !loading && e && e.kind === 'unite' && e.left ? { ...e.left } : null;
|
||
}
|
||
|
||
function state() {
|
||
const e = cur;
|
||
if (!e) return loading ? { loading: true, battleId: loading.battleId, fieldId: loading.fieldId, kind: loading.kind, leaks: leakMap(), uniteLeft: uniteLeftMap(), bondLayers: layerMap() } : 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(), uniteLeft: uniteLeftMap(), bondLayers: layerMap(),
|
||
};
|
||
}
|
||
|
||
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).
|
||
* 联防: each leaker's enemies still standing (/sim/spec.js uniteLeft) whenever a kill, a leak, a spawn or the end
|
||
* changed them.
|
||
*/
|
||
function noteLeaks(e) {
|
||
noteLayers(e);
|
||
if (e.kind === 'unite') { noteUniteLeft(e); return; }
|
||
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; }
|
||
}
|
||
|
||
function noteUniteLeft(e) {
|
||
const b = e.battle;
|
||
const mark = uniteLeftMark(b);
|
||
if (mark === e.leakMark) return;
|
||
e.leakMark = mark;
|
||
let left = e.left;
|
||
try { left = e.sim.spec.uniteLeft(b) || left; } catch { /* keep the last count */ }
|
||
if (JSON.stringify(left) !== JSON.stringify(e.left)) { e.left = left; leaksDirty = true; }
|
||
}
|
||
|
||
/**
|
||
* Re-read an entry's live bond layers (DESIGN §20.15): per player of its spec, the bonds whose layers in the battle's
|
||
* live copy (Battle.addLayers, clamped at BOND_LAYER_CAP like the client's AddBondCount) exceed the spec's start
|
||
* count. Nothing to do when the battle disables gains (联防, boss rounds).
|
||
*/
|
||
function noteLayers(e) {
|
||
const b = e.battle;
|
||
if (!b || (b.flags && b.flags.layerGainsEnabled === false) || typeof b.getPlayer !== 'function') return;
|
||
let sum = 0;
|
||
let live = null;
|
||
for (const sp of Array.isArray(e.spec.players) ? e.spec.players : []) {
|
||
const ps = sp && sp.playerId != null ? b.getPlayer(sp.playerId) : null;
|
||
if (!ps || !ps.bonds || typeof ps.bonds !== 'object') continue;
|
||
const start = sp.bonds && typeof sp.bonds === 'object' ? sp.bonds : {};
|
||
for (const id of Object.keys(ps.bonds)) {
|
||
const n = Number(ps.bonds[id] && ps.bonds[id].layers) || 0;
|
||
const from = Number(start[id] && start[id].layers) || 0;
|
||
if (!(n > from)) continue;
|
||
sum += n - from;
|
||
if (!live) live = {};
|
||
if (!live[sp.playerId]) live[sp.playerId] = {};
|
||
live[sp.playerId][id] = n;
|
||
}
|
||
}
|
||
if (sum === e.layerSum) return;
|
||
e.layerSum = sum;
|
||
e.live = live;
|
||
leaksDirty = true;
|
||
}
|
||
|
||
/** Publish the state when a leak count (or a battle's bond layers) 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));
|
||
|
||
/**
|
||
* Step `n` ticks. `sliced` (a catch-up frame, a hidden-tab step): every EV_SLICE ticks the events drained so far go to
|
||
* `e.slices` as { gt, ev } with that game time, for emitFrame / hold.
|
||
*/
|
||
function stepEntry(e, n, sliced = false) {
|
||
const b = e.battle;
|
||
const t = now();
|
||
let k = 0;
|
||
try {
|
||
for (; k < n && !b.finished; k++) {
|
||
b.step();
|
||
if (sliced && (k + 1) % EV_SLICE === 0 && k + 1 < n) {
|
||
const ev = b.drainEvents() || [];
|
||
if (ev.length) e.slices.push({ gt: Number(b.time) || 0, ev });
|
||
}
|
||
}
|
||
} catch (err) {
|
||
stats.errors++;
|
||
console.warn('[runner] battle step failed', err);
|
||
recordError('runner', err, 'battle step failed');
|
||
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 };
|
||
}
|
||
|
||
/** The steps' events, each batch with its own game time: the sliced ones (stepEntry), then the rest at `gt`. */
|
||
function drainSlices(e, gt) {
|
||
let ev = [];
|
||
try { ev = e.battle.drainEvents() || []; } catch { ev = []; }
|
||
const out = e.slices;
|
||
e.slices = [];
|
||
if (ev.length) out.push({ gt, ev });
|
||
return out;
|
||
}
|
||
|
||
function emitFrame(e, catchingUp) {
|
||
// the hidden-tab backlog overflowed: the view starts again from the field meta (UnitInfo carries every unit's form)
|
||
if (e.stale) { show(e); return; }
|
||
const gt = Number(e.battle.time) || 0;
|
||
// the hidden-tab backlog first, batched by the game time each tuple was drained at (a tuple put there by hand: now)
|
||
const held = e.held;
|
||
if (held.length) e.held = [];
|
||
let run = null;
|
||
for (const x of held) {
|
||
const at = heldAt.get(x) ?? gt;
|
||
if (!run || run.gt !== at) { if (run) emit('ev', run); run = { t: 'b.ev', fieldId: e.fieldId, gt: at, ev: [] }; }
|
||
run.ev.push(x);
|
||
}
|
||
if (run) emit('ev', run);
|
||
for (const s of drainSlices(e, gt)) {
|
||
const list = catchingUp ? s.ev.filter(keepsState) : s.ev;
|
||
if (list.length) emit('ev', { t: 'b.ev', fieldId: e.fieldId, gt: s.gt, ev: list });
|
||
}
|
||
try { emit('snap', frameOf(e)); } catch (err) { console.warn('[runner] snapshot failed', err); recordError('runner', err, 'snapshot failed'); }
|
||
}
|
||
|
||
/**
|
||
* Events of a step that is not rendered (hidden tab): the battle on screen keeps its state-bearing ones for the next
|
||
* rendered frame (keepsState, ≤ HELD_MAX — beyond that it is re-entered from the field meta); any other battle starts
|
||
* from its field meta when shown (show()), so its events go.
|
||
*/
|
||
function hold(e) {
|
||
const batches = drainSlices(e, Number(e.battle.time) || 0);
|
||
if (e !== cur || e.stale) return;
|
||
for (const s of batches) for (const x of s.ev) if (keepsState(x)) { e.held.push(x); heldAt.set(x, s.gt); }
|
||
if (e.held.length > HELD_MAX) {
|
||
e.held = compactHeld(e.held);
|
||
if (e.held.length > HELD_MAX) { e.held = []; e.stale = true; }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The tab is visible again: the battle on screen that ended while it was hidden (advance() no longer runs for it)
|
||
* delivers its backlog and last snapshot now — else the view kept the pre-hide state for the rest of the phase.
|
||
*/
|
||
function flushHidden() {
|
||
const e = cur;
|
||
if (e && e.battle.finished && (e.held.length || e.stale)) emitFrame(e, false);
|
||
}
|
||
|
||
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 };
|
||
// the HUD capsule's numerator of this field (shared/protocol.js b.progress `resolved`): the field's own scheduled
|
||
// enemies knocked out or leaked. Sent only when the battle reports one (Battle.resolved) — an absent field leaves
|
||
// the teammate UI on its `resolved ?? killed` fallback
|
||
if (Number.isFinite(p.resolved)) msg.resolved = Math.max(0, Math.min(msg.total, p.resolved));
|
||
if (bossLike(e)) {
|
||
const pool = e.battle.sharedBoss;
|
||
msg.leaks = Math.min(1e6, e.meter.lp);
|
||
// the split of that LP between the players' own leaks (the rest is the leader's effects): the server holds the result's
|
||
// leaked lists to it before a perfect-payout bounty pays (Match._bossLeaksAgree)
|
||
if (p.leaksBy) msg.leaksBy = p.leaksBy;
|
||
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);
|
||
// 联防: the leakers' enemies still standing (shared/protocol.js b.progress `left`, ≤ 4 players)
|
||
if (p.left) msg.left = Object.fromEntries(Object.entries(p.left).slice(0, 4));
|
||
}
|
||
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); recordError('runner', err, 'result failed'); }
|
||
if (result) {
|
||
e.result = result;
|
||
// the view answers with the settlement voice of this battle (screens/game.js → audio.voice result*): the
|
||
// compact result carries the leaks and the kill count the slot is picked from
|
||
emit('result', { fieldId: e.fieldId, battleId: e.battleId, own: !!e.own, 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);
|
||
recordError('runner', code, 'b.result refused');
|
||
}
|
||
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;
|
||
// a pending battle not built yet takes its clock when it is built
|
||
for (const e of [...entries.values(), ...pending.values()]) if (e.t0 != null) { 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, catchingUp || !render);
|
||
if (dt > stats.maxFrameMs) stats.maxFrameMs = dt;
|
||
if (render) emitFrame(e, catchingUp);
|
||
else hold(e);
|
||
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;
|
||
e.held = [];
|
||
e.slices = [];
|
||
e.stale = false;
|
||
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,
|
||
// the watched player's effects column (user playtest #2; undefined for 联防 / boss pairs and server-run fields)
|
||
effects: spectateEffects(e.spec, e.members),
|
||
// 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 seq = ++startSeq;
|
||
const prep = pending.get(msg.battleId);
|
||
const existing = entries.get(msg.battleId) || prep;
|
||
if (existing) {
|
||
existing.seq = seq;
|
||
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.battle) {
|
||
// still loading: it starts from the latest field clock
|
||
existing.elapsed = Number(msg.elapsed) || 0;
|
||
} else 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); }
|
||
}
|
||
if (prep) {
|
||
// being prepared: the view asked for again, shown when ready (one Battle per battleId)
|
||
loading = prep;
|
||
publishState();
|
||
return prep.ready;
|
||
}
|
||
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 e = {
|
||
battleId: msg.battleId, fieldId: msg.fieldId || msg.spec.fieldId, kind: msg.kind || msg.spec.kind, spec: msg.spec,
|
||
// built by prepare(); `seq` = its latest b.start, `elapsed` = the field clock to start from, `endReason` = the first
|
||
// forced / timeout b.end (applied once the Battle exists), `ready` = the preparation (a promise)
|
||
sim: null, battle: null, meter: null, seq, elapsed: Number(msg.elapsed) || 0, endReason: null, ready: null,
|
||
authoritative: !!msg.authoritative, watch: !!msg.watch, own: !msg.watch, speed,
|
||
members: (msg.spec.players || []).map((p) => p && p.playerId).filter(Boolean),
|
||
t0: null, lastProgressAt: -Infinity, done: false, resultSent: false,
|
||
result: null, delivery: null,
|
||
// counted leaks so far (normal fields; noteLeaks) and the Battle state they were counted at; 联防 fields: each
|
||
// leaker's enemies still standing (noteUniteLeft)
|
||
leaks: 0, leakMark: '', left: null,
|
||
// live bond layers grown in this battle { [playerId]: { [bondId]: n } } and their total gain (noteLayers)
|
||
live: null, layerSum: 0,
|
||
// state-bearing events of the steps run while the tab was hidden (hold(); delivered by the next rendered frame;
|
||
// heldAt = the game time each was drained at), and whether that backlog overflowed (the next frame re-enters the
|
||
// view from the field meta); the event batches of the current sliced step (stepEntry)
|
||
held: [], stale: false, slices: [],
|
||
};
|
||
pending.set(e.battleId, e);
|
||
loading = e;
|
||
publishState();
|
||
e.ready = prepare(e);
|
||
return e.ready;
|
||
}
|
||
|
||
/**
|
||
* Build a pending entry's Battle once the sim is loaded, apply the b.end that came meanwhile, catch it up silently to
|
||
* the field's clock and register it (shown when it is still the view asked for). It is dropped when clear() removed it
|
||
* (or a new b.start replaced it after a clear) and when a b.start of another field superseded it — unless it is an
|
||
* authority (it must finish and report) or the server already ended it (switching back shows the end).
|
||
*/
|
||
async function prepare(e) {
|
||
const wanted = () => pending.get(e.battleId) === e && (e.seq === startSeq || e.authoritative || !!e.endReason);
|
||
try {
|
||
let sim;
|
||
try { sim = await ensureSim(); } catch (err) {
|
||
console.warn('[runner] simulation unavailable', err);
|
||
recordError('runner', err, 'simulation unavailable');
|
||
return;
|
||
}
|
||
if (!wanted()) return;
|
||
let battle;
|
||
try {
|
||
battle = sim.spec.createBattleFromSpec(e.spec, sim.ds, { logger });
|
||
} catch (err) {
|
||
console.warn('[runner] battle construction failed', err);
|
||
recordError('runner', err, 'battle construction failed');
|
||
return;
|
||
}
|
||
stats.battles++;
|
||
e.sim = sim;
|
||
e.battle = battle;
|
||
e.meter = sim.spec.attachLpMeter(battle);
|
||
e.t0 = clock() - (e.elapsed / e.speed) * 1000;
|
||
if (lastPool && battle.sharedBoss && typeof battle.sharedBoss.sync === 'function') {
|
||
battle.sharedBoss.sync(lastPool.hp, lastPool.acked ? lastPool.acked[e.fieldId] : undefined);
|
||
}
|
||
if (e.endReason) endBattle(e);
|
||
// silent catch-up to the field's clock before it is shown (a reconnect / observing a running field) — for the view
|
||
// asked for; a superseded authority catches up in the background (advance)
|
||
while (!battle.finished && e.seq === startSeq && targetTick(e, clock()) - battle.tickCount > ticksPerFrameCap(e.speed)) {
|
||
const n = Math.min(PREPARE_SLICE, targetTick(e, clock()) - battle.tickCount);
|
||
stepEntry(e, n);
|
||
try { battle.drainEvents(); } catch { /* ignore */ }
|
||
noteLeaks(e);
|
||
progress(e);
|
||
await yieldFrame();
|
||
if (!wanted()) return;
|
||
}
|
||
pending.delete(e.battleId);
|
||
entries.set(e.battleId, e);
|
||
evict();
|
||
noteLeaks(e);
|
||
leaksDirty = true; // its leak count / bond layers join the published state
|
||
if (e.seq === startSeq) { loading = null; show(e); }
|
||
if (battle.finished) finished(e);
|
||
flushLeaks();
|
||
schedule();
|
||
} finally {
|
||
if (pending.get(e.battleId) === e) pending.delete(e.battleId);
|
||
if (loading === e) { loading = null; publishState(); }
|
||
}
|
||
}
|
||
|
||
function onPool(msg) {
|
||
if (!msg || typeof msg !== 'object') return;
|
||
lastPool = msg;
|
||
// a pending battle not built yet takes lastPool when it is built
|
||
for (const e of [...entries.values(), ...pending.values()]) {
|
||
const pool = e.battle ? e.battle.sharedBoss : null;
|
||
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) || pending.get(msg.battleId));
|
||
if (!e) return;
|
||
if (msg.reason === 'takeover') {
|
||
e.authoritative = false;
|
||
if (e === cur) publishState();
|
||
schedule();
|
||
return;
|
||
}
|
||
// the first end counts; a battle still loading ends as soon as it is built (prepare)
|
||
if (!e.endReason) e.endReason = msg.reason === 'timeout' ? 'timeout' : 'forced';
|
||
if (e.battle) endBattle(e);
|
||
}
|
||
|
||
/** End an entry's battle with its b.end reason; an authority reports it at once. */
|
||
function endBattle(e) {
|
||
if (!e.battle.finished) {
|
||
try { e.battle.forceEnd(e.endReason); } 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 */ } }
|
||
}
|
||
// a battle still being prepared too: its preparation finds itself gone and stops (prepare)
|
||
pending.clear();
|
||
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 || pending.size) 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()) return; flushHidden(); 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,
|
||
/**
|
||
* The battle on screen as a report attaches it (diag.js): its b.start fields and its game time; null without one.
|
||
* @returns {{ battleId: string, fieldId: string, kind: string, spec: object, time: number|null }|null}
|
||
*/
|
||
currentBattle() {
|
||
const e = cur;
|
||
if (!e || !e.spec) return null;
|
||
const time = e.battle && Number.isFinite(e.battle.time) ? e.battle.time : null;
|
||
return { battleId: e.battleId, fieldId: e.fieldId, kind: e.kind, spec: e.spec, time };
|
||
},
|
||
stats() {
|
||
return { ...stats, avgTickMs: stats.ticks ? stats.stepMs / stats.ticks : 0, entries: entries.size, loadingSim: !!simP };
|
||
},
|
||
/**
|
||
* Live stats of unit `unitId` of the battle on screen (null: no such battle / unit, or `fieldId` names another
|
||
* field). Reads the sim's last computed stats (`unit._s`, falling back to the base) — never `unit.s`, whose lazy
|
||
* recompute would run earlier than the sim itself would run it.
|
||
* @param {number} unitId @param {string|null} [fieldId]
|
||
*/
|
||
unitStats(unitId, fieldId = null) {
|
||
const e = cur;
|
||
if (!e || !Number.isInteger(unitId) || (fieldId != null && e.fieldId !== fieldId)) return null;
|
||
let u = null;
|
||
try { u = typeof e.battle.unitById === 'function' ? e.battle.unitById(unitId) : null; } catch { u = null; }
|
||
if (!u) return null;
|
||
try { return unitStatsEntry(u, u._s || null); } catch { return null; }
|
||
},
|
||
/**
|
||
* The unit id of the board piece `uid` owned by `ownerId` in the battle on screen (an own operator's card opened in
|
||
* prep and left open into the battle turns live); null when that battle has no such unit.
|
||
* @param {number} uid @param {string} ownerId @param {string|null} [fieldId]
|
||
*/
|
||
unitIdOf(uid, ownerId, fieldId = null) {
|
||
const e = cur;
|
||
if (!e || !Number.isInteger(uid) || (fieldId != null && e.fieldId !== fieldId)) return null;
|
||
const list = Array.isArray(e.battle.allyUnits) ? e.battle.allyUnits : [];
|
||
const u = list.find((x) => x && x.uid === uid && x.ownerId === ownerId);
|
||
return u && Number.isInteger(u.id) ? u.id : null;
|
||
},
|
||
/**
|
||
* The operators `ownerId` fields in the battle on screen — their board in that battle, waiting to deploy, deployed
|
||
* or knocked out (the field meta published by show() is taken before they deploy) — as UnitInfo-like
|
||
* { kind: 'op', ownerId, defId }: a teammate's bond popup lists them as the members in play (ui/watchBonds.js
|
||
* ownerBoard). [] when no such battle is on screen.
|
||
* @param {string} ownerId @param {string|null} [fieldId]
|
||
*/
|
||
ownerOps(ownerId, fieldId = null) {
|
||
const e = cur;
|
||
if (!e || typeof ownerId !== 'string' || !ownerId || (fieldId != null && e.fieldId !== fieldId)) return [];
|
||
const list = Array.isArray(e.battle.allyUnits) ? e.battle.allyUnits : [];
|
||
return list.filter((u) => u && u.kind === 'op' && u.ownerId === ownerId && typeof u.defId === 'string')
|
||
.map((u) => {
|
||
const o = Array.isArray(u.items) && u.items.length ? { kind: 'op', ownerId, defId: u.defId, items: [...u.items] } : { kind: 'op', ownerId, defId: u.defId };
|
||
const si = u.def && typeof u.def.standInFor === 'string' ? u.def.standInFor : null;
|
||
const diy = u.def?.diyFor ? u.def.loadout?.diy : null;
|
||
return { ...o, ...(si ? { standInFor: si } : {}), ...(diy ? { diy: { ...diy } } : {}) };
|
||
});
|
||
},
|
||
/** 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
|
||
if (battleRunner) setBattleSource(() => battleRunner.currentBattle());
|