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