@diffenderfer-games/hub 2.0.0-next.5 → 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 -19
- 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 +503 -14
- 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 +2020 -144
- 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 +8 -0
- package/src/core/errors.ts +7 -1
- package/src/core/hub-progress.ts +269 -238
- package/src/core/hub.ts +174 -6
- package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
- package/src/core/offline/hub-kinds/index.ts +9 -0
- package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
- 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 +10 -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
|
|
@@ -81,6 +411,7 @@ var HubError = class extends Error {
|
|
|
81
411
|
_defineProperty(this, "hint", void 0);
|
|
82
412
|
_defineProperty(this, "retryAfterMs", void 0);
|
|
83
413
|
_defineProperty(this, "until", void 0);
|
|
414
|
+
_defineProperty(this, "rule", void 0);
|
|
84
415
|
this.name = "HubError";
|
|
85
416
|
this.status = status;
|
|
86
417
|
this.code = details.code;
|
|
@@ -88,6 +419,7 @@ var HubError = class extends Error {
|
|
|
88
419
|
this.hint = details.hint;
|
|
89
420
|
this.retryAfterMs = details.retryAfterMs;
|
|
90
421
|
this.until = details.until;
|
|
422
|
+
this.rule = details.rule;
|
|
91
423
|
}
|
|
92
424
|
/** `true` when the failure was a network or transport error, not an HTTP status. */
|
|
93
425
|
get isOffline() {
|
|
@@ -123,12 +455,13 @@ function readErrorFields(body) {
|
|
|
123
455
|
reason: optionalString(body.reason),
|
|
124
456
|
hint: optionalString(body.hint),
|
|
125
457
|
retryAfterMs: optionalNumber(body.retryAfterMs),
|
|
126
|
-
until: optionalNumber(body.until)
|
|
458
|
+
until: optionalNumber(body.until),
|
|
459
|
+
rule: optionalString(body.rule)
|
|
127
460
|
};
|
|
128
461
|
}
|
|
129
462
|
/** The right error class for a failure: a 422 or `content_rejected` is a {@link ContentRejectedError}. */
|
|
130
463
|
function errorFor(message, status, details) {
|
|
131
|
-
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);
|
|
132
465
|
}
|
|
133
466
|
/**
|
|
134
467
|
* Re-create an error from another bundle (the social runtime, or another
|
|
@@ -144,50 +477,6 @@ function toHubError(thrown) {
|
|
|
144
477
|
return errorFor(message, optionalNumber(thrown.status), fields);
|
|
145
478
|
}
|
|
146
479
|
//#endregion
|
|
147
|
-
//#region src/core/storage/key-value.ts
|
|
148
|
-
/** A store backed by a `Storage` (`localStorage`). */
|
|
149
|
-
function storageBacked(storage) {
|
|
150
|
-
return {
|
|
151
|
-
read: (key) => storage.getItem(key),
|
|
152
|
-
write: (key, text) => {
|
|
153
|
-
try {
|
|
154
|
-
storage.setItem(key, text);
|
|
155
|
-
} catch {}
|
|
156
|
-
},
|
|
157
|
-
remove: (key) => {
|
|
158
|
-
storage.removeItem(key);
|
|
159
|
-
},
|
|
160
|
-
keys: () => Object.keys(storage)
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
/** A store held in memory for this page only. */
|
|
164
|
-
function createMemoryStore() {
|
|
165
|
-
const entries = /* @__PURE__ */ new Map();
|
|
166
|
-
return {
|
|
167
|
-
read: (key) => entries.get(key) ?? null,
|
|
168
|
-
write: (key, text) => {
|
|
169
|
-
entries.set(key, text);
|
|
170
|
-
},
|
|
171
|
-
remove: (key) => {
|
|
172
|
-
entries.delete(key);
|
|
173
|
-
},
|
|
174
|
-
keys: () => [...entries.keys()]
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
/** The page's `localStorage`, or `undefined` when it is missing or throws (privacy mode). */
|
|
178
|
-
function pageStorage() {
|
|
179
|
-
try {
|
|
180
|
-
return typeof localStorage === "undefined" ? void 0 : localStorage;
|
|
181
|
-
} catch {
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
/** `localStorage` when available, else a fresh in-memory store. */
|
|
186
|
-
function createPageStore() {
|
|
187
|
-
const storage = pageStorage();
|
|
188
|
-
return storage === void 0 ? createMemoryStore() : storageBacked(storage);
|
|
189
|
-
}
|
|
190
|
-
//#endregion
|
|
191
480
|
//#region src/core/offline/favorites.ts
|
|
192
481
|
/**
|
|
193
482
|
* Starred games are site-wide, not per game, so they live under one global
|
|
@@ -261,6 +550,36 @@ function gameKind(definition) {
|
|
|
261
550
|
};
|
|
262
551
|
}
|
|
263
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
|
|
264
583
|
//#region src/core/offline/hub-kinds/daily-kind.ts
|
|
265
584
|
/** The kind name. */
|
|
266
585
|
const DAILY_KIND = "hub.daily";
|
|
@@ -725,6 +1044,74 @@ function resumeKinds(game, local) {
|
|
|
725
1044
|
}) };
|
|
726
1045
|
}
|
|
727
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
|
|
728
1115
|
//#region src/core/offline/hub-kinds/legacy-ops.ts
|
|
729
1116
|
/** The outbox operation for a v1 queued write, or `undefined` when it no longer reads as one. */
|
|
730
1117
|
function legacyOperation(op) {
|
|
@@ -796,7 +1183,9 @@ function registerHubKinds(outbox, hooks) {
|
|
|
796
1183
|
...progressKinds(hooks.game, hooks.cloudSaves),
|
|
797
1184
|
...dailyKinds(hooks.game, hooks.onDailyComplete),
|
|
798
1185
|
...economyKinds(hooks.game, writeLocalFavorites),
|
|
799
|
-
...resumeKinds(hooks.game, hooks.resume)
|
|
1186
|
+
...resumeKinds(hooks.game, hooks.resume),
|
|
1187
|
+
...avatarKinds(),
|
|
1188
|
+
...savedKinds(hooks.dailies)
|
|
800
1189
|
};
|
|
801
1190
|
for (const [kind, definition] of Object.entries(kinds)) outbox.define(kind, definition);
|
|
802
1191
|
}
|
|
@@ -941,7 +1330,7 @@ function withoutGoods(inventory, goods) {
|
|
|
941
1330
|
//#region src/core/offline/local-store.ts
|
|
942
1331
|
/** The key of the locally known user, shared by every game (like the real session). */
|
|
943
1332
|
const USER_KEY = "hub:user";
|
|
944
|
-
function readUser(stored) {
|
|
1333
|
+
function readUser$1(stored) {
|
|
945
1334
|
if (!isRecord(stored)) return null;
|
|
946
1335
|
const id = optionalNumber(stored.id);
|
|
947
1336
|
const username = optionalString(stored.username);
|
|
@@ -1013,7 +1402,7 @@ var LocalStore = class {
|
|
|
1013
1402
|
}
|
|
1014
1403
|
/** The locally known user. */
|
|
1015
1404
|
getUser() {
|
|
1016
|
-
return readUser(this.readStored(USER_KEY));
|
|
1405
|
+
return readUser$1(this.readStored(USER_KEY));
|
|
1017
1406
|
}
|
|
1018
1407
|
/** Remember the user (offline sign-in, or a snapshot). */
|
|
1019
1408
|
setUser(user) {
|
|
@@ -1286,7 +1675,7 @@ function newEntryId(now, sequence) {
|
|
|
1286
1675
|
return `${String(now).padStart(TIME_DIGITS, "0")}-${String(sequence % 10 ** SEQUENCE_DIGITS).padStart(SEQUENCE_DIGITS, "0")}-${tag}`;
|
|
1287
1676
|
}
|
|
1288
1677
|
/** A stored entry, or `undefined` when it is malformed. */
|
|
1289
|
-
function readEntry(stored) {
|
|
1678
|
+
function readEntry$1(stored) {
|
|
1290
1679
|
if (!isRecord(stored) || !isJson(stored.payload)) return;
|
|
1291
1680
|
const id = optionalString(stored.id);
|
|
1292
1681
|
const kind = optionalString(stored.kind);
|
|
@@ -1509,7 +1898,7 @@ var OutboxStore = class {
|
|
|
1509
1898
|
const entries = [];
|
|
1510
1899
|
for (const key of this.entryKeys()) {
|
|
1511
1900
|
const text = this.store.read(key);
|
|
1512
|
-
const entry = text === null ? void 0 : readEntry(parseJsonText(text));
|
|
1901
|
+
const entry = text === null ? void 0 : readEntry$1(parseJsonText(text));
|
|
1513
1902
|
if (entry === void 0) this.store.remove(key);
|
|
1514
1903
|
else entries.push(entry);
|
|
1515
1904
|
}
|
|
@@ -1522,7 +1911,7 @@ var OutboxStore = class {
|
|
|
1522
1911
|
/** One entry, or `undefined` once it is gone (replayed, or removed by another tab). */
|
|
1523
1912
|
get(id) {
|
|
1524
1913
|
const text = this.store.read(this.prefix + id);
|
|
1525
|
-
return text === null ? void 0 : readEntry(parseJsonText(text));
|
|
1914
|
+
return text === null ? void 0 : readEntry$1(parseJsonText(text));
|
|
1526
1915
|
}
|
|
1527
1916
|
/** Whether `extra` more stored characters in one more entry still fit under the caps. */
|
|
1528
1917
|
fits(extraChars) {
|
|
@@ -1777,8 +2166,8 @@ function outboxFull() {
|
|
|
1777
2166
|
}
|
|
1778
2167
|
//#endregion
|
|
1779
2168
|
//#region src/core/resume/local-resume.ts
|
|
1780
|
-
const KEY_PATTERN = /^hub:(.+):resume$/;
|
|
1781
|
-
function keyFor(slug) {
|
|
2169
|
+
const KEY_PATTERN$1 = /^hub:(.+):resume$/;
|
|
2170
|
+
function keyFor$1(slug) {
|
|
1782
2171
|
return `hub:${slug}:resume`;
|
|
1783
2172
|
}
|
|
1784
2173
|
/** A stored record read back, or `null` when missing or malformed. */
|
|
@@ -1820,18 +2209,18 @@ var LocalResumeStore = class {
|
|
|
1820
2209
|
}
|
|
1821
2210
|
/** `slug`'s copy, or `null`. */
|
|
1822
2211
|
read(slug) {
|
|
1823
|
-
const text = this.store.read(keyFor(slug));
|
|
2212
|
+
const text = this.store.read(keyFor$1(slug));
|
|
1824
2213
|
return text === null ? null : readRecord(parseJsonText(text));
|
|
1825
2214
|
}
|
|
1826
2215
|
/** Store `slug`'s copy; answers whether it stuck (a full storage refuses a large state). */
|
|
1827
2216
|
write(slug, record) {
|
|
1828
2217
|
const text = JSON.stringify(record);
|
|
1829
|
-
this.store.write(keyFor(slug), text);
|
|
1830
|
-
return this.store.read(keyFor(slug)) === text;
|
|
2218
|
+
this.store.write(keyFor$1(slug), text);
|
|
2219
|
+
return this.store.read(keyFor$1(slug)) === text;
|
|
1831
2220
|
}
|
|
1832
2221
|
/** Forget `slug`'s copy. */
|
|
1833
2222
|
remove(slug) {
|
|
1834
|
-
this.store.remove(keyFor(slug));
|
|
2223
|
+
this.store.remove(keyFor$1(slug));
|
|
1835
2224
|
}
|
|
1836
2225
|
/** The hub now has `slug`'s save or clear made at `at`: mark it synced (a clear is simply forgotten). */
|
|
1837
2226
|
markSynced(slug, at) {
|
|
@@ -1847,7 +2236,7 @@ var LocalResumeStore = class {
|
|
|
1847
2236
|
all() {
|
|
1848
2237
|
const records = /* @__PURE__ */ new Map();
|
|
1849
2238
|
for (const key of this.store.keys()) {
|
|
1850
|
-
const slug = KEY_PATTERN.exec(key)?.[1];
|
|
2239
|
+
const slug = KEY_PATTERN$1.exec(key)?.[1];
|
|
1851
2240
|
const record = slug === void 0 ? null : this.read(slug);
|
|
1852
2241
|
if (slug !== void 0 && record !== null) records.set(slug, record);
|
|
1853
2242
|
}
|
|
@@ -1855,6 +2244,98 @@ var LocalResumeStore = class {
|
|
|
1855
2244
|
}
|
|
1856
2245
|
};
|
|
1857
2246
|
//#endregion
|
|
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}`;
|
|
2256
|
+
}
|
|
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;
|
|
2272
|
+
return {
|
|
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
|
|
1858
2339
|
//#region src/core/page.ts
|
|
1859
2340
|
/** Whether this code runs in a browser page. */
|
|
1860
2341
|
function isBrowserPage() {
|
|
@@ -2084,6 +2565,12 @@ const ROUTES = {
|
|
|
2084
2565
|
query: [],
|
|
2085
2566
|
offline: null
|
|
2086
2567
|
},
|
|
2568
|
+
resumeDailyChallenge: {
|
|
2569
|
+
method: "POST",
|
|
2570
|
+
path: "/daily/instances/:id/resume",
|
|
2571
|
+
query: [],
|
|
2572
|
+
offline: null
|
|
2573
|
+
},
|
|
2087
2574
|
startDailyChallenge: {
|
|
2088
2575
|
method: "POST",
|
|
2089
2576
|
path: "/games/:slug/daily/:day/start",
|
|
@@ -2324,6 +2811,12 @@ const ROUTES = {
|
|
|
2324
2811
|
query: [],
|
|
2325
2812
|
offline: null
|
|
2326
2813
|
},
|
|
2814
|
+
setQuietHours: {
|
|
2815
|
+
method: "PUT",
|
|
2816
|
+
path: "/social/notify/quiet",
|
|
2817
|
+
query: [],
|
|
2818
|
+
offline: null
|
|
2819
|
+
},
|
|
2327
2820
|
subscribeToPush: {
|
|
2328
2821
|
method: "POST",
|
|
2329
2822
|
path: "/social/push",
|
|
@@ -2556,20 +3049,188 @@ const ROUTES = {
|
|
|
2556
3049
|
path: "/me/resume",
|
|
2557
3050
|
query: [],
|
|
2558
3051
|
offline: null
|
|
2559
|
-
}
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
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
|
|
3220
|
+
}
|
|
3221
|
+
};
|
|
3222
|
+
/** The request header that carries an offline operation key (the contract's `OFFLINE_OP_HEADER`). */
|
|
3223
|
+
const OFFLINE_OP_HEADER = "x-hub-op";
|
|
3224
|
+
//#endregion
|
|
3225
|
+
//#region src/core/rest/rest-client.ts
|
|
3226
|
+
/**
|
|
3227
|
+
* The REST transport: a small fetch router over the contract's route table
|
|
3228
|
+
* (plan §12 A10). It splits one merged fields object into the path, query
|
|
3229
|
+
* and JSON body the way ayepi's client does, stamps `x-hub-client`, and turns
|
|
3230
|
+
* failures into {@link HubError}s. It carries no schemas: replies are typed
|
|
3231
|
+
* from the contract and trusted, as ayepi's zod-free client trusts them.
|
|
3232
|
+
*/
|
|
3233
|
+
/** The header that tells the hub which client version is calling. */
|
|
2573
3234
|
const CLIENT_VERSION_HEADER = "x-hub-client";
|
|
2574
3235
|
/** What both hubs answer for a route they do not have. */
|
|
2575
3236
|
const NO_SUCH_ENDPOINT = "No such API endpoint.";
|
|
@@ -2680,6 +3341,7 @@ var HubConnection = class {
|
|
|
2680
3341
|
_defineProperty(this, "store", void 0);
|
|
2681
3342
|
_defineProperty(this, "outbox", void 0);
|
|
2682
3343
|
_defineProperty(this, "resumeStore", void 0);
|
|
3344
|
+
_defineProperty(this, "dailyStore", void 0);
|
|
2683
3345
|
_defineProperty(this, "offlineMode", void 0);
|
|
2684
3346
|
_defineProperty(this, "isReachable", true);
|
|
2685
3347
|
_defineProperty(this, "runningSync", null);
|
|
@@ -2702,6 +3364,7 @@ var HubConnection = class {
|
|
|
2702
3364
|
const pageStore = createPageStore();
|
|
2703
3365
|
this.store = new LocalStore(this.slug ?? "app", pageStore);
|
|
2704
3366
|
this.resumeStore = new LocalResumeStore(pageStore);
|
|
3367
|
+
this.dailyStore = new LocalDailyStore(pageStore);
|
|
2705
3368
|
this.outbox = new Outbox({
|
|
2706
3369
|
slug: this.slug ?? "app",
|
|
2707
3370
|
store: pageStore,
|
|
@@ -2720,6 +3383,7 @@ var HubConnection = class {
|
|
|
2720
3383
|
registerHubKinds(this.outbox, {
|
|
2721
3384
|
game: () => this.gameFields(),
|
|
2722
3385
|
onDailyComplete: (payload, completion) => {
|
|
3386
|
+
if (this.slug !== null) this.dailyStore.remove(this.slug, payload.day);
|
|
2723
3387
|
emitWindowEvent("hub:daily-complete", {
|
|
2724
3388
|
day: payload.day,
|
|
2725
3389
|
count: completion.count,
|
|
@@ -2728,6 +3392,7 @@ var HubConnection = class {
|
|
|
2728
3392
|
});
|
|
2729
3393
|
},
|
|
2730
3394
|
resume: this.resumeStore,
|
|
3395
|
+
dailies: this.dailyStore,
|
|
2731
3396
|
cloudSaves: this.store
|
|
2732
3397
|
});
|
|
2733
3398
|
this.importLegacyQueue();
|
|
@@ -2960,6 +3625,93 @@ var HubConnection = class {
|
|
|
2960
3625
|
}
|
|
2961
3626
|
};
|
|
2962
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
|
|
2963
3715
|
//#region src/core/hub-progress.ts
|
|
2964
3716
|
/** A profile with the game's own data typed as `TData`. */
|
|
2965
3717
|
function typedProfile(profile) {
|
|
@@ -2977,9 +3729,29 @@ function typedSave(save) {
|
|
|
2977
3729
|
}
|
|
2978
3730
|
/** Accounts, profile, saves, stats and leaderboards. */
|
|
2979
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
|
+
}
|
|
2980
3748
|
/** The current user (or `null`) and the games they have a profile in. */
|
|
2981
3749
|
me() {
|
|
2982
|
-
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
|
+
}, () => {
|
|
2983
3755
|
const profile = this.store.getProfile();
|
|
2984
3756
|
return {
|
|
2985
3757
|
user: this.store.getUser(),
|
|
@@ -2999,13 +3771,11 @@ var HubProgress = class extends HubConnection {
|
|
|
2999
3771
|
return this.withFallback(async () => this.signedIn(await this.rest.call("ensureGuestSession")), () => {
|
|
3000
3772
|
const known = this.store.getUser();
|
|
3001
3773
|
if (known !== null) return { user: known };
|
|
3002
|
-
|
|
3774
|
+
return this.signInLocally({
|
|
3003
3775
|
id: 0,
|
|
3004
3776
|
username: "Guest-local",
|
|
3005
3777
|
guest: true
|
|
3006
|
-
};
|
|
3007
|
-
this.store.setUser(guest);
|
|
3008
|
-
return { user: guest };
|
|
3778
|
+
});
|
|
3009
3779
|
});
|
|
3010
3780
|
}
|
|
3011
3781
|
/**
|
|
@@ -3047,22 +3817,31 @@ var HubProgress = class extends HubConnection {
|
|
|
3047
3817
|
logout() {
|
|
3048
3818
|
return this.withFallback(async () => {
|
|
3049
3819
|
const reply = await this.rest.call("logOut");
|
|
3050
|
-
this.
|
|
3820
|
+
this.signedOut();
|
|
3051
3821
|
return reply;
|
|
3052
3822
|
}, () => {
|
|
3053
|
-
this.
|
|
3823
|
+
this.signedOut();
|
|
3054
3824
|
return { ok: true };
|
|
3055
3825
|
});
|
|
3056
3826
|
}
|
|
3057
3827
|
signInLocally(user) {
|
|
3058
|
-
this.
|
|
3828
|
+
this.remember(user);
|
|
3059
3829
|
return { user };
|
|
3060
3830
|
}
|
|
3061
3831
|
/** Remember who the hub signed in, so the cache's notes about the hub's copies are read for the right player. */
|
|
3062
3832
|
signedIn(reply) {
|
|
3063
|
-
this.
|
|
3833
|
+
this.remember(reply.user);
|
|
3064
3834
|
return reply;
|
|
3065
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
|
+
}
|
|
3066
3845
|
/** This game's profile for the current user, or `profile: null`. `TData` is the game's own data type. */
|
|
3067
3846
|
getProfile() {
|
|
3068
3847
|
return this.withFallback(async () => ({ profile: typedProfile((await this.rest.call("getGameProfile", this.gameFields())).profile) }), () => ({ profile: typedProfile(this.store.getProfile()) }));
|
|
@@ -3518,7 +4297,7 @@ function mergeResumeLists(hub, copies, userId) {
|
|
|
3518
4297
|
/** The query parameter (the contract's `RESUME_REQUEST_PARAM`; the browser client carries no contract code). */
|
|
3519
4298
|
const RESUME_REQUEST_PARAM = "hub_resume";
|
|
3520
4299
|
/** Where the answer is kept for the page. */
|
|
3521
|
-
const PAGE_FLAG = "__HUB_RESUME_REQUESTED__";
|
|
4300
|
+
const PAGE_FLAG$2 = "__HUB_RESUME_REQUESTED__";
|
|
3522
4301
|
/** Take `hub_resume` out of the address bar, keeping the rest of the URL and the history state. */
|
|
3523
4302
|
function stripParam(url) {
|
|
3524
4303
|
url.searchParams.delete(RESUME_REQUEST_PARAM);
|
|
@@ -3540,9 +4319,9 @@ function takeParam() {
|
|
|
3540
4319
|
/** `true` when the page was opened to resume (synchronous, so a game can decide before it routes or draws). */
|
|
3541
4320
|
function isResumeRequested() {
|
|
3542
4321
|
if (!isBrowserPage()) return false;
|
|
3543
|
-
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;
|
|
3544
4323
|
const isRequested = takeParam();
|
|
3545
|
-
Reflect.set(window, PAGE_FLAG, isRequested);
|
|
4324
|
+
Reflect.set(window, PAGE_FLAG$2, isRequested);
|
|
3546
4325
|
return isRequested;
|
|
3547
4326
|
}
|
|
3548
4327
|
//#endregion
|
|
@@ -3555,14 +4334,17 @@ const RESUME_MAX_STATE_BYTES = 524288;
|
|
|
3555
4334
|
const BLOCKED_HINTS = {
|
|
3556
4335
|
daily: "a daily challenge is active. When the player leaves a daily without finishing it, call hub.daily.cancel().",
|
|
3557
4336
|
race: "a race is on: races are never resumed.",
|
|
3558
|
-
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."
|
|
3559
4339
|
};
|
|
3560
4340
|
/** Longest label or title kept (the contract's `RESUME_TEXT_MAX`). */
|
|
3561
|
-
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";
|
|
3562
4344
|
/** A label or title trimmed and clipped, or `null` when blank. */
|
|
3563
|
-
function shortText(text) {
|
|
4345
|
+
function shortText$1(text) {
|
|
3564
4346
|
const trimmed = (text ?? "").trim();
|
|
3565
|
-
return trimmed === "" ? null : trimmed.slice(0, TEXT_MAX);
|
|
4347
|
+
return trimmed === "" ? null : trimmed.slice(0, TEXT_MAX$1);
|
|
3566
4348
|
}
|
|
3567
4349
|
/** The UTF-8 size of `text`. */
|
|
3568
4350
|
function byteSize(text) {
|
|
@@ -3616,8 +4398,9 @@ var HubResume = class {
|
|
|
3616
4398
|
/**
|
|
3617
4399
|
* Save the run (the latest save wins). Stored on the device at once; sent
|
|
3618
4400
|
* after {@link RESUME_DEBOUNCE_MS} of quiet, or when the page is hidden.
|
|
3619
|
-
* Returns `false` (and saves nothing) in a race
|
|
3620
|
-
*
|
|
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.
|
|
3621
4404
|
*/
|
|
3622
4405
|
save(run) {
|
|
3623
4406
|
const blocker = this.host.blocker();
|
|
@@ -3625,8 +4408,10 @@ var HubResume = class {
|
|
|
3625
4408
|
this.warnBlocked(blocker);
|
|
3626
4409
|
return false;
|
|
3627
4410
|
}
|
|
4411
|
+
const daily = this.host.daily?.() ?? null;
|
|
4412
|
+
if (daily !== null) return daily.save(run);
|
|
3628
4413
|
const slug = this.requireSlug();
|
|
3629
|
-
const label = shortText(run.progress.label);
|
|
4414
|
+
const label = shortText$1(run.progress.label);
|
|
3630
4415
|
if (label === null) throw new HubError("hub.resume.save: progress.label is required.", 400);
|
|
3631
4416
|
const state = toStoredJson(run.state);
|
|
3632
4417
|
if (byteSize(JSON.stringify(state)) > 524288) throw new HubError(`hub.resume.save: state exceeds ${String(RESUME_MAX_STATE_BYTES)} bytes.`, 413);
|
|
@@ -3637,13 +4422,14 @@ var HubResume = class {
|
|
|
3637
4422
|
at: Date.now(),
|
|
3638
4423
|
label,
|
|
3639
4424
|
fraction,
|
|
3640
|
-
title: shortText(run.title),
|
|
4425
|
+
title: shortText$1(run.title),
|
|
3641
4426
|
version: Math.max(0, Math.floor(run.version ?? 0)),
|
|
3642
4427
|
gameTitle: this.host.gameTitle(),
|
|
3643
4428
|
state,
|
|
3644
4429
|
isSynced: false
|
|
3645
4430
|
};
|
|
3646
4431
|
const isStored = this.host.local.write(slug, copy);
|
|
4432
|
+
this.host.onChange?.();
|
|
3647
4433
|
this.schedule({
|
|
3648
4434
|
action: "save",
|
|
3649
4435
|
savedAt: copy.at,
|
|
@@ -3668,6 +4454,18 @@ var HubResume = class {
|
|
|
3668
4454
|
/** Clear the run: call it when the run ends (won, lost or quit). Works offline. */
|
|
3669
4455
|
async clear() {
|
|
3670
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) {
|
|
3671
4469
|
if (this.timer !== null) {
|
|
3672
4470
|
clearTimeout(this.timer);
|
|
3673
4471
|
this.timer = null;
|
|
@@ -3680,6 +4478,7 @@ var HubResume = class {
|
|
|
3680
4478
|
at,
|
|
3681
4479
|
isSynced: false
|
|
3682
4480
|
});
|
|
4481
|
+
this.host.onChange?.();
|
|
3683
4482
|
await this.host.send({
|
|
3684
4483
|
action: "clear",
|
|
3685
4484
|
before: at
|
|
@@ -3698,7 +4497,7 @@ var HubResume = class {
|
|
|
3698
4497
|
run = mine?.status === "saved" && !isClearedElsewhere ? runFromCopy(mine) : null;
|
|
3699
4498
|
}
|
|
3700
4499
|
if (run === null || options.version === void 0 || run.version === options.version) return run;
|
|
3701
|
-
if (run.version < options.version) await this.
|
|
4500
|
+
if (run.version < options.version) await this.clearRun(slug);
|
|
3702
4501
|
return null;
|
|
3703
4502
|
}
|
|
3704
4503
|
/** The saved state, or `null` (see {@link get} for the version rule). */
|
|
@@ -3746,6 +4545,916 @@ var HubResume = class {
|
|
|
3746
4545
|
}
|
|
3747
4546
|
};
|
|
3748
4547
|
//#endregion
|
|
4548
|
+
//#region src/core/saved/daily-seed.ts
|
|
4549
|
+
/**
|
|
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.
|
|
4553
|
+
*/
|
|
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;
|
|
4563
|
+
}
|
|
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";
|
|
4569
|
+
}
|
|
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
|
|
3749
5458
|
//#region src/core/ticket.ts
|
|
3750
5459
|
/**
|
|
3751
5460
|
* The own-server ticket request (`hub.mp.ticket()`): a 60-second identity
|
|
@@ -3888,6 +5597,11 @@ var Hub = class extends HubEconomy {
|
|
|
3888
5597
|
super(..._args);
|
|
3889
5598
|
_defineProperty(this, "clientVersion", HUB_CLIENT_VERSION);
|
|
3890
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());
|
|
3891
5605
|
}
|
|
3892
5606
|
/**
|
|
3893
5607
|
* The hub's version and the client versions it supports (`GET /_api/version`),
|
|
@@ -3962,6 +5676,128 @@ var Hub = class extends HubEconomy {
|
|
|
3962
5676
|
this.resumeApi ?? (this.resumeApi = new HubResume(this.resumeHost()));
|
|
3963
5677
|
return this.resumeApi;
|
|
3964
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
|
+
}
|
|
3965
5801
|
/** What `hub.resume` needs from this client. */
|
|
3966
5802
|
resumeHost() {
|
|
3967
5803
|
return {
|
|
@@ -3971,7 +5807,12 @@ var Hub = class extends HubEconomy {
|
|
|
3971
5807
|
gameTitle: () => detectGameTitle(this.slug ?? ""),
|
|
3972
5808
|
blocker: () => {
|
|
3973
5809
|
if (raceForwarder.current() !== null) return "race";
|
|
3974
|
-
|
|
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;
|
|
3975
5816
|
},
|
|
3976
5817
|
send: async (payload) => {
|
|
3977
5818
|
await this.mutateOffline(RESUME_KIND, payload, () => void 0);
|
|
@@ -3987,24 +5828,58 @@ var Hub = class extends HubEconomy {
|
|
|
3987
5828
|
fetchList: () => this.withFallback(async () => (await this.rest.call("listMyResumeSlots")).games, () => void 0),
|
|
3988
5829
|
clearOther: async (slug) => {
|
|
3989
5830
|
await this.rest.call("clearResumeSlot", { slug });
|
|
5831
|
+
},
|
|
5832
|
+
onChange: () => {
|
|
5833
|
+
this.savedApi?.changed();
|
|
3990
5834
|
}
|
|
3991
5835
|
};
|
|
3992
5836
|
}
|
|
3993
|
-
/**
|
|
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. */
|
|
3994
5854
|
dailyHost() {
|
|
3995
5855
|
return {
|
|
3996
5856
|
slug: this.slug,
|
|
3997
5857
|
status: () => this.rest.call("getGameDailyChallenges", this.gameFields()),
|
|
3998
5858
|
all: () => this.rest.call("listDailyChallengesForAllGames"),
|
|
3999
|
-
start: (day) => this.rest.call("startDailyChallenge", {
|
|
5859
|
+
start: (day) => this.withFallback(async () => this.dailySaves().remember(await this.rest.call("startDailyChallenge", {
|
|
4000
5860
|
...this.gameFields(),
|
|
4001
5861
|
day
|
|
4002
|
-
}),
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
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"] }),
|
|
4008
5883
|
emit: (eventName, detail) => {
|
|
4009
5884
|
emitWindowEvent(eventName, detail);
|
|
4010
5885
|
}
|
|
@@ -4019,11 +5894,12 @@ linkRealtimeToHub({
|
|
|
4019
5894
|
/** A ready-to-use client, with the slug and API base detected from the page. */
|
|
4020
5895
|
const hub = new Hub();
|
|
4021
5896
|
isResumeRequested();
|
|
5897
|
+
savedOpenedOnPage();
|
|
4022
5898
|
/** Create a client for another slug or API base, or with other offline behaviour. */
|
|
4023
5899
|
function createHub(options = {}) {
|
|
4024
5900
|
return new Hub(options);
|
|
4025
5901
|
}
|
|
4026
5902
|
//#endregion
|
|
4027
|
-
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 };
|
|
4028
5904
|
|
|
4029
5905
|
//# sourceMappingURL=src.js.map
|