@sadhaka/loom-engine 0.15.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audio/cue-catalog.d.ts +5 -1
- package/dist/audio/cue-catalog.d.ts.map +1 -1
- package/dist/audio/cue-catalog.js +14 -9
- package/dist/audio/cue-catalog.js.map +1 -1
- package/dist/director/director-bridge.d.ts +5 -0
- package/dist/director/director-bridge.d.ts.map +1 -1
- package/dist/director/director-bridge.js.map +1 -1
- package/dist/director/director-system.d.ts.map +1 -1
- package/dist/director/director-system.js +4 -1
- package/dist/director/director-system.js.map +1 -1
- package/dist/director/mock-director-bridge.d.ts.map +1 -1
- package/dist/director/mock-director-bridge.js +5 -0
- package/dist/director/mock-director-bridge.js.map +1 -1
- package/dist/director/sse-director-bridge.d.ts +20 -0
- package/dist/director/sse-director-bridge.d.ts.map +1 -1
- package/dist/director/sse-director-bridge.js +251 -66
- package/dist/director/sse-director-bridge.js.map +1 -1
- package/dist/director/zone/zone-boss-entity-system.d.ts +9 -0
- package/dist/director/zone/zone-boss-entity-system.d.ts.map +1 -0
- package/dist/director/zone/zone-boss-entity-system.js +114 -0
- package/dist/director/zone/zone-boss-entity-system.js.map +1 -0
- package/dist/director/zone/zone-boss-entity.d.ts +30 -0
- package/dist/director/zone/zone-boss-entity.d.ts.map +1 -0
- package/dist/director/zone/zone-boss-entity.js +103 -0
- package/dist/director/zone/zone-boss-entity.js.map +1 -0
- package/dist/director/zone/zone-event-system.d.ts.map +1 -1
- package/dist/director/zone/zone-event-system.js +6 -2
- package/dist/director/zone/zone-event-system.js.map +1 -1
- package/dist/engine.d.ts +1 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +6 -0
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/client-registry.d.ts +61 -0
- package/dist/plugins/client-registry.d.ts.map +1 -0
- package/dist/plugins/client-registry.js +994 -0
- package/dist/plugins/client-registry.js.map +1 -0
- package/dist/plugins/index.d.ts +5 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +20 -0
- package/dist/plugins/index.js.map +1 -0
- package/dist/plugins/types.d.ts +105 -0
- package/dist/plugins/types.d.ts.map +1 -0
- package/dist/plugins/types.js +112 -0
- package/dist/plugins/types.js.map +1 -0
- package/dist/runtime/entropy.d.ts +22 -0
- package/dist/runtime/entropy.d.ts.map +1 -0
- package/dist/runtime/entropy.js +122 -0
- package/dist/runtime/entropy.js.map +1 -0
- package/dist/systems/attack-system.d.ts.map +1 -1
- package/dist/systems/attack-system.js +6 -2
- package/dist/systems/attack-system.js.map +1 -1
- package/dist/systems/damage-system.d.ts.map +1 -1
- package/dist/systems/damage-system.js +6 -1
- package/dist/systems/damage-system.js.map +1 -1
- package/dist/systems/particle-emitter-system.d.ts.map +1 -1
- package/dist/systems/particle-emitter-system.js +20 -6
- package/dist/systems/particle-emitter-system.js.map +1 -1
- package/dist/systems/peer-presence-system.d.ts.map +1 -1
- package/dist/systems/peer-presence-system.js +6 -1
- package/dist/systems/peer-presence-system.js.map +1 -1
- package/dist/systems/projectile-system.d.ts.map +1 -1
- package/dist/systems/projectile-system.js +5 -1
- package/dist/systems/projectile-system.js.map +1 -1
- package/dist/systems/pursue-system.d.ts.map +1 -1
- package/dist/systems/pursue-system.js +5 -1
- package/dist/systems/pursue-system.js.map +1 -1
- package/dist/systems/ranged-attack-system.d.ts.map +1 -1
- package/dist/systems/ranged-attack-system.js +5 -1
- package/dist/systems/ranged-attack-system.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
// ClientPluginRegistry - dispatches lifecycle hooks across registered
|
|
2
|
+
// client-side Loom plugins.
|
|
3
|
+
//
|
|
4
|
+
// Browser-side companion of api/loom_ai_plugin_runtime.py
|
|
5
|
+
// AIPluginRegistry. Same shape, adapted to the browser:
|
|
6
|
+
// - No asyncio. Promise + async/await throughout.
|
|
7
|
+
// - No per-character v1 stream. Plugins react to zone-events only.
|
|
8
|
+
// - Routes window.dispatchEvent('arpg:zone-*') CustomEvents through
|
|
9
|
+
// onZoneEvent; the host (typically the ARPG-loom IIFE) keeps
|
|
10
|
+
// dispatching custom events as before, and registered plugins
|
|
11
|
+
// opt in via the registry instead of attaching listeners
|
|
12
|
+
// themselves.
|
|
13
|
+
// - reload(name) re-imports a plugin module via dynamic import,
|
|
14
|
+
// bypassing the browser cache by appending a cache-bust query.
|
|
15
|
+
//
|
|
16
|
+
// Error isolation: if a plugin's hook throws, the registry logs the
|
|
17
|
+
// failure via the plugin's logger, drops that plugin's contribution
|
|
18
|
+
// for THIS dispatch only, continues with the next plugin. Dispatch
|
|
19
|
+
// never throws to the caller. Plugin authors are still expected to
|
|
20
|
+
// catch in their own hooks - this is the safety net.
|
|
21
|
+
//
|
|
22
|
+
// House rules: var only, no arrow functions in browser-bound src/,
|
|
23
|
+
// short dashes, defensive try/catch.
|
|
24
|
+
import { PluginEntropy, PluginError, ALL_SCOPES, DEFAULT_PLUGIN_STORAGE_MAX_BYTES, DEFAULT_PLUGIN_TICK_BUDGET_MS, } from './types.js';
|
|
25
|
+
// ----- MapPluginStorage -----
|
|
26
|
+
//
|
|
27
|
+
// In-memory PluginStorage. Mirror of the Python MapPluginStorage.
|
|
28
|
+
// Sufficient for a single-tab session; consumers wanting persistence
|
|
29
|
+
// across tab reloads can swap in an IndexedDB-backed adapter without
|
|
30
|
+
// touching the registry.
|
|
31
|
+
export class MapPluginStorage {
|
|
32
|
+
data = new Map();
|
|
33
|
+
async get(key) {
|
|
34
|
+
return this.data.get(String(key));
|
|
35
|
+
}
|
|
36
|
+
async set(key, value) {
|
|
37
|
+
this.data.set(String(key), value);
|
|
38
|
+
}
|
|
39
|
+
async delete(key) {
|
|
40
|
+
this.data.delete(String(key));
|
|
41
|
+
}
|
|
42
|
+
resetForTest() {
|
|
43
|
+
this.data.clear();
|
|
44
|
+
}
|
|
45
|
+
// Inspect-only: total entries. Useful in tests.
|
|
46
|
+
size() {
|
|
47
|
+
return this.data.size;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// ----- ConsolePluginLogger -----
|
|
51
|
+
//
|
|
52
|
+
// Tags every line with [plugin: <name>] and forwards to the matching
|
|
53
|
+
// console method. Meta is JSON-stringified; circular refs fall back
|
|
54
|
+
// to a short description so logging never throws at the boundary.
|
|
55
|
+
export class ConsolePluginLogger {
|
|
56
|
+
pluginName;
|
|
57
|
+
constructor(pluginName) {
|
|
58
|
+
this.pluginName = pluginName;
|
|
59
|
+
}
|
|
60
|
+
info(msg, meta) {
|
|
61
|
+
this.write('info', msg, meta);
|
|
62
|
+
}
|
|
63
|
+
warn(msg, meta) {
|
|
64
|
+
this.write('warn', msg, meta);
|
|
65
|
+
}
|
|
66
|
+
error(msg, meta) {
|
|
67
|
+
this.write('error', msg, meta);
|
|
68
|
+
}
|
|
69
|
+
write(level, msg, meta) {
|
|
70
|
+
var tag = '[plugin: ' + this.pluginName + ']';
|
|
71
|
+
var line = tag + ' ' + msg;
|
|
72
|
+
var metaStr = '';
|
|
73
|
+
if (meta) {
|
|
74
|
+
try {
|
|
75
|
+
metaStr = ' ' + JSON.stringify(meta);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
metaStr = ' [meta-not-serializable]';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
var out = line + metaStr;
|
|
82
|
+
try {
|
|
83
|
+
if (level === 'info')
|
|
84
|
+
console.info(out);
|
|
85
|
+
else if (level === 'warn')
|
|
86
|
+
console.warn(out);
|
|
87
|
+
else
|
|
88
|
+
console.error(out);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Logger errors must never break dispatch.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// ----- TTL helpers -----
|
|
96
|
+
//
|
|
97
|
+
// Mirror of the Python set_with_ttl / get_with_ttl_check. TTL layers
|
|
98
|
+
// over any PluginStorage so plugin authors can use it on the default
|
|
99
|
+
// MapPluginStorage or a custom IndexedDB adapter without changes.
|
|
100
|
+
const TTL_ENVELOPE_TAG = '__loom_ttl_v1__';
|
|
101
|
+
export async function setWithTtl(storage, key, value, ttlMs, nowFn) {
|
|
102
|
+
var now = nowFn ? nowFn() : Date.now();
|
|
103
|
+
var ttl = Number(ttlMs) || 0;
|
|
104
|
+
var envelope = {};
|
|
105
|
+
envelope[TTL_ENVELOPE_TAG] = 1;
|
|
106
|
+
envelope.value = value;
|
|
107
|
+
envelope.expires_at_ms = now + ttl;
|
|
108
|
+
await storage.set(key, envelope);
|
|
109
|
+
}
|
|
110
|
+
export async function getWithTtlCheck(storage, key, nowFn) {
|
|
111
|
+
var raw = await storage.get(key);
|
|
112
|
+
if (raw === undefined || raw === null)
|
|
113
|
+
return undefined;
|
|
114
|
+
if (typeof raw === 'object' && raw !== null) {
|
|
115
|
+
var obj = raw;
|
|
116
|
+
if (obj[TTL_ENVELOPE_TAG] === 1) {
|
|
117
|
+
var expires = Number(obj.expires_at_ms) || 0;
|
|
118
|
+
var now = nowFn ? nowFn() : Date.now();
|
|
119
|
+
if (expires > 0 && expires < now) {
|
|
120
|
+
try {
|
|
121
|
+
await storage.delete(key);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Lazy delete is best-effort; swallow.
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
return obj.value;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return raw;
|
|
132
|
+
}
|
|
133
|
+
// ----- CountingStorageWrapper -----
|
|
134
|
+
//
|
|
135
|
+
// Wraps any PluginStorage and adds: per-plugin ops counters
|
|
136
|
+
// (delegated to a stats object), a per-plugin byte cap (rejects
|
|
137
|
+
// set() over the cap with PluginError('storage_quota_exceeded')),
|
|
138
|
+
// and approximate byte tracking based on JSON-stringified value
|
|
139
|
+
// size. Mirror of Python CountingStorageWrapper.
|
|
140
|
+
class CountingStorageWrapper {
|
|
141
|
+
inner;
|
|
142
|
+
stats;
|
|
143
|
+
maxBytes;
|
|
144
|
+
pluginName;
|
|
145
|
+
constructor(inner, stats, maxBytes, pluginName) {
|
|
146
|
+
this.inner = inner;
|
|
147
|
+
this.stats = stats;
|
|
148
|
+
this.maxBytes = maxBytes;
|
|
149
|
+
this.pluginName = pluginName;
|
|
150
|
+
}
|
|
151
|
+
async get(key) {
|
|
152
|
+
this.stats.storage_get_count += 1;
|
|
153
|
+
return this.inner.get(key);
|
|
154
|
+
}
|
|
155
|
+
async set(key, value) {
|
|
156
|
+
var vSize = 0;
|
|
157
|
+
try {
|
|
158
|
+
vSize = JSON.stringify(value).length;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
vSize = 0;
|
|
162
|
+
}
|
|
163
|
+
var projected = this.stats.storage_bytes_used + vSize;
|
|
164
|
+
if (this.maxBytes > 0 && projected > this.maxBytes) {
|
|
165
|
+
this.stats.storage_caps_rejected += 1;
|
|
166
|
+
throw new PluginError('storage_quota_exceeded', false, this.pluginName);
|
|
167
|
+
}
|
|
168
|
+
this.stats.storage_set_count += 1;
|
|
169
|
+
this.stats.storage_bytes_used = projected;
|
|
170
|
+
await this.inner.set(key, value);
|
|
171
|
+
}
|
|
172
|
+
async delete(key) {
|
|
173
|
+
this.stats.storage_delete_count += 1;
|
|
174
|
+
// Note: byte tracking is approximate - we do not decrement on
|
|
175
|
+
// delete because we'd need to read the prior value. Operators
|
|
176
|
+
// should treat storage_bytes_used as 'lifetime bytes written',
|
|
177
|
+
// not 'currently resident'. Same posture as the Python runtime.
|
|
178
|
+
await this.inner.delete(key);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// ----- buildEmptyStats -----
|
|
182
|
+
function buildEmptyStats() {
|
|
183
|
+
return {
|
|
184
|
+
storage_set_count: 0,
|
|
185
|
+
storage_get_count: 0,
|
|
186
|
+
storage_delete_count: 0,
|
|
187
|
+
storage_bytes_used: 0,
|
|
188
|
+
storage_caps_rejected: 0,
|
|
189
|
+
hook_call_count: 0,
|
|
190
|
+
hook_timeout_count: 0,
|
|
191
|
+
hook_error_count: 0,
|
|
192
|
+
hook_retry_count: 0,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
// ----- ClientPluginRegistry -----
|
|
196
|
+
export class ClientPluginRegistry {
|
|
197
|
+
// Sorted by priority ascending, registration order on ties.
|
|
198
|
+
plugins = [];
|
|
199
|
+
// Per-plugin in-memory MapPluginStorage. Wrapped in a
|
|
200
|
+
// CountingStorageWrapper before being handed to plugins.
|
|
201
|
+
storageByName = new Map();
|
|
202
|
+
// Per-plugin logger.
|
|
203
|
+
loggersByName = new Map();
|
|
204
|
+
// Per-plugin ops stats.
|
|
205
|
+
statsByName = new Map();
|
|
206
|
+
opts;
|
|
207
|
+
// DOM bridge state. The registry attaches a single capturing
|
|
208
|
+
// listener per prefix to the eventTarget; that listener fans out
|
|
209
|
+
// to dispatchZoneEvent. Storing the bound function lets dispose()
|
|
210
|
+
// remove the listeners cleanly.
|
|
211
|
+
bridgedHandlers = [];
|
|
212
|
+
constructor(options) {
|
|
213
|
+
var opts = options || {};
|
|
214
|
+
var defaultTarget = null;
|
|
215
|
+
try {
|
|
216
|
+
defaultTarget = (typeof globalThis !== 'undefined' && globalThis.window) ? globalThis.window : null;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
defaultTarget = null;
|
|
220
|
+
}
|
|
221
|
+
this.opts = {
|
|
222
|
+
now: opts.now || function () { return Date.now(); },
|
|
223
|
+
getZonePeers: opts.getZonePeers || function () { return []; },
|
|
224
|
+
getZoneState: opts.getZoneState || function () { return new Map(); },
|
|
225
|
+
getZoneEventsTail: opts.getZoneEventsTail || function () { return []; },
|
|
226
|
+
eventTarget: opts.eventTarget === undefined ? defaultTarget : opts.eventTarget,
|
|
227
|
+
eventPrefixes: opts.eventPrefixes && opts.eventPrefixes.length > 0
|
|
228
|
+
? opts.eventPrefixes
|
|
229
|
+
: ['arpg:zone-'],
|
|
230
|
+
};
|
|
231
|
+
this.attachBridge();
|
|
232
|
+
}
|
|
233
|
+
// ----- Bridge -----
|
|
234
|
+
//
|
|
235
|
+
// Attaches one capturing listener for each known prefix. The host
|
|
236
|
+
// dispatches CustomEvents named e.g. 'arpg:zone-boss-spawn' with
|
|
237
|
+
// detail = a ZoneEventEnvelope-shaped payload. The registry pulls
|
|
238
|
+
// detail and routes through dispatchZoneEvent.
|
|
239
|
+
attachBridge() {
|
|
240
|
+
var target = this.opts.eventTarget;
|
|
241
|
+
if (!target)
|
|
242
|
+
return;
|
|
243
|
+
var self = this;
|
|
244
|
+
// We attach a single 'arpg:zone-*' wildcard via attaching one
|
|
245
|
+
// handler per known event type. Browsers do not support wildcard
|
|
246
|
+
// listeners on EventTarget, so we lazily attach when a plugin
|
|
247
|
+
// first registers AND the host has dispatched at least one such
|
|
248
|
+
// event. Simplest correct approach: rely on the host to also
|
|
249
|
+
// dispatch a generic 'arpg:zone-event' or call dispatchZoneEvent
|
|
250
|
+
// directly. For ergonomics we ALSO attach to the well-known
|
|
251
|
+
// arpg:zone-* event names that ARPG-loom currently dispatches.
|
|
252
|
+
var knownTypes = [
|
|
253
|
+
'arpg:zone-boss-spawn',
|
|
254
|
+
'arpg:zone-boss-tick',
|
|
255
|
+
'arpg:zone-boss-end',
|
|
256
|
+
'arpg:zone-narrator',
|
|
257
|
+
'arpg:zone-knot',
|
|
258
|
+
'arpg:zone-state',
|
|
259
|
+
'arpg:zone-snapshot',
|
|
260
|
+
];
|
|
261
|
+
for (var i = 0; i < knownTypes.length; i++) {
|
|
262
|
+
var t = knownTypes[i];
|
|
263
|
+
if (!t)
|
|
264
|
+
continue;
|
|
265
|
+
var handler = function (ev) {
|
|
266
|
+
try {
|
|
267
|
+
var ce = ev;
|
|
268
|
+
var detail = ce && ce.detail;
|
|
269
|
+
if (detail && typeof detail === 'object') {
|
|
270
|
+
// Fire-and-forget; dispatchZoneEvent is async but the
|
|
271
|
+
// browser CustomEvent contract is synchronous and the
|
|
272
|
+
// registry's error isolation ensures we never throw
|
|
273
|
+
// back into the dispatcher.
|
|
274
|
+
self.dispatchZoneEvent(detail).catch(function () {
|
|
275
|
+
// Swallowed - error isolation is per-plugin inside
|
|
276
|
+
// dispatchZoneEvent; this catch only fires if the
|
|
277
|
+
// outer dispatch frame itself rejects.
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// Bridge listener must never throw.
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
try {
|
|
286
|
+
target.addEventListener(t, handler);
|
|
287
|
+
this.bridgedHandlers.push({ prefix: this.opts.eventPrefixes[0] || 'arpg:zone-', type: t, handler });
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// Some headless environments may reject addEventListener for
|
|
291
|
+
// certain types - skip silently.
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
detachBridge() {
|
|
296
|
+
var target = this.opts.eventTarget;
|
|
297
|
+
if (!target) {
|
|
298
|
+
this.bridgedHandlers = [];
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
for (var i = 0; i < this.bridgedHandlers.length; i++) {
|
|
302
|
+
var h = this.bridgedHandlers[i];
|
|
303
|
+
if (!h)
|
|
304
|
+
continue;
|
|
305
|
+
try {
|
|
306
|
+
target.removeEventListener(h.type, h.handler);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
// Ignore detach errors.
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
this.bridgedHandlers = [];
|
|
313
|
+
}
|
|
314
|
+
// ----- Lifecycle -----
|
|
315
|
+
// Register a plugin. Allocates fresh storage / logger / stats.
|
|
316
|
+
// Re-registering a plugin with the same name replaces it (the
|
|
317
|
+
// previous instance's storage is reset). This matches the Python
|
|
318
|
+
// registry's posture, not the v0.16 server-side TS registry which
|
|
319
|
+
// throws on duplicate - the client surface is meant to be reload-
|
|
320
|
+
// friendly.
|
|
321
|
+
register(plugin) {
|
|
322
|
+
if (!plugin)
|
|
323
|
+
throw new Error('plugin required');
|
|
324
|
+
var name = String(plugin.name || '').trim();
|
|
325
|
+
if (!name)
|
|
326
|
+
throw new Error('plugin.name required');
|
|
327
|
+
// Replace if name already registered.
|
|
328
|
+
var idx = -1;
|
|
329
|
+
for (var i = 0; i < this.plugins.length; i++) {
|
|
330
|
+
var existing = this.plugins[i];
|
|
331
|
+
if (existing && existing.name === name) {
|
|
332
|
+
idx = i;
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (idx !== -1) {
|
|
337
|
+
this.plugins.splice(idx, 1);
|
|
338
|
+
this.storageByName.delete(name);
|
|
339
|
+
this.loggersByName.delete(name);
|
|
340
|
+
this.statsByName.delete(name);
|
|
341
|
+
}
|
|
342
|
+
// Insert in priority order; lower priority first; ties keep
|
|
343
|
+
// registration order.
|
|
344
|
+
var insertAt = this.plugins.length;
|
|
345
|
+
for (var j = 0; j < this.plugins.length; j++) {
|
|
346
|
+
var p = this.plugins[j];
|
|
347
|
+
if (p && p.priority > plugin.priority) {
|
|
348
|
+
insertAt = j;
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
this.plugins.splice(insertAt, 0, plugin);
|
|
353
|
+
this.storageByName.set(name, new MapPluginStorage());
|
|
354
|
+
this.loggersByName.set(name, new ConsolePluginLogger(name));
|
|
355
|
+
this.statsByName.set(name, buildEmptyStats());
|
|
356
|
+
}
|
|
357
|
+
// Unregister a plugin by name. Awaits dispose() if defined; logs
|
|
358
|
+
// and drops if dispose throws. Returns true if a plugin was
|
|
359
|
+
// removed, false if no plugin with that name was registered.
|
|
360
|
+
async unregister(name) {
|
|
361
|
+
var n = String(name || '').trim();
|
|
362
|
+
if (!n)
|
|
363
|
+
return false;
|
|
364
|
+
var idx = -1;
|
|
365
|
+
for (var i = 0; i < this.plugins.length; i++) {
|
|
366
|
+
var p = this.plugins[i];
|
|
367
|
+
if (p && p.name === n) {
|
|
368
|
+
idx = i;
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (idx === -1)
|
|
373
|
+
return false;
|
|
374
|
+
var plugin = this.plugins[idx];
|
|
375
|
+
this.plugins.splice(idx, 1);
|
|
376
|
+
this.storageByName.delete(n);
|
|
377
|
+
this.loggersByName.delete(n);
|
|
378
|
+
this.statsByName.delete(n);
|
|
379
|
+
if (plugin && typeof plugin.dispose === 'function') {
|
|
380
|
+
try {
|
|
381
|
+
var result = plugin.dispose();
|
|
382
|
+
if (result && typeof result.then === 'function') {
|
|
383
|
+
await result;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
try {
|
|
388
|
+
console.error('[plugin-registry] dispose for ' + n + ' failed:', err);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
// Logger errors must never throw.
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
// Hot-reload a plugin by re-importing its source module via dynamic
|
|
398
|
+
// import (browser-side cache-bust by appending a query string).
|
|
399
|
+
// Steps:
|
|
400
|
+
// 1. Look up the registered plugin instance.
|
|
401
|
+
// 2. dynamic-import the moduleSpecifier with a cache-bust query.
|
|
402
|
+
// 3. Read exportName from the module (default to the plugin's
|
|
403
|
+
// class name when not provided).
|
|
404
|
+
// 4. Construct a new instance via `new Cls()`.
|
|
405
|
+
// 5. unregister + register so the new instance takes over.
|
|
406
|
+
// 6. Return the new describe row, or null on failure.
|
|
407
|
+
//
|
|
408
|
+
// The registry serialises reload behind a per-name lock so a
|
|
409
|
+
// dispatch cannot land mid-reload. Heavy on the fast path -
|
|
410
|
+
// intended for development + ops triggers, not the request loop.
|
|
411
|
+
async reload(name, moduleSpecifier, exportName) {
|
|
412
|
+
var n = String(name || '').trim();
|
|
413
|
+
if (!n)
|
|
414
|
+
return null;
|
|
415
|
+
var current;
|
|
416
|
+
for (var i = 0; i < this.plugins.length; i++) {
|
|
417
|
+
var p = this.plugins[i];
|
|
418
|
+
if (p && p.name === n) {
|
|
419
|
+
current = p;
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (!current)
|
|
424
|
+
return null;
|
|
425
|
+
// No moduleSpecifier supplied AND no host way to find one means
|
|
426
|
+
// we cannot reload - return null. A future revision could keep
|
|
427
|
+
// an internal map of name -> moduleSpecifier set at register()
|
|
428
|
+
// time; for now we leave it explicit so plugin authors opt in.
|
|
429
|
+
if (!moduleSpecifier)
|
|
430
|
+
return null;
|
|
431
|
+
var bustQuery = '?v=' + String(Date.now());
|
|
432
|
+
var url = String(moduleSpecifier) + bustQuery;
|
|
433
|
+
var mod;
|
|
434
|
+
try {
|
|
435
|
+
// The dynamic-import RHS is a string at runtime - TS does not
|
|
436
|
+
// know the module shape so we type the result as an unknown
|
|
437
|
+
// record and pull the export by name.
|
|
438
|
+
mod = (await import(/* @vite-ignore */ url));
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
try {
|
|
442
|
+
console.error('[plugin-registry] reload import failed for ' + n + ':', err);
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
// ignore
|
|
446
|
+
}
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
var exportedKey = exportName || (current.constructor && current.constructor.name) || n;
|
|
450
|
+
var Cls = mod[exportedKey];
|
|
451
|
+
if (typeof Cls !== 'function') {
|
|
452
|
+
try {
|
|
453
|
+
console.error('[plugin-registry] reload could not find export ' + exportedKey + ' in module ' + moduleSpecifier);
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
// ignore
|
|
457
|
+
}
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
var instance;
|
|
461
|
+
try {
|
|
462
|
+
instance = new Cls();
|
|
463
|
+
}
|
|
464
|
+
catch (err) {
|
|
465
|
+
try {
|
|
466
|
+
console.error('[plugin-registry] reload re-instantiate failed for ' + n + ':', err);
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
// ignore
|
|
470
|
+
}
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
await this.unregister(n);
|
|
474
|
+
this.register(instance);
|
|
475
|
+
var rows = this.describe();
|
|
476
|
+
for (var k = 0; k < rows.length; k++) {
|
|
477
|
+
var row = rows[k];
|
|
478
|
+
if (row && row.name === n)
|
|
479
|
+
return row;
|
|
480
|
+
}
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
list() {
|
|
484
|
+
return this.plugins.slice();
|
|
485
|
+
}
|
|
486
|
+
get(name) {
|
|
487
|
+
var n = String(name || '').trim();
|
|
488
|
+
for (var i = 0; i < this.plugins.length; i++) {
|
|
489
|
+
var p = this.plugins[i];
|
|
490
|
+
if (p && p.name === n)
|
|
491
|
+
return p;
|
|
492
|
+
}
|
|
493
|
+
return undefined;
|
|
494
|
+
}
|
|
495
|
+
// Drop every registered plugin. Tests call this between blocks so
|
|
496
|
+
// state stays isolated.
|
|
497
|
+
async resetForTest() {
|
|
498
|
+
var snapshot = this.plugins.slice();
|
|
499
|
+
for (var i = 0; i < snapshot.length; i++) {
|
|
500
|
+
var p = snapshot[i];
|
|
501
|
+
if (!p)
|
|
502
|
+
continue;
|
|
503
|
+
try {
|
|
504
|
+
await this.unregister(p.name);
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
// Best-effort.
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
this.plugins = [];
|
|
511
|
+
this.storageByName.clear();
|
|
512
|
+
this.loggersByName.clear();
|
|
513
|
+
this.statsByName.clear();
|
|
514
|
+
}
|
|
515
|
+
// ----- Describe -----
|
|
516
|
+
describe() {
|
|
517
|
+
var hookNames = [
|
|
518
|
+
'onZoneEvent',
|
|
519
|
+
'onPreTick',
|
|
520
|
+
'onPostTick',
|
|
521
|
+
'onBossSpawn',
|
|
522
|
+
'onBossEnd',
|
|
523
|
+
'onLootDrop',
|
|
524
|
+
'dispose',
|
|
525
|
+
];
|
|
526
|
+
var out = [];
|
|
527
|
+
for (var i = 0; i < this.plugins.length; i++) {
|
|
528
|
+
var plugin = this.plugins[i];
|
|
529
|
+
if (!plugin)
|
|
530
|
+
continue;
|
|
531
|
+
var hooks = [];
|
|
532
|
+
for (var h = 0; h < hookNames.length; h++) {
|
|
533
|
+
var hookName = hookNames[h];
|
|
534
|
+
if (!hookName)
|
|
535
|
+
continue;
|
|
536
|
+
var hookFn = plugin[hookName];
|
|
537
|
+
if (typeof hookFn === 'function')
|
|
538
|
+
hooks.push(hookName);
|
|
539
|
+
}
|
|
540
|
+
var requires = String(plugin.requiresProtocol || '');
|
|
541
|
+
var supersedes = (plugin.supersedesPlugins || []).map(function (s) { return String(s); });
|
|
542
|
+
var tags = (plugin.tags || []).map(function (t) { return String(t); });
|
|
543
|
+
var description = String(plugin.description || '');
|
|
544
|
+
var name = String(plugin.name);
|
|
545
|
+
var version = String(plugin.version);
|
|
546
|
+
var priority = Number(plugin.priority) | 0;
|
|
547
|
+
var tickBudgetMs = Number(plugin.tickBudgetMs);
|
|
548
|
+
if (!isFinite(tickBudgetMs) || tickBudgetMs <= 0)
|
|
549
|
+
tickBudgetMs = DEFAULT_PLUGIN_TICK_BUDGET_MS;
|
|
550
|
+
var storageMaxBytes = Number(plugin.storageMaxBytes);
|
|
551
|
+
if (!isFinite(storageMaxBytes) || storageMaxBytes < 0)
|
|
552
|
+
storageMaxBytes = DEFAULT_PLUGIN_STORAGE_MAX_BYTES;
|
|
553
|
+
var declared = plugin.requiredScopes;
|
|
554
|
+
var scopes;
|
|
555
|
+
if (!declared) {
|
|
556
|
+
scopes = ALL_SCOPES.slice().sort();
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
var seen = {};
|
|
560
|
+
var arr = [];
|
|
561
|
+
for (var s = 0; s < declared.length; s++) {
|
|
562
|
+
var sc = String(declared[s]);
|
|
563
|
+
if (!seen[sc]) {
|
|
564
|
+
seen[sc] = true;
|
|
565
|
+
arr.push(sc);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
scopes = arr.sort();
|
|
569
|
+
}
|
|
570
|
+
var stats = this.statsByName.get(name) || buildEmptyStats();
|
|
571
|
+
// Snapshot stats so external mutation doesn't bleed into the
|
|
572
|
+
// returned row.
|
|
573
|
+
var snapshotStats = {
|
|
574
|
+
storage_set_count: stats.storage_set_count,
|
|
575
|
+
storage_get_count: stats.storage_get_count,
|
|
576
|
+
storage_delete_count: stats.storage_delete_count,
|
|
577
|
+
storage_bytes_used: stats.storage_bytes_used,
|
|
578
|
+
storage_caps_rejected: stats.storage_caps_rejected,
|
|
579
|
+
hook_call_count: stats.hook_call_count,
|
|
580
|
+
hook_timeout_count: stats.hook_timeout_count,
|
|
581
|
+
hook_error_count: stats.hook_error_count,
|
|
582
|
+
hook_retry_count: stats.hook_retry_count,
|
|
583
|
+
};
|
|
584
|
+
out.push({
|
|
585
|
+
name: name,
|
|
586
|
+
version: version,
|
|
587
|
+
priority: priority,
|
|
588
|
+
requires_protocol: requires,
|
|
589
|
+
supersedes_plugins: supersedes,
|
|
590
|
+
tags: tags,
|
|
591
|
+
description: description,
|
|
592
|
+
hooks: hooks,
|
|
593
|
+
tick_budget_ms: tickBudgetMs,
|
|
594
|
+
storage_max_bytes: storageMaxBytes,
|
|
595
|
+
scopes: scopes,
|
|
596
|
+
stats: snapshotStats,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
return out;
|
|
600
|
+
}
|
|
601
|
+
// ----- Per-plugin context -----
|
|
602
|
+
makeCtx(plugin) {
|
|
603
|
+
var name = String(plugin.name);
|
|
604
|
+
var inner = this.storageByName.get(name) || new MapPluginStorage();
|
|
605
|
+
var logger = this.loggersByName.get(name) || new ConsolePluginLogger(name);
|
|
606
|
+
var stats = this.statsByName.get(name) || buildEmptyStats();
|
|
607
|
+
var maxBytes = Number(plugin.storageMaxBytes);
|
|
608
|
+
if (!isFinite(maxBytes) || maxBytes < 0)
|
|
609
|
+
maxBytes = DEFAULT_PLUGIN_STORAGE_MAX_BYTES;
|
|
610
|
+
var wrapped = new CountingStorageWrapper(inner, stats, maxBytes, name);
|
|
611
|
+
var declared = plugin.requiredScopes;
|
|
612
|
+
var scopes = declared ? declared : ALL_SCOPES;
|
|
613
|
+
function hasScope(s) {
|
|
614
|
+
for (var i = 0; i < scopes.length; i++) {
|
|
615
|
+
if (scopes[i] === s)
|
|
616
|
+
return true;
|
|
617
|
+
}
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
var rootGetZonePeers = this.opts.getZonePeers;
|
|
621
|
+
var rootGetZoneState = this.opts.getZoneState;
|
|
622
|
+
var rootGetZoneEventsTail = this.opts.getZoneEventsTail;
|
|
623
|
+
var rootNow = this.opts.now;
|
|
624
|
+
function scopedZonePeers(zid) {
|
|
625
|
+
if (!hasScope('read_zones'))
|
|
626
|
+
return [];
|
|
627
|
+
try {
|
|
628
|
+
return rootGetZonePeers(String(zid)) || [];
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
return [];
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
function scopedZoneState(zid) {
|
|
635
|
+
if (!hasScope('read_zones'))
|
|
636
|
+
return new Map();
|
|
637
|
+
try {
|
|
638
|
+
return rootGetZoneState(String(zid)) || new Map();
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
return new Map();
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
function scopedZoneEventsTail(zid, n) {
|
|
645
|
+
if (!hasScope('read_events'))
|
|
646
|
+
return [];
|
|
647
|
+
try {
|
|
648
|
+
return rootGetZoneEventsTail(String(zid), Number(n) | 0) || [];
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
return [];
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
function peersInRadius(zid, x, y, radius) {
|
|
655
|
+
var r = Number(radius) || 0;
|
|
656
|
+
if (r <= 0)
|
|
657
|
+
return [];
|
|
658
|
+
var rsq = r * r;
|
|
659
|
+
var peers = scopedZonePeers(String(zid));
|
|
660
|
+
var out = [];
|
|
661
|
+
for (var i = 0; i < peers.length; i++) {
|
|
662
|
+
var p = peers[i];
|
|
663
|
+
if (!p)
|
|
664
|
+
continue;
|
|
665
|
+
var dx = (Number(p.x) || 0) - Number(x);
|
|
666
|
+
var dy = (Number(p.y) || 0) - Number(y);
|
|
667
|
+
if ((dx * dx + dy * dy) <= rsq)
|
|
668
|
+
out.push(p);
|
|
669
|
+
}
|
|
670
|
+
return out;
|
|
671
|
+
}
|
|
672
|
+
function nearestPeer(zid, x, y) {
|
|
673
|
+
var peers = scopedZonePeers(String(zid));
|
|
674
|
+
if (peers.length === 0)
|
|
675
|
+
return null;
|
|
676
|
+
var best = null;
|
|
677
|
+
var bestDsq = Infinity;
|
|
678
|
+
for (var i = 0; i < peers.length; i++) {
|
|
679
|
+
var p = peers[i];
|
|
680
|
+
if (!p)
|
|
681
|
+
continue;
|
|
682
|
+
var dx = (Number(p.x) || 0) - Number(x);
|
|
683
|
+
var dy = (Number(p.y) || 0) - Number(y);
|
|
684
|
+
var dsq = dx * dx + dy * dy;
|
|
685
|
+
if (dsq < bestDsq) {
|
|
686
|
+
bestDsq = dsq;
|
|
687
|
+
best = p;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
if (!best)
|
|
691
|
+
return null;
|
|
692
|
+
return { peer: best, distance: Math.sqrt(bestDsq) };
|
|
693
|
+
}
|
|
694
|
+
function entropy(seed) {
|
|
695
|
+
return new PluginEntropy(seed === undefined ? null : seed);
|
|
696
|
+
}
|
|
697
|
+
return {
|
|
698
|
+
getZonePeers: scopedZonePeers,
|
|
699
|
+
getZoneState: scopedZoneState,
|
|
700
|
+
getZoneEventsTail: scopedZoneEventsTail,
|
|
701
|
+
storage: wrapped,
|
|
702
|
+
logger: logger,
|
|
703
|
+
now: rootNow,
|
|
704
|
+
peersInRadius: peersInRadius,
|
|
705
|
+
nearestPeer: nearestPeer,
|
|
706
|
+
entropy: entropy,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
// ----- Dispatchers -----
|
|
710
|
+
// Generic safe-call. Returns the EmittedEvents result (or null on
|
|
711
|
+
// miss / error). Wraps the hook call in a tick-budget timeout and
|
|
712
|
+
// bumps the per-plugin ops counters. PluginError(retryable=true)
|
|
713
|
+
// triggers ONE retry before dropping; bare errors drop immediately.
|
|
714
|
+
async safeCall(plugin, hookName, ctx, args) {
|
|
715
|
+
var hookFn = plugin[hookName];
|
|
716
|
+
if (typeof hookFn !== 'function')
|
|
717
|
+
return null;
|
|
718
|
+
var stats = this.statsByName.get(String(plugin.name));
|
|
719
|
+
if (stats)
|
|
720
|
+
stats.hook_call_count += 1;
|
|
721
|
+
var budgetMs = Number(plugin.tickBudgetMs);
|
|
722
|
+
if (!isFinite(budgetMs) || budgetMs <= 0)
|
|
723
|
+
budgetMs = DEFAULT_PLUGIN_TICK_BUDGET_MS;
|
|
724
|
+
var attempts = 0;
|
|
725
|
+
var maxAttempts = 2;
|
|
726
|
+
while (attempts < maxAttempts) {
|
|
727
|
+
attempts += 1;
|
|
728
|
+
try {
|
|
729
|
+
// 0.19.1 fix: hooks may return null / undefined / a value
|
|
730
|
+
// synchronously (mirrors the Python-side Optional[EmittedEvents]
|
|
731
|
+
// shape). Wrap unconditionally so .then() in withTimeout cannot
|
|
732
|
+
// throw on a non-Promise. Promise.resolve() is a no-op on an
|
|
733
|
+
// existing thenable.
|
|
734
|
+
var hookResult = hookFn.apply(plugin, [ctx].concat(args));
|
|
735
|
+
var hookPromise = Promise.resolve(hookResult);
|
|
736
|
+
var raced = await this.withTimeout(hookPromise, budgetMs, plugin, hookName);
|
|
737
|
+
if (raced === null) {
|
|
738
|
+
// Timeout already logged + counted; return null to drop.
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
return this.normalizeEmitted(raced);
|
|
742
|
+
}
|
|
743
|
+
catch (err) {
|
|
744
|
+
if (err instanceof PluginError) {
|
|
745
|
+
err.pluginName = err.pluginName || String(plugin.name);
|
|
746
|
+
if (err.retryable && attempts < maxAttempts) {
|
|
747
|
+
if (stats)
|
|
748
|
+
stats.hook_retry_count += 1;
|
|
749
|
+
try {
|
|
750
|
+
ctx.logger.warn('hook ' + hookName + ' retryable PluginError: ' + err.code);
|
|
751
|
+
}
|
|
752
|
+
catch {
|
|
753
|
+
// ignore
|
|
754
|
+
}
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
if (stats)
|
|
758
|
+
stats.hook_error_count += 1;
|
|
759
|
+
try {
|
|
760
|
+
ctx.logger.error('hook ' + hookName + ' raised PluginError: ' + err.code);
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
// ignore
|
|
764
|
+
}
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
if (stats)
|
|
768
|
+
stats.hook_error_count += 1;
|
|
769
|
+
try {
|
|
770
|
+
ctx.logger.error('hook ' + hookName + ' threw', this.errorMeta(err));
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
// ignore
|
|
774
|
+
}
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
// Race a hook promise against a timeout. Returns the result or
|
|
781
|
+
// null on timeout. The timed-out hook is allowed to keep running
|
|
782
|
+
// in the background (we cannot really cancel a Promise) but its
|
|
783
|
+
// contribution is dropped.
|
|
784
|
+
//
|
|
785
|
+
// 0.19.1 fix: a "fired" flag short-circuits the timeout callback
|
|
786
|
+
// when the hook resolves (or rejects) first. The previous
|
|
787
|
+
// implementation cleared the setTimeout via clearTimeout in
|
|
788
|
+
// .then() callbacks, but the callbacks were attached to the inner
|
|
789
|
+
// promise which had ALREADY resolved synchronously by the time
|
|
790
|
+
// .then() was wired up - so the clearTimeout never ran and the
|
|
791
|
+
// timeout always fired at +budget. The flag-guard works regardless
|
|
792
|
+
// of microtask ordering.
|
|
793
|
+
withTimeout(promise, ms, plugin, hookName) {
|
|
794
|
+
var stats = this.statsByName.get(String(plugin.name));
|
|
795
|
+
var fired = false;
|
|
796
|
+
var timeoutId = null;
|
|
797
|
+
var timed = new Promise(function (resolve) {
|
|
798
|
+
timeoutId = setTimeout(function () {
|
|
799
|
+
if (fired)
|
|
800
|
+
return;
|
|
801
|
+
fired = true;
|
|
802
|
+
if (stats)
|
|
803
|
+
stats.hook_timeout_count += 1;
|
|
804
|
+
try {
|
|
805
|
+
console.warn('[plugin-registry] plugin ' + String(plugin.name) + ' hook ' + hookName + ' exceeded tick budget ' + String(ms) + 'ms - dropping');
|
|
806
|
+
}
|
|
807
|
+
catch {
|
|
808
|
+
// ignore
|
|
809
|
+
}
|
|
810
|
+
resolve(null);
|
|
811
|
+
}, Math.max(1, Number(ms) | 0));
|
|
812
|
+
});
|
|
813
|
+
var settled = promise.then(function (v) {
|
|
814
|
+
fired = true;
|
|
815
|
+
if (timeoutId !== null) {
|
|
816
|
+
try {
|
|
817
|
+
clearTimeout(timeoutId);
|
|
818
|
+
}
|
|
819
|
+
catch { /* ignore */ }
|
|
820
|
+
}
|
|
821
|
+
return v;
|
|
822
|
+
}, function (err) {
|
|
823
|
+
fired = true;
|
|
824
|
+
if (timeoutId !== null) {
|
|
825
|
+
try {
|
|
826
|
+
clearTimeout(timeoutId);
|
|
827
|
+
}
|
|
828
|
+
catch { /* ignore */ }
|
|
829
|
+
}
|
|
830
|
+
throw err;
|
|
831
|
+
});
|
|
832
|
+
return Promise.race([settled, timed]);
|
|
833
|
+
}
|
|
834
|
+
normalizeEmitted(raw) {
|
|
835
|
+
if (!raw)
|
|
836
|
+
return {};
|
|
837
|
+
if (typeof raw !== 'object')
|
|
838
|
+
return {};
|
|
839
|
+
var out = {};
|
|
840
|
+
var ze = raw.zoneEvents;
|
|
841
|
+
if (ze && ze.length > 0)
|
|
842
|
+
out.zoneEvents = ze.slice();
|
|
843
|
+
return out;
|
|
844
|
+
}
|
|
845
|
+
// Dispatch a hook across all registered plugins. Snapshots the
|
|
846
|
+
// plugin list at dispatch start so a hook that mutates the
|
|
847
|
+
// registry cannot change which plugins run for THIS dispatch.
|
|
848
|
+
async dispatch(hookName, args) {
|
|
849
|
+
var snapshot = this.plugins.slice();
|
|
850
|
+
var merged = {};
|
|
851
|
+
var zoneEvents;
|
|
852
|
+
for (var i = 0; i < snapshot.length; i++) {
|
|
853
|
+
var plugin = snapshot[i];
|
|
854
|
+
if (!plugin)
|
|
855
|
+
continue;
|
|
856
|
+
var ctx = this.makeCtx(plugin);
|
|
857
|
+
var emitted = await this.safeCall(plugin, hookName, ctx, args);
|
|
858
|
+
if (!emitted)
|
|
859
|
+
continue;
|
|
860
|
+
if (emitted.zoneEvents && emitted.zoneEvents.length > 0) {
|
|
861
|
+
if (!zoneEvents)
|
|
862
|
+
zoneEvents = [];
|
|
863
|
+
for (var j = 0; j < emitted.zoneEvents.length; j++) {
|
|
864
|
+
var ev = emitted.zoneEvents[j];
|
|
865
|
+
if (ev)
|
|
866
|
+
zoneEvents.push(ev);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
if (zoneEvents)
|
|
871
|
+
merged.zoneEvents = zoneEvents;
|
|
872
|
+
return merged;
|
|
873
|
+
}
|
|
874
|
+
// Public entrypoints. The bridge listener calls dispatchZoneEvent;
|
|
875
|
+
// hosts can also call any of these directly.
|
|
876
|
+
async dispatchZoneEvent(envelope) {
|
|
877
|
+
var merged = {};
|
|
878
|
+
var zoneEvents;
|
|
879
|
+
var snapshot = this.plugins.slice();
|
|
880
|
+
for (var i = 0; i < snapshot.length; i++) {
|
|
881
|
+
var plugin = snapshot[i];
|
|
882
|
+
if (!plugin)
|
|
883
|
+
continue;
|
|
884
|
+
var ctx = this.makeCtx(plugin);
|
|
885
|
+
// Catch-all hook first.
|
|
886
|
+
var caught = await this.safeCall(plugin, 'onZoneEvent', ctx, [envelope]);
|
|
887
|
+
if (caught && caught.zoneEvents && caught.zoneEvents.length > 0) {
|
|
888
|
+
if (!zoneEvents)
|
|
889
|
+
zoneEvents = [];
|
|
890
|
+
for (var j = 0; j < caught.zoneEvents.length; j++) {
|
|
891
|
+
var ev = caught.zoneEvents[j];
|
|
892
|
+
if (ev)
|
|
893
|
+
zoneEvents.push(ev);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
// Narrow boss conveniences.
|
|
897
|
+
if (envelope.type === 'zone.boss.spawn') {
|
|
898
|
+
var spawnData = envelope.data;
|
|
899
|
+
if (spawnData && spawnData.boss) {
|
|
900
|
+
var emitSpawn = await this.safeCall(plugin, 'onBossSpawn', ctx, [String(envelope.zone_id), spawnData.boss]);
|
|
901
|
+
if (emitSpawn && emitSpawn.zoneEvents && emitSpawn.zoneEvents.length > 0) {
|
|
902
|
+
if (!zoneEvents)
|
|
903
|
+
zoneEvents = [];
|
|
904
|
+
for (var k = 0; k < emitSpawn.zoneEvents.length; k++) {
|
|
905
|
+
var sev = emitSpawn.zoneEvents[k];
|
|
906
|
+
if (sev)
|
|
907
|
+
zoneEvents.push(sev);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
else if (envelope.type === 'zone.boss.end') {
|
|
913
|
+
var endData = envelope.data;
|
|
914
|
+
if (endData) {
|
|
915
|
+
var emitEnd = await this.safeCall(plugin, 'onBossEnd', ctx, [String(envelope.zone_id), String(endData.boss_id), endData.outcome]);
|
|
916
|
+
if (emitEnd && emitEnd.zoneEvents && emitEnd.zoneEvents.length > 0) {
|
|
917
|
+
if (!zoneEvents)
|
|
918
|
+
zoneEvents = [];
|
|
919
|
+
for (var l = 0; l < emitEnd.zoneEvents.length; l++) {
|
|
920
|
+
var lev = emitEnd.zoneEvents[l];
|
|
921
|
+
if (lev)
|
|
922
|
+
zoneEvents.push(lev);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
if (endData.loot && endData.loot.length > 0) {
|
|
926
|
+
var emitLoot = await this.safeCall(plugin, 'onLootDrop', ctx, [String(envelope.zone_id), String(endData.boss_id), endData.loot]);
|
|
927
|
+
if (emitLoot && emitLoot.zoneEvents && emitLoot.zoneEvents.length > 0) {
|
|
928
|
+
if (!zoneEvents)
|
|
929
|
+
zoneEvents = [];
|
|
930
|
+
for (var m = 0; m < emitLoot.zoneEvents.length; m++) {
|
|
931
|
+
var mev = emitLoot.zoneEvents[m];
|
|
932
|
+
if (mev)
|
|
933
|
+
zoneEvents.push(mev);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
if (zoneEvents)
|
|
941
|
+
merged.zoneEvents = zoneEvents;
|
|
942
|
+
return merged;
|
|
943
|
+
}
|
|
944
|
+
async dispatchPreTick() {
|
|
945
|
+
return this.dispatch('onPreTick', []);
|
|
946
|
+
}
|
|
947
|
+
async dispatchPostTick() {
|
|
948
|
+
return this.dispatch('onPostTick', []);
|
|
949
|
+
}
|
|
950
|
+
async dispatchBossSpawn(zoneId, boss) {
|
|
951
|
+
return this.dispatch('onBossSpawn', [String(zoneId), boss]);
|
|
952
|
+
}
|
|
953
|
+
async dispatchBossEnd(zoneId, bossId, outcome) {
|
|
954
|
+
return this.dispatch('onBossEnd', [String(zoneId), String(bossId), outcome]);
|
|
955
|
+
}
|
|
956
|
+
async dispatchLootDrop(zoneId, bossId, items) {
|
|
957
|
+
return this.dispatch('onLootDrop', [String(zoneId), String(bossId), items.slice()]);
|
|
958
|
+
}
|
|
959
|
+
// ----- Disposal -----
|
|
960
|
+
// Tear the registry down: detach DOM listeners, dispose every
|
|
961
|
+
// plugin, drop state. Tests call this at the end of a block; the
|
|
962
|
+
// ARPG-loom IIFE calls it on hot module reload to prevent listener
|
|
963
|
+
// leaks.
|
|
964
|
+
async dispose() {
|
|
965
|
+
this.detachBridge();
|
|
966
|
+
await this.resetForTest();
|
|
967
|
+
}
|
|
968
|
+
// ----- Internals -----
|
|
969
|
+
errorMeta(err) {
|
|
970
|
+
if (err instanceof Error) {
|
|
971
|
+
return {
|
|
972
|
+
error_name: err.name,
|
|
973
|
+
error_message: err.message,
|
|
974
|
+
error_stack: err.stack || null,
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
var safe;
|
|
978
|
+
try {
|
|
979
|
+
safe = JSON.stringify(err);
|
|
980
|
+
}
|
|
981
|
+
catch {
|
|
982
|
+
safe = String(err);
|
|
983
|
+
}
|
|
984
|
+
return { error: safe };
|
|
985
|
+
}
|
|
986
|
+
// Test affordance: read per-plugin stats. The stats reference is
|
|
987
|
+
// live (not snapshotted) so tests that increment via the wrapper
|
|
988
|
+
// and read via this method see consistent values without round-
|
|
989
|
+
// tripping through describe().
|
|
990
|
+
statsFor(name) {
|
|
991
|
+
return this.statsByName.get(String(name));
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
//# sourceMappingURL=client-registry.js.map
|