@wasm-gaming/engine-specs 0.3.0 → 0.3.2

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/README.md CHANGED
@@ -4,8 +4,9 @@ The **engine contract** for the wasm-gaming ecosystem: one shared JS API that ev
4
4
  WASM game/emulator engine implements and every host drives. Define it once here,
5
5
  depend on it everywhere, and engines can't drift.
6
6
 
7
- TypeScript types and nothing else — [src/engine-specs.ts](src/engine-specs.ts) is the
8
- whole package. No runtime dependencies, because twelve engines depend on this.
7
+ The types, plus the contract implemented once so twelve engines inherit it instead of
8
+ re-deriving it — [src/engine-specs.ts](src/engine-specs.ts) is the whole package. No
9
+ runtime dependencies, because twelve engines depend on this.
9
10
 
10
11
  ## The shape
11
12
 
@@ -55,6 +56,85 @@ without asking the engine anything, and without the host knowing a single key na
55
56
  `restart()`. The descriptor shape is never accepted on write: that asymmetry is what
56
57
  keeps a write unambiguous the day a value is itself an object.
57
58
 
59
+ ## Listening
60
+
61
+ `on` and `off` are on the same fluent surface, so they chain wherever the setters do —
62
+ and **registration is synchronous on every carrier**, including the promise `start()`
63
+ returns. The chain is what the return value continues, never what the subscription
64
+ waits for; otherwise `start().on('error', …)` would bind after the session opened and
65
+ miss an error thrown while it was opening.
66
+
67
+ A listener receives an **event**, not the payload: what it carries is `detail`, as
68
+ `CustomEvent` does it. The envelope is what lets an event grow a field without every
69
+ listener signature changing — and what survives `postMessage` intact when the engine
70
+ lives in a worker, which is where this ecosystem is going.
71
+
72
+ ```js
73
+ const play = await new Rsdkv4SDK({ on: { error: report } }) // in the constructor
74
+ .on('start', (event) => console.log('session', event.detail))
75
+ .assets({ pack })
76
+ .start();
77
+ ```
78
+
79
+ It is deliberately **not** a DOM event: two members, frozen, no bubbling, no
80
+ `preventDefault()`, no `target`. Half-implementing a familiar interface is worse than
81
+ offering a different one.
82
+
83
+ The factory and the engine it opens are **one channel**, so subscribing before there is
84
+ an engine is the normal way to do it. Every engine emits `start`, `error`, `exit`,
85
+ `pause` and `resume`; an engine that has more of its own declares them in its payload
86
+ bundle and they type-check alongside the core set:
87
+
88
+ ```ts
89
+ interface Rsdkv4Payloads extends EnginePayloads {
90
+ // …the four payloads
91
+ events: { levelLoaded: { zone: string } }; // optional; omit it for the core set alone
92
+ }
93
+ ```
94
+
95
+ `error` is emitted **and** the promise rejects — both, always — so a host that does not
96
+ `await` is not left blind, and one that does is not forced to subscribe.
97
+
98
+ ## Writing an engine
99
+
100
+ None of the above has to be implemented by hand. `EnginePlayBase` and `EngineSDKBase`
101
+ carry the state, the descriptors, the cumulative merge, the chainable promise and the
102
+ channel; an engine declares its properties as data and fills in what only it knows.
103
+
104
+ ```js
105
+ import { EnginePlayBase, EngineSDKBase } from '@wasm-gaming/engine-specs';
106
+
107
+ const spec = {
108
+ config: { region: { default: 'us', enum: ['us', 'eu', 'jp'], live: false } },
109
+ options: { slot: { default: 0, live: true } },
110
+ assets: { pack: { allowedTypes: ['.bin'], live: false } },
111
+ storage: { namespace: { default: 'rsdkv4', live: false } },
112
+ };
113
+
114
+ class Rsdkv4Play extends EnginePlayBase {
115
+ constructor(init, events) { super(spec, init, events); }
116
+ async open(options) { /* start or reopen a session */ }
117
+ async close() { /* stop and release */ }
118
+ mounted(target) { /* the picture goes here */ }
119
+ }
120
+
121
+ export class Rsdkv4SDK extends EngineSDKBase {
122
+ static id = 'rsdkv4';
123
+ static version = '1.0.0';
124
+ createPlay(init, events) { return new Rsdkv4Play(init, events); }
125
+ }
126
+ ```
127
+
128
+ `default` is declared in the spec and **nowhere else** — the base seeds its starting
129
+ state from it, so the initial value and the advertised default cannot drift apart.
130
+ `start`, `exit` and `error` are emitted for you, including the rule that a failing hook
131
+ both rejects the chain and emits `error`; `this.emit(…)` is for the engine's own
132
+ events.
133
+
134
+ Adoption is optional: the contract is structural, so an engine with its own emitter and
135
+ its own state satisfies `EngineSDK` without extending anything. `EventEmitter` and
136
+ `hydratePlay` are exported for engines that want the pieces rather than the whole.
137
+
58
138
  ## Closing the types
59
139
 
60
140
  One type parameter carries all four payloads, so there is no order to get wrong:
@@ -85,10 +165,15 @@ make preview # serve site/ on :8020 (PORT= to change it)
85
165
  Two engines implement the contract in this repo, and both are type-checked in CI:
86
166
  [tests/engine.conformance.ts](tests/engine.conformance.ts), the reference engine, and
87
167
  [demo/demo.sdk.js](demo/demo.sdk.js), which drives the [demo
88
- page](https://wasm-gaming.github.io/engine-specs/demo/). The negative cases live in
168
+ page](https://wasm-gaming.github.io/engine-specs/demo/). Between them they cover both
169
+ branches of the event map — the reference engine declares one of its own, the demo
170
+ omits `events` and gets the core set alone. The negative cases live in
89
171
  [tests/types.test-d.ts](tests/types.test-d.ts), guarded by `@ts-expect-error` — which
90
172
  fails if a rejection ever stops happening.
91
173
 
174
+ The emitter is the one part with behaviour to run rather than compile, so it has
175
+ tests that run: [tests/event-emitter.test.mjs](tests/event-emitter.test.mjs).
176
+
92
177
  ## License
93
178
 
94
179
  MIT.
package/demo/app.html CHANGED
@@ -71,6 +71,27 @@
71
71
  calls = [line, ...calls].slice(0, 40);
72
72
  };
73
73
 
74
+ /**
75
+ * The only place the factory is built, so the page subscribes exactly once — and
76
+ * does it in the constructor, which is the same call as `.on(type, listener)`.
77
+ *
78
+ * The handlers outlive every engine this factory opens: `start` fires again on each
79
+ * `restart()`, `exit` on `destroy()`, and `pause`/`resume` come from methods the
80
+ * contract does not know about. Nothing below re-subscribes.
81
+ *
82
+ * Each one receives an event, not the payload: what it carries is `detail`.
83
+ */
84
+ const engine = () =>
85
+ (sdk ??= new DemoEngineSDK({
86
+ on: {
87
+ start: ({ detail }) => say(`▸ start fileName: ${detail.fileName ?? 'unset'}`),
88
+ pause: ({ detail }) => say(`▸ pause owner: ${detail.owner}`),
89
+ resume: ({ detail }) => say(`▸ resume owner: ${detail.owner}`),
90
+ error: ({ detail }) => say(`▸ error ${detail.message}`),
91
+ exit: ({ type }) => say(`▸ ${type}`),
92
+ },
93
+ }));
94
+
74
95
  const show = (value) => {
75
96
  if (value === undefined) return 'unset';
76
97
  if (value instanceof Uint8Array) return `${value.byteLength} bytes`;
@@ -92,7 +113,7 @@
92
113
 
93
114
  /** Reads the live engine — or the factory, when there is not one yet. */
94
115
  const refresh = async () => {
95
- const source = play ?? (sdk ??= new DemoEngineSDK());
116
+ const source = play ?? engine();
96
117
  const [config, options, assetProps, storage] = await Promise.all([
97
118
  source.config(),
98
119
  source.options(),
@@ -121,7 +142,7 @@
121
142
  const onSelect = async ({ key, file }) => {
122
143
  const bytes = new Uint8Array(await file.arrayBuffer());
123
144
  picked[key] = file.name;
124
- sdk ??= new DemoEngineSDK();
145
+ engine();
125
146
  say(`sdk.assets({ ${key}: Uint8Array(${bytes.byteLength}) })`);
126
147
  sdk.assets({ [key]: bytes });
127
148
  if (key === 'rom') {
@@ -139,7 +160,7 @@
139
160
  };
140
161
 
141
162
  const onStart = async () => {
142
- sdk ??= new DemoEngineSDK();
163
+ engine();
143
164
  say('play = await sdk.mount(stage).start()');
144
165
  // The whole chain in one expression, exactly as the contract advertises it.
145
166
  play = await sdk.mount(document.querySelector('#stage')).start();
@@ -152,15 +173,16 @@
152
173
  await refresh();
153
174
  };
154
175
 
155
- // `pause` and `resume` are this engine's own members, not the contract's.
176
+ // The methods are this engine's own; the `pause`/`resume` they emit are the
177
+ // contract's — which is why the log shows a ▸ line the page never wrote here.
156
178
  const onPause = async () => {
157
- say('play.pause() // engine-specific, not in the contract');
179
+ say('play.pause() // engine-specific method, contract event');
158
180
  play.pause();
159
181
  await refresh();
160
182
  };
161
183
 
162
184
  const onResume = async () => {
163
- say('play.resume() // engine-specific, not in the contract');
185
+ say('play.resume() // engine-specific method, contract event');
164
186
  play.resume();
165
187
  await refresh();
166
188
  };