@arcadible/sdk 0.0.0-stage → 0.3.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.
@@ -0,0 +1,175 @@
1
+ // The page's end of the bus: it answers a game's requests with its modules'
2
+ // handlers, one at a time in order, and refuses what's too large, too many,
3
+ // or unknown. `answerHellos` connects it to the play page's iframe.
4
+ // [import]
5
+ import { ArcadibleError, BUS_VERSION, Codes, isHello, isMessage, MAX_IN_FLIGHT, MAX_MESSAGE, messageSize, } from "./protocol.js";
6
+ // [function]
7
+ function errorBody(err) {
8
+ if (err instanceof ArcadibleError)
9
+ return err.toJSON();
10
+ console.error("arcadible: a request failed:", err);
11
+ return {
12
+ code: Codes.Unavailable,
13
+ message: "That's unavailable right now. Try again.",
14
+ };
15
+ }
16
+ // [function]
17
+ function paramsOf(value) {
18
+ return typeof value === "object" && value !== null ? value : {};
19
+ }
20
+ // [function]
21
+ // Answers the bus for one game, on any number of ports, one request at a
22
+ // time in order, so a read always sees the writes before it.
23
+ export function createServer(options) {
24
+ const ports = new Set();
25
+ const inFlight = new Map();
26
+ // requests waiting their turn, by name and coalescing key.
27
+ const waiting = new Map();
28
+ let queue = Promise.resolve();
29
+ const enqueue = function (job) {
30
+ const run = queue.then(job);
31
+ queue = run.catch(() => undefined);
32
+ return run;
33
+ };
34
+ const send = function (port, name, data) {
35
+ if (ports.has(port))
36
+ port.postMessage({ kind: "event", name, data });
37
+ };
38
+ const modules = options.modules.map((make) => make({
39
+ broadcast(name, data) {
40
+ for (const port of ports)
41
+ send(port, name, data);
42
+ },
43
+ }));
44
+ const handlers = new Map();
45
+ for (const module of modules) {
46
+ for (const [name, handler] of Object.entries(module.handlers)) {
47
+ handlers.set(name, handler);
48
+ }
49
+ }
50
+ const refuse = function (port, id, error) {
51
+ port.postMessage({ kind: "response", id, error });
52
+ };
53
+ const respond = function (port, id, outcome) {
54
+ inFlight.set(port, (inFlight.get(port) ?? 1) - 1);
55
+ if (ports.has(port))
56
+ port.postMessage({ kind: "response", id, ...outcome });
57
+ };
58
+ const answer = function (port, request) {
59
+ const { id, name } = request;
60
+ const handler = handlers.get(name);
61
+ if (!handler) {
62
+ refuse(port, id, {
63
+ code: Codes.Unsupported,
64
+ message: `Unknown request: ${name}`,
65
+ });
66
+ return;
67
+ }
68
+ if ((inFlight.get(port) ?? 0) >= MAX_IN_FLIGHT) {
69
+ refuse(port, id, {
70
+ code: Codes.Busy,
71
+ message: "Too many requests at once. Wait for some to finish.",
72
+ });
73
+ return;
74
+ }
75
+ inFlight.set(port, (inFlight.get(port) ?? 0) + 1);
76
+ const params = paramsOf(request.params);
77
+ const key = handler.coalesce
78
+ ? `${name}\u0000${handler.coalesce(params)}`
79
+ : null;
80
+ const pending = key === null ? undefined : waiting.get(key);
81
+ if (pending) {
82
+ pending.params = params;
83
+ pending.ids.push(id);
84
+ return;
85
+ }
86
+ const entry = { params, ids: [id] };
87
+ if (key !== null)
88
+ waiting.set(key, entry);
89
+ enqueue(() => {
90
+ if (key !== null)
91
+ waiting.delete(key);
92
+ return handler.run(entry.params);
93
+ }).then((result) => entry.ids.forEach((i) => respond(port, i, { result })), (err) => {
94
+ const error = errorBody(err);
95
+ entry.ids.forEach((i) => respond(port, i, { error }));
96
+ });
97
+ };
98
+ const receive = function (port, data) {
99
+ if (!isMessage(data) || data.kind !== "request")
100
+ return;
101
+ if (messageSize(data, MAX_MESSAGE) > MAX_MESSAGE) {
102
+ refuse(port, data.id, {
103
+ code: Codes.TooLarge,
104
+ message: "The message is too large.",
105
+ });
106
+ return;
107
+ }
108
+ answer(port, data);
109
+ };
110
+ const disconnect = function (port) {
111
+ ports.delete(port);
112
+ inFlight.delete(port);
113
+ port.onmessage = null;
114
+ port.close();
115
+ };
116
+ return {
117
+ connect(port) {
118
+ ports.add(port);
119
+ inFlight.set(port, 0);
120
+ port.onmessage = (event) => receive(port, event.data);
121
+ // first in the queue, so it's sent before any answer. A channel
122
+ // replaced before its turn (hellos in a loop) opens nothing.
123
+ enqueue(async () => {
124
+ for (const module of modules) {
125
+ if (!ports.has(port))
126
+ return;
127
+ await module.open?.((name, data) => send(port, name, data));
128
+ }
129
+ });
130
+ return {
131
+ arcadible: "welcome",
132
+ v: BUS_VERSION,
133
+ build: options.build,
134
+ };
135
+ },
136
+ disconnect,
137
+ close() {
138
+ for (const module of modules)
139
+ module.close?.();
140
+ for (const port of ports)
141
+ disconnect(port);
142
+ },
143
+ };
144
+ }
145
+ // [function]
146
+ // The play page's end of the handshake: it answers a hello from its own
147
+ // iframe, at its build's exact origin, with a welcome and a private
148
+ // channel. A new page load in the frame says hello again, and replaces the
149
+ // channel. Call it before inserting the iframe, so no hello is missed.
150
+ export function answerHellos({ frame, origin, server, window: win, }) {
151
+ let current = null;
152
+ const onMessage = function (event) {
153
+ const target = frame.contentWindow;
154
+ if (!target ||
155
+ event.source !== target ||
156
+ event.origin !== origin ||
157
+ !isHello(event.data)) {
158
+ return;
159
+ }
160
+ const channel = new MessageChannel();
161
+ const welcome = server.connect(channel.port1);
162
+ if (current)
163
+ server.disconnect(current);
164
+ current = channel.port1;
165
+ target.postMessage({ ...welcome, v: Math.min(event.data.v, BUS_VERSION) }, origin, [channel.port2]);
166
+ };
167
+ win.addEventListener("message", onMessage);
168
+ return {
169
+ close() {
170
+ win.removeEventListener("message", onMessage);
171
+ server.close();
172
+ current = null;
173
+ },
174
+ };
175
+ }
@@ -0,0 +1,20 @@
1
+ import { type Build } from "./bus/protocol.js";
2
+ import type { Backend } from "./saves/backend/types.js";
3
+ import { type Saves } from "./saves/client.js";
4
+ export type Ready = {
5
+ build: Build | null;
6
+ };
7
+ export interface Arcadible {
8
+ readonly version: string;
9
+ readonly ready: Promise<Ready>;
10
+ readonly build: Build | null | undefined;
11
+ readonly saves: Saves;
12
+ }
13
+ export type ClientOptions = {
14
+ window?: Window;
15
+ origins?: readonly string[];
16
+ handshakeTimeout?: number;
17
+ requestTimeout?: number;
18
+ backend?: () => Backend;
19
+ };
20
+ export declare function createClient(options?: ClientOptions): Arcadible;
package/dist/client.js ADDED
@@ -0,0 +1,42 @@
1
+ // `createClient`: the `arcadible` object a game calls, the bus with its
2
+ // modules on top. It says hello to the play page, and stands alone, with the
3
+ // same rules in its own page, when no page answers.
4
+ // [import]
5
+ import { openBus } from "./bus/client.js";
6
+ import { REQUEST_TIMEOUT } from "./bus/protocol.js";
7
+ import { indexedDBBackend } from "./saves/backend/indexeddb.js";
8
+ import { createSaves } from "./saves/client.js";
9
+ import { serve } from "./host.js";
10
+ import { VERSION } from "./version.js";
11
+ // The scope a game standing alone keeps its saves under, on its own origin.
12
+ // Standing alone there's no version to stamp: saves say `null`.
13
+ const STANDALONE = { game: "standalone", channel: "local", version: null };
14
+ // [function]
15
+ export function createClient(options = {}) {
16
+ const backend = options.backend ?? (() => indexedDBBackend());
17
+ const bus = openBus({
18
+ client: VERSION,
19
+ window: options.window,
20
+ origins: options.origins,
21
+ handshakeTimeout: options.handshakeTimeout,
22
+ requestTimeout: options.requestTimeout,
23
+ standalone: () => serve({ ...STANDALONE, backend: backend() }),
24
+ });
25
+ const { saves, opened } = createSaves(bus, {
26
+ timeout: options.requestTimeout ?? REQUEST_TIMEOUT,
27
+ });
28
+ let settled = null;
29
+ // ready once welcomed, and each module's first messages have arrived.
30
+ const ready = Promise.all([bus.welcomed, opened]).then(([welcome]) => {
31
+ settled = { build: welcome.build ?? null };
32
+ return settled;
33
+ });
34
+ return {
35
+ version: VERSION,
36
+ ready,
37
+ get build() {
38
+ return settled ? settled.build : undefined;
39
+ },
40
+ saves,
41
+ };
42
+ }
package/dist/host.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { type Server } from "./bus/server.js";
2
+ import type { Backend } from "./saves/backend/types.js";
3
+ export type ServeOptions = {
4
+ game: string;
5
+ channel: string;
6
+ version: string | null;
7
+ backend: Backend;
8
+ now?: () => Date;
9
+ };
10
+ export type HostOptions = ServeOptions & {
11
+ frame: HTMLIFrameElement;
12
+ origin: string;
13
+ window?: Window;
14
+ };
15
+ export declare function serve(options: ServeOptions): Server;
16
+ export declare function createHost(options: HostOptions): {
17
+ close(): void;
18
+ };
19
+ export type { Server } from "./bus/server.js";
20
+ export { indexedDBBackend } from "./saves/backend/indexeddb.js";
21
+ export type { IndexedDBOptions } from "./saves/backend/indexeddb.js";
22
+ export { memoryBackend } from "./saves/backend/memory.js";
23
+ export type { Backend, Change, NewRecord, SaveRecord, Scope, } from "./saves/backend/types.js";
package/dist/host.js ADDED
@@ -0,0 +1,35 @@
1
+ // `@arcadible/sdk/host`: the page's side. The play page uses `createHost`;
2
+ // a game standing alone runs `serve` in its own page.
3
+ // [import]
4
+ import { answerHellos, createServer } from "./bus/server.js";
5
+ import { savesServer } from "./saves/server.js";
6
+ // [function]
7
+ // Answers the bus for one game in one channel, on any number of ports.
8
+ export function serve(options) {
9
+ const { backend, channel, game, version } = options;
10
+ return createServer({
11
+ build: version === null ? null : { version, channel },
12
+ modules: [
13
+ savesServer({
14
+ backend,
15
+ now: options.now ?? (() => new Date()),
16
+ scope: { game, channel },
17
+ version,
18
+ }),
19
+ ],
20
+ });
21
+ }
22
+ // [function]
23
+ // The play page's host: it answers its iframe's hello with a welcome and a
24
+ // private channel. Call it before inserting the iframe, so no hello is
25
+ // missed.
26
+ export function createHost(options) {
27
+ return answerHellos({
28
+ frame: options.frame,
29
+ origin: options.origin,
30
+ server: serve(options),
31
+ window: options.window ?? window,
32
+ });
33
+ }
34
+ export { indexedDBBackend } from "./saves/backend/indexeddb.js";
35
+ export { memoryBackend } from "./saves/backend/memory.js";
@@ -0,0 +1,7 @@
1
+ import { type Arcadible } from "./client.js";
2
+ export declare const arcadible: Arcadible;
3
+ export { ArcadibleError, Codes } from "./bus/protocol.js";
4
+ export type { Build, Code } from "./bus/protocol.js";
5
+ export type { Arcadible, Ready } from "./client.js";
6
+ export type { Saves } from "./saves/client.js";
7
+ export type { SaveInfo } from "./saves/rules.js";
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ // `@arcadible/sdk`: the `arcadible` object a game calls. One per page, so a
2
+ // game that has both the hosted script and the package shares it.
3
+ // [import]
4
+ import { createClient } from "./client.js";
5
+ // [declare]
6
+ const scope = globalThis;
7
+ // [export]
8
+ export const arcadible = scope.arcadible ?? createClient();
9
+ scope.arcadible = arcadible;
10
+ export { ArcadibleError, Codes } from "./bus/protocol.js";
@@ -0,0 +1,10 @@
1
+ import type { Backend } from "./types.js";
2
+ export type IndexedDBOptions = {
3
+ factory?: IDBFactory | null;
4
+ player?: string;
5
+ persist?: boolean;
6
+ openTimeout?: number;
7
+ };
8
+ export declare function indexedDBBackend(options?: IndexedDBOptions): Backend & {
9
+ close(): void;
10
+ };
@@ -0,0 +1,224 @@
1
+ // [import]
2
+ import { listenersByKey } from "./listeners.js";
3
+ import { memoryBackend } from "./memory.js";
4
+ // [declare]
5
+ const DB_NAME = "arcadible-saves";
6
+ const DB_VERSION = 1;
7
+ const SAVES = "saves";
8
+ const GAMES = "games";
9
+ const BY_SCOPE = "scope";
10
+ // tells the page's other tabs about each change.
11
+ const CHANNEL = "arcadible-saves";
12
+ // every save is a guest's for One; a signed-in player's sit beside them
13
+ // later, under their own id, with no migration.
14
+ const GUEST = "guest";
15
+ // how long opening may take before saves are kept in memory instead: some
16
+ // browsers can leave an open hanging.
17
+ const OPEN_TIMEOUT = 5000;
18
+ // [function]
19
+ function settle(request) {
20
+ return new Promise((resolve, reject) => {
21
+ request.onsuccess = () => resolve(request.result);
22
+ request.onerror = () => reject(request.error);
23
+ });
24
+ }
25
+ // [function]
26
+ function finish(tx) {
27
+ return new Promise((resolve, reject) => {
28
+ tx.oncomplete = () => resolve();
29
+ tx.onerror = () => reject(tx.error);
30
+ tx.onabort = () => reject(tx.error ?? new Error("Transaction aborted."));
31
+ });
32
+ }
33
+ // [function]
34
+ function open(factory, timeout) {
35
+ return new Promise((resolve, reject) => {
36
+ const timer = setTimeout(() => reject(new Error("Opening IndexedDB timed out.")), timeout);
37
+ const request = factory.open(DB_NAME, DB_VERSION);
38
+ request.onupgradeneeded = () => {
39
+ const db = request.result;
40
+ // the same key the server's records will have: player, game,
41
+ // channel, and name.
42
+ const saves = db.createObjectStore(SAVES, {
43
+ keyPath: ["player", "game", "channel", "name"],
44
+ });
45
+ saves.createIndex(BY_SCOPE, ["player", "game", "channel"]);
46
+ db.createObjectStore(GAMES, { keyPath: ["player", "game", "channel"] });
47
+ };
48
+ request.onsuccess = () => {
49
+ clearTimeout(timer);
50
+ resolve(request.result);
51
+ };
52
+ request.onerror = () => {
53
+ clearTimeout(timer);
54
+ reject(request.error);
55
+ };
56
+ });
57
+ }
58
+ // [function]
59
+ function toRecord(stored) {
60
+ const { name, data, version, savedAt, size, revision } = stored;
61
+ return { name, data, version, savedAt, size, revision };
62
+ }
63
+ // [function]
64
+ // the connection was closed under us (a newer page upgrading, or Safari
65
+ // dropping it): open again.
66
+ function isClosed(err) {
67
+ return (err instanceof Error &&
68
+ (err.name === "InvalidStateError" || err.name === "UnknownError"));
69
+ }
70
+ // [function]
71
+ // Saves kept in IndexedDB: the play page's, which every game shares, and a
72
+ // game standing alone on its own origin. Where IndexedDB won't open, such as
73
+ // some `file://` pages, they're kept in memory, and `status()` says so.
74
+ export function indexedDBBackend(options = {}) {
75
+ const factory = options.factory === undefined
76
+ ? (globalThis.indexedDB ?? null)
77
+ : options.factory;
78
+ const player = options.player ?? GUEST;
79
+ const fallback = memoryBackend();
80
+ const listeners = listenersByKey();
81
+ let status = "local";
82
+ let asked = false;
83
+ const keyOf = (scope) => `${player}\u0000${scope.game}\u0000${scope.channel}`;
84
+ const rangeOf = (scope) => IDBKeyRange.only([player, scope.game, scope.channel]);
85
+ const connect = async function () {
86
+ try {
87
+ if (!factory)
88
+ throw new Error("IndexedDB isn't available here.");
89
+ const db = await open(factory, options.openTimeout ?? OPEN_TIMEOUT);
90
+ // a newer version of this page wants to upgrade: step aside.
91
+ db.onversionchange = () => db.close();
92
+ if (options.persist && !asked) {
93
+ asked = true;
94
+ globalThis.navigator?.storage?.persist?.().catch(() => false);
95
+ }
96
+ return db;
97
+ }
98
+ catch (err) {
99
+ console.warn("arcadible: saves are kept in memory only:", err);
100
+ status = "ephemeral";
101
+ return null;
102
+ }
103
+ };
104
+ let opening = connect();
105
+ // runs `op` on the database, opening it again once if it was closed, or
106
+ // on the memory fallback when there's none.
107
+ const run = async function (op, otherwise) {
108
+ const current = opening;
109
+ const db = await current;
110
+ if (!db)
111
+ return otherwise();
112
+ try {
113
+ return await op(db);
114
+ }
115
+ catch (err) {
116
+ if (!isClosed(err))
117
+ throw err;
118
+ // once, however many operations found it closed.
119
+ if (opening === current)
120
+ opening = connect();
121
+ const again = await opening;
122
+ return again ? op(again) : otherwise();
123
+ }
124
+ };
125
+ const broadcast = typeof BroadcastChannel === "undefined"
126
+ ? null
127
+ : new BroadcastChannel(CHANNEL);
128
+ if (broadcast) {
129
+ broadcast.onmessage = (event) => {
130
+ const { key, change, source } = event.data;
131
+ listeners.tell(key, change, source);
132
+ };
133
+ }
134
+ const tell = function (scope, change, source) {
135
+ const key = keyOf(scope);
136
+ listeners.tell(key, change, source);
137
+ broadcast?.postMessage({ key, change, source });
138
+ };
139
+ const change = async function (db, scope, name, decide) {
140
+ // one transaction for the check and the write, so two tabs can't both
141
+ // pass the limits, or take the same revision.
142
+ const tx = db.transaction([SAVES, GAMES], "readwrite");
143
+ const saves = tx.objectStore(SAVES);
144
+ const games = tx.objectStore(GAMES);
145
+ const gameKey = [player, scope.game, scope.channel];
146
+ const [all, row] = await Promise.all([
147
+ settle(saves.index(BY_SCOPE).getAll(rangeOf(scope))),
148
+ settle(games.get(gameKey)),
149
+ ]);
150
+ let next;
151
+ try {
152
+ next = decide(all.map(toRecord));
153
+ }
154
+ catch (err) {
155
+ tx.abort();
156
+ await finish(tx).catch(() => undefined);
157
+ throw err;
158
+ }
159
+ const exists = all.some((r) => r.name === name);
160
+ if (!next && !exists) {
161
+ await finish(tx);
162
+ return null;
163
+ }
164
+ const game = row ?? {
165
+ player,
166
+ game: scope.game,
167
+ channel: scope.channel,
168
+ };
169
+ const revision = (game.revision ?? 0) + 1;
170
+ games.put({ ...game, revision });
171
+ const record = next ? { ...next, revision } : null;
172
+ if (record)
173
+ saves.put({ ...record, player, ...scope });
174
+ else
175
+ saves.delete([player, scope.game, scope.channel, name]);
176
+ await finish(tx);
177
+ return { name, record, revision };
178
+ };
179
+ return {
180
+ status: () => status,
181
+ list(scope) {
182
+ return run(async (db) => {
183
+ const tx = db.transaction(SAVES, "readonly");
184
+ const all = await settle(tx.objectStore(SAVES).index(BY_SCOPE).getAll(rangeOf(scope)));
185
+ return all.map(toRecord);
186
+ }, () => fallback.list(scope));
187
+ },
188
+ get(scope, name) {
189
+ return run(async (db) => {
190
+ const tx = db.transaction(SAVES, "readonly");
191
+ const stored = await settle(tx
192
+ .objectStore(SAVES)
193
+ .get([player, scope.game, scope.channel, name]));
194
+ return stored ? toRecord(stored) : null;
195
+ }, () => fallback.get(scope, name));
196
+ },
197
+ async change(scope, name, decide, source) {
198
+ const result = await run((db) => change(db, scope, name, decide), () => fallback.change(scope, name, decide, source));
199
+ if (result)
200
+ tell(scope, result, source);
201
+ return result;
202
+ },
203
+ touch(scope, at) {
204
+ return run(async (db) => {
205
+ const tx = db.transaction(GAMES, "readwrite");
206
+ const games = tx.objectStore(GAMES);
207
+ const key = [player, scope.game, scope.channel];
208
+ const row = (await settle(games.get(key)));
209
+ games.put({
210
+ ...(row ?? { player, game: scope.game, channel: scope.channel }),
211
+ usedAt: at,
212
+ });
213
+ return finish(tx);
214
+ }, () => fallback.touch(scope, at));
215
+ },
216
+ subscribe(scope, listener) {
217
+ return listeners.add(keyOf(scope), listener);
218
+ },
219
+ close() {
220
+ broadcast?.close();
221
+ opening.then((db) => db?.close()).catch(() => undefined);
222
+ },
223
+ };
224
+ }
@@ -0,0 +1,6 @@
1
+ import type { Change } from "./types.js";
2
+ export type Listener = (change: Change, source: string) => void;
3
+ export declare function listenersByKey(): {
4
+ add(key: string, listener: Listener): () => void;
5
+ tell(key: string, change: Change, source: string): void;
6
+ };
@@ -0,0 +1,33 @@
1
+ // [import]
2
+ // [function]
3
+ // Each game's subscribers, by key, for a backend to tell of changes. Each
4
+ // gets its own copy, so one can't change what the next one sees, and one
5
+ // that throws doesn't fail the change, which is already kept.
6
+ export function listenersByKey() {
7
+ const listeners = new Map();
8
+ return {
9
+ add(key, listener) {
10
+ let set = listeners.get(key);
11
+ if (!set) {
12
+ set = new Set();
13
+ listeners.set(key, set);
14
+ }
15
+ set.add(listener);
16
+ return () => {
17
+ set.delete(listener);
18
+ if (set.size === 0 && listeners.get(key) === set)
19
+ listeners.delete(key);
20
+ };
21
+ },
22
+ tell(key, change, source) {
23
+ for (const listener of [...(listeners.get(key) ?? [])]) {
24
+ try {
25
+ listener(structuredClone(change), source);
26
+ }
27
+ catch (err) {
28
+ console.error("arcadible: a change's listener failed:", err);
29
+ }
30
+ }
31
+ },
32
+ };
33
+ }
@@ -0,0 +1,7 @@
1
+ import type { SaveStatus } from "../rules.js";
2
+ import type { Backend, Scope } from "./types.js";
3
+ export declare function memoryBackend({ status, }?: {
4
+ status?: SaveStatus;
5
+ }): Backend & {
6
+ used(scope: Scope): string | undefined;
7
+ };
@@ -0,0 +1,51 @@
1
+ // [import]
2
+ import { listenersByKey } from "./listeners.js";
3
+ // [function]
4
+ function keyOf(scope) {
5
+ return `${scope.game}\u0000${scope.channel}`;
6
+ }
7
+ // [function]
8
+ // Saves kept in memory, for tests, `arc preview`, and wherever IndexedDB
9
+ // won't open. Hosts sharing one act like tabs sharing a device. Its saves
10
+ // last as long as the page, so it says `ephemeral`.
11
+ export function memoryBackend({ status = "ephemeral", } = {}) {
12
+ const games = new Map();
13
+ const listeners = listenersByKey();
14
+ const gameOf = function (scope) {
15
+ const key = keyOf(scope);
16
+ let game = games.get(key);
17
+ if (!game) {
18
+ game = { saves: new Map(), revision: 0 };
19
+ games.set(key, game);
20
+ }
21
+ return game;
22
+ };
23
+ return {
24
+ status: () => status,
25
+ list: async (scope) => [...gameOf(scope).saves.values()].map((r) => ({ ...r })),
26
+ get: async (scope, name) => {
27
+ const record = gameOf(scope).saves.get(name);
28
+ return record ? { ...record } : null;
29
+ },
30
+ change: async (scope, name, decide, source) => {
31
+ const game = gameOf(scope);
32
+ const next = decide([...game.saves.values()].map((r) => ({ ...r })));
33
+ if (!next && !game.saves.has(name))
34
+ return null;
35
+ const revision = ++game.revision;
36
+ const record = next ? { ...next, revision } : null;
37
+ if (record)
38
+ game.saves.set(name, record);
39
+ else
40
+ game.saves.delete(name);
41
+ const change = { name, record: record && { ...record }, revision };
42
+ listeners.tell(keyOf(scope), change, source);
43
+ return change;
44
+ },
45
+ touch: async (scope, at) => {
46
+ gameOf(scope).usedAt = at;
47
+ },
48
+ subscribe: (scope, listener) => listeners.add(keyOf(scope), listener),
49
+ used: (scope) => games.get(keyOf(scope))?.usedAt,
50
+ };
51
+ }
@@ -0,0 +1,27 @@
1
+ import type { SaveStatus } from "../rules.js";
2
+ export type Scope = {
3
+ game: string;
4
+ channel: string;
5
+ };
6
+ export type SaveRecord = {
7
+ name: string;
8
+ data: string;
9
+ version: string | null;
10
+ savedAt: string;
11
+ size: number;
12
+ revision: number;
13
+ };
14
+ export type NewRecord = Omit<SaveRecord, "revision">;
15
+ export type Change = {
16
+ name: string;
17
+ record: SaveRecord | null;
18
+ revision: number;
19
+ };
20
+ export interface Backend {
21
+ status(): SaveStatus;
22
+ list(scope: Scope): Promise<SaveRecord[]>;
23
+ get(scope: Scope, name: string): Promise<SaveRecord | null>;
24
+ change(scope: Scope, name: string, decide: (saves: SaveRecord[]) => NewRecord | null, source: string): Promise<Change | null>;
25
+ touch(scope: Scope, at: string): Promise<void>;
26
+ subscribe(scope: Scope, listener: (change: Change, source: string) => void): () => void;
27
+ }