@arcadible/sdk 1.5.0-next.4 → 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
@@ -7,20 +7,26 @@
7
7
  <h1 align="center">Arcadible SDK</h1>
8
8
 
9
9
  <p align="center">
10
- Arcadible's loading screen for your game, and saves that carry over from one version to the next.
10
+ How your game works with Arcadible.
11
11
  </p>
12
12
 
13
13
  <p align="center">
14
14
  <a href="https://developer.arcadible.com/references/arcadible-sdk">Docs</a> ·
15
15
  <a href="https://developer.arcadible.com/build/initialization">Initialization</a> ·
16
16
  <a href="https://developer.arcadible.com/build/saves">Saves</a> ·
17
+ <a href="https://developer.arcadible.com/build/pausing">Pausing</a> ·
17
18
  <a href="https://arcadible.com">Arcadible</a> ·
18
19
  <a href="https://studio.arcadible.com">Studio</a>
19
20
  </p>
20
21
 
21
22
  <br>
22
23
 
23
- The Arcadible SDK is optional: a game runs on [Arcadible](https://arcadible.com) without it. With it, a game gets two things from the play page around it: its [initialization](https://developer.arcadible.com/build/initialization), bracketed, so Arcadible's loading screen shows the game's splash and progress until its first frame is drawn, and [saves](https://developer.arcadible.com/build/saves), kept by the play page, so the player's progress carries over from one version to the next.
24
+ The Arcadible SDK is optional: a game runs on [Arcadible](https://arcadible.com) without it. With it, a game gets these from the play page around it:
25
+
26
+ - **[Initialization](https://developer.arcadible.com/build/initialization),** bracketed, so Arcadible's loading screen shows the game's splash and progress until its first frame is drawn.
27
+ - **[Saves](https://developer.arcadible.com/build/saves),** kept by the play page, so the player's progress carries over from one version to the next.
28
+ - **[Pausing](https://developer.arcadible.com/build/pausing):** Arcadible tells the game when to stop, go quiet, and save, and when to carry on.
29
+ - **[`system` and `context`](https://developer.arcadible.com/references/arcadible-sdk#system):** the store's language, and when this run launched.
24
30
 
25
31
  ## Install
26
32
 
@@ -58,11 +64,26 @@ Saves are for what your game remembers between sessions, such as progress, setti
58
64
 
59
65
  Off Arcadible, such as on your dev server, the package still works, with the same rules and limits: there's no loading screen, so `initComplete()` resolves at once, saves are kept in the game's own browser storage, `arcadible.saves.where` is `"build"`, and `arcadible.build` is `null`.
60
66
 
67
+ ## Pause and resume
68
+
69
+ ```js
70
+ arcadible.on("pause", async () => {
71
+ // … stop your game loop and timers, and silence your audio …
72
+ await arcadible.saves.write("progress", JSON.stringify(progress));
73
+ });
74
+ arcadible.on("resume", () => {
75
+ // … start your game loop and timers again, and your audio …
76
+ });
77
+ ```
78
+
79
+ Arcadible pauses your game when the page is hidden, when it covers your game, and before it closes or reloads it, when it waits a moment for your save. Stop, go quiet, and save, drawing nothing over your game; on `resume`, carry on, or show a "Ready?" first in an action game.
80
+
61
81
  ## Learn more
62
82
 
63
83
  - [Initialization](https://developer.arcadible.com/build/initialization): where each call goes, progress, and what comes after.
64
84
  - [Saves](https://developer.arcadible.com/build/saves): using saves well, across versions, channels, and tabs, and where they're kept.
65
- - [Arcadible SDK](https://developer.arcadible.com/references/arcadible-sdk): including it, every call, the saves' included, and every code.
85
+ - [Pausing](https://developer.arcadible.com/build/pausing): what a pause asks of your game, and resuming.
86
+ - [Arcadible SDK](https://developer.arcadible.com/references/arcadible-sdk): including it, every call, the saves' included, `system` and `context`, and every code.
66
87
  - [Limits](https://developer.arcadible.com/references/limits#saves): how many saves, and how large.
67
88
 
68
89
  ## License
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-next.4";
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-next.4";
3
+ export const VERSION = "1.5.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "author": "Offst, Inc.",
3
- "description": "The Arcadible SDK: Arcadible's loading screen for a game's initialization, and saves that carry over between its versions.",
3
+ "description": "The Arcadible SDK: how your game works with Arcadible.",
4
4
  "devDependencies": {
5
5
  "@playwright/test": "1.63.0",
6
6
  "@types/node": "^25.0.3",
@@ -43,5 +43,5 @@
43
43
  ],
44
44
  "type": "module",
45
45
  "types": "./dist/index.d.ts",
46
- "version": "1.5.0-next.4"
46
+ "version": "1.5.1"
47
47
  }