// 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); } }