@joystyk/session 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +36 -0
  4. package/README.md +266 -2
  5. package/dist/assistance.d.ts +3 -0
  6. package/dist/assistance.js +20 -0
  7. package/dist/assistance.js.map +1 -0
  8. package/dist/browser/audio-sounds.d.ts +19 -0
  9. package/dist/browser/audio-sounds.js +413 -0
  10. package/dist/browser/audio-sounds.js.map +1 -0
  11. package/dist/browser/audio.d.ts +16 -0
  12. package/dist/browser/audio.js +182 -0
  13. package/dist/browser/audio.js.map +1 -0
  14. package/dist/browser/index.d.ts +48 -0
  15. package/dist/browser/index.js +140 -0
  16. package/dist/browser/index.js.map +1 -0
  17. package/dist/browser/preparer.d.ts +3 -0
  18. package/dist/browser/preparer.js +73 -0
  19. package/dist/browser/preparer.js.map +1 -0
  20. package/dist/browser/rooms.d.ts +35 -0
  21. package/dist/browser/rooms.js +140 -0
  22. package/dist/browser/rooms.js.map +1 -0
  23. package/dist/fixed-step.d.ts +66 -0
  24. package/dist/fixed-step.js +280 -0
  25. package/dist/fixed-step.js.map +1 -0
  26. package/dist/index.d.ts +6 -0
  27. package/dist/index.js +7 -0
  28. package/dist/local.d.ts +130 -0
  29. package/dist/local.js +723 -0
  30. package/dist/local.js.map +1 -0
  31. package/dist/ports.d.ts +70 -0
  32. package/dist/ports.js +10 -0
  33. package/dist/ports.js.map +1 -0
  34. package/dist/publisher.d.ts +11 -0
  35. package/dist/publisher.js +44 -0
  36. package/dist/publisher.js.map +1 -0
  37. package/dist/remote.d.ts +128 -0
  38. package/dist/remote.js +474 -0
  39. package/dist/remote.js.map +1 -0
  40. package/dist/replay.d.ts +65 -0
  41. package/dist/replay.js +199 -0
  42. package/dist/replay.js.map +1 -0
  43. package/dist/rngs.d.ts +22 -0
  44. package/dist/rngs.js +34 -0
  45. package/dist/rngs.js.map +1 -0
  46. package/dist/saves.d.ts +20 -0
  47. package/dist/saves.js +59 -0
  48. package/dist/saves.js.map +1 -0
  49. package/dist/stopwatch.d.ts +22 -0
  50. package/dist/stopwatch.js +51 -0
  51. package/dist/stopwatch.js.map +1 -0
  52. package/dist/values.d.ts +19 -0
  53. package/dist/values.js +66 -0
  54. package/dist/values.js.map +1 -0
  55. package/package.json +56 -3
@@ -0,0 +1,48 @@
1
+ import type { AiRunner, Clock, FrameScheduler, JobRunner, Reporter, SaveStore, Visibility } from '../ports.js';
2
+ export { workerPreparer } from './preparer.js';
3
+ export { createGameAudio, watchGameAudio, type GameAudioService, type AudioEnvironment } from './audio.js';
4
+ /** `performance.now()` and `setTimeout`. */
5
+ export declare const browserClock: () => Clock;
6
+ /** The Page Visibility API. `pagehide` also counts as hidden, for pages entering the back/forward cache. */
7
+ export declare const documentVisibility: (doc?: Document) => Visibility;
8
+ /** `requestAnimationFrame`, passing the frame's timestamp. */
9
+ export declare const animationFrames: () => FrameScheduler;
10
+ /**
11
+ * A save slot in Web Storage, until the `storage` package's IndexedDB store exists. Writes
12
+ * reject when storage is unavailable or full (private browsing, quota).
13
+ */
14
+ export declare const webStorageSaveStore: (key: string, storage?: Storage) => SaveStore & {
15
+ read(): unknown;
16
+ };
17
+ /**
18
+ * Runs each job in a fresh worker from `createWorker(kind)`. The worker receives
19
+ * `{ id, kind, payload }` and answers with one message: the job outcome
20
+ * (`{ ok: true, value, presentation? }` or `{ ok: false, reason }`). Cancelling, or the
21
+ * answer arriving, terminates the worker. The runner parses the answer.
22
+ */
23
+ export declare const workerJobRunner: (createWorker: (kind: string) => Worker) => JobRunner;
24
+ /**
25
+ * Runs each AI choice in a fresh worker from `createWorker()`, for games whose manifest
26
+ * declares heavy AI. The worker receives the `AiTask` (`{ view, seat, level, rng }`, all
27
+ * JSON) and answers with one message: the command. A game's worker entry is a few lines:
28
+ *
29
+ * ```ts
30
+ * // games/<id>/jobs/ai.worker.ts
31
+ * import { restoreRng } from '@joystyk/kit';
32
+ * import { engine } from '../engine/index.js';
33
+ * onmessage = ({ data }) => postMessage(engine.ai.choose(data.view, data.seat, data.level, restoreRng(data.rng)));
34
+ * ```
35
+ *
36
+ * Cancelling terminates the worker, which is the only way to stop a search that's running.
37
+ * The runner parses the answer with the engine's command schema.
38
+ */
39
+ export declare const workerAiRunner: <View>(createWorker: () => Worker) => AiRunner<View>;
40
+ /** Logs caught bugs to the console. */
41
+ export declare const consoleReporter: Reporter;
42
+ /** The usual browser ports in one object, to spread into a runner's options. */
43
+ export declare const browserPorts: () => {
44
+ readonly clock: Clock;
45
+ readonly visibility: Visibility;
46
+ readonly frames: FrameScheduler;
47
+ readonly report: Reporter;
48
+ };
@@ -0,0 +1,140 @@
1
+ import { createGameAudio, watchGameAudio } from "./audio.js";
2
+ import { workerPreparer } from "./preparer.js";
3
+ //#region src/browser/index.ts
4
+ /** `performance.now()` and `setTimeout`. */
5
+ var browserClock = () => ({
6
+ now: () => performance.now(),
7
+ after: (ms, callback) => {
8
+ const id = setTimeout(callback, ms);
9
+ return () => clearTimeout(id);
10
+ }
11
+ });
12
+ /** The Page Visibility API. `pagehide` also counts as hidden, for pages entering the back/forward cache. */
13
+ var documentVisibility = (doc = document) => ({
14
+ hidden: () => doc.visibilityState === "hidden",
15
+ subscribe: (listener) => {
16
+ const onChange = () => listener(doc.visibilityState === "hidden");
17
+ const onHide = () => listener(true);
18
+ const view = doc.defaultView;
19
+ doc.addEventListener("visibilitychange", onChange);
20
+ view?.addEventListener("pagehide", onHide);
21
+ view?.addEventListener("pageshow", onChange);
22
+ return () => {
23
+ doc.removeEventListener("visibilitychange", onChange);
24
+ view?.removeEventListener("pagehide", onHide);
25
+ view?.removeEventListener("pageshow", onChange);
26
+ };
27
+ }
28
+ });
29
+ /** `requestAnimationFrame`, passing the frame's timestamp. */
30
+ var animationFrames = () => ({ request: (callback) => {
31
+ const id = requestAnimationFrame(callback);
32
+ return () => cancelAnimationFrame(id);
33
+ } });
34
+ /**
35
+ * A save slot in Web Storage, until the `storage` package's IndexedDB store exists. Writes
36
+ * reject when storage is unavailable or full (private browsing, quota).
37
+ */
38
+ var webStorageSaveStore = (key, storage = localStorage) => ({
39
+ write: (save) => {
40
+ try {
41
+ storage.setItem(key, JSON.stringify(save));
42
+ return Promise.resolve();
43
+ } catch (error) {
44
+ return Promise.reject(error instanceof Error ? error : new Error(String(error)));
45
+ }
46
+ },
47
+ clear: () => {
48
+ try {
49
+ storage.removeItem(key);
50
+ return Promise.resolve();
51
+ } catch (error) {
52
+ return Promise.reject(error instanceof Error ? error : new Error(String(error)));
53
+ }
54
+ },
55
+ /** The stored value, unparsed beyond JSON (restore parses it), or null. */
56
+ read: () => {
57
+ try {
58
+ const raw = storage.getItem(key);
59
+ return raw === null ? null : JSON.parse(raw);
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+ });
65
+ /**
66
+ * Runs each job in a fresh worker from `createWorker(kind)`. The worker receives
67
+ * `{ id, kind, payload }` and answers with one message: the job outcome
68
+ * (`{ ok: true, value, presentation? }` or `{ ok: false, reason }`). Cancelling, or the
69
+ * answer arriving, terminates the worker. The runner parses the answer.
70
+ */
71
+ var workerJobRunner = (createWorker) => ({ run: (job, _spec) => {
72
+ const worker = createWorker(job.kind);
73
+ const result = new Promise((resolve, reject) => {
74
+ worker.onmessage = (event) => {
75
+ worker.terminate();
76
+ resolve(event.data);
77
+ };
78
+ worker.onerror = (event) => {
79
+ worker.terminate();
80
+ reject(new Error(event.message || `The ${job.kind} worker failed.`));
81
+ };
82
+ });
83
+ worker.postMessage({
84
+ id: job.id,
85
+ kind: job.kind,
86
+ payload: job.payload
87
+ });
88
+ return {
89
+ result,
90
+ cancel: () => worker.terminate()
91
+ };
92
+ } });
93
+ /**
94
+ * Runs each AI choice in a fresh worker from `createWorker()`, for games whose manifest
95
+ * declares heavy AI. The worker receives the `AiTask` (`{ view, seat, level, rng }`, all
96
+ * JSON) and answers with one message: the command. A game's worker entry is a few lines:
97
+ *
98
+ * ```ts
99
+ * // games/<id>/jobs/ai.worker.ts
100
+ * import { restoreRng } from '@joystyk/kit';
101
+ * import { engine } from '../engine/index.ts';
102
+ * onmessage = ({ data }) => postMessage(engine.ai.choose(data.view, data.seat, data.level, restoreRng(data.rng)));
103
+ * ```
104
+ *
105
+ * Cancelling terminates the worker, which is the only way to stop a search that's running.
106
+ * The runner parses the answer with the engine's command schema.
107
+ */
108
+ var workerAiRunner = (createWorker) => ({ choose: (task) => {
109
+ const worker = createWorker();
110
+ const result = new Promise((resolve, reject) => {
111
+ worker.onmessage = (event) => {
112
+ worker.terminate();
113
+ resolve(event.data);
114
+ };
115
+ worker.onerror = (event) => {
116
+ worker.terminate();
117
+ reject(new Error(event.message || "The AI worker failed."));
118
+ };
119
+ });
120
+ worker.postMessage(task);
121
+ return {
122
+ result,
123
+ cancel: () => worker.terminate()
124
+ };
125
+ } });
126
+ /** Logs caught bugs to the console. */
127
+ var consoleReporter = (error, context) => {
128
+ console.error(`[session] ${context}`, error);
129
+ };
130
+ /** The usual browser ports in one object, to spread into a runner's options. */
131
+ var browserPorts = () => ({
132
+ clock: browserClock(),
133
+ visibility: documentVisibility(),
134
+ frames: animationFrames(),
135
+ report: consoleReporter
136
+ });
137
+ //#endregion
138
+ export { animationFrames, browserClock, browserPorts, consoleReporter, createGameAudio, documentVisibility, watchGameAudio, webStorageSaveStore, workerAiRunner, workerJobRunner, workerPreparer };
139
+
140
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/browser/index.ts"],"sourcesContent":["// @joystyk/session/browser: the runners' ports, implemented with browser APIs. The core\n// package never touches these globals; the launcher passes these adapters in.\nimport type { JobRequest, JobSpec, SaveEnvelope } from '@joystyk/contracts';\nimport type {\n AiRunner,\n AiTask,\n Clock,\n FrameScheduler,\n JobRunner,\n Reporter,\n SaveStore,\n Task,\n Visibility,\n} from '../ports.ts';\nexport { workerPreparer } from './preparer.ts';\nexport { createGameAudio, watchGameAudio, type GameAudioService, type AudioEnvironment } from './audio.ts';\n\n/** `performance.now()` and `setTimeout`. */\nexport const browserClock = (): Clock => ({\n now: () => performance.now(),\n after: (ms, callback) => {\n const id = setTimeout(callback, ms);\n return () => clearTimeout(id);\n },\n});\n\n/** The Page Visibility API. `pagehide` also counts as hidden, for pages entering the back/forward cache. */\nexport const documentVisibility = (doc: Document = document): Visibility => ({\n hidden: () => doc.visibilityState === 'hidden',\n subscribe: (listener) => {\n const onChange = (): void => listener(doc.visibilityState === 'hidden');\n const onHide = (): void => listener(true);\n const view = doc.defaultView;\n doc.addEventListener('visibilitychange', onChange);\n view?.addEventListener('pagehide', onHide);\n view?.addEventListener('pageshow', onChange);\n return () => {\n doc.removeEventListener('visibilitychange', onChange);\n view?.removeEventListener('pagehide', onHide);\n view?.removeEventListener('pageshow', onChange);\n };\n },\n});\n\n/** `requestAnimationFrame`, passing the frame's timestamp. */\nexport const animationFrames = (): FrameScheduler => ({\n request: (callback) => {\n const id = requestAnimationFrame(callback);\n return () => cancelAnimationFrame(id);\n },\n});\n\n/**\n * A save slot in Web Storage, until the `storage` package's IndexedDB store exists. Writes\n * reject when storage is unavailable or full (private browsing, quota).\n */\nexport const webStorageSaveStore = (key: string, storage: Storage = localStorage): SaveStore & { read(): unknown } => ({\n write: (save: SaveEnvelope) => {\n try {\n storage.setItem(key, JSON.stringify(save));\n return Promise.resolve();\n } catch (error) {\n return Promise.reject(error instanceof Error ? error : new Error(String(error)));\n }\n },\n clear: () => {\n try {\n storage.removeItem(key);\n return Promise.resolve();\n } catch (error) {\n return Promise.reject(error instanceof Error ? error : new Error(String(error)));\n }\n },\n /** The stored value, unparsed beyond JSON (restore parses it), or null. */\n read: () => {\n try {\n const raw = storage.getItem(key);\n return raw === null ? null : (JSON.parse(raw) as unknown);\n } catch {\n return null;\n }\n },\n});\n\n/**\n * Runs each job in a fresh worker from `createWorker(kind)`. The worker receives\n * `{ id, kind, payload }` and answers with one message: the job outcome\n * (`{ ok: true, value, presentation? }` or `{ ok: false, reason }`). Cancelling, or the\n * answer arriving, terminates the worker. The runner parses the answer.\n */\nexport const workerJobRunner = (createWorker: (kind: string) => Worker): JobRunner => ({\n run: (job: JobRequest, _spec: JobSpec): Task<unknown> => {\n const worker = createWorker(job.kind);\n const result = new Promise<unknown>((resolve, reject) => {\n worker.onmessage = (event: MessageEvent<unknown>) => {\n worker.terminate();\n resolve(event.data);\n };\n worker.onerror = (event) => {\n worker.terminate();\n reject(new Error(event.message || `The ${job.kind} worker failed.`));\n };\n });\n worker.postMessage({ id: job.id, kind: job.kind, payload: job.payload });\n return { result, cancel: () => worker.terminate() };\n },\n});\n\n/**\n * Runs each AI choice in a fresh worker from `createWorker()`, for games whose manifest\n * declares heavy AI. The worker receives the `AiTask` (`{ view, seat, level, rng }`, all\n * JSON) and answers with one message: the command. A game's worker entry is a few lines:\n *\n * ```ts\n * // games/<id>/jobs/ai.worker.ts\n * import { restoreRng } from '@joystyk/kit';\n * import { engine } from '../engine/index.ts';\n * onmessage = ({ data }) => postMessage(engine.ai.choose(data.view, data.seat, data.level, restoreRng(data.rng)));\n * ```\n *\n * Cancelling terminates the worker, which is the only way to stop a search that's running.\n * The runner parses the answer with the engine's command schema.\n */\nexport const workerAiRunner = <View>(createWorker: () => Worker): AiRunner<View> => ({\n choose: (task: AiTask<View>): Task<unknown> => {\n const worker = createWorker();\n const result = new Promise<unknown>((resolve, reject) => {\n worker.onmessage = (event: MessageEvent<unknown>) => {\n worker.terminate();\n resolve(event.data);\n };\n worker.onerror = (event) => {\n worker.terminate();\n reject(new Error(event.message || 'The AI worker failed.'));\n };\n });\n worker.postMessage(task);\n return { result, cancel: () => worker.terminate() };\n },\n});\n\n/** Logs caught bugs to the console. */\nexport const consoleReporter: Reporter = (error, context) => {\n console.error(`[session] ${context}`, error);\n};\n\n/** The usual browser ports in one object, to spread into a runner's options. */\nexport const browserPorts = (): {\n readonly clock: Clock;\n readonly visibility: Visibility;\n readonly frames: FrameScheduler;\n readonly report: Reporter;\n} => ({\n clock: browserClock(),\n visibility: documentVisibility(),\n frames: animationFrames(),\n report: consoleReporter,\n});\n"],"mappings":";;;;AAkBA,IAAa,sBAA6B;CACxC,WAAW,YAAY,IAAI;CAC3B,QAAQ,IAAI,aAAa;EACvB,MAAM,KAAK,WAAW,UAAU,EAAE;EAClC,aAAa,aAAa,EAAE;CAC9B;AACF;;AAGA,IAAa,sBAAsB,MAAgB,cAA0B;CAC3E,cAAc,IAAI,oBAAoB;CACtC,YAAY,aAAa;EACvB,MAAM,iBAAuB,SAAS,IAAI,oBAAoB,QAAQ;EACtE,MAAM,eAAqB,SAAS,IAAI;EACxC,MAAM,OAAO,IAAI;EACjB,IAAI,iBAAiB,oBAAoB,QAAQ;EACjD,MAAM,iBAAiB,YAAY,MAAM;EACzC,MAAM,iBAAiB,YAAY,QAAQ;EAC3C,aAAa;GACX,IAAI,oBAAoB,oBAAoB,QAAQ;GACpD,MAAM,oBAAoB,YAAY,MAAM;GAC5C,MAAM,oBAAoB,YAAY,QAAQ;EAChD;CACF;AACF;;AAGA,IAAa,yBAAyC,EACpD,UAAU,aAAa;CACrB,MAAM,KAAK,sBAAsB,QAAQ;CACzC,aAAa,qBAAqB,EAAE;AACtC,EACF;;;;;AAMA,IAAa,uBAAuB,KAAa,UAAmB,kBAAmD;CACrH,QAAQ,SAAuB;EAC7B,IAAI;GACF,QAAQ,QAAQ,KAAK,KAAK,UAAU,IAAI,CAAC;GACzC,OAAO,QAAQ,QAAQ;EACzB,SAAS,OAAO;GACd,OAAO,QAAQ,OAAO,iBAAiB,QAAQ,QAAQ,IAAI,MAAM,OAAO,KAAK,CAAC,CAAC;EACjF;CACF;CACA,aAAa;EACX,IAAI;GACF,QAAQ,WAAW,GAAG;GACtB,OAAO,QAAQ,QAAQ;EACzB,SAAS,OAAO;GACd,OAAO,QAAQ,OAAO,iBAAiB,QAAQ,QAAQ,IAAI,MAAM,OAAO,KAAK,CAAC,CAAC;EACjF;CACF;;CAEA,YAAY;EACV,IAAI;GACF,MAAM,MAAM,QAAQ,QAAQ,GAAG;GAC/B,OAAO,QAAQ,OAAO,OAAQ,KAAK,MAAM,GAAG;EAC9C,QAAQ;GACN,OAAO;EACT;CACF;AACF;;;;;;;AAQA,IAAa,mBAAmB,kBAAuD,EACrF,MAAM,KAAiB,UAAkC;CACvD,MAAM,SAAS,aAAa,IAAI,IAAI;CACpC,MAAM,SAAS,IAAI,SAAkB,SAAS,WAAW;EACvD,OAAO,aAAa,UAAiC;GACnD,OAAO,UAAU;GACjB,QAAQ,MAAM,IAAI;EACpB;EACA,OAAO,WAAW,UAAU;GAC1B,OAAO,UAAU;GACjB,OAAO,IAAI,MAAM,MAAM,WAAW,OAAO,IAAI,KAAK,gBAAgB,CAAC;EACrE;CACF,CAAC;CACD,OAAO,YAAY;EAAE,IAAI,IAAI;EAAI,MAAM,IAAI;EAAM,SAAS,IAAI;CAAQ,CAAC;CACvE,OAAO;EAAE;EAAQ,cAAc,OAAO,UAAU;CAAE;AACpD,EACF;;;;;;;;;;;;;;;;AAiBA,IAAa,kBAAwB,kBAAgD,EACnF,SAAS,SAAsC;CAC7C,MAAM,SAAS,aAAa;CAC5B,MAAM,SAAS,IAAI,SAAkB,SAAS,WAAW;EACvD,OAAO,aAAa,UAAiC;GACnD,OAAO,UAAU;GACjB,QAAQ,MAAM,IAAI;EACpB;EACA,OAAO,WAAW,UAAU;GAC1B,OAAO,UAAU;GACjB,OAAO,IAAI,MAAM,MAAM,WAAW,uBAAuB,CAAC;EAC5D;CACF,CAAC;CACD,OAAO,YAAY,IAAI;CACvB,OAAO;EAAE;EAAQ,cAAc,OAAO,UAAU;CAAE;AACpD,EACF;;AAGA,IAAa,mBAA6B,OAAO,YAAY;CAC3D,QAAQ,MAAM,aAAa,WAAW,KAAK;AAC7C;;AAGA,IAAa,sBAKP;CACJ,OAAO,aAAa;CACpB,YAAY,mBAAmB;CAC/B,QAAQ,gBAAgB;CACxB,QAAQ;AACV"}
@@ -0,0 +1,3 @@
1
+ import { type AsyncPreparer, type Json, type Validator } from '@joystyk/contracts';
2
+ /** A one-request worker, with cancellation and a validated response at the browser boundary. */
3
+ export declare function workerPreparer<S extends Json, C extends Json>(setup: Validator<S>, config: Validator<C>, createWorker: () => Worker): AsyncPreparer<S, C>;
@@ -0,0 +1,73 @@
1
+ import { err, gameError } from "@joystyk/contracts";
2
+ //#region src/browser/preparer.ts
3
+ /** A one-request worker, with cancellation and a validated response at the browser boundary. */
4
+ function workerPreparer(setup, config, createWorker) {
5
+ let disposed = false;
6
+ const pending = /* @__PURE__ */ new Map();
7
+ const reply = { "~standard": {
8
+ version: 1,
9
+ vendor: "joystyk",
10
+ validate: async (value) => {
11
+ if (typeof value !== "object" || value === null) return { issues: [{ message: "Invalid preparer reply." }] };
12
+ const row = value;
13
+ if (row["ok"] === true) {
14
+ const decoded = await config["~standard"].validate(row["value"]);
15
+ return decoded.issues === void 0 ? { value: {
16
+ ok: true,
17
+ value: decoded.value
18
+ } } : decoded;
19
+ }
20
+ if (row["ok"] === false && typeof row["error"] === "object" && row["error"] !== null) {
21
+ const error = row["error"];
22
+ if (typeof error["code"] === "string" && typeof error["message"] === "string") return { value: err(gameError(error["code"], error["message"])) };
23
+ }
24
+ return { issues: [{ message: "Invalid preparer reply." }] };
25
+ }
26
+ } };
27
+ return {
28
+ setup,
29
+ prepare: async (choice, rng) => {
30
+ if (disposed) return err(gameError("Disposed", "The game was closed."));
31
+ const decoded = await setup["~standard"].validate(choice);
32
+ if (decoded.issues !== void 0) return err(gameError("BadSetup", "The game setup could not be read."));
33
+ if (disposed) return err(gameError("Disposed", "The game was closed."));
34
+ return new Promise((resolve) => {
35
+ let worker;
36
+ try {
37
+ worker = createWorker();
38
+ } catch {
39
+ resolve(err(gameError("WorkerFailed", "The game could not be prepared. Try again.")));
40
+ return;
41
+ }
42
+ const finish = (result) => {
43
+ if (!pending.delete(worker)) return;
44
+ worker.terminate();
45
+ resolve(result);
46
+ };
47
+ pending.set(worker, finish);
48
+ worker.addEventListener("message", (event) => {
49
+ Promise.resolve(reply["~standard"].validate(event.data)).then((parsed) => finish(parsed.issues === void 0 ? parsed.value : err(gameError("BadConfig", "The prepared game could not be read."))), () => finish(err(gameError("BadConfig", "The prepared game could not be read."))));
50
+ });
51
+ worker.addEventListener("error", () => finish(err(gameError("WorkerFailed", "The game worker could not finish."))));
52
+ worker.addEventListener("messageerror", () => finish(err(gameError("BadConfig", "The prepared game could not be read."))));
53
+ try {
54
+ worker.postMessage({
55
+ setup: decoded.value,
56
+ seed: rng.save()
57
+ });
58
+ } catch {
59
+ finish(err(gameError("WorkerFailed", "The game could not be prepared. Try again.")));
60
+ }
61
+ });
62
+ },
63
+ dispose: () => {
64
+ if (disposed) return;
65
+ disposed = true;
66
+ for (const finish of pending.values()) finish(err(gameError("Disposed", "The game was closed.")));
67
+ }
68
+ };
69
+ }
70
+ //#endregion
71
+ export { workerPreparer };
72
+
73
+ //# sourceMappingURL=preparer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preparer.js","names":[],"sources":["../../src/browser/preparer.ts"],"sourcesContent":["import { err, gameError, type AsyncPreparer, type Json, type Result, type Validator } from '@joystyk/contracts';\n\n/** A one-request worker, with cancellation and a validated response at the browser boundary. */\nexport function workerPreparer<S extends Json, C extends Json>(\n setup: Validator<S>,\n config: Validator<C>,\n createWorker: () => Worker,\n): AsyncPreparer<S, C> {\n let disposed = false;\n const pending = new Map<Worker, (result: Result<C>) => void>();\n const reply: Validator<Result<C>> = {\n '~standard': {\n version: 1,\n vendor: 'joystyk',\n validate: async (value) => {\n if (typeof value !== 'object' || value === null) return { issues: [{ message: 'Invalid preparer reply.' }] };\n const row = value as Record<string, unknown>;\n if (row['ok'] === true) {\n const decoded = await config['~standard'].validate(row['value']);\n return decoded.issues === undefined ? { value: { ok: true, value: decoded.value } } : decoded;\n }\n if (row['ok'] === false && typeof row['error'] === 'object' && row['error'] !== null) {\n const error = row['error'] as Record<string, unknown>;\n if (typeof error['code'] === 'string' && typeof error['message'] === 'string')\n return { value: err(gameError(error['code'], error['message'])) };\n }\n return { issues: [{ message: 'Invalid preparer reply.' }] };\n },\n },\n };\n return {\n setup,\n prepare: async (choice, rng) => {\n if (disposed) return err(gameError('Disposed', 'The game was closed.'));\n const decoded = await setup['~standard'].validate(choice);\n if (decoded.issues !== undefined) return err(gameError('BadSetup', 'The game setup could not be read.'));\n if (disposed) return err(gameError('Disposed', 'The game was closed.'));\n return new Promise<Result<C>>((resolve) => {\n let worker: Worker;\n try {\n worker = createWorker();\n } catch {\n resolve(err(gameError('WorkerFailed', 'The game could not be prepared. Try again.')));\n return;\n }\n const finish = (result: Result<C>) => {\n if (!pending.delete(worker)) return;\n worker.terminate();\n resolve(result);\n };\n pending.set(worker, finish);\n worker.addEventListener('message', (event: MessageEvent<unknown>) => {\n void Promise.resolve(reply['~standard'].validate(event.data)).then(\n (parsed) =>\n finish(\n parsed.issues === undefined\n ? parsed.value\n : err(gameError('BadConfig', 'The prepared game could not be read.')),\n ),\n () => finish(err(gameError('BadConfig', 'The prepared game could not be read.'))),\n );\n });\n worker.addEventListener('error', () =>\n finish(err(gameError('WorkerFailed', 'The game worker could not finish.'))),\n );\n worker.addEventListener('messageerror', () =>\n finish(err(gameError('BadConfig', 'The prepared game could not be read.'))),\n );\n try {\n worker.postMessage({ setup: decoded.value, seed: rng.save() });\n } catch {\n finish(err(gameError('WorkerFailed', 'The game could not be prepared. Try again.')));\n }\n });\n },\n dispose: () => {\n if (disposed) return;\n disposed = true;\n for (const finish of pending.values()) finish(err(gameError('Disposed', 'The game was closed.')));\n },\n };\n}\n"],"mappings":";;;AAGA,SAAgB,eACd,OACA,QACA,cACqB;CACrB,IAAI,WAAW;CACf,MAAM,0BAAU,IAAI,IAAyC;CAC7D,MAAM,QAA8B,EAClC,aAAa;EACX,SAAS;EACT,QAAQ;EACR,UAAU,OAAO,UAAU;GACzB,IAAI,OAAO,UAAU,YAAY,UAAU,MAAM,OAAO,EAAE,QAAQ,CAAC,EAAE,SAAS,0BAA0B,CAAC,EAAE;GAC3G,MAAM,MAAM;GACZ,IAAI,IAAI,UAAU,MAAM;IACtB,MAAM,UAAU,MAAM,OAAO,YAAY,CAAC,SAAS,IAAI,QAAQ;IAC/D,OAAO,QAAQ,WAAW,KAAA,IAAY,EAAE,OAAO;KAAE,IAAI;KAAM,OAAO,QAAQ;IAAM,EAAE,IAAI;GACxF;GACA,IAAI,IAAI,UAAU,SAAS,OAAO,IAAI,aAAa,YAAY,IAAI,aAAa,MAAM;IACpF,MAAM,QAAQ,IAAI;IAClB,IAAI,OAAO,MAAM,YAAY,YAAY,OAAO,MAAM,eAAe,UACnE,OAAO,EAAE,OAAO,IAAI,UAAU,MAAM,SAAS,MAAM,UAAU,CAAC,EAAE;GACpE;GACA,OAAO,EAAE,QAAQ,CAAC,EAAE,SAAS,0BAA0B,CAAC,EAAE;EAC5D;CACF,EACF;CACA,OAAO;EACL;EACA,SAAS,OAAO,QAAQ,QAAQ;GAC9B,IAAI,UAAU,OAAO,IAAI,UAAU,YAAY,sBAAsB,CAAC;GACtE,MAAM,UAAU,MAAM,MAAM,YAAY,CAAC,SAAS,MAAM;GACxD,IAAI,QAAQ,WAAW,KAAA,GAAW,OAAO,IAAI,UAAU,YAAY,mCAAmC,CAAC;GACvG,IAAI,UAAU,OAAO,IAAI,UAAU,YAAY,sBAAsB,CAAC;GACtE,OAAO,IAAI,SAAoB,YAAY;IACzC,IAAI;IACJ,IAAI;KACF,SAAS,aAAa;IACxB,QAAQ;KACN,QAAQ,IAAI,UAAU,gBAAgB,4CAA4C,CAAC,CAAC;KACpF;IACF;IACA,MAAM,UAAU,WAAsB;KACpC,IAAI,CAAC,QAAQ,OAAO,MAAM,GAAG;KAC7B,OAAO,UAAU;KACjB,QAAQ,MAAM;IAChB;IACA,QAAQ,IAAI,QAAQ,MAAM;IAC1B,OAAO,iBAAiB,YAAY,UAAiC;KACnE,QAAa,QAAQ,MAAM,YAAY,CAAC,SAAS,MAAM,IAAI,CAAC,CAAC,CAAC,MAC3D,WACC,OACE,OAAO,WAAW,KAAA,IACd,OAAO,QACP,IAAI,UAAU,aAAa,sCAAsC,CAAC,CACxE,SACI,OAAO,IAAI,UAAU,aAAa,sCAAsC,CAAC,CAAC,CAClF;IACF,CAAC;IACD,OAAO,iBAAiB,eACtB,OAAO,IAAI,UAAU,gBAAgB,mCAAmC,CAAC,CAAC,CAC5E;IACA,OAAO,iBAAiB,sBACtB,OAAO,IAAI,UAAU,aAAa,sCAAsC,CAAC,CAAC,CAC5E;IACA,IAAI;KACF,OAAO,YAAY;MAAE,OAAO,QAAQ;MAAO,MAAM,IAAI,KAAK;KAAE,CAAC;IAC/D,QAAQ;KACN,OAAO,IAAI,UAAU,gBAAgB,4CAA4C,CAAC,CAAC;IACrF;GACF,CAAC;EACH;EACA,eAAe;GACb,IAAI,UAAU;GACd,WAAW;GACX,KAAK,MAAM,UAAU,QAAQ,OAAO,GAAG,OAAO,IAAI,UAAU,YAAY,sBAAsB,CAAC,CAAC;EAClG;CACF;AACF"}
@@ -0,0 +1,35 @@
1
+ import { type CreateRoomRequest, type CreateRoomResponse, type RoomInfo } from '@joystyk/rooms/protocol';
2
+ import type { SocketFactory, TokenStore } from '../remote.js';
3
+ /** The room server's WebSocket URL for a page on `origin`: same host, `/ws` (Traefik routes it). */
4
+ export declare const roomsSocketUrl: (origin: string) => string;
5
+ /** A `SocketFactory` over the browser's `WebSocket`. */
6
+ export declare const browserSocket: (url: string) => SocketFactory;
7
+ /**
8
+ * Seat tokens in local storage, one key per room code, so every tab of this browser presents
9
+ * the same token and the newest tab takes the seat (room-server.md section 5.5). Storage that
10
+ * throws (blocked site data) falls back to memory for this page.
11
+ */
12
+ export declare const webStorageTokens: (storage?: Storage | null) => TokenStore;
13
+ /** 32 URL-safe characters from the browser's cryptographic randomness. */
14
+ export declare const randomActionId: () => string;
15
+ /** Why a room request failed, worded for players. */
16
+ export type RoomRequestError = {
17
+ readonly status: number;
18
+ readonly message: string;
19
+ };
20
+ /** `POST /api/rooms`: creates a room and returns its code with the creator's seat token. */
21
+ export declare const createRoom: (origin: string, request: CreateRoomRequest, fetcher?: typeof fetch) => Promise<{
22
+ readonly ok: true;
23
+ readonly value: CreateRoomResponse;
24
+ } | {
25
+ readonly ok: false;
26
+ readonly error: RoomRequestError;
27
+ }>;
28
+ /** `GET /api/rooms/:code`: the room's game build, phase and open seats, or why not. */
29
+ export declare const fetchRoomInfo: (origin: string, code: string, fetcher?: typeof fetch) => Promise<{
30
+ readonly ok: true;
31
+ readonly value: RoomInfo;
32
+ } | {
33
+ readonly ok: false;
34
+ readonly error: RoomRequestError;
35
+ }>;
@@ -0,0 +1,140 @@
1
+ import { CreateRoomResponseSchema, RoomInfoSchema } from "@joystyk/rooms/protocol";
2
+ //#region src/browser/rooms.ts
3
+ /** The room server's WebSocket URL for a page on `origin`: same host, `/ws` (Traefik routes it). */
4
+ var roomsSocketUrl = (origin) => {
5
+ const url = new URL("/ws", origin);
6
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
7
+ return url.href;
8
+ };
9
+ /** A `SocketFactory` over the browser's `WebSocket`. */
10
+ var browserSocket = (url) => (events) => {
11
+ const ws = new WebSocket(url);
12
+ let closed = false;
13
+ const finish = (code) => {
14
+ if (closed) return;
15
+ closed = true;
16
+ events.close(code);
17
+ };
18
+ ws.addEventListener("open", () => events.open());
19
+ ws.addEventListener("message", (event) => {
20
+ if (typeof event.data === "string") events.message(event.data);
21
+ });
22
+ ws.addEventListener("close", (event) => finish(event.code));
23
+ ws.addEventListener("error", () => finish(1006));
24
+ return {
25
+ send: (text) => {
26
+ if (ws.readyState === WebSocket.OPEN) ws.send(text);
27
+ },
28
+ close: (code, reason) => {
29
+ closed = true;
30
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) ws.close(code ?? 1e3, reason);
31
+ }
32
+ };
33
+ };
34
+ /**
35
+ * Seat tokens in local storage, one key per room code, so every tab of this browser presents
36
+ * the same token and the newest tab takes the seat (room-server.md section 5.5). Storage that
37
+ * throws (blocked site data) falls back to memory for this page.
38
+ */
39
+ var webStorageTokens = (storage = globalThis.localStorage ?? null) => {
40
+ const memory = /* @__PURE__ */ new Map();
41
+ const key = (room) => `joystyk:room-token:${room}`;
42
+ return {
43
+ get: (room) => {
44
+ try {
45
+ return storage?.getItem(key(room)) ?? memory.get(room) ?? null;
46
+ } catch {
47
+ return memory.get(room) ?? null;
48
+ }
49
+ },
50
+ set: (room, token) => {
51
+ memory.set(room, token);
52
+ try {
53
+ storage?.setItem(key(room), token);
54
+ } catch {}
55
+ },
56
+ delete: (room) => {
57
+ memory.delete(room);
58
+ try {
59
+ storage?.removeItem(key(room));
60
+ } catch {}
61
+ }
62
+ };
63
+ };
64
+ /** 32 URL-safe characters from the browser's cryptographic randomness. */
65
+ var randomActionId = () => crypto.randomUUID().replaceAll("-", "");
66
+ var ROOM_ERRORS = {
67
+ 400: "The room could not be set up with those settings.",
68
+ 403: "This page may not open rooms.",
69
+ 404: "This game can't be played online here.",
70
+ 413: "The room could not be set up with those settings.",
71
+ 429: "Too many rooms were opened from here. Try again later.",
72
+ 503: "The room server is busy or restarting. Try again in a moment."
73
+ };
74
+ var roomError = (status) => ({
75
+ status,
76
+ message: ROOM_ERRORS[status] ?? "The room server couldn't be reached. Check your connection, then try again."
77
+ });
78
+ /** `POST /api/rooms`: creates a room and returns its code with the creator's seat token. */
79
+ var createRoom = async (origin, request, fetcher = fetch) => {
80
+ let response;
81
+ try {
82
+ response = await fetcher(new URL("/api/rooms", origin), {
83
+ method: "POST",
84
+ headers: { "content-type": "application/json" },
85
+ body: JSON.stringify(request)
86
+ });
87
+ } catch {
88
+ return {
89
+ ok: false,
90
+ error: roomError(0)
91
+ };
92
+ }
93
+ if (response.status !== 201) return {
94
+ ok: false,
95
+ error: roomError(response.status)
96
+ };
97
+ const parsed = CreateRoomResponseSchema.safeParse(await response.json().catch(() => null));
98
+ return parsed.success ? {
99
+ ok: true,
100
+ value: parsed.data
101
+ } : {
102
+ ok: false,
103
+ error: roomError(0)
104
+ };
105
+ };
106
+ /** `GET /api/rooms/:code`: the room's game build, phase and open seats, or why not. */
107
+ var fetchRoomInfo = async (origin, code, fetcher = fetch) => {
108
+ let response;
109
+ try {
110
+ response = await fetcher(new URL(`/api/rooms/${encodeURIComponent(code)}`, origin));
111
+ } catch {
112
+ return {
113
+ ok: false,
114
+ error: roomError(0)
115
+ };
116
+ }
117
+ if (response.status === 404) return {
118
+ ok: false,
119
+ error: {
120
+ status: 404,
121
+ message: "No room has that code. Check it, or ask for a new link."
122
+ }
123
+ };
124
+ if (!response.ok) return {
125
+ ok: false,
126
+ error: roomError(response.status)
127
+ };
128
+ const parsed = RoomInfoSchema.safeParse(await response.json().catch(() => null));
129
+ return parsed.success ? {
130
+ ok: true,
131
+ value: parsed.data
132
+ } : {
133
+ ok: false,
134
+ error: roomError(0)
135
+ };
136
+ };
137
+ //#endregion
138
+ export { browserSocket, createRoom, fetchRoomInfo, randomActionId, roomsSocketUrl, webStorageTokens };
139
+
140
+ //# sourceMappingURL=rooms.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rooms.js","names":[],"sources":["../../src/browser/rooms.ts"],"sourcesContent":["// @joystyk/session/browser/rooms: the remote session client's ports for server rooms, with\n// browser APIs: a WebSocket, seat tokens in local storage, action IDs from the CSPRNG, and the\n// room server's HTTP API. A separate entry, so pages that never open a room never load the\n// protocol's schemas; the launcher imports it from the stage, on the first Start.\nimport {\n CreateRoomResponseSchema,\n RoomInfoSchema,\n type CreateRoomRequest,\n type CreateRoomResponse,\n type RoomInfo,\n} from '@joystyk/rooms/protocol';\nimport type { RemoteSocket, SocketEvents, SocketFactory, TokenStore } from '../remote.ts';\n\n/** The room server's WebSocket URL for a page on `origin`: same host, `/ws` (Traefik routes it). */\nexport const roomsSocketUrl = (origin: string): string => {\n const url = new URL('/ws', origin);\n url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';\n return url.href;\n};\n\n/** A `SocketFactory` over the browser's `WebSocket`. */\nexport const browserSocket =\n (url: string): SocketFactory =>\n (events: SocketEvents): RemoteSocket => {\n const ws = new WebSocket(url);\n let closed = false;\n const finish = (code: number): void => {\n if (closed) return;\n closed = true;\n events.close(code);\n };\n ws.addEventListener('open', () => events.open());\n ws.addEventListener('message', (event: MessageEvent<unknown>) => {\n if (typeof event.data === 'string') events.message(event.data);\n });\n ws.addEventListener('close', (event) => finish(event.code));\n ws.addEventListener('error', () => finish(1006));\n return {\n send: (text) => {\n if (ws.readyState === WebSocket.OPEN) ws.send(text);\n },\n close: (code, reason) => {\n closed = true;\n if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) ws.close(code ?? 1000, reason);\n },\n };\n };\n\n/**\n * Seat tokens in local storage, one key per room code, so every tab of this browser presents\n * the same token and the newest tab takes the seat (room-server.md section 5.5). Storage that\n * throws (blocked site data) falls back to memory for this page.\n */\nexport const webStorageTokens = (storage: Storage | null = globalThis.localStorage ?? null): TokenStore => {\n const memory = new Map<string, string>();\n const key = (room: string): string => `joystyk:room-token:${room}`;\n return {\n get: (room) => {\n try {\n return storage?.getItem(key(room)) ?? memory.get(room) ?? null;\n } catch {\n return memory.get(room) ?? null;\n }\n },\n set: (room, token) => {\n memory.set(room, token);\n try {\n storage?.setItem(key(room), token);\n } catch {\n // Kept in memory for this page.\n }\n },\n delete: (room) => {\n memory.delete(room);\n try {\n storage?.removeItem(key(room));\n } catch {\n // Nothing stored.\n }\n },\n };\n};\n\n/** 32 URL-safe characters from the browser's cryptographic randomness. */\nexport const randomActionId = (): string => crypto.randomUUID().replaceAll('-', '');\n\n/** Why a room request failed, worded for players. */\nexport type RoomRequestError = { readonly status: number; readonly message: string };\n\nconst ROOM_ERRORS: Readonly<Record<number, string>> = {\n 400: 'The room could not be set up with those settings.',\n 403: 'This page may not open rooms.',\n 404: \"This game can't be played online here.\",\n 413: 'The room could not be set up with those settings.',\n 429: 'Too many rooms were opened from here. Try again later.',\n 503: 'The room server is busy or restarting. Try again in a moment.',\n};\n\nconst roomError = (status: number): RoomRequestError => ({\n status,\n message: ROOM_ERRORS[status] ?? \"The room server couldn't be reached. Check your connection, then try again.\",\n});\n\n/** `POST /api/rooms`: creates a room and returns its code with the creator's seat token. */\nexport const createRoom = async (\n origin: string,\n request: CreateRoomRequest,\n fetcher: typeof fetch = fetch,\n): Promise<\n { readonly ok: true; readonly value: CreateRoomResponse } | { readonly ok: false; readonly error: RoomRequestError }\n> => {\n let response: Response;\n try {\n response = await fetcher(new URL('/api/rooms', origin), {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(request),\n });\n } catch {\n return { ok: false, error: roomError(0) };\n }\n if (response.status !== 201) return { ok: false, error: roomError(response.status) };\n const parsed = CreateRoomResponseSchema.safeParse(await response.json().catch(() => null));\n return parsed.success ? { ok: true, value: parsed.data as CreateRoomResponse } : { ok: false, error: roomError(0) };\n};\n\n/** `GET /api/rooms/:code`: the room's game build, phase and open seats, or why not. */\nexport const fetchRoomInfo = async (\n origin: string,\n code: string,\n fetcher: typeof fetch = fetch,\n): Promise<\n { readonly ok: true; readonly value: RoomInfo } | { readonly ok: false; readonly error: RoomRequestError }\n> => {\n let response: Response;\n try {\n response = await fetcher(new URL(`/api/rooms/${encodeURIComponent(code)}`, origin));\n } catch {\n return { ok: false, error: roomError(0) };\n }\n if (response.status === 404)\n return { ok: false, error: { status: 404, message: 'No room has that code. Check it, or ask for a new link.' } };\n if (!response.ok) return { ok: false, error: roomError(response.status) };\n const parsed = RoomInfoSchema.safeParse(await response.json().catch(() => null));\n return parsed.success ? { ok: true, value: parsed.data as RoomInfo } : { ok: false, error: roomError(0) };\n};\n"],"mappings":"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"}
@@ -0,0 +1,66 @@
1
+ import { type FixedStepSimulation, type GameError, type GameSession, type Json, type Migration, type Result, type RngState, type SaveEnvelope, type SeatId, type SeatInfo, type SimulationReplay, type SimulationSnapshot } from '@joystyk/contracts';
2
+ import { type FrameScheduler, type Reporter, type RngFactory, type SaveStore, type Visibility } from './ports.js';
3
+ import { type RunnerManifest } from './saves.js';
4
+ /** What the game's UI dispatches to a fixed-step runner. */
5
+ export type FixedStepInput<Input> = {
6
+ readonly kind: 'input';
7
+ readonly input: Input;
8
+ } | {
9
+ readonly kind: 'pause';
10
+ } | {
11
+ readonly kind: 'resume';
12
+ };
13
+ /** The contract's `SimulationSnapshot` plus the tick events since the last snapshot. */
14
+ export type FixedStepSnapshot<View, Event> = SimulationSnapshot<View> & {
15
+ /** One-off events from the ticks since the previous snapshot, for sound and effects. */
16
+ readonly events: readonly Event[];
17
+ /** Active play time: ticks × step length, so a pause stops it. */
18
+ readonly elapsedMs: number;
19
+ };
20
+ export interface FixedStepRunner<Input, View, Event> extends GameSession<FixedStepSnapshot<View, Event>, FixedStepInput<Input>> {
21
+ /**
22
+ * How far real time has run into the next tick, from 0 up to 1. A renderer may interpolate
23
+ * between the last two states with it; it never changes the simulation.
24
+ */
25
+ alpha(): number;
26
+ /** A checkpoint of the current tick. */
27
+ save(): SaveEnvelope;
28
+ /** The replay log so far, or null after restoring a checkpoint that had none. */
29
+ replay(): SimulationReplay | null;
30
+ }
31
+ export type FixedStepPorts = {
32
+ readonly manifest: RunnerManifest;
33
+ /** Restores the random state. Default: the kit's `restoreRng`. */
34
+ readonly restoreRng?: RngFactory;
35
+ readonly frames: FrameScheduler;
36
+ readonly visibility?: Visibility;
37
+ /** Checkpoints go here on the simulation's request, pause, hide, outcome and dispose. */
38
+ readonly storage?: SaveStore;
39
+ /** The viewer. Default: seat 0. */
40
+ readonly seat?: SeatId | null;
41
+ /**
42
+ * The most ticks one frame may run. After a long stall the rest of the backlog is dropped
43
+ * instead of freezing the page. Default: a tenth of a second's worth (6 at 60 Hz).
44
+ */
45
+ readonly maxStepsPerFrame?: number;
46
+ /** Start paused, e.g. behind a "Press to start" screen. Default false. */
47
+ readonly startPaused?: boolean;
48
+ readonly freeze?: boolean;
49
+ readonly report?: Reporter;
50
+ };
51
+ export type FixedStepOptions = FixedStepPorts & {
52
+ readonly seats: readonly SeatInfo[];
53
+ /** Decoded with the simulation's config schema. */
54
+ readonly config: unknown;
55
+ readonly seed: RngState;
56
+ };
57
+ export type FixedStepRestoreOptions = FixedStepPorts & {
58
+ readonly migrations: readonly Migration[];
59
+ };
60
+ /** Starts a new real-time game. Fails when the config is invalid or `init` refuses it. */
61
+ export declare const createFixedStepRunner: <State extends Json, Input extends Json, View, Event extends Json, Config extends Json>(simulation: FixedStepSimulation<State, Input, View, Event, Config>, options: FixedStepOptions) => Result<FixedStepRunner<Input, View, Event>, GameError>;
62
+ /**
63
+ * Resumes a checkpoint. When the checkpoint carries a replay that still reproduces it, the
64
+ * replay log continues; otherwise the game resumes without one.
65
+ */
66
+ export declare const restoreFixedStepRunner: <State extends Json, Input extends Json, View, Event extends Json, Config extends Json>(simulation: FixedStepSimulation<State, Input, View, Event, Config>, save: unknown, options: FixedStepRestoreOptions) => Result<FixedStepRunner<Input, View, Event>, GameError>;