@arcadible/sdk 1.1.0 → 1.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.
- package/README.md +4 -4
- package/dist/bus/client.d.ts +7 -3
- package/dist/bus/client.js +107 -15
- package/dist/bus/protocol.d.ts +18 -0
- package/dist/bus/protocol.js +45 -4
- package/dist/bus/server.d.ts +2 -1
- package/dist/bus/server.js +50 -27
- package/dist/client.d.ts +14 -0
- package/dist/client.js +11 -5
- package/dist/host.d.ts +2 -0
- package/dist/host.js +1 -0
- package/dist/saves/backend/indexeddb.d.ts +0 -1
- package/dist/saves/backend/indexeddb.js +53 -28
- package/dist/saves/backend/listeners.js +2 -1
- package/dist/saves/backend/memory.d.ts +2 -2
- package/dist/saves/backend/memory.js +4 -3
- package/dist/saves/backend/types.d.ts +2 -2
- package/dist/saves/client.d.ts +20 -2
- package/dist/saves/client.js +146 -87
- package/dist/saves/rules.d.ts +21 -2
- package/dist/saves/rules.js +46 -1
- package/dist/saves/server.d.ts +2 -0
- package/dist/saves/server.js +29 -13
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// [import]
|
|
2
|
+
import { ArcadibleError, Codes, log } from "../../bus/protocol.js";
|
|
2
3
|
import { listenersByKey } from "./listeners.js";
|
|
3
4
|
import { memoryBackend } from "./memory.js";
|
|
4
5
|
// [declare]
|
|
@@ -12,9 +13,6 @@ const CHANNEL = "arcadible-saves";
|
|
|
12
13
|
// every save is a guest's for One; a signed-in player's sit beside them
|
|
13
14
|
// later, under their own id, with no migration.
|
|
14
15
|
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
16
|
// [function]
|
|
19
17
|
function settle(request) {
|
|
20
18
|
return new Promise((resolve, reject) => {
|
|
@@ -31,9 +29,10 @@ function finish(tx) {
|
|
|
31
29
|
});
|
|
32
30
|
}
|
|
33
31
|
// [function]
|
|
34
|
-
|
|
32
|
+
// Opens the database, however long it takes: a slow open is waited for,
|
|
33
|
+
// since the saves are late, not gone (the game's calls wait, or time out).
|
|
34
|
+
function open(factory) {
|
|
35
35
|
return new Promise((resolve, reject) => {
|
|
36
|
-
const timer = setTimeout(() => reject(new Error("Opening IndexedDB timed out.")), timeout);
|
|
37
36
|
const request = factory.open(DB_NAME, DB_VERSION);
|
|
38
37
|
request.onupgradeneeded = () => {
|
|
39
38
|
const db = request.result;
|
|
@@ -45,14 +44,8 @@ function open(factory, timeout) {
|
|
|
45
44
|
saves.createIndex(BY_SCOPE, ["player", "game", "channel"]);
|
|
46
45
|
db.createObjectStore(GAMES, { keyPath: ["player", "game", "channel"] });
|
|
47
46
|
};
|
|
48
|
-
request.onsuccess = () =>
|
|
49
|
-
|
|
50
|
-
resolve(request.result);
|
|
51
|
-
};
|
|
52
|
-
request.onerror = () => {
|
|
53
|
-
clearTimeout(timer);
|
|
54
|
-
reject(request.error);
|
|
55
|
-
};
|
|
47
|
+
request.onsuccess = () => resolve(request.result);
|
|
48
|
+
request.onerror = () => reject(request.error);
|
|
56
49
|
});
|
|
57
50
|
}
|
|
58
51
|
// [function]
|
|
@@ -69,8 +62,11 @@ function isClosed(err) {
|
|
|
69
62
|
}
|
|
70
63
|
// [function]
|
|
71
64
|
// Saves kept in IndexedDB: the play page's, which every game shares, and a
|
|
72
|
-
// game standing alone on its own origin. Where
|
|
73
|
-
//
|
|
65
|
+
// game standing alone on its own origin. Where there's no IndexedDB, or it
|
|
66
|
+
// refuses to open (a browser blocking site data, some `file://` pages),
|
|
67
|
+
// they're kept in memory, and `status()` says so. Once it has opened, it
|
|
68
|
+
// never switches: a lost connection fails calls with `unavailable` until it
|
|
69
|
+
// opens again, rather than show an empty copy.
|
|
74
70
|
export function indexedDBBackend(options = {}) {
|
|
75
71
|
const factory = options.factory === undefined
|
|
76
72
|
? (globalThis.indexedDB ?? null)
|
|
@@ -78,35 +74,64 @@ export function indexedDBBackend(options = {}) {
|
|
|
78
74
|
const player = options.player ?? GUEST;
|
|
79
75
|
const fallback = memoryBackend();
|
|
80
76
|
const listeners = listenersByKey();
|
|
81
|
-
|
|
77
|
+
// memory only when the first open fails; never once it has opened.
|
|
78
|
+
let status = "device";
|
|
79
|
+
let opened = false;
|
|
80
|
+
let current = null;
|
|
81
|
+
let opening = null;
|
|
82
82
|
let asked = false;
|
|
83
83
|
const keyOf = (scope) => `${player}\u0000${scope.game}\u0000${scope.channel}`;
|
|
84
84
|
const rangeOf = (scope) => IDBKeyRange.only([player, scope.game, scope.channel]);
|
|
85
|
+
const lost = function (db) {
|
|
86
|
+
if (current === db)
|
|
87
|
+
current = null;
|
|
88
|
+
};
|
|
85
89
|
const connect = async function () {
|
|
86
90
|
try {
|
|
87
91
|
if (!factory)
|
|
88
92
|
throw new Error("IndexedDB isn't available here.");
|
|
89
|
-
const db = await open(factory
|
|
93
|
+
const db = await open(factory);
|
|
90
94
|
// a newer version of this page wants to upgrade: step aside.
|
|
91
|
-
db.onversionchange = () =>
|
|
95
|
+
db.onversionchange = () => {
|
|
96
|
+
db.close();
|
|
97
|
+
lost(db);
|
|
98
|
+
};
|
|
99
|
+
// the browser closed it under us (Safari can).
|
|
100
|
+
db.onclose = () => lost(db);
|
|
92
101
|
if (options.persist && !asked) {
|
|
93
102
|
asked = true;
|
|
94
103
|
globalThis.navigator?.storage?.persist?.().catch(() => false);
|
|
95
104
|
}
|
|
105
|
+
opened = true;
|
|
106
|
+
current = db;
|
|
96
107
|
return db;
|
|
97
108
|
}
|
|
98
109
|
catch (err) {
|
|
99
|
-
|
|
100
|
-
|
|
110
|
+
if (opened) {
|
|
111
|
+
log("warn", "the saves' storage couldn't be opened again.", err);
|
|
112
|
+
throw new ArcadibleError(Codes.Unavailable, "Saves can't be reached right now. Try again.");
|
|
113
|
+
}
|
|
114
|
+
log("warn", "this browser won't keep saves here, so they're kept in memory until the page closes.", err);
|
|
115
|
+
status = "memory";
|
|
101
116
|
return null;
|
|
102
117
|
}
|
|
118
|
+
finally {
|
|
119
|
+
opening = null;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
// the database, opening it once however many ask; null in memory.
|
|
123
|
+
const database = function () {
|
|
124
|
+
if (status === "memory")
|
|
125
|
+
return Promise.resolve(null);
|
|
126
|
+
if (current)
|
|
127
|
+
return Promise.resolve(current);
|
|
128
|
+
opening ??= connect();
|
|
129
|
+
return opening;
|
|
103
130
|
};
|
|
104
|
-
let opening = connect();
|
|
105
131
|
// runs `op` on the database, opening it again once if it was closed, or
|
|
106
132
|
// on the memory fallback when there's none.
|
|
107
133
|
const run = async function (op, otherwise) {
|
|
108
|
-
const
|
|
109
|
-
const db = await current;
|
|
134
|
+
const db = await database();
|
|
110
135
|
if (!db)
|
|
111
136
|
return otherwise();
|
|
112
137
|
try {
|
|
@@ -115,13 +140,12 @@ export function indexedDBBackend(options = {}) {
|
|
|
115
140
|
catch (err) {
|
|
116
141
|
if (!isClosed(err))
|
|
117
142
|
throw err;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
opening = connect();
|
|
121
|
-
const again = await opening;
|
|
143
|
+
lost(db);
|
|
144
|
+
const again = await database();
|
|
122
145
|
return again ? op(again) : otherwise();
|
|
123
146
|
}
|
|
124
147
|
};
|
|
148
|
+
database().catch(() => undefined);
|
|
125
149
|
const broadcast = typeof BroadcastChannel === "undefined"
|
|
126
150
|
? null
|
|
127
151
|
: new BroadcastChannel(CHANNEL);
|
|
@@ -218,7 +242,8 @@ export function indexedDBBackend(options = {}) {
|
|
|
218
242
|
},
|
|
219
243
|
close() {
|
|
220
244
|
broadcast?.close();
|
|
221
|
-
|
|
245
|
+
current?.close();
|
|
246
|
+
opening?.then((db) => db?.close()).catch(() => undefined);
|
|
222
247
|
},
|
|
223
248
|
};
|
|
224
249
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// [import]
|
|
2
|
+
import { log } from "../../bus/protocol.js";
|
|
2
3
|
// [function]
|
|
3
4
|
// Each game's subscribers, by key, for a backend to tell of changes. Each
|
|
4
5
|
// gets its own copy, so one can't change what the next one sees, and one
|
|
@@ -25,7 +26,7 @@ export function listenersByKey() {
|
|
|
25
26
|
listener(structuredClone(change), source);
|
|
26
27
|
}
|
|
27
28
|
catch (err) {
|
|
28
|
-
|
|
29
|
+
log("error", "a change's listener failed.", err);
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { BackendStatus } from "../rules.js";
|
|
2
2
|
import type { Backend, Scope } from "./types.js";
|
|
3
3
|
export declare function memoryBackend({ status, }?: {
|
|
4
|
-
status?:
|
|
4
|
+
status?: BackendStatus;
|
|
5
5
|
}): Backend & {
|
|
6
6
|
used(scope: Scope): string | undefined;
|
|
7
7
|
};
|
|
@@ -5,10 +5,11 @@ function keyOf(scope) {
|
|
|
5
5
|
return `${scope.game}\u0000${scope.channel}`;
|
|
6
6
|
}
|
|
7
7
|
// [function]
|
|
8
|
-
// Saves kept in memory, for tests,
|
|
8
|
+
// Saves kept in memory, for tests, and wherever IndexedDB
|
|
9
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 `
|
|
11
|
-
|
|
10
|
+
// last as long as the page, so it says `memory`; tests pretend it's a
|
|
11
|
+
// device's with `device`.
|
|
12
|
+
export function memoryBackend({ status = "memory", } = {}) {
|
|
12
13
|
const games = new Map();
|
|
13
14
|
const listeners = listenersByKey();
|
|
14
15
|
const gameOf = function (scope) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { BackendStatus } from "../rules.js";
|
|
2
2
|
export type Scope = {
|
|
3
3
|
game: string;
|
|
4
4
|
channel: string;
|
|
@@ -18,7 +18,7 @@ export type Change = {
|
|
|
18
18
|
revision: number;
|
|
19
19
|
};
|
|
20
20
|
export interface Backend {
|
|
21
|
-
status():
|
|
21
|
+
status(): BackendStatus;
|
|
22
22
|
list(scope: Scope): Promise<SaveRecord[]>;
|
|
23
23
|
get(scope: Scope, name: string): Promise<SaveRecord | null>;
|
|
24
24
|
change(scope: Scope, name: string, decide: (saves: SaveRecord[]) => NewRecord | null, source: string): Promise<Change | null>;
|
package/dist/saves/client.d.ts
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { type SaveInfo } from "./rules.js";
|
|
1
|
+
import { type Bus } from "../bus/client.js";
|
|
2
|
+
import { type SaveInfo, type Where } from "./rules.js";
|
|
3
|
+
/** `arcadible.saves`: each save is a name holding one string. */
|
|
3
4
|
export interface Saves {
|
|
5
|
+
/** Keeps `data` as the save `name`, replacing what it held. */
|
|
4
6
|
write(name: string, data: string): Promise<SaveInfo>;
|
|
7
|
+
/** The save's string, or `null` when there's none. */
|
|
5
8
|
read(name: string): Promise<string | null>;
|
|
9
|
+
/** The names of the game's saves, sorted. */
|
|
6
10
|
list(): Promise<string[]>;
|
|
11
|
+
/** Which version wrote the save, and when, or `null` when there's none. */
|
|
7
12
|
info(name: string): Promise<SaveInfo | null>;
|
|
13
|
+
/** Removes the save, if there is one. */
|
|
8
14
|
remove(name: string): Promise<void>;
|
|
15
|
+
/** After `ready`, the save's string from memory; before it, throws `not_ready`. */
|
|
9
16
|
readSync(name: string): string | null;
|
|
17
|
+
/** After `ready`, the names of the game's saves from memory, sorted. */
|
|
18
|
+
listSync(): string[];
|
|
19
|
+
/** After `ready`, the save's version and time from memory, or `null`. */
|
|
20
|
+
infoSync(name: string): SaveInfo | null;
|
|
21
|
+
/**
|
|
22
|
+
* Who can read the saves back, once `ready` has settled: `"game"` (every
|
|
23
|
+
* version of the game), `"build"` (only this build), or `"memory"` (only
|
|
24
|
+
* until the page closes). `undefined` until it's known. Other values may
|
|
25
|
+
* be added: treat one you don't know as `"game"`.
|
|
26
|
+
*/
|
|
27
|
+
readonly where: Where | undefined;
|
|
10
28
|
}
|
|
11
29
|
export declare function createSaves(bus: Bus, { timeout }: {
|
|
12
30
|
timeout: number;
|
package/dist/saves/client.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// Saves, the game's side: `arcadible.saves`, and its memory copy of the
|
|
2
2
|
// game's saves, so reads after `ready` answer at once.
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
3
|
+
// [import]
|
|
4
|
+
import { mayHaveLanded } from "../bus/client.js";
|
|
5
|
+
import { ArcadibleError, buildOf, Codes, isStringArray, isStringOrNull, log, utf8Size, } from "../bus/protocol.js";
|
|
6
|
+
import { checkName, checkWrite, isRevisionOrNull, isSaveInfoOrNull, isSaveMeta, isSavesChange, isSavesLoad, } from "./rules.js";
|
|
5
7
|
// [function]
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
return err instanceof ArcadibleError && err.code === Codes.Timeout;
|
|
8
|
+
function isAnything(_value) {
|
|
9
|
+
return true;
|
|
9
10
|
}
|
|
10
11
|
// [function]
|
|
11
12
|
function infoOf(meta) {
|
|
@@ -20,11 +21,17 @@ export function createSaves(bus, { timeout }) {
|
|
|
20
21
|
let settled = false;
|
|
21
22
|
// whether the memory copy holds the game's saves.
|
|
22
23
|
let loaded = false;
|
|
24
|
+
let where;
|
|
23
25
|
let version = null;
|
|
24
26
|
const mirror = new Map();
|
|
25
27
|
// the newest revision seen of each save, removals included.
|
|
26
28
|
const revisions = new Map();
|
|
27
29
|
const early = [];
|
|
30
|
+
// the newest write of each save, until the page answers it: an older
|
|
31
|
+
// write's answer, merged with it or ahead of it, mustn't replace it in
|
|
32
|
+
// the memory copy.
|
|
33
|
+
const writing = new Map();
|
|
34
|
+
let writes = 0;
|
|
28
35
|
let open = () => undefined;
|
|
29
36
|
const opened = new Promise((resolve) => {
|
|
30
37
|
open = () => {
|
|
@@ -52,6 +59,14 @@ export function createSaves(bus, { timeout }) {
|
|
|
52
59
|
kept(name, revision, save);
|
|
53
60
|
};
|
|
54
61
|
// [function]
|
|
62
|
+
// the answer to a write or remove that timed out, once it comes: the
|
|
63
|
+
// page did keep it, so the memory copy takes it, unless a newer write of
|
|
64
|
+
// the save is waiting, whose answer will be newer still.
|
|
65
|
+
const landed = function (name, revision, save) {
|
|
66
|
+
if (!writing.has(name))
|
|
67
|
+
kept(name, revision, save);
|
|
68
|
+
};
|
|
69
|
+
// [function]
|
|
55
70
|
const restore = function (name, previous) {
|
|
56
71
|
if (previous)
|
|
57
72
|
mirror.set(name, previous);
|
|
@@ -72,131 +87,160 @@ export function createSaves(bus, { timeout }) {
|
|
|
72
87
|
}
|
|
73
88
|
};
|
|
74
89
|
// [function]
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
90
|
+
// runs `call` once `ready` has settled: at once when it has, so a write
|
|
91
|
+
// changes the memory copy in the same tick, and a `readSync` right after
|
|
92
|
+
// it sees the save.
|
|
93
|
+
const whenOpened = function (call) {
|
|
94
|
+
return settled ? call() : opened.then(call);
|
|
95
|
+
};
|
|
96
|
+
// [function]
|
|
97
|
+
const send = function (name, data) {
|
|
98
|
+
// before the saves have loaded, only the name and document can be
|
|
99
|
+
// checked here; the page checks the rest once they have.
|
|
100
|
+
const optimistic = loaded;
|
|
101
|
+
const refused = checkWrite(optimistic ? mirror : new Map(), name, data);
|
|
102
|
+
if (refused)
|
|
103
|
+
return Promise.reject(refused);
|
|
104
|
+
// the memory copy changes at once, so the next read sees it; the
|
|
105
|
+
// promise settles once the page has kept it.
|
|
106
|
+
const previous = mirror.get(name);
|
|
107
|
+
const ticket = ++writes;
|
|
108
|
+
writing.set(name, ticket);
|
|
109
|
+
if (optimistic) {
|
|
110
|
+
mirror.set(name, {
|
|
111
|
+
name,
|
|
112
|
+
data,
|
|
113
|
+
info: {
|
|
114
|
+
version,
|
|
115
|
+
savedAt: new Date().toISOString(),
|
|
116
|
+
size: utf8Size(data),
|
|
117
|
+
revision: previous?.info.revision ?? 0,
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const save = (meta) => ({ name, data, info: meta });
|
|
122
|
+
return bus
|
|
123
|
+
.request("saves.write", { name, data }, isSaveMeta, {
|
|
124
|
+
late: (meta) => landed(name, meta.revision, save(meta)),
|
|
125
|
+
})
|
|
126
|
+
.then((meta) => {
|
|
127
|
+
if (writing.get(name) === ticket) {
|
|
128
|
+
writing.delete(name);
|
|
129
|
+
kept(name, meta.revision, save(meta));
|
|
130
|
+
}
|
|
131
|
+
return infoOf(meta);
|
|
132
|
+
}, (err) => {
|
|
133
|
+
if (writing.get(name) === ticket)
|
|
134
|
+
writing.delete(name);
|
|
135
|
+
// one the page refused is rolled back, unless something newer
|
|
136
|
+
// replaced it meanwhile; one it may have kept stays.
|
|
137
|
+
if (optimistic &&
|
|
138
|
+
!mayHaveLanded(err) &&
|
|
139
|
+
mirror.get(name)?.data === data) {
|
|
140
|
+
restore(name, previous);
|
|
141
|
+
}
|
|
142
|
+
throw err;
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
// [function]
|
|
146
|
+
const erase = function (name) {
|
|
147
|
+
const optimistic = loaded;
|
|
148
|
+
const previous = mirror.get(name);
|
|
149
|
+
if (optimistic)
|
|
150
|
+
mirror.delete(name);
|
|
151
|
+
return bus
|
|
152
|
+
.request("saves.remove", { name }, isRevisionOrNull, {
|
|
153
|
+
late: (revision) => {
|
|
154
|
+
if (revision !== null)
|
|
155
|
+
landed(name, revision, null);
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
.then((revision) => {
|
|
159
|
+
if (revision !== null)
|
|
160
|
+
kept(name, revision, null);
|
|
161
|
+
}, (err) => {
|
|
162
|
+
if (optimistic && !mayHaveLanded(err) && !mirror.has(name)) {
|
|
163
|
+
restore(name, previous);
|
|
164
|
+
}
|
|
165
|
+
throw err;
|
|
166
|
+
});
|
|
78
167
|
};
|
|
79
168
|
// [start]
|
|
80
169
|
let timer;
|
|
81
|
-
|
|
170
|
+
// the load is checked here, not by the bus, so even a malformed one
|
|
171
|
+
// settles `ready` at once: the saves didn't load, and the sync reads say so.
|
|
172
|
+
bus.on("saves.load", isAnything, (load) => {
|
|
82
173
|
if (loaded)
|
|
83
174
|
return;
|
|
84
175
|
clearTimeout(timer);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
mirror.set(save.name, save);
|
|
90
|
-
revisions.set(save.name, save.info.revision);
|
|
91
|
-
}
|
|
92
|
-
loaded = true;
|
|
93
|
-
for (const change of early.splice(0))
|
|
94
|
-
apply(change);
|
|
95
|
-
}
|
|
176
|
+
if (!isSavesLoad(load)) {
|
|
177
|
+
log("warn", "the play page sent saves the game couldn't read.");
|
|
178
|
+
open();
|
|
179
|
+
return;
|
|
96
180
|
}
|
|
97
|
-
|
|
98
|
-
// even a malformed load settles `ready`.
|
|
181
|
+
if (load.error) {
|
|
99
182
|
open();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
for (const save of load.items) {
|
|
186
|
+
mirror.set(save.name, save);
|
|
187
|
+
revisions.set(save.name, save.info.revision);
|
|
100
188
|
}
|
|
189
|
+
loaded = true;
|
|
190
|
+
where = typeof load.where === "string" ? load.where : undefined;
|
|
191
|
+
for (const change of early.splice(0))
|
|
192
|
+
apply(change);
|
|
193
|
+
open();
|
|
101
194
|
});
|
|
102
|
-
bus.on("saves.change", (
|
|
195
|
+
bus.on("saves.change", isSavesChange, (change) => {
|
|
103
196
|
// one that comes before the saves is kept until they do.
|
|
104
197
|
if (loaded)
|
|
105
|
-
apply(
|
|
198
|
+
apply(change);
|
|
106
199
|
else
|
|
107
|
-
early.push(
|
|
200
|
+
early.push(change);
|
|
108
201
|
});
|
|
109
202
|
bus.welcomed.then((welcome) => {
|
|
110
|
-
version = welcome.build?.version ?? null;
|
|
203
|
+
version = buildOf(welcome.build)?.version ?? null;
|
|
111
204
|
// the memory copy doesn't pretend to be empty while the saves are late:
|
|
112
205
|
// a game that took that for "no save yet" could start over, and
|
|
113
206
|
// overwrite the player's progress once they arrived.
|
|
114
207
|
if (!settled) {
|
|
115
208
|
timer = setTimeout(() => {
|
|
116
|
-
|
|
209
|
+
if (!loaded) {
|
|
210
|
+
log("warn", "the game's saves haven't arrived yet, so the sync reads throw unavailable until they do.");
|
|
211
|
+
}
|
|
117
212
|
open();
|
|
118
213
|
}, timeout);
|
|
119
214
|
}
|
|
120
215
|
});
|
|
121
216
|
const saves = {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
// before the saves have loaded, only the name and document can be
|
|
125
|
-
// checked here; the page checks the rest once they have.
|
|
126
|
-
const optimistic = loaded;
|
|
127
|
-
const refused = checkWrite(optimistic ? mirror : new Map(), name, data);
|
|
128
|
-
if (refused)
|
|
129
|
-
throw refused;
|
|
130
|
-
// the memory copy changes at once, so the next read sees it; the
|
|
131
|
-
// promise settles once the page has kept it.
|
|
132
|
-
const previous = mirror.get(name);
|
|
133
|
-
if (optimistic) {
|
|
134
|
-
mirror.set(name, {
|
|
135
|
-
name,
|
|
136
|
-
data,
|
|
137
|
-
info: {
|
|
138
|
-
version,
|
|
139
|
-
savedAt: new Date().toISOString(),
|
|
140
|
-
size: utf8Size(data),
|
|
141
|
-
revision: previous?.info.revision ?? 0,
|
|
142
|
-
},
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
try {
|
|
146
|
-
const meta = (await bus.request("saves.write", {
|
|
147
|
-
name,
|
|
148
|
-
data,
|
|
149
|
-
}));
|
|
150
|
-
kept(name, meta.revision, { name, data, info: meta });
|
|
151
|
-
return infoOf(meta);
|
|
152
|
-
}
|
|
153
|
-
catch (err) {
|
|
154
|
-
// one the page refused is rolled back, unless something newer
|
|
155
|
-
// replaced it meanwhile.
|
|
156
|
-
if (optimistic &&
|
|
157
|
-
!mayHaveLanded(err) &&
|
|
158
|
-
mirror.get(name)?.data === data) {
|
|
159
|
-
restore(name, previous);
|
|
160
|
-
}
|
|
161
|
-
throw err;
|
|
162
|
-
}
|
|
217
|
+
write(name, data) {
|
|
218
|
+
return whenOpened(() => send(name, data));
|
|
163
219
|
},
|
|
164
220
|
async read(name) {
|
|
165
221
|
const refused = checkName(name);
|
|
166
222
|
if (refused)
|
|
167
223
|
throw refused;
|
|
168
|
-
|
|
224
|
+
await opened;
|
|
225
|
+
return bus.request("saves.read", { name }, isStringOrNull);
|
|
169
226
|
},
|
|
170
227
|
async list() {
|
|
171
|
-
|
|
228
|
+
await opened;
|
|
229
|
+
return bus.request("saves.list", undefined, isStringArray);
|
|
172
230
|
},
|
|
173
231
|
async info(name) {
|
|
174
232
|
const refused = checkName(name);
|
|
175
233
|
if (refused)
|
|
176
234
|
throw refused;
|
|
177
|
-
|
|
235
|
+
await opened;
|
|
236
|
+
const meta = await bus.request("saves.info", { name }, isSaveInfoOrNull);
|
|
178
237
|
return meta ? infoOf(meta) : null;
|
|
179
238
|
},
|
|
180
239
|
async remove(name) {
|
|
181
240
|
const refused = checkName(name);
|
|
182
241
|
if (refused)
|
|
183
242
|
throw refused;
|
|
184
|
-
|
|
185
|
-
const optimistic = loaded;
|
|
186
|
-
const previous = mirror.get(name);
|
|
187
|
-
if (optimistic)
|
|
188
|
-
mirror.delete(name);
|
|
189
|
-
try {
|
|
190
|
-
const revision = (await bus.request("saves.remove", { name }));
|
|
191
|
-
if (revision !== null)
|
|
192
|
-
kept(name, revision, null);
|
|
193
|
-
}
|
|
194
|
-
catch (err) {
|
|
195
|
-
if (optimistic && !mayHaveLanded(err) && !mirror.has(name)) {
|
|
196
|
-
restore(name, previous);
|
|
197
|
-
}
|
|
198
|
-
throw err;
|
|
199
|
-
}
|
|
243
|
+
return whenOpened(() => erase(name));
|
|
200
244
|
},
|
|
201
245
|
readSync(name) {
|
|
202
246
|
assertLoaded();
|
|
@@ -205,6 +249,21 @@ export function createSaves(bus, { timeout }) {
|
|
|
205
249
|
throw refused;
|
|
206
250
|
return mirror.get(name)?.data ?? null;
|
|
207
251
|
},
|
|
252
|
+
listSync() {
|
|
253
|
+
assertLoaded();
|
|
254
|
+
return [...mirror.keys()].sort();
|
|
255
|
+
},
|
|
256
|
+
infoSync(name) {
|
|
257
|
+
assertLoaded();
|
|
258
|
+
const refused = checkName(name);
|
|
259
|
+
if (refused)
|
|
260
|
+
throw refused;
|
|
261
|
+
const save = mirror.get(name);
|
|
262
|
+
return save ? infoOf(save.info) : null;
|
|
263
|
+
},
|
|
264
|
+
get where() {
|
|
265
|
+
return where;
|
|
266
|
+
},
|
|
208
267
|
};
|
|
209
268
|
return { saves, opened };
|
|
210
269
|
}
|
package/dist/saves/rules.d.ts
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import { ArcadibleError } from "../bus/protocol.js";
|
|
2
2
|
export declare const MAX_SAVES = 32;
|
|
3
3
|
export declare const MAX_TOTAL: number;
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Who can read the saves back: every version of the game (`"game"`), only
|
|
6
|
+
* this build (`"build"`), or only until the page closes (`"memory"`). Other
|
|
7
|
+
* values may be added, such as `"account"`: a game treats one it doesn't
|
|
8
|
+
* know as `"game"`.
|
|
9
|
+
*/
|
|
10
|
+
export type Where = "game" | "build" | "memory" | (string & {});
|
|
11
|
+
export type BackendStatus = "device" | "memory";
|
|
12
|
+
/** What a game is told of a save. */
|
|
5
13
|
export type SaveInfo = {
|
|
14
|
+
/**
|
|
15
|
+
* The game's version that wrote it, stamped by the page; `null` when it
|
|
16
|
+
* was written standing alone, where there's no version to stamp.
|
|
17
|
+
*/
|
|
6
18
|
version: string | null;
|
|
19
|
+
/** When it was written, as an ISO 8601 time. */
|
|
7
20
|
savedAt: string;
|
|
8
21
|
};
|
|
9
22
|
export type SaveMeta = SaveInfo & {
|
|
@@ -16,7 +29,7 @@ export type Save = {
|
|
|
16
29
|
info: SaveMeta;
|
|
17
30
|
};
|
|
18
31
|
export type SavesLoad = {
|
|
19
|
-
|
|
32
|
+
where?: Where;
|
|
20
33
|
items: Save[];
|
|
21
34
|
error?: {
|
|
22
35
|
code: string;
|
|
@@ -32,3 +45,9 @@ export declare function checkName(name: unknown): ArcadibleError | null;
|
|
|
32
45
|
export declare function checkWrite(saves: ReadonlyMap<string, {
|
|
33
46
|
info: SaveMeta;
|
|
34
47
|
}>, name: unknown, data: unknown): ArcadibleError | null;
|
|
48
|
+
export declare function isSaveMeta(value: unknown): value is SaveMeta;
|
|
49
|
+
export declare function isSaveInfoOrNull(value: unknown): value is SaveInfo | null;
|
|
50
|
+
export declare function isSave(value: unknown): value is Save;
|
|
51
|
+
export declare function isSavesLoad(value: unknown): value is SavesLoad;
|
|
52
|
+
export declare function isSavesChange(value: unknown): value is SavesChange;
|
|
53
|
+
export declare function isRevisionOrNull(value: unknown): value is number | null;
|
package/dist/saves/rules.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Saves' rules, shared by the game's side and the page's so they can't drift
|
|
2
2
|
// apart: the shapes, the limits, and their checks (DESIGN.md, section 6).
|
|
3
3
|
// [import]
|
|
4
|
-
import { ArcadibleError, Codes, utf8Size } from "../bus/protocol.js";
|
|
4
|
+
import { ArcadibleError, Codes, isNumber, isObject, isStringOrNull, utf8Size, } from "../bus/protocol.js";
|
|
5
5
|
// [declare]
|
|
6
6
|
// Saves' limits, the same everywhere (DESIGN.md, section 6, Limits).
|
|
7
7
|
export const MAX_SAVES = 32;
|
|
@@ -47,3 +47,48 @@ export function checkWrite(saves, name, data) {
|
|
|
47
47
|
}
|
|
48
48
|
return null;
|
|
49
49
|
}
|
|
50
|
+
// [function]
|
|
51
|
+
// What the page sends of a save, checked for only what a game relies on
|
|
52
|
+
// (DESIGN.md, The host is never trusted): extra fields pass.
|
|
53
|
+
export function isSaveMeta(value) {
|
|
54
|
+
return (isObject(value) &&
|
|
55
|
+
isStringOrNull(value["version"]) &&
|
|
56
|
+
typeof value["savedAt"] === "string" &&
|
|
57
|
+
isNumber(value["size"]) &&
|
|
58
|
+
isNumber(value["revision"]));
|
|
59
|
+
}
|
|
60
|
+
// [function]
|
|
61
|
+
export function isSaveInfoOrNull(value) {
|
|
62
|
+
return (value === null ||
|
|
63
|
+
(isObject(value) &&
|
|
64
|
+
isStringOrNull(value["version"]) &&
|
|
65
|
+
typeof value["savedAt"] === "string"));
|
|
66
|
+
}
|
|
67
|
+
// [function]
|
|
68
|
+
export function isSave(value) {
|
|
69
|
+
return (isObject(value) &&
|
|
70
|
+
checkName(value["name"]) === null &&
|
|
71
|
+
typeof value["data"] === "string" &&
|
|
72
|
+
isSaveMeta(value["info"]));
|
|
73
|
+
}
|
|
74
|
+
// [function]
|
|
75
|
+
// A load the memory copy can take whole: every save readable, or none.
|
|
76
|
+
export function isSavesLoad(value) {
|
|
77
|
+
return (isObject(value) &&
|
|
78
|
+
Array.isArray(value["items"]) &&
|
|
79
|
+
(value["error"] !== undefined || value["items"].every(isSave)));
|
|
80
|
+
}
|
|
81
|
+
// [function]
|
|
82
|
+
export function isSavesChange(value) {
|
|
83
|
+
if (!isObject(value) ||
|
|
84
|
+
checkName(value["name"]) !== null ||
|
|
85
|
+
!isNumber(value["revision"])) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
const save = value["save"];
|
|
89
|
+
return save === null || (isSave(save) && save.name === value["name"]);
|
|
90
|
+
}
|
|
91
|
+
// [function]
|
|
92
|
+
export function isRevisionOrNull(value) {
|
|
93
|
+
return value === null || isNumber(value);
|
|
94
|
+
}
|
package/dist/saves/server.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { Module, ModuleContext } from "../bus/server.js";
|
|
2
2
|
import type { Backend, Scope } from "./backend/types.js";
|
|
3
|
+
import { type Where } from "./rules.js";
|
|
3
4
|
type SavesServerOptions = {
|
|
4
5
|
scope: Scope;
|
|
5
6
|
version: string | null;
|
|
7
|
+
where: Where;
|
|
6
8
|
backend: Backend;
|
|
7
9
|
now: () => Date;
|
|
8
10
|
};
|