@wasm-gaming/engine-specs 0.2.6 → 0.2.7

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/src/engine.ts CHANGED
@@ -1,88 +1,516 @@
1
1
  // Layer B — the imperative contract.
2
2
  //
3
- // Every engine subproject's SDK exposes `{ manifest, load }` (an `EngineSDK`).
4
- // `load()` boots the engine and returns an `EngineInstance` the host controls.
5
- // The interface is identical across engines; per-engine differences are absorbed
6
- // by `manifest` (Layer A) and the `assets`/`options` payloads.
3
+ // Every engine subproject exports a class that extends `EngineSDK`: a factory the
4
+ // host configures by chaining, and whose `boot()`/`start()` hand back an `EnginePlay`
5
+ // — the live engine. The chain is written once, here, so twelve engines don't each
6
+ // reinvent it; a concrete engine supplies only `create()`.
7
+ //
8
+ // Per-engine differences are absorbed by `manifest` (Layer A) and by closing the
9
+ // `Config` / `Options` generics.
10
+ //
11
+ // Status: shapes only. Every member is `abstract`, so the classes exist at runtime but
12
+ // implement nothing yet — the chain, the freeze, the payload merge and the idempotent
13
+ // `boot()` land with the contract 1.0 release.
7
14
 
8
15
  import type { EngineManifest, KeyMap, InputPreset } from './manifest.js';
9
16
 
10
- /** Binary asset payload accepted by `load()`. */
11
- export type AssetData = Uint8Array | ArrayBuffer | string;
12
-
13
- /** Where an engine may persist saves/SRAM. */
14
- export type PersistMode = 'idbfs' | 'opfs' | null;
15
-
16
- type EngineConfigBase = {
17
- /** Runtime files keyed by `AssetSpec.key`. Required specs must be present. */
18
- assets: Record<string, AssetData>;
19
- /** Engine-specific settings, validated against `manifest.options`. */
20
- options?: Record<string, unknown>;
21
- /** Persistence backend for saves, when the engine supports it. */
22
- persist?: PersistMode;
23
- /**
24
- * Optional storage namespace under the engine's working dir.
25
- * Hosts can use this to isolate per-game persisted assets (e.g. sonic1/sonic2).
26
- */
27
- storageNamespace?: string;
28
- /** Lifecycle/telemetry sink. Handlers must not throw. */
29
- onEvent?: (e: EngineEvent) => void;
30
- /** Override artifact locations (e.g. host-fetched Release assets). */
31
- jsUrl?: string;
32
- wasmUrl?: string;
17
+ // ---------------------------------------------------------------------------
18
+ // Setup — what the host hands the factory
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /**
22
+ * Where a runtime asset comes from.
23
+ *
24
+ * The host owns its files; the SDK only ever reads them. The SDK picks the
25
+ * strategy: an OPFS handle can be read on demand, a `showOpenFilePicker()` handle
26
+ * has no sync access and gets copied. The host does not need to know which.
27
+ *
28
+ * A thunk is resolved by `boot()`, and only if the asset is actually needed.
29
+ *
30
+ * @category Setup
31
+ */
32
+ export type AssetSource =
33
+ | Uint8Array
34
+ | ArrayBuffer
35
+ | Blob
36
+ /** A door to a file, read on demand. */
37
+ | FileSystemFileHandle
38
+ /** A URL the SDK downloads. The only source that makes the SDK touch the network. */
39
+ | string
40
+ /** Resolved lazily, by `boot()`, and only if the asset is needed. */
41
+ | (() => AssetSource | Promise<AssetSource>);
42
+
43
+ /**
44
+ * The SDK's own folder: saved games, plus whatever internal state the engine
45
+ * persists. Never assets, never player preferences.
46
+ *
47
+ * There is no `required` flag: if the engine needs persistence and cannot have it,
48
+ * boot fails on its own with a typed {@link EngineError} that says *what* went
49
+ * wrong. Without `storage()` nothing is persisted at all.
50
+ *
51
+ * @category Setup
52
+ */
53
+ export interface StorageSetup {
54
+ /** Folder the SDK owns, e.g. `"rsdkv4/sonic-1"`. Chosen by the host. */
55
+ namespace: string;
56
+ }
57
+
58
+ /** Live storage state, once the engine is up. @category Setup */
59
+ export interface StorageInfo extends StorageSetup {
60
+ /** `false` when the browser granted only best-effort storage — warn the player. */
61
+ persistent: boolean;
62
+ }
63
+
64
+ /**
65
+ * The JSON-safe half of {@link EngineSetup}.
66
+ *
67
+ * `structuredClone` (and therefore IndexedDB) round-trips the full setup, but
68
+ * `JSON.stringify` silently turns a `FileSystemFileHandle`, an `ArrayBuffer` or a
69
+ * `Blob` into `{}` and drops functions. Only this half survives JSON.
70
+ *
71
+ * @category Setup
72
+ */
73
+ export interface EngineSetupData<Config, Options> {
74
+ /** Engine settings, applied at boot. */
75
+ config?: Partial<Config>;
76
+ /** Default session payload — see {@link EngineSDK.options}. */
77
+ options?: Partial<Options>;
78
+ storage?: StorageSetup;
79
+ }
80
+
81
+ /**
82
+ * Everything the factory can be configured with, in one object.
83
+ *
84
+ * Accepted by the constructor, which is what makes "restore the last session"
85
+ * a single call. Anything settable here is settable by chaining, and vice versa.
86
+ *
87
+ * @category Setup
88
+ */
89
+ export interface EngineSetup<Config, Options> extends EngineSetupData<Config, Options> {
90
+ /** Runtime files keyed by `AssetSpec.key`. */
91
+ assets?: Record<string, AssetSource>;
92
+ /** Where the engine is seen. A container is scaled by the SDK; a canvas is yours. */
93
+ mount?: HTMLElement;
94
+ on?: Partial<EngineEventHandlers>;
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Resolved properties — the one shape all four getters return
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /**
102
+ * One setting, resolved: what is in effect, plus the JSON Schema vocabulary the
103
+ * manifest declared for it. Enough to paint a settings screen without asking the
104
+ * engine anything.
105
+ *
106
+ * There is no `source` field: the method you called *is* the source —
107
+ * {@link EngineSDK.config} are engine keys, {@link EngineSDK.options} are session keys.
108
+ *
109
+ * @category Config & options
110
+ */
111
+ export interface ResolvedProperty<Value = unknown> {
112
+ /** What is in effect — or what will be used, if there is no engine yet. */
113
+ value: Value;
114
+ /** What the manifest declares, when it declares it. */
115
+ default?: Value;
116
+ /**
117
+ * Changing this needs a restart. It does **not** mean immutable: `player` is
118
+ * `readOnly` in `options()` and changes fine through {@link EnginePlay.restart}.
119
+ * A settings screen can grey it out instead of hiding it.
120
+ */
121
+ readOnly?: true;
122
+ type?: string;
123
+ enum?: readonly unknown[];
124
+ enumNames?: readonly string[];
125
+ minimum?: number;
126
+ maximum?: number;
127
+ title?: string;
128
+ description?: string;
129
+ }
130
+
131
+ /** Every key of `T`, resolved. The return shape of all four getters. @category Config & options */
132
+ export type ResolvedProperties<T> = { [K in keyof T]-?: ResolvedProperty<T[K]> };
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Events and errors
136
+ // ---------------------------------------------------------------------------
137
+
138
+ /**
139
+ * `ready` fires when `boot()` completes, `start` when a session opens. A
140
+ * `sdk.start()` emits both, in that order; a `restart()` does not re-emit `ready`.
141
+ *
142
+ * @category Events
143
+ */
144
+ export interface EngineEventMap {
145
+ /** Engine up, no session. */
146
+ ready: void;
147
+ /** A session opened, with the payload it opened with. */
148
+ start: { payload: unknown };
149
+ /** `total` may be absent — guard before dividing. */
150
+ progress: { key: string; loaded: number; total?: number };
151
+ frame: { fps: number };
152
+ pause: { owner: string };
153
+ resume: { owner: string };
154
+ error: { error: EngineError };
155
+ exit: void;
156
+ }
157
+
158
+ /** @category Events */
159
+ export type EngineEventHandlers = {
160
+ [K in keyof EngineEventMap]: (event: EngineEventMap[K]) => void;
33
161
  };
34
162
 
35
- export type EngineConfig = EngineConfigBase &
36
- (
37
- | {
38
- /** Render target. The SDK is responsible for the `#canvas` id if the engine needs it. */
39
- canvasEl: HTMLCanvasElement;
40
- /** Optional container element for SDK-owned DOM when a canvas element is present. */
41
- attachTo?: HTMLElement;
42
- }
43
- | {
44
- /** Optional render target when the SDK primarily mounts into a container element. */
45
- canvasEl?: HTMLCanvasElement;
46
- /** Container element the SDK can use to mount engine-owned DOM. */
47
- attachTo: HTMLElement;
48
- }
49
- );
50
-
51
- export type EngineEvent =
52
- | { type: 'ready' }
53
- | { type: 'error'; error: Error }
54
- | { type: 'exit' }
55
- | { type: 'frame'; fps: number };
56
-
57
- export interface EngineInstance {
58
- /** Begin running. `load()` may auto-start; then this is a no-op. */
59
- start(): void;
60
- pause(): void;
61
- resume(): void;
62
- /** Reset to power-on. May throw if the engine cannot reset in-process. */
63
- reset(): void;
64
- /** Swap the active input preset/map at runtime. */
65
- setInput(map: InputPreset | KeyMap): void;
66
-
67
- // Capability-gated (see manifest.capabilities). Present only when supported.
68
- saveState?(): Promise<Uint8Array>;
69
- loadState?(data: Uint8Array): Promise<void>;
70
- screenshot?(): Promise<Blob>;
71
-
72
- /**
73
- * Optional targeted purge of persisted runtime files for the active namespace.
74
- * Engines that do not persist assets can omit this method.
75
- */
76
- purgeStorage?():
77
- | { data: boolean; settings: boolean }
78
- | Promise<{ data: boolean; settings: boolean }>;
79
-
80
- /** Tear down: stop the loop, release listeners/DOM, free the instance. */
81
- destroy(): void;
163
+ /**
164
+ * Why boot or a call failed.
165
+ *
166
+ * Storage checks (OPFS availability, `crossOriginIsolated`, sync access, quota) run
167
+ * *before* the wasm is instantiated, so the failure arrives as a typed exception and
168
+ * not as a console warning halfway through loading.
169
+ *
170
+ * @category Errors
171
+ */
172
+ export type EngineErrorCode =
173
+ /** No storage backend at all. */
174
+ | 'storage-unavailable'
175
+ /** The page needs COOP/COEP. */
176
+ | 'not-isolated'
177
+ /** Another tab holds the namespace. */
178
+ | 'storage-locked'
179
+ | 'quota-exceeded'
180
+ /** A `required` asset was never handed over — see {@link EngineSDK.missingAssets}. */
181
+ | 'asset-missing'
182
+ /** The engine could not parse the file. Integrity checks against a catalogue are the host's job. */
183
+ | 'asset-invalid'
184
+ | 'unsupported-browser'
185
+ /** A `readOnly` key was written on the live engine — use {@link EnginePlay.restart}. */
186
+ | 'read-only'
187
+ /** A setup method was called while an instance was alive. */
188
+ | 'frozen'
189
+ /** A method was called on a torn-down {@link EnginePlay}. */
190
+ | 'destroyed';
191
+
192
+ /**
193
+ * Errors reach the host **both ways**: the promise rejects *and* `error` is
194
+ * emitted, so a host that does not `await` is not left blind.
195
+ *
196
+ * @category Errors
197
+ */
198
+ export interface EngineError extends Error {
199
+ code: EngineErrorCode;
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Saves
204
+ // ---------------------------------------------------------------------------
205
+
206
+ /** One save slot, as a launcher would paint it. @category Saves */
207
+ export interface SaveSlot {
208
+ id: number;
209
+ empty: boolean;
210
+ /** Ready to show, e.g. `"Zone 1 · A · 3 lives"`. */
211
+ label: string;
212
+ /** Engine-specific extras the host may render. */
213
+ meta: Record<string, unknown>;
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // The chainable promise
218
+ // ---------------------------------------------------------------------------
219
+
220
+ /**
221
+ * A promise of the live engine, hydrated with the engine's own methods.
222
+ *
223
+ * Every method of {@link EnginePlay} already returns a promise, so hydrating is
224
+ * enough: the actions come back as another `PlayPromise` and keep chaining, the
225
+ * queries come back as a plain promise and close.
226
+ *
227
+ * ```js
228
+ * await play.pause(OWNER.menu).options({ spindash: true }).resume(OWNER.menu)
229
+ * const slots = await play.pause(OWNER.menu).saves() // closes
230
+ * ```
231
+ *
232
+ * Two normative rules:
233
+ *
234
+ * - A rejection aborts the rest of the chain — nothing downstream runs.
235
+ * - It is a `Promise` subclass whose `Symbol.species` is `Promise`, so `then()`,
236
+ * `catch()` and `finally()` return a **plain** promise. Calling `then()` closes
237
+ * the chain, like a getter does.
238
+ *
239
+ * @category Lifecycle
240
+ */
241
+ export type PlayPromise<Play> = Promise<Play> & Play;
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // EnginePlay — the live engine
245
+ // ---------------------------------------------------------------------------
246
+
247
+ /**
248
+ * The live engine. Handed to the host by {@link EngineSDK.boot} or
249
+ * {@link EngineSDK.start}; never constructed by the host.
250
+ *
251
+ * Sessions come and go on the same live engine: `boot()` gives you one with no
252
+ * session at all, and each {@link EnginePlay.restart} opens another.
253
+ *
254
+ * The capability-gated members exist **if and only if** `manifest.capabilities`
255
+ * says so, so `if (play.saveState)` and reading the manifest always agree.
256
+ *
257
+ * @category Lifecycle
258
+ */
259
+ export abstract class EnginePlay<
260
+ Options = Record<string, unknown>,
261
+ Config = Record<string, unknown>,
262
+ > {
263
+ /**
264
+ * Open a session, or reopen it.
265
+ *
266
+ * The payload is cumulative: there is one current payload, it starts as the
267
+ * factory's, and every `start()`/`restart()` merges its own on top (flat merge,
268
+ * one level). So `restart({ slot: 3 })` keeps the `player` you opened with, and
269
+ * `restart()` with no argument is a power cycle that repeats the current payload.
270
+ *
271
+ * `null` is an explicit value, not "leave unchanged".
272
+ */
273
+ abstract restart(options?: Partial<Options>): PlayPromise<this>;
274
+
275
+ /**
276
+ * Freeze, on behalf of `owner`. Only the owner can resume, so an overlay cannot
277
+ * resume what another one froze.
278
+ */
279
+ abstract pause(owner: string): PlayPromise<this>;
280
+ /** Resume. Ignored when the pause belongs to somebody else. */
281
+ abstract resume(owner: string): PlayPromise<this>;
282
+
283
+ /**
284
+ * Move the picture — reparents and refits. Throws on a different canvas: the GL
285
+ * context was bound to a surface at boot.
286
+ */
287
+ abstract mount(target: HTMLElement): PlayPromise<this>;
288
+ abstract setInput(map: InputPreset | KeyMap): PlayPromise<this>;
289
+
290
+ /** Read the live engine settings: same keys as `sdk.config()`, current values. */
291
+ abstract config(): Promise<ResolvedProperties<Config>>;
292
+ /**
293
+ * Apply engine settings to *this session only*. Never persisted — where
294
+ * preferences live is the UI's call. Throws `read-only` on a `readOnly` key.
295
+ */
296
+ abstract config(values: Partial<Config>): PlayPromise<this>;
297
+
298
+ /** Read the session options. Runtime discovery: their keys come from the loaded content. */
299
+ abstract options(): Promise<ResolvedProperties<Options>>;
300
+ /**
301
+ * Write session options, as flat values. Throws `read-only` on a `readOnly` key —
302
+ * those change through {@link EnginePlay.restart}.
303
+ *
304
+ * The getter shape (`{ volume: { value: 0.8 } }`) is not accepted: it would be
305
+ * ambiguous the day a property is itself an object.
306
+ */
307
+ abstract options(values: Partial<Options>): PlayPromise<this>;
308
+
309
+ /** Where saves are going, and whether the browser will really keep them. */
310
+ abstract storage(): Promise<StorageInfo>;
311
+
312
+ abstract on<K extends keyof EngineEventMap>(
313
+ type: K,
314
+ handler: (event: EngineEventMap[K]) => void,
315
+ ): PlayPromise<this>;
316
+ abstract off<K extends keyof EngineEventMap>(
317
+ type: K,
318
+ handler: (event: EngineEventMap[K]) => void,
319
+ ): PlayPromise<this>;
320
+
321
+ /**
322
+ * Stop the loop, silence the audio, flush saves, release the canvas.
323
+ *
324
+ * **Closes the chain** — the factory stays frozen until this settles, which is
325
+ * what stops a host from repointing `storage()` while the old engine is still
326
+ * flushing into the old namespace.
327
+ *
328
+ * Idempotent: calling it again resolves and does nothing. The freeze is released
329
+ * when it *settles*, not when it fulfils — a failed flush must not brick the
330
+ * factory. Afterwards every method here throws `destroyed`, in-flight chains
331
+ * included.
332
+ */
333
+ abstract destroy(): Promise<void>;
334
+
335
+ /** Present when `capabilities.saves !== false`. */
336
+ abstract saves?(): Promise<SaveSlot[]>;
337
+ /** Present when `capabilities.saves !== false`. */
338
+ abstract deleteSave?(id: number): PlayPromise<this>;
339
+ /** Present when `capabilities.saveStates`. */
340
+ abstract saveState?(): Promise<Uint8Array>;
341
+ /** Present when `capabilities.saveStates`. */
342
+ abstract loadState?(data: Uint8Array): PlayPromise<this>;
343
+ /** Present when `capabilities.screenshots`. */
344
+ abstract screenshot?(): Promise<Blob>;
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // EngineSDK — the factory
349
+ // ---------------------------------------------------------------------------
350
+
351
+ /**
352
+ * The factory every engine package exports a subclass of.
353
+ *
354
+ * Configured by chaining — all of it synchronous, because none of it crosses to
355
+ * the engine; these methods record, they do not resolve. Then `boot()` or
356
+ * `start()` produce the live {@link EnginePlay}.
357
+ *
358
+ * ```js
359
+ * const play = await new MotorSDK()
360
+ * .assets({ data: packHandle })
361
+ * .config({ language: 4 })
362
+ * .storage({ namespace: 'motor/game-1' })
363
+ * .on('error', report)
364
+ * .mount(document.querySelector('#stage'))
365
+ * .start({ slot: 0, player: 1 })
366
+ * ```
367
+ *
368
+ * Anything settable by chaining is settable in the constructor, and vice versa.
369
+ *
370
+ * Three boot verbs, two of them sugar for the third — and the equivalence is
371
+ * literal, it is how the base class implements them:
372
+ *
373
+ * | | what it does | equals |
374
+ * | --- | --- | --- |
375
+ * | `boot()` | engine alive, no session | — |
376
+ * | `play.restart(p)` | opens or reopens a session | — |
377
+ * | `start(p)` | engine alive **and** session running | `boot().restart(p)` |
378
+ *
379
+ * `boot()` is idempotent, so a factory governs at most one live engine, and
380
+ * `start(p)` on a live one is just `restart(p)`. Neither throws.
381
+ *
382
+ * @typeParam Config - the engine's own settings. Close it per engine; an open
383
+ * `Record<string, unknown>` gives up the compile-time check that is the point.
384
+ * @typeParam Options - the session payload, i.e. the writable keys of
385
+ * {@link EnginePlay.options}.
386
+ * @typeParam Play - the engine's own {@link EnginePlay} subclass, when it adds methods.
387
+ *
388
+ * @category Factory
389
+ */
390
+ export abstract class EngineSDK<
391
+ Config = Record<string, unknown>,
392
+ Options = Record<string, unknown>,
393
+ Play extends EnginePlay<Options, Config> = EnginePlay<Options, Config>,
394
+ > {
395
+ /**
396
+ * Accepts the whole setup at once, which is what makes "restore the last session"
397
+ * a single call. Only {@link EngineSetupData} survives `JSON` — use
398
+ * `structuredClone`/IndexedDB for the rest.
399
+ */
400
+ constructor(setup?: EngineSetup<Config, Options>) {
401
+ void setup; // shape only: the base class does not run yet
402
+ }
403
+
404
+ /**
405
+ * Register asset sources. **Always synchronous** — it records sources, it does
406
+ * not resolve them, which is why a value may be a thunk.
407
+ *
408
+ * Throws `frozen` while an engine is alive.
409
+ */
410
+ abstract assets(sources: Record<string, AssetSource>): this;
411
+
412
+ /**
413
+ * Read the boot settings, without booting anything — enough to paint a
414
+ * preferences screen. Synchronous: there is no engine to ask, only
415
+ * `manifest.config` and whatever the chain put on top.
416
+ *
417
+ * With a live engine this still reads *what it booted with*;
418
+ * {@link EnginePlay.config} reads how it is now. Both are correct at once.
419
+ */
420
+ abstract config(): ResolvedProperties<Config>;
421
+ /** Set the boot settings. Throws `frozen` while an engine is alive. */
422
+ abstract config(values: Partial<Config>): this;
423
+
424
+ /**
425
+ * Read the current session payload. Synchronous, and thinner than the other
426
+ * getters — session keys are declared by the loaded content, so with no engine
427
+ * there is no `enum` or `title` to give, only `value`.
428
+ */
429
+ abstract options(): ResolvedProperties<Options>;
430
+ /**
431
+ * Set the payload the next session will open with. Exactly the same as passing it
432
+ * to `start()`.
433
+ *
434
+ * Unlike `assets()`, `config()` and `storage()` this does **not** freeze with a
435
+ * live engine: it does not describe the engine, it describes the next session,
436
+ * and opening another session is legal at any time. It changes nothing hot —
437
+ * that is {@link EnginePlay.options}.
438
+ */
439
+ abstract options(payload: Partial<Options>): this;
440
+
441
+ /** Point the SDK at its folder. Throws `frozen` while an engine is alive. */
442
+ abstract storage(setup: StorageSetup): this;
443
+
444
+ /**
445
+ * Where the engine will be seen. A container is scaled by the SDK; pass your own
446
+ * canvas and the scaling is yours — and only before booting.
447
+ */
448
+ abstract mount(target: HTMLElement): this;
449
+
450
+ abstract on<K extends keyof EngineEventMap>(
451
+ type: K,
452
+ handler: (event: EngineEventMap[K]) => void,
453
+ ): this;
454
+ abstract off<K extends keyof EngineEventMap>(
455
+ type: K,
456
+ handler: (event: EngineEventMap[K]) => void,
457
+ ): this;
458
+
459
+ /**
460
+ * The `required` manifest keys that have not been handed over, **or whose value
461
+ * is not a valid {@link AssetSource}** — a `{}` left by a lost JSON round-trip
462
+ * counts as absent, not as present.
463
+ *
464
+ * Synchronous: it reads the chain, not the disk. Whether the file exists in the
465
+ * host's library is the host's question.
466
+ */
467
+ abstract missingAssets(): string[];
468
+
469
+ /**
470
+ * Engine alive, no session — where a launcher reads the saves and paints its
471
+ * cards before committing to a game.
472
+ *
473
+ * Idempotent: with a live engine it returns *that* engine.
474
+ */
475
+ abstract boot(): PlayPromise<Play>;
476
+ /** `boot()` and open a session. Literally `boot().restart(payload)`. */
477
+ abstract start(payload?: Partial<Options>): PlayPromise<Play>;
478
+
479
+ /**
480
+ * Read the saves without a session.
481
+ *
482
+ * Present when `capabilities.saves !== false`. With `'storage'` it reads the
483
+ * store in JS and does **not** boot; with `'engine'` it is sugar for
484
+ * `boot().saves()` and does boot. Same rows either way.
485
+ *
486
+ * Every factory method that needs a live engine is sugar for
487
+ * `boot().<method>()`; the capability only says whether that boot can be skipped.
488
+ */
489
+ abstract saves?(): Promise<SaveSlot[]>;
490
+
491
+ /**
492
+ * The only thing a concrete engine implements. Everything above is written once,
493
+ * in the base class.
494
+ */
495
+ protected abstract create(setup: EngineSetup<Config, Options>): Promise<Play>;
82
496
  }
83
497
 
84
- /** The default export shape every engine package must provide. */
85
- export interface EngineSDK {
86
- manifest: EngineManifest;
87
- load(config: EngineConfig): Promise<EngineInstance>;
498
+ /**
499
+ * The static side of an engine package's export — the half `implements` cannot
500
+ * check, since TypeScript has no abstract statics.
501
+ *
502
+ * ```ts
503
+ * const _contract: EngineSDKClass<MotorConfig, MotorOptions, MotorPlay> = MotorSDK
504
+ * ```
505
+ *
506
+ * @category Factory
507
+ */
508
+ export interface EngineSDKClass<
509
+ Config = Record<string, unknown>,
510
+ Options = Record<string, unknown>,
511
+ Play extends EnginePlay<Options, Config> = EnginePlay<Options, Config>,
512
+ > {
513
+ new (setup?: EngineSetup<Config, Options>): EngineSDK<Config, Options, Play>;
514
+ /** What the engine knows about itself, with nothing instantiated. */
515
+ readonly manifest: EngineManifest;
88
516
  }
package/src/index.ts CHANGED
@@ -1,7 +1,13 @@
1
+ /**
2
+ * @module
3
+ * @document ../docs/CONTRACT-MAP.md
4
+ * @document ../docs/USAGE.md
5
+ */
6
+
1
7
  // @wasm-gaming/engine-specs — the engine contract.
2
8
  //
3
9
  // Layer A (declarative): EngineManifest & friends — what an engine ships/needs.
4
- // Layer B (imperative): EngineSDK / EngineInstance — how the host drives it.
10
+ // Layer B (imperative): EngineSDK / EnginePlay — how the host drives it.
5
11
  // Plus a runtime validator for the manifest.
6
12
 
7
13
  export type {
@@ -16,13 +22,30 @@ export type {
16
22
  JSONSchema,
17
23
  } from './manifest.js';
18
24
 
25
+ // The two base classes. Every member is abstract for now: the shapes are the 1.0
26
+ // contract, the implementation lands with it.
27
+ export { EngineSDK, EnginePlay } from './engine.js';
28
+
19
29
  export type {
20
- EngineSDK,
21
- EngineInstance,
22
- EngineConfig,
23
- EngineEvent,
24
- AssetData,
25
- PersistMode,
30
+ EngineSDKClass,
31
+ // Setup
32
+ AssetSource,
33
+ EngineSetup,
34
+ EngineSetupData,
35
+ StorageSetup,
36
+ StorageInfo,
37
+ // Config & options
38
+ ResolvedProperty,
39
+ ResolvedProperties,
40
+ // Lifecycle
41
+ PlayPromise,
42
+ // Events & errors
43
+ EngineEventMap,
44
+ EngineEventHandlers,
45
+ EngineError,
46
+ EngineErrorCode,
47
+ // Saves
48
+ SaveSlot,
26
49
  } from './engine.js';
27
50
 
28
51
  export { validateManifest, assertManifest } from './validate.js';