@diffenderfer-games/hub 0.0.0-stage → 2.0.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +191 -3
- package/dist/auto.d.ts +36 -0
- package/dist/auto.d.ts.map +1 -0
- package/dist/auto.js +27 -0
- package/dist/auto.js.map +1 -0
- package/dist/daily.d.ts +2 -0
- package/dist/daily.js +1 -0
- package/dist/doctor/cli.mjs +268 -0
- package/dist/doctor/cli.mjs.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +6 -0
- package/dist/input.d.ts +2 -0
- package/dist/input.js +3 -0
- package/dist/mp.d.ts +4 -0
- package/dist/mp.js +1 -0
- package/dist/race.d.ts +4 -0
- package/dist/race.js +1 -0
- package/dist/rooms.d.ts +4 -0
- package/dist/rooms.js +1 -0
- package/dist/shared/bridge.js +209 -0
- package/dist/shared/bridge.js.map +1 -0
- package/dist/shared/client-version.gen.js +7 -0
- package/dist/shared/client-version.gen.js.map +1 -0
- package/dist/shared/daily.d.ts +123 -0
- package/dist/shared/daily.d.ts.map +1 -0
- package/dist/shared/daily.js +191 -0
- package/dist/shared/daily.js.map +1 -0
- package/dist/shared/dom.js +48 -0
- package/dist/shared/dom.js.map +1 -0
- package/dist/shared/globals.js +102 -0
- package/dist/shared/globals.js.map +1 -0
- package/dist/shared/index.d.ts +706 -0
- package/dist/shared/index.d.ts.map +1 -0
- package/dist/shared/input.d.ts +730 -0
- package/dist/shared/input.d.ts.map +1 -0
- package/dist/shared/input.js +2718 -0
- package/dist/shared/input.js.map +1 -0
- package/dist/shared/latest-state.js +17 -0
- package/dist/shared/latest-state.js.map +1 -0
- package/dist/shared/mp.d.ts +9 -0
- package/dist/shared/mp.d.ts.map +1 -0
- package/dist/shared/mp.js +30 -0
- package/dist/shared/mp.js.map +1 -0
- package/dist/shared/multiplayer-types.d.ts +47 -0
- package/dist/shared/multiplayer-types.d.ts.map +1 -0
- package/dist/shared/page-stack.js +207 -0
- package/dist/shared/page-stack.js.map +1 -0
- package/dist/shared/race-types.d.ts +126 -0
- package/dist/shared/race-types.d.ts.map +1 -0
- package/dist/shared/race.d.ts +9 -0
- package/dist/shared/race.d.ts.map +1 -0
- package/dist/shared/race.js +53 -0
- package/dist/shared/race.js.map +1 -0
- package/dist/shared/room-types.d.ts +125 -0
- package/dist/shared/room-types.d.ts.map +1 -0
- package/dist/shared/rooms.d.ts +9 -0
- package/dist/shared/rooms.d.ts.map +1 -0
- package/dist/shared/rooms.js +62 -0
- package/dist/shared/rooms.js.map +1 -0
- package/dist/shared/runtime-opener.js +84 -0
- package/dist/shared/runtime-opener.js.map +1 -0
- package/dist/shared/social.d.ts +148 -0
- package/dist/shared/social.d.ts.map +1 -0
- package/dist/shared/social.js +73 -0
- package/dist/shared/social.js.map +1 -0
- package/dist/shared/src.js +2164 -0
- package/dist/shared/src.js.map +1 -0
- package/dist/shared/wire-types.d.ts +51 -0
- package/dist/shared/wire-types.d.ts.map +1 -0
- package/dist/social.d.ts +6 -0
- package/dist/social.js +3 -0
- package/dist/types.d.ts +1 -0
- package/dist/types.js +0 -0
- package/package.json +89 -6
- package/src/auto.ts +39 -0
- package/src/core/connection.ts +299 -0
- package/src/core/errors.ts +147 -0
- package/src/core/game-data.ts +38 -0
- package/src/core/hub-economy.ts +179 -0
- package/src/core/hub-progress.ts +205 -0
- package/src/core/hub.ts +131 -0
- package/src/core/menu-control.ts +61 -0
- package/src/core/offline/events.ts +50 -0
- package/src/core/offline/favorites.ts +21 -0
- package/src/core/offline/local-store.ts +261 -0
- package/src/core/offline/queue.ts +171 -0
- package/src/core/page.ts +74 -0
- package/src/core/rest/endpoint-types.ts +22 -0
- package/src/core/rest/rest-client.ts +152 -0
- package/src/core/rest/routes.gen.ts +90 -0
- package/src/core/storage/json-guards.ts +74 -0
- package/src/core/storage/key-value.ts +66 -0
- package/src/core/ticket.ts +43 -0
- package/src/core/types.ts +134 -0
- package/src/core/version/client-version.gen.ts +3 -0
- package/src/core/version/hub-version.ts +62 -0
- package/src/core/version/semver.ts +97 -0
- package/src/daily/daily-system.ts +161 -0
- package/src/daily/page-daily.ts +36 -0
- package/src/daily/types.ts +67 -0
- package/src/daily.ts +10 -0
- package/src/doctor/checks.ts +78 -0
- package/src/doctor/cli.ts +98 -0
- package/src/doctor/hub-version-fetch.ts +43 -0
- package/src/doctor/tsconfig.json +12 -0
- package/src/globals.ts +115 -0
- package/src/index.ts +50 -0
- package/src/input/device-state.ts +146 -0
- package/src/input/edges.ts +55 -0
- package/src/input/gamepad.ts +61 -0
- package/src/input/input-events.ts +32 -0
- package/src/input/input-registry.ts +164 -0
- package/src/input/input-system.ts +393 -0
- package/src/input/legacy-controls.ts +64 -0
- package/src/input/nav-state.ts +191 -0
- package/src/input/navigator.ts +171 -0
- package/src/input/on-screen-controls.ts +101 -0
- package/src/input/page-control.ts +43 -0
- package/src/input/page-input.ts +193 -0
- package/src/input/player-remaps.ts +77 -0
- package/src/input/source-values.ts +116 -0
- package/src/input/spatial.ts +47 -0
- package/src/input/types.ts +331 -0
- package/src/input/zero-config.ts +177 -0
- package/src/input.ts +22 -0
- package/src/mp.ts +10 -0
- package/src/overlay/controls-panel.ts +163 -0
- package/src/overlay/dom.ts +46 -0
- package/src/overlay/layout-clusters.ts +162 -0
- package/src/overlay/layout-geometry.ts +136 -0
- package/src/overlay/layout.ts +203 -0
- package/src/overlay/listener-scope.ts +23 -0
- package/src/overlay/on-screen-keyboard.ts +137 -0
- package/src/overlay/overlay.ts +208 -0
- package/src/overlay/styles.ts +54 -0
- package/src/overlay/virtual-controls.ts +262 -0
- package/src/race.ts +10 -0
- package/src/realtime/bridge.ts +121 -0
- package/src/realtime/cross-bundle.ts +29 -0
- package/src/realtime/fallback-notice.ts +48 -0
- package/src/realtime/hub-link.ts +65 -0
- package/src/realtime/latest-state.ts +33 -0
- package/src/realtime/multiplayer-forwarder.ts +28 -0
- package/src/realtime/multiplayer-types.ts +52 -0
- package/src/realtime/page-runtime.ts +60 -0
- package/src/realtime/race-forwarder.ts +59 -0
- package/src/realtime/race-types.ts +132 -0
- package/src/realtime/room-types.ts +125 -0
- package/src/realtime/rooms-forwarder.ts +79 -0
- package/src/realtime/runtime-api.ts +49 -0
- package/src/realtime/runtime-opener.ts +61 -0
- package/src/realtime/social-forwarder.ts +72 -0
- package/src/realtime/social-types.ts +78 -0
- package/src/realtime/types.ts +53 -0
- package/src/realtime/wire-types.ts +72 -0
- package/src/rooms.ts +10 -0
- package/src/social.ts +18 -0
- package/src/types.ts +13 -0
- package/src/uistack/page-stack.ts +45 -0
- package/src/uistack/play-suspension.ts +82 -0
- package/src/uistack/types.ts +76 -0
- package/src/uistack/ui-stack.ts +144 -0
|
@@ -0,0 +1,2164 @@
|
|
|
1
|
+
import { r as _defineProperty } from "./globals.js";
|
|
2
|
+
import { n as getDaily } from "./daily.js";
|
|
3
|
+
import { t as HUB_CLIENT_VERSION } from "./client-version.gen.js";
|
|
4
|
+
import { n as getInput } from "./input.js";
|
|
5
|
+
import { t as getUiStack } from "./page-stack.js";
|
|
6
|
+
import { d as linkRealtimeToHub } from "./bridge.js";
|
|
7
|
+
import { t as multiplayerForwarder } from "./mp.js";
|
|
8
|
+
import { t as raceForwarder } from "./race.js";
|
|
9
|
+
import { t as roomsForwarder } from "./rooms.js";
|
|
10
|
+
import { n as presenceForwarder, r as socialForwarder, t as notifyForwarder } from "./social.js";
|
|
11
|
+
//#region src/core/storage/json-guards.ts
|
|
12
|
+
/** Whether `candidate` is a non-null, non-array object. */
|
|
13
|
+
function isRecord(candidate) {
|
|
14
|
+
return typeof candidate === "object" && candidate !== null && !Array.isArray(candidate);
|
|
15
|
+
}
|
|
16
|
+
/** `candidate` when it is a string, else `undefined`. */
|
|
17
|
+
function optionalString(candidate) {
|
|
18
|
+
return typeof candidate === "string" ? candidate : void 0;
|
|
19
|
+
}
|
|
20
|
+
/** `candidate` when it is a finite number, else `undefined`. */
|
|
21
|
+
function optionalNumber(candidate) {
|
|
22
|
+
return typeof candidate === "number" && Number.isFinite(candidate) ? candidate : void 0;
|
|
23
|
+
}
|
|
24
|
+
/** Whether `candidate` is a string or `null`. */
|
|
25
|
+
function isNullableString(candidate) {
|
|
26
|
+
return candidate === null || typeof candidate === "string";
|
|
27
|
+
}
|
|
28
|
+
/** Whether `candidate` is a JSON value (what `JSON.parse` can return). */
|
|
29
|
+
function isJson(candidate) {
|
|
30
|
+
if (candidate === null || typeof candidate === "string" || typeof candidate === "boolean") return true;
|
|
31
|
+
if (typeof candidate === "number") return Number.isFinite(candidate);
|
|
32
|
+
if (Array.isArray(candidate)) return candidate.every(isJson);
|
|
33
|
+
return isRecord(candidate) && Object.values(candidate).every(isJson);
|
|
34
|
+
}
|
|
35
|
+
/** Whether `candidate` is an object whose every value is a finite number. */
|
|
36
|
+
function isNumberRecord(candidate) {
|
|
37
|
+
return isRecord(candidate) && Object.values(candidate).every((entry) => optionalNumber(entry) !== void 0);
|
|
38
|
+
}
|
|
39
|
+
/** The strings in `candidate`, or an empty list when it is not an array. */
|
|
40
|
+
function stringsIn(candidate) {
|
|
41
|
+
return Array.isArray(candidate) ? candidate.filter((entry) => typeof entry === "string") : [];
|
|
42
|
+
}
|
|
43
|
+
/** Parse JSON text without throwing: `undefined` when it is not JSON. */
|
|
44
|
+
function parseJsonText(text) {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(text);
|
|
47
|
+
} catch {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/core/errors.ts
|
|
53
|
+
/**
|
|
54
|
+
* The code a client gives a call the hub does not have yet (a newer client on
|
|
55
|
+
* an older hub, plan §4.3.3). Deploy the hub first, or use an older client.
|
|
56
|
+
*/
|
|
57
|
+
const UNSUPPORTED_BY_HUB = "unsupported_by_hub";
|
|
58
|
+
/**
|
|
59
|
+
* Thrown by every hub call that fails. `status` is the HTTP status when the
|
|
60
|
+
* hub answered (401 = not signed in, 404 = unknown game or board, 413 = too
|
|
61
|
+
* large), or `undefined` for a transport failure (offline, no host). Social
|
|
62
|
+
* and multiplayer errors also carry {@link HubErrorDetails}.
|
|
63
|
+
*/
|
|
64
|
+
var HubError = class extends Error {
|
|
65
|
+
/** An error with a player-safe `message`, the HTTP `status` (if any) and the body's extras. */
|
|
66
|
+
constructor(message, status, details = {}) {
|
|
67
|
+
super(message);
|
|
68
|
+
_defineProperty(this, "status", void 0);
|
|
69
|
+
_defineProperty(this, "code", void 0);
|
|
70
|
+
_defineProperty(this, "reason", void 0);
|
|
71
|
+
_defineProperty(this, "hint", void 0);
|
|
72
|
+
_defineProperty(this, "retryAfterMs", void 0);
|
|
73
|
+
_defineProperty(this, "until", void 0);
|
|
74
|
+
this.name = "HubError";
|
|
75
|
+
this.status = status;
|
|
76
|
+
this.code = details.code;
|
|
77
|
+
this.reason = details.reason;
|
|
78
|
+
this.hint = details.hint;
|
|
79
|
+
this.retryAfterMs = details.retryAfterMs;
|
|
80
|
+
this.until = details.until;
|
|
81
|
+
}
|
|
82
|
+
/** `true` when the failure was a network or transport error, not an HTTP status. */
|
|
83
|
+
get isOffline() {
|
|
84
|
+
return this.status === void 0;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
/** Thrown when an action that genuinely needs the hub is attempted offline. */
|
|
88
|
+
var OfflineError = class extends HubError {
|
|
89
|
+
/** An offline error; the default message is "This needs a connection.". */
|
|
90
|
+
constructor(message = "This needs a connection.") {
|
|
91
|
+
super(message);
|
|
92
|
+
this.name = "OfflineError";
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Text was refused by the safety filter (HTTP 422, or a socket ack with
|
|
97
|
+
* `code: 'content_rejected'`). Keep the player's draft and show `hint`; when
|
|
98
|
+
* `reason` is `muted` or `rate_limited`, `retryAfterMs` says how long to wait.
|
|
99
|
+
*/
|
|
100
|
+
var ContentRejectedError = class extends HubError {
|
|
101
|
+
/** A content reject with the hub's message, status and extras. */
|
|
102
|
+
constructor(message, status, details) {
|
|
103
|
+
super(message, status, details);
|
|
104
|
+
this.name = "ContentRejectedError";
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
/** Read the fields this client understands from an error body (or any thrown object). */
|
|
108
|
+
function readErrorFields(body) {
|
|
109
|
+
if (!isRecord(body)) return {};
|
|
110
|
+
return {
|
|
111
|
+
error: optionalString(body.error),
|
|
112
|
+
code: optionalString(body.code),
|
|
113
|
+
reason: optionalString(body.reason),
|
|
114
|
+
hint: optionalString(body.hint),
|
|
115
|
+
retryAfterMs: optionalNumber(body.retryAfterMs),
|
|
116
|
+
until: optionalNumber(body.until)
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** The right error class for a failure: a 422 or `content_rejected` is a {@link ContentRejectedError}. */
|
|
120
|
+
function errorFor(message, status, details) {
|
|
121
|
+
return status === 422 || details.code === "content_rejected" ? new ContentRejectedError(message, status, details) : new HubError(message, status, details);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Re-create an error from another bundle (the social runtime, or another
|
|
125
|
+
* client copy on the page) as this bundle's class, so `instanceof` works in
|
|
126
|
+
* games.
|
|
127
|
+
*/
|
|
128
|
+
function toHubError(thrown) {
|
|
129
|
+
if (thrown instanceof HubError) return thrown;
|
|
130
|
+
if (!isRecord(thrown)) return new HubError(String(thrown));
|
|
131
|
+
const fields = readErrorFields(thrown);
|
|
132
|
+
const message = optionalString(thrown.message) ?? "Request failed";
|
|
133
|
+
if (fields.code === "offline") return new OfflineError(message);
|
|
134
|
+
return errorFor(message, optionalNumber(thrown.status), fields);
|
|
135
|
+
}
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region src/core/storage/key-value.ts
|
|
138
|
+
/** A store backed by a `Storage` (`localStorage`). */
|
|
139
|
+
function storageBacked(storage) {
|
|
140
|
+
return {
|
|
141
|
+
read: (key) => storage.getItem(key),
|
|
142
|
+
write: (key, text) => {
|
|
143
|
+
try {
|
|
144
|
+
storage.setItem(key, text);
|
|
145
|
+
} catch {}
|
|
146
|
+
},
|
|
147
|
+
remove: (key) => {
|
|
148
|
+
storage.removeItem(key);
|
|
149
|
+
},
|
|
150
|
+
keys: () => Object.keys(storage)
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/** A store held in memory for this page only. */
|
|
154
|
+
function createMemoryStore() {
|
|
155
|
+
const entries = /* @__PURE__ */ new Map();
|
|
156
|
+
return {
|
|
157
|
+
read: (key) => entries.get(key) ?? null,
|
|
158
|
+
write: (key, text) => {
|
|
159
|
+
entries.set(key, text);
|
|
160
|
+
},
|
|
161
|
+
remove: (key) => {
|
|
162
|
+
entries.delete(key);
|
|
163
|
+
},
|
|
164
|
+
keys: () => [...entries.keys()]
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** The page's `localStorage`, or `undefined` when it is missing or throws (privacy mode). */
|
|
168
|
+
function pageStorage() {
|
|
169
|
+
try {
|
|
170
|
+
return typeof localStorage === "undefined" ? void 0 : localStorage;
|
|
171
|
+
} catch {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** `localStorage` when available, else a fresh in-memory store. */
|
|
176
|
+
function createPageStore() {
|
|
177
|
+
const storage = pageStorage();
|
|
178
|
+
return storage === void 0 ? createMemoryStore() : storageBacked(storage);
|
|
179
|
+
}
|
|
180
|
+
/** The well-formed events of a stored buffer. */
|
|
181
|
+
function readEvents(stored) {
|
|
182
|
+
if (!Array.isArray(stored)) return [];
|
|
183
|
+
return stored.flatMap((entry) => {
|
|
184
|
+
if (!isRecord(entry)) return [];
|
|
185
|
+
const type = optionalString(entry.type);
|
|
186
|
+
const ts = optionalNumber(entry.ts);
|
|
187
|
+
return type === void 0 || ts === void 0 ? [] : [{
|
|
188
|
+
type,
|
|
189
|
+
ts
|
|
190
|
+
}];
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
/** The buffer with one more event, trimmed to {@link EVENT_BUFFER_CAP}: oldest heartbeats go first, then the oldest events. */
|
|
194
|
+
function appendEvent(events, incoming) {
|
|
195
|
+
let buffered = [...events, incoming];
|
|
196
|
+
let excess = buffered.length - 100;
|
|
197
|
+
if (excess <= 0) return buffered;
|
|
198
|
+
buffered = buffered.filter((event) => {
|
|
199
|
+
if (excess > 0 && event.type === "heartbeat") {
|
|
200
|
+
excess -= 1;
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
return true;
|
|
204
|
+
});
|
|
205
|
+
return buffered.length > 100 ? buffered.slice(buffered.length - 100) : buffered;
|
|
206
|
+
}
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/core/offline/queue.ts
|
|
209
|
+
/** The score options in a stored op, or `undefined` when they are malformed. */
|
|
210
|
+
function readScoreOptions(stored) {
|
|
211
|
+
const title = optionalString(stored.title);
|
|
212
|
+
const sort = stored.sort === "asc" || stored.sort === "desc" ? stored.sort : void 0;
|
|
213
|
+
if (stored.meta !== void 0 && !isJson(stored.meta)) return;
|
|
214
|
+
return {
|
|
215
|
+
...title === void 0 ? {} : { title },
|
|
216
|
+
...sort === void 0 ? {} : { sort },
|
|
217
|
+
...stored.meta === void 0 ? {} : { meta: stored.meta }
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
/** The profile patch in a stored op, or `undefined` when it is malformed. */
|
|
221
|
+
function readProfilePatch(stored) {
|
|
222
|
+
if (stored.displayName !== void 0 && !isNullableString(stored.displayName) || stored.data !== void 0 && !isJson(stored.data)) return;
|
|
223
|
+
const patch = {};
|
|
224
|
+
if (stored.displayName !== void 0) patch.displayName = stored.displayName;
|
|
225
|
+
if (stored.data !== void 0) patch.data = stored.data;
|
|
226
|
+
return patch;
|
|
227
|
+
}
|
|
228
|
+
/** One stored op's write, or `undefined` when it is not a known, well-formed op. */
|
|
229
|
+
function readOpInput(stored) {
|
|
230
|
+
const slot = optionalString(stored.slot);
|
|
231
|
+
const key = optionalString(stored.key);
|
|
232
|
+
const statValue = optionalNumber(stored.value);
|
|
233
|
+
switch (stored.kind) {
|
|
234
|
+
case "putSave": return slot !== void 0 && isJson(stored.data) && isNullableString(stored.label) ? {
|
|
235
|
+
kind: "putSave",
|
|
236
|
+
slot,
|
|
237
|
+
data: stored.data,
|
|
238
|
+
label: stored.label
|
|
239
|
+
} : void 0;
|
|
240
|
+
case "deleteSave": return slot === void 0 ? void 0 : {
|
|
241
|
+
kind: "deleteSave",
|
|
242
|
+
slot
|
|
243
|
+
};
|
|
244
|
+
case "setStat":
|
|
245
|
+
case "maxStat": return key !== void 0 && statValue !== void 0 ? {
|
|
246
|
+
kind: stored.kind,
|
|
247
|
+
key,
|
|
248
|
+
value: statValue
|
|
249
|
+
} : void 0;
|
|
250
|
+
case "incrStat": {
|
|
251
|
+
const amount = optionalNumber(stored.amount);
|
|
252
|
+
return key !== void 0 && amount !== void 0 ? {
|
|
253
|
+
kind: "incrStat",
|
|
254
|
+
key,
|
|
255
|
+
amount
|
|
256
|
+
} : void 0;
|
|
257
|
+
}
|
|
258
|
+
case "submitScore": {
|
|
259
|
+
const score = optionalNumber(stored.score);
|
|
260
|
+
const opts = isRecord(stored.opts) ? readScoreOptions(stored.opts) : void 0;
|
|
261
|
+
return key !== void 0 && score !== void 0 && opts !== void 0 ? {
|
|
262
|
+
kind: "submitScore",
|
|
263
|
+
key,
|
|
264
|
+
score,
|
|
265
|
+
opts
|
|
266
|
+
} : void 0;
|
|
267
|
+
}
|
|
268
|
+
case "setProfile": {
|
|
269
|
+
const patch = isRecord(stored.patch) ? readProfilePatch(stored.patch) : void 0;
|
|
270
|
+
return patch === void 0 ? void 0 : {
|
|
271
|
+
kind: "setProfile",
|
|
272
|
+
patch
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
default: return;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/** The well-formed ops of a stored queue, in order; anything malformed is dropped. */
|
|
279
|
+
function readQueue(stored) {
|
|
280
|
+
if (!Array.isArray(stored)) return [];
|
|
281
|
+
return stored.flatMap((entry) => {
|
|
282
|
+
if (!isRecord(entry)) return [];
|
|
283
|
+
const input = readOpInput(entry);
|
|
284
|
+
const id = optionalString(entry.id);
|
|
285
|
+
const ts = optionalNumber(entry.ts);
|
|
286
|
+
return input === void 0 || id === void 0 || ts === void 0 ? [] : [{
|
|
287
|
+
...input,
|
|
288
|
+
id,
|
|
289
|
+
ts
|
|
290
|
+
}];
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
/** The better of two scores on a board sorted `sort` (`desc` by default). */
|
|
294
|
+
function betterScore(first, second, sort) {
|
|
295
|
+
return sort === "asc" ? Math.min(first, second) : Math.max(first, second);
|
|
296
|
+
}
|
|
297
|
+
/** The op that `queued` becomes when `incoming` collapses into it, or `undefined` when they don't collapse. */
|
|
298
|
+
function collapsedOp(queued, incoming) {
|
|
299
|
+
if (queued.kind === "incrStat" && incoming.kind === "incrStat" && queued.key === incoming.key) return {
|
|
300
|
+
...queued,
|
|
301
|
+
amount: queued.amount + incoming.amount
|
|
302
|
+
};
|
|
303
|
+
if (queued.kind === "maxStat" && incoming.kind === "maxStat" && queued.key === incoming.key) return {
|
|
304
|
+
...queued,
|
|
305
|
+
value: Math.max(queued.value, incoming.value)
|
|
306
|
+
};
|
|
307
|
+
if (queued.kind === "setStat" && incoming.kind === "setStat" && queued.key === incoming.key) return {
|
|
308
|
+
...queued,
|
|
309
|
+
value: incoming.value
|
|
310
|
+
};
|
|
311
|
+
if (queued.kind === "submitScore" && incoming.kind === "submitScore" && queued.key === incoming.key) return {
|
|
312
|
+
...queued,
|
|
313
|
+
score: betterScore(queued.score, incoming.score, incoming.opts.sort),
|
|
314
|
+
opts: incoming.opts
|
|
315
|
+
};
|
|
316
|
+
if (queued.kind === "setProfile" && incoming.kind === "setProfile") return {
|
|
317
|
+
...queued,
|
|
318
|
+
patch: {
|
|
319
|
+
...queued.patch,
|
|
320
|
+
...incoming.patch
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/** Whether an op writes save slot `slot`. */
|
|
325
|
+
function writesSlot(op, slot) {
|
|
326
|
+
return (op.kind === "putSave" || op.kind === "deleteSave") && op.slot === slot;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* The queue with `incoming` added: a save or delete replaces any queued
|
|
330
|
+
* write to the same slot (and goes last); a compatible op merges into the
|
|
331
|
+
* first queued one in place; anything else is appended.
|
|
332
|
+
*/
|
|
333
|
+
function enqueueOp(queue, incoming) {
|
|
334
|
+
if (incoming.kind === "putSave" || incoming.kind === "deleteSave") return [...queue.filter((op) => !writesSlot(op, incoming.slot)), incoming];
|
|
335
|
+
const mergeAt = queue.findIndex((op) => collapsedOp(op, incoming) !== void 0);
|
|
336
|
+
if (mergeAt < 0) return [...queue, incoming];
|
|
337
|
+
return queue.map((op, i) => i === mergeAt ? collapsedOp(op, incoming) ?? op : op);
|
|
338
|
+
}
|
|
339
|
+
//#endregion
|
|
340
|
+
//#region src/core/offline/local-store.ts
|
|
341
|
+
/** The key of the locally known user, shared by every game (like the real session). */
|
|
342
|
+
const USER_KEY = "hub:user";
|
|
343
|
+
function readUser(stored) {
|
|
344
|
+
if (!isRecord(stored)) return null;
|
|
345
|
+
const id = optionalNumber(stored.id);
|
|
346
|
+
const username = optionalString(stored.username);
|
|
347
|
+
if (id === void 0 || username === void 0) return null;
|
|
348
|
+
return typeof stored.guest === "boolean" ? {
|
|
349
|
+
id,
|
|
350
|
+
username,
|
|
351
|
+
guest: stored.guest
|
|
352
|
+
} : {
|
|
353
|
+
id,
|
|
354
|
+
username
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
function readSave(stored) {
|
|
358
|
+
if (!isRecord(stored)) return null;
|
|
359
|
+
const slot = optionalString(stored.slot);
|
|
360
|
+
const updatedAt = optionalNumber(stored.updatedAt);
|
|
361
|
+
if (slot === void 0 || updatedAt === void 0 || !isNullableString(stored.label) || !isJson(stored.data)) return null;
|
|
362
|
+
return {
|
|
363
|
+
slot,
|
|
364
|
+
label: stored.label,
|
|
365
|
+
data: stored.data,
|
|
366
|
+
updatedAt
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
function readProfile(stored) {
|
|
370
|
+
if (!isRecord(stored)) return null;
|
|
371
|
+
const game = optionalString(stored.game);
|
|
372
|
+
const updatedAt = optionalNumber(stored.updatedAt);
|
|
373
|
+
if (game === void 0 || updatedAt === void 0 || !isNullableString(stored.displayName) || !isJson(stored.data)) return null;
|
|
374
|
+
return {
|
|
375
|
+
game,
|
|
376
|
+
displayName: stored.displayName,
|
|
377
|
+
data: stored.data,
|
|
378
|
+
updatedAt
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
function readLocalBoard(stored) {
|
|
382
|
+
if (!isRecord(stored) || !isRecord(stored.board)) return null;
|
|
383
|
+
const key = optionalString(stored.board.key);
|
|
384
|
+
const title = optionalString(stored.board.title);
|
|
385
|
+
const sortDir = stored.board.sortDir === "asc" || stored.board.sortDir === "desc" ? stored.board.sortDir : void 0;
|
|
386
|
+
const best = optionalNumber(stored.best);
|
|
387
|
+
if (key === void 0 || title === void 0 || sortDir === void 0 || best === void 0) return null;
|
|
388
|
+
return {
|
|
389
|
+
board: {
|
|
390
|
+
key,
|
|
391
|
+
title,
|
|
392
|
+
sortDir
|
|
393
|
+
},
|
|
394
|
+
best
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
/** One game's offline cache, queue and event buffer. */
|
|
398
|
+
var LocalStore = class {
|
|
399
|
+
/** The cache for game `slug` in `store` (default: `localStorage`, or memory without it). */
|
|
400
|
+
constructor(slug, store = createPageStore()) {
|
|
401
|
+
_defineProperty(this, "prefix", void 0);
|
|
402
|
+
_defineProperty(this, "store", void 0);
|
|
403
|
+
_defineProperty(this, "sequence", 0);
|
|
404
|
+
this.prefix = `hub:${slug}:`;
|
|
405
|
+
this.store = store;
|
|
406
|
+
}
|
|
407
|
+
readStored(key) {
|
|
408
|
+
const text = this.store.read(key);
|
|
409
|
+
return text === null ? void 0 : parseJsonText(text);
|
|
410
|
+
}
|
|
411
|
+
writeStored(key, stored) {
|
|
412
|
+
this.store.write(key, JSON.stringify(stored));
|
|
413
|
+
}
|
|
414
|
+
/** The locally known user. */
|
|
415
|
+
getUser() {
|
|
416
|
+
return readUser(this.readStored(USER_KEY));
|
|
417
|
+
}
|
|
418
|
+
/** Remember the user (offline sign-in, or a snapshot). */
|
|
419
|
+
setUser(user) {
|
|
420
|
+
this.writeStored(USER_KEY, user);
|
|
421
|
+
}
|
|
422
|
+
/** Forget the user (offline sign-out). */
|
|
423
|
+
clearUser() {
|
|
424
|
+
this.store.remove(USER_KEY);
|
|
425
|
+
}
|
|
426
|
+
/** This game's cached profile. */
|
|
427
|
+
getProfile() {
|
|
428
|
+
return readProfile(this.readStored(`${this.prefix}profile`));
|
|
429
|
+
}
|
|
430
|
+
/** Store this game's profile and return it. */
|
|
431
|
+
setProfile(game, displayName, profileData) {
|
|
432
|
+
const profile = {
|
|
433
|
+
game,
|
|
434
|
+
displayName,
|
|
435
|
+
data: profileData,
|
|
436
|
+
updatedAt: Date.now()
|
|
437
|
+
};
|
|
438
|
+
this.writeStored(`${this.prefix}profile`, profile);
|
|
439
|
+
return profile;
|
|
440
|
+
}
|
|
441
|
+
/** Every cached save's metadata, newest first. */
|
|
442
|
+
listSaves() {
|
|
443
|
+
return this.store.keys().filter((key) => key.startsWith(`${this.prefix}save:`)).flatMap((key) => {
|
|
444
|
+
const save = readSave(this.readStored(key));
|
|
445
|
+
return save === null ? [] : [{
|
|
446
|
+
slot: save.slot,
|
|
447
|
+
label: save.label,
|
|
448
|
+
updatedAt: save.updatedAt
|
|
449
|
+
}];
|
|
450
|
+
}).sort((first, second) => second.updatedAt - first.updatedAt);
|
|
451
|
+
}
|
|
452
|
+
/** One cached save, or `null`. */
|
|
453
|
+
getSave(slot) {
|
|
454
|
+
return readSave(this.readStored(`${this.prefix}save:${slot}`));
|
|
455
|
+
}
|
|
456
|
+
/** Store a save and return its `updatedAt`. */
|
|
457
|
+
putSave(slot, saveData, label) {
|
|
458
|
+
const updatedAt = Date.now();
|
|
459
|
+
this.writeStored(`${this.prefix}save:${slot}`, {
|
|
460
|
+
slot,
|
|
461
|
+
label,
|
|
462
|
+
data: saveData,
|
|
463
|
+
updatedAt
|
|
464
|
+
});
|
|
465
|
+
return updatedAt;
|
|
466
|
+
}
|
|
467
|
+
/** Forget a save. */
|
|
468
|
+
deleteSave(slot) {
|
|
469
|
+
this.store.remove(`${this.prefix}save:${slot}`);
|
|
470
|
+
}
|
|
471
|
+
/** This game's cached stats. */
|
|
472
|
+
getStats() {
|
|
473
|
+
const stored = this.readStored(`${this.prefix}stats`);
|
|
474
|
+
return isNumberRecord(stored) ? { ...stored } : {};
|
|
475
|
+
}
|
|
476
|
+
/** Apply one stat update and return the stat's new value. */
|
|
477
|
+
updateStat(key, amount, mode) {
|
|
478
|
+
const stats = this.getStats();
|
|
479
|
+
const current = stats[key] ?? 0;
|
|
480
|
+
const updated = mode === "incr" ? current + amount : mode === "max" ? Math.max(current, amount) : amount;
|
|
481
|
+
this.writeStored(`${this.prefix}stats`, {
|
|
482
|
+
...stats,
|
|
483
|
+
[key]: updated
|
|
484
|
+
});
|
|
485
|
+
return updated;
|
|
486
|
+
}
|
|
487
|
+
/** The boards this player has a local best on (offline boards only know the player's own best). */
|
|
488
|
+
listBoards() {
|
|
489
|
+
return this.store.keys().filter((key) => key.startsWith(`${this.prefix}lb:`)).flatMap((key) => {
|
|
490
|
+
const local = readLocalBoard(this.readStored(key));
|
|
491
|
+
return local === null ? [] : [local.board];
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
/** One board's local best, or `null`. */
|
|
495
|
+
getBoard(key) {
|
|
496
|
+
return readLocalBoard(this.readStored(`${this.prefix}lb:${key}`));
|
|
497
|
+
}
|
|
498
|
+
/** Record a score locally, keeping the best one; returns the board and best. */
|
|
499
|
+
submitScore(key, score, options) {
|
|
500
|
+
const existing = this.getBoard(key);
|
|
501
|
+
const sortDir = existing?.board.sortDir ?? (options.sort === "asc" ? "asc" : "desc");
|
|
502
|
+
const board = existing?.board ?? {
|
|
503
|
+
key,
|
|
504
|
+
title: options.title !== void 0 && options.title !== "" ? options.title : key,
|
|
505
|
+
sortDir
|
|
506
|
+
};
|
|
507
|
+
const best = existing === null ? score : betterScore(existing.best, score, sortDir);
|
|
508
|
+
this.writeStored(`${this.prefix}lb:${key}`, {
|
|
509
|
+
board,
|
|
510
|
+
best
|
|
511
|
+
});
|
|
512
|
+
return {
|
|
513
|
+
board,
|
|
514
|
+
best
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
/** The queued writes, oldest first. */
|
|
518
|
+
getQueue() {
|
|
519
|
+
return readQueue(this.readStored(`${this.prefix}queue`));
|
|
520
|
+
}
|
|
521
|
+
/** Replace the queue. */
|
|
522
|
+
setQueue(queue) {
|
|
523
|
+
this.writeStored(`${this.prefix}queue`, queue);
|
|
524
|
+
}
|
|
525
|
+
/** How many writes are queued. */
|
|
526
|
+
queueLength() {
|
|
527
|
+
return this.getQueue().length;
|
|
528
|
+
}
|
|
529
|
+
/** Queue a write, collapsing it with a compatible queued one. */
|
|
530
|
+
enqueue(input) {
|
|
531
|
+
this.sequence += 1;
|
|
532
|
+
const now = Date.now();
|
|
533
|
+
this.setQueue(enqueueOp(this.getQueue(), {
|
|
534
|
+
...input,
|
|
535
|
+
id: `${String(now)}-${String(this.sequence)}`,
|
|
536
|
+
ts: now
|
|
537
|
+
}));
|
|
538
|
+
}
|
|
539
|
+
/** The buffered analytics events, oldest first. */
|
|
540
|
+
getEvents() {
|
|
541
|
+
return readEvents(this.readStored(`${this.prefix}events`));
|
|
542
|
+
}
|
|
543
|
+
/** Replace the event buffer. */
|
|
544
|
+
setEvents(events) {
|
|
545
|
+
this.writeStored(`${this.prefix}events`, events);
|
|
546
|
+
}
|
|
547
|
+
/** Buffer one event (capped; heartbeats are dropped first). */
|
|
548
|
+
bufferEvent(type) {
|
|
549
|
+
this.setEvents(appendEvent(this.getEvents(), {
|
|
550
|
+
type,
|
|
551
|
+
ts: Date.now()
|
|
552
|
+
}));
|
|
553
|
+
}
|
|
554
|
+
/** Fan a `/me/snapshot` reply out across every game's cache keys. */
|
|
555
|
+
hydrate(snapshot) {
|
|
556
|
+
if (snapshot.user !== null) this.writeStored(USER_KEY, snapshot.user);
|
|
557
|
+
for (const [slug, game] of Object.entries(snapshot.games)) {
|
|
558
|
+
const gamePrefix = `hub:${slug}:`;
|
|
559
|
+
if (game.profile !== null) this.writeStored(`${gamePrefix}profile`, game.profile);
|
|
560
|
+
for (const save of game.saves) this.writeStored(`${gamePrefix}save:${save.slot}`, save);
|
|
561
|
+
this.writeStored(`${gamePrefix}stats`, game.stats);
|
|
562
|
+
this.writeStored(`${gamePrefix}inventory`, game.inventory);
|
|
563
|
+
}
|
|
564
|
+
for (const gameRecords of snapshot.records) for (const record of gameRecords.records) this.writeStored(`hub:${gameRecords.slug}:lb:${record.key}`, {
|
|
565
|
+
board: {
|
|
566
|
+
key: record.key,
|
|
567
|
+
title: record.title,
|
|
568
|
+
sortDir: record.sortDir
|
|
569
|
+
},
|
|
570
|
+
best: record.score
|
|
571
|
+
});
|
|
572
|
+
this.writeStored("hub:records", snapshot.records);
|
|
573
|
+
this.writeStored("hub:leaderboards", snapshot.leaderboards);
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
//#endregion
|
|
577
|
+
//#region src/core/page.ts
|
|
578
|
+
/** Whether this code runs in a browser page. */
|
|
579
|
+
function isBrowserPage() {
|
|
580
|
+
return typeof window !== "undefined";
|
|
581
|
+
}
|
|
582
|
+
/** The injected config's slug and API base, read defensively (empty outside a hub page). */
|
|
583
|
+
function injectedConfig() {
|
|
584
|
+
if (!isBrowserPage() || !isRecord(window.__HUB__)) return {};
|
|
585
|
+
const slug = optionalString(window.__HUB__.slug);
|
|
586
|
+
const apiBase = optionalString(window.__HUB__.apiBase);
|
|
587
|
+
return {
|
|
588
|
+
...slug === void 0 ? {} : { slug },
|
|
589
|
+
...apiBase === void 0 ? {} : { apiBase }
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
/** The game's slug: the injected one, else the first URL path segment, else `null`. */
|
|
593
|
+
function detectSlug() {
|
|
594
|
+
const injectedSlug = injectedConfig().slug;
|
|
595
|
+
if (injectedSlug !== void 0 && injectedSlug !== "") return injectedSlug;
|
|
596
|
+
if (typeof location === "undefined") return null;
|
|
597
|
+
return location.pathname.split("/").find((segment) => segment !== "") ?? null;
|
|
598
|
+
}
|
|
599
|
+
/** An API base made absolute against the page origin (`http://localhost` outside a browser), without a trailing slash. */
|
|
600
|
+
function absoluteApiBase(apiBase) {
|
|
601
|
+
const origin = typeof location === "undefined" ? "http://localhost" : location.origin;
|
|
602
|
+
return new URL(apiBase, origin).toString().replace(/\/$/, "");
|
|
603
|
+
}
|
|
604
|
+
/** The page's API base: the injected one, else `/_api`, made absolute. */
|
|
605
|
+
function detectApiBase() {
|
|
606
|
+
return absoluteApiBase(injectedConfig().apiBase ?? "/_api");
|
|
607
|
+
}
|
|
608
|
+
/** Dispatch a `CustomEvent` on `window` (no-op outside a browser; listener errors never reach the caller). */
|
|
609
|
+
function emitWindowEvent(eventName, detail) {
|
|
610
|
+
if (!isBrowserPage()) return;
|
|
611
|
+
try {
|
|
612
|
+
window.dispatchEvent(new CustomEvent(eventName, { detail }));
|
|
613
|
+
} catch {}
|
|
614
|
+
}
|
|
615
|
+
//#endregion
|
|
616
|
+
//#region src/core/rest/routes.gen.ts
|
|
617
|
+
/** Every public REST endpoint's method, path (under `/_api`) and query keys, from the contract. */
|
|
618
|
+
const ROUTES = {
|
|
619
|
+
ensureGuestSession: {
|
|
620
|
+
method: "POST",
|
|
621
|
+
path: "/auth/guest",
|
|
622
|
+
query: []
|
|
623
|
+
},
|
|
624
|
+
signUpWithPassword: {
|
|
625
|
+
method: "POST",
|
|
626
|
+
path: "/auth/signup",
|
|
627
|
+
query: []
|
|
628
|
+
},
|
|
629
|
+
logInWithPassword: {
|
|
630
|
+
method: "POST",
|
|
631
|
+
path: "/auth/login",
|
|
632
|
+
query: []
|
|
633
|
+
},
|
|
634
|
+
logOut: {
|
|
635
|
+
method: "POST",
|
|
636
|
+
path: "/auth/logout",
|
|
637
|
+
query: []
|
|
638
|
+
},
|
|
639
|
+
getCurrentUser: {
|
|
640
|
+
method: "GET",
|
|
641
|
+
path: "/me",
|
|
642
|
+
query: []
|
|
643
|
+
},
|
|
644
|
+
claimGuestAccount: {
|
|
645
|
+
method: "POST",
|
|
646
|
+
path: "/auth/claim",
|
|
647
|
+
query: []
|
|
648
|
+
},
|
|
649
|
+
listGames: {
|
|
650
|
+
method: "GET",
|
|
651
|
+
path: "/games",
|
|
652
|
+
query: []
|
|
653
|
+
},
|
|
654
|
+
listWhatsNewNotes: {
|
|
655
|
+
method: "GET",
|
|
656
|
+
path: "/changes",
|
|
657
|
+
query: ["game", "since"]
|
|
658
|
+
},
|
|
659
|
+
listFavoriteGames: {
|
|
660
|
+
method: "GET",
|
|
661
|
+
path: "/favorites",
|
|
662
|
+
query: []
|
|
663
|
+
},
|
|
664
|
+
setGameFavorite: {
|
|
665
|
+
method: "PUT",
|
|
666
|
+
path: "/favorites/:slug",
|
|
667
|
+
query: []
|
|
668
|
+
},
|
|
669
|
+
markWhatsNewSeen: {
|
|
670
|
+
method: "POST",
|
|
671
|
+
path: "/changes/seen",
|
|
672
|
+
query: []
|
|
673
|
+
},
|
|
674
|
+
getGameStats: {
|
|
675
|
+
method: "GET",
|
|
676
|
+
path: "/games/:slug/stats",
|
|
677
|
+
query: ["user"]
|
|
678
|
+
},
|
|
679
|
+
getGameProfile: {
|
|
680
|
+
method: "GET",
|
|
681
|
+
path: "/games/:slug/profile",
|
|
682
|
+
query: []
|
|
683
|
+
},
|
|
684
|
+
setGameProfile: {
|
|
685
|
+
method: "PUT",
|
|
686
|
+
path: "/games/:slug/profile",
|
|
687
|
+
query: []
|
|
688
|
+
},
|
|
689
|
+
listSaves: {
|
|
690
|
+
method: "GET",
|
|
691
|
+
path: "/games/:slug/saves",
|
|
692
|
+
query: []
|
|
693
|
+
},
|
|
694
|
+
getSave: {
|
|
695
|
+
method: "GET",
|
|
696
|
+
path: "/games/:slug/saves/:slot",
|
|
697
|
+
query: []
|
|
698
|
+
},
|
|
699
|
+
writeSave: {
|
|
700
|
+
method: "PUT",
|
|
701
|
+
path: "/games/:slug/saves/:slot",
|
|
702
|
+
query: []
|
|
703
|
+
},
|
|
704
|
+
deleteSave: {
|
|
705
|
+
method: "DELETE",
|
|
706
|
+
path: "/games/:slug/saves/:slot",
|
|
707
|
+
query: []
|
|
708
|
+
},
|
|
709
|
+
updateGameStat: {
|
|
710
|
+
method: "POST",
|
|
711
|
+
path: "/games/:slug/stats",
|
|
712
|
+
query: []
|
|
713
|
+
},
|
|
714
|
+
resetMyGameProgress: {
|
|
715
|
+
method: "POST",
|
|
716
|
+
path: "/games/:slug/reset",
|
|
717
|
+
query: []
|
|
718
|
+
},
|
|
719
|
+
listMyLeaderboardRecords: {
|
|
720
|
+
method: "GET",
|
|
721
|
+
path: "/me/records",
|
|
722
|
+
query: ["game"]
|
|
723
|
+
},
|
|
724
|
+
getMyOfflineSnapshot: {
|
|
725
|
+
method: "GET",
|
|
726
|
+
path: "/me/snapshot",
|
|
727
|
+
query: ["top"]
|
|
728
|
+
},
|
|
729
|
+
listTopOfEveryLeaderboard: {
|
|
730
|
+
method: "GET",
|
|
731
|
+
path: "/leaderboards",
|
|
732
|
+
query: ["top"]
|
|
733
|
+
},
|
|
734
|
+
listGameLeaderboards: {
|
|
735
|
+
method: "GET",
|
|
736
|
+
path: "/games/:slug/leaderboards",
|
|
737
|
+
query: []
|
|
738
|
+
},
|
|
739
|
+
getLeaderboard: {
|
|
740
|
+
method: "GET",
|
|
741
|
+
path: "/games/:slug/leaderboards/:key",
|
|
742
|
+
query: ["limit"]
|
|
743
|
+
},
|
|
744
|
+
submitScore: {
|
|
745
|
+
method: "POST",
|
|
746
|
+
path: "/games/:slug/leaderboards/:key/scores",
|
|
747
|
+
query: []
|
|
748
|
+
},
|
|
749
|
+
listDailyChallengesForAllGames: {
|
|
750
|
+
method: "GET",
|
|
751
|
+
path: "/daily",
|
|
752
|
+
query: []
|
|
753
|
+
},
|
|
754
|
+
getGameDailyChallenges: {
|
|
755
|
+
method: "GET",
|
|
756
|
+
path: "/games/:slug/daily",
|
|
757
|
+
query: []
|
|
758
|
+
},
|
|
759
|
+
startDailyChallenge: {
|
|
760
|
+
method: "POST",
|
|
761
|
+
path: "/games/:slug/daily/:day/start",
|
|
762
|
+
query: []
|
|
763
|
+
},
|
|
764
|
+
completeDailyChallenge: {
|
|
765
|
+
method: "POST",
|
|
766
|
+
path: "/games/:slug/daily/:day/complete",
|
|
767
|
+
query: []
|
|
768
|
+
},
|
|
769
|
+
getMyInventory: {
|
|
770
|
+
method: "GET",
|
|
771
|
+
path: "/games/:slug/inventory",
|
|
772
|
+
query: []
|
|
773
|
+
},
|
|
774
|
+
grantInventoryItems: {
|
|
775
|
+
method: "POST",
|
|
776
|
+
path: "/games/:slug/inventory/grant",
|
|
777
|
+
query: []
|
|
778
|
+
},
|
|
779
|
+
exchangeInventoryItems: {
|
|
780
|
+
method: "POST",
|
|
781
|
+
path: "/games/:slug/inventory/exchange",
|
|
782
|
+
query: []
|
|
783
|
+
},
|
|
784
|
+
getPlayerInventory: {
|
|
785
|
+
method: "GET",
|
|
786
|
+
path: "/games/:slug/players/:userId/inventory",
|
|
787
|
+
query: []
|
|
788
|
+
},
|
|
789
|
+
listTradePartners: {
|
|
790
|
+
method: "GET",
|
|
791
|
+
path: "/games/:slug/trades/players",
|
|
792
|
+
query: []
|
|
793
|
+
},
|
|
794
|
+
listMyTrades: {
|
|
795
|
+
method: "GET",
|
|
796
|
+
path: "/games/:slug/trades",
|
|
797
|
+
query: ["box", "status"]
|
|
798
|
+
},
|
|
799
|
+
declineTrade: {
|
|
800
|
+
method: "POST",
|
|
801
|
+
path: "/games/:slug/trades/:id/decline",
|
|
802
|
+
query: []
|
|
803
|
+
},
|
|
804
|
+
cancelTrade: {
|
|
805
|
+
method: "POST",
|
|
806
|
+
path: "/games/:slug/trades/:id/cancel",
|
|
807
|
+
query: []
|
|
808
|
+
},
|
|
809
|
+
createTrade: {
|
|
810
|
+
method: "POST",
|
|
811
|
+
path: "/games/:slug/trades",
|
|
812
|
+
query: []
|
|
813
|
+
},
|
|
814
|
+
acceptTrade: {
|
|
815
|
+
method: "POST",
|
|
816
|
+
path: "/games/:slug/trades/:id/accept",
|
|
817
|
+
query: []
|
|
818
|
+
},
|
|
819
|
+
counterTradeOffer: {
|
|
820
|
+
method: "POST",
|
|
821
|
+
path: "/games/:slug/trades/:id/counter",
|
|
822
|
+
query: []
|
|
823
|
+
},
|
|
824
|
+
getSiteAnalytics: {
|
|
825
|
+
method: "GET",
|
|
826
|
+
path: "/analytics",
|
|
827
|
+
query: []
|
|
828
|
+
},
|
|
829
|
+
reportClientError: {
|
|
830
|
+
method: "POST",
|
|
831
|
+
path: "/client-log",
|
|
832
|
+
query: []
|
|
833
|
+
},
|
|
834
|
+
recordGameEvent: {
|
|
835
|
+
method: "POST",
|
|
836
|
+
path: "/games/:slug/events",
|
|
837
|
+
query: []
|
|
838
|
+
},
|
|
839
|
+
getGameAnalytics: {
|
|
840
|
+
method: "GET",
|
|
841
|
+
path: "/games/:slug/analytics",
|
|
842
|
+
query: []
|
|
843
|
+
},
|
|
844
|
+
getSocialConfig: {
|
|
845
|
+
method: "GET",
|
|
846
|
+
path: "/social/config",
|
|
847
|
+
query: []
|
|
848
|
+
},
|
|
849
|
+
getOnlineCounts: {
|
|
850
|
+
method: "GET",
|
|
851
|
+
path: "/social/counts",
|
|
852
|
+
query: []
|
|
853
|
+
},
|
|
854
|
+
getMySocialProfile: {
|
|
855
|
+
method: "GET",
|
|
856
|
+
path: "/social/me",
|
|
857
|
+
query: []
|
|
858
|
+
},
|
|
859
|
+
listFriendsAndRequests: {
|
|
860
|
+
method: "GET",
|
|
861
|
+
path: "/social/friends",
|
|
862
|
+
query: []
|
|
863
|
+
},
|
|
864
|
+
findPlayerByName: {
|
|
865
|
+
method: "GET",
|
|
866
|
+
path: "/social/players/by-name/:name",
|
|
867
|
+
query: []
|
|
868
|
+
},
|
|
869
|
+
getPlayerCard: {
|
|
870
|
+
method: "GET",
|
|
871
|
+
path: "/social/players/:id",
|
|
872
|
+
query: []
|
|
873
|
+
},
|
|
874
|
+
updateMySocialProfile: {
|
|
875
|
+
method: "PUT",
|
|
876
|
+
path: "/social/me",
|
|
877
|
+
query: []
|
|
878
|
+
},
|
|
879
|
+
setParentChatLock: {
|
|
880
|
+
method: "POST",
|
|
881
|
+
path: "/social/chat-lock",
|
|
882
|
+
query: []
|
|
883
|
+
},
|
|
884
|
+
sendFriendRequest: {
|
|
885
|
+
method: "POST",
|
|
886
|
+
path: "/social/friends",
|
|
887
|
+
query: []
|
|
888
|
+
},
|
|
889
|
+
acceptFriendRequest: {
|
|
890
|
+
method: "POST",
|
|
891
|
+
path: "/social/friends/:id/accept",
|
|
892
|
+
query: []
|
|
893
|
+
},
|
|
894
|
+
declineFriendRequest: {
|
|
895
|
+
method: "POST",
|
|
896
|
+
path: "/social/friends/:id/decline",
|
|
897
|
+
query: []
|
|
898
|
+
},
|
|
899
|
+
removeFriend: {
|
|
900
|
+
method: "DELETE",
|
|
901
|
+
path: "/social/friends/:id",
|
|
902
|
+
query: []
|
|
903
|
+
},
|
|
904
|
+
setFriendNotifications: {
|
|
905
|
+
method: "PUT",
|
|
906
|
+
path: "/social/friends/:id/notify",
|
|
907
|
+
query: []
|
|
908
|
+
},
|
|
909
|
+
blockPlayer: {
|
|
910
|
+
method: "POST",
|
|
911
|
+
path: "/social/blocks",
|
|
912
|
+
query: []
|
|
913
|
+
},
|
|
914
|
+
unblockPlayer: {
|
|
915
|
+
method: "DELETE",
|
|
916
|
+
path: "/social/blocks/:id",
|
|
917
|
+
query: []
|
|
918
|
+
},
|
|
919
|
+
listRecentPlayers: {
|
|
920
|
+
method: "GET",
|
|
921
|
+
path: "/social/recent",
|
|
922
|
+
query: []
|
|
923
|
+
},
|
|
924
|
+
listConversations: {
|
|
925
|
+
method: "GET",
|
|
926
|
+
path: "/social/conversations",
|
|
927
|
+
query: []
|
|
928
|
+
},
|
|
929
|
+
listDirectMessages: {
|
|
930
|
+
method: "GET",
|
|
931
|
+
path: "/social/messages/:userId",
|
|
932
|
+
query: ["before"]
|
|
933
|
+
},
|
|
934
|
+
sendDirectMessage: {
|
|
935
|
+
method: "POST",
|
|
936
|
+
path: "/social/messages/:userId",
|
|
937
|
+
query: []
|
|
938
|
+
},
|
|
939
|
+
markDirectMessagesRead: {
|
|
940
|
+
method: "POST",
|
|
941
|
+
path: "/social/messages/:userId/read",
|
|
942
|
+
query: []
|
|
943
|
+
},
|
|
944
|
+
getNotificationPreferences: {
|
|
945
|
+
method: "GET",
|
|
946
|
+
path: "/social/notify",
|
|
947
|
+
query: []
|
|
948
|
+
},
|
|
949
|
+
unsubscribeFromPush: {
|
|
950
|
+
method: "DELETE",
|
|
951
|
+
path: "/social/push",
|
|
952
|
+
query: []
|
|
953
|
+
},
|
|
954
|
+
setNotificationPreferences: {
|
|
955
|
+
method: "PUT",
|
|
956
|
+
path: "/social/notify",
|
|
957
|
+
query: []
|
|
958
|
+
},
|
|
959
|
+
subscribeToPush: {
|
|
960
|
+
method: "POST",
|
|
961
|
+
path: "/social/push",
|
|
962
|
+
query: []
|
|
963
|
+
},
|
|
964
|
+
fileReport: {
|
|
965
|
+
method: "POST",
|
|
966
|
+
path: "/social/reports",
|
|
967
|
+
query: []
|
|
968
|
+
},
|
|
969
|
+
listPublicSuggestions: {
|
|
970
|
+
method: "GET",
|
|
971
|
+
path: "/suggestions",
|
|
972
|
+
query: ["sort"]
|
|
973
|
+
},
|
|
974
|
+
submitSuggestion: {
|
|
975
|
+
method: "POST",
|
|
976
|
+
path: "/suggestions",
|
|
977
|
+
query: []
|
|
978
|
+
},
|
|
979
|
+
voteForSuggestion: {
|
|
980
|
+
method: "POST",
|
|
981
|
+
path: "/suggestions/:id/vote",
|
|
982
|
+
query: []
|
|
983
|
+
},
|
|
984
|
+
removeSuggestionVote: {
|
|
985
|
+
method: "DELETE",
|
|
986
|
+
path: "/suggestions/:id/vote",
|
|
987
|
+
query: []
|
|
988
|
+
},
|
|
989
|
+
withdrawSuggestion: {
|
|
990
|
+
method: "DELETE",
|
|
991
|
+
path: "/suggestions/:id",
|
|
992
|
+
query: []
|
|
993
|
+
},
|
|
994
|
+
sendGameInvite: {
|
|
995
|
+
method: "POST",
|
|
996
|
+
path: "/mp/invites",
|
|
997
|
+
query: []
|
|
998
|
+
},
|
|
999
|
+
acceptGameInvite: {
|
|
1000
|
+
method: "POST",
|
|
1001
|
+
path: "/mp/invites/:id/accept",
|
|
1002
|
+
query: []
|
|
1003
|
+
},
|
|
1004
|
+
declineGameInvite: {
|
|
1005
|
+
method: "POST",
|
|
1006
|
+
path: "/mp/invites/:id/decline",
|
|
1007
|
+
query: []
|
|
1008
|
+
},
|
|
1009
|
+
cancelGameInvite: {
|
|
1010
|
+
method: "DELETE",
|
|
1011
|
+
path: "/mp/invites/:id",
|
|
1012
|
+
query: []
|
|
1013
|
+
},
|
|
1014
|
+
redeemLaunchToken: {
|
|
1015
|
+
method: "GET",
|
|
1016
|
+
path: "/mp/launch/:token",
|
|
1017
|
+
query: []
|
|
1018
|
+
},
|
|
1019
|
+
setPartyJoinInfo: {
|
|
1020
|
+
method: "POST",
|
|
1021
|
+
path: "/mp/parties/:id/join-info",
|
|
1022
|
+
query: []
|
|
1023
|
+
},
|
|
1024
|
+
leaveParty: {
|
|
1025
|
+
method: "POST",
|
|
1026
|
+
path: "/mp/parties/:id/leave",
|
|
1027
|
+
query: []
|
|
1028
|
+
},
|
|
1029
|
+
launchIntoFriendsRoom: {
|
|
1030
|
+
method: "POST",
|
|
1031
|
+
path: "/mp/join",
|
|
1032
|
+
query: []
|
|
1033
|
+
},
|
|
1034
|
+
getGameServerTicket: {
|
|
1035
|
+
method: "POST",
|
|
1036
|
+
path: "/mp/ticket",
|
|
1037
|
+
query: []
|
|
1038
|
+
},
|
|
1039
|
+
listMyRaceHistory: {
|
|
1040
|
+
method: "GET",
|
|
1041
|
+
path: "/race/history",
|
|
1042
|
+
query: [
|
|
1043
|
+
"game",
|
|
1044
|
+
"with",
|
|
1045
|
+
"before",
|
|
1046
|
+
"limit"
|
|
1047
|
+
]
|
|
1048
|
+
},
|
|
1049
|
+
getHubVersion: {
|
|
1050
|
+
method: "GET",
|
|
1051
|
+
path: "/version",
|
|
1052
|
+
query: []
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
//#endregion
|
|
1056
|
+
//#region src/core/rest/rest-client.ts
|
|
1057
|
+
/**
|
|
1058
|
+
* The REST transport: a small fetch router over the contract's route table
|
|
1059
|
+
* (plan §12 A10). It splits one merged fields object into the path, query
|
|
1060
|
+
* and JSON body the way ayepi's client does, stamps `x-hub-client`, and turns
|
|
1061
|
+
* failures into {@link HubError}s. It carries no schemas: replies are typed
|
|
1062
|
+
* from the contract and trusted, as ayepi's zod-free client trusts them.
|
|
1063
|
+
*/
|
|
1064
|
+
/** The header that tells the hub which client version is calling. */
|
|
1065
|
+
const CLIENT_VERSION_HEADER = "x-hub-client";
|
|
1066
|
+
/** What both hubs answer for a route they do not have. */
|
|
1067
|
+
const NO_SUCH_ENDPOINT = "No such API endpoint.";
|
|
1068
|
+
/** One field value as URL text. */
|
|
1069
|
+
function urlText(fieldValue) {
|
|
1070
|
+
if (typeof fieldValue === "string") return fieldValue;
|
|
1071
|
+
if (typeof fieldValue === "number" || typeof fieldValue === "boolean") return String(fieldValue);
|
|
1072
|
+
return JSON.stringify(fieldValue);
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* Split merged fields into path, query and body for one endpoint: `:name`
|
|
1076
|
+
* segments take their field, query keys go to the query string, and the
|
|
1077
|
+
* rest is the JSON body. POST and PUT always send a body (`{}` when empty),
|
|
1078
|
+
* as the v1 client did; DELETE sends one only when it has fields.
|
|
1079
|
+
*/
|
|
1080
|
+
function planRequest(name, fields) {
|
|
1081
|
+
const route = ROUTES[name];
|
|
1082
|
+
const remaining = new Map(Object.entries(isRecord(fields) ? fields : {}).filter(([, fieldValue]) => fieldValue !== void 0));
|
|
1083
|
+
const path = route.path.replace(/:([A-Za-z]+)/g, (_segment, paramName) => {
|
|
1084
|
+
const paramValue = remaining.get(paramName);
|
|
1085
|
+
remaining.delete(paramName);
|
|
1086
|
+
return encodeURIComponent(urlText(paramValue));
|
|
1087
|
+
});
|
|
1088
|
+
const queryKeys = route.method === "GET" ? [...remaining.keys()] : route.query;
|
|
1089
|
+
const query = new URLSearchParams();
|
|
1090
|
+
for (const key of queryKeys) {
|
|
1091
|
+
const queryValue = remaining.get(key);
|
|
1092
|
+
remaining.delete(key);
|
|
1093
|
+
if (queryValue !== void 0 && queryValue !== null) query.set(key, urlText(queryValue));
|
|
1094
|
+
}
|
|
1095
|
+
const queryText = query.toString();
|
|
1096
|
+
const hasBody = route.method === "POST" || route.method === "PUT" || route.method === "DELETE" && remaining.size > 0;
|
|
1097
|
+
return {
|
|
1098
|
+
pathAndQuery: queryText === "" ? path : `${path}?${queryText}`,
|
|
1099
|
+
method: route.method,
|
|
1100
|
+
bodyText: hasBody ? JSON.stringify(Object.fromEntries(remaining)) : void 0
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* The reply body, typed by the contract. This is the client's one trust
|
|
1105
|
+
* boundary on replies: there is no zod in the browser bundle (plan D16).
|
|
1106
|
+
*/
|
|
1107
|
+
async function readReply(response) {
|
|
1108
|
+
try {
|
|
1109
|
+
return await response.json();
|
|
1110
|
+
} catch {
|
|
1111
|
+
throw new HubError("The hub sent a reply that is not JSON.", response.status);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
/** The error for a failed response. */
|
|
1115
|
+
async function failureOf(name, response) {
|
|
1116
|
+
let fields = readErrorFields(void 0);
|
|
1117
|
+
try {
|
|
1118
|
+
fields = readErrorFields(await response.json());
|
|
1119
|
+
} catch {}
|
|
1120
|
+
if (response.status === 404 && fields.error === NO_SUCH_ENDPOINT) return new HubError(`This hub does not have "${name}" yet: deploy the hub first, or use an older client.`, 404, { code: UNSUPPORTED_BY_HUB });
|
|
1121
|
+
return errorFor(fields.error ?? `Request failed (${response.status})`, response.status, fields);
|
|
1122
|
+
}
|
|
1123
|
+
/** A REST client for one API base. */
|
|
1124
|
+
function createRestClient(options) {
|
|
1125
|
+
async function send(name, fields) {
|
|
1126
|
+
const planned = planRequest(name, fields);
|
|
1127
|
+
let response;
|
|
1128
|
+
try {
|
|
1129
|
+
response = await options.fetch(options.apiBase + planned.pathAndQuery, {
|
|
1130
|
+
method: planned.method,
|
|
1131
|
+
credentials: "same-origin",
|
|
1132
|
+
headers: {
|
|
1133
|
+
"content-type": "application/json",
|
|
1134
|
+
[CLIENT_VERSION_HEADER]: HUB_CLIENT_VERSION
|
|
1135
|
+
},
|
|
1136
|
+
...planned.bodyText === void 0 ? {} : { body: planned.bodyText }
|
|
1137
|
+
});
|
|
1138
|
+
} catch (failure) {
|
|
1139
|
+
throw new HubError(`Hub unreachable: ${failure instanceof Error ? failure.message : String(failure)}`);
|
|
1140
|
+
}
|
|
1141
|
+
options.onReachable?.();
|
|
1142
|
+
if (!response.ok) throw await failureOf(name, response);
|
|
1143
|
+
return readReply(response);
|
|
1144
|
+
}
|
|
1145
|
+
return {
|
|
1146
|
+
apiBase: options.apiBase,
|
|
1147
|
+
call: (name, ...fieldsArgument) => send(name, fieldsArgument[0])
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
//#endregion
|
|
1151
|
+
//#region src/core/connection.ts
|
|
1152
|
+
/**
|
|
1153
|
+
* The machinery under every `Hub` call: reaching the REST API, falling back
|
|
1154
|
+
* to the offline cache, queueing writes made offline and replaying them,
|
|
1155
|
+
* buffering analytics, warming the cache for every game, and reporting
|
|
1156
|
+
* online/offline/sync status (to `onStatus` listeners and as `hub:*` window
|
|
1157
|
+
* events, which the injected menu listens for).
|
|
1158
|
+
*/
|
|
1159
|
+
/** How long `prefetchAll()` waits between snapshots unless forced. */
|
|
1160
|
+
const PREFETCH_INTERVAL_MS = 3e4;
|
|
1161
|
+
/** Whether a failure means "the hub can't be reached" (rather than a refusal). */
|
|
1162
|
+
function isTransportFailure(failure) {
|
|
1163
|
+
return failure instanceof HubError && failure.isOffline;
|
|
1164
|
+
}
|
|
1165
|
+
/** The connection, offline cache and sync machinery shared by every `Hub` method. */
|
|
1166
|
+
var HubConnection = class {
|
|
1167
|
+
/** A connection for `options` (slug and API base default to the page's). */
|
|
1168
|
+
constructor(options = {}) {
|
|
1169
|
+
_defineProperty(this, "slug", void 0);
|
|
1170
|
+
_defineProperty(this, "apiBase", void 0);
|
|
1171
|
+
_defineProperty(this, "rest", void 0);
|
|
1172
|
+
_defineProperty(this, "store", void 0);
|
|
1173
|
+
_defineProperty(this, "offlineMode", void 0);
|
|
1174
|
+
_defineProperty(this, "isReachable", true);
|
|
1175
|
+
_defineProperty(this, "isSyncing", false);
|
|
1176
|
+
_defineProperty(this, "lastPrefetchAt", 0);
|
|
1177
|
+
_defineProperty(this, "statusListeners", /* @__PURE__ */ new Set());
|
|
1178
|
+
this.slug = options.slug ?? detectSlug();
|
|
1179
|
+
this.apiBase = options.apiBase === void 0 ? detectApiBase() : absoluteApiBase(options.apiBase);
|
|
1180
|
+
this.offlineMode = options.offline ?? "auto";
|
|
1181
|
+
this.rest = createRestClient({
|
|
1182
|
+
apiBase: this.apiBase,
|
|
1183
|
+
fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
|
|
1184
|
+
onReachable: () => {
|
|
1185
|
+
this.setReachable(true);
|
|
1186
|
+
}
|
|
1187
|
+
});
|
|
1188
|
+
this.store = new LocalStore(this.slug ?? "app");
|
|
1189
|
+
if (typeof navigator !== "undefined" && "onLine" in navigator) this.isReachable = navigator.onLine;
|
|
1190
|
+
if (isBrowserPage()) {
|
|
1191
|
+
window.addEventListener("online", () => {
|
|
1192
|
+
this.setReachable(true);
|
|
1193
|
+
});
|
|
1194
|
+
window.addEventListener("offline", () => {
|
|
1195
|
+
this.setReachable(false);
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
/** `true` when calls are being served from the offline cache rather than the hub. */
|
|
1200
|
+
get isOffline() {
|
|
1201
|
+
return this.offlineMode === "always" || this.offlineMode === "auto" && !this.isReachable;
|
|
1202
|
+
}
|
|
1203
|
+
/** `true` while the hub is reachable. */
|
|
1204
|
+
get isOnline() {
|
|
1205
|
+
return this.isReachable;
|
|
1206
|
+
}
|
|
1207
|
+
/** Queued writes plus buffered analytics events waiting to sync. */
|
|
1208
|
+
pending() {
|
|
1209
|
+
return this.store.queueLength() + this.store.getEvents().length;
|
|
1210
|
+
}
|
|
1211
|
+
/** Subscribe to connection and sync status changes; returns the unsubscribe function. */
|
|
1212
|
+
onStatus(listener) {
|
|
1213
|
+
this.statusListeners.add(listener);
|
|
1214
|
+
return () => {
|
|
1215
|
+
this.statusListeners.delete(listener);
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
/** Tell `onStatus` listeners and the page (`hub:status`) the current status. */
|
|
1219
|
+
notifyStatus() {
|
|
1220
|
+
const status = {
|
|
1221
|
+
online: this.isReachable,
|
|
1222
|
+
offline: this.isOffline,
|
|
1223
|
+
pending: this.pending()
|
|
1224
|
+
};
|
|
1225
|
+
for (const listener of this.statusListeners) try {
|
|
1226
|
+
listener(status);
|
|
1227
|
+
} catch {}
|
|
1228
|
+
emitWindowEvent("hub:status", status);
|
|
1229
|
+
}
|
|
1230
|
+
/** Record reachability; coming back online syncs the queue and re-warms the cache. */
|
|
1231
|
+
setReachable(isReachableNow) {
|
|
1232
|
+
if (isReachableNow === this.isReachable) return;
|
|
1233
|
+
this.isReachable = isReachableNow;
|
|
1234
|
+
emitWindowEvent(isReachableNow ? "hub:online" : "hub:offline", { pending: this.pending() });
|
|
1235
|
+
this.notifyStatus();
|
|
1236
|
+
if (isReachableNow) {
|
|
1237
|
+
this.sync();
|
|
1238
|
+
this.prefetchAll().catch(() => void 0);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
/** `{ slug }` for this game's endpoints; throws when the slug is unknown. */
|
|
1242
|
+
gameFields() {
|
|
1243
|
+
if (this.slug === null) throw new HubError("Hub: could not determine the game slug.");
|
|
1244
|
+
return { slug: this.slug };
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Run the network call, falling back to the offline answer when the hub
|
|
1248
|
+
* can't be reached (per the offline mode). Real API errors (with an HTTP
|
|
1249
|
+
* status) always reach the caller.
|
|
1250
|
+
*/
|
|
1251
|
+
async withFallback(networkCall, offlineAnswer) {
|
|
1252
|
+
if (this.isOffline) return offlineAnswer();
|
|
1253
|
+
try {
|
|
1254
|
+
return await networkCall();
|
|
1255
|
+
} catch (failure) {
|
|
1256
|
+
if (!isTransportFailure(failure) || this.offlineMode === "never") throw failure;
|
|
1257
|
+
this.setReachable(false);
|
|
1258
|
+
return offlineAnswer();
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
/** Send one queued write. */
|
|
1262
|
+
async sendOp(op) {
|
|
1263
|
+
const game = this.gameFields();
|
|
1264
|
+
switch (op.kind) {
|
|
1265
|
+
case "putSave":
|
|
1266
|
+
await this.rest.call("writeSave", {
|
|
1267
|
+
...game,
|
|
1268
|
+
slot: op.slot,
|
|
1269
|
+
data: op.data,
|
|
1270
|
+
label: op.label
|
|
1271
|
+
});
|
|
1272
|
+
return;
|
|
1273
|
+
case "deleteSave":
|
|
1274
|
+
await this.rest.call("deleteSave", {
|
|
1275
|
+
...game,
|
|
1276
|
+
slot: op.slot
|
|
1277
|
+
});
|
|
1278
|
+
return;
|
|
1279
|
+
case "setStat":
|
|
1280
|
+
await this.rest.call("updateGameStat", {
|
|
1281
|
+
...game,
|
|
1282
|
+
key: op.key,
|
|
1283
|
+
value: op.value,
|
|
1284
|
+
mode: "set"
|
|
1285
|
+
});
|
|
1286
|
+
return;
|
|
1287
|
+
case "maxStat":
|
|
1288
|
+
await this.rest.call("updateGameStat", {
|
|
1289
|
+
...game,
|
|
1290
|
+
key: op.key,
|
|
1291
|
+
value: op.value,
|
|
1292
|
+
mode: "max"
|
|
1293
|
+
});
|
|
1294
|
+
return;
|
|
1295
|
+
case "incrStat":
|
|
1296
|
+
await this.rest.call("updateGameStat", {
|
|
1297
|
+
...game,
|
|
1298
|
+
key: op.key,
|
|
1299
|
+
value: op.amount,
|
|
1300
|
+
mode: "incr"
|
|
1301
|
+
});
|
|
1302
|
+
return;
|
|
1303
|
+
case "submitScore":
|
|
1304
|
+
await this.rest.call("submitScore", {
|
|
1305
|
+
...game,
|
|
1306
|
+
key: op.key,
|
|
1307
|
+
score: op.score,
|
|
1308
|
+
...op.opts
|
|
1309
|
+
});
|
|
1310
|
+
return;
|
|
1311
|
+
case "setProfile":
|
|
1312
|
+
await this.rest.call("setGameProfile", {
|
|
1313
|
+
...game,
|
|
1314
|
+
...op.patch
|
|
1315
|
+
});
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
/**
|
|
1320
|
+
* Apply a write to the offline cache at once, then send it now (online
|
|
1321
|
+
* with an empty queue) or queue it behind the others. Resolves with the
|
|
1322
|
+
* local answer; a real API error on the direct path reaches the caller and
|
|
1323
|
+
* is not queued.
|
|
1324
|
+
*/
|
|
1325
|
+
async mutateQueued(input, localAnswer) {
|
|
1326
|
+
const answer = localAnswer();
|
|
1327
|
+
if (this.isOffline || this.store.queueLength() > 0) {
|
|
1328
|
+
this.store.enqueue(input);
|
|
1329
|
+
this.notifyStatus();
|
|
1330
|
+
if (!this.isOffline) this.sync();
|
|
1331
|
+
return answer;
|
|
1332
|
+
}
|
|
1333
|
+
try {
|
|
1334
|
+
await this.sendOp(input);
|
|
1335
|
+
return answer;
|
|
1336
|
+
} catch (failure) {
|
|
1337
|
+
if (!isTransportFailure(failure)) throw failure;
|
|
1338
|
+
this.setReachable(false);
|
|
1339
|
+
this.store.enqueue(input);
|
|
1340
|
+
this.notifyStatus();
|
|
1341
|
+
return answer;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* Replay the queue, then the buffered events, in order. Stops at the first
|
|
1346
|
+
* transport failure (offline again: the rest stays queued); a write the hub
|
|
1347
|
+
* refuses is dropped and reported as a conflict.
|
|
1348
|
+
*/
|
|
1349
|
+
async sync() {
|
|
1350
|
+
if (this.isSyncing || this.isOffline || this.slug === null) return {
|
|
1351
|
+
flushed: 0,
|
|
1352
|
+
conflicts: []
|
|
1353
|
+
};
|
|
1354
|
+
this.isSyncing = true;
|
|
1355
|
+
let flushed = 0;
|
|
1356
|
+
const conflicts = [];
|
|
1357
|
+
try {
|
|
1358
|
+
let queue = this.store.getQueue();
|
|
1359
|
+
for (let op = queue[0]; op !== void 0; op = queue[0]) {
|
|
1360
|
+
try {
|
|
1361
|
+
await this.sendOp(op);
|
|
1362
|
+
flushed += 1;
|
|
1363
|
+
} catch (failure) {
|
|
1364
|
+
if (isTransportFailure(failure)) break;
|
|
1365
|
+
conflicts.push({
|
|
1366
|
+
op,
|
|
1367
|
+
error: failure instanceof Error ? failure.message : String(failure)
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
queue = queue.slice(1);
|
|
1371
|
+
this.store.setQueue(queue);
|
|
1372
|
+
}
|
|
1373
|
+
await this.flushEvents();
|
|
1374
|
+
} finally {
|
|
1375
|
+
this.isSyncing = false;
|
|
1376
|
+
}
|
|
1377
|
+
emitWindowEvent("hub:sync", {
|
|
1378
|
+
pending: this.pending(),
|
|
1379
|
+
flushed,
|
|
1380
|
+
conflicts
|
|
1381
|
+
});
|
|
1382
|
+
this.notifyStatus();
|
|
1383
|
+
return {
|
|
1384
|
+
flushed,
|
|
1385
|
+
conflicts
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
/** Send the buffered analytics events; a refused one is dropped (telemetry never blocks). */
|
|
1389
|
+
async flushEvents() {
|
|
1390
|
+
let events = this.store.getEvents();
|
|
1391
|
+
for (let event = events[0]; event !== void 0; event = events[0]) {
|
|
1392
|
+
try {
|
|
1393
|
+
await this.rest.call("recordGameEvent", {
|
|
1394
|
+
...this.gameFields(),
|
|
1395
|
+
type: event.type
|
|
1396
|
+
});
|
|
1397
|
+
} catch (failure) {
|
|
1398
|
+
if (isTransportFailure(failure)) return;
|
|
1399
|
+
}
|
|
1400
|
+
events = events.slice(1);
|
|
1401
|
+
this.store.setEvents(events);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Warm the offline cache for every game the player has touched, in one
|
|
1406
|
+
* request, so going offline later still shows complete progress.
|
|
1407
|
+
* Throttled to once per 30 s unless `isForced`; `null` when offline or throttled.
|
|
1408
|
+
*/
|
|
1409
|
+
async prefetchAll(isForced = false) {
|
|
1410
|
+
if (this.isOffline || !isForced && Date.now() - this.lastPrefetchAt < PREFETCH_INTERVAL_MS) return null;
|
|
1411
|
+
let snapshot;
|
|
1412
|
+
try {
|
|
1413
|
+
snapshot = await this.rest.call("getMyOfflineSnapshot");
|
|
1414
|
+
} catch (failure) {
|
|
1415
|
+
if (isTransportFailure(failure)) return null;
|
|
1416
|
+
throw failure;
|
|
1417
|
+
}
|
|
1418
|
+
this.lastPrefetchAt = Date.now();
|
|
1419
|
+
this.store.hydrate(snapshot);
|
|
1420
|
+
emitWindowEvent("hub:prefetch", { games: Object.keys(snapshot.games).length });
|
|
1421
|
+
return snapshot;
|
|
1422
|
+
}
|
|
1423
|
+
};
|
|
1424
|
+
//#endregion
|
|
1425
|
+
//#region src/core/game-data.ts
|
|
1426
|
+
/** Always true: a game's payload is sent as JSON (`JSON.stringify` drops what JSON can't hold). */
|
|
1427
|
+
function isSentAsJson(_payload) {
|
|
1428
|
+
return true;
|
|
1429
|
+
}
|
|
1430
|
+
/** Always true: JSON the game stored is read back as the game's type. */
|
|
1431
|
+
function isGameOwnData(_stored) {
|
|
1432
|
+
return true;
|
|
1433
|
+
}
|
|
1434
|
+
/** A game's payload as the JSON the hub stores. */
|
|
1435
|
+
function toStoredJson(payload) {
|
|
1436
|
+
if (isSentAsJson(payload)) return payload;
|
|
1437
|
+
throw new TypeError("unreachable: every payload is sent as JSON");
|
|
1438
|
+
}
|
|
1439
|
+
/** JSON the game stored, typed as the game's own type. */
|
|
1440
|
+
function toGameData(stored) {
|
|
1441
|
+
if (isGameOwnData(stored)) return stored;
|
|
1442
|
+
throw new TypeError("unreachable: stored game data is the game's own");
|
|
1443
|
+
}
|
|
1444
|
+
//#endregion
|
|
1445
|
+
//#region src/core/hub-progress.ts
|
|
1446
|
+
/** A profile with the game's own data typed as `TData`. */
|
|
1447
|
+
function typedProfile(profile) {
|
|
1448
|
+
return profile === null ? null : {
|
|
1449
|
+
...profile,
|
|
1450
|
+
data: toGameData(profile.data)
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
/** A save with the game's own data typed as `TData`. */
|
|
1454
|
+
function typedSave(save) {
|
|
1455
|
+
return save === null ? null : {
|
|
1456
|
+
...save,
|
|
1457
|
+
data: toGameData(save.data)
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
/** Accounts, profile, saves, stats and leaderboards. */
|
|
1461
|
+
var HubProgress = class extends HubConnection {
|
|
1462
|
+
/** The current user (or `null`) and the games they have a profile in. */
|
|
1463
|
+
me() {
|
|
1464
|
+
return this.withFallback(() => this.rest.call("getCurrentUser"), () => {
|
|
1465
|
+
const profile = this.store.getProfile();
|
|
1466
|
+
return {
|
|
1467
|
+
user: this.store.getUser(),
|
|
1468
|
+
profiles: profile === null ? [] : [{
|
|
1469
|
+
game: profile.game,
|
|
1470
|
+
displayName: profile.displayName
|
|
1471
|
+
}]
|
|
1472
|
+
};
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Make sure a session exists, creating a guest (a random name, no
|
|
1477
|
+
* password) when the player isn't signed in, so progress saves without a
|
|
1478
|
+
* sign-in. Idempotent. Offline, a local guest is used.
|
|
1479
|
+
*/
|
|
1480
|
+
ensureGuest() {
|
|
1481
|
+
return this.withFallback(() => this.rest.call("ensureGuestSession"), () => {
|
|
1482
|
+
const known = this.store.getUser();
|
|
1483
|
+
if (known !== null) return { user: known };
|
|
1484
|
+
const guest = {
|
|
1485
|
+
id: 0,
|
|
1486
|
+
username: "Guest-local",
|
|
1487
|
+
guest: true
|
|
1488
|
+
};
|
|
1489
|
+
this.store.setUser(guest);
|
|
1490
|
+
return { user: guest };
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Turn the current guest into a registered account (the same identity, so
|
|
1495
|
+
* all progress carries over). Rejects with 409 when already registered or
|
|
1496
|
+
* the name is taken; the player should `login` instead.
|
|
1497
|
+
*/
|
|
1498
|
+
claim(username, password) {
|
|
1499
|
+
return this.withFallback(() => this.rest.call("claimGuestAccount", {
|
|
1500
|
+
username,
|
|
1501
|
+
password
|
|
1502
|
+
}), () => this.signInLocally({
|
|
1503
|
+
id: 0,
|
|
1504
|
+
username,
|
|
1505
|
+
guest: false
|
|
1506
|
+
}));
|
|
1507
|
+
}
|
|
1508
|
+
/** Create an account and sign in. (Offline: a local user is stored.) */
|
|
1509
|
+
signup(username, password) {
|
|
1510
|
+
return this.withFallback(() => this.rest.call("signUpWithPassword", {
|
|
1511
|
+
username,
|
|
1512
|
+
password
|
|
1513
|
+
}), () => this.signInLocally({
|
|
1514
|
+
id: 0,
|
|
1515
|
+
username
|
|
1516
|
+
}));
|
|
1517
|
+
}
|
|
1518
|
+
/** Sign in. (Offline: a local user is stored so signed-in flows work.) */
|
|
1519
|
+
login(username, password) {
|
|
1520
|
+
return this.withFallback(() => this.rest.call("logInWithPassword", {
|
|
1521
|
+
username,
|
|
1522
|
+
password
|
|
1523
|
+
}), () => this.signInLocally({
|
|
1524
|
+
id: 0,
|
|
1525
|
+
username
|
|
1526
|
+
}));
|
|
1527
|
+
}
|
|
1528
|
+
/** Sign out (ends the session shared by every game). */
|
|
1529
|
+
logout() {
|
|
1530
|
+
return this.withFallback(() => this.rest.call("logOut"), () => {
|
|
1531
|
+
this.store.clearUser();
|
|
1532
|
+
return { ok: true };
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
signInLocally(user) {
|
|
1536
|
+
this.store.setUser(user);
|
|
1537
|
+
return { user };
|
|
1538
|
+
}
|
|
1539
|
+
/** This game's profile for the current user, or `profile: null`. `TData` is the game's own data type. */
|
|
1540
|
+
getProfile() {
|
|
1541
|
+
return this.withFallback(async () => ({ profile: typedProfile((await this.rest.call("getGameProfile", this.gameFields())).profile) }), () => ({ profile: typedProfile(this.store.getProfile()) }));
|
|
1542
|
+
}
|
|
1543
|
+
/** Create or update this game's profile for the current user. */
|
|
1544
|
+
setProfile(patch) {
|
|
1545
|
+
const queued = {};
|
|
1546
|
+
if (patch.displayName !== void 0) queued.displayName = patch.displayName;
|
|
1547
|
+
if (patch.data !== void 0) queued.data = toStoredJson(patch.data);
|
|
1548
|
+
return this.mutateQueued({
|
|
1549
|
+
kind: "setProfile",
|
|
1550
|
+
patch: queued
|
|
1551
|
+
}, () => {
|
|
1552
|
+
const stored = this.store.setProfile(this.slug ?? "app", patch.displayName ?? null, queued.data ?? {});
|
|
1553
|
+
return { profile: {
|
|
1554
|
+
...stored,
|
|
1555
|
+
data: toGameData(stored.data)
|
|
1556
|
+
} };
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
/** Metadata for every save slot (no payloads), newest first. */
|
|
1560
|
+
listSaves() {
|
|
1561
|
+
return this.withFallback(() => this.rest.call("listSaves", this.gameFields()), () => ({ saves: this.store.listSaves() }));
|
|
1562
|
+
}
|
|
1563
|
+
/** One slot's contents, or `save: null` when the slot is empty. `TData` is the game's own save type. */
|
|
1564
|
+
getSave(slot) {
|
|
1565
|
+
return this.withFallback(async () => ({ save: typedSave((await this.rest.call("getSave", {
|
|
1566
|
+
...this.gameFields(),
|
|
1567
|
+
slot
|
|
1568
|
+
})).save) }), () => ({ save: typedSave(this.store.getSave(slot)) }));
|
|
1569
|
+
}
|
|
1570
|
+
/** Create or overwrite a save slot. `saveData` is any JSON up to 64 KB. */
|
|
1571
|
+
putSave(slot, saveData, label) {
|
|
1572
|
+
const stored = toStoredJson(saveData);
|
|
1573
|
+
return this.mutateQueued({
|
|
1574
|
+
kind: "putSave",
|
|
1575
|
+
slot,
|
|
1576
|
+
data: stored,
|
|
1577
|
+
label: label ?? null
|
|
1578
|
+
}, () => ({
|
|
1579
|
+
ok: true,
|
|
1580
|
+
updatedAt: this.store.putSave(slot, stored, label ?? null)
|
|
1581
|
+
}));
|
|
1582
|
+
}
|
|
1583
|
+
/** Delete a save slot. */
|
|
1584
|
+
deleteSave(slot) {
|
|
1585
|
+
return this.mutateQueued({
|
|
1586
|
+
kind: "deleteSave",
|
|
1587
|
+
slot
|
|
1588
|
+
}, () => {
|
|
1589
|
+
this.store.deleteSave(slot);
|
|
1590
|
+
return { ok: true };
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
/** Numeric stats for the current user, or another player's via `userId`. */
|
|
1594
|
+
getStats(userId) {
|
|
1595
|
+
return this.withFallback(() => this.rest.call("getGameStats", {
|
|
1596
|
+
...this.gameFields(),
|
|
1597
|
+
...userId === void 0 ? {} : { user: userId }
|
|
1598
|
+
}), () => ({ stats: this.store.getStats() }));
|
|
1599
|
+
}
|
|
1600
|
+
/** Overwrite a stat. */
|
|
1601
|
+
setStat(key, value) {
|
|
1602
|
+
return this.mutateQueued({
|
|
1603
|
+
kind: "setStat",
|
|
1604
|
+
key,
|
|
1605
|
+
value
|
|
1606
|
+
}, () => ({
|
|
1607
|
+
key,
|
|
1608
|
+
value: this.store.updateStat(key, value, "set")
|
|
1609
|
+
}));
|
|
1610
|
+
}
|
|
1611
|
+
/** Add to a stat (creating it at `amount` when absent). */
|
|
1612
|
+
incrStat(key, amount = 1) {
|
|
1613
|
+
return this.mutateQueued({
|
|
1614
|
+
kind: "incrStat",
|
|
1615
|
+
key,
|
|
1616
|
+
amount
|
|
1617
|
+
}, () => ({
|
|
1618
|
+
key,
|
|
1619
|
+
value: this.store.updateStat(key, amount, "incr")
|
|
1620
|
+
}));
|
|
1621
|
+
}
|
|
1622
|
+
/** Keep the larger of the stored and the given value. */
|
|
1623
|
+
maxStat(key, value) {
|
|
1624
|
+
return this.mutateQueued({
|
|
1625
|
+
kind: "maxStat",
|
|
1626
|
+
key,
|
|
1627
|
+
value
|
|
1628
|
+
}, () => ({
|
|
1629
|
+
key,
|
|
1630
|
+
value: this.store.updateStat(key, value, "max")
|
|
1631
|
+
}));
|
|
1632
|
+
}
|
|
1633
|
+
/** Submit a score. The board is created on first use; only a player's best score sticks. */
|
|
1634
|
+
submitScore(boardKey, score, opts = {}) {
|
|
1635
|
+
const queued = {
|
|
1636
|
+
...opts.title === void 0 ? {} : { title: opts.title },
|
|
1637
|
+
...opts.sort === void 0 ? {} : { sort: opts.sort },
|
|
1638
|
+
...opts.meta === void 0 ? {} : { meta: toStoredJson(opts.meta) }
|
|
1639
|
+
};
|
|
1640
|
+
return this.mutateQueued({
|
|
1641
|
+
kind: "submitScore",
|
|
1642
|
+
key: boardKey,
|
|
1643
|
+
score,
|
|
1644
|
+
opts: queued
|
|
1645
|
+
}, () => ({
|
|
1646
|
+
...this.store.submitScore(boardKey, score, queued),
|
|
1647
|
+
rank: 1
|
|
1648
|
+
}));
|
|
1649
|
+
}
|
|
1650
|
+
/** One board's ranked entries (top `limit`, default 50). Offline: only the player's own best. */
|
|
1651
|
+
getLeaderboard(boardKey, limit = 50) {
|
|
1652
|
+
return this.withFallback(() => this.rest.call("getLeaderboard", {
|
|
1653
|
+
...this.gameFields(),
|
|
1654
|
+
key: boardKey,
|
|
1655
|
+
limit
|
|
1656
|
+
}), () => {
|
|
1657
|
+
const local = this.store.getBoard(boardKey);
|
|
1658
|
+
if (local === null) return {
|
|
1659
|
+
board: {
|
|
1660
|
+
key: boardKey,
|
|
1661
|
+
title: boardKey,
|
|
1662
|
+
sortDir: "desc"
|
|
1663
|
+
},
|
|
1664
|
+
entries: []
|
|
1665
|
+
};
|
|
1666
|
+
const user = this.store.getUser();
|
|
1667
|
+
return {
|
|
1668
|
+
board: local.board,
|
|
1669
|
+
entries: [{
|
|
1670
|
+
rank: 1,
|
|
1671
|
+
userId: user?.id ?? 0,
|
|
1672
|
+
username: user?.username ?? "you",
|
|
1673
|
+
score: local.best,
|
|
1674
|
+
meta: null
|
|
1675
|
+
}]
|
|
1676
|
+
};
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
/** The boards this game owns (keys and titles, no entries). */
|
|
1680
|
+
listLeaderboards() {
|
|
1681
|
+
return this.withFallback(() => this.rest.call("listGameLeaderboards", this.gameFields()), () => ({ boards: this.store.listBoards() }));
|
|
1682
|
+
}
|
|
1683
|
+
};
|
|
1684
|
+
//#endregion
|
|
1685
|
+
//#region src/core/offline/favorites.ts
|
|
1686
|
+
/**
|
|
1687
|
+
* Starred games are site-wide, not per game, so they live under one global
|
|
1688
|
+
* key (`hub:favorites`, the v1 client's) rather than in a game's cache. The
|
|
1689
|
+
* list mirrors the hub's so the menu still shows it offline.
|
|
1690
|
+
*/
|
|
1691
|
+
/** The key of the mirrored favorites list. */
|
|
1692
|
+
const FAVORITES_KEY = "hub:favorites";
|
|
1693
|
+
/** The mirrored favorite slugs (empty when none or unreadable). */
|
|
1694
|
+
function readLocalFavorites() {
|
|
1695
|
+
const text = createPageStore().read(FAVORITES_KEY);
|
|
1696
|
+
return text === null ? [] : stringsIn(parseJsonText(text));
|
|
1697
|
+
}
|
|
1698
|
+
/** Mirror the hub's favorites list. */
|
|
1699
|
+
function writeLocalFavorites(games) {
|
|
1700
|
+
createPageStore().write(FAVORITES_KEY, JSON.stringify(games));
|
|
1701
|
+
}
|
|
1702
|
+
//#endregion
|
|
1703
|
+
//#region src/core/hub-economy.ts
|
|
1704
|
+
/**
|
|
1705
|
+
* Analytics, the catalog, favorites, records, inventory and trades. Analytics
|
|
1706
|
+
* calls are fire-and-forget and never throw (telemetry must not break a game);
|
|
1707
|
+
* offline they are buffered and sent when the hub is back. Method names are
|
|
1708
|
+
* the v1 client's, unchanged.
|
|
1709
|
+
*/
|
|
1710
|
+
/** Analytics, catalog, favorites, records, inventory and trades. */
|
|
1711
|
+
var HubEconomy = class extends HubProgress {
|
|
1712
|
+
/** Record a "play". Call once when the game starts. */
|
|
1713
|
+
recordPlay() {
|
|
1714
|
+
return this.event("play");
|
|
1715
|
+
}
|
|
1716
|
+
/** Record a "heartbeat". Call periodically while the game is active. */
|
|
1717
|
+
heartbeat() {
|
|
1718
|
+
return this.event("heartbeat");
|
|
1719
|
+
}
|
|
1720
|
+
/** Record a custom event. Never throws; offline, events are buffered and sent on reconnect. */
|
|
1721
|
+
async event(type) {
|
|
1722
|
+
if (this.slug === null) return;
|
|
1723
|
+
if (this.isOffline) {
|
|
1724
|
+
this.store.bufferEvent(type);
|
|
1725
|
+
this.notifyStatus();
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
try {
|
|
1729
|
+
await this.rest.call("recordGameEvent", {
|
|
1730
|
+
...this.gameFields(),
|
|
1731
|
+
type
|
|
1732
|
+
});
|
|
1733
|
+
} catch (failure) {
|
|
1734
|
+
if (failure instanceof HubError && failure.isOffline) {
|
|
1735
|
+
this.setReachable(false);
|
|
1736
|
+
this.store.bufferEvent(type);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
/** This game's play counts. */
|
|
1741
|
+
analytics() {
|
|
1742
|
+
return this.withFallback(() => this.rest.call("getGameAnalytics", this.gameFields()), () => ({
|
|
1743
|
+
slug: this.slug ?? "app",
|
|
1744
|
+
totalPlays: 0,
|
|
1745
|
+
uniquePlayers: 0,
|
|
1746
|
+
activeNow: 0
|
|
1747
|
+
}));
|
|
1748
|
+
}
|
|
1749
|
+
/** Catalog-wide play counts with a per-game breakdown. */
|
|
1750
|
+
globalAnalytics() {
|
|
1751
|
+
return this.withFallback(() => this.rest.call("getSiteAnalytics"), () => ({
|
|
1752
|
+
global: {
|
|
1753
|
+
totalPlays: 0,
|
|
1754
|
+
uniquePlayers: 0,
|
|
1755
|
+
activeNow: 0
|
|
1756
|
+
},
|
|
1757
|
+
games: []
|
|
1758
|
+
}));
|
|
1759
|
+
}
|
|
1760
|
+
/** The listed games, for "jump to another game" navigation. */
|
|
1761
|
+
listGames() {
|
|
1762
|
+
return this.withFallback(() => this.rest.call("listGames"), () => ({ games: [] }));
|
|
1763
|
+
}
|
|
1764
|
+
/** Every board of every game, each with its top `top` entries (default 5). */
|
|
1765
|
+
allLeaderboards(top = 5) {
|
|
1766
|
+
return this.withFallback(() => this.rest.call("listTopOfEveryLeaderboard", { top }), () => ({ games: [] }));
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* The starred games (slugs), shown first in the menu's game list. Kept per
|
|
1770
|
+
* account by the hub and mirrored to `localStorage`, so it shows offline.
|
|
1771
|
+
*/
|
|
1772
|
+
favorites() {
|
|
1773
|
+
return this.withFallback(async () => {
|
|
1774
|
+
const reply = await this.rest.call("listFavoriteGames");
|
|
1775
|
+
writeLocalFavorites(reply.games);
|
|
1776
|
+
return reply;
|
|
1777
|
+
}, () => ({ games: readLocalFavorites() }));
|
|
1778
|
+
}
|
|
1779
|
+
/** Star (`isOn = true`) or unstar a game. Needs a connection. */
|
|
1780
|
+
async setFavorite(slug, isOn) {
|
|
1781
|
+
const reply = await this.rest.call("setGameFavorite", {
|
|
1782
|
+
slug,
|
|
1783
|
+
on: isOn
|
|
1784
|
+
});
|
|
1785
|
+
writeLocalFavorites(reply.games);
|
|
1786
|
+
return reply;
|
|
1787
|
+
}
|
|
1788
|
+
/** The player's leaderboard standings, grouped by game (every game, or one via `forSlug`). Empty offline or signed out. */
|
|
1789
|
+
myRecords(forSlug) {
|
|
1790
|
+
return this.withFallback(() => this.rest.call("listMyLeaderboardRecords", forSlug === void 0 || forSlug === "" ? {} : { game: forSlug }), () => ({ games: [] }));
|
|
1791
|
+
}
|
|
1792
|
+
/** This game's inventory and coin balance for the current user. */
|
|
1793
|
+
getInventory() {
|
|
1794
|
+
return this.withFallback(() => this.rest.call("getMyInventory", this.gameFields()), () => ({
|
|
1795
|
+
items: [],
|
|
1796
|
+
coins: 0
|
|
1797
|
+
}));
|
|
1798
|
+
}
|
|
1799
|
+
/** Another player's available holdings (to compose a trade). */
|
|
1800
|
+
playerInventory(userId) {
|
|
1801
|
+
return this.withFallback(() => this.rest.call("getPlayerInventory", {
|
|
1802
|
+
...this.gameFields(),
|
|
1803
|
+
userId
|
|
1804
|
+
}), () => ({
|
|
1805
|
+
items: [],
|
|
1806
|
+
coins: 0
|
|
1807
|
+
}));
|
|
1808
|
+
}
|
|
1809
|
+
/** Add goods to the player (gameplay rewards); answers the new holdings. */
|
|
1810
|
+
grant(bundle) {
|
|
1811
|
+
return this.rest.call("grantInventoryItems", {
|
|
1812
|
+
...this.gameFields(),
|
|
1813
|
+
...bundle
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
/** An atomic swap with the house: remove `take`, add `give`. */
|
|
1817
|
+
exchange(take, give) {
|
|
1818
|
+
return this.rest.call("exchangeInventoryItems", {
|
|
1819
|
+
...this.gameFields(),
|
|
1820
|
+
...take === void 0 ? {} : { take },
|
|
1821
|
+
...give === void 0 ? {} : { give }
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
/** Sell items to the house for coins. */
|
|
1825
|
+
sell(items, coins) {
|
|
1826
|
+
return this.exchange({ items }, { coins });
|
|
1827
|
+
}
|
|
1828
|
+
/** Buy items from the house with coins. */
|
|
1829
|
+
buy(coins, items) {
|
|
1830
|
+
return this.exchange({ coins }, { items });
|
|
1831
|
+
}
|
|
1832
|
+
/** Wipe the player's progress in this game (saves, stats, inventory and coins). */
|
|
1833
|
+
resetGame() {
|
|
1834
|
+
return this.rest.call("resetMyGameProgress", this.gameFields());
|
|
1835
|
+
}
|
|
1836
|
+
/** Players this player can trade with in this game. */
|
|
1837
|
+
listPlayers() {
|
|
1838
|
+
return this.withFallback(() => this.rest.call("listTradePartners", this.gameFields()), () => ({ players: [] }));
|
|
1839
|
+
}
|
|
1840
|
+
/** The player's trades (`box`: `incoming` or `outgoing`; `status` filters). */
|
|
1841
|
+
listTrades(opts = {}) {
|
|
1842
|
+
return this.withFallback(() => this.rest.call("listMyTrades", {
|
|
1843
|
+
...this.gameFields(),
|
|
1844
|
+
...opts
|
|
1845
|
+
}), () => ({ offers: [] }));
|
|
1846
|
+
}
|
|
1847
|
+
/** Offer a trade to another player (by id or name). */
|
|
1848
|
+
createTrade(toUser, give, want, opts = {}) {
|
|
1849
|
+
return this.rest.call("createTrade", {
|
|
1850
|
+
...this.gameFields(),
|
|
1851
|
+
toUser: String(toUser),
|
|
1852
|
+
...give === void 0 ? {} : { give },
|
|
1853
|
+
...want === void 0 ? {} : { want },
|
|
1854
|
+
...opts
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
/** Accept a trade offered to the player. */
|
|
1858
|
+
acceptTrade(id) {
|
|
1859
|
+
return this.rest.call("acceptTrade", {
|
|
1860
|
+
...this.gameFields(),
|
|
1861
|
+
id: Number(id)
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
/** Decline a trade offered to the player. */
|
|
1865
|
+
declineTrade(id) {
|
|
1866
|
+
return this.rest.call("declineTrade", {
|
|
1867
|
+
...this.gameFields(),
|
|
1868
|
+
id: Number(id)
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1871
|
+
/** Cancel a trade the player offered. */
|
|
1872
|
+
cancelTrade(id) {
|
|
1873
|
+
return this.rest.call("cancelTrade", {
|
|
1874
|
+
...this.gameFields(),
|
|
1875
|
+
id: Number(id)
|
|
1876
|
+
});
|
|
1877
|
+
}
|
|
1878
|
+
/** Answer a trade with a counter-offer. */
|
|
1879
|
+
counterTrade(id, give, want, opts = {}) {
|
|
1880
|
+
return this.rest.call("counterTradeOffer", {
|
|
1881
|
+
...this.gameFields(),
|
|
1882
|
+
id: Number(id),
|
|
1883
|
+
...give === void 0 ? {} : { give },
|
|
1884
|
+
...want === void 0 ? {} : { want },
|
|
1885
|
+
...opts
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
};
|
|
1889
|
+
//#endregion
|
|
1890
|
+
//#region src/core/menu-control.ts
|
|
1891
|
+
/**
|
|
1892
|
+
* `hub.menu`: show or hide the injected hub menu button. The menu
|
|
1893
|
+
* (`/_hub/menu.js`) may load before or after the game, and the game may
|
|
1894
|
+
* bundle its own client copy, so the shared state is a window global and a
|
|
1895
|
+
* window event rather than an object:
|
|
1896
|
+
*
|
|
1897
|
+
* - `window.__HUB_MENU_VISIBLE__` (boolean; absent means visible);
|
|
1898
|
+
* - `hub:menu-visibility` (`CustomEvent`, detail `{ visible }`).
|
|
1899
|
+
*
|
|
1900
|
+
* The menu reads the global on boot and listens for the event.
|
|
1901
|
+
*/
|
|
1902
|
+
/** The page's menu control (one per page; every client copy shares the window state). */
|
|
1903
|
+
const hubMenu = {
|
|
1904
|
+
setVisible(isVisible) {
|
|
1905
|
+
if (!isBrowserPage()) return;
|
|
1906
|
+
const wasVisible = window.__HUB_MENU_VISIBLE__;
|
|
1907
|
+
if (wasVisible === isVisible || wasVisible === void 0 && isVisible) return;
|
|
1908
|
+
window.__HUB_MENU_VISIBLE__ = isVisible;
|
|
1909
|
+
emitWindowEvent("hub:menu-visibility", { visible: isVisible });
|
|
1910
|
+
},
|
|
1911
|
+
show() {
|
|
1912
|
+
hubMenu.setVisible(true);
|
|
1913
|
+
},
|
|
1914
|
+
hide() {
|
|
1915
|
+
hubMenu.setVisible(false);
|
|
1916
|
+
},
|
|
1917
|
+
get visible() {
|
|
1918
|
+
return !isBrowserPage() || window.__HUB_MENU_VISIBLE__ !== false;
|
|
1919
|
+
}
|
|
1920
|
+
};
|
|
1921
|
+
//#endregion
|
|
1922
|
+
//#region src/core/ticket.ts
|
|
1923
|
+
/**
|
|
1924
|
+
* The own-server ticket request (`hub.mp.ticket()`): a 60-second identity
|
|
1925
|
+
* ticket for this game's own server, fetched straight from the hub. It must
|
|
1926
|
+
* not depend on the social runtime: on the first visit after a deploy the
|
|
1927
|
+
* service worker can still serve an older menu that never loads it. When the
|
|
1928
|
+
* page has no session yet, a guest one is created and the request retried
|
|
1929
|
+
* once.
|
|
1930
|
+
*/
|
|
1931
|
+
/** A REST client for the page's API base that uses the global fetch at call time. */
|
|
1932
|
+
function pageRestClient() {
|
|
1933
|
+
return createRestClient({
|
|
1934
|
+
apiBase: detectApiBase(),
|
|
1935
|
+
fetch: (input, init) => globalThis.fetch(input, init)
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
/** One ticket request; a transport failure is an {@link OfflineError}. */
|
|
1939
|
+
async function requestTicket(rest, slug) {
|
|
1940
|
+
try {
|
|
1941
|
+
return (await rest.call("getGameServerTicket", { slug })).ticket;
|
|
1942
|
+
} catch (failure) {
|
|
1943
|
+
throw failure instanceof HubError && failure.isOffline ? new OfflineError() : failure;
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
/** A ticket for this page's game; signs in a guest first when the page has no session. */
|
|
1947
|
+
async function fetchGameServerTicket() {
|
|
1948
|
+
const slug = detectSlug();
|
|
1949
|
+
if (slug === null) throw new HubError("Hub: could not determine the game slug.");
|
|
1950
|
+
const rest = pageRestClient();
|
|
1951
|
+
try {
|
|
1952
|
+
return await requestTicket(rest, slug);
|
|
1953
|
+
} catch (failure) {
|
|
1954
|
+
if (!(failure instanceof HubError) || failure.status !== 401) throw failure;
|
|
1955
|
+
await rest.call("ensureGuestSession");
|
|
1956
|
+
return requestTicket(rest, slug);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
//#endregion
|
|
1960
|
+
//#region src/core/version/semver.ts
|
|
1961
|
+
const VERSION_PATTERN = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
1962
|
+
/** Parse a version (`2`, `2.1`, `2.1.0`, `2.1.0-next.3`); `undefined` when it is not one. */
|
|
1963
|
+
function parseVersion(text) {
|
|
1964
|
+
const match = VERSION_PATTERN.exec(text.trim());
|
|
1965
|
+
if (match === null) return;
|
|
1966
|
+
const [, major = "0", minor = "0", patch = "0", prerelease] = match;
|
|
1967
|
+
return {
|
|
1968
|
+
release: [
|
|
1969
|
+
Number(major),
|
|
1970
|
+
Number(minor),
|
|
1971
|
+
Number(patch)
|
|
1972
|
+
],
|
|
1973
|
+
prerelease: prerelease === void 0 ? [] : prerelease.split(".")
|
|
1974
|
+
};
|
|
1975
|
+
}
|
|
1976
|
+
/** Compare two prerelease identifiers: numbers by value and before words, words as text. */
|
|
1977
|
+
function compareIdentifiers(first, second) {
|
|
1978
|
+
const isFirstNumeric = /^\d+$/.test(first);
|
|
1979
|
+
const isSecondNumeric = /^\d+$/.test(second);
|
|
1980
|
+
if (isFirstNumeric && isSecondNumeric) return Number(first) - Number(second);
|
|
1981
|
+
if (isFirstNumeric !== isSecondNumeric) return isFirstNumeric ? -1 : 1;
|
|
1982
|
+
return first < second ? -1 : first > second ? 1 : 0;
|
|
1983
|
+
}
|
|
1984
|
+
/** Negative when `first` is older, positive when newer, 0 when equal. */
|
|
1985
|
+
function compareVersions(first, second) {
|
|
1986
|
+
for (let i = 0; i < 3; i += 1) {
|
|
1987
|
+
const difference = (first.release[i] ?? 0) - (second.release[i] ?? 0);
|
|
1988
|
+
if (difference !== 0) return difference;
|
|
1989
|
+
}
|
|
1990
|
+
if (first.prerelease.length === 0 || second.prerelease.length === 0) return second.prerelease.length - first.prerelease.length;
|
|
1991
|
+
const length = Math.max(first.prerelease.length, second.prerelease.length);
|
|
1992
|
+
for (let i = 0; i < length; i += 1) {
|
|
1993
|
+
const firstIdentifier = first.prerelease[i];
|
|
1994
|
+
const secondIdentifier = second.prerelease[i];
|
|
1995
|
+
if (firstIdentifier === void 0 || secondIdentifier === void 0) return firstIdentifier === void 0 ? -1 : 1;
|
|
1996
|
+
const difference = compareIdentifiers(firstIdentifier, secondIdentifier);
|
|
1997
|
+
if (difference !== 0) return difference;
|
|
1998
|
+
}
|
|
1999
|
+
return 0;
|
|
2000
|
+
}
|
|
2001
|
+
const COMPARATOR_PATTERN = /^(>=|<=|>|<|=)?\s*(.+)$/;
|
|
2002
|
+
/** Whether `version` meets one comparator (`>=2.0.0`); a malformed comparator never holds. */
|
|
2003
|
+
function meetsComparator(version, comparator) {
|
|
2004
|
+
const match = COMPARATOR_PATTERN.exec(comparator);
|
|
2005
|
+
const bound = match?.[2] === void 0 ? void 0 : parseVersion(match[2]);
|
|
2006
|
+
if (match === null || bound === void 0) return false;
|
|
2007
|
+
const order = compareVersions(version, bound);
|
|
2008
|
+
switch (match[1]) {
|
|
2009
|
+
case ">=": return order >= 0;
|
|
2010
|
+
case "<=": return order <= 0;
|
|
2011
|
+
case ">": return order > 0;
|
|
2012
|
+
case "<": return order < 0;
|
|
2013
|
+
default: return order === 0;
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
/** Whether `versionText` is in `range`; a malformed version or empty range never is. */
|
|
2017
|
+
function satisfiesRange(versionText, range) {
|
|
2018
|
+
const version = parseVersion(versionText);
|
|
2019
|
+
if (version === void 0) return false;
|
|
2020
|
+
return range.split("||").some((alternative) => {
|
|
2021
|
+
const comparators = alternative.trim().split(/\s+/).filter((comparator) => comparator !== "");
|
|
2022
|
+
return comparators.length > 0 && comparators.every((comparator) => meetsComparator(version, comparator));
|
|
2023
|
+
});
|
|
2024
|
+
}
|
|
2025
|
+
//#endregion
|
|
2026
|
+
//#region src/core/version/hub-version.ts
|
|
2027
|
+
/** How `version` fares against a hub's answer (`null`: a hub from before versioning). */
|
|
2028
|
+
function clientSupport(hubVersion, version = HUB_CLIENT_VERSION) {
|
|
2029
|
+
if (hubVersion === null) return "unknown";
|
|
2030
|
+
if (!satisfiesRange(version, hubVersion.supported)) return "unsupported";
|
|
2031
|
+
return satisfiesRange(version, hubVersion.deprecated) ? "deprecated" : "supported";
|
|
2032
|
+
}
|
|
2033
|
+
const versionsByApiBase = /* @__PURE__ */ new Map();
|
|
2034
|
+
/**
|
|
2035
|
+
* The hub's version, fetched once per page and API base. Resolves `null` for
|
|
2036
|
+
* a hub from before versioning (it has no `/version` route); a failed
|
|
2037
|
+
* request is not cached, so a later call tries again.
|
|
2038
|
+
*/
|
|
2039
|
+
function readHubVersion(rest) {
|
|
2040
|
+
const cached = versionsByApiBase.get(rest.apiBase);
|
|
2041
|
+
if (cached !== void 0) return cached;
|
|
2042
|
+
const pending = fetchHubVersion(rest);
|
|
2043
|
+
versionsByApiBase.set(rest.apiBase, pending);
|
|
2044
|
+
return pending;
|
|
2045
|
+
}
|
|
2046
|
+
/** One `GET /version`; `null` when the hub has no such route. */
|
|
2047
|
+
async function fetchHubVersion(rest) {
|
|
2048
|
+
try {
|
|
2049
|
+
return await rest.call("getHubVersion");
|
|
2050
|
+
} catch (failure) {
|
|
2051
|
+
if (failure instanceof HubError && failure.code === "unsupported_by_hub") return null;
|
|
2052
|
+
versionsByApiBase.delete(rest.apiBase);
|
|
2053
|
+
throw failure;
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
//#endregion
|
|
2057
|
+
//#region src/core/hub.ts
|
|
2058
|
+
/** The hub client. Use the ready-made {@link hub}, or `createHub()` for another slug or API base. */
|
|
2059
|
+
var Hub = class extends HubEconomy {
|
|
2060
|
+
constructor(..._args) {
|
|
2061
|
+
super(..._args);
|
|
2062
|
+
_defineProperty(this, "clientVersion", HUB_CLIENT_VERSION);
|
|
2063
|
+
}
|
|
2064
|
+
/**
|
|
2065
|
+
* The hub's version and the client versions it supports (`GET /_api/version`),
|
|
2066
|
+
* read once per page. `null` for a hub from before versioning.
|
|
2067
|
+
*/
|
|
2068
|
+
hubVersion() {
|
|
2069
|
+
return readHubVersion(this.rest);
|
|
2070
|
+
}
|
|
2071
|
+
/** How the hub regards this client: `supported`, `deprecated`, `unsupported`, or `unknown` (an older hub). */
|
|
2072
|
+
async clientSupport() {
|
|
2073
|
+
return clientSupport(await this.hubVersion());
|
|
2074
|
+
}
|
|
2075
|
+
/**
|
|
2076
|
+
* The input system (keyboard, touch and gamepad): `hub.input.define({ ... })`,
|
|
2077
|
+
* `hub.input.value('jump')`. Page-wide; remaps are saved locally and to the
|
|
2078
|
+
* reserved `__controls` save slot.
|
|
2079
|
+
*/
|
|
2080
|
+
get input() {
|
|
2081
|
+
return getInput({
|
|
2082
|
+
slug: this.slug,
|
|
2083
|
+
saveToCloud: (overrides) => {
|
|
2084
|
+
this.putSave("__controls", overrides).catch(() => void 0);
|
|
2085
|
+
}
|
|
2086
|
+
});
|
|
2087
|
+
}
|
|
2088
|
+
/** Hub UI covering the game (menu, dialogs, chat): open/close events and `autoPause`. Page-wide. */
|
|
2089
|
+
get overlay() {
|
|
2090
|
+
return getUiStack();
|
|
2091
|
+
}
|
|
2092
|
+
/** Show or hide the injected hub menu button (page-wide). */
|
|
2093
|
+
get menu() {
|
|
2094
|
+
return hubMenu;
|
|
2095
|
+
}
|
|
2096
|
+
/**
|
|
2097
|
+
* Daily challenges: call `hub.daily.define({ play })` once at load and
|
|
2098
|
+
* `hub.daily.complete({ ... })` on finish. Page-wide, shared with the
|
|
2099
|
+
* injected menu, which can start a clicked day on the same instance.
|
|
2100
|
+
*/
|
|
2101
|
+
get daily() {
|
|
2102
|
+
return getDaily(this.dailyHost());
|
|
2103
|
+
}
|
|
2104
|
+
/** Tell friends what the player is doing in this game. */
|
|
2105
|
+
get presence() {
|
|
2106
|
+
return presenceForwarder;
|
|
2107
|
+
}
|
|
2108
|
+
/** Friends, players and the hub's social dialogs. */
|
|
2109
|
+
get social() {
|
|
2110
|
+
return socialForwarder;
|
|
2111
|
+
}
|
|
2112
|
+
/** Invites, launches, the leave guard and own-server tickets. */
|
|
2113
|
+
get mp() {
|
|
2114
|
+
return multiplayerForwarder;
|
|
2115
|
+
}
|
|
2116
|
+
/** Hub relay rooms, for games without a server. */
|
|
2117
|
+
get rooms() {
|
|
2118
|
+
return roomsForwarder;
|
|
2119
|
+
}
|
|
2120
|
+
/** Toast behaviour during intense play. */
|
|
2121
|
+
get notify() {
|
|
2122
|
+
return notifyForwarder;
|
|
2123
|
+
}
|
|
2124
|
+
/** Races: a single-player game becomes multiplayer by racing. */
|
|
2125
|
+
get race() {
|
|
2126
|
+
return raceForwarder;
|
|
2127
|
+
}
|
|
2128
|
+
/** What the daily system needs from this client: its REST calls and window events. */
|
|
2129
|
+
dailyHost() {
|
|
2130
|
+
return {
|
|
2131
|
+
slug: this.slug,
|
|
2132
|
+
status: () => this.rest.call("getGameDailyChallenges", this.gameFields()),
|
|
2133
|
+
all: () => this.rest.call("listDailyChallengesForAllGames"),
|
|
2134
|
+
start: (day) => this.rest.call("startDailyChallenge", {
|
|
2135
|
+
...this.gameFields(),
|
|
2136
|
+
day
|
|
2137
|
+
}),
|
|
2138
|
+
complete: (day, token, meta) => this.rest.call("completeDailyChallenge", {
|
|
2139
|
+
...this.gameFields(),
|
|
2140
|
+
day,
|
|
2141
|
+
token,
|
|
2142
|
+
meta
|
|
2143
|
+
}),
|
|
2144
|
+
emit: (eventName, detail) => {
|
|
2145
|
+
emitWindowEvent(eventName, detail);
|
|
2146
|
+
}
|
|
2147
|
+
};
|
|
2148
|
+
}
|
|
2149
|
+
};
|
|
2150
|
+
linkRealtimeToHub({
|
|
2151
|
+
toHubError,
|
|
2152
|
+
createOfflineError: (message) => new OfflineError(message),
|
|
2153
|
+
getGameServerTicket: fetchGameServerTicket
|
|
2154
|
+
});
|
|
2155
|
+
/** A ready-to-use client, with the slug and API base detected from the page. */
|
|
2156
|
+
const hub = new Hub();
|
|
2157
|
+
/** Create a client for another slug or API base, or with other offline behaviour. */
|
|
2158
|
+
function createHub(options = {}) {
|
|
2159
|
+
return new Hub(options);
|
|
2160
|
+
}
|
|
2161
|
+
//#endregion
|
|
2162
|
+
export { ContentRejectedError as a, UNSUPPORTED_BY_HUB as c, CLIENT_VERSION_HEADER as i, createHub as n, HubError as o, hub as r, OfflineError as s, Hub as t };
|
|
2163
|
+
|
|
2164
|
+
//# sourceMappingURL=src.js.map
|