@diffenderfer-games/hub 2.0.0-next.4 → 2.0.0-next.6
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/CHANGELOG.md +54 -11
- package/README.md +3 -6
- package/dist/auto.d.ts +5 -5
- package/dist/auto.js +2 -2
- package/dist/avatar-pixi.d.ts +147 -0
- package/dist/avatar-pixi.d.ts.map +1 -0
- package/dist/avatar-pixi.js +280 -0
- package/dist/avatar-pixi.js.map +1 -0
- package/dist/avatar-svg.d.ts +4 -0
- package/dist/avatar-svg.js +5 -0
- package/dist/avatar-three.d.ts +69 -0
- package/dist/avatar-three.d.ts.map +1 -0
- package/dist/avatar-three.js +49 -0
- package/dist/avatar-three.js.map +1 -0
- package/dist/avatar.d.ts +7 -0
- package/dist/avatar.js +487 -0
- package/dist/avatar.js.map +1 -0
- package/dist/daily.d.ts +2 -2
- package/dist/doctor/cli.mjs +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/input.d.ts +2 -2
- package/dist/shared/animator.d.ts +104 -0
- package/dist/shared/animator.d.ts.map +1 -0
- package/dist/shared/art.js +1804 -0
- package/dist/shared/art.js.map +1 -0
- package/dist/shared/atlas.d.ts +134 -0
- package/dist/shared/atlas.d.ts.map +1 -0
- package/dist/shared/avatar.d.ts +117 -0
- package/dist/shared/avatar.d.ts.map +1 -0
- package/dist/shared/bridge.js +13 -5
- package/dist/shared/bridge.js.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.d.ts +129 -18
- package/dist/shared/daily.d.ts.map +1 -1
- package/dist/shared/daily.js +120 -26
- package/dist/shared/daily.js.map +1 -1
- package/dist/shared/defineProperty.js +39 -0
- package/dist/shared/describe.d.ts +32 -0
- package/dist/shared/describe.d.ts.map +1 -0
- package/dist/shared/describe.js +101 -0
- package/dist/shared/describe.js.map +1 -0
- package/dist/shared/globals.js +1 -39
- package/dist/shared/globals.js.map +1 -1
- package/dist/shared/index.d.ts +537 -17
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/input.d.ts +31 -13
- package/dist/shared/input.d.ts.map +1 -1
- package/dist/shared/input.js +40 -2
- package/dist/shared/input.js.map +1 -1
- package/dist/shared/json-guards.js +48 -0
- package/dist/shared/json-guards.js.map +1 -0
- package/dist/shared/live-canvas.js +62 -0
- package/dist/shared/live-canvas.js.map +1 -0
- package/dist/shared/mp.js +1 -1
- package/dist/shared/overlays.js +800 -0
- package/dist/shared/overlays.js.map +1 -0
- package/dist/shared/page-stack.js +2 -1
- package/dist/shared/page-stack.js.map +1 -1
- package/dist/shared/pose-key.js +249 -0
- package/dist/shared/pose-key.js.map +1 -0
- package/dist/shared/race.js +1 -1
- package/dist/shared/rooms.js +1 -1
- package/dist/shared/scene.d.ts +90 -0
- package/dist/shared/scene.d.ts.map +1 -0
- package/dist/shared/social.d.ts +55 -4
- package/dist/shared/social.d.ts.map +1 -1
- package/dist/shared/src.js +2217 -199
- package/dist/shared/src.js.map +1 -1
- package/dist/shared/svg-markup.js +1860 -0
- package/dist/shared/svg-markup.js.map +1 -0
- package/dist/shared/svg.js +27 -0
- package/dist/shared/svg.js.map +1 -0
- package/dist/shared/types.d.ts +166 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/social.d.ts +2 -2
- package/dist/social.js +2 -2
- package/dist/turns.d.ts +39 -0
- package/dist/turns.d.ts.map +1 -0
- package/dist/turns.js +117 -0
- package/dist/turns.js.map +1 -0
- package/package.json +33 -3
- package/src/avatar/adapters/pixi-avatar.ts +73 -0
- package/src/avatar/adapters/pixi-crowd.ts +302 -0
- package/src/avatar/adapters/pixi-types.ts +84 -0
- package/src/avatar/adapters/three.ts +126 -0
- package/src/avatar/animator/animator.ts +285 -0
- package/src/avatar/animator/reactions.ts +44 -0
- package/src/avatar/animator/schedule.ts +73 -0
- package/src/avatar/art/beard.ts +49 -0
- package/src/avatar/art/glasses.ts +9 -0
- package/src/avatar/art/hair.ts +254 -0
- package/src/avatar/art/headwear.ts +75 -0
- package/src/avatar/art/index.ts +21 -0
- package/src/avatar/art/shape.ts +68 -0
- package/src/avatar/art-store.ts +36 -0
- package/src/avatar/atlas.ts +236 -0
- package/src/avatar/catalog-view.ts +14 -0
- package/src/avatar/colour.ts +109 -0
- package/src/avatar/describe.ts +76 -0
- package/src/avatar/face.ts +187 -0
- package/src/avatar/geom.ts +168 -0
- package/src/avatar/layout.ts +47 -0
- package/src/avatar/look-like.ts +52 -0
- package/src/avatar/outputs/bitmap.ts +75 -0
- package/src/avatar/outputs/canvas-paint.ts +177 -0
- package/src/avatar/outputs/canvas.ts +62 -0
- package/src/avatar/outputs/custom-element.ts +140 -0
- package/src/avatar/outputs/element.ts +58 -0
- package/src/avatar/outputs/live-canvas.ts +57 -0
- package/src/avatar/outputs/overlay-svg.ts +22 -0
- package/src/avatar/outputs/pose-key.ts +30 -0
- package/src/avatar/outputs/svg-markup.ts +113 -0
- package/src/avatar/outputs/svg.ts +41 -0
- package/src/avatar/overlays.ts +128 -0
- package/src/avatar/render/body.ts +116 -0
- package/src/avatar/render/colours.ts +81 -0
- package/src/avatar/render/compose.ts +258 -0
- package/src/avatar/render/context.ts +117 -0
- package/src/avatar/render/eyes.ts +78 -0
- package/src/avatar/render/eyewear.ts +56 -0
- package/src/avatar/render/frame.ts +60 -0
- package/src/avatar/render/head.ts +81 -0
- package/src/avatar/render/mouth.ts +86 -0
- package/src/avatar/render/props.ts +91 -0
- package/src/avatar/render/types.ts +84 -0
- package/src/avatar/scene.ts +119 -0
- package/src/avatar-pixi.ts +20 -0
- package/src/avatar-svg.ts +21 -0
- package/src/avatar-three.ts +18 -0
- package/src/avatar.ts +62 -0
- package/src/core/avatar/avatar-cache.ts +95 -0
- package/src/core/avatar/avatar-ref.ts +34 -0
- package/src/core/avatar/avatar-types.ts +43 -0
- package/src/core/avatar/hub-avatar.ts +308 -0
- package/src/core/connection.ts +9 -0
- package/src/core/errors.ts +15 -2
- package/src/core/hub-progress.ts +269 -202
- package/src/core/hub.ts +174 -6
- package/src/core/offline/cloud-saves.ts +62 -0
- package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
- package/src/core/offline/hub-kinds/index.ts +14 -3
- package/src/core/offline/hub-kinds/progress-kinds.ts +21 -4
- package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
- package/src/core/offline/local-store.ts +46 -1
- package/src/core/page-account.ts +121 -0
- package/src/core/rest/routes.gen.ts +31 -1
- package/src/core/resume/hub-resume.ts +39 -5
- package/src/core/resume/resume-types.ts +2 -2
- package/src/core/saved/daily-saves.ts +196 -0
- package/src/core/saved/daily-seed.ts +16 -0
- package/src/core/saved/hub-saved.ts +221 -0
- package/src/core/saved/local-saved.ts +122 -0
- package/src/core/saved/saved-list.ts +129 -0
- package/src/core/saved/saved-open.ts +104 -0
- package/src/core/saved/saved-order.ts +49 -0
- package/src/core/saved/saved-types.ts +24 -0
- package/src/core/turns/hub-turns.ts +140 -0
- package/src/core/turns/turn-game.ts +388 -0
- package/src/core/version/client-version.gen.ts +2 -2
- package/src/daily/daily-system.ts +112 -13
- package/src/daily/page-daily.ts +2 -2
- package/src/daily/types.ts +46 -9
- package/src/doctor/hub-version-fetch.ts +43 -43
- package/src/index.ts +11 -1
- package/src/input/input-system.ts +10 -1
- package/src/input/navigator.ts +11 -0
- package/src/input.ts +1 -0
- package/src/overlay/overlay.ts +25 -1
- package/src/realtime/runtime-api.ts +14 -3
- package/src/social.ts +1 -0
- package/src/turns-kit/check-rules.ts +182 -0
- package/src/turns.ts +13 -0
package/dist/shared/src.js
CHANGED
|
@@ -1,58 +1,388 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as readField } from "./globals.js";
|
|
2
|
+
import { t as _defineProperty } from "./defineProperty.js";
|
|
2
3
|
import { n as getDaily } from "./daily.js";
|
|
3
4
|
import { t as HUB_CLIENT_VERSION } from "./client-version.gen.js";
|
|
4
5
|
import { n as getInput } from "./input.js";
|
|
5
6
|
import { t as getUiStack } from "./page-stack.js";
|
|
6
|
-
import {
|
|
7
|
+
import { d as SESSION_EVENT, n as pageBridge, p as linkRealtimeToHub } from "./bridge.js";
|
|
7
8
|
import { t as multiplayerForwarder } from "./mp.js";
|
|
9
|
+
import { t as createRuntimeOpener } from "./runtime-opener.js";
|
|
8
10
|
import { t as raceForwarder } from "./race.js";
|
|
9
11
|
import { n as roomsForwarder, t as isInHubRoom } from "./rooms.js";
|
|
10
12
|
import { n as presenceForwarder, r as socialForwarder, t as notifyForwarder } from "./social.js";
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
|
|
13
|
+
import { a as isRecord, c as parseJsonText, i as isNumberRecord, l as stringsIn, n as isJsonObject, o as optionalNumber, r as isNullableString, s as optionalString, t as isJson } from "./json-guards.js";
|
|
14
|
+
import { decodeLook, defaultLook, encodeLook, isLookCode } from "@diffenderfer-games/hub-contract/avatar";
|
|
15
|
+
//#region src/core/avatar/avatar-cache.ts
|
|
16
|
+
/**
|
|
17
|
+
* The device's avatar cache (avatars.md §5.6), so faces render offline:
|
|
18
|
+
* your own look (`hub:avatar:me`, read synchronously at startup) and an LRU
|
|
19
|
+
* of other players' codes (`hub:avatar:cache`, 200 entries).
|
|
20
|
+
*/
|
|
21
|
+
const MINE_KEY = "hub:avatar:me";
|
|
22
|
+
const OTHERS_KEY = "hub:avatar:cache";
|
|
23
|
+
const OTHERS_MAX = 200;
|
|
24
|
+
/** The device cache over a key-value store. */
|
|
25
|
+
var AvatarCache = class {
|
|
26
|
+
constructor(store) {
|
|
27
|
+
_defineProperty(this, "store", void 0);
|
|
28
|
+
_defineProperty(this, "others", null);
|
|
29
|
+
this.store = store;
|
|
30
|
+
}
|
|
31
|
+
/** Your cached look, or `null` (never cached, or another player's on this device). */
|
|
32
|
+
readMine(userId) {
|
|
33
|
+
const parsed = parseJsonText(this.store.read(MINE_KEY) ?? "");
|
|
34
|
+
if (!isRecord(parsed)) return null;
|
|
35
|
+
const id = optionalNumber(parsed.userId);
|
|
36
|
+
const code = optionalString(parsed.code);
|
|
37
|
+
if (id === void 0 || code === void 0 || !isLookCode(code) || userId !== null && id !== userId) return null;
|
|
38
|
+
return {
|
|
39
|
+
userId: id,
|
|
40
|
+
code,
|
|
41
|
+
isDefault: parsed.isDefault === true,
|
|
42
|
+
updatedAt: optionalNumber(parsed.updatedAt) ?? 0
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Remember your look. */
|
|
46
|
+
writeMine(mine) {
|
|
47
|
+
this.store.write(MINE_KEY, JSON.stringify(mine));
|
|
48
|
+
this.remember(mine.userId, mine.code);
|
|
49
|
+
}
|
|
50
|
+
/** A player's cached code, or `undefined`. */
|
|
51
|
+
codeOf(userId) {
|
|
52
|
+
return this.loadOthers().get(userId);
|
|
53
|
+
}
|
|
54
|
+
/** Remember a player's code (most recent last; the oldest drop past 200). */
|
|
55
|
+
remember(userId, code) {
|
|
56
|
+
if (code === "") return;
|
|
57
|
+
const others = this.loadOthers();
|
|
58
|
+
if (others.get(userId) === code) return;
|
|
59
|
+
others.delete(userId);
|
|
60
|
+
others.set(userId, code);
|
|
61
|
+
while (others.size > OTHERS_MAX) {
|
|
62
|
+
const oldest = others.keys().next().value;
|
|
63
|
+
if (oldest === void 0) break;
|
|
64
|
+
others.delete(oldest);
|
|
65
|
+
}
|
|
66
|
+
this.store.write(OTHERS_KEY, JSON.stringify([...others]));
|
|
67
|
+
}
|
|
68
|
+
loadOthers() {
|
|
69
|
+
if (this.others !== null) return this.others;
|
|
70
|
+
const others = /* @__PURE__ */ new Map();
|
|
71
|
+
const parsed = parseJsonText(this.store.read(OTHERS_KEY) ?? "[]");
|
|
72
|
+
if (Array.isArray(parsed)) for (const entry of parsed) {
|
|
73
|
+
const id = Array.isArray(entry) ? optionalNumber(entry[0]) : void 0;
|
|
74
|
+
const code = Array.isArray(entry) ? optionalString(entry[1]) : void 0;
|
|
75
|
+
if (id !== void 0 && code !== void 0 && isLookCode(code)) others.set(id, code);
|
|
76
|
+
}
|
|
77
|
+
this.others = others;
|
|
78
|
+
return others;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region src/core/avatar/avatar-ref.ts
|
|
83
|
+
/** Building {@link AvatarRef}s from codes, payloads and seeds. */
|
|
84
|
+
/** A ref for a code (`''` = hidden). */
|
|
85
|
+
function refOf(code, isDefault = false) {
|
|
86
|
+
if (code === "") return {
|
|
87
|
+
code: "",
|
|
88
|
+
look: null,
|
|
89
|
+
isDefault: false,
|
|
90
|
+
hidden: true
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
code,
|
|
94
|
+
look: decodeLook(code)?.look ?? null,
|
|
95
|
+
isDefault,
|
|
96
|
+
hidden: false
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** A seeded default look as a ref (bots, previews, local pass-and-play seats). */
|
|
100
|
+
function botRef(seed) {
|
|
101
|
+
const look = defaultLook(seed);
|
|
102
|
+
return {
|
|
103
|
+
code: encodeLook(look),
|
|
104
|
+
look,
|
|
105
|
+
isDefault: true,
|
|
106
|
+
hidden: false
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** The ref a payload carries; an old hub's payload (no `look`) falls back to the id's default. */
|
|
110
|
+
function refFrom(carrier) {
|
|
111
|
+
if (carrier.look !== void 0) return refOf(carrier.look);
|
|
112
|
+
const id = carrier.userId ?? carrier.id ?? carrier.uid;
|
|
113
|
+
return id === void 0 ? refOf("") : botRef(id);
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
116
|
+
//#region src/core/storage/key-value.ts
|
|
117
|
+
/** A store backed by a `Storage` (`localStorage`). */
|
|
118
|
+
function storageBacked(storage) {
|
|
119
|
+
return {
|
|
120
|
+
read: (key) => storage.getItem(key),
|
|
121
|
+
write: (key, text) => {
|
|
122
|
+
try {
|
|
123
|
+
storage.setItem(key, text);
|
|
124
|
+
} catch {}
|
|
125
|
+
},
|
|
126
|
+
remove: (key) => {
|
|
127
|
+
storage.removeItem(key);
|
|
128
|
+
},
|
|
129
|
+
keys: () => Object.keys(storage)
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** A store held in memory for this page only. */
|
|
133
|
+
function createMemoryStore() {
|
|
134
|
+
const entries = /* @__PURE__ */ new Map();
|
|
135
|
+
return {
|
|
136
|
+
read: (key) => entries.get(key) ?? null,
|
|
137
|
+
write: (key, text) => {
|
|
138
|
+
entries.set(key, text);
|
|
139
|
+
},
|
|
140
|
+
remove: (key) => {
|
|
141
|
+
entries.delete(key);
|
|
142
|
+
},
|
|
143
|
+
keys: () => [...entries.keys()]
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** The page's `localStorage`, or `undefined` when it is missing or throws (privacy mode). */
|
|
147
|
+
function pageStorage() {
|
|
45
148
|
try {
|
|
46
|
-
return
|
|
149
|
+
return typeof localStorage === "undefined" ? void 0 : localStorage;
|
|
47
150
|
} catch {
|
|
48
151
|
return;
|
|
49
152
|
}
|
|
50
153
|
}
|
|
51
|
-
/**
|
|
52
|
-
function
|
|
53
|
-
|
|
154
|
+
/** `localStorage` when available, else a fresh in-memory store. */
|
|
155
|
+
function createPageStore() {
|
|
156
|
+
const storage = pageStorage();
|
|
157
|
+
return storage === void 0 ? createMemoryStore() : storageBacked(storage);
|
|
54
158
|
}
|
|
55
159
|
//#endregion
|
|
160
|
+
//#region src/core/avatar/hub-avatar.ts
|
|
161
|
+
const BATCH_MAX = 100;
|
|
162
|
+
const openWithRuntime = createRuntimeOpener(pageBridge);
|
|
163
|
+
/** The look `mine()` gives before the account exists (a new guest's is being made). */
|
|
164
|
+
const NO_ACCOUNT_YET = botRef("you");
|
|
165
|
+
/** `hub.avatar`. */
|
|
166
|
+
var HubAvatar = class {
|
|
167
|
+
constructor(host) {
|
|
168
|
+
_defineProperty(this, "host", void 0);
|
|
169
|
+
_defineProperty(this, "cache", new AvatarCache(createPageStore()));
|
|
170
|
+
_defineProperty(this, "listeners", /* @__PURE__ */ new Set());
|
|
171
|
+
_defineProperty(this, "pending", /* @__PURE__ */ new Map());
|
|
172
|
+
_defineProperty(this, "flushQueued", false);
|
|
173
|
+
_defineProperty(this, "detachRuntime", null);
|
|
174
|
+
_defineProperty(this, "lastUserId", void 0);
|
|
175
|
+
_defineProperty(this, "mineShown", null);
|
|
176
|
+
_defineProperty(this, "seen", /* @__PURE__ */ new Set());
|
|
177
|
+
this.host = host;
|
|
178
|
+
this.lastUserId = host.currentUserId();
|
|
179
|
+
host.onAccount((user, source) => {
|
|
180
|
+
this.onAccount(user, source === "session");
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Your look (your default if you never designed one). Works offline from
|
|
185
|
+
* the device cache. Before your account exists (a new guest's is made just
|
|
186
|
+
* after the game starts) it resolves at once with a default look, and
|
|
187
|
+
* `change` brings yours when the account is there.
|
|
188
|
+
*/
|
|
189
|
+
async mine() {
|
|
190
|
+
const userId = this.host.currentUserId();
|
|
191
|
+
if (userId === null) return this.showMine(NO_ACCOUNT_YET);
|
|
192
|
+
try {
|
|
193
|
+
const mine = await this.host.fetchMine();
|
|
194
|
+
return this.noteMine(userId, mine);
|
|
195
|
+
} catch (failure) {
|
|
196
|
+
const cached = this.mineSync();
|
|
197
|
+
if (cached !== null) return cached;
|
|
198
|
+
throw failure;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** Your cached look, or your default; `null` only while nobody is signed in. */
|
|
202
|
+
mineSync() {
|
|
203
|
+
const userId = this.host.currentUserId();
|
|
204
|
+
if (userId === null) return null;
|
|
205
|
+
const cached = this.cache.readMine(userId);
|
|
206
|
+
return this.showMine(cached === null ? botRef(userId) : refOf(cached.code, cached.isDefault));
|
|
207
|
+
}
|
|
208
|
+
/** Any player's look: from the cache, else batched with other requests in the same tick. */
|
|
209
|
+
of(userId) {
|
|
210
|
+
this.seen.add(userId);
|
|
211
|
+
const cached = this.cache.codeOf(userId);
|
|
212
|
+
if (cached !== void 0) return Promise.resolve(refOf(cached));
|
|
213
|
+
return new Promise((resolve) => {
|
|
214
|
+
const waiting = this.pending.get(userId) ?? [];
|
|
215
|
+
waiting.push((code) => {
|
|
216
|
+
resolve(code === "" ? botRef(userId) : refOf(code));
|
|
217
|
+
});
|
|
218
|
+
this.pending.set(userId, waiting);
|
|
219
|
+
this.scheduleFlush();
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
/** Many players' looks at once. */
|
|
223
|
+
async many(userIds) {
|
|
224
|
+
const refs = await Promise.all(userIds.map(async (id) => [id, await this.of(id)]));
|
|
225
|
+
return new Map(refs);
|
|
226
|
+
}
|
|
227
|
+
/** The look a payload already carries (a `RoomSeat`, `PlayerLite`, racer or ticket user). No request. */
|
|
228
|
+
from(carrier) {
|
|
229
|
+
const ref = refFrom(carrier);
|
|
230
|
+
const id = carrier.userId ?? carrier.id ?? carrier.uid;
|
|
231
|
+
if (id !== void 0 && carrier.look !== void 0) {
|
|
232
|
+
this.seen.add(id);
|
|
233
|
+
this.cache.remember(id, carrier.look);
|
|
234
|
+
}
|
|
235
|
+
return ref;
|
|
236
|
+
}
|
|
237
|
+
/** A seeded default for bots, previews and local pass-and-play seats: `bot('local:2')`. */
|
|
238
|
+
bot(seed) {
|
|
239
|
+
return botRef(seed);
|
|
240
|
+
}
|
|
241
|
+
/** Save your look (designer code paths; queued offline, latest wins). */
|
|
242
|
+
async save(look) {
|
|
243
|
+
const code = encodeLook(look);
|
|
244
|
+
const savedAt = Date.now();
|
|
245
|
+
const userId = this.host.currentUserId();
|
|
246
|
+
if (userId !== null) this.noteMine(userId, {
|
|
247
|
+
code,
|
|
248
|
+
isDefault: false,
|
|
249
|
+
updatedAt: savedAt
|
|
250
|
+
});
|
|
251
|
+
await this.host.queueSave(code, savedAt);
|
|
252
|
+
return refOf(code);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Hear about look changes (yours, friends', your room's, and yours when
|
|
256
|
+
* your account appears). Returns the unsubscribe function. Wait for this
|
|
257
|
+
* rather than polling `mine()`.
|
|
258
|
+
*/
|
|
259
|
+
on(_event, listener) {
|
|
260
|
+
this.listeners.add(listener);
|
|
261
|
+
this.attachRuntime();
|
|
262
|
+
return () => {
|
|
263
|
+
this.listeners.delete(listener);
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
/** Open "Make your avatar" over the game; resolves when it closes. */
|
|
267
|
+
openDesigner(options = {}) {
|
|
268
|
+
return new Promise((resolve) => {
|
|
269
|
+
openWithRuntime((realtime) => {
|
|
270
|
+
if (realtime.avatar === void 0) {
|
|
271
|
+
resolve({ saved: false });
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
realtime.avatar.openDesigner(options).then(resolve, () => {
|
|
275
|
+
resolve({ saved: false });
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
/** Loads the renderer and its art; resolves when rendering is synchronous. */
|
|
281
|
+
async load() {
|
|
282
|
+
const avatar = await import("../avatar.js");
|
|
283
|
+
await avatar.ready();
|
|
284
|
+
return avatar;
|
|
285
|
+
}
|
|
286
|
+
/** Remember the ref the page was given for your look. */
|
|
287
|
+
showMine(ref) {
|
|
288
|
+
this.mineShown = ref.code;
|
|
289
|
+
return ref;
|
|
290
|
+
}
|
|
291
|
+
/** Cache your look; `change` fires when it differs from the one the page last had. */
|
|
292
|
+
noteMine(userId, mine) {
|
|
293
|
+
const before = this.mineShown ?? this.cache.readMine(userId)?.code ?? null;
|
|
294
|
+
this.cache.writeMine({
|
|
295
|
+
userId,
|
|
296
|
+
code: mine.code,
|
|
297
|
+
isDefault: mine.isDefault,
|
|
298
|
+
updatedAt: mine.updatedAt
|
|
299
|
+
});
|
|
300
|
+
const ref = this.showMine(refOf(mine.code, mine.isDefault));
|
|
301
|
+
if (mine.code !== before) this.emit({
|
|
302
|
+
userId,
|
|
303
|
+
ref
|
|
304
|
+
});
|
|
305
|
+
return ref;
|
|
306
|
+
}
|
|
307
|
+
/** Someone said who is signed in: fetch your look when the account is new or its look isn't the cached one. */
|
|
308
|
+
onAccount(user, isHello) {
|
|
309
|
+
const userId = user === null || user.id === 0 ? null : user.id;
|
|
310
|
+
const isNewAccount = userId !== this.lastUserId;
|
|
311
|
+
this.lastUserId = userId;
|
|
312
|
+
if (user === null || userId === null) return;
|
|
313
|
+
if (isNewAccount || user.look !== null && user.look !== this.cache.readMine(userId)?.code) this.refreshMine(userId);
|
|
314
|
+
if (isHello) this.recheckSeen();
|
|
315
|
+
}
|
|
316
|
+
async refreshMine(userId) {
|
|
317
|
+
const mine = await this.host.fetchMine().catch(() => null);
|
|
318
|
+
if (mine !== null && this.host.currentUserId() === userId) this.noteMine(userId, mine);
|
|
319
|
+
}
|
|
320
|
+
/** The socket (re)connected: a change sent while it was down never arrived, so ask again. */
|
|
321
|
+
async recheckSeen() {
|
|
322
|
+
if (this.listeners.size === 0 || this.seen.size === 0) return;
|
|
323
|
+
const codes = await this.fetchCodes([...this.seen]);
|
|
324
|
+
for (const [userId, code] of codes) if (code !== "" && code !== this.cache.codeOf(userId)) {
|
|
325
|
+
this.cache.remember(userId, code);
|
|
326
|
+
this.emit({
|
|
327
|
+
userId,
|
|
328
|
+
ref: refOf(code)
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
emit(change) {
|
|
333
|
+
for (const listener of this.listeners) listener(change);
|
|
334
|
+
}
|
|
335
|
+
attachRuntime() {
|
|
336
|
+
if (this.detachRuntime !== null) return;
|
|
337
|
+
this.detachRuntime = pageBridge.subscribe((realtime) => realtime.avatar?.on("change", ({ userId, code }) => {
|
|
338
|
+
if (userId === this.host.currentUserId()) {
|
|
339
|
+
this.noteMine(userId, {
|
|
340
|
+
code,
|
|
341
|
+
isDefault: false,
|
|
342
|
+
updatedAt: Date.now()
|
|
343
|
+
});
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
this.cache.remember(userId, code);
|
|
347
|
+
this.emit({
|
|
348
|
+
userId,
|
|
349
|
+
ref: refOf(code)
|
|
350
|
+
});
|
|
351
|
+
}) ?? (() => void 0));
|
|
352
|
+
}
|
|
353
|
+
scheduleFlush() {
|
|
354
|
+
if (this.flushQueued) return;
|
|
355
|
+
this.flushQueued = true;
|
|
356
|
+
queueMicrotask(() => {
|
|
357
|
+
this.flushQueued = false;
|
|
358
|
+
this.flush();
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
async flush() {
|
|
362
|
+
const batch = this.pending;
|
|
363
|
+
this.pending = /* @__PURE__ */ new Map();
|
|
364
|
+
const codes = await this.fetchCodes([...batch.keys()]);
|
|
365
|
+
for (const [id, waiting] of batch) {
|
|
366
|
+
const code = codes.get(id) ?? "";
|
|
367
|
+
if (code !== "") this.cache.remember(id, code);
|
|
368
|
+
for (const resolve of waiting) resolve(code);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/** Players' codes, in batches; offline or failing, the missing ones are left out (they get their default). */
|
|
372
|
+
async fetchCodes(ids) {
|
|
373
|
+
const codes = /* @__PURE__ */ new Map();
|
|
374
|
+
for (let i = 0; i < ids.length; i += BATCH_MAX) {
|
|
375
|
+
const slice = ids.slice(i, i + BATCH_MAX);
|
|
376
|
+
const answered = await this.host.fetchCodes(slice).catch(() => ({}));
|
|
377
|
+
for (const id of slice) {
|
|
378
|
+
const code = answered[String(id)];
|
|
379
|
+
if (code !== void 0) codes.set(id, code);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return codes;
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
//#endregion
|
|
56
386
|
//#region src/core/errors.ts
|
|
57
387
|
/**
|
|
58
388
|
* The code a client gives a call the hub does not have yet (a newer client on
|
|
@@ -60,6 +390,12 @@ function isJsonObject(candidate) {
|
|
|
60
390
|
*/
|
|
61
391
|
const UNSUPPORTED_BY_HUB = "unsupported_by_hub";
|
|
62
392
|
/**
|
|
393
|
+
* The code `getSave` rejects with when the hub can't be reached and this
|
|
394
|
+
* device has no copy of the slot it can trust (a fresh device or cleared
|
|
395
|
+
* browser): the slot may well hold a save, so it is not reported empty.
|
|
396
|
+
*/
|
|
397
|
+
const SAVE_UNKNOWN_CODE = "save_unknown";
|
|
398
|
+
/**
|
|
63
399
|
* Thrown by every hub call that fails. `status` is the HTTP status when the
|
|
64
400
|
* hub answered (401 = not signed in, 404 = unknown game or board, 413 = too
|
|
65
401
|
* large), or `undefined` for a transport failure (offline, no host). Social
|
|
@@ -75,6 +411,7 @@ var HubError = class extends Error {
|
|
|
75
411
|
_defineProperty(this, "hint", void 0);
|
|
76
412
|
_defineProperty(this, "retryAfterMs", void 0);
|
|
77
413
|
_defineProperty(this, "until", void 0);
|
|
414
|
+
_defineProperty(this, "rule", void 0);
|
|
78
415
|
this.name = "HubError";
|
|
79
416
|
this.status = status;
|
|
80
417
|
this.code = details.code;
|
|
@@ -82,6 +419,7 @@ var HubError = class extends Error {
|
|
|
82
419
|
this.hint = details.hint;
|
|
83
420
|
this.retryAfterMs = details.retryAfterMs;
|
|
84
421
|
this.until = details.until;
|
|
422
|
+
this.rule = details.rule;
|
|
85
423
|
}
|
|
86
424
|
/** `true` when the failure was a network or transport error, not an HTTP status. */
|
|
87
425
|
get isOffline() {
|
|
@@ -117,12 +455,13 @@ function readErrorFields(body) {
|
|
|
117
455
|
reason: optionalString(body.reason),
|
|
118
456
|
hint: optionalString(body.hint),
|
|
119
457
|
retryAfterMs: optionalNumber(body.retryAfterMs),
|
|
120
|
-
until: optionalNumber(body.until)
|
|
458
|
+
until: optionalNumber(body.until),
|
|
459
|
+
rule: optionalString(body.rule)
|
|
121
460
|
};
|
|
122
461
|
}
|
|
123
462
|
/** The right error class for a failure: a 422 or `content_rejected` is a {@link ContentRejectedError}. */
|
|
124
463
|
function errorFor(message, status, details) {
|
|
125
|
-
return status === 422 || details.code === "content_rejected" ? new ContentRejectedError(message, status, details) : new HubError(message, status, details);
|
|
464
|
+
return status === 422 && details.code !== "illegal" || details.code === "content_rejected" ? new ContentRejectedError(message, status, details) : new HubError(message, status, details);
|
|
126
465
|
}
|
|
127
466
|
/**
|
|
128
467
|
* Re-create an error from another bundle (the social runtime, or another
|
|
@@ -138,50 +477,6 @@ function toHubError(thrown) {
|
|
|
138
477
|
return errorFor(message, optionalNumber(thrown.status), fields);
|
|
139
478
|
}
|
|
140
479
|
//#endregion
|
|
141
|
-
//#region src/core/storage/key-value.ts
|
|
142
|
-
/** A store backed by a `Storage` (`localStorage`). */
|
|
143
|
-
function storageBacked(storage) {
|
|
144
|
-
return {
|
|
145
|
-
read: (key) => storage.getItem(key),
|
|
146
|
-
write: (key, text) => {
|
|
147
|
-
try {
|
|
148
|
-
storage.setItem(key, text);
|
|
149
|
-
} catch {}
|
|
150
|
-
},
|
|
151
|
-
remove: (key) => {
|
|
152
|
-
storage.removeItem(key);
|
|
153
|
-
},
|
|
154
|
-
keys: () => Object.keys(storage)
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
/** A store held in memory for this page only. */
|
|
158
|
-
function createMemoryStore() {
|
|
159
|
-
const entries = /* @__PURE__ */ new Map();
|
|
160
|
-
return {
|
|
161
|
-
read: (key) => entries.get(key) ?? null,
|
|
162
|
-
write: (key, text) => {
|
|
163
|
-
entries.set(key, text);
|
|
164
|
-
},
|
|
165
|
-
remove: (key) => {
|
|
166
|
-
entries.delete(key);
|
|
167
|
-
},
|
|
168
|
-
keys: () => [...entries.keys()]
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
/** The page's `localStorage`, or `undefined` when it is missing or throws (privacy mode). */
|
|
172
|
-
function pageStorage() {
|
|
173
|
-
try {
|
|
174
|
-
return typeof localStorage === "undefined" ? void 0 : localStorage;
|
|
175
|
-
} catch {
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
/** `localStorage` when available, else a fresh in-memory store. */
|
|
180
|
-
function createPageStore() {
|
|
181
|
-
const storage = pageStorage();
|
|
182
|
-
return storage === void 0 ? createMemoryStore() : storageBacked(storage);
|
|
183
|
-
}
|
|
184
|
-
//#endregion
|
|
185
480
|
//#region src/core/offline/favorites.ts
|
|
186
481
|
/**
|
|
187
482
|
* Starred games are site-wide, not per game, so they live under one global
|
|
@@ -255,6 +550,36 @@ function gameKind(definition) {
|
|
|
255
550
|
};
|
|
256
551
|
}
|
|
257
552
|
//#endregion
|
|
553
|
+
//#region src/core/offline/hub-kinds/avatar-kind.ts
|
|
554
|
+
/** The kind name. */
|
|
555
|
+
const AVATAR_KIND = "hub.avatar";
|
|
556
|
+
function readAvatar(stored) {
|
|
557
|
+
if (!isRecord(stored)) return;
|
|
558
|
+
const code = optionalString(stored.code);
|
|
559
|
+
const savedAt = optionalNumber(stored.savedAt);
|
|
560
|
+
return code === void 0 || savedAt === void 0 ? void 0 : {
|
|
561
|
+
code,
|
|
562
|
+
savedAt
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
/** The avatar kind, by name. */
|
|
566
|
+
function avatarKinds() {
|
|
567
|
+
return { [AVATAR_KIND]: checkedKind(readAvatar, {
|
|
568
|
+
mergeKey: () => "avatar",
|
|
569
|
+
replaces: true,
|
|
570
|
+
run: async (op, context) => {
|
|
571
|
+
const look = decodeLook(op.code)?.look;
|
|
572
|
+
if (look !== void 0) await context.call("saveMyAvatar", {
|
|
573
|
+
look: {
|
|
574
|
+
...look,
|
|
575
|
+
accessories: [...look.accessories]
|
|
576
|
+
},
|
|
577
|
+
savedAt: op.savedAt
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
}) };
|
|
581
|
+
}
|
|
582
|
+
//#endregion
|
|
258
583
|
//#region src/core/offline/hub-kinds/daily-kind.ts
|
|
259
584
|
/** The kind name. */
|
|
260
585
|
const DAILY_KIND = "hub.daily";
|
|
@@ -555,23 +880,36 @@ function mergeStats(waiting, incoming) {
|
|
|
555
880
|
case "set": return incoming;
|
|
556
881
|
}
|
|
557
882
|
}
|
|
558
|
-
/**
|
|
559
|
-
|
|
883
|
+
/**
|
|
884
|
+
* The progress kinds, by name. A save write replayed from the queue sends
|
|
885
|
+
* the hub's `updatedAt` this device last saw for the slot (`null` when it
|
|
886
|
+
* saw it empty or never saw it), so the hub refuses it (409 `save_conflict`,
|
|
887
|
+
* dropped and reported as a conflict) when another device saved since. A
|
|
888
|
+
* write sent at once, online, always lands, as before.
|
|
889
|
+
*/
|
|
890
|
+
function progressKinds(game, cloudSaves) {
|
|
560
891
|
return {
|
|
561
892
|
[PROGRESS_KINDS.save]: checkedKind(readSave$1, {
|
|
562
893
|
mergeKey: (save) => save.slot,
|
|
563
894
|
replaces: true,
|
|
564
895
|
run: async (save, context) => {
|
|
565
|
-
if ("deleted" in save)
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
896
|
+
if ("deleted" in save) {
|
|
897
|
+
await context.call("deleteSave", {
|
|
898
|
+
...game(),
|
|
899
|
+
slot: save.slot
|
|
900
|
+
});
|
|
901
|
+
cloudSaves.rememberCloudSave(save.slot, null);
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
const base = context.isReplay ? { base: cloudSaves.cloudSave(save.slot) ?? null } : {};
|
|
905
|
+
const written = await context.call("writeSave", {
|
|
570
906
|
...game(),
|
|
571
907
|
slot: save.slot,
|
|
572
908
|
data: save.data,
|
|
573
|
-
label: save.label
|
|
909
|
+
label: save.label,
|
|
910
|
+
...base
|
|
574
911
|
});
|
|
912
|
+
cloudSaves.rememberCloudSave(save.slot, written.updatedAt);
|
|
575
913
|
}
|
|
576
914
|
}),
|
|
577
915
|
[PROGRESS_KINDS.stat]: checkedKind(readStat, {
|
|
@@ -706,6 +1044,74 @@ function resumeKinds(game, local) {
|
|
|
706
1044
|
}) };
|
|
707
1045
|
}
|
|
708
1046
|
//#endregion
|
|
1047
|
+
//#region src/core/offline/hub-kinds/saved-kind.ts
|
|
1048
|
+
/** The kind name. */
|
|
1049
|
+
const SAVED_KIND = "hub.saved";
|
|
1050
|
+
function readSaved(stored) {
|
|
1051
|
+
if (!isRecord(stored) || stored.action !== "save" && stored.action !== "discard") return;
|
|
1052
|
+
const game = optionalString(stored.game);
|
|
1053
|
+
const day = optionalString(stored.day);
|
|
1054
|
+
const at = optionalNumber(stored.at);
|
|
1055
|
+
return game === void 0 || day === void 0 || at === void 0 ? void 0 : {
|
|
1056
|
+
action: stored.action,
|
|
1057
|
+
game,
|
|
1058
|
+
day,
|
|
1059
|
+
at
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
/** Whether a failure means the instance is gone or closed on the hub (the change is moot). */
|
|
1063
|
+
function isGoneOrClosed(failure) {
|
|
1064
|
+
return failure instanceof HubError && (failure.status === 404 || failure.status === 409);
|
|
1065
|
+
}
|
|
1066
|
+
/** The hub's id for the copy's day: its own, or the one the hub gives the day when it was started offline (`null`: already completed). */
|
|
1067
|
+
async function resolveId(copy, context) {
|
|
1068
|
+
if (!copy.id.startsWith("local:")) return copy.id;
|
|
1069
|
+
return (await context.call("startDailyChallenge", {
|
|
1070
|
+
slug: copy.game,
|
|
1071
|
+
day: copy.day
|
|
1072
|
+
})).instanceId;
|
|
1073
|
+
}
|
|
1074
|
+
/** Run one op against the copy it names. */
|
|
1075
|
+
async function runSaved(op, local, context) {
|
|
1076
|
+
const copy = local.read(op.game, op.day);
|
|
1077
|
+
if (copy === null) return;
|
|
1078
|
+
try {
|
|
1079
|
+
const id = await resolveId(copy, context);
|
|
1080
|
+
if (id === null) {
|
|
1081
|
+
local.remove(op.game, op.day);
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
if (op.action === "discard") {
|
|
1085
|
+
await context.call("discardSavedGame", { id });
|
|
1086
|
+
local.remove(op.game, op.day);
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
if (copy.status === "active") await context.call("saveSavedGameState", {
|
|
1090
|
+
id,
|
|
1091
|
+
state: copy.state,
|
|
1092
|
+
progress: {
|
|
1093
|
+
label: copy.label,
|
|
1094
|
+
fraction: copy.fraction
|
|
1095
|
+
},
|
|
1096
|
+
title: copy.title,
|
|
1097
|
+
version: copy.version,
|
|
1098
|
+
savedAt: copy.at
|
|
1099
|
+
});
|
|
1100
|
+
local.markSynced(op.game, op.day, copy.at, id);
|
|
1101
|
+
} catch (failure) {
|
|
1102
|
+
if (!(failure instanceof Error) || !isGoneOrClosed(failure)) throw failure;
|
|
1103
|
+
local.remove(op.game, op.day);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
/** The saved-games kind, by name, over the device copies in `local`. */
|
|
1107
|
+
function savedKinds(local) {
|
|
1108
|
+
return { [SAVED_KIND]: checkedKind(readSaved, {
|
|
1109
|
+
mergeKey: (op) => `daily:${op.game}#${op.day}`,
|
|
1110
|
+
replaces: true,
|
|
1111
|
+
run: (op, context) => runSaved(op, local, context)
|
|
1112
|
+
}) };
|
|
1113
|
+
}
|
|
1114
|
+
//#endregion
|
|
709
1115
|
//#region src/core/offline/hub-kinds/legacy-ops.ts
|
|
710
1116
|
/** The outbox operation for a v1 queued write, or `undefined` when it no longer reads as one. */
|
|
711
1117
|
function legacyOperation(op) {
|
|
@@ -774,13 +1180,70 @@ function legacyOperation(op) {
|
|
|
774
1180
|
/** Register every hub kind on `outbox`. */
|
|
775
1181
|
function registerHubKinds(outbox, hooks) {
|
|
776
1182
|
const kinds = {
|
|
777
|
-
...progressKinds(hooks.game),
|
|
1183
|
+
...progressKinds(hooks.game, hooks.cloudSaves),
|
|
778
1184
|
...dailyKinds(hooks.game, hooks.onDailyComplete),
|
|
779
1185
|
...economyKinds(hooks.game, writeLocalFavorites),
|
|
780
|
-
...resumeKinds(hooks.game, hooks.resume)
|
|
1186
|
+
...resumeKinds(hooks.game, hooks.resume),
|
|
1187
|
+
...avatarKinds(),
|
|
1188
|
+
...savedKinds(hooks.dailies)
|
|
781
1189
|
};
|
|
782
1190
|
for (const [kind, definition] of Object.entries(kinds)) outbox.define(kind, definition);
|
|
783
1191
|
}
|
|
1192
|
+
//#endregion
|
|
1193
|
+
//#region src/core/offline/cloud-saves.ts
|
|
1194
|
+
/**
|
|
1195
|
+
* What this device last saw of a game's save slots on the hub, per player:
|
|
1196
|
+
* each slot's `updatedAt` there, or `null` for a slot known to be empty. It
|
|
1197
|
+
* decides two things offline. `getSave` answers from the cache only when
|
|
1198
|
+
* the device knows the hub's copy of that slot (so a fresh device or cleared
|
|
1199
|
+
* browser never reports "no save" for a slot it simply hasn't seen), and a
|
|
1200
|
+
* queued save write sends the hub's `updatedAt` it was built on, so the hub
|
|
1201
|
+
* refuses it when the slot has changed since.
|
|
1202
|
+
*
|
|
1203
|
+
* A snapshot gives the full list (`complete`: any other slot is empty); a
|
|
1204
|
+
* single read or write adds that slot to a partial list.
|
|
1205
|
+
*/
|
|
1206
|
+
/** A stored index, or `null` when missing or malformed. */
|
|
1207
|
+
function readCloudSaveIndex(stored) {
|
|
1208
|
+
if (!isRecord(stored) || typeof stored.complete !== "boolean" || !isRecord(stored.saves)) return null;
|
|
1209
|
+
const userId = optionalNumber(stored.userId);
|
|
1210
|
+
if (userId === void 0) return null;
|
|
1211
|
+
const saves = {};
|
|
1212
|
+
for (const [slot, updatedAt] of Object.entries(stored.saves)) {
|
|
1213
|
+
const known = updatedAt === null ? null : optionalNumber(updatedAt);
|
|
1214
|
+
if (known === void 0) return null;
|
|
1215
|
+
saves[slot] = known;
|
|
1216
|
+
}
|
|
1217
|
+
return {
|
|
1218
|
+
userId,
|
|
1219
|
+
complete: stored.complete,
|
|
1220
|
+
saves
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* The hub's `updatedAt` for `slot` as `userId` last saw it: a number, `null`
|
|
1225
|
+
* when the slot was empty, or `undefined` when this device doesn't know.
|
|
1226
|
+
*/
|
|
1227
|
+
function cloudSaveOf(index, userId, slot) {
|
|
1228
|
+
if (index?.userId !== userId) return;
|
|
1229
|
+
const updatedAt = index.saves[slot];
|
|
1230
|
+
return updatedAt === void 0 && index.complete ? null : updatedAt;
|
|
1231
|
+
}
|
|
1232
|
+
/** `index` with `slot` seen at `updatedAt` (`null`: empty) by `userId`; another player's index starts over. */
|
|
1233
|
+
function withCloudSave(index, userId, slot, updatedAt) {
|
|
1234
|
+
const base = index?.userId === userId ? index : {
|
|
1235
|
+
userId,
|
|
1236
|
+
complete: false,
|
|
1237
|
+
saves: {}
|
|
1238
|
+
};
|
|
1239
|
+
return {
|
|
1240
|
+
...base,
|
|
1241
|
+
saves: {
|
|
1242
|
+
...base.saves,
|
|
1243
|
+
[slot]: updatedAt
|
|
1244
|
+
}
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
784
1247
|
/** The well-formed events of a stored buffer. */
|
|
785
1248
|
function readEvents(stored) {
|
|
786
1249
|
if (!Array.isArray(stored)) return [];
|
|
@@ -867,7 +1330,7 @@ function withoutGoods(inventory, goods) {
|
|
|
867
1330
|
//#region src/core/offline/local-store.ts
|
|
868
1331
|
/** The key of the locally known user, shared by every game (like the real session). */
|
|
869
1332
|
const USER_KEY = "hub:user";
|
|
870
|
-
function readUser(stored) {
|
|
1333
|
+
function readUser$1(stored) {
|
|
871
1334
|
if (!isRecord(stored)) return null;
|
|
872
1335
|
const id = optionalNumber(stored.id);
|
|
873
1336
|
const username = optionalString(stored.username);
|
|
@@ -939,7 +1402,7 @@ var LocalStore = class {
|
|
|
939
1402
|
}
|
|
940
1403
|
/** The locally known user. */
|
|
941
1404
|
getUser() {
|
|
942
|
-
return readUser(this.readStored(USER_KEY));
|
|
1405
|
+
return readUser$1(this.readStored(USER_KEY));
|
|
943
1406
|
}
|
|
944
1407
|
/** Remember the user (offline sign-in, or a snapshot). */
|
|
945
1408
|
setUser(user) {
|
|
@@ -994,6 +1457,28 @@ var LocalStore = class {
|
|
|
994
1457
|
deleteSave(slot) {
|
|
995
1458
|
this.store.remove(`${this.prefix}save:${slot}`);
|
|
996
1459
|
}
|
|
1460
|
+
/**
|
|
1461
|
+
* The hub's `updatedAt` for `slot` as the current user last saw it (`null`:
|
|
1462
|
+
* the slot was empty), or `undefined` when this device doesn't know.
|
|
1463
|
+
*/
|
|
1464
|
+
cloudSave(slot) {
|
|
1465
|
+
const user = this.getUser();
|
|
1466
|
+
return user === null ? void 0 : cloudSaveOf(this.readCloudSaves(this.prefix), user.id, slot);
|
|
1467
|
+
}
|
|
1468
|
+
/** Note the hub's copy of `slot` (just read or written) for the current user: its `updatedAt`, or `null` when empty. */
|
|
1469
|
+
rememberCloudSave(slot, updatedAt) {
|
|
1470
|
+
const user = this.getUser();
|
|
1471
|
+
if (user !== null) this.writeStored(`${this.prefix}cloud-saves`, withCloudSave(this.readCloudSaves(this.prefix), user.id, slot, updatedAt));
|
|
1472
|
+
}
|
|
1473
|
+
/** Cache a slot as read from the hub (forgetting it when the hub has none), and note the hub's copy. */
|
|
1474
|
+
cacheCloudSave(slot, save) {
|
|
1475
|
+
if (save === null) this.deleteSave(slot);
|
|
1476
|
+
else this.writeStored(`${this.prefix}save:${slot}`, save);
|
|
1477
|
+
this.rememberCloudSave(slot, save?.updatedAt ?? null);
|
|
1478
|
+
}
|
|
1479
|
+
readCloudSaves(gamePrefix) {
|
|
1480
|
+
return readCloudSaveIndex(this.readStored(`${gamePrefix}cloud-saves`));
|
|
1481
|
+
}
|
|
997
1482
|
/** This game's cached stats. */
|
|
998
1483
|
getStats() {
|
|
999
1484
|
const stored = this.readStored(`${this.prefix}stats`);
|
|
@@ -1070,13 +1555,32 @@ var LocalStore = class {
|
|
|
1070
1555
|
ts: Date.now()
|
|
1071
1556
|
}));
|
|
1072
1557
|
}
|
|
1073
|
-
/**
|
|
1558
|
+
/**
|
|
1559
|
+
* Fan a `/me/snapshot` reply out across every game's cache keys. It lists
|
|
1560
|
+
* every save slot of every game the player has played, so each game's
|
|
1561
|
+
* slots on the hub become known (and this game's are all empty when it
|
|
1562
|
+
* isn't listed).
|
|
1563
|
+
*/
|
|
1074
1564
|
hydrate(snapshot) {
|
|
1075
1565
|
if (snapshot.user !== null) this.writeStored(USER_KEY, snapshot.user);
|
|
1566
|
+
const userId = snapshot.user?.id;
|
|
1567
|
+
if (userId !== void 0 && !Object.keys(snapshot.games).some((slug) => `hub:${slug}:` === this.prefix)) this.writeStored(`${this.prefix}cloud-saves`, {
|
|
1568
|
+
userId,
|
|
1569
|
+
complete: true,
|
|
1570
|
+
saves: {}
|
|
1571
|
+
});
|
|
1076
1572
|
for (const [slug, game] of Object.entries(snapshot.games)) {
|
|
1077
1573
|
const gamePrefix = `hub:${slug}:`;
|
|
1078
1574
|
if (game.profile !== null) this.writeStored(`${gamePrefix}profile`, game.profile);
|
|
1079
1575
|
for (const save of game.saves) this.writeStored(`${gamePrefix}save:${save.slot}`, save);
|
|
1576
|
+
if (userId !== void 0) {
|
|
1577
|
+
const saves = Object.fromEntries(game.saves.map((save) => [save.slot, save.updatedAt]));
|
|
1578
|
+
this.writeStored(`${gamePrefix}cloud-saves`, {
|
|
1579
|
+
userId,
|
|
1580
|
+
complete: true,
|
|
1581
|
+
saves
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1080
1584
|
this.writeStored(`${gamePrefix}stats`, game.stats);
|
|
1081
1585
|
this.writeStored(`${gamePrefix}inventory`, game.inventory);
|
|
1082
1586
|
}
|
|
@@ -1171,7 +1675,7 @@ function newEntryId(now, sequence) {
|
|
|
1171
1675
|
return `${String(now).padStart(TIME_DIGITS, "0")}-${String(sequence % 10 ** SEQUENCE_DIGITS).padStart(SEQUENCE_DIGITS, "0")}-${tag}`;
|
|
1172
1676
|
}
|
|
1173
1677
|
/** A stored entry, or `undefined` when it is malformed. */
|
|
1174
|
-
function readEntry(stored) {
|
|
1678
|
+
function readEntry$1(stored) {
|
|
1175
1679
|
if (!isRecord(stored) || !isJson(stored.payload)) return;
|
|
1176
1680
|
const id = optionalString(stored.id);
|
|
1177
1681
|
const kind = optionalString(stored.kind);
|
|
@@ -1394,7 +1898,7 @@ var OutboxStore = class {
|
|
|
1394
1898
|
const entries = [];
|
|
1395
1899
|
for (const key of this.entryKeys()) {
|
|
1396
1900
|
const text = this.store.read(key);
|
|
1397
|
-
const entry = text === null ? void 0 : readEntry(parseJsonText(text));
|
|
1901
|
+
const entry = text === null ? void 0 : readEntry$1(parseJsonText(text));
|
|
1398
1902
|
if (entry === void 0) this.store.remove(key);
|
|
1399
1903
|
else entries.push(entry);
|
|
1400
1904
|
}
|
|
@@ -1407,7 +1911,7 @@ var OutboxStore = class {
|
|
|
1407
1911
|
/** One entry, or `undefined` once it is gone (replayed, or removed by another tab). */
|
|
1408
1912
|
get(id) {
|
|
1409
1913
|
const text = this.store.read(this.prefix + id);
|
|
1410
|
-
return text === null ? void 0 : readEntry(parseJsonText(text));
|
|
1914
|
+
return text === null ? void 0 : readEntry$1(parseJsonText(text));
|
|
1411
1915
|
}
|
|
1412
1916
|
/** Whether `extra` more stored characters in one more entry still fit under the caps. */
|
|
1413
1917
|
fits(extraChars) {
|
|
@@ -1662,8 +2166,8 @@ function outboxFull() {
|
|
|
1662
2166
|
}
|
|
1663
2167
|
//#endregion
|
|
1664
2168
|
//#region src/core/resume/local-resume.ts
|
|
1665
|
-
const KEY_PATTERN = /^hub:(.+):resume$/;
|
|
1666
|
-
function keyFor(slug) {
|
|
2169
|
+
const KEY_PATTERN$1 = /^hub:(.+):resume$/;
|
|
2170
|
+
function keyFor$1(slug) {
|
|
1667
2171
|
return `hub:${slug}:resume`;
|
|
1668
2172
|
}
|
|
1669
2173
|
/** A stored record read back, or `null` when missing or malformed. */
|
|
@@ -1705,18 +2209,18 @@ var LocalResumeStore = class {
|
|
|
1705
2209
|
}
|
|
1706
2210
|
/** `slug`'s copy, or `null`. */
|
|
1707
2211
|
read(slug) {
|
|
1708
|
-
const text = this.store.read(keyFor(slug));
|
|
2212
|
+
const text = this.store.read(keyFor$1(slug));
|
|
1709
2213
|
return text === null ? null : readRecord(parseJsonText(text));
|
|
1710
2214
|
}
|
|
1711
2215
|
/** Store `slug`'s copy; answers whether it stuck (a full storage refuses a large state). */
|
|
1712
2216
|
write(slug, record) {
|
|
1713
2217
|
const text = JSON.stringify(record);
|
|
1714
|
-
this.store.write(keyFor(slug), text);
|
|
1715
|
-
return this.store.read(keyFor(slug)) === text;
|
|
2218
|
+
this.store.write(keyFor$1(slug), text);
|
|
2219
|
+
return this.store.read(keyFor$1(slug)) === text;
|
|
1716
2220
|
}
|
|
1717
2221
|
/** Forget `slug`'s copy. */
|
|
1718
2222
|
remove(slug) {
|
|
1719
|
-
this.store.remove(keyFor(slug));
|
|
2223
|
+
this.store.remove(keyFor$1(slug));
|
|
1720
2224
|
}
|
|
1721
2225
|
/** The hub now has `slug`'s save or clear made at `at`: mark it synced (a clear is simply forgotten). */
|
|
1722
2226
|
markSynced(slug, at) {
|
|
@@ -1732,7 +2236,7 @@ var LocalResumeStore = class {
|
|
|
1732
2236
|
all() {
|
|
1733
2237
|
const records = /* @__PURE__ */ new Map();
|
|
1734
2238
|
for (const key of this.store.keys()) {
|
|
1735
|
-
const slug = KEY_PATTERN.exec(key)?.[1];
|
|
2239
|
+
const slug = KEY_PATTERN$1.exec(key)?.[1];
|
|
1736
2240
|
const record = slug === void 0 ? null : this.read(slug);
|
|
1737
2241
|
if (slug !== void 0 && record !== null) records.set(slug, record);
|
|
1738
2242
|
}
|
|
@@ -1740,26 +2244,118 @@ var LocalResumeStore = class {
|
|
|
1740
2244
|
}
|
|
1741
2245
|
};
|
|
1742
2246
|
//#endregion
|
|
1743
|
-
//#region src/core/
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
2247
|
+
//#region src/core/saved/local-saved.ts
|
|
2248
|
+
const KEY_PATTERN = /^hub:(.+):daily:(\d{4}-\d{2}-\d{2})$/;
|
|
2249
|
+
const STATUSES = [
|
|
2250
|
+
"active",
|
|
2251
|
+
"finished",
|
|
2252
|
+
"discarded"
|
|
2253
|
+
];
|
|
2254
|
+
function keyFor(game, day) {
|
|
2255
|
+
return `hub:${game}:daily:${day}`;
|
|
1747
2256
|
}
|
|
1748
|
-
/**
|
|
1749
|
-
function
|
|
1750
|
-
if (!
|
|
1751
|
-
const
|
|
1752
|
-
const
|
|
2257
|
+
/** A stored copy read back, or `null` when missing or malformed. */
|
|
2258
|
+
function readCopy(stored) {
|
|
2259
|
+
if (!isRecord(stored)) return null;
|
|
2260
|
+
const id = optionalString(stored.id);
|
|
2261
|
+
const game = optionalString(stored.game);
|
|
2262
|
+
const day = optionalString(stored.day);
|
|
2263
|
+
const seed = optionalNumber(stored.seed);
|
|
2264
|
+
const at = optionalNumber(stored.at);
|
|
2265
|
+
const label = optionalString(stored.label);
|
|
2266
|
+
const version = optionalNumber(stored.version);
|
|
2267
|
+
const gameTitle = optionalString(stored.gameTitle);
|
|
2268
|
+
const userId = stored.userId === null ? null : optionalNumber(stored.userId);
|
|
2269
|
+
const fraction = stored.fraction === null ? null : optionalNumber(stored.fraction);
|
|
2270
|
+
const status = STATUSES.find((candidate) => candidate === stored.status);
|
|
2271
|
+
if (id === void 0 || game === void 0 || day === void 0 || seed === void 0 || at === void 0 || label === void 0 || version === void 0 || gameTitle === void 0 || userId === void 0 || fraction === void 0 || status === void 0 || !isNullableString(stored.title) || !isJson(stored.state)) return null;
|
|
1753
2272
|
return {
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
2273
|
+
id,
|
|
2274
|
+
game,
|
|
2275
|
+
day,
|
|
2276
|
+
seed,
|
|
2277
|
+
status,
|
|
2278
|
+
userId,
|
|
2279
|
+
at,
|
|
2280
|
+
label,
|
|
2281
|
+
fraction,
|
|
2282
|
+
title: stored.title,
|
|
2283
|
+
version,
|
|
2284
|
+
gameTitle,
|
|
2285
|
+
state: stored.state,
|
|
2286
|
+
isSynced: stored.isSynced === true
|
|
2287
|
+
};
|
|
2288
|
+
}
|
|
2289
|
+
/** The device copies of daily challenges. */
|
|
2290
|
+
var LocalDailyStore = class {
|
|
2291
|
+
constructor(store) {
|
|
2292
|
+
_defineProperty(this, "store", void 0);
|
|
2293
|
+
this.store = store;
|
|
2294
|
+
}
|
|
2295
|
+
/** The copy of `game`'s `day`, or `null`. */
|
|
2296
|
+
read(game, day) {
|
|
2297
|
+
const text = this.store.read(keyFor(game, day));
|
|
2298
|
+
return text === null ? null : readCopy(parseJsonText(text));
|
|
2299
|
+
}
|
|
2300
|
+
/** Store a copy; answers whether it stuck (a full storage refuses a large state). */
|
|
2301
|
+
write(copy) {
|
|
2302
|
+
const text = JSON.stringify(copy);
|
|
2303
|
+
this.store.write(keyFor(copy.game, copy.day), text);
|
|
2304
|
+
return this.store.read(keyFor(copy.game, copy.day)) === text;
|
|
2305
|
+
}
|
|
2306
|
+
/** Forget a copy. */
|
|
2307
|
+
remove(game, day) {
|
|
2308
|
+
this.store.remove(keyFor(game, day));
|
|
2309
|
+
}
|
|
2310
|
+
/** The hub has the change made at `at` (and calls the instance `id`): mark it synced; a closed copy is forgotten. */
|
|
2311
|
+
markSynced(game, day, at, id) {
|
|
2312
|
+
const copy = this.read(game, day);
|
|
2313
|
+
if (copy === null || copy.at !== at) {
|
|
2314
|
+
if (copy !== null && id !== void 0 && copy.id !== id) this.write({
|
|
2315
|
+
...copy,
|
|
2316
|
+
id
|
|
2317
|
+
});
|
|
2318
|
+
return;
|
|
2319
|
+
}
|
|
2320
|
+
if (copy.status === "active") this.write({
|
|
2321
|
+
...copy,
|
|
2322
|
+
id: id ?? copy.id,
|
|
2323
|
+
isSynced: true
|
|
2324
|
+
});
|
|
2325
|
+
else this.remove(game, day);
|
|
2326
|
+
}
|
|
2327
|
+
/** Every copy of `userId`'s, any game. */
|
|
2328
|
+
all(userId) {
|
|
2329
|
+
const copies = [];
|
|
2330
|
+
for (const key of this.store.keys()) {
|
|
2331
|
+
const match = KEY_PATTERN.exec(key);
|
|
2332
|
+
const copy = match?.[1] === void 0 || match[2] === void 0 ? null : this.read(match[1], match[2]);
|
|
2333
|
+
if (copy !== null && copy.userId === userId) copies.push(copy);
|
|
2334
|
+
}
|
|
2335
|
+
return copies;
|
|
2336
|
+
}
|
|
2337
|
+
};
|
|
2338
|
+
//#endregion
|
|
2339
|
+
//#region src/core/page.ts
|
|
2340
|
+
/** Whether this code runs in a browser page. */
|
|
2341
|
+
function isBrowserPage() {
|
|
2342
|
+
return typeof window !== "undefined";
|
|
2343
|
+
}
|
|
2344
|
+
/** The injected config's slug and API base, read defensively (empty outside a hub page). */
|
|
2345
|
+
function injectedConfig() {
|
|
2346
|
+
if (!isBrowserPage() || !isRecord(window.__HUB__)) return {};
|
|
2347
|
+
const slug = optionalString(window.__HUB__.slug);
|
|
2348
|
+
const apiBase = optionalString(window.__HUB__.apiBase);
|
|
2349
|
+
return {
|
|
2350
|
+
...slug === void 0 ? {} : { slug },
|
|
2351
|
+
...apiBase === void 0 ? {} : { apiBase }
|
|
2352
|
+
};
|
|
2353
|
+
}
|
|
2354
|
+
/** The game's display title: the host's injected one, else the page's `<title>`, else `fallback`. */
|
|
2355
|
+
function detectGameTitle(fallback) {
|
|
2356
|
+
const injected = isBrowserPage() && isRecord(window.__HUB__) ? optionalString(window.__HUB__.title) : void 0;
|
|
2357
|
+
if (injected !== void 0 && injected !== "") return injected;
|
|
2358
|
+
const pageTitle = typeof document === "undefined" ? "" : document.title.trim();
|
|
1763
2359
|
return pageTitle === "" ? fallback : pageTitle;
|
|
1764
2360
|
}
|
|
1765
2361
|
/** The game's slug: the injected one, else the first URL path segment, else `null`. */
|
|
@@ -1969,6 +2565,12 @@ const ROUTES = {
|
|
|
1969
2565
|
query: [],
|
|
1970
2566
|
offline: null
|
|
1971
2567
|
},
|
|
2568
|
+
resumeDailyChallenge: {
|
|
2569
|
+
method: "POST",
|
|
2570
|
+
path: "/daily/instances/:id/resume",
|
|
2571
|
+
query: [],
|
|
2572
|
+
offline: null
|
|
2573
|
+
},
|
|
1972
2574
|
startDailyChallenge: {
|
|
1973
2575
|
method: "POST",
|
|
1974
2576
|
path: "/games/:slug/daily/:day/start",
|
|
@@ -2209,6 +2811,12 @@ const ROUTES = {
|
|
|
2209
2811
|
query: [],
|
|
2210
2812
|
offline: null
|
|
2211
2813
|
},
|
|
2814
|
+
setQuietHours: {
|
|
2815
|
+
method: "PUT",
|
|
2816
|
+
path: "/social/notify/quiet",
|
|
2817
|
+
query: [],
|
|
2818
|
+
offline: null
|
|
2819
|
+
},
|
|
2212
2820
|
subscribeToPush: {
|
|
2213
2821
|
method: "POST",
|
|
2214
2822
|
path: "/social/push",
|
|
@@ -2441,6 +3049,174 @@ const ROUTES = {
|
|
|
2441
3049
|
path: "/me/resume",
|
|
2442
3050
|
query: [],
|
|
2443
3051
|
offline: null
|
|
3052
|
+
},
|
|
3053
|
+
getMyAvatar: {
|
|
3054
|
+
method: "GET",
|
|
3055
|
+
path: "/avatar/me",
|
|
3056
|
+
query: [],
|
|
3057
|
+
offline: null
|
|
3058
|
+
},
|
|
3059
|
+
recordAvatarNudge: {
|
|
3060
|
+
method: "POST",
|
|
3061
|
+
path: "/avatar/me/nudge",
|
|
3062
|
+
query: [],
|
|
3063
|
+
offline: null
|
|
3064
|
+
},
|
|
3065
|
+
saveMyAvatar: {
|
|
3066
|
+
method: "PUT",
|
|
3067
|
+
path: "/avatar/me",
|
|
3068
|
+
query: [],
|
|
3069
|
+
offline: null
|
|
3070
|
+
},
|
|
3071
|
+
resetMyAvatar: {
|
|
3072
|
+
method: "DELETE",
|
|
3073
|
+
path: "/avatar/me",
|
|
3074
|
+
query: [],
|
|
3075
|
+
offline: null
|
|
3076
|
+
},
|
|
3077
|
+
getAvatar: {
|
|
3078
|
+
method: "GET",
|
|
3079
|
+
path: "/avatar/of/:id",
|
|
3080
|
+
query: [],
|
|
3081
|
+
offline: null
|
|
3082
|
+
},
|
|
3083
|
+
getAvatars: {
|
|
3084
|
+
method: "POST",
|
|
3085
|
+
path: "/avatar/batch",
|
|
3086
|
+
query: [],
|
|
3087
|
+
offline: null
|
|
3088
|
+
},
|
|
3089
|
+
listMySavedGames: {
|
|
3090
|
+
method: "GET",
|
|
3091
|
+
path: "/saved",
|
|
3092
|
+
query: ["game", "kinds"],
|
|
3093
|
+
offline: null
|
|
3094
|
+
},
|
|
3095
|
+
getSavedGame: {
|
|
3096
|
+
method: "GET",
|
|
3097
|
+
path: "/saved/:id",
|
|
3098
|
+
query: [],
|
|
3099
|
+
offline: null
|
|
3100
|
+
},
|
|
3101
|
+
saveSavedGameState: {
|
|
3102
|
+
method: "PUT",
|
|
3103
|
+
path: "/saved/:id/state",
|
|
3104
|
+
query: [],
|
|
3105
|
+
offline: "replay"
|
|
3106
|
+
},
|
|
3107
|
+
discardSavedGame: {
|
|
3108
|
+
method: "POST",
|
|
3109
|
+
path: "/saved/:id/discard",
|
|
3110
|
+
query: [],
|
|
3111
|
+
offline: "replay"
|
|
3112
|
+
},
|
|
3113
|
+
discardGameSavedGames: {
|
|
3114
|
+
method: "POST",
|
|
3115
|
+
path: "/games/:slug/saved/discard",
|
|
3116
|
+
query: [],
|
|
3117
|
+
offline: null
|
|
3118
|
+
},
|
|
3119
|
+
createTurnGame: {
|
|
3120
|
+
method: "POST",
|
|
3121
|
+
path: "/turns",
|
|
3122
|
+
query: [],
|
|
3123
|
+
offline: null
|
|
3124
|
+
},
|
|
3125
|
+
joinTurnGame: {
|
|
3126
|
+
method: "POST",
|
|
3127
|
+
path: "/turns/:id/join",
|
|
3128
|
+
query: [],
|
|
3129
|
+
offline: null
|
|
3130
|
+
},
|
|
3131
|
+
joinTurnGameByLink: {
|
|
3132
|
+
method: "POST",
|
|
3133
|
+
path: "/turns/join",
|
|
3134
|
+
query: [],
|
|
3135
|
+
offline: null
|
|
3136
|
+
},
|
|
3137
|
+
startTurnGame: {
|
|
3138
|
+
method: "POST",
|
|
3139
|
+
path: "/turns/:id/start",
|
|
3140
|
+
query: [],
|
|
3141
|
+
offline: null
|
|
3142
|
+
},
|
|
3143
|
+
moveTurnGame: {
|
|
3144
|
+
method: "POST",
|
|
3145
|
+
path: "/turns/:id/moves",
|
|
3146
|
+
query: [],
|
|
3147
|
+
offline: null
|
|
3148
|
+
},
|
|
3149
|
+
leaveTurnGame: {
|
|
3150
|
+
method: "POST",
|
|
3151
|
+
path: "/turns/:id/leave",
|
|
3152
|
+
query: [],
|
|
3153
|
+
offline: null
|
|
3154
|
+
},
|
|
3155
|
+
nudgeTurnGame: {
|
|
3156
|
+
method: "POST",
|
|
3157
|
+
path: "/turns/:id/nudge",
|
|
3158
|
+
query: [],
|
|
3159
|
+
offline: null
|
|
3160
|
+
},
|
|
3161
|
+
setTurnGameNotify: {
|
|
3162
|
+
method: "PUT",
|
|
3163
|
+
path: "/turns/:id/notify",
|
|
3164
|
+
query: [],
|
|
3165
|
+
offline: null
|
|
3166
|
+
},
|
|
3167
|
+
rematchTurnGame: {
|
|
3168
|
+
method: "POST",
|
|
3169
|
+
path: "/turns/:id/rematch",
|
|
3170
|
+
query: [],
|
|
3171
|
+
offline: null
|
|
3172
|
+
},
|
|
3173
|
+
inviteToTurnGame: {
|
|
3174
|
+
method: "POST",
|
|
3175
|
+
path: "/turns/:id/invite",
|
|
3176
|
+
query: [],
|
|
3177
|
+
offline: null
|
|
3178
|
+
},
|
|
3179
|
+
shareTurnGame: {
|
|
3180
|
+
method: "POST",
|
|
3181
|
+
path: "/turns/:id/share",
|
|
3182
|
+
query: [],
|
|
3183
|
+
offline: null
|
|
3184
|
+
},
|
|
3185
|
+
autoPlayTurn: {
|
|
3186
|
+
method: "POST",
|
|
3187
|
+
path: "/turns/:id/auto",
|
|
3188
|
+
query: [],
|
|
3189
|
+
offline: null
|
|
3190
|
+
},
|
|
3191
|
+
chatInTurnGame: {
|
|
3192
|
+
method: "POST",
|
|
3193
|
+
path: "/turns/:id/chat",
|
|
3194
|
+
query: [],
|
|
3195
|
+
offline: null
|
|
3196
|
+
},
|
|
3197
|
+
getTurnGame: {
|
|
3198
|
+
method: "GET",
|
|
3199
|
+
path: "/turns/:id",
|
|
3200
|
+
query: [],
|
|
3201
|
+
offline: null
|
|
3202
|
+
},
|
|
3203
|
+
getTurnGameHistory: {
|
|
3204
|
+
method: "GET",
|
|
3205
|
+
path: "/turns/:id/history",
|
|
3206
|
+
query: ["since"],
|
|
3207
|
+
offline: null
|
|
3208
|
+
},
|
|
3209
|
+
listOpenTurnGames: {
|
|
3210
|
+
method: "GET",
|
|
3211
|
+
path: "/open-turns",
|
|
3212
|
+
query: ["game"],
|
|
3213
|
+
offline: null
|
|
3214
|
+
},
|
|
3215
|
+
listMyTurnGames: {
|
|
3216
|
+
method: "GET",
|
|
3217
|
+
path: "/turns",
|
|
3218
|
+
query: ["game"],
|
|
3219
|
+
offline: null
|
|
2444
3220
|
}
|
|
2445
3221
|
};
|
|
2446
3222
|
/** The request header that carries an offline operation key (the contract's `OFFLINE_OP_HEADER`). */
|
|
@@ -2565,6 +3341,7 @@ var HubConnection = class {
|
|
|
2565
3341
|
_defineProperty(this, "store", void 0);
|
|
2566
3342
|
_defineProperty(this, "outbox", void 0);
|
|
2567
3343
|
_defineProperty(this, "resumeStore", void 0);
|
|
3344
|
+
_defineProperty(this, "dailyStore", void 0);
|
|
2568
3345
|
_defineProperty(this, "offlineMode", void 0);
|
|
2569
3346
|
_defineProperty(this, "isReachable", true);
|
|
2570
3347
|
_defineProperty(this, "runningSync", null);
|
|
@@ -2587,6 +3364,7 @@ var HubConnection = class {
|
|
|
2587
3364
|
const pageStore = createPageStore();
|
|
2588
3365
|
this.store = new LocalStore(this.slug ?? "app", pageStore);
|
|
2589
3366
|
this.resumeStore = new LocalResumeStore(pageStore);
|
|
3367
|
+
this.dailyStore = new LocalDailyStore(pageStore);
|
|
2590
3368
|
this.outbox = new Outbox({
|
|
2591
3369
|
slug: this.slug ?? "app",
|
|
2592
3370
|
store: pageStore,
|
|
@@ -2605,6 +3383,7 @@ var HubConnection = class {
|
|
|
2605
3383
|
registerHubKinds(this.outbox, {
|
|
2606
3384
|
game: () => this.gameFields(),
|
|
2607
3385
|
onDailyComplete: (payload, completion) => {
|
|
3386
|
+
if (this.slug !== null) this.dailyStore.remove(this.slug, payload.day);
|
|
2608
3387
|
emitWindowEvent("hub:daily-complete", {
|
|
2609
3388
|
day: payload.day,
|
|
2610
3389
|
count: completion.count,
|
|
@@ -2612,7 +3391,9 @@ var HubConnection = class {
|
|
|
2612
3391
|
meta: payload.meta
|
|
2613
3392
|
});
|
|
2614
3393
|
},
|
|
2615
|
-
resume: this.resumeStore
|
|
3394
|
+
resume: this.resumeStore,
|
|
3395
|
+
dailies: this.dailyStore,
|
|
3396
|
+
cloudSaves: this.store
|
|
2616
3397
|
});
|
|
2617
3398
|
this.importLegacyQueue();
|
|
2618
3399
|
if (typeof navigator !== "undefined" && "onLine" in navigator) this.isReachable = navigator.onLine;
|
|
@@ -2844,6 +3625,93 @@ var HubConnection = class {
|
|
|
2844
3625
|
}
|
|
2845
3626
|
};
|
|
2846
3627
|
//#endregion
|
|
3628
|
+
//#region src/core/page-account.ts
|
|
3629
|
+
/**
|
|
3630
|
+
* Who the page is signed in as, as the hub tells it, so `hub.userId()` and
|
|
3631
|
+
* `hub.avatar` follow the account without the game calling `hub.me()`:
|
|
3632
|
+
*
|
|
3633
|
+
* - the menu's `hub:account` (every refresh: after it makes a new guest's
|
|
3634
|
+
* account, after sign-in) and `hub:auth` (the account changed);
|
|
3635
|
+
* - the social runtime's {@link SESSION_EVENT} each time its socket says
|
|
3636
|
+
* hello (connected, or connected again after a drop);
|
|
3637
|
+
* - this client's own sign-in, sign-up, claim and sign-out.
|
|
3638
|
+
*
|
|
3639
|
+
* One per page: every bundle's copy hears the same window events.
|
|
3640
|
+
*/
|
|
3641
|
+
/** The window events that say who is signed in (`{ user }`). */
|
|
3642
|
+
const ACCOUNT_EVENTS = ["hub:account", "hub:auth"];
|
|
3643
|
+
/** The page's account, as last heard. */
|
|
3644
|
+
var PageAccount = class {
|
|
3645
|
+
constructor() {
|
|
3646
|
+
_defineProperty(this, "heard", void 0);
|
|
3647
|
+
_defineProperty(this, "listeners", /* @__PURE__ */ new Set());
|
|
3648
|
+
_defineProperty(this, "isListening", false);
|
|
3649
|
+
this.listen();
|
|
3650
|
+
}
|
|
3651
|
+
/** The signed-in player, `null` when signed out, `undefined` before anything said. */
|
|
3652
|
+
get user() {
|
|
3653
|
+
this.listen();
|
|
3654
|
+
return this.heard;
|
|
3655
|
+
}
|
|
3656
|
+
/** Record who is signed in (this client's own sign-in or sign-out) and tell the listeners. */
|
|
3657
|
+
note(user, source = "account") {
|
|
3658
|
+
this.heard = user;
|
|
3659
|
+
for (const listener of [...this.listeners]) listener(user, source);
|
|
3660
|
+
}
|
|
3661
|
+
/** Hear every report; returns the unsubscribe function. */
|
|
3662
|
+
on(listener) {
|
|
3663
|
+
this.listen();
|
|
3664
|
+
this.listeners.add(listener);
|
|
3665
|
+
return () => {
|
|
3666
|
+
this.listeners.delete(listener);
|
|
3667
|
+
};
|
|
3668
|
+
}
|
|
3669
|
+
/** Listen to the window events (at once in a page; made before a window existed, on first use). */
|
|
3670
|
+
listen() {
|
|
3671
|
+
if (this.isListening || !isBrowserPage()) return;
|
|
3672
|
+
this.isListening = true;
|
|
3673
|
+
for (const eventName of ACCOUNT_EVENTS) window.addEventListener(eventName, (event) => {
|
|
3674
|
+
this.onEvent(event, "account");
|
|
3675
|
+
});
|
|
3676
|
+
window.addEventListener(SESSION_EVENT, (event) => {
|
|
3677
|
+
this.onEvent(event, "session");
|
|
3678
|
+
});
|
|
3679
|
+
}
|
|
3680
|
+
onEvent(event, source) {
|
|
3681
|
+
if (!(event instanceof CustomEvent)) return;
|
|
3682
|
+
const user = readUser(event.detail);
|
|
3683
|
+
if (user !== void 0) this.note(user, source);
|
|
3684
|
+
}
|
|
3685
|
+
};
|
|
3686
|
+
/** A page user from an account the hub sent (`look` when it has one). */
|
|
3687
|
+
function toPageUser(user) {
|
|
3688
|
+
return {
|
|
3689
|
+
id: user.id,
|
|
3690
|
+
look: lookOrNull(user.look)
|
|
3691
|
+
};
|
|
3692
|
+
}
|
|
3693
|
+
/** A non-empty look code, else `null`. */
|
|
3694
|
+
function lookOrNull(look) {
|
|
3695
|
+
return typeof look === "string" && look !== "" ? look : null;
|
|
3696
|
+
}
|
|
3697
|
+
/** The user in a `{ user }` detail: the user, `null` (signed out), or `undefined` (it doesn't say). */
|
|
3698
|
+
function readUser(detail) {
|
|
3699
|
+
const user = readField(detail, "user");
|
|
3700
|
+
if (user === null) return null;
|
|
3701
|
+
const id = readField(user, "id");
|
|
3702
|
+
if (typeof id !== "number" || !Number.isInteger(id)) return;
|
|
3703
|
+
return {
|
|
3704
|
+
id,
|
|
3705
|
+
look: lookOrNull(readField(user, "look"))
|
|
3706
|
+
};
|
|
3707
|
+
}
|
|
3708
|
+
let pageAccountSingleton = null;
|
|
3709
|
+
/** The page's account tracker (made on first use; the hub client makes it at load). */
|
|
3710
|
+
function pageAccount() {
|
|
3711
|
+
pageAccountSingleton ?? (pageAccountSingleton = new PageAccount());
|
|
3712
|
+
return pageAccountSingleton;
|
|
3713
|
+
}
|
|
3714
|
+
//#endregion
|
|
2847
3715
|
//#region src/core/hub-progress.ts
|
|
2848
3716
|
/** A profile with the game's own data typed as `TData`. */
|
|
2849
3717
|
function typedProfile(profile) {
|
|
@@ -2861,9 +3729,29 @@ function typedSave(save) {
|
|
|
2861
3729
|
}
|
|
2862
3730
|
/** Accounts, profile, saves, stats and leaderboards. */
|
|
2863
3731
|
var HubProgress = class extends HubConnection {
|
|
3732
|
+
constructor(..._args) {
|
|
3733
|
+
super(..._args);
|
|
3734
|
+
_defineProperty(this, "account", pageAccount());
|
|
3735
|
+
}
|
|
3736
|
+
/**
|
|
3737
|
+
* Your user id, or `null` when nobody is signed in (also before an offline
|
|
3738
|
+
* first visit has an account). Synchronous, and it follows the account as
|
|
3739
|
+
* the hub changes it (a new guest's account made by the menu, sign-in,
|
|
3740
|
+
* sign-out) with no `hub.me()` needed. Compare it with a seat's `userId`
|
|
3741
|
+
* to find yours.
|
|
3742
|
+
*/
|
|
3743
|
+
userId() {
|
|
3744
|
+
const heard = this.account.user;
|
|
3745
|
+
const id = heard === void 0 ? this.store.getUser()?.id ?? null : heard?.id ?? null;
|
|
3746
|
+
return id === 0 ? null : id;
|
|
3747
|
+
}
|
|
2864
3748
|
/** The current user (or `null`) and the games they have a profile in. */
|
|
2865
3749
|
me() {
|
|
2866
|
-
return this.withFallback(
|
|
3750
|
+
return this.withFallback(async () => {
|
|
3751
|
+
const reply = await this.rest.call("getCurrentUser");
|
|
3752
|
+
this.account.note(reply.user === null ? null : toPageUser(reply.user));
|
|
3753
|
+
return reply;
|
|
3754
|
+
}, () => {
|
|
2867
3755
|
const profile = this.store.getProfile();
|
|
2868
3756
|
return {
|
|
2869
3757
|
user: this.store.getUser(),
|
|
@@ -2880,16 +3768,14 @@ var HubProgress = class extends HubConnection {
|
|
|
2880
3768
|
* sign-in. Idempotent. Offline, a local guest is used.
|
|
2881
3769
|
*/
|
|
2882
3770
|
ensureGuest() {
|
|
2883
|
-
return this.withFallback(() => this.rest.call("ensureGuestSession"), () => {
|
|
3771
|
+
return this.withFallback(async () => this.signedIn(await this.rest.call("ensureGuestSession")), () => {
|
|
2884
3772
|
const known = this.store.getUser();
|
|
2885
3773
|
if (known !== null) return { user: known };
|
|
2886
|
-
|
|
3774
|
+
return this.signInLocally({
|
|
2887
3775
|
id: 0,
|
|
2888
3776
|
username: "Guest-local",
|
|
2889
3777
|
guest: true
|
|
2890
|
-
};
|
|
2891
|
-
this.store.setUser(guest);
|
|
2892
|
-
return { user: guest };
|
|
3778
|
+
});
|
|
2893
3779
|
});
|
|
2894
3780
|
}
|
|
2895
3781
|
/**
|
|
@@ -2898,10 +3784,10 @@ var HubProgress = class extends HubConnection {
|
|
|
2898
3784
|
* the name is taken; the player should `login` instead.
|
|
2899
3785
|
*/
|
|
2900
3786
|
claim(username, password) {
|
|
2901
|
-
return this.withFallback(() => this.rest.call("claimGuestAccount", {
|
|
3787
|
+
return this.withFallback(async () => this.signedIn(await this.rest.call("claimGuestAccount", {
|
|
2902
3788
|
username,
|
|
2903
3789
|
password
|
|
2904
|
-
}), () => this.signInLocally({
|
|
3790
|
+
})), () => this.signInLocally({
|
|
2905
3791
|
id: 0,
|
|
2906
3792
|
username,
|
|
2907
3793
|
guest: false
|
|
@@ -2909,35 +3795,53 @@ var HubProgress = class extends HubConnection {
|
|
|
2909
3795
|
}
|
|
2910
3796
|
/** Create an account and sign in. (Offline: a local user is stored.) */
|
|
2911
3797
|
signup(username, password) {
|
|
2912
|
-
return this.withFallback(() => this.rest.call("signUpWithPassword", {
|
|
3798
|
+
return this.withFallback(async () => this.signedIn(await this.rest.call("signUpWithPassword", {
|
|
2913
3799
|
username,
|
|
2914
3800
|
password
|
|
2915
|
-
}), () => this.signInLocally({
|
|
3801
|
+
})), () => this.signInLocally({
|
|
2916
3802
|
id: 0,
|
|
2917
3803
|
username
|
|
2918
3804
|
}));
|
|
2919
3805
|
}
|
|
2920
3806
|
/** Sign in. (Offline: a local user is stored so signed-in flows work.) */
|
|
2921
3807
|
login(username, password) {
|
|
2922
|
-
return this.withFallback(() => this.rest.call("logInWithPassword", {
|
|
3808
|
+
return this.withFallback(async () => this.signedIn(await this.rest.call("logInWithPassword", {
|
|
2923
3809
|
username,
|
|
2924
3810
|
password
|
|
2925
|
-
}), () => this.signInLocally({
|
|
3811
|
+
})), () => this.signInLocally({
|
|
2926
3812
|
id: 0,
|
|
2927
3813
|
username
|
|
2928
3814
|
}));
|
|
2929
3815
|
}
|
|
2930
3816
|
/** Sign out (ends the session shared by every game). */
|
|
2931
3817
|
logout() {
|
|
2932
|
-
return this.withFallback(
|
|
2933
|
-
this.
|
|
3818
|
+
return this.withFallback(async () => {
|
|
3819
|
+
const reply = await this.rest.call("logOut");
|
|
3820
|
+
this.signedOut();
|
|
3821
|
+
return reply;
|
|
3822
|
+
}, () => {
|
|
3823
|
+
this.signedOut();
|
|
2934
3824
|
return { ok: true };
|
|
2935
3825
|
});
|
|
2936
3826
|
}
|
|
2937
3827
|
signInLocally(user) {
|
|
2938
|
-
this.
|
|
3828
|
+
this.remember(user);
|
|
2939
3829
|
return { user };
|
|
2940
3830
|
}
|
|
3831
|
+
/** Remember who the hub signed in, so the cache's notes about the hub's copies are read for the right player. */
|
|
3832
|
+
signedIn(reply) {
|
|
3833
|
+
this.remember(reply.user);
|
|
3834
|
+
return reply;
|
|
3835
|
+
}
|
|
3836
|
+
/** The cache and the page (`hub.userId()`, `hub.avatar`) learn who is signed in. */
|
|
3837
|
+
remember(user) {
|
|
3838
|
+
this.store.setUser(user);
|
|
3839
|
+
this.account.note(toPageUser(user));
|
|
3840
|
+
}
|
|
3841
|
+
signedOut() {
|
|
3842
|
+
this.store.clearUser();
|
|
3843
|
+
this.account.note(null);
|
|
3844
|
+
}
|
|
2941
3845
|
/** This game's profile for the current user, or `profile: null`. `TData` is the game's own data type. */
|
|
2942
3846
|
getProfile() {
|
|
2943
3847
|
return this.withFallback(async () => ({ profile: typedProfile((await this.rest.call("getGameProfile", this.gameFields())).profile) }), () => ({ profile: typedProfile(this.store.getProfile()) }));
|
|
@@ -2960,12 +3864,29 @@ var HubProgress = class extends HubConnection {
|
|
|
2960
3864
|
listSaves() {
|
|
2961
3865
|
return this.withFallback(() => this.rest.call("listSaves", this.gameFields()), () => ({ saves: this.store.listSaves() }));
|
|
2962
3866
|
}
|
|
2963
|
-
/**
|
|
3867
|
+
/**
|
|
3868
|
+
* One slot's contents, or `save: null` when the slot is empty. `TData` is
|
|
3869
|
+
* the game's own save type. Offline it answers from the device's copy, but
|
|
3870
|
+
* only when the device knows the hub's copy of the slot; otherwise (a fresh
|
|
3871
|
+
* device or cleared browser) it rejects with a {@link HubError} whose
|
|
3872
|
+
* `code` is `save_unknown` (`isOffline` is `true`), never a false "empty".
|
|
3873
|
+
*/
|
|
2964
3874
|
getSave(slot) {
|
|
2965
|
-
return this.withFallback(async () =>
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
3875
|
+
return this.withFallback(async () => {
|
|
3876
|
+
const { save } = await this.rest.call("getSave", {
|
|
3877
|
+
...this.gameFields(),
|
|
3878
|
+
slot
|
|
3879
|
+
});
|
|
3880
|
+
if (this.outbox.count() === 0) this.store.cacheCloudSave(slot, save);
|
|
3881
|
+
return { save: typedSave(save) };
|
|
3882
|
+
}, () => ({ save: typedSave(this.offlineSave(slot)) }));
|
|
3883
|
+
}
|
|
3884
|
+
/** The device's copy of `slot`, when it can be trusted offline (see {@link getSave}). */
|
|
3885
|
+
offlineSave(slot) {
|
|
3886
|
+
const isLocalOnly = this.store.getUser()?.id === 0;
|
|
3887
|
+
const hasQueuedWrite = this.outbox.pending().some((op) => op.kind === PROGRESS_KINDS.save && isRecord(op.payload) && op.payload.slot === slot);
|
|
3888
|
+
if (!isLocalOnly && !hasQueuedWrite && this.store.cloudSave(slot) === void 0) throw new HubError(`Hub: offline, and this device hasn't seen save "${slot}" yet.`, void 0, { code: SAVE_UNKNOWN_CODE });
|
|
3889
|
+
return this.store.getSave(slot);
|
|
2969
3890
|
}
|
|
2970
3891
|
/** Create or overwrite a save slot. `saveData` is any JSON up to 64 KB. */
|
|
2971
3892
|
putSave(slot, saveData, label) {
|
|
@@ -3376,7 +4297,7 @@ function mergeResumeLists(hub, copies, userId) {
|
|
|
3376
4297
|
/** The query parameter (the contract's `RESUME_REQUEST_PARAM`; the browser client carries no contract code). */
|
|
3377
4298
|
const RESUME_REQUEST_PARAM = "hub_resume";
|
|
3378
4299
|
/** Where the answer is kept for the page. */
|
|
3379
|
-
const PAGE_FLAG = "__HUB_RESUME_REQUESTED__";
|
|
4300
|
+
const PAGE_FLAG$2 = "__HUB_RESUME_REQUESTED__";
|
|
3380
4301
|
/** Take `hub_resume` out of the address bar, keeping the rest of the URL and the history state. */
|
|
3381
4302
|
function stripParam(url) {
|
|
3382
4303
|
url.searchParams.delete(RESUME_REQUEST_PARAM);
|
|
@@ -3398,9 +4319,9 @@ function takeParam() {
|
|
|
3398
4319
|
/** `true` when the page was opened to resume (synchronous, so a game can decide before it routes or draws). */
|
|
3399
4320
|
function isResumeRequested() {
|
|
3400
4321
|
if (!isBrowserPage()) return false;
|
|
3401
|
-
if (Reflect.has(window, PAGE_FLAG)) return Reflect.get(window, PAGE_FLAG) === true;
|
|
4322
|
+
if (Reflect.has(window, PAGE_FLAG$2)) return Reflect.get(window, PAGE_FLAG$2) === true;
|
|
3402
4323
|
const isRequested = takeParam();
|
|
3403
|
-
Reflect.set(window, PAGE_FLAG, isRequested);
|
|
4324
|
+
Reflect.set(window, PAGE_FLAG$2, isRequested);
|
|
3404
4325
|
return isRequested;
|
|
3405
4326
|
}
|
|
3406
4327
|
//#endregion
|
|
@@ -3413,14 +4334,17 @@ const RESUME_MAX_STATE_BYTES = 524288;
|
|
|
3413
4334
|
const BLOCKED_HINTS = {
|
|
3414
4335
|
daily: "a daily challenge is active. When the player leaves a daily without finishing it, call hub.daily.cancel().",
|
|
3415
4336
|
race: "a race is on: races are never resumed.",
|
|
3416
|
-
room: "the page is in a hub room: online games are never resumed."
|
|
4337
|
+
room: "the page is in a hub room: online games are never resumed.",
|
|
4338
|
+
turns: "an online game is open: the hub keeps its state, so nothing is saved on the page."
|
|
3417
4339
|
};
|
|
3418
4340
|
/** Longest label or title kept (the contract's `RESUME_TEXT_MAX`). */
|
|
3419
|
-
const TEXT_MAX = 60;
|
|
4341
|
+
const TEXT_MAX$1 = 60;
|
|
4342
|
+
/** The label a daily goes back to when the game clears its run mid-daily (the day restarts from its seed). */
|
|
4343
|
+
const DAILY_RESTART_LABEL = "Started";
|
|
3420
4344
|
/** A label or title trimmed and clipped, or `null` when blank. */
|
|
3421
|
-
function shortText(text) {
|
|
4345
|
+
function shortText$1(text) {
|
|
3422
4346
|
const trimmed = (text ?? "").trim();
|
|
3423
|
-
return trimmed === "" ? null : trimmed.slice(0, TEXT_MAX);
|
|
4347
|
+
return trimmed === "" ? null : trimmed.slice(0, TEXT_MAX$1);
|
|
3424
4348
|
}
|
|
3425
4349
|
/** The UTF-8 size of `text`. */
|
|
3426
4350
|
function byteSize(text) {
|
|
@@ -3474,8 +4398,9 @@ var HubResume = class {
|
|
|
3474
4398
|
/**
|
|
3475
4399
|
* Save the run (the latest save wins). Stored on the device at once; sent
|
|
3476
4400
|
* after {@link RESUME_DEBOUNCE_MS} of quiet, or when the page is hidden.
|
|
3477
|
-
* Returns `false` (and saves nothing) in a race
|
|
3478
|
-
*
|
|
4401
|
+
* Returns `false` (and saves nothing) in a race or an online room; during
|
|
4402
|
+
* a daily challenge the save goes to the day (`hub.daily.save`). Throws a
|
|
4403
|
+
* `HubError` 413 over 512 KB, 400 without a label.
|
|
3479
4404
|
*/
|
|
3480
4405
|
save(run) {
|
|
3481
4406
|
const blocker = this.host.blocker();
|
|
@@ -3483,8 +4408,10 @@ var HubResume = class {
|
|
|
3483
4408
|
this.warnBlocked(blocker);
|
|
3484
4409
|
return false;
|
|
3485
4410
|
}
|
|
4411
|
+
const daily = this.host.daily?.() ?? null;
|
|
4412
|
+
if (daily !== null) return daily.save(run);
|
|
3486
4413
|
const slug = this.requireSlug();
|
|
3487
|
-
const label = shortText(run.progress.label);
|
|
4414
|
+
const label = shortText$1(run.progress.label);
|
|
3488
4415
|
if (label === null) throw new HubError("hub.resume.save: progress.label is required.", 400);
|
|
3489
4416
|
const state = toStoredJson(run.state);
|
|
3490
4417
|
if (byteSize(JSON.stringify(state)) > 524288) throw new HubError(`hub.resume.save: state exceeds ${String(RESUME_MAX_STATE_BYTES)} bytes.`, 413);
|
|
@@ -3495,13 +4422,14 @@ var HubResume = class {
|
|
|
3495
4422
|
at: Date.now(),
|
|
3496
4423
|
label,
|
|
3497
4424
|
fraction,
|
|
3498
|
-
title: shortText(run.title),
|
|
4425
|
+
title: shortText$1(run.title),
|
|
3499
4426
|
version: Math.max(0, Math.floor(run.version ?? 0)),
|
|
3500
4427
|
gameTitle: this.host.gameTitle(),
|
|
3501
4428
|
state,
|
|
3502
4429
|
isSynced: false
|
|
3503
4430
|
};
|
|
3504
4431
|
const isStored = this.host.local.write(slug, copy);
|
|
4432
|
+
this.host.onChange?.();
|
|
3505
4433
|
this.schedule({
|
|
3506
4434
|
action: "save",
|
|
3507
4435
|
savedAt: copy.at,
|
|
@@ -3526,6 +4454,18 @@ var HubResume = class {
|
|
|
3526
4454
|
/** Clear the run: call it when the run ends (won, lost or quit). Works offline. */
|
|
3527
4455
|
async clear() {
|
|
3528
4456
|
const slug = this.requireSlug();
|
|
4457
|
+
const daily = this.host.daily?.() ?? null;
|
|
4458
|
+
if (daily !== null) {
|
|
4459
|
+
daily.save({
|
|
4460
|
+
state: null,
|
|
4461
|
+
progress: { label: DAILY_RESTART_LABEL }
|
|
4462
|
+
});
|
|
4463
|
+
return;
|
|
4464
|
+
}
|
|
4465
|
+
await this.clearRun(slug);
|
|
4466
|
+
}
|
|
4467
|
+
/** Delete the game's saved run (never the day being played). */
|
|
4468
|
+
async clearRun(slug) {
|
|
3529
4469
|
if (this.timer !== null) {
|
|
3530
4470
|
clearTimeout(this.timer);
|
|
3531
4471
|
this.timer = null;
|
|
@@ -3538,6 +4478,7 @@ var HubResume = class {
|
|
|
3538
4478
|
at,
|
|
3539
4479
|
isSynced: false
|
|
3540
4480
|
});
|
|
4481
|
+
this.host.onChange?.();
|
|
3541
4482
|
await this.host.send({
|
|
3542
4483
|
action: "clear",
|
|
3543
4484
|
before: at
|
|
@@ -3556,7 +4497,7 @@ var HubResume = class {
|
|
|
3556
4497
|
run = mine?.status === "saved" && !isClearedElsewhere ? runFromCopy(mine) : null;
|
|
3557
4498
|
}
|
|
3558
4499
|
if (run === null || options.version === void 0 || run.version === options.version) return run;
|
|
3559
|
-
if (run.version < options.version) await this.
|
|
4500
|
+
if (run.version < options.version) await this.clearRun(slug);
|
|
3560
4501
|
return null;
|
|
3561
4502
|
}
|
|
3562
4503
|
/** The saved state, or `null` (see {@link get} for the version rule). */
|
|
@@ -3604,39 +4545,949 @@ var HubResume = class {
|
|
|
3604
4545
|
}
|
|
3605
4546
|
};
|
|
3606
4547
|
//#endregion
|
|
3607
|
-
//#region src/core/
|
|
4548
|
+
//#region src/core/saved/daily-seed.ts
|
|
3608
4549
|
/**
|
|
3609
|
-
* The
|
|
3610
|
-
*
|
|
3611
|
-
*
|
|
3612
|
-
* service worker can still serve an older menu that never loads it. When the
|
|
3613
|
-
* page has no session yet, a guest one is created and the request retried
|
|
3614
|
-
* once.
|
|
4550
|
+
* The deterministic seed of a (game, day), the same FNV-1a as the
|
|
4551
|
+
* contract's `seedForDay` (the browser client carries no contract code; a
|
|
4552
|
+
* test keeps the two in step). Used for a day started offline.
|
|
3615
4553
|
*/
|
|
3616
|
-
/**
|
|
3617
|
-
function
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
4554
|
+
/** The uint32 seed for `game` on `day`. */
|
|
4555
|
+
function dailySeed(game, day) {
|
|
4556
|
+
let hash = 2166136261;
|
|
4557
|
+
const seedInput = `${game}#${day}`;
|
|
4558
|
+
for (let i = 0; i < seedInput.length; i++) {
|
|
4559
|
+
hash ^= seedInput.charCodeAt(i);
|
|
4560
|
+
hash = Math.imul(hash, 16777619);
|
|
4561
|
+
}
|
|
4562
|
+
return hash >>> 0;
|
|
3622
4563
|
}
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
throw failure instanceof HubError && failure.isOffline ? new OfflineError() : failure;
|
|
3629
|
-
}
|
|
4564
|
+
//#endregion
|
|
4565
|
+
//#region src/core/saved/saved-order.ts
|
|
4566
|
+
/** The player must move in this online game now. */
|
|
4567
|
+
function isYourTurn(entry) {
|
|
4568
|
+
return entry.turns?.isMyTurn === true && entry.turns.phase === "playing";
|
|
3630
4569
|
}
|
|
3631
|
-
/**
|
|
3632
|
-
|
|
3633
|
-
const
|
|
3634
|
-
if (
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
4570
|
+
/** Online games waiting on the player first (oldest turn first), then the newest. */
|
|
4571
|
+
function compareSaved(a, b) {
|
|
4572
|
+
const isATurn = isYourTurn(a);
|
|
4573
|
+
if (isATurn !== isYourTurn(b)) return isATurn ? -1 : 1;
|
|
4574
|
+
if (isATurn && a.turns !== void 0 && b.turns !== void 0 && a.turns.turnStartedAt !== b.turns.turnStartedAt) return a.turns.turnStartedAt - b.turns.turnStartedAt;
|
|
4575
|
+
if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt;
|
|
4576
|
+
return a.id < b.id ? 1 : -1;
|
|
4577
|
+
}
|
|
4578
|
+
/** The badge counts of a list of games in progress. */
|
|
4579
|
+
function countSaved(entries) {
|
|
4580
|
+
const counts = {
|
|
4581
|
+
yourTurn: 0,
|
|
4582
|
+
dailies: 0,
|
|
4583
|
+
runs: 0,
|
|
4584
|
+
waiting: 0,
|
|
4585
|
+
total: entries.length
|
|
4586
|
+
};
|
|
4587
|
+
for (const entry of entries) if (entry.kind === "daily") counts.dailies += 1;
|
|
4588
|
+
else if (entry.kind === "run") counts.runs += 1;
|
|
4589
|
+
else if (isYourTurn(entry)) counts.yourTurn += 1;
|
|
4590
|
+
else counts.waiting += 1;
|
|
4591
|
+
return counts;
|
|
4592
|
+
}
|
|
4593
|
+
//#endregion
|
|
4594
|
+
//#region src/core/saved/saved-list.ts
|
|
4595
|
+
/**
|
|
4596
|
+
* A player's games in progress: the hub's list merged with this device's
|
|
4597
|
+
* copies of runs (`hub.resume`) and daily challenges (`hub.daily.save`), so
|
|
4598
|
+
* one saved offline (or just before the tab closed) shows at once, and one
|
|
4599
|
+
* cleared or discarded offline disappears at once. Pure.
|
|
4600
|
+
*/
|
|
4601
|
+
/** The id a run saved only on this device goes by until the hub has it. */
|
|
4602
|
+
function localRunId(slug) {
|
|
4603
|
+
return `local:${slug}:run`;
|
|
4604
|
+
}
|
|
4605
|
+
/** The id a daily started only on this device goes by until the hub has it. */
|
|
4606
|
+
function localDailyId(slug, day) {
|
|
4607
|
+
return `local:${slug}:${day}`;
|
|
4608
|
+
}
|
|
4609
|
+
/** A device run as an entry, with the game's catalog fields from the hub's entry when it has one. */
|
|
4610
|
+
function runEntry(slug, run, known) {
|
|
4611
|
+
return {
|
|
4612
|
+
id: known?.id ?? localRunId(slug),
|
|
4613
|
+
game: slug,
|
|
4614
|
+
gameTitle: known?.gameTitle ?? run.gameTitle,
|
|
4615
|
+
path: known?.path ?? `/${slug}`,
|
|
4616
|
+
hasImage: known?.hasImage ?? false,
|
|
4617
|
+
kind: "run",
|
|
4618
|
+
status: "active",
|
|
4619
|
+
title: run.title,
|
|
4620
|
+
progress: {
|
|
4621
|
+
label: run.label,
|
|
4622
|
+
fraction: run.fraction
|
|
4623
|
+
},
|
|
4624
|
+
version: run.version,
|
|
4625
|
+
createdAt: known?.createdAt ?? run.at,
|
|
4626
|
+
updatedAt: run.at,
|
|
4627
|
+
finishedAt: null,
|
|
4628
|
+
isLocalOnly: !run.isSynced
|
|
4629
|
+
};
|
|
4630
|
+
}
|
|
4631
|
+
/** One game's run: the hub's, the device's newer copy, or none (cleared on the device). */
|
|
4632
|
+
function mergeRun(slug, hub, copy, isHubKnown) {
|
|
4633
|
+
if (copy === void 0) return hub ?? null;
|
|
4634
|
+
if (copy.status === "cleared") return hub !== void 0 && hub.updatedAt > copy.at ? hub : null;
|
|
4635
|
+
if (hub !== void 0 && hub.updatedAt >= copy.at) return hub;
|
|
4636
|
+
return !copy.isSynced || !isHubKnown || hub !== void 0 ? runEntry(slug, copy, hub) : null;
|
|
4637
|
+
}
|
|
4638
|
+
/** A device daily as an entry. */
|
|
4639
|
+
function dailyEntry(daily, known) {
|
|
4640
|
+
return {
|
|
4641
|
+
id: daily.id,
|
|
4642
|
+
game: daily.game,
|
|
4643
|
+
gameTitle: known?.gameTitle ?? daily.gameTitle,
|
|
4644
|
+
path: known?.path ?? `/${daily.game}`,
|
|
4645
|
+
hasImage: known?.hasImage ?? false,
|
|
4646
|
+
kind: "daily",
|
|
4647
|
+
status: "active",
|
|
4648
|
+
title: daily.title,
|
|
4649
|
+
progress: {
|
|
4650
|
+
label: daily.label,
|
|
4651
|
+
fraction: daily.fraction
|
|
4652
|
+
},
|
|
4653
|
+
version: daily.version,
|
|
4654
|
+
createdAt: known?.createdAt ?? daily.at,
|
|
4655
|
+
updatedAt: daily.at,
|
|
4656
|
+
finishedAt: null,
|
|
4657
|
+
daily: {
|
|
4658
|
+
day: daily.day,
|
|
4659
|
+
seed: daily.seed
|
|
4660
|
+
},
|
|
4661
|
+
isLocalOnly: !daily.isSynced
|
|
4662
|
+
};
|
|
4663
|
+
}
|
|
4664
|
+
/** One daily: the hub's entry, the device's newer copy, or none (discarded or finished on the device). */
|
|
4665
|
+
function mergeDaily(hub, copy, isHubKnown) {
|
|
4666
|
+
if (copy === void 0) return hub ?? null;
|
|
4667
|
+
if (copy.status !== "active") return hub !== void 0 && hub.updatedAt > copy.at ? hub : null;
|
|
4668
|
+
if (hub !== void 0 && hub.updatedAt >= copy.at) return hub;
|
|
4669
|
+
return !copy.isSynced || !isHubKnown || hub !== void 0 ? dailyEntry(copy, hub) : null;
|
|
4670
|
+
}
|
|
4671
|
+
/** The key that pairs a hub daily with a device copy: its game and day. */
|
|
4672
|
+
const dailyKey = (game, day) => `${game}#${day}`;
|
|
4673
|
+
/**
|
|
4674
|
+
* The entries to show, in the shared list order. `hub` is the hub's list,
|
|
4675
|
+
* or `undefined` when it couldn't be reached; `copies` are the device's
|
|
4676
|
+
* (already only the player's); `game` limits the device copies to one game.
|
|
4677
|
+
*/
|
|
4678
|
+
function mergeSavedLists(hub, copies, game) {
|
|
4679
|
+
const isHubKnown = hub !== void 0;
|
|
4680
|
+
const fromHub = hub ?? [];
|
|
4681
|
+
const runs = new Map(fromHub.filter((entry) => entry.kind === "run").map((entry) => [entry.game, entry]));
|
|
4682
|
+
const dailies = new Map(fromHub.flatMap((entry) => entry.kind === "daily" && entry.daily !== void 0 ? [[dailyKey(entry.game, entry.daily.day), entry]] : []));
|
|
4683
|
+
const runCopies = new Map([...copies.runs].filter(([slug]) => game === null || slug === game));
|
|
4684
|
+
const dailyCopies = new Map(copies.dailies.filter((copy) => game === null || copy.game === game).map((copy) => [dailyKey(copy.game, copy.day), copy]));
|
|
4685
|
+
const merged = fromHub.filter((entry) => entry.kind === "turns");
|
|
4686
|
+
for (const slug of /* @__PURE__ */ new Set([...runs.keys(), ...runCopies.keys()])) {
|
|
4687
|
+
const entry = mergeRun(slug, runs.get(slug), runCopies.get(slug), isHubKnown);
|
|
4688
|
+
if (entry !== null) merged.push(entry);
|
|
4689
|
+
}
|
|
4690
|
+
for (const key of /* @__PURE__ */ new Set([...dailies.keys(), ...dailyCopies.keys()])) {
|
|
4691
|
+
const entry = mergeDaily(dailies.get(key), dailyCopies.get(key), isHubKnown);
|
|
4692
|
+
if (entry !== null) merged.push(entry);
|
|
4693
|
+
}
|
|
4694
|
+
return merged.sort(compareSaved);
|
|
4695
|
+
}
|
|
4696
|
+
//#endregion
|
|
4697
|
+
//#region src/core/saved/daily-saves.ts
|
|
4698
|
+
/** How long saves must be quiet before the latest is sent, ms (the same as `hub.resume`). */
|
|
4699
|
+
const DAILY_SAVE_DEBOUNCE_MS = 3e3;
|
|
4700
|
+
/** Largest state, bytes of JSON (the contract's `SAVED_MAX_STATE_BYTES`). */
|
|
4701
|
+
const MAX_STATE_BYTES = 524288;
|
|
4702
|
+
/** Longest label or title kept. */
|
|
4703
|
+
const TEXT_MAX = 60;
|
|
4704
|
+
/** The label of a day started and not saved yet. */
|
|
4705
|
+
const STARTED_LABEL = "Started";
|
|
4706
|
+
/** A label or title trimmed and clipped, or `null` when blank. */
|
|
4707
|
+
function shortText(text) {
|
|
4708
|
+
const trimmed = (text ?? "").trim();
|
|
4709
|
+
return trimmed === "" ? null : trimmed.slice(0, TEXT_MAX);
|
|
4710
|
+
}
|
|
4711
|
+
/** A game's daily saves. */
|
|
4712
|
+
var DailySaves = class {
|
|
4713
|
+
constructor(host) {
|
|
4714
|
+
_defineProperty(this, "host", void 0);
|
|
4715
|
+
_defineProperty(this, "timers", /* @__PURE__ */ new Map());
|
|
4716
|
+
_defineProperty(this, "waiting", /* @__PURE__ */ new Map());
|
|
4717
|
+
_defineProperty(this, "isListening", false);
|
|
4718
|
+
this.host = host;
|
|
4719
|
+
}
|
|
4720
|
+
/**
|
|
4721
|
+
* The answer a device-only start gives while the hub can't be reached:
|
|
4722
|
+
* the day's copy here (its saved state) or a fresh one. No token: the
|
|
4723
|
+
* completion fetches one when the hub is back.
|
|
4724
|
+
*/
|
|
4725
|
+
startLocally(day) {
|
|
4726
|
+
const userId = this.host.currentUserId();
|
|
4727
|
+
const existing = this.host.local.read(this.host.slug, day);
|
|
4728
|
+
const copy = existing !== null && existing.status === "active" && existing.userId === userId ? existing : {
|
|
4729
|
+
id: localDailyId(this.host.slug, day),
|
|
4730
|
+
game: this.host.slug,
|
|
4731
|
+
day,
|
|
4732
|
+
seed: dailySeed(this.host.slug, day),
|
|
4733
|
+
status: "active",
|
|
4734
|
+
userId,
|
|
4735
|
+
at: Date.now(),
|
|
4736
|
+
label: STARTED_LABEL,
|
|
4737
|
+
fraction: null,
|
|
4738
|
+
title: null,
|
|
4739
|
+
version: 0,
|
|
4740
|
+
gameTitle: this.host.gameTitle(),
|
|
4741
|
+
state: null,
|
|
4742
|
+
isSynced: false
|
|
4743
|
+
};
|
|
4744
|
+
if (copy !== existing) this.host.local.write(copy);
|
|
4745
|
+
return {
|
|
4746
|
+
token: "",
|
|
4747
|
+
day,
|
|
4748
|
+
seed: copy.seed,
|
|
4749
|
+
expiresAt: 0,
|
|
4750
|
+
alreadyCompleted: false,
|
|
4751
|
+
completedAt: null,
|
|
4752
|
+
meta: null,
|
|
4753
|
+
instanceId: copy.id,
|
|
4754
|
+
saved: copy.state,
|
|
4755
|
+
savedVersion: copy.version,
|
|
4756
|
+
resumed: existing !== null
|
|
4757
|
+
};
|
|
4758
|
+
}
|
|
4759
|
+
/** The hub started (or resumed) a day: keep a copy that knows its id, unless the device has a newer save. */
|
|
4760
|
+
remember(started) {
|
|
4761
|
+
if (started.instanceId === null) return started;
|
|
4762
|
+
const existing = this.host.local.read(this.host.slug, started.day);
|
|
4763
|
+
if (existing !== null && existing.status === "active" && existing.userId === this.host.currentUserId() && !existing.isSynced) {
|
|
4764
|
+
this.host.local.write({
|
|
4765
|
+
...existing,
|
|
4766
|
+
id: started.instanceId
|
|
4767
|
+
});
|
|
4768
|
+
return {
|
|
4769
|
+
...started,
|
|
4770
|
+
saved: existing.state,
|
|
4771
|
+
savedVersion: existing.version
|
|
4772
|
+
};
|
|
4773
|
+
}
|
|
4774
|
+
return started;
|
|
4775
|
+
}
|
|
4776
|
+
/** Save the active day's state: on the device now, to the hub soon. */
|
|
4777
|
+
save(challenge, input) {
|
|
4778
|
+
const label = shortText(input.progress.label);
|
|
4779
|
+
if (label === null) throw new HubError("hub.daily.save: progress.label is required.", 400);
|
|
4780
|
+
const state = toStoredJson(input.state);
|
|
4781
|
+
if (new TextEncoder().encode(JSON.stringify(state)).length > MAX_STATE_BYTES) throw new HubError(`hub.daily.save: state exceeds ${String(MAX_STATE_BYTES)} bytes.`, 413);
|
|
4782
|
+
if (challenge.instanceId === null) return false;
|
|
4783
|
+
const fraction = typeof input.progress.fraction === "number" && Number.isFinite(input.progress.fraction) ? Math.min(1, Math.max(0, input.progress.fraction)) : null;
|
|
4784
|
+
const copy = {
|
|
4785
|
+
id: challenge.instanceId,
|
|
4786
|
+
game: this.host.slug,
|
|
4787
|
+
day: challenge.day,
|
|
4788
|
+
seed: challenge.seed,
|
|
4789
|
+
status: "active",
|
|
4790
|
+
userId: this.host.currentUserId(),
|
|
4791
|
+
at: Date.now(),
|
|
4792
|
+
label,
|
|
4793
|
+
fraction,
|
|
4794
|
+
title: shortText(input.title),
|
|
4795
|
+
version: Math.max(0, Math.floor(input.version ?? 0)),
|
|
4796
|
+
gameTitle: this.host.gameTitle(),
|
|
4797
|
+
state,
|
|
4798
|
+
isSynced: false
|
|
4799
|
+
};
|
|
4800
|
+
if (!this.host.local.write(copy)) return false;
|
|
4801
|
+
this.schedule({
|
|
4802
|
+
action: "save",
|
|
4803
|
+
game: copy.game,
|
|
4804
|
+
day: copy.day,
|
|
4805
|
+
at: copy.at
|
|
4806
|
+
});
|
|
4807
|
+
return true;
|
|
4808
|
+
}
|
|
4809
|
+
/** Give the day up: the copy says so at once, the hub hears it now (or when back online). */
|
|
4810
|
+
async discard(challenge) {
|
|
4811
|
+
this.dropWaiting(challenge.day);
|
|
4812
|
+
const at = Date.now();
|
|
4813
|
+
const base = this.host.local.read(this.host.slug, challenge.day) ?? this.blankCopy(challenge);
|
|
4814
|
+
this.host.local.write({
|
|
4815
|
+
...base,
|
|
4816
|
+
id: challenge.instanceId ?? base.id,
|
|
4817
|
+
status: "discarded",
|
|
4818
|
+
at,
|
|
4819
|
+
isSynced: false
|
|
4820
|
+
});
|
|
4821
|
+
await this.host.send({
|
|
4822
|
+
action: "discard",
|
|
4823
|
+
game: this.host.slug,
|
|
4824
|
+
day: challenge.day,
|
|
4825
|
+
at
|
|
4826
|
+
});
|
|
4827
|
+
}
|
|
4828
|
+
/** The day was won: no save is waiting for it any more, and the lists hide it until the hub confirms. */
|
|
4829
|
+
finished(day) {
|
|
4830
|
+
this.dropWaiting(day);
|
|
4831
|
+
const existing = this.host.local.read(this.host.slug, day);
|
|
4832
|
+
if (existing !== null) this.host.local.write({
|
|
4833
|
+
...existing,
|
|
4834
|
+
status: "finished",
|
|
4835
|
+
at: Date.now(),
|
|
4836
|
+
isSynced: false
|
|
4837
|
+
});
|
|
4838
|
+
}
|
|
4839
|
+
/** The hub recorded the day: forget the copy. */
|
|
4840
|
+
completed(day) {
|
|
4841
|
+
this.host.local.remove(this.host.slug, day);
|
|
4842
|
+
}
|
|
4843
|
+
/** Send every waiting save now. */
|
|
4844
|
+
async flush() {
|
|
4845
|
+
const payloads = [...this.waiting.values()];
|
|
4846
|
+
for (const timer of this.timers.values()) clearTimeout(timer);
|
|
4847
|
+
this.timers.clear();
|
|
4848
|
+
this.waiting.clear();
|
|
4849
|
+
await Promise.all(payloads.map((payload) => this.host.send(payload)));
|
|
4850
|
+
}
|
|
4851
|
+
blankCopy(challenge) {
|
|
4852
|
+
return {
|
|
4853
|
+
id: challenge.instanceId ?? localDailyId(this.host.slug, challenge.day),
|
|
4854
|
+
game: this.host.slug,
|
|
4855
|
+
day: challenge.day,
|
|
4856
|
+
seed: challenge.seed,
|
|
4857
|
+
status: "active",
|
|
4858
|
+
userId: this.host.currentUserId(),
|
|
4859
|
+
at: Date.now(),
|
|
4860
|
+
label: STARTED_LABEL,
|
|
4861
|
+
fraction: null,
|
|
4862
|
+
title: null,
|
|
4863
|
+
version: 0,
|
|
4864
|
+
gameTitle: this.host.gameTitle(),
|
|
4865
|
+
state: null,
|
|
4866
|
+
isSynced: false
|
|
4867
|
+
};
|
|
4868
|
+
}
|
|
4869
|
+
dropWaiting(day) {
|
|
4870
|
+
const timer = this.timers.get(day);
|
|
4871
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
4872
|
+
this.timers.delete(day);
|
|
4873
|
+
this.waiting.delete(day);
|
|
4874
|
+
}
|
|
4875
|
+
schedule(payload) {
|
|
4876
|
+
this.dropWaiting(payload.day);
|
|
4877
|
+
this.waiting.set(payload.day, payload);
|
|
4878
|
+
this.timers.set(payload.day, setTimeout(() => {
|
|
4879
|
+
this.timers.delete(payload.day);
|
|
4880
|
+
this.waiting.delete(payload.day);
|
|
4881
|
+
this.host.send(payload).catch(() => void 0);
|
|
4882
|
+
}, DAILY_SAVE_DEBOUNCE_MS));
|
|
4883
|
+
this.listenToPage();
|
|
4884
|
+
}
|
|
4885
|
+
/** Send waiting saves as soon as the page is hidden or closed. */
|
|
4886
|
+
listenToPage() {
|
|
4887
|
+
if (this.isListening || !isBrowserPage()) return;
|
|
4888
|
+
this.isListening = true;
|
|
4889
|
+
const flushNow = () => {
|
|
4890
|
+
this.flush().catch(() => void 0);
|
|
4891
|
+
};
|
|
4892
|
+
window.addEventListener("pagehide", flushNow);
|
|
4893
|
+
if (typeof document !== "undefined") document.addEventListener("visibilitychange", () => {
|
|
4894
|
+
if (document.visibilityState === "hidden") flushNow();
|
|
4895
|
+
});
|
|
4896
|
+
}
|
|
4897
|
+
};
|
|
4898
|
+
//#endregion
|
|
4899
|
+
//#region src/core/saved/saved-open.ts
|
|
4900
|
+
/** The instance id parameter (the contract's `SAVED_OPEN_PARAM`). */
|
|
4901
|
+
const SAVED_OPEN_PARAM = "hub_open";
|
|
4902
|
+
/** The instance kind parameter (the contract's `SAVED_KIND_PARAM`). */
|
|
4903
|
+
const SAVED_KIND_PARAM = "hub_kind";
|
|
4904
|
+
/** The window event the hub menu fires to open an instance of this page's game (cancelable). */
|
|
4905
|
+
const SAVED_OPEN_EVENT = "hub:saved-open";
|
|
4906
|
+
/** The window event the social runtime fires for each `ev:saved` (detail: the `SavedGame`). */
|
|
4907
|
+
const SAVED_CHANGE_EVENT = "hub:saved";
|
|
4908
|
+
/** Where the answer is kept for the page. */
|
|
4909
|
+
const PAGE_FLAG$1 = "__HUB_SAVED_OPENED__";
|
|
4910
|
+
const KINDS = [
|
|
4911
|
+
"daily",
|
|
4912
|
+
"run",
|
|
4913
|
+
"turns"
|
|
4914
|
+
];
|
|
4915
|
+
/** `text` as a kind, or `null`. */
|
|
4916
|
+
function asKind(text) {
|
|
4917
|
+
return KINDS.find((kind) => kind === text) ?? null;
|
|
4918
|
+
}
|
|
4919
|
+
/** The address that opens `game` in its page. */
|
|
4920
|
+
function savedGameUrl(game) {
|
|
4921
|
+
const params = new URLSearchParams();
|
|
4922
|
+
if (game.kind === "run") params.set(RESUME_REQUEST_PARAM, "1");
|
|
4923
|
+
if (game.kind === "daily" && game.daily !== void 0) params.set("daily", game.daily.day);
|
|
4924
|
+
params.set(SAVED_OPEN_PARAM, game.id);
|
|
4925
|
+
params.set(SAVED_KIND_PARAM, game.kind);
|
|
4926
|
+
return `${game.path.replace(/\/+$/, "")}/?${params.toString()}`;
|
|
4927
|
+
}
|
|
4928
|
+
/** Read and strip `hub_open` / `hub_kind`; `null` when absent or malformed. */
|
|
4929
|
+
function takeParams() {
|
|
4930
|
+
try {
|
|
4931
|
+
const url = new URL(location.href);
|
|
4932
|
+
const id = url.searchParams.get(SAVED_OPEN_PARAM);
|
|
4933
|
+
const kind = asKind(url.searchParams.get(SAVED_KIND_PARAM));
|
|
4934
|
+
if (!url.searchParams.has("hub_open") && !url.searchParams.has("hub_kind")) return null;
|
|
4935
|
+
url.searchParams.delete(SAVED_OPEN_PARAM);
|
|
4936
|
+
url.searchParams.delete(SAVED_KIND_PARAM);
|
|
4937
|
+
try {
|
|
4938
|
+
history.replaceState(history.state, "", `${url.pathname}${url.search}${url.hash}`);
|
|
4939
|
+
} catch {}
|
|
4940
|
+
return id === null || id === "" || kind === null ? null : {
|
|
4941
|
+
id,
|
|
4942
|
+
kind
|
|
4943
|
+
};
|
|
4944
|
+
} catch {
|
|
4945
|
+
return null;
|
|
4946
|
+
}
|
|
4947
|
+
}
|
|
4948
|
+
/** A recorded answer read back. */
|
|
4949
|
+
function readRecorded(value) {
|
|
4950
|
+
const id = readField(value, "id");
|
|
4951
|
+
const kind = readField(value, "kind");
|
|
4952
|
+
const savedKind = asKind(typeof kind === "string" ? kind : null);
|
|
4953
|
+
return typeof id === "string" && savedKind !== null ? {
|
|
4954
|
+
id,
|
|
4955
|
+
kind: savedKind
|
|
4956
|
+
} : null;
|
|
4957
|
+
}
|
|
4958
|
+
/** The instance this page was opened for, or `null` (synchronous, so a game can route before it draws). */
|
|
4959
|
+
function savedOpenedOnPage() {
|
|
4960
|
+
if (!isBrowserPage()) return null;
|
|
4961
|
+
if (Reflect.has(window, PAGE_FLAG$1)) return readRecorded(Reflect.get(window, PAGE_FLAG$1));
|
|
4962
|
+
const opened = takeParams();
|
|
4963
|
+
Reflect.set(window, PAGE_FLAG$1, opened);
|
|
4964
|
+
return opened;
|
|
4965
|
+
}
|
|
4966
|
+
//#endregion
|
|
4967
|
+
//#region src/core/saved/hub-saved.ts
|
|
4968
|
+
/** How long changes must be quiet before `on('change')` listeners hear the new list, ms. */
|
|
4969
|
+
const SAVED_CHANGE_QUIET_MS = 300;
|
|
4970
|
+
/** Whether an entry is of one of `kinds` (all when absent). */
|
|
4971
|
+
function isOfKinds(entry, options) {
|
|
4972
|
+
return options.kinds === void 0 || options.kinds.includes(entry.kind);
|
|
4973
|
+
}
|
|
4974
|
+
/** `hub.saved`. */
|
|
4975
|
+
var HubSaved = class {
|
|
4976
|
+
constructor(host) {
|
|
4977
|
+
_defineProperty(this, "host", void 0);
|
|
4978
|
+
_defineProperty(this, "openHandlers", /* @__PURE__ */ new Map());
|
|
4979
|
+
_defineProperty(this, "changeListeners", /* @__PURE__ */ new Set());
|
|
4980
|
+
_defineProperty(this, "isListeningForOpen", false);
|
|
4981
|
+
_defineProperty(this, "isListeningForChange", false);
|
|
4982
|
+
_defineProperty(this, "changeTimer", null);
|
|
4983
|
+
this.host = host;
|
|
4984
|
+
}
|
|
4985
|
+
/**
|
|
4986
|
+
* The instance this page was opened for (`?hub_open=<id>&hub_kind=<kind>`,
|
|
4987
|
+
* taken out of the address bar), or `null`. Synchronous: read it before
|
|
4988
|
+
* routing or drawing a title screen.
|
|
4989
|
+
*/
|
|
4990
|
+
get opened() {
|
|
4991
|
+
return savedOpenedOnPage();
|
|
4992
|
+
}
|
|
4993
|
+
/** This game's games in progress: online games waiting on the player first, then the newest. Works offline. */
|
|
4994
|
+
async list(options = {}) {
|
|
4995
|
+
const slug = this.host.slug;
|
|
4996
|
+
if (slug === null) return [];
|
|
4997
|
+
return mergeSavedLists(await this.host.fetchList(slug), this.host.deviceCopies(), slug).filter((entry) => isOfKinds(entry, options));
|
|
4998
|
+
}
|
|
4999
|
+
/** Every game's games in progress (the home page and the menu). Online, plus this device's copies. */
|
|
5000
|
+
async listAll() {
|
|
5001
|
+
return mergeSavedLists(await this.host.fetchList(null), this.host.deviceCopies(), null);
|
|
5002
|
+
}
|
|
5003
|
+
/** The badge counts across every game. */
|
|
5004
|
+
async counts() {
|
|
5005
|
+
return countSaved(await this.listAll());
|
|
5006
|
+
}
|
|
5007
|
+
/**
|
|
5008
|
+
* Switch to an instance of THIS game when the player picks it from the
|
|
5009
|
+
* hub menu, without a reload. Without a handler the page reloads with
|
|
5010
|
+
* `?hub_open=<id>`. With `{ kinds }` the handler takes only those kinds;
|
|
5011
|
+
* the others still reload the page (with `?hub_resume=1` or `?daily=` for
|
|
5012
|
+
* a saved run or a daily). Returns a function that removes the handler.
|
|
5013
|
+
*/
|
|
5014
|
+
onOpen(handler, options = {}) {
|
|
5015
|
+
this.openHandlers.set(handler, options);
|
|
5016
|
+
this.listenForOpen();
|
|
5017
|
+
return () => {
|
|
5018
|
+
this.openHandlers.delete(handler);
|
|
5019
|
+
};
|
|
5020
|
+
}
|
|
5021
|
+
/** Open an instance: in this game through `onOpen` (or a reload), in another game by going there. */
|
|
5022
|
+
async open(id) {
|
|
5023
|
+
const entry = (await this.listAll()).find((candidate) => candidate.id === id);
|
|
5024
|
+
if (entry === void 0) return;
|
|
5025
|
+
this.openEntry(entry);
|
|
5026
|
+
}
|
|
5027
|
+
/** Open an entry already in hand (the hub's lists). */
|
|
5028
|
+
openEntry(entry) {
|
|
5029
|
+
if (entry.game === this.host.slug && isBrowserPage()) {
|
|
5030
|
+
const event = new CustomEvent(SAVED_OPEN_EVENT, {
|
|
5031
|
+
detail: { entry },
|
|
5032
|
+
cancelable: true
|
|
5033
|
+
});
|
|
5034
|
+
if (!window.dispatchEvent(event)) return;
|
|
5035
|
+
}
|
|
5036
|
+
this.host.navigate(savedGameUrl(entry));
|
|
5037
|
+
}
|
|
5038
|
+
/**
|
|
5039
|
+
* Discard a daily challenge or a saved run (the caller asks first; the
|
|
5040
|
+
* hub's own lists ask for themselves). Online games are left with
|
|
5041
|
+
* `hub.turns`, never discarded.
|
|
5042
|
+
*/
|
|
5043
|
+
async discard(id) {
|
|
5044
|
+
const entry = (await this.listAll()).find((candidate) => candidate.id === id);
|
|
5045
|
+
if (entry === void 0 || entry.kind === "turns") return;
|
|
5046
|
+
await this.host.discard(entry);
|
|
5047
|
+
this.changed();
|
|
5048
|
+
}
|
|
5049
|
+
/** Hear when this game's list changes; returns a function that stops listening. */
|
|
5050
|
+
on(_eventName, listener) {
|
|
5051
|
+
this.changeListeners.add(listener);
|
|
5052
|
+
this.listenForChange();
|
|
5053
|
+
return () => {
|
|
5054
|
+
this.changeListeners.delete(listener);
|
|
5055
|
+
};
|
|
5056
|
+
}
|
|
5057
|
+
/** Something of this game's changed (a save here, an event from the hub): tell the listeners, once per quiet spell. */
|
|
5058
|
+
changed() {
|
|
5059
|
+
if (this.changeListeners.size === 0 || this.changeTimer !== null) return;
|
|
5060
|
+
this.changeTimer = setTimeout(() => {
|
|
5061
|
+
this.changeTimer = null;
|
|
5062
|
+
this.list().then((games) => {
|
|
5063
|
+
for (const listener of this.changeListeners) listener(games);
|
|
5064
|
+
}, () => void 0);
|
|
5065
|
+
}, 300);
|
|
5066
|
+
}
|
|
5067
|
+
listenForOpen() {
|
|
5068
|
+
if (this.isListeningForOpen || !isBrowserPage()) return;
|
|
5069
|
+
this.isListeningForOpen = true;
|
|
5070
|
+
window.addEventListener(SAVED_OPEN_EVENT, (event) => {
|
|
5071
|
+
const entry = event instanceof CustomEvent ? readEntry(event.detail) : null;
|
|
5072
|
+
const handlers = entry === null ? [] : [...this.openHandlers].filter(([, options]) => isOfKinds(entry, options)).map(([handler]) => handler);
|
|
5073
|
+
if (entry === null || entry.game !== this.host.slug || handlers.length === 0 || event.defaultPrevented) return;
|
|
5074
|
+
event.preventDefault();
|
|
5075
|
+
for (const handler of handlers) Promise.resolve(handler(entry)).catch(() => void 0);
|
|
5076
|
+
});
|
|
5077
|
+
}
|
|
5078
|
+
listenForChange() {
|
|
5079
|
+
if (this.isListeningForChange || !isBrowserPage()) return;
|
|
5080
|
+
this.isListeningForChange = true;
|
|
5081
|
+
window.addEventListener(SAVED_CHANGE_EVENT, (event) => {
|
|
5082
|
+
if ((event instanceof CustomEvent ? readField(event.detail, "game") : void 0) === this.host.slug) this.changed();
|
|
5083
|
+
});
|
|
5084
|
+
}
|
|
5085
|
+
};
|
|
5086
|
+
/** The entry in an open event's detail (it may come from another bundle), or `null`. */
|
|
5087
|
+
function readEntry(detail) {
|
|
5088
|
+
const entry = readField(detail, "entry");
|
|
5089
|
+
const id = readField(entry, "id");
|
|
5090
|
+
const game = readField(entry, "game");
|
|
5091
|
+
const kind = readField(entry, "kind");
|
|
5092
|
+
if (typeof id !== "string" || typeof game !== "string" || kind !== "daily" && kind !== "run" && kind !== "turns") return null;
|
|
5093
|
+
return isSavedEntry(entry) ? entry : null;
|
|
5094
|
+
}
|
|
5095
|
+
/** Shape check for an entry made by another copy of this client. */
|
|
5096
|
+
function isSavedEntry(candidate) {
|
|
5097
|
+
return typeof readField(candidate, "path") === "string" && typeof readField(candidate, "progress") === "object";
|
|
5098
|
+
}
|
|
5099
|
+
//#endregion
|
|
5100
|
+
//#region src/core/turns/turn-game.ts
|
|
5101
|
+
/** The window event the social runtime fires for each `ev:turn`. */
|
|
5102
|
+
const TURN_EVENT = "hub:turn";
|
|
5103
|
+
/** The runtime's live connection (re)opened: anything sent while it was down is fetched again. */
|
|
5104
|
+
const TURN_RESYNC_EVENT = "hub:turn-resync";
|
|
5105
|
+
/** The window event the social runtime fires for each `ev:turn.chat`. */
|
|
5106
|
+
const TURN_CHAT_EVENT = "hub:turn-chat";
|
|
5107
|
+
/** How often a page without the live runtime looks for moves, ms. */
|
|
5108
|
+
const TURN_POLL_MS = 2e4;
|
|
5109
|
+
/** One online game. `View`, `Move` and `Ev` (its events) are the game's own JSON types from its rules. */
|
|
5110
|
+
var TurnGame = class TurnGame {
|
|
5111
|
+
constructor(host, initial) {
|
|
5112
|
+
_defineProperty(this, "id", void 0);
|
|
5113
|
+
_defineProperty(this, "state", void 0);
|
|
5114
|
+
_defineProperty(this, "host", void 0);
|
|
5115
|
+
_defineProperty(this, "listeners", {
|
|
5116
|
+
move: /* @__PURE__ */ new Set(),
|
|
5117
|
+
turn: /* @__PURE__ */ new Set(),
|
|
5118
|
+
ended: /* @__PURE__ */ new Set(),
|
|
5119
|
+
chat: /* @__PURE__ */ new Set()
|
|
5120
|
+
});
|
|
5121
|
+
_defineProperty(this, "stopListening", []);
|
|
5122
|
+
_defineProperty(this, "isClosed", false);
|
|
5123
|
+
_defineProperty(this, "catchingUp", null);
|
|
5124
|
+
_defineProperty(this, "ownSeq", null);
|
|
5125
|
+
this.host = host;
|
|
5126
|
+
this.id = initial.id;
|
|
5127
|
+
this.state = initial;
|
|
5128
|
+
host.opened(this);
|
|
5129
|
+
this.listen();
|
|
5130
|
+
}
|
|
5131
|
+
/** Whose move it is, the seats and the deadline. */
|
|
5132
|
+
get summary() {
|
|
5133
|
+
return this.state.summary;
|
|
5134
|
+
}
|
|
5135
|
+
/** This seat's view, from the game's rules (`null` before the game starts). */
|
|
5136
|
+
get view() {
|
|
5137
|
+
return toGameData(this.state.view);
|
|
5138
|
+
}
|
|
5139
|
+
/** This player's seat, or `null` (they left). */
|
|
5140
|
+
get mySeat() {
|
|
5141
|
+
return this.state.mySeat;
|
|
5142
|
+
}
|
|
5143
|
+
/** The end, once the game is over. */
|
|
5144
|
+
get outcome() {
|
|
5145
|
+
return this.state.outcome;
|
|
5146
|
+
}
|
|
5147
|
+
/** The last chat lines, oldest first. */
|
|
5148
|
+
get chat() {
|
|
5149
|
+
return this.state.chat;
|
|
5150
|
+
}
|
|
5151
|
+
/** `on`: friends may type (moderated); `quick`: quick-chat and emotes only; `off`. */
|
|
5152
|
+
get chatMode() {
|
|
5153
|
+
return this.state.chatMode;
|
|
5154
|
+
}
|
|
5155
|
+
/** The link others can take a seat with (the creator's, while seats are open), else `null`. */
|
|
5156
|
+
get link() {
|
|
5157
|
+
return this.state.link === null ? null : new URL(this.state.link, location.origin).href;
|
|
5158
|
+
}
|
|
5159
|
+
/** Creator: start the game (empty seats go to the computer when the game allows). */
|
|
5160
|
+
async start() {
|
|
5161
|
+
this.update(await this.host.start(this.id));
|
|
5162
|
+
}
|
|
5163
|
+
/**
|
|
5164
|
+
* Make a move. Rejects with a `HubError`: 403 `not_your_turn`, 409
|
|
5165
|
+
* `stale` (the game moved on: the view is refreshed first), 422 `illegal`
|
|
5166
|
+
* with the rules' own code in `rule`.
|
|
5167
|
+
*/
|
|
5168
|
+
async move(move) {
|
|
5169
|
+
this.ownSeq = this.state.summary.seq + 1;
|
|
5170
|
+
try {
|
|
5171
|
+
const answer = await this.host.move(this.id, this.state.summary.seq, toPayload(move));
|
|
5172
|
+
this.update(answer.game);
|
|
5173
|
+
return {
|
|
5174
|
+
seq: answer.game.summary.seq,
|
|
5175
|
+
view: this.view,
|
|
5176
|
+
events: answer.events.map((event) => toGameData(event))
|
|
5177
|
+
};
|
|
5178
|
+
} catch (failure) {
|
|
5179
|
+
if (failure instanceof HubError && failure.code === "stale") await this.refresh();
|
|
5180
|
+
throw failure;
|
|
5181
|
+
} finally {
|
|
5182
|
+
this.ownSeq = null;
|
|
5183
|
+
}
|
|
5184
|
+
}
|
|
5185
|
+
/** The moves after `sinceSeq`, with their events as this seat may see them ("what happened while you were away"). */
|
|
5186
|
+
async history(sinceSeq) {
|
|
5187
|
+
return (await this.host.history(this.id, sinceSeq)).map((move) => ({
|
|
5188
|
+
seq: move.seq,
|
|
5189
|
+
seat: move.seat,
|
|
5190
|
+
events: move.events.map((event) => toGameData(event))
|
|
5191
|
+
}));
|
|
5192
|
+
}
|
|
5193
|
+
/** Leave: the computer plays the seat (or, without bots, the others win). The player can't come back. */
|
|
5194
|
+
async leave() {
|
|
5195
|
+
await this.host.leave(this.id);
|
|
5196
|
+
this.close();
|
|
5197
|
+
}
|
|
5198
|
+
/** Remind whoever must move (once a day). */
|
|
5199
|
+
nudge() {
|
|
5200
|
+
return this.host.nudge(this.id);
|
|
5201
|
+
}
|
|
5202
|
+
/** This game's "your turn" alerts. */
|
|
5203
|
+
setNotify(mode) {
|
|
5204
|
+
return this.host.setNotify(this.id, mode);
|
|
5205
|
+
}
|
|
5206
|
+
/** A finished game: a new one with the same friends (they get an invite). */
|
|
5207
|
+
async rematch() {
|
|
5208
|
+
return new TurnGame(this.host, await this.host.rematch(this.id));
|
|
5209
|
+
}
|
|
5210
|
+
/** Creator, before the start: add an invite link (`link: true`, then read `link`) or list the open seats for anyone (`public`). */
|
|
5211
|
+
async share(share) {
|
|
5212
|
+
this.update(await this.host.share(this.id, share));
|
|
5213
|
+
}
|
|
5214
|
+
/** Invite friends to free seats (before the start). */
|
|
5215
|
+
async invite(userIds) {
|
|
5216
|
+
this.update(await this.host.invite(this.id, userIds));
|
|
5217
|
+
}
|
|
5218
|
+
/** The turn's deadline passed (or its player is suspended): let the computer play it. */
|
|
5219
|
+
async letComputerPlay() {
|
|
5220
|
+
this.update(await this.host.autoPlay(this.id));
|
|
5221
|
+
}
|
|
5222
|
+
/** Say something: `{ text }` between friends (moderated), `{ quick }` (a quick-chat phrase id) with anyone. */
|
|
5223
|
+
say(send) {
|
|
5224
|
+
return this.host.chat(this.id, send);
|
|
5225
|
+
}
|
|
5226
|
+
/** Fetch the game again, and replay moves missed since the last one this page saw. */
|
|
5227
|
+
async refresh() {
|
|
5228
|
+
const before = this.state.summary.seq;
|
|
5229
|
+
const fresh = await this.host.get(this.id);
|
|
5230
|
+
if (fresh.summary.seq > before) {
|
|
5231
|
+
const missed = await this.host.history(this.id, before);
|
|
5232
|
+
this.update(fresh);
|
|
5233
|
+
for (const move of missed) this.emit("move", {
|
|
5234
|
+
seq: move.seq,
|
|
5235
|
+
seat: move.seat,
|
|
5236
|
+
view: this.view,
|
|
5237
|
+
events: move.events.map((event) => toGameData(event))
|
|
5238
|
+
});
|
|
5239
|
+
} else this.update(fresh);
|
|
5240
|
+
}
|
|
5241
|
+
/** Hear moves, turn changes, the end and chat; returns a function that stops listening. */
|
|
5242
|
+
on(eventName, listener) {
|
|
5243
|
+
const set = this.listeners[eventName];
|
|
5244
|
+
set.add(listener);
|
|
5245
|
+
return () => {
|
|
5246
|
+
set.delete(listener);
|
|
5247
|
+
};
|
|
5248
|
+
}
|
|
5249
|
+
/** Stop live updates for this page (the game itself goes on). */
|
|
5250
|
+
close() {
|
|
5251
|
+
if (this.isClosed) return;
|
|
5252
|
+
this.isClosed = true;
|
|
5253
|
+
for (const stop of this.stopListening) stop();
|
|
5254
|
+
this.host.closed(this);
|
|
5255
|
+
}
|
|
5256
|
+
/** Take a fresh answer from the hub; fire `turn` and `ended` when they changed. */
|
|
5257
|
+
update(next) {
|
|
5258
|
+
const previous = this.state;
|
|
5259
|
+
this.state = next;
|
|
5260
|
+
if (previous.summary.seq !== next.summary.seq || previous.summary.phase !== next.summary.phase || !sameSeats(previous.summary, next.summary) || previous.chatMode !== next.chatMode || previous.link !== next.link) this.emit("turn", next.summary);
|
|
5261
|
+
if (previous.summary.phase !== "ended" && next.summary.phase === "ended") this.emit("ended", {
|
|
5262
|
+
outcome: next.outcome,
|
|
5263
|
+
view: this.view
|
|
5264
|
+
});
|
|
5265
|
+
}
|
|
5266
|
+
emit(eventName, payload) {
|
|
5267
|
+
const set = this.listeners[eventName];
|
|
5268
|
+
for (const listener of set) listener(payload);
|
|
5269
|
+
}
|
|
5270
|
+
/** A live `hub:turn` for this game: a move (or a missed run of them), or another change. */
|
|
5271
|
+
onLive(detail) {
|
|
5272
|
+
const seq = readField(detail, "seq");
|
|
5273
|
+
const view = readField(detail, "view");
|
|
5274
|
+
const summary = readField(detail, "summary");
|
|
5275
|
+
if (typeof seq !== "number" || !isSummary(summary) || view === void 0) return;
|
|
5276
|
+
if (seq > this.state.summary.seq + 1) {
|
|
5277
|
+
this.catchUp();
|
|
5278
|
+
return;
|
|
5279
|
+
}
|
|
5280
|
+
const seat = readField(detail, "seat");
|
|
5281
|
+
const events = readField(detail, "events");
|
|
5282
|
+
const outcome = readField(detail, "outcome");
|
|
5283
|
+
const isMove = typeof seat === "number" && seq === this.state.summary.seq + 1 && seq !== this.ownSeq;
|
|
5284
|
+
if (summary.phase !== this.state.summary.phase || !sameSeating(this.state.summary, summary)) this.catchUp();
|
|
5285
|
+
this.update({
|
|
5286
|
+
...this.state,
|
|
5287
|
+
summary,
|
|
5288
|
+
view: toPayloadJson(view),
|
|
5289
|
+
outcome: isOutcome(outcome) ? outcome : this.state.outcome
|
|
5290
|
+
});
|
|
5291
|
+
if (isMove) this.emit("move", {
|
|
5292
|
+
seq,
|
|
5293
|
+
seat,
|
|
5294
|
+
view: this.view,
|
|
5295
|
+
events: Array.isArray(events) ? events.map((event) => toGameData(toPayloadJson(event))) : []
|
|
5296
|
+
});
|
|
5297
|
+
}
|
|
5298
|
+
/** Replay what was missed (one catch-up at a time). */
|
|
5299
|
+
catchUp() {
|
|
5300
|
+
this.catchingUp ?? (this.catchingUp = this.refresh().catch(() => void 0).finally(() => {
|
|
5301
|
+
this.catchingUp = null;
|
|
5302
|
+
}));
|
|
5303
|
+
return this.catchingUp;
|
|
5304
|
+
}
|
|
5305
|
+
listen() {
|
|
5306
|
+
if (!isBrowserPage()) return;
|
|
5307
|
+
const onTurn = (event) => {
|
|
5308
|
+
if (!(event instanceof CustomEvent) || readField(event.detail, "id") !== this.id) return;
|
|
5309
|
+
const detail = event.detail;
|
|
5310
|
+
if (typeof detail === "object" && detail !== null) this.onLive(detail);
|
|
5311
|
+
};
|
|
5312
|
+
const onChat = (event) => {
|
|
5313
|
+
if (event instanceof CustomEvent && readField(event.detail, "id") === this.id) {
|
|
5314
|
+
const line = readField(event.detail, "line");
|
|
5315
|
+
if (isChatLine(line)) {
|
|
5316
|
+
this.state = {
|
|
5317
|
+
...this.state,
|
|
5318
|
+
chat: [...this.state.chat, line].slice(-50)
|
|
5319
|
+
};
|
|
5320
|
+
this.emit("chat", line);
|
|
5321
|
+
}
|
|
5322
|
+
}
|
|
5323
|
+
};
|
|
5324
|
+
const onVisible = () => {
|
|
5325
|
+
if (document.visibilityState === "visible") this.catchUp();
|
|
5326
|
+
};
|
|
5327
|
+
const onResync = () => {
|
|
5328
|
+
this.catchUp();
|
|
5329
|
+
};
|
|
5330
|
+
window.addEventListener(TURN_EVENT, onTurn);
|
|
5331
|
+
window.addEventListener(TURN_CHAT_EVENT, onChat);
|
|
5332
|
+
window.addEventListener(TURN_RESYNC_EVENT, onResync);
|
|
5333
|
+
document.addEventListener("visibilitychange", onVisible);
|
|
5334
|
+
const poll = setInterval(() => {
|
|
5335
|
+
if (!this.host.isLive() && document.visibilityState === "visible") this.catchUp();
|
|
5336
|
+
}, TURN_POLL_MS);
|
|
5337
|
+
this.stopListening.push(() => {
|
|
5338
|
+
window.removeEventListener(TURN_EVENT, onTurn);
|
|
5339
|
+
}, () => {
|
|
5340
|
+
window.removeEventListener(TURN_CHAT_EVENT, onChat);
|
|
5341
|
+
}, () => {
|
|
5342
|
+
window.removeEventListener(TURN_RESYNC_EVENT, onResync);
|
|
5343
|
+
}, () => {
|
|
5344
|
+
document.removeEventListener("visibilitychange", onVisible);
|
|
5345
|
+
}, () => {
|
|
5346
|
+
clearInterval(poll);
|
|
5347
|
+
});
|
|
5348
|
+
}
|
|
5349
|
+
};
|
|
5350
|
+
/** Every seat matches its counterpart by `same`. */
|
|
5351
|
+
function eachSeat(a, b, same) {
|
|
5352
|
+
return a.seats.length === b.seats.length && a.seats.every((seat, index) => {
|
|
5353
|
+
const other = b.seats[index];
|
|
5354
|
+
return other !== void 0 && same(seat, other);
|
|
5355
|
+
});
|
|
5356
|
+
}
|
|
5357
|
+
/** The same players in the same seats with the same statuses (who sits where). */
|
|
5358
|
+
function sameSeating(a, b) {
|
|
5359
|
+
return eachSeat(a, b, (seat, other) => seat.status === other.status && seat.userId === other.userId);
|
|
5360
|
+
}
|
|
5361
|
+
/** The same seating, and the same looks (a player's new avatar is a change: `turn` fires). */
|
|
5362
|
+
function sameSeats(a, b) {
|
|
5363
|
+
return sameSeating(a, b) && eachSeat(a, b, (seat, other) => seat.look === other.look);
|
|
5364
|
+
}
|
|
5365
|
+
/** A game's move as the JSON the hub takes. */
|
|
5366
|
+
function toPayload(move) {
|
|
5367
|
+
return toPayloadJson(move);
|
|
5368
|
+
}
|
|
5369
|
+
/** Any JSON-safe value as `Json` (a JSON round trip). */
|
|
5370
|
+
function toPayloadJson(value) {
|
|
5371
|
+
const parsed = parseJsonText(JSON.stringify(value ?? null));
|
|
5372
|
+
return isJson(parsed) ? parsed : null;
|
|
5373
|
+
}
|
|
5374
|
+
/** Shape checks for a live event's parts (it comes from another bundle). */
|
|
5375
|
+
function isSummary(value) {
|
|
5376
|
+
return typeof readField(value, "phase") === "string" && Array.isArray(readField(value, "seats")) && Array.isArray(readField(value, "toAct"));
|
|
5377
|
+
}
|
|
5378
|
+
function isOutcome(value) {
|
|
5379
|
+
return value === null || Array.isArray(readField(value, "winners"));
|
|
5380
|
+
}
|
|
5381
|
+
function isChatLine(value) {
|
|
5382
|
+
return typeof readField(value, "text") === "string" && typeof readField(value, "from") === "number";
|
|
5383
|
+
}
|
|
5384
|
+
//#endregion
|
|
5385
|
+
//#region src/core/turns/hub-turns.ts
|
|
5386
|
+
/** The query parameter an invite link carries (`/<slug>/?hub_turn_link=<key>`); the client strips it. */
|
|
5387
|
+
const TURN_LINK_PARAM = "hub_turn_link";
|
|
5388
|
+
/** Where the answer is kept for the page. */
|
|
5389
|
+
const PAGE_FLAG = "__HUB_TURN_LINK__";
|
|
5390
|
+
/** Read and strip `hub_turn_link`, once per page. */
|
|
5391
|
+
function linkOnPage() {
|
|
5392
|
+
if (!isBrowserPage()) return null;
|
|
5393
|
+
if (Reflect.has(window, PAGE_FLAG)) {
|
|
5394
|
+
const recorded = Reflect.get(window, PAGE_FLAG);
|
|
5395
|
+
return typeof recorded === "string" ? recorded : null;
|
|
5396
|
+
}
|
|
5397
|
+
let key = null;
|
|
5398
|
+
try {
|
|
5399
|
+
const url = new URL(location.href);
|
|
5400
|
+
key = url.searchParams.get(TURN_LINK_PARAM);
|
|
5401
|
+
if (key !== null) {
|
|
5402
|
+
url.searchParams.delete(TURN_LINK_PARAM);
|
|
5403
|
+
history.replaceState(history.state, "", `${url.pathname}${url.search}${url.hash}`);
|
|
5404
|
+
}
|
|
5405
|
+
} catch {}
|
|
5406
|
+
Reflect.set(window, PAGE_FLAG, key);
|
|
5407
|
+
return key;
|
|
5408
|
+
}
|
|
5409
|
+
/** Whether `idOrKey` is a game id (`sg_…`) rather than a link key. */
|
|
5410
|
+
function isGameId(idOrKey) {
|
|
5411
|
+
return idOrKey.startsWith("sg_");
|
|
5412
|
+
}
|
|
5413
|
+
/** `hub.turns`. */
|
|
5414
|
+
var HubTurns = class {
|
|
5415
|
+
constructor(host) {
|
|
5416
|
+
_defineProperty(this, "host", void 0);
|
|
5417
|
+
this.host = host;
|
|
5418
|
+
}
|
|
5419
|
+
/** The invite link key this page was opened with (`?hub_turn_link=`), taken out of the address bar; `null` when none. Synchronous. */
|
|
5420
|
+
get link() {
|
|
5421
|
+
return linkOnPage();
|
|
5422
|
+
}
|
|
5423
|
+
/** Make a game in this page's game (its lobby): the player takes the first seat. */
|
|
5424
|
+
async create(options = {}) {
|
|
5425
|
+
const body = {
|
|
5426
|
+
game: this.host.slug ?? "",
|
|
5427
|
+
...options.mode === void 0 ? {} : { mode: options.mode },
|
|
5428
|
+
...options.options === void 0 ? {} : { options: toStoredJson(options.options) },
|
|
5429
|
+
...options.invite === void 0 ? {} : { invite: [...options.invite] },
|
|
5430
|
+
...options.link === void 0 ? {} : { link: options.link },
|
|
5431
|
+
...options.public === void 0 ? {} : { public: options.public },
|
|
5432
|
+
...options.seats === void 0 ? {} : { seats: options.seats },
|
|
5433
|
+
...options.bots === void 0 ? {} : { bots: options.bots },
|
|
5434
|
+
...options.deadline === void 0 ? {} : { deadline: options.deadline },
|
|
5435
|
+
...options.autoStart === void 0 ? {} : { autoStart: options.autoStart }
|
|
5436
|
+
};
|
|
5437
|
+
return new TurnGame(this.host, await this.host.create(body));
|
|
5438
|
+
}
|
|
5439
|
+
/** Take a seat: by the game's id (an invite, an open seat) or by an invite link's key. */
|
|
5440
|
+
async join(idOrLinkKey) {
|
|
5441
|
+
const joined = isGameId(idOrLinkKey) ? await this.host.join(idOrLinkKey) : await this.host.joinByLink(idOrLinkKey);
|
|
5442
|
+
return new TurnGame(this.host, joined);
|
|
5443
|
+
}
|
|
5444
|
+
/** Open a game the player sits in (from `hub.saved.opened`, the Continue list, an invite). */
|
|
5445
|
+
async open(id) {
|
|
5446
|
+
return new TurnGame(this.host, await this.host.get(id));
|
|
5447
|
+
}
|
|
5448
|
+
/** This game's online games the player sits in (the same rows as `hub.saved.list({ kinds: ['turns'] })`). */
|
|
5449
|
+
list() {
|
|
5450
|
+
return this.host.list();
|
|
5451
|
+
}
|
|
5452
|
+
/** Open-seat games of this game anyone may join (none across a block). */
|
|
5453
|
+
openLobbies() {
|
|
5454
|
+
return this.host.listOpen(this.host.slug);
|
|
5455
|
+
}
|
|
5456
|
+
};
|
|
5457
|
+
//#endregion
|
|
5458
|
+
//#region src/core/ticket.ts
|
|
5459
|
+
/**
|
|
5460
|
+
* The own-server ticket request (`hub.mp.ticket()`): a 60-second identity
|
|
5461
|
+
* ticket for this game's own server, fetched straight from the hub. It must
|
|
5462
|
+
* not depend on the social runtime: on the first visit after a deploy the
|
|
5463
|
+
* service worker can still serve an older menu that never loads it. When the
|
|
5464
|
+
* page has no session yet, a guest one is created and the request retried
|
|
5465
|
+
* once.
|
|
5466
|
+
*/
|
|
5467
|
+
/** A REST client for the page's API base that uses the global fetch at call time. */
|
|
5468
|
+
function pageRestClient() {
|
|
5469
|
+
return createRestClient({
|
|
5470
|
+
apiBase: detectApiBase(),
|
|
5471
|
+
fetch: (input, init) => globalThis.fetch(input, init)
|
|
5472
|
+
});
|
|
5473
|
+
}
|
|
5474
|
+
/** One ticket request; a transport failure is an {@link OfflineError}. */
|
|
5475
|
+
async function requestTicket(rest, slug) {
|
|
5476
|
+
try {
|
|
5477
|
+
return (await rest.call("getGameServerTicket", { slug })).ticket;
|
|
5478
|
+
} catch (failure) {
|
|
5479
|
+
throw failure instanceof HubError && failure.isOffline ? new OfflineError() : failure;
|
|
5480
|
+
}
|
|
5481
|
+
}
|
|
5482
|
+
/** A ticket for this page's game; signs in a guest first when the page has no session. */
|
|
5483
|
+
async function fetchGameServerTicket() {
|
|
5484
|
+
const slug = detectSlug();
|
|
5485
|
+
if (slug === null) throw new HubError("Hub: could not determine the game slug.");
|
|
5486
|
+
const rest = pageRestClient();
|
|
5487
|
+
try {
|
|
5488
|
+
return await requestTicket(rest, slug);
|
|
5489
|
+
} catch (failure) {
|
|
5490
|
+
if (!(failure instanceof HubError) || failure.status !== 401) throw failure;
|
|
3640
5491
|
await rest.call("ensureGuestSession");
|
|
3641
5492
|
return requestTicket(rest, slug);
|
|
3642
5493
|
}
|
|
@@ -3746,6 +5597,11 @@ var Hub = class extends HubEconomy {
|
|
|
3746
5597
|
super(..._args);
|
|
3747
5598
|
_defineProperty(this, "clientVersion", HUB_CLIENT_VERSION);
|
|
3748
5599
|
_defineProperty(this, "resumeApi", void 0);
|
|
5600
|
+
_defineProperty(this, "avatarApi", void 0);
|
|
5601
|
+
_defineProperty(this, "savedApi", void 0);
|
|
5602
|
+
_defineProperty(this, "dailySavesApi", void 0);
|
|
5603
|
+
_defineProperty(this, "turnsApi", void 0);
|
|
5604
|
+
_defineProperty(this, "openTurnGames", /* @__PURE__ */ new Set());
|
|
3749
5605
|
}
|
|
3750
5606
|
/**
|
|
3751
5607
|
* The hub's version and the client versions it supports (`GET /_api/version`),
|
|
@@ -3820,6 +5676,128 @@ var Hub = class extends HubEconomy {
|
|
|
3820
5676
|
this.resumeApi ?? (this.resumeApi = new HubResume(this.resumeHost()));
|
|
3821
5677
|
return this.resumeApi;
|
|
3822
5678
|
}
|
|
5679
|
+
/**
|
|
5680
|
+
* Players' avatars: `hub.avatar.mine()`, `of(userId)`, `from(seat)`,
|
|
5681
|
+
* `bot(seed)`, `on('change')`, `openDesigner()` and `load()` for the renderer
|
|
5682
|
+
* (docs: "Avatars"). No art or renderer until `load()`.
|
|
5683
|
+
*/
|
|
5684
|
+
get avatar() {
|
|
5685
|
+
this.avatarApi ?? (this.avatarApi = new HubAvatar({
|
|
5686
|
+
currentUserId: () => this.userId(),
|
|
5687
|
+
onAccount: (listener) => this.account.on(listener),
|
|
5688
|
+
fetchMine: () => this.rest.call("getMyAvatar"),
|
|
5689
|
+
fetchCodes: async (ids) => (await this.rest.call("getAvatars", { ids: [...ids] })).codes,
|
|
5690
|
+
queueSave: async (code, savedAt) => {
|
|
5691
|
+
await this.mutateOffline("hub.avatar", {
|
|
5692
|
+
code,
|
|
5693
|
+
savedAt
|
|
5694
|
+
}, () => void 0);
|
|
5695
|
+
}
|
|
5696
|
+
}));
|
|
5697
|
+
return this.avatarApi;
|
|
5698
|
+
}
|
|
5699
|
+
/**
|
|
5700
|
+
* Every game the player has in progress, of every kind: `hub.saved.list()`
|
|
5701
|
+
* for this game's "Continue" list, `opened` and `onOpen` to open the one
|
|
5702
|
+
* the player picked (docs: "Saved games").
|
|
5703
|
+
*/
|
|
5704
|
+
get saved() {
|
|
5705
|
+
this.savedApi ?? (this.savedApi = new HubSaved(this.savedHost()));
|
|
5706
|
+
return this.savedApi;
|
|
5707
|
+
}
|
|
5708
|
+
/**
|
|
5709
|
+
* Online games played over minutes or days, run by the hub from the
|
|
5710
|
+
* game's rules module (`game.turns`): `hub.turns.create`, `join`, `open`
|
|
5711
|
+
* (docs/multiplayer.md "Turn games").
|
|
5712
|
+
*/
|
|
5713
|
+
get turns() {
|
|
5714
|
+
this.turnsApi ?? (this.turnsApi = new HubTurns(this.turnsHost()));
|
|
5715
|
+
return this.turnsApi;
|
|
5716
|
+
}
|
|
5717
|
+
/** What `hub.turns` needs from this client: its REST calls. */
|
|
5718
|
+
turnsHost() {
|
|
5719
|
+
const { rest } = this;
|
|
5720
|
+
return {
|
|
5721
|
+
slug: this.slug,
|
|
5722
|
+
create: (body) => rest.call("createTurnGame", body),
|
|
5723
|
+
join: (id) => rest.call("joinTurnGame", { id }),
|
|
5724
|
+
joinByLink: (key) => rest.call("joinTurnGameByLink", { key }),
|
|
5725
|
+
get: (id) => rest.call("getTurnGame", { id }),
|
|
5726
|
+
start: (id) => rest.call("startTurnGame", { id }),
|
|
5727
|
+
move: (id, seq, move) => rest.call("moveTurnGame", {
|
|
5728
|
+
id,
|
|
5729
|
+
seq,
|
|
5730
|
+
move
|
|
5731
|
+
}),
|
|
5732
|
+
history: async (id, since) => (await rest.call("getTurnGameHistory", {
|
|
5733
|
+
id,
|
|
5734
|
+
since
|
|
5735
|
+
})).moves,
|
|
5736
|
+
leave: async (id) => {
|
|
5737
|
+
await rest.call("leaveTurnGame", { id });
|
|
5738
|
+
},
|
|
5739
|
+
nudge: async (id) => {
|
|
5740
|
+
await rest.call("nudgeTurnGame", { id });
|
|
5741
|
+
},
|
|
5742
|
+
setNotify: async (id, mode) => {
|
|
5743
|
+
await rest.call("setTurnGameNotify", {
|
|
5744
|
+
id,
|
|
5745
|
+
mode
|
|
5746
|
+
});
|
|
5747
|
+
},
|
|
5748
|
+
rematch: (id) => rest.call("rematchTurnGame", { id }),
|
|
5749
|
+
invite: (id, userIds) => rest.call("inviteToTurnGame", {
|
|
5750
|
+
id,
|
|
5751
|
+
userIds: [...userIds]
|
|
5752
|
+
}),
|
|
5753
|
+
share: (id, share) => rest.call("shareTurnGame", {
|
|
5754
|
+
id,
|
|
5755
|
+
...share
|
|
5756
|
+
}),
|
|
5757
|
+
autoPlay: (id) => rest.call("autoPlayTurn", { id }),
|
|
5758
|
+
chat: (id, send) => rest.call("chatInTurnGame", {
|
|
5759
|
+
id,
|
|
5760
|
+
...send
|
|
5761
|
+
}),
|
|
5762
|
+
listOpen: async (game) => (await rest.call("listOpenTurnGames", game === null ? {} : { game })).games,
|
|
5763
|
+
list: () => this.saved.list({ kinds: ["turns"] }),
|
|
5764
|
+
opened: (game) => {
|
|
5765
|
+
this.openTurnGames.add(game);
|
|
5766
|
+
},
|
|
5767
|
+
closed: (game) => {
|
|
5768
|
+
this.openTurnGames.delete(game);
|
|
5769
|
+
},
|
|
5770
|
+
isLive: () => pageBridge.current() !== null
|
|
5771
|
+
};
|
|
5772
|
+
}
|
|
5773
|
+
/** What `hub.saved` needs from this client. */
|
|
5774
|
+
savedHost() {
|
|
5775
|
+
return {
|
|
5776
|
+
slug: this.slug,
|
|
5777
|
+
fetchList: (game) => this.withFallback(async () => (await this.rest.call("listMySavedGames", game === null ? {} : { game })).games, () => void 0),
|
|
5778
|
+
deviceCopies: () => {
|
|
5779
|
+
const userId = this.store.getUser()?.id ?? null;
|
|
5780
|
+
return {
|
|
5781
|
+
runs: new Map([...this.resumeStore.all()].filter(([, copy]) => copy.userId === userId)),
|
|
5782
|
+
dailies: this.dailyStore.all(userId)
|
|
5783
|
+
};
|
|
5784
|
+
},
|
|
5785
|
+
discard: (entry) => this.discardSaved(entry),
|
|
5786
|
+
navigate: (url) => {
|
|
5787
|
+
location.assign(url);
|
|
5788
|
+
}
|
|
5789
|
+
};
|
|
5790
|
+
}
|
|
5791
|
+
/** Discard a daily or run: this game's run through `hub.resume` (works offline), others on the hub. */
|
|
5792
|
+
async discardSaved(entry) {
|
|
5793
|
+
if (entry.kind === "run" && entry.game === this.slug) {
|
|
5794
|
+
await this.resume.clear();
|
|
5795
|
+
return;
|
|
5796
|
+
}
|
|
5797
|
+
if (!entry.id.startsWith("local:")) await this.rest.call("discardSavedGame", { id: entry.id });
|
|
5798
|
+
if (entry.kind === "run") this.resumeStore.remove(entry.game);
|
|
5799
|
+
else if (entry.daily !== void 0) this.dailyStore.remove(entry.game, entry.daily.day);
|
|
5800
|
+
}
|
|
3823
5801
|
/** What `hub.resume` needs from this client. */
|
|
3824
5802
|
resumeHost() {
|
|
3825
5803
|
return {
|
|
@@ -3829,7 +5807,12 @@ var Hub = class extends HubEconomy {
|
|
|
3829
5807
|
gameTitle: () => detectGameTitle(this.slug ?? ""),
|
|
3830
5808
|
blocker: () => {
|
|
3831
5809
|
if (raceForwarder.current() !== null) return "race";
|
|
3832
|
-
|
|
5810
|
+
if (this.openTurnGames.size > 0) return "turns";
|
|
5811
|
+
return isInHubRoom() ? "room" : null;
|
|
5812
|
+
},
|
|
5813
|
+
daily: () => {
|
|
5814
|
+
const daily = this.daily;
|
|
5815
|
+
return daily.active === null ? null : daily;
|
|
3833
5816
|
},
|
|
3834
5817
|
send: async (payload) => {
|
|
3835
5818
|
await this.mutateOffline(RESUME_KIND, payload, () => void 0);
|
|
@@ -3845,24 +5828,58 @@ var Hub = class extends HubEconomy {
|
|
|
3845
5828
|
fetchList: () => this.withFallback(async () => (await this.rest.call("listMyResumeSlots")).games, () => void 0),
|
|
3846
5829
|
clearOther: async (slug) => {
|
|
3847
5830
|
await this.rest.call("clearResumeSlot", { slug });
|
|
5831
|
+
},
|
|
5832
|
+
onChange: () => {
|
|
5833
|
+
this.savedApi?.changed();
|
|
3848
5834
|
}
|
|
3849
5835
|
};
|
|
3850
5836
|
}
|
|
3851
|
-
/**
|
|
5837
|
+
/** This game's daily saves (device copies and their sends). */
|
|
5838
|
+
dailySaves() {
|
|
5839
|
+
const slug = this.slug;
|
|
5840
|
+
if (slug === null) throw new HubError("hub.daily: could not determine the game slug.");
|
|
5841
|
+
this.dailySavesApi ?? (this.dailySavesApi = new DailySaves({
|
|
5842
|
+
slug,
|
|
5843
|
+
local: this.dailyStore,
|
|
5844
|
+
currentUserId: () => this.store.getUser()?.id ?? null,
|
|
5845
|
+
gameTitle: () => detectGameTitle(slug),
|
|
5846
|
+
send: async (payload) => {
|
|
5847
|
+
await this.mutateOffline("hub.saved", payload, () => void 0);
|
|
5848
|
+
this.savedApi?.changed();
|
|
5849
|
+
}
|
|
5850
|
+
}));
|
|
5851
|
+
return this.dailySavesApi;
|
|
5852
|
+
}
|
|
5853
|
+
/** What the daily system needs from this client: its REST calls, the device copies and window events. */
|
|
3852
5854
|
dailyHost() {
|
|
3853
5855
|
return {
|
|
3854
5856
|
slug: this.slug,
|
|
3855
5857
|
status: () => this.rest.call("getGameDailyChallenges", this.gameFields()),
|
|
3856
5858
|
all: () => this.rest.call("listDailyChallengesForAllGames"),
|
|
3857
|
-
start: (day) => this.rest.call("startDailyChallenge", {
|
|
5859
|
+
start: (day) => this.withFallback(async () => this.dailySaves().remember(await this.rest.call("startDailyChallenge", {
|
|
3858
5860
|
...this.gameFields(),
|
|
3859
5861
|
day
|
|
3860
|
-
}),
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
5862
|
+
})), () => this.dailySaves().startLocally(day)),
|
|
5863
|
+
resume: async (instanceId) => this.dailySaves().remember(await this.rest.call("resumeDailyChallenge", { id: instanceId })),
|
|
5864
|
+
complete: async (day, token, meta) => {
|
|
5865
|
+
this.dailySaves().finished(day);
|
|
5866
|
+
const completed = await this.mutateOffline(DAILY_KIND, {
|
|
5867
|
+
day,
|
|
5868
|
+
token,
|
|
5869
|
+
meta
|
|
5870
|
+
}, () => null, (result) => readDailyCompletion(result));
|
|
5871
|
+
if (completed !== null) this.dailySaves().completed(day);
|
|
5872
|
+
this.savedApi?.changed();
|
|
5873
|
+
return completed;
|
|
5874
|
+
},
|
|
5875
|
+
save: (challenge, input) => {
|
|
5876
|
+
const isSaved = this.dailySaves().save(challenge, input);
|
|
5877
|
+
this.savedApi?.changed();
|
|
5878
|
+
return isSaved;
|
|
5879
|
+
},
|
|
5880
|
+
flush: () => this.dailySaves().flush(),
|
|
5881
|
+
discard: (challenge) => this.dailySaves().discard(challenge),
|
|
5882
|
+
inProgress: () => this.saved.list({ kinds: ["daily"] }),
|
|
3866
5883
|
emit: (eventName, detail) => {
|
|
3867
5884
|
emitWindowEvent(eventName, detail);
|
|
3868
5885
|
}
|
|
@@ -3877,11 +5894,12 @@ linkRealtimeToHub({
|
|
|
3877
5894
|
/** A ready-to-use client, with the slug and API base detected from the page. */
|
|
3878
5895
|
const hub = new Hub();
|
|
3879
5896
|
isResumeRequested();
|
|
5897
|
+
savedOpenedOnPage();
|
|
3880
5898
|
/** Create a client for another slug or API base, or with other offline behaviour. */
|
|
3881
5899
|
function createHub(options = {}) {
|
|
3882
5900
|
return new Hub(options);
|
|
3883
5901
|
}
|
|
3884
5902
|
//#endregion
|
|
3885
|
-
export {
|
|
5903
|
+
export { UNSUPPORTED_BY_HUB as A, OUTBOX_MAX_CHARS as C, HubError as D, ContentRejectedError as E, OfflineError as O, OUTBOX_FULL_CODE as S, OfflineApi as T, savedGameUrl as _, TURN_LINK_PARAM as a, RESUME_MAX_STATE_BYTES as b, TURN_POLL_MS as c, HubSaved as d, SAVED_CHANGE_QUIET_MS as f, SAVED_OPEN_PARAM as g, SAVED_OPEN_EVENT as h, HubTurns as i, HubAvatar as j, SAVE_UNKNOWN_CODE as k, TURN_RESYNC_EVENT as l, SAVED_KIND_PARAM as m, createHub as n, TURN_CHAT_EVENT as o, SAVED_CHANGE_EVENT as p, hub as r, TURN_EVENT as s, Hub as t, TurnGame as u, HubResume as v, OUTBOX_MAX_ENTRIES as w, CLIENT_VERSION_HEADER as x, RESUME_DEBOUNCE_MS as y };
|
|
3886
5904
|
|
|
3887
5905
|
//# sourceMappingURL=src.js.map
|