@arcadible/sdk 1.5.0 → 1.5.1

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
@@ -68,13 +68,11 @@ Off Arcadible, such as on your dev server, the package still works, with the sam
68
68
 
69
69
  ```js
70
70
  arcadible.on("pause", async () => {
71
- loop.stop();
72
- audio.mute();
71
+ // … stop your game loop and timers, and silence your audio …
73
72
  await arcadible.saves.write("progress", JSON.stringify(progress));
74
73
  });
75
74
  arcadible.on("resume", () => {
76
- loop.start();
77
- audio.unmute();
75
+ // … start your game loop and timers again, and your audio …
78
76
  });
79
77
  ```
80
78
 
package/dist/client.js CHANGED
@@ -33,28 +33,36 @@ export function createClient(options = {}) {
33
33
  requestTimeout: options.requestTimeout,
34
34
  standalone: () => serve({ ...STANDALONE, backend: backend() }),
35
35
  });
36
- const { saves, opened } = createSaves(bus, {
36
+ // each module's game side, made on the bus as the client starts, before
37
+ // anything arrives.
38
+ const saves = createSaves(bus, {
37
39
  timeout: options.requestTimeout ?? REQUEST_TIMEOUT,
38
40
  });
39
- let settled = null;
40
41
  const system = createSystemClient(bus);
41
42
  const context = createContextClient(bus);
42
- // ready once welcomed, and each module's first messages have arrived.
43
- const ready = Promise.all([bus.welcomed, opened]).then(([welcome]) => {
44
- system.settle();
45
- context.settle();
43
+ const lifecycle = createLifecycle(bus, win);
44
+ const pause = createPause(bus);
45
+ // the ones `ready` waits on, or tells: lifecycle and pause do neither.
46
+ const parts = [saves, system, context];
47
+ let settled = null;
48
+ // ready once welcomed, and each module's first messages have arrived;
49
+ // then each is told, so its facts are known, or null.
50
+ const ready = Promise.all([
51
+ bus.welcomed,
52
+ ...parts.map((part) => part.opened),
53
+ ]).then(([welcome]) => {
54
+ for (const part of parts)
55
+ part.settle?.();
46
56
  settled = { build: buildOf(welcome.build) };
47
57
  return settled;
48
58
  });
49
- const lifecycle = createLifecycle(bus, win);
50
- const pause = createPause(bus);
51
59
  return {
52
60
  version: VERSION,
53
61
  ready,
54
62
  get build() {
55
63
  return settled ? settled.build : undefined;
56
64
  },
57
- saves,
65
+ saves: saves.saves,
58
66
  get system() {
59
67
  return system.value;
60
68
  },
@@ -1,4 +1,5 @@
1
1
  import type { Bus } from "../bus/client.js";
2
+ import { type Fact } from "../facts.js";
2
3
  /** How this run started, fixed for the run. */
3
4
  export interface Context {
4
5
  readonly launch: {
@@ -6,7 +7,4 @@ export interface Context {
6
7
  readonly at: string;
7
8
  };
8
9
  }
9
- export declare function createContextClient(bus: Bus): {
10
- settle(): void;
11
- readonly value: Context | null | undefined;
12
- };
10
+ export declare function createContextClient(bus: Bus): Fact<Context>;
@@ -1,31 +1,17 @@
1
1
  // The game's side of `context`: `arcadible.context`, how this run started,
2
2
  // fixed for the run. `undefined` before `ready`, and `null` where no page
3
3
  // says (standing alone, or an older page).
4
- import { isObject, log } from "../bus/protocol.js";
4
+ import { fact } from "../facts.js";
5
5
  import { contextOf, Names } from "./rules.js";
6
6
  // [function]
7
7
  export function createContextClient(bus) {
8
- let context = null;
9
- let settled = false;
10
- bus.on(Names.State, isObject, (data) => {
11
- // fixed for the run: the first one stands, and none comes after `ready`.
12
- if (context || settled)
13
- return;
14
- const next = contextOf(data);
15
- if (!next) {
16
- log("warn", "ignored a malformed context from the play page.");
17
- return;
18
- }
19
- context = Object.freeze({ launch: Object.freeze({ ...next.launch }) });
20
- });
21
- return {
22
- settle() {
23
- settled = true;
24
- },
25
- get value() {
26
- if (!settled)
27
- return undefined;
28
- return context;
8
+ return fact(bus, Names.State, {
9
+ fixed: true,
10
+ parse(data) {
11
+ const context = contextOf(data);
12
+ return context
13
+ ? Object.freeze({ launch: Object.freeze({ ...context.launch }) })
14
+ : null;
29
15
  },
30
- };
16
+ });
31
17
  }
@@ -0,0 +1,11 @@
1
+ import type { Bus } from "./bus/client.js";
2
+ export type Fact<T> = {
3
+ settle(): void;
4
+ readonly value: T | null | undefined;
5
+ };
6
+ export type FactOptions<T> = {
7
+ parse(value: unknown): T | null;
8
+ fixed?: boolean;
9
+ changed?(before: T, next: T): void;
10
+ };
11
+ export declare function fact<T>(bus: Bus, name: string, options: FactOptions<T>): Fact<T>;
package/dist/facts.js ADDED
@@ -0,0 +1,33 @@
1
+ // A fact the page sends as its module opens, such as `system`'s state or
2
+ // `context`: `undefined` before `ready`, the fact from then on, or `null`
3
+ // where no page sent one (standing alone, or an older page). Only what
4
+ // `parse` keeps is kept, so the host is trusted with nothing; a fixed fact
5
+ // takes its first and nothing after `ready`, and a changing one tells
6
+ // `changed` what came after it.
7
+ import { isObject, log } from "./bus/protocol.js";
8
+ // [function]
9
+ export function fact(bus, name, options) {
10
+ let value = null;
11
+ let settled = false;
12
+ bus.on(name, isObject, (data) => {
13
+ if (options.fixed && (value !== null || settled))
14
+ return;
15
+ const next = options.parse(data);
16
+ if (next === null) {
17
+ log("warn", `ignored a malformed ${name} from the play page.`);
18
+ return;
19
+ }
20
+ const before = value;
21
+ value = next;
22
+ if (before !== null && settled)
23
+ options.changed?.(before, next);
24
+ });
25
+ return {
26
+ settle() {
27
+ settled = true;
28
+ },
29
+ get value() {
30
+ return settled ? value : undefined;
31
+ },
32
+ };
33
+ }
@@ -1,7 +1,5 @@
1
1
  import type { Bus } from "../bus/client.js";
2
- export type PauseListener = () => void | Promise<void>;
3
- export type ResumeListener = () => void;
4
2
  export type Pause = {
5
- on(name: string, listener: unknown): () => void;
3
+ on(name: unknown, listener: unknown): () => void;
6
4
  };
7
5
  export declare function createPause(bus: Bus): Pause;
@@ -3,11 +3,9 @@
3
3
  // why; this side keeps that from the moment it connects, so a game that
4
4
  // subscribes while held still gets its pause, and tells the page once its
5
5
  // pause listeners have settled, which the page waits on only as it closes.
6
- import { ArcadibleError, Codes, isObject, log } from "../bus/protocol.js";
6
+ import { isObject, log } from "../bus/protocol.js";
7
+ import { listeners } from "../listeners.js";
7
8
  import { Names } from "./rules.js";
8
- // the events a game can listen for on `arcadible`: a closed list, so a typo
9
- // throws rather than never firing.
10
- const EVENTS = ["pause", "resume"];
11
9
  // [function]
12
10
  function isHeld(value) {
13
11
  return (isObject(value) &&
@@ -22,21 +20,14 @@ export function createPause(bus) {
22
20
  // whether the game heard this hold's pause: a resume goes only to a game
23
21
  // that did, so it never resumes from a pause it wasn't told of.
24
22
  let heard = false;
25
- const pauses = new Set();
26
- const resumes = new Set();
27
- // runs pause listeners, each on its own, and tells the page once they've
28
- // all settled, for pause `count`.
29
- const pause = function (listeners, count) {
30
- if (listeners.length > 0)
23
+ // the events a game can listen for on `arcadible`.
24
+ const on = listeners("arcadible", ["pause", "resume"]);
25
+ // runs pause listeners, and tells the page once they've all settled, for
26
+ // pause `count`.
27
+ const pause = function (run, count) {
28
+ const runs = run().map((result) => Promise.resolve(result));
29
+ if (runs.length > 0)
31
30
  heard = true;
32
- const runs = listeners.map((listener) => {
33
- try {
34
- return Promise.resolve(listener());
35
- }
36
- catch (err) {
37
- return Promise.reject(err);
38
- }
39
- });
40
31
  void Promise.allSettled(runs).then((results) => {
41
32
  for (const result of results) {
42
33
  if (result.status === "rejected") {
@@ -46,45 +37,23 @@ export function createPause(bus) {
46
37
  bus.emit(Names.Paused, { n: count });
47
38
  });
48
39
  };
49
- const resume = function () {
50
- for (const listener of [...resumes]) {
51
- try {
52
- listener();
53
- }
54
- catch (err) {
55
- log("error", "a resume listener failed.", err);
56
- }
57
- }
58
- };
59
40
  bus.on(Names.Held, isHeld, (data) => {
60
41
  const was = held;
61
42
  held = data.held;
62
43
  n = data.n;
63
44
  if (held && !was)
64
- pause([...pauses], n);
45
+ pause(() => on.call("pause"), n);
65
46
  else if (!held && was) {
66
47
  if (heard)
67
- resume();
48
+ on.call("resume");
68
49
  heard = false;
69
50
  }
70
51
  });
71
52
  return {
72
53
  on(name, listener) {
73
- if (!EVENTS.includes(name)) {
74
- throw new ArcadibleError(Codes.Invalid, `arcadible.on takes "pause" or "resume", not ${JSON.stringify(name)}.`);
75
- }
76
- if (typeof listener !== "function") {
77
- throw new ArcadibleError(Codes.Invalid, `arcadible.on("${name}") takes a function.`);
78
- }
79
- if (name === "resume") {
80
- const fn = listener;
81
- resumes.add(fn);
82
- return () => {
83
- resumes.delete(fn);
84
- };
85
- }
86
- const fn = listener;
87
- pauses.add(fn);
54
+ const off = on.add(name, listener);
55
+ if (name !== "pause")
56
+ return off;
88
57
  if (!told) {
89
58
  told = true;
90
59
  bus.emit(Names.Listening);
@@ -94,13 +63,20 @@ export function createPause(bus) {
94
63
  if (held) {
95
64
  const count = n;
96
65
  queueMicrotask(() => {
97
- if (held && n === count && pauses.has(fn))
98
- pause([fn], count);
66
+ if (held && n === count && on.has("pause", listener)) {
67
+ pause(() => {
68
+ try {
69
+ return [listener()];
70
+ }
71
+ catch (err) {
72
+ log("error", "a pause listener failed.", err);
73
+ return [undefined];
74
+ }
75
+ }, count);
76
+ }
99
77
  });
100
78
  }
101
- return () => {
102
- pauses.delete(fn);
103
- };
79
+ return off;
104
80
  },
105
81
  };
106
82
  }
@@ -0,0 +1,13 @@
1
+ type Listener = (...args: never[]) => unknown;
2
+ export type Listeners<N extends string> = {
3
+ add(name: unknown, listener: unknown): () => void;
4
+ of(name: N): Listener[];
5
+ has(name: N, listener: unknown): boolean;
6
+ call(name: N, ...args: unknown[]): unknown[];
7
+ };
8
+ /**
9
+ * The listeners for `names` on the object a game calls as `where`
10
+ * (`"arcadible"`, `"arcadible.system"`).
11
+ */
12
+ export declare function listeners<N extends string>(where: string, names: readonly N[]): Listeners<N>;
13
+ export {};
@@ -0,0 +1,48 @@
1
+ // The listeners behind every `on` a game calls (`arcadible.on`,
2
+ // `arcadible.system.on`): a closed list of names, so a typo throws rather
3
+ // than never firing; a listener that must be a function; the function that
4
+ // unsubscribes; and each listener run on its own, so one that throws stops
5
+ // none of the others. One shape for each, with the same messages.
6
+ // [import]
7
+ import { ArcadibleError, Codes, log } from "./bus/protocol.js";
8
+ // [function]
9
+ /**
10
+ * The listeners for `names` on the object a game calls as `where`
11
+ * (`"arcadible"`, `"arcadible.system"`).
12
+ */
13
+ export function listeners(where, names) {
14
+ const sets = new Map(names.map((name) => [name, new Set()]));
15
+ const list = names.map((name) => JSON.stringify(name)).join(" or ");
16
+ const of = (name) => [...(sets.get(name) ?? [])];
17
+ return {
18
+ add(name, listener) {
19
+ const set = sets.get(name);
20
+ if (!set) {
21
+ throw new ArcadibleError(Codes.Invalid, `${where}.on takes ${list}, not ${JSON.stringify(name)}.`);
22
+ }
23
+ if (typeof listener !== "function") {
24
+ throw new ArcadibleError(Codes.Invalid, `${where}.on(${JSON.stringify(name)}) takes a function.`);
25
+ }
26
+ const fn = listener;
27
+ set.add(fn);
28
+ return () => {
29
+ set.delete(fn);
30
+ };
31
+ },
32
+ of,
33
+ has(name, listener) {
34
+ return sets.get(name)?.has(listener) ?? false;
35
+ },
36
+ call(name, ...args) {
37
+ return of(name).map((fn) => {
38
+ try {
39
+ return fn(...args);
40
+ }
41
+ catch (err) {
42
+ log("error", `a ${name} listener failed.`, err);
43
+ return undefined;
44
+ }
45
+ });
46
+ },
47
+ };
48
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Bus } from "../bus/client.js";
2
+ import { type Fact } from "../facts.js";
2
3
  /** Arcadible's state for the player, which changes while the game runs. */
3
4
  export interface System {
4
5
  /**
@@ -14,7 +15,4 @@ export interface System {
14
15
  */
15
16
  on(name: "change", listener: (system: System, changed: readonly string[]) => void): () => void;
16
17
  }
17
- export declare function createSystemClient(bus: Bus): {
18
- settle(): void;
19
- readonly value: System | null | undefined;
20
- };
18
+ export declare function createSystemClient(bus: Bus): Fact<System>;
@@ -2,61 +2,36 @@
2
2
  // player, read at any time, with one `change` event naming what changed.
3
3
  // `undefined` before `ready`, and `null` where no page says (standing
4
4
  // alone, or an older page).
5
- import { ArcadibleError, Codes, isObject, log } from "../bus/protocol.js";
5
+ import { fact } from "../facts.js";
6
+ import { listeners } from "../listeners.js";
6
7
  import { Names, stateOf } from "./rules.js";
7
8
  // [function]
8
9
  export function createSystemClient(bus) {
9
- let state = null;
10
- let settled = false;
11
- const listeners = new Set();
10
+ // the events a game can listen for on `arcadible.system`.
11
+ const on = listeners("arcadible.system", ["change"]);
12
+ const kept = fact(bus, Names.State, {
13
+ parse: stateOf,
14
+ changed(before, next) {
15
+ const changed = Object.keys(next).filter((key) => next[key] !== before[key]);
16
+ if (changed.length > 0)
17
+ on.call("change", system, changed);
18
+ },
19
+ });
20
+ // one object for the run: what a game holds, and what `change` passes,
21
+ // reads the state as it is now.
12
22
  const system = {
13
23
  get language() {
14
- return state.language;
15
- },
16
- on(name, listener) {
17
- if (name !== "change") {
18
- throw new ArcadibleError(Codes.Invalid, `arcadible.system.on takes "change", not ${JSON.stringify(name)}.`);
19
- }
20
- if (typeof listener !== "function") {
21
- throw new ArcadibleError(Codes.Invalid, 'arcadible.system.on("change") takes a function.');
22
- }
23
- const fn = listener;
24
- listeners.add(fn);
25
- return () => {
26
- listeners.delete(fn);
27
- };
24
+ return kept.value.language;
28
25
  },
26
+ on: (name, listener) => on.add(name, listener),
29
27
  };
30
- bus.on(Names.State, isObject, (data) => {
31
- const next = stateOf(data);
32
- if (!next) {
33
- log("warn", "ignored a malformed system state from the play page.");
34
- return;
35
- }
36
- const before = state;
37
- state = next;
38
- if (!before || !settled)
39
- return;
40
- const changed = Object.keys(next).filter((key) => next[key] !== before[key]);
41
- if (changed.length === 0)
42
- return;
43
- for (const listener of [...listeners]) {
44
- try {
45
- listener(system, changed);
46
- }
47
- catch (err) {
48
- log("error", "a system change listener failed.", err);
49
- }
50
- }
51
- });
52
28
  return {
53
- settle() {
54
- settled = true;
55
- },
29
+ settle: () => kept.settle(),
56
30
  get value() {
57
- if (!settled)
31
+ const value = kept.value;
32
+ if (value === undefined)
58
33
  return undefined;
59
- return state ? system : null;
34
+ return value === null ? null : system;
60
35
  },
61
36
  };
62
37
  }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "1.5.0";
1
+ export declare const VERSION = "1.5.1";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // The client's version, sent in its hello and shown as `arcadible.version`,
2
2
  // for bug reports. It matches package.json (test/unit/protocol.test.ts).
3
- export const VERSION = "1.5.0";
3
+ export const VERSION = "1.5.1";
package/package.json CHANGED
@@ -43,5 +43,5 @@
43
43
  ],
44
44
  "type": "module",
45
45
  "types": "./dist/index.d.ts",
46
- "version": "1.5.0"
46
+ "version": "1.5.1"
47
47
  }