@wasm-gaming/engine-specs 0.3.1 → 0.4.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.
@@ -17,6 +17,7 @@ export interface EngineConfig { [key: string]: unknown }
17
17
  export interface EngineOptions { [key: string]: unknown }
18
18
  export interface EngineAssets { [key: string]: unknown }
19
19
  export interface EngineStorage { [key: string]: unknown }
20
+ export interface EngineEvents { [key: string]: unknown }
20
21
 
21
22
  /**
22
23
  * Members are `object`, not the four interfaces above, on purpose: a named
@@ -30,6 +31,12 @@ export interface EnginePayloads {
30
31
  options: object;
31
32
  assets: object;
32
33
  storage: object;
34
+ /**
35
+ * The events this engine emits **beyond** {@link CoreEvents}, which every engine
36
+ * emits and no engine declares. Optional, unlike the four above: an engine with
37
+ * nothing of its own to announce omits it.
38
+ */
39
+ events?: object;
33
40
  }
34
41
 
35
42
  export interface DefaultPayloads extends EnginePayloads {
@@ -37,6 +44,145 @@ export interface DefaultPayloads extends EnginePayloads {
37
44
  options: EngineOptions;
38
45
  assets: EngineAssets;
39
46
  storage: EngineStorage;
47
+ events: EngineEvents;
48
+ }
49
+
50
+ // ---- Events ----
51
+
52
+ /**
53
+ * What every engine emits, whatever else it adds. Unioned in by {@link EventsOf}
54
+ * rather than declared in the payload bundle, so an engine can neither forget these
55
+ * nor redefine them away.
56
+ *
57
+ * There is no `ready`: it announced that the engine was up but no session was open,
58
+ * which was what the removed `boot()` meant.
59
+ */
60
+ export interface CoreEvents<P extends EnginePayloads = DefaultPayloads> {
61
+ /** The options the session opened with. A `restart()` emits it too. */
62
+ start: Partial<P['options']>;
63
+ /**
64
+ * Emitted **and** rejected. Both, always: a host that does not `await` the chain is
65
+ * not left blind, and a host that does is not forced to subscribe.
66
+ */
67
+ error: Error;
68
+ /** The engine went away — `destroy()` has settled. Fires once, with no detail. */
69
+ exit: void;
70
+ /** Who asked, so a host can tell its own pause from a visibility change. */
71
+ pause: { owner: string };
72
+ resume: { owner: string };
73
+ }
74
+
75
+ /**
76
+ * What a listener receives. The payload travels under `detail`, as `CustomEvent`
77
+ * does it, rather than being the argument itself: an event can then grow a field
78
+ * without changing every listener signature, one listener can serve several types by
79
+ * reading `type`, and — the reason that actually decided it — an engine living in a
80
+ * worker has to put its type on the wire anyway, so `{ type, detail }` is the shape
81
+ * that survives `postMessage` instead of being re-wrapped on arrival.
82
+ *
83
+ * **This is not a DOM event.** Two members, and deliberately no more: it does not
84
+ * bubble, it cannot be cancelled, there is no `target` and no `preventDefault()`.
85
+ * Half-implementing a familiar interface is worse than offering a different one, so
86
+ * the shape stops where its guarantees do.
87
+ *
88
+ * Frozen when emitted, because the same object is handed to every listener in turn —
89
+ * otherwise the first could rewrite what the second reads.
90
+ */
91
+ export interface EngineEvent<K = PropertyKey, Detail = unknown> {
92
+ readonly type: K;
93
+ readonly detail: Detail;
94
+ }
95
+
96
+ /** What `on` takes for one event of `E`. */
97
+ export type EngineListener<E, K extends keyof E> = (event: EngineEvent<K, E[K]>) => void;
98
+
99
+ /**
100
+ * Every event the engine emits: the core set, plus whatever it declared.
101
+ *
102
+ * `NonNullable` is what makes `events` optional: with the member absent, `P['events']`
103
+ * is `object | undefined`, and a bare `object` contributes no keys to the
104
+ * intersection. It also covers an engine that writes `events?:` in its own payload
105
+ * interface.
106
+ */
107
+ export type EventsOf<P extends EnginePayloads> = CoreEvents<P> & NonNullable<P['events']>;
108
+
109
+ /** A map of listeners, which is how {@link EngineInit.on} carries them. */
110
+ export type EventListeners<E> = { [K in keyof E]?: EngineListener<E, K> };
111
+
112
+ /**
113
+ * The listener bookkeeping, shipped rather than described — the one piece of runtime
114
+ * code in this package.
115
+ *
116
+ * It is a **convenience, not a requirement**: the contract stays structural, so an
117
+ * engine that already owns an emitter satisfies {@link EngineSurface} without this.
118
+ * What it saves is the part everyone gets subtly wrong — iterating a copy, so a
119
+ * listener that unsubscribes mid-emit does not corrupt the loop; de-duplicating a
120
+ * listener registered twice; and keeping one throwing listener from silencing the
121
+ * rest.
122
+ *
123
+ * Hold it **privately** and hand the same instance to every carrier. That is what
124
+ * makes `sdk.on(…)` and `play.on(…)` one channel rather than two, lets a host
125
+ * subscribe before the engine exists, and keeps `emit` out of the public API — a host
126
+ * must not be able to forge an engine event.
127
+ */
128
+ export class EventEmitter<E> {
129
+ readonly #listeners = new Map<keyof E, Set<(event: never) => void>>();
130
+
131
+ /** @param listeners Registered immediately — what `EngineInit.on` carries. */
132
+ constructor(listeners?: EventListeners<E>) {
133
+ for (const type of Object.keys(listeners ?? {}) as (keyof E)[]) {
134
+ const listener = listeners?.[type];
135
+ if (listener) this.on(type, listener);
136
+ }
137
+ }
138
+
139
+ /** Registering the same listener twice registers it once, as `EventTarget` does. */
140
+ on<K extends keyof E>(type: K, listener: EngineListener<E, K>): this {
141
+ let set = this.#listeners.get(type);
142
+ if (!set) this.#listeners.set(type, (set = new Set()));
143
+ set.add(listener as (event: never) => void);
144
+ return this;
145
+ }
146
+
147
+ /**
148
+ * The listener is required, as in `EventTarget`. A convenience `off(type)` dropping
149
+ * every listener for a type is a footgun on a page where two components subscribed
150
+ * independently: one unsubscribing would silence the other.
151
+ */
152
+ off<K extends keyof E>(type: K, listener: EngineListener<E, K>): this {
153
+ const set = this.#listeners.get(type);
154
+ if (set?.delete(listener as (event: never) => void) && set.size === 0) {
155
+ this.#listeners.delete(type);
156
+ }
157
+ return this;
158
+ }
159
+
160
+ /**
161
+ * Takes the **detail**, and wraps it. The rest tuple is what lets an event with no
162
+ * detail be emitted as `emit('exit')` while still checking the ones that have one.
163
+ */
164
+ emit<K extends keyof E>(
165
+ type: K,
166
+ ...[detail]: E[K] extends void ? [] : [detail: E[K]]
167
+ ): void {
168
+ const set = this.#listeners.get(type);
169
+ if (!set) return;
170
+ // Built once and frozen: every listener gets this same object, so a mutable one
171
+ // would let the first rewrite what the rest read.
172
+ const event: EngineEvent<K, E[K]> = Object.freeze({ type, detail: detail as E[K] });
173
+ // A copy: a listener may call `off()` — on itself or another — while this runs.
174
+ for (const listener of [...set]) {
175
+ try {
176
+ (listener as EngineListener<E, K>)(event);
177
+ } catch (thrown) {
178
+ // One bad listener must neither silence the rest nor vanish. Rethrowing on a
179
+ // fresh task puts it in front of `window.onerror` with its stack intact.
180
+ queueMicrotask(() => {
181
+ throw thrown;
182
+ });
183
+ }
184
+ }
185
+ }
40
186
  }
41
187
 
42
188
  export interface EngineInit<P extends EnginePayloads = DefaultPayloads> {
@@ -49,6 +195,12 @@ export interface EngineInit<P extends EnginePayloads = DefaultPayloads> {
49
195
  * cumulative state, a mount target is a single value that replaces.
50
196
  */
51
197
  mount?: HTMLElement;
198
+ /**
199
+ * The same subscription as {@link EngineSurface.on}, as a map — the one shape the
200
+ * chain cannot express, because an object literal has nothing to chain onto.
201
+ * `new Sdk({ on: { error } })` and `new Sdk().on('error', error)` are the same call.
202
+ */
203
+ on?: EventListeners<EventsOf<P>>;
52
204
  }
53
205
 
54
206
  // ---- The shared fluent surface: the members every carrier exposes ----
@@ -63,6 +215,21 @@ export interface EngineSurface<TSelf, P extends EnginePayloads = DefaultPayloads
63
215
  storage(): Promise<PropertiesDefinition<P['storage']>>;
64
216
  storage(patch: Partial<P['storage']>): TSelf;
65
217
  mount(target: HTMLElement): TSelf;
218
+ /**
219
+ * Subscribe. Like `mount`, and unlike the four payloads, this has no getter form:
220
+ * there is no `on()` that hands back a listener list.
221
+ *
222
+ * **Synchronous on every carrier, `PromisePlay` included.** The chain is what the
223
+ * return value continues, never what the registration waits for — otherwise
224
+ * `sdk.start().on('error', report)` would bind after the session opened and miss an
225
+ * error thrown while it was opening, which is the error a host most wants.
226
+ *
227
+ * The factory and the engine it opens share one channel, so subscribing before
228
+ * `start()` is the normal way to do it, not a special case.
229
+ */
230
+ on<K extends keyof EventsOf<P>>(type: K, listener: EngineListener<EventsOf<P>, K>): TSelf;
231
+ /** Takes the listener, not just the type — see {@link EventEmitter.off}. */
232
+ off<K extends keyof EventsOf<P>>(type: K, listener: EngineListener<EventsOf<P>, K>): TSelf;
66
233
  }
67
234
 
68
235
  // ---- Public contract each WASM engine must implement ----
@@ -115,3 +282,813 @@ export interface EngineSDKConstructor<P extends EnginePayloads = DefaultPayloads
115
282
 
116
283
  new (init?: EngineInit<P>): EngineSDK<P>;
117
284
  }
285
+
286
+ // ---- The contract, implemented once ----
287
+ //
288
+ // Everything below is optional. The contract is structural: an engine satisfies
289
+ // `EngineSDK` by having the right members, not by extending anything here. What these
290
+ // exist for is that twelve engines were otherwise going to write the same three
291
+ // things — the chainable promise, the getter/setter overload dance, and the channel —
292
+ // and get them subtly different.
293
+
294
+ /**
295
+ * A descriptor with its `value` removed: what an engine *declares*, since the value
296
+ * is state and changes.
297
+ *
298
+ * `default` is declared here and nowhere else. {@link EnginePlayBase} seeds its
299
+ * starting state from it, which is what collapses the two sources of truth every
300
+ * hand-written engine had — an initial `{ region: 'us' }` field alongside a
301
+ * `default: 'us'` in the descriptor, with nothing to catch them drifting apart.
302
+ */
303
+ export type PropertySpec<Value> = Omit<PropertyDefinition<Value>, 'value'>;
304
+
305
+ /** `-?` as in {@link PropertiesDefinition}: an engine cannot forget to describe a key. */
306
+ export type PropertiesSpec<T> = { [K in keyof T]-?: PropertySpec<T[K]> };
307
+
308
+ /** Everything an engine has to say about its four payloads. */
309
+ export interface EngineSpec<P extends EnginePayloads = DefaultPayloads> {
310
+ config: PropertiesSpec<P['config']>;
311
+ options: PropertiesSpec<P['options']>;
312
+ assets: PropertiesSpec<P['assets']>;
313
+ storage: PropertiesSpec<P['storage']>;
314
+ }
315
+
316
+ /** The four payload names, which are also the four getter/setter pairs. */
317
+ export type PayloadKind = 'config' | 'options' | 'assets' | 'storage';
318
+
319
+ /**
320
+ * Wrap a promise of a live engine in the chainable surface.
321
+ *
322
+ * Exported because it is the single most duplicated function in an engine, and an
323
+ * engine that does not extend {@link EnginePlayBase} still needs exactly this.
324
+ *
325
+ * `on` and `off` do **not** go through the chain: they reach the channel now, so a
326
+ * listener is live before the promise settles. Routing them through it would bind
327
+ * after the session opened and lose whatever was emitted while it was opening.
328
+ */
329
+ export function hydratePlay<P extends EnginePayloads = DefaultPayloads>(
330
+ inner: Promise<EnginePlay<P>>,
331
+ events: EventEmitter<EventsOf<P>>,
332
+ ): PromisePlay<P> {
333
+ const chain = (step: (play: EnginePlay<P>) => PromisePlay<P>): PromisePlay<P> =>
334
+ hydratePlay(
335
+ inner.then(async (play) => {
336
+ await step(play);
337
+ return play;
338
+ }),
339
+ events,
340
+ );
341
+
342
+ const hydrated: PromisePlay<P> = {
343
+ then: (onfulfilled, onrejected) => inner.then(onfulfilled, onrejected),
344
+ catch: (onrejected) => inner.catch(onrejected),
345
+ finally: (onfinally) => inner.finally(onfinally),
346
+ [Symbol.toStringTag]: 'PromisePlay',
347
+
348
+ config: ((patch?: Partial<P['config']>) =>
349
+ patch === undefined
350
+ ? inner.then((play) => play.config())
351
+ : chain((play) => play.config(patch))) as PromisePlay<P>['config'],
352
+ options: ((patch?: Partial<P['options']>) =>
353
+ patch === undefined
354
+ ? inner.then((play) => play.options())
355
+ : chain((play) => play.options(patch))) as PromisePlay<P>['options'],
356
+ assets: ((patch?: Partial<P['assets']>) =>
357
+ patch === undefined
358
+ ? inner.then((play) => play.assets())
359
+ : chain((play) => play.assets(patch))) as PromisePlay<P>['assets'],
360
+ storage: ((patch?: Partial<P['storage']>) =>
361
+ patch === undefined
362
+ ? inner.then((play) => play.storage())
363
+ : chain((play) => play.storage(patch))) as PromisePlay<P>['storage'],
364
+
365
+ mount: (target: HTMLElement) => chain((play) => play.mount(target)),
366
+
367
+ on: <K extends keyof EventsOf<P>>(type: K, listener: EngineListener<EventsOf<P>, K>) => {
368
+ events.on(type, listener);
369
+ return hydrated;
370
+ },
371
+ off: <K extends keyof EventsOf<P>>(type: K, listener: EngineListener<EventsOf<P>, K>) => {
372
+ events.off(type, listener);
373
+ return hydrated;
374
+ },
375
+
376
+ restart: (options?: Partial<P['options']>) => chain((play) => play.restart(options)),
377
+ destroy: () => inner.then((play) => play.destroy()),
378
+ };
379
+ return hydrated;
380
+ }
381
+
382
+ /**
383
+ * The live engine, with everything the contract prescribes already written: state,
384
+ * the cumulative merge, the descriptors, the chainable promise, and the channel.
385
+ *
386
+ * A subclass supplies what only it can know — {@link EnginePlayBase.open} and
387
+ * {@link EnginePlayBase.close}, optionally {@link EnginePlayBase.mounted} and
388
+ * {@link EnginePlayBase.patched} — and its properties as an {@link EngineSpec}.
389
+ *
390
+ * The spec arrives as a **constructor argument**, not an abstract field, and that is
391
+ * not a style choice: a subclass field initialiser runs *after* `super()`, so a
392
+ * `protected abstract spec` would still be `undefined` while this constructor seeded
393
+ * state from it. Passing it up makes the ordering trap impossible rather than merely
394
+ * avoided.
395
+ */
396
+ export abstract class EnginePlayBase<P extends EnginePayloads = DefaultPayloads>
397
+ implements EnginePlay<P>
398
+ {
399
+ readonly #spec: EngineSpec<P>;
400
+ readonly #state: Record<PayloadKind, Record<string, unknown>>;
401
+ readonly #events: EventEmitter<EventsOf<P>>;
402
+ #target: HTMLElement | undefined;
403
+ /** The target {@link EnginePlayBase.mounted} has already been told about. */
404
+ #attached: HTMLElement | undefined;
405
+
406
+ /**
407
+ * @param events The factory's channel. Omitted only when a play is built on its
408
+ * own; the factory always passes its own, which is what makes `sdk.on(…)` and
409
+ * `play.on(…)` one channel rather than two.
410
+ */
411
+ constructor(spec: EngineSpec<P>, init?: EngineInit<P>, events?: EventEmitter<EventsOf<P>>) {
412
+ this.#spec = spec;
413
+ this.#events = events ?? new EventEmitter<EventsOf<P>>(init?.on);
414
+ this.#state = {
415
+ config: seed(spec.config, init?.config),
416
+ options: seed(spec.options, init?.options),
417
+ assets: seed(spec.assets, init?.assets),
418
+ storage: seed(spec.storage, init?.storage),
419
+ };
420
+ // Recorded, not applied. `mounted()` is overridable, and a subclass's own fields
421
+ // are still uninitialised while this constructor runs — so the hook is deferred to
422
+ // the first `restart()`, by which point the subclass is whole.
423
+ this.#target = init?.mount;
424
+ }
425
+
426
+ // ---- What a subclass implements ----
427
+
428
+ /**
429
+ * Open or reopen a session with the options as they now stand. Called by
430
+ * `restart()`, which merges the patch, emits `start` once this resolves, and turns
431
+ * a throw into both a rejection and an `error` event.
432
+ */
433
+ protected abstract open(options: P['options']): void | Promise<void>;
434
+
435
+ /** Stop and release. `exit` is emitted once this resolves. */
436
+ protected abstract close(): void | Promise<void>;
437
+
438
+ /** The element is already recorded when this runs. */
439
+ protected mounted(target: HTMLElement): void | Promise<void> {
440
+ void target;
441
+ }
442
+
443
+ /** A payload changed on a running engine — apply what is `live`, ignore the rest. */
444
+ protected patched(kind: PayloadKind, patch: object): void | Promise<void> {
445
+ void kind;
446
+ void patch;
447
+ }
448
+
449
+ /**
450
+ * Emit one of this engine's own events.
451
+ *
452
+ * `protected`, so a typed host cannot forge an engine event — but be exact about
453
+ * what that buys: TypeScript erases `protected`, so this is a compile-time guard,
454
+ * not a capability boundary. Plain JavaScript can still reach it. Only the `#`
455
+ * fields below — the channel and the state — are unreachable at runtime.
456
+ */
457
+ protected emit<K extends keyof EventsOf<P>>(
458
+ type: K,
459
+ ...detail: EventsOf<P>[K] extends void ? [] : [detail: EventsOf<P>[K]]
460
+ ): void {
461
+ this.#events.emit(type, ...detail);
462
+ }
463
+
464
+ /** Where the picture is, for a subclass that needs the element after `mounted()`. */
465
+ protected get target(): HTMLElement | undefined {
466
+ return this.#target;
467
+ }
468
+
469
+ // ---- The contract ----
470
+
471
+ config(): Promise<PropertiesDefinition<P['config']>>;
472
+ config(patch: Partial<P['config']>): PromisePlay<P>;
473
+ config(patch?: Partial<P['config']>): Promise<PropertiesDefinition<P['config']>> | PromisePlay<P> {
474
+ // `arguments.length`, not `patch === undefined`: `config(undefined)` is a write of
475
+ // nothing, not a read.
476
+ if (arguments.length === 0) return Promise.resolve(this.#describe('config'));
477
+ return this.#patch('config', patch);
478
+ }
479
+
480
+ options(): Promise<PropertiesDefinition<P['options']>>;
481
+ options(patch: Partial<P['options']>): PromisePlay<P>;
482
+ options(
483
+ patch?: Partial<P['options']>,
484
+ ): Promise<PropertiesDefinition<P['options']>> | PromisePlay<P> {
485
+ if (arguments.length === 0) return Promise.resolve(this.#describe('options'));
486
+ return this.#patch('options', patch);
487
+ }
488
+
489
+ assets(): Promise<PropertiesDefinition<P['assets']>>;
490
+ assets(patch: Partial<P['assets']>): PromisePlay<P>;
491
+ assets(patch?: Partial<P['assets']>): Promise<PropertiesDefinition<P['assets']>> | PromisePlay<P> {
492
+ if (arguments.length === 0) return Promise.resolve(this.#describe('assets'));
493
+ return this.#patch('assets', patch);
494
+ }
495
+
496
+ storage(): Promise<PropertiesDefinition<P['storage']>>;
497
+ storage(patch: Partial<P['storage']>): PromisePlay<P>;
498
+ storage(
499
+ patch?: Partial<P['storage']>,
500
+ ): Promise<PropertiesDefinition<P['storage']>> | PromisePlay<P> {
501
+ if (arguments.length === 0) return Promise.resolve(this.#describe('storage'));
502
+ return this.#patch('storage', patch);
503
+ }
504
+
505
+ mount(target: HTMLElement): PromisePlay<P> {
506
+ this.#target = target;
507
+ return this.#run(() => this.#attach());
508
+ }
509
+
510
+ on<K extends keyof EventsOf<P>>(type: K, listener: EngineListener<EventsOf<P>, K>): PromisePlay<P> {
511
+ this.#events.on(type, listener);
512
+ return hydratePlay<P>(Promise.resolve(this), this.#events);
513
+ }
514
+
515
+ off<K extends keyof EventsOf<P>>(
516
+ type: K,
517
+ listener: EngineListener<EventsOf<P>, K>,
518
+ ): PromisePlay<P> {
519
+ this.#events.off(type, listener);
520
+ return hydratePlay<P>(Promise.resolve(this), this.#events);
521
+ }
522
+
523
+ restart(options?: Partial<P['options']>): PromisePlay<P> {
524
+ return this.#run(async () => {
525
+ if (options) Object.assign(this.#state.options, options);
526
+ // A target supplied to the constructor lands here, which is what makes
527
+ // `new Sdk({ mount: el }).start()` and `new Sdk().mount(el).start()` the same
528
+ // call — the rule the contract states for everything on the surface.
529
+ await this.#attach();
530
+ const opened = { ...this.#state.options } as P['options'];
531
+ await this.open(opened);
532
+ this.#emitCore('start', opened);
533
+ });
534
+ }
535
+
536
+ async destroy(): Promise<void> {
537
+ await this.#guard(async () => {
538
+ await this.close();
539
+ this.#target = undefined;
540
+ this.#attached = undefined;
541
+ this.#emitCore('exit', undefined);
542
+ });
543
+ }
544
+
545
+ /** Tell the engine where it is, once per distinct target. Reparenting is legal. */
546
+ async #attach(): Promise<void> {
547
+ if (!this.#target || this.#target === this.#attached) return;
548
+ this.#attached = this.#target;
549
+ await this.mounted(this.#target);
550
+ }
551
+
552
+ // ---- Shared machinery ----
553
+
554
+ /** One getter's answer: the spec, with the current value written into each entry. */
555
+ #describe<K extends PayloadKind>(kind: K): PropertiesDefinition<P[K]> {
556
+ const spec = this.#spec[kind] as Record<string, PropertySpec<unknown>>;
557
+ const state = this.#state[kind];
558
+ const out: Record<string, PropertyDefinition<unknown>> = {};
559
+ for (const key of Object.keys(spec)) {
560
+ out[key] = { ...spec[key], value: state[key] };
561
+ }
562
+ return out as PropertiesDefinition<P[K]>;
563
+ }
564
+
565
+ /** Cumulative, merged flat, one level — `undefined` is a value, not "leave alone". */
566
+ #patch(kind: PayloadKind, patch: object | undefined): PromisePlay<P> {
567
+ Object.assign(this.#state[kind], patch);
568
+ return this.#run(async () => {
569
+ await this.patched(kind, patch ?? {});
570
+ });
571
+ }
572
+
573
+ /** A step, as a link in the chain — and as an `error` event if it throws. */
574
+ #run(step: () => Promise<void>): PromisePlay<P> {
575
+ return hydratePlay<P>(this.#guard(step), this.#events);
576
+ }
577
+
578
+ /**
579
+ * The rule that errors reach a host **both ways**: the promise rejects *and*
580
+ * `error` is emitted. Written once here rather than at every throw site.
581
+ */
582
+ async #guard(step: () => Promise<void>): Promise<this> {
583
+ try {
584
+ await step();
585
+ return this;
586
+ } catch (thrown) {
587
+ const error = thrown instanceof Error ? thrown : new Error(String(thrown));
588
+ this.#emitCore('error', error);
589
+ throw error;
590
+ }
591
+ }
592
+
593
+ /**
594
+ * The core events, emitted from here rather than by a subclass.
595
+ *
596
+ * The cast is the one thing TypeScript will not check here: `emit` takes a rest
597
+ * tuple that is conditional on the detail type, and that conditional stays
598
+ * unresolved while `P` is still a type parameter. It is sound — `EventsOf<P>`
599
+ * always contains `CoreEvents<P>` by construction — and confining it to this one
600
+ * method is why the three call sites above read plainly.
601
+ */
602
+ #emitCore<K extends keyof CoreEvents<P>>(type: K, detail: CoreEvents<P>[K]): void {
603
+ // Cast the channel, not the method: pulling `emit` out into a variable would call
604
+ // it unbound, and it reads its listeners off `this`.
605
+ const events = this.#events as unknown as { emit(type: K, detail: CoreEvents<P>[K]): void };
606
+ events.emit(type, detail);
607
+ }
608
+ }
609
+
610
+ /** Starting state: every `default` the spec declares, then whatever `init` overrides. */
611
+ function seed(spec: object, patch: object | undefined): Record<string, unknown> {
612
+ const state: Record<string, unknown> = {};
613
+ for (const [key, property] of Object.entries(spec as Record<string, PropertySpec<unknown>>)) {
614
+ if (property.default !== undefined) state[key] = property.default;
615
+ }
616
+ return Object.assign(state, patch);
617
+ }
618
+
619
+ /**
620
+ * The factory, with everything the contract prescribes already written: the
621
+ * cumulative init, the synchronous chain, the channel that outlives every session,
622
+ * and one live engine per factory.
623
+ *
624
+ * A subclass supplies its static identity and {@link EngineSDKBase.createPlay}.
625
+ */
626
+ export abstract class EngineSDKBase<P extends EnginePayloads = DefaultPayloads>
627
+ implements EngineSDK<P>
628
+ {
629
+ readonly #init: EngineInit<P>;
630
+ #play: EnginePlayBase<P> | undefined;
631
+
632
+ /**
633
+ * Created before any engine exists and handed to every carrier downstream. That is
634
+ * what lets a host subscribe to an error thrown while the first session opens.
635
+ */
636
+ protected readonly events: EventEmitter<EventsOf<P>>;
637
+
638
+ constructor(init?: EngineInit<P>) {
639
+ this.#init = { ...init };
640
+ this.events = new EventEmitter<EventsOf<P>>(init?.on);
641
+ }
642
+
643
+ /** Build the live engine. The channel is passed in, never created here. */
644
+ protected abstract createPlay(
645
+ init: EngineInit<P>,
646
+ events: EventEmitter<EventsOf<P>>,
647
+ ): EnginePlayBase<P>;
648
+
649
+ config(): Promise<PropertiesDefinition<P['config']>>;
650
+ config(patch: Partial<P['config']>): this;
651
+ config(patch?: Partial<P['config']>): Promise<PropertiesDefinition<P['config']>> | this {
652
+ if (arguments.length === 0) return this.#pending().config();
653
+ return this.#patch('config', patch);
654
+ }
655
+
656
+ options(): Promise<PropertiesDefinition<P['options']>>;
657
+ options(patch: Partial<P['options']>): this;
658
+ options(patch?: Partial<P['options']>): Promise<PropertiesDefinition<P['options']>> | this {
659
+ if (arguments.length === 0) return this.#pending().options();
660
+ return this.#patch('options', patch);
661
+ }
662
+
663
+ assets(): Promise<PropertiesDefinition<P['assets']>>;
664
+ assets(patch: Partial<P['assets']>): this;
665
+ assets(patch?: Partial<P['assets']>): Promise<PropertiesDefinition<P['assets']>> | this {
666
+ if (arguments.length === 0) return this.#pending().assets();
667
+ return this.#patch('assets', patch);
668
+ }
669
+
670
+ storage(): Promise<PropertiesDefinition<P['storage']>>;
671
+ storage(patch: Partial<P['storage']>): this;
672
+ storage(patch?: Partial<P['storage']>): Promise<PropertiesDefinition<P['storage']>> | this {
673
+ if (arguments.length === 0) return this.#pending().storage();
674
+ return this.#patch('storage', patch);
675
+ }
676
+
677
+ /** Recorded now, applied when the engine exists — same call as `new Sdk({ mount })`. */
678
+ mount(target: HTMLElement): this {
679
+ this.#init.mount = target;
680
+ this.#play?.mount(target);
681
+ return this;
682
+ }
683
+
684
+ on<K extends keyof EventsOf<P>>(type: K, listener: EngineListener<EventsOf<P>, K>): this {
685
+ this.events.on(type, listener);
686
+ return this;
687
+ }
688
+
689
+ off<K extends keyof EventsOf<P>>(type: K, listener: EngineListener<EventsOf<P>, K>): this {
690
+ this.events.off(type, listener);
691
+ return this;
692
+ }
693
+
694
+ /**
695
+ * The only way in. On a live engine it reopens the session rather than building a
696
+ * second one: a factory governs at most one engine.
697
+ */
698
+ start(options?: Partial<P['options']>): PromisePlay<P> {
699
+ this.#play ??= this.createPlay(this.#init, this.events);
700
+ return this.#play.restart(options);
701
+ }
702
+
703
+ /** Answers a read before there is an engine: the settings the next one will use. */
704
+ #pending(): EnginePlayBase<P> {
705
+ return this.#play ?? this.createPlay(this.#init, this.events);
706
+ }
707
+
708
+ #patch(kind: PayloadKind, patch: object | undefined): this {
709
+ this.#init[kind] = { ...this.#init[kind], ...patch };
710
+ // A live engine gets it too, so the factory does not silently describe a state
711
+ // the engine is not in.
712
+ if (this.#play && patch) this.#play[kind](patch as never);
713
+ return this;
714
+ }
715
+ }
716
+
717
+ // ============================================================================
718
+ // ---- The usability scheme: one input model for every menu (UX.md) ----
719
+ // ============================================================================
720
+ //
721
+ // Every engine ships its own app — start screen, load-game, pause menu, options —
722
+ // and the launcher has its own menus. They must feel identical under a gamepad, so
723
+ // the mapping, the repeat timing and the focus rules live here, beside the contract
724
+ // every engine already depends on. Normative description: UX.md.
725
+
726
+ /** What a menu reacts to. Never raw keys or buttons. */
727
+ export type UiAction =
728
+ | 'up'
729
+ | 'down'
730
+ | 'left'
731
+ | 'right'
732
+ | 'accept'
733
+ | 'back'
734
+ | 'menu'
735
+ | 'more'
736
+ | 'prev'
737
+ | 'next';
738
+
739
+ export type UiDirection = 'up' | 'down' | 'left' | 'right';
740
+
741
+ /** Keyboard → action (`KeyboardEvent.key`). */
742
+ export const UI_KEYMAP: Readonly<Record<string, UiAction>> = Object.freeze({
743
+ ArrowUp: 'up',
744
+ ArrowDown: 'down',
745
+ ArrowLeft: 'left',
746
+ ArrowRight: 'right',
747
+ Enter: 'accept',
748
+ ' ': 'accept',
749
+ Escape: 'back',
750
+ Backspace: 'back',
751
+ Tab: 'more',
752
+ v: 'more',
753
+ V: 'more',
754
+ ContextMenu: 'more',
755
+ q: 'prev',
756
+ Q: 'prev',
757
+ e: 'next',
758
+ E: 'next',
759
+ F1: 'menu',
760
+ });
761
+
762
+ /**
763
+ * Gamepad button index (W3C "standard" mapping) → action. A = 0, B = 1, X = 2, Y = 3,
764
+ * LB = 4, RB = 5, Select = 8, Start = 9, d-pad = 12–15. Select + Start together is
765
+ * `menu` as well (see {@link createInput}).
766
+ */
767
+ export const UI_PADMAP: Readonly<Record<number, UiAction>> = Object.freeze({
768
+ 0: 'accept',
769
+ 1: 'back',
770
+ 2: 'more',
771
+ 3: 'more',
772
+ 4: 'prev',
773
+ 5: 'next',
774
+ 9: 'menu',
775
+ 12: 'up',
776
+ 13: 'down',
777
+ 14: 'left',
778
+ 15: 'right',
779
+ });
780
+
781
+ /** Hold-to-repeat for directions: first repeat after `delay` ms, then every `interval` ms. */
782
+ export const UI_REPEAT = Object.freeze({ delay: 380, interval: 110, axis: 0.6 });
783
+
784
+ /** How an action reached the listener. */
785
+ export interface UiActionDetail {
786
+ source: 'keyboard' | 'gamepad';
787
+ /** True for a held key or button repeating. */
788
+ repeat: boolean;
789
+ }
790
+
791
+ export type UiActionListener = (action: UiAction, detail: UiActionDetail) => void;
792
+
793
+ /** The minimum of the Gamepad API {@link createInput} reads, so tests can fake it. */
794
+ export interface UiGamepadLike {
795
+ readonly buttons: ReadonlyArray<{ readonly pressed: boolean }>;
796
+ readonly axes: ReadonlyArray<number>;
797
+ }
798
+
799
+ export interface UiInputOptions {
800
+ /** Where key events are heard. Default: `globalThis` (the window). */
801
+ target?: EventTarget;
802
+ /** Listen in the capture phase, ahead of the engine's own keyboard handlers. Default true. */
803
+ capture?: boolean;
804
+ /**
805
+ * `preventDefault()` + `stopPropagation()` on keys that map to an action, so a
806
+ * menu's keys never reach the game underneath. Default true.
807
+ */
808
+ swallow?: boolean;
809
+ keymap?: Readonly<Record<string, UiAction>>;
810
+ padmap?: Readonly<Record<number, UiAction>>;
811
+ /** Gamepad source. Default: `navigator.getGamepads()`. */
812
+ gamepads?: () => ReadonlyArray<UiGamepadLike | null>;
813
+ /** Clock in ms. Default: `performance.now()`. */
814
+ now?: () => number;
815
+ /** Frame scheduler for gamepad polling. Default: `requestAnimationFrame`; `null` = poll manually. */
816
+ schedule?: ((poll: () => void) => unknown) | null;
817
+ /** Keys that still open the menu while suspended (in game). Default `['Escape', 'F1']`. */
818
+ menuKeys?: readonly string[];
819
+ }
820
+
821
+ export interface UiInput {
822
+ /** Subscribe; returns the unsubscribe function. */
823
+ on(listener: UiActionListener): () => void;
824
+ /**
825
+ * The game has the controls: nothing is delivered or swallowed, except the in-game
826
+ * menu combo — Select + Start, and the `menuKeys` (Escape) — which arrives as `menu`.
827
+ */
828
+ suspend(): void;
829
+ resume(): void;
830
+ readonly suspended: boolean;
831
+ /** Read the gamepads once. Called every frame by the default scheduler. */
832
+ poll(): void;
833
+ /** Remove every listener and stop polling. */
834
+ destroy(): void;
835
+ }
836
+
837
+ const DIRECTIONS: ReadonlySet<UiAction> = new Set<UiAction>(['up', 'down', 'left', 'right']);
838
+
839
+ const isEditable = (target: EventTarget | null): boolean => {
840
+ const element = target as { tagName?: string; isContentEditable?: boolean } | null;
841
+ if (!element) return false;
842
+ if (element.isContentEditable) return true;
843
+ return element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT';
844
+ };
845
+
846
+ /**
847
+ * The scheme's runtime: keyboard and gamepads become {@link UiAction}s.
848
+ *
849
+ * Keyboard: `keydown` mapped through {@link UI_KEYMAP} (keys typed into form fields
850
+ * are left alone). Gamepads: polled each frame through {@link UI_PADMAP} and the left
851
+ * stick; directions repeat with {@link UI_REPEAT}; Select + Start together is `menu`
852
+ * (and then neither fires on its own). While {@link UiInput.suspend | suspended} (in
853
+ * game) the game gets every input except the menu combo: Select + Start and the
854
+ * `menuKeys` (Escape, F1) still arrive, as `menu`, so the pause menu can always open.
855
+ */
856
+ export function createInput(options: UiInputOptions = {}): UiInput {
857
+ const target = options.target ?? (globalThis as unknown as EventTarget);
858
+ const capture = options.capture ?? true;
859
+ const swallow = options.swallow ?? true;
860
+ const keymap = options.keymap ?? UI_KEYMAP;
861
+ const menuKeys = new Set(options.menuKeys ?? ['Escape', 'F1']);
862
+ const padmap = options.padmap ?? UI_PADMAP;
863
+ const now = options.now ?? (() => globalThis.performance?.now() ?? Date.now());
864
+ const gamepads =
865
+ options.gamepads ??
866
+ (() => (globalThis.navigator?.getGamepads?.() ?? []) as ReadonlyArray<UiGamepadLike | null>);
867
+ const schedule =
868
+ options.schedule === undefined
869
+ ? (globalThis.requestAnimationFrame?.bind(globalThis) ?? null)
870
+ : options.schedule;
871
+
872
+ const listeners = new Set<UiActionListener>();
873
+ let suspended = false;
874
+ let destroyed = false;
875
+ let polling = false;
876
+ const held = new Map<UiAction, { since: number; last: number }>();
877
+
878
+ const fire = (action: UiAction, detail: UiActionDetail): void => {
879
+ for (const listener of listeners) listener(action, detail);
880
+ };
881
+
882
+ const onKey = (event: Event): void => {
883
+ if (destroyed) return;
884
+ const key = event as KeyboardEvent;
885
+ if (suspended) {
886
+ if (!menuKeys.has(key.key) || key.repeat) return;
887
+ key.preventDefault();
888
+ key.stopPropagation();
889
+ fire('menu', { source: 'keyboard', repeat: false });
890
+ return;
891
+ }
892
+ if (isEditable(key.target) && key.key !== 'Escape') return;
893
+ const action = keymap[key.key];
894
+ if (!action) return;
895
+ if (swallow) {
896
+ key.preventDefault();
897
+ key.stopPropagation();
898
+ }
899
+ fire(action, { source: 'keyboard', repeat: key.repeat === true });
900
+ };
901
+
902
+ const pressed = (pad: UiGamepadLike): Set<UiAction> => {
903
+ const actions = new Set<UiAction>();
904
+ const select = pad.buttons[8]?.pressed === true;
905
+ const start = pad.buttons[9]?.pressed === true;
906
+ if (select && start) actions.add('menu');
907
+ pad.buttons.forEach((button, index) => {
908
+ if (!button.pressed) return;
909
+ if ((index === 8 || index === 9) && select && start) return;
910
+ const action = padmap[index];
911
+ if (action) actions.add(action);
912
+ });
913
+ const [x = 0, y = 0] = pad.axes;
914
+ if (x < -UI_REPEAT.axis) actions.add('left');
915
+ if (x > UI_REPEAT.axis) actions.add('right');
916
+ if (y < -UI_REPEAT.axis) actions.add('up');
917
+ if (y > UI_REPEAT.axis) actions.add('down');
918
+ return actions;
919
+ };
920
+
921
+ const poll = (): void => {
922
+ if (destroyed) return;
923
+ const t = now();
924
+ const active = new Set<UiAction>();
925
+ for (const pad of gamepads()) if (pad) for (const action of pressed(pad)) active.add(action);
926
+
927
+ for (const action of active) {
928
+ const state = held.get(action);
929
+ if (!state) {
930
+ held.set(action, { since: t, last: t });
931
+ // In game only the Select + Start combo gets through (plain Start is the game's).
932
+ const combo = action === 'menu' && gamepads().some((p) => p?.buttons[8]?.pressed && p.buttons[9]?.pressed);
933
+ if (!suspended || combo) fire(action, { source: 'gamepad', repeat: false });
934
+ } else if (
935
+ DIRECTIONS.has(action) &&
936
+ t - state.since >= UI_REPEAT.delay &&
937
+ t - state.last >= UI_REPEAT.interval
938
+ ) {
939
+ state.last = t;
940
+ if (!suspended) fire(action, { source: 'gamepad', repeat: true });
941
+ }
942
+ }
943
+ for (const action of [...held.keys()]) if (!active.has(action)) held.delete(action);
944
+ };
945
+
946
+ const loop = (): void => {
947
+ if (destroyed || !schedule) return;
948
+ poll();
949
+ schedule(loop);
950
+ };
951
+ const startPolling = (): void => {
952
+ if (polling || !schedule) return;
953
+ polling = true;
954
+ schedule(loop);
955
+ };
956
+
957
+ target.addEventListener('keydown', onKey, { capture });
958
+ target.addEventListener('gamepadconnected', startPolling);
959
+ if (gamepads().some(Boolean)) startPolling();
960
+
961
+ return {
962
+ on(listener) {
963
+ listeners.add(listener);
964
+ return () => listeners.delete(listener);
965
+ },
966
+ suspend() {
967
+ suspended = true;
968
+ },
969
+ resume() {
970
+ suspended = false;
971
+ },
972
+ get suspended() {
973
+ return suspended;
974
+ },
975
+ poll,
976
+ destroy() {
977
+ destroyed = true;
978
+ listeners.clear();
979
+ held.clear();
980
+ target.removeEventListener('keydown', onKey, { capture });
981
+ target.removeEventListener('gamepadconnected', startPolling);
982
+ },
983
+ };
984
+ }
985
+
986
+ /** Anything with a layout box (an `Element`, or a test double). */
987
+ export interface UiFocusable {
988
+ getBoundingClientRect(): { left: number; top: number; width: number; height: number };
989
+ }
990
+
991
+ /**
992
+ * Spatial navigation: the candidate a direction leads to from `from`, or null.
993
+ *
994
+ * Only candidates whose centre lies ahead in that direction count; among them the
995
+ * nearest wins, with sideways distance weighted double so "down" prefers the item
996
+ * straight below over a closer one off to the side.
997
+ */
998
+ export function nextFocus<T extends UiFocusable>(
999
+ from: UiFocusable,
1000
+ candidates: Iterable<T>,
1001
+ direction: UiDirection,
1002
+ ): T | null {
1003
+ const centre = (el: UiFocusable) => {
1004
+ const r = el.getBoundingClientRect();
1005
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
1006
+ };
1007
+ const origin = centre(from);
1008
+ let best: T | null = null;
1009
+ let bestScore = Infinity;
1010
+ for (const candidate of candidates) {
1011
+ if ((candidate as UiFocusable) === from) continue;
1012
+ const c = centre(candidate);
1013
+ const dx = c.x - origin.x;
1014
+ const dy = c.y - origin.y;
1015
+ const ahead = direction === 'up' ? -dy : direction === 'down' ? dy : direction === 'left' ? -dx : dx;
1016
+ if (ahead <= 0.5) continue;
1017
+ const side = direction === 'up' || direction === 'down' ? Math.abs(dx) : Math.abs(dy);
1018
+ const score = ahead + side * 2;
1019
+ if (score < bestScore) {
1020
+ bestScore = score;
1021
+ best = candidate;
1022
+ }
1023
+ }
1024
+ return best;
1025
+ }
1026
+
1027
+ /**
1028
+ * The look every menu shares, as CSS custom properties: the focus ring, radii, type
1029
+ * faces, colours and spacing. Apps read them with `var(--wg-…)`; {@link uiTokensCss}
1030
+ * writes them into a stylesheet.
1031
+ */
1032
+ export const UI_TOKENS: Readonly<Record<string, string>> = Object.freeze({
1033
+ '--wg-bg': '#07080c',
1034
+ '--wg-surface': 'rgb(18 20 28 / 0.78)',
1035
+ '--wg-line': 'rgb(255 255 255 / 0.12)',
1036
+ '--wg-text': '#f4f6fb',
1037
+ '--wg-muted': '#8e97a9',
1038
+ '--wg-ok': '#4ade80',
1039
+ '--wg-danger': '#f87171',
1040
+ '--wg-focus': '#ffffff',
1041
+ '--wg-focus-ring': '0 0 0 3px #ffffff',
1042
+ '--wg-radius': '22px',
1043
+ '--wg-radius-small': '12px',
1044
+ '--wg-gap': '16px',
1045
+ '--wg-font-display': "'Unbounded Variable', 'Unbounded', system-ui, sans-serif",
1046
+ '--wg-font-ui': "'Inter Variable', 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif",
1047
+ '--wg-ease': 'cubic-bezier(0.2, 0.8, 0.2, 1)',
1048
+ '--wg-safe-top': 'env(safe-area-inset-top, 0px)',
1049
+ '--wg-safe-bottom': 'env(safe-area-inset-bottom, 0px)',
1050
+ });
1051
+
1052
+ /** `UI_TOKENS` as a CSS rule, e.g. for a `<style>` an app injects. */
1053
+ export function uiTokensCss(selector = ':root'): string {
1054
+ const body = Object.entries(UI_TOKENS)
1055
+ .map(([name, value]) => ` ${name}: ${value};`)
1056
+ .join('\n');
1057
+ return `${selector} {\n${body}\n}\n`;
1058
+ }
1059
+
1060
+ // ============================================================================
1061
+ // ---- The engine app: the screen an engine ships next to its SDK ----
1062
+ // ============================================================================
1063
+
1064
+ /** What the host (the launcher's route page) hands an engine app. */
1065
+ export interface EngineAppHost {
1066
+ /** The game being played, as the host's catalog describes it. */
1067
+ game: {
1068
+ slug: string;
1069
+ title: string;
1070
+ accent?: string;
1071
+ art?: string;
1072
+ };
1073
+ /** Pass to the SDK's `storage({ namespace })`; the engine prefixes its own id. */
1074
+ storage: { namespace: string };
1075
+ /** The game's files, each read only when the engine asks (keyed by the SDK's asset names). */
1076
+ assets: Record<string, () => Promise<Uint8Array>>;
1077
+ /** The shared input; the app suspends it while the game itself has the controls. */
1078
+ input: UiInput;
1079
+ /** Leave: the host navigates back (to its library or home). Call after `unmount()` work is done. */
1080
+ exit(): void;
1081
+ }
1082
+
1083
+ /** What `mount()` returns: the way to take the app down. */
1084
+ export interface EngineAppHandle {
1085
+ unmount(): Promise<void>;
1086
+ }
1087
+
1088
+ /**
1089
+ * An engine app module (`<id>/app/app.js`): mounts the engine's own screens — start
1090
+ * / load game, pause, options — into `target` and runs the game, following UX.md.
1091
+ */
1092
+ export interface EngineApp {
1093
+ mount(target: HTMLElement, host: EngineAppHost): Promise<EngineAppHandle>;
1094
+ }