Files
ark-stronghold-protocol/server/sim/battle/hooks.js
T

189 lines
7.9 KiB
JavaScript

// server/sim/battle/hooks.js — Battle methods: the hook bus (on / off / offOwner / emit, the MAX_HOOK_DEPTH guard and
// its chain diagnostics), the timers (after / every, run at the start of each tick) and the isolation of content
// callbacks (_safe, _handlerError: a failing handler is skipped and counted; repeated engine errors end the battle as a
// timeout).
// Installed on Battle.prototype by server/sim/Battle.js (a method container: never instantiated; `this` is the battle).
import { TICK, MAX_INTERNAL_ERRORS, MAX_HOOK_DEPTH } from '../constants.js';
let hookSeq = 0;
let schedSeq = 0;
export class BattleHooks {
// =============================================================================================================
// hook bus
on(name, fn, opts = {}) {
if (typeof fn !== 'function') return null;
const h = { name, fn, priority: Number(opts.priority) || 0, owner: opts.owner ?? null, seq: ++hookSeq, removed: false, once: !!opts.once };
const list = this._hooks[name] ?? (this._hooks[name] = []);
let i = list.length;
while (i > 0 && list[i - 1].priority < h.priority) i--;
list.splice(i, 0, h);
return h;
}
off(nameOrHandle, fn) {
if (nameOrHandle && typeof nameOrHandle === 'object') {
const h = nameOrHandle;
h.removed = true;
const list = this._hooks[h.name];
if (list) {
const i = list.indexOf(h);
if (i >= 0) list.splice(i, 1);
if (!list.length) delete this._hooks[h.name];
}
return;
}
const list = this._hooks[nameOrHandle];
if (!list) return;
for (let i = list.length - 1; i >= 0; i--) if (!fn || list[i].fn === fn) { list[i].removed = true; list.splice(i, 1); }
if (!list.length) delete this._hooks[nameOrHandle];
}
/** Remove every hook and scheduled callback registered with `owner`. */
offOwner(owner) {
for (const name of Object.keys(this._hooks)) {
const list = this._hooks[name];
for (let i = list.length - 1; i >= 0; i--) if (list[i].owner === owner) { list[i].removed = true; list.splice(i, 1); }
if (!list.length) delete this._hooks[name];
}
for (const s of this._sched) if (s.owner === owner) s.cancelled = true;
}
hasHook(name) { return !!this._hooks[name]; }
emit(name, ctx) {
const list = this._hooks[name];
if (!list) return ctx;
// Recursion guard: handlers that re-trigger their own event (damaged → dealDamage → damaged …) would otherwise
// recurse until a stack overflow, whose depth (and thus the outcome) depends on the machine — not deterministic.
if (this._emitDepth >= MAX_HOOK_DEPTH) {
this._handlerError(`hookDepth:${name}`, null, new Error(`hook nesting deeper than ${MAX_HOOK_DEPTH}; handlers skipped — ${this._chain(name)}`));
return ctx;
}
const arr = list.length === 1 ? [list[0]] : list.slice();
const d = this._emitDepth++;
this._frameCtx[d] = ctx;
try {
for (const h of arr) {
if (h.removed) continue;
this._frameName[d] = name;
this._frameOwner[d] = h.owner;
try { h.fn(ctx, this); } catch (e) { this._handlerError(`hook:${name}`, h.owner, e); }
if (h.once) this.off(h);
if (ctx && ctx.stopPropagation) break;
}
} finally {
this._emitDepth--;
}
return ctx;
}
/**
* The open hook/callback frames (outermost first) — names the loop when the nesting guard trips, e.g.
* "chain: hit(enemy_1430_lrrook) > damaged(chess_char_3_18_a) > hit(enemy_1430_lrrook) > … > damaged".
*/
_chain(last) {
const id = (u) => (u && typeof u === 'object' ? (u.defId ?? u.name ?? '?') : u == null ? '' : String(u));
const lab = (i) => {
const o = this._frameOwner[i];
let who = id(o);
const c = this._frameCtx[i];
if (!who && c && typeof c === 'object') { // global handler: show the units of the event instead
const a = c.source ?? c.attacker ?? c.killer ?? null, t = c.target ?? c.unit ?? c.victim ?? c.enemy ?? null;
who = a || t ? `${id(a)}→${id(t)}` : '';
}
return `${this._frameName[i]}${who ? `(${who})` : ''}`;
};
const n = this._emitDepth;
const parts = [];
for (let i = 0; i < Math.min(n, 4); i++) parts.push(lab(i));
if (n > 12) parts.push('…');
for (let i = Math.max(4, n - 8); i < n; i++) parts.push(lab(i));
parts.push(last);
return `chain: ${parts.join(' > ')}`;
}
// =============================================================================================================
// scheduling
after(seconds, fn, opts = {}) {
const s = { due: this.time + Math.max(0, Number(seconds) || 0), fn, owner: opts.owner ?? null, interval: 0, seq: ++schedSeq, cancelled: false, holdsBattle: !!opts.holdsBattle };
s.cancel = () => { s.cancelled = true; };
this._sched.push(s);
return s;
}
every(seconds, fn, opts = {}) {
const iv = Math.max(TICK, Number(seconds) || TICK);
const s = { due: this.time + (opts.immediate ? 0 : iv), fn, owner: opts.owner ?? null, interval: iv, seq: ++schedSeq, cancelled: false, count: 0, holdsBattle: false };
s.cancel = () => { s.cancelled = true; };
this._sched.push(s);
return s;
}
_runScheduled() {
if (!this._sched.length) return;
const now = this.time + 1e-9;
let due = this._sched.filter((s) => !s.cancelled && s.due <= now);
if (!due.length) { if (this._sched.some((s) => s.cancelled)) this._sched = this._sched.filter((s) => !s.cancelled); return; }
due.sort((a, b) => a.due - b.due || a.seq - b.seq);
for (const s of due) {
if (s.cancelled) continue;
if (s.interval > 0) {
let n = 0;
while (s.due <= now && !s.cancelled && n++ < 8) {
s.count++;
this._safe(() => s.fn(this, s), 'every', s.owner);
s.due += s.interval;
}
if (s.due <= now) s.due = this.time + s.interval;
} else {
s.cancelled = true;
this._safe(() => s.fn(this, s), 'after', s.owner);
}
}
this._sched = this._sched.filter((s) => !s.cancelled);
}
// =============================================================================================================
// callback isolation and errors
/** Run a content callback (skill/buff/projectile/timer/install) isolated from errors; shares the hook depth guard. */
_safe(fn, label, owner = null) {
if (this._emitDepth >= MAX_HOOK_DEPTH) {
this._handlerError(`hookDepth:${label}`, owner, new Error(`callback nesting deeper than ${MAX_HOOK_DEPTH}; skipped — ${this._chain(label)}`));
return undefined;
}
const d = this._emitDepth++;
this._frameName[d] = label;
this._frameOwner[d] = owner;
this._frameCtx[d] = null;
try { return fn(); } catch (e) { this._handlerError(label, owner, e); return undefined; } finally { this._emitDepth--; }
}
_handlerError(label, owner, e, internal = false) {
this.errorCount++;
const who = owner && owner.defId ? owner.defId : (owner && owner.name) || '';
const key = `${label}|${who}|${e && e.message}`;
if (!this._errKeys.has(key)) {
this._errKeys.add(key);
if (this.errors.length < 100) this.errors.push({ label, who, message: String(e && e.message), stack: e && e.stack });
if (this.opts.quiet !== true) this.logger.error?.(`[sim] ${label}${who ? ' (' + who + ')' : ''} failed: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`);
}
// Content handler errors are isolated (the handler is skipped, the battle continues). Repeated *engine*
// errors mean the field state is suspect: force-end it as a timeout (DESIGN §11).
if (internal) {
this.internalErrorCount = (this.internalErrorCount ?? 0) + 1;
if (this.internalErrorCount > MAX_INTERNAL_ERRORS && !this.finished && !this._forcing) {
this._forcing = true;
try { this.forceEnd('timeout'); } catch { this._hardFinish('timeout'); }
}
}
}
_internalError(label, e) { this._handlerError('internal:' + label, null, e, true); }
}