@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.
Files changed (74) hide show
  1. package/dist/audio/cue-catalog.d.ts +5 -1
  2. package/dist/audio/cue-catalog.d.ts.map +1 -1
  3. package/dist/audio/cue-catalog.js +14 -9
  4. package/dist/audio/cue-catalog.js.map +1 -1
  5. package/dist/director/director-bridge.d.ts +5 -0
  6. package/dist/director/director-bridge.d.ts.map +1 -1
  7. package/dist/director/director-bridge.js.map +1 -1
  8. package/dist/director/director-system.d.ts.map +1 -1
  9. package/dist/director/director-system.js +4 -1
  10. package/dist/director/director-system.js.map +1 -1
  11. package/dist/director/mock-director-bridge.d.ts.map +1 -1
  12. package/dist/director/mock-director-bridge.js +5 -0
  13. package/dist/director/mock-director-bridge.js.map +1 -1
  14. package/dist/director/sse-director-bridge.d.ts +20 -0
  15. package/dist/director/sse-director-bridge.d.ts.map +1 -1
  16. package/dist/director/sse-director-bridge.js +251 -66
  17. package/dist/director/sse-director-bridge.js.map +1 -1
  18. package/dist/director/zone/zone-boss-entity-system.d.ts +9 -0
  19. package/dist/director/zone/zone-boss-entity-system.d.ts.map +1 -0
  20. package/dist/director/zone/zone-boss-entity-system.js +114 -0
  21. package/dist/director/zone/zone-boss-entity-system.js.map +1 -0
  22. package/dist/director/zone/zone-boss-entity.d.ts +30 -0
  23. package/dist/director/zone/zone-boss-entity.d.ts.map +1 -0
  24. package/dist/director/zone/zone-boss-entity.js +103 -0
  25. package/dist/director/zone/zone-boss-entity.js.map +1 -0
  26. package/dist/director/zone/zone-event-system.d.ts.map +1 -1
  27. package/dist/director/zone/zone-event-system.js +6 -2
  28. package/dist/director/zone/zone-event-system.js.map +1 -1
  29. package/dist/engine.d.ts +1 -0
  30. package/dist/engine.d.ts.map +1 -1
  31. package/dist/engine.js +6 -0
  32. package/dist/engine.js.map +1 -1
  33. package/dist/index.d.ts +9 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +5 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/plugins/client-registry.d.ts +61 -0
  38. package/dist/plugins/client-registry.d.ts.map +1 -0
  39. package/dist/plugins/client-registry.js +994 -0
  40. package/dist/plugins/client-registry.js.map +1 -0
  41. package/dist/plugins/index.d.ts +5 -0
  42. package/dist/plugins/index.d.ts.map +1 -0
  43. package/dist/plugins/index.js +20 -0
  44. package/dist/plugins/index.js.map +1 -0
  45. package/dist/plugins/types.d.ts +105 -0
  46. package/dist/plugins/types.d.ts.map +1 -0
  47. package/dist/plugins/types.js +112 -0
  48. package/dist/plugins/types.js.map +1 -0
  49. package/dist/runtime/entropy.d.ts +22 -0
  50. package/dist/runtime/entropy.d.ts.map +1 -0
  51. package/dist/runtime/entropy.js +122 -0
  52. package/dist/runtime/entropy.js.map +1 -0
  53. package/dist/systems/attack-system.d.ts.map +1 -1
  54. package/dist/systems/attack-system.js +6 -2
  55. package/dist/systems/attack-system.js.map +1 -1
  56. package/dist/systems/damage-system.d.ts.map +1 -1
  57. package/dist/systems/damage-system.js +6 -1
  58. package/dist/systems/damage-system.js.map +1 -1
  59. package/dist/systems/particle-emitter-system.d.ts.map +1 -1
  60. package/dist/systems/particle-emitter-system.js +20 -6
  61. package/dist/systems/particle-emitter-system.js.map +1 -1
  62. package/dist/systems/peer-presence-system.d.ts.map +1 -1
  63. package/dist/systems/peer-presence-system.js +6 -1
  64. package/dist/systems/peer-presence-system.js.map +1 -1
  65. package/dist/systems/projectile-system.d.ts.map +1 -1
  66. package/dist/systems/projectile-system.js +5 -1
  67. package/dist/systems/projectile-system.js.map +1 -1
  68. package/dist/systems/pursue-system.d.ts.map +1 -1
  69. package/dist/systems/pursue-system.js +5 -1
  70. package/dist/systems/pursue-system.js.map +1 -1
  71. package/dist/systems/ranged-attack-system.d.ts.map +1 -1
  72. package/dist/systems/ranged-attack-system.js +5 -1
  73. package/dist/systems/ranged-attack-system.js.map +1 -1
  74. 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