@diffenderfer-games/hub 2.0.0-next.0 → 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 -21
- package/README.md +191 -139
- package/dist/doctor/cli.mjs +49 -19
- package/dist/doctor/cli.mjs.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.js +1 -1
- package/dist/shared/globals.js +8 -3
- package/dist/shared/globals.js.map +1 -1
- package/dist/shared/index.d.ts +1 -1
- package/package.json +89 -89
- package/src/auto.ts +39 -39
- package/src/core/connection.ts +299 -299
- package/src/core/errors.ts +147 -147
- package/src/core/game-data.ts +38 -38
- package/src/core/hub-economy.ts +179 -179
- package/src/core/hub-progress.ts +205 -205
- package/src/core/hub.ts +131 -131
- package/src/core/menu-control.ts +61 -61
- package/src/core/offline/events.ts +50 -50
- package/src/core/offline/favorites.ts +21 -21
- package/src/core/offline/local-store.ts +261 -261
- package/src/core/offline/queue.ts +171 -171
- package/src/core/page.ts +74 -74
- package/src/core/rest/endpoint-types.ts +22 -22
- package/src/core/rest/rest-client.ts +152 -152
- package/src/core/storage/json-guards.ts +74 -74
- package/src/core/storage/key-value.ts +66 -66
- package/src/core/ticket.ts +43 -43
- package/src/core/types.ts +134 -134
- package/src/core/version/client-version.gen.ts +1 -1
- package/src/core/version/hub-version.ts +62 -62
- package/src/core/version/semver.ts +97 -97
- package/src/daily/daily-system.ts +161 -161
- package/src/daily/page-daily.ts +36 -36
- package/src/daily/types.ts +67 -67
- package/src/daily.ts +10 -10
- package/src/doctor/checks.ts +78 -76
- package/src/doctor/cli.ts +98 -106
- package/src/doctor/hub-version-fetch.ts +43 -0
- package/src/doctor/tsconfig.json +12 -12
- package/src/globals.ts +115 -110
- package/src/index.ts +50 -50
- package/src/input/device-state.ts +146 -146
- package/src/input/edges.ts +55 -55
- package/src/input/gamepad.ts +61 -61
- package/src/input/input-events.ts +32 -32
- package/src/input/input-registry.ts +164 -164
- package/src/input/input-system.ts +393 -393
- package/src/input/legacy-controls.ts +64 -64
- package/src/input/nav-state.ts +191 -191
- package/src/input/navigator.ts +171 -171
- package/src/input/on-screen-controls.ts +101 -101
- package/src/input/page-control.ts +43 -43
- package/src/input/page-input.ts +193 -193
- package/src/input/player-remaps.ts +77 -77
- package/src/input/source-values.ts +116 -116
- package/src/input/spatial.ts +47 -47
- package/src/input/types.ts +331 -331
- package/src/input/zero-config.ts +177 -177
- package/src/input.ts +22 -22
- package/src/mp.ts +10 -10
- package/src/overlay/controls-panel.ts +163 -163
- package/src/overlay/dom.ts +46 -46
- package/src/overlay/layout-clusters.ts +162 -162
- package/src/overlay/layout-geometry.ts +136 -136
- package/src/overlay/layout.ts +203 -203
- package/src/overlay/listener-scope.ts +23 -23
- package/src/overlay/on-screen-keyboard.ts +137 -137
- package/src/overlay/overlay.ts +208 -208
- package/src/overlay/styles.ts +9 -9
- package/src/overlay/virtual-controls.ts +262 -262
- package/src/race.ts +10 -10
- package/src/realtime/bridge.ts +121 -121
- package/src/realtime/cross-bundle.ts +29 -29
- package/src/realtime/fallback-notice.ts +48 -48
- package/src/realtime/hub-link.ts +65 -65
- package/src/realtime/latest-state.ts +33 -33
- package/src/realtime/multiplayer-forwarder.ts +28 -28
- package/src/realtime/multiplayer-types.ts +52 -52
- package/src/realtime/page-runtime.ts +60 -60
- package/src/realtime/race-forwarder.ts +59 -59
- package/src/realtime/race-types.ts +132 -132
- package/src/realtime/room-types.ts +125 -125
- package/src/realtime/rooms-forwarder.ts +79 -79
- package/src/realtime/runtime-api.ts +49 -49
- package/src/realtime/runtime-opener.ts +61 -61
- package/src/realtime/social-forwarder.ts +72 -72
- package/src/realtime/social-types.ts +78 -78
- package/src/realtime/types.ts +53 -53
- package/src/realtime/wire-types.ts +72 -72
- package/src/rooms.ts +10 -10
- package/src/social.ts +18 -18
- package/src/types.ts +13 -13
- package/src/uistack/page-stack.ts +45 -45
- package/src/uistack/play-suspension.ts +82 -82
- package/src/uistack/types.ts +76 -76
- package/src/uistack/ui-stack.ts +144 -144
|
@@ -1,171 +1,171 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The offline mutation queue. Writes apply to the local cache at once and
|
|
3
|
-
* queue a durable op; when the hub is reachable again the queue replays in
|
|
4
|
-
* order. Ops collapse as they are queued (a save's last write wins, `incr`s
|
|
5
|
-
* add up, `max`es keep the largest, a board keeps its best, profile patches
|
|
6
|
-
* merge), so the backlog stays small.
|
|
7
|
-
*
|
|
8
|
-
* The op shapes are the v1 client's, unchanged: a page upgraded from a
|
|
9
|
-
* vendored copy replays the queue that copy left in `localStorage`.
|
|
10
|
-
*/
|
|
11
|
-
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
12
|
-
import type { SortDir } from '@diffenderfer-games/hub-contract';
|
|
13
|
-
import { isJson, isNullableString, isRecord, optionalNumber, optionalString, type UncheckedRecord } from '../storage/json-guards';
|
|
14
|
-
|
|
15
|
-
/** A score submission's options as queued (the metadata already JSON). */
|
|
16
|
-
export interface QueuedScoreOptions {
|
|
17
|
-
/** The board's title; only used when the board key is first created. */
|
|
18
|
-
readonly title?: string;
|
|
19
|
-
/** The sort direction; only used on board creation. Default `desc`. */
|
|
20
|
-
readonly sort?: SortDir;
|
|
21
|
-
/** Any JSON stored with the entry (a character, a run seed). */
|
|
22
|
-
readonly meta?: Json;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** A profile change: either part may be left out. */
|
|
26
|
-
export interface ProfilePatch {
|
|
27
|
-
/** Shown to other players; `null` clears it. */
|
|
28
|
-
displayName?: string | null;
|
|
29
|
-
/** The game's own profile data. */
|
|
30
|
-
data?: Json;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** A queued op's id and when it was queued. */
|
|
34
|
-
interface QueueStamp {
|
|
35
|
-
/** Unique within this page: `<epoch ms>-<sequence>`. */
|
|
36
|
-
readonly id: string;
|
|
37
|
-
/** When it was queued (epoch ms). */
|
|
38
|
-
readonly ts: number;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** A queued write, before it is stamped. */
|
|
42
|
-
export type QueueOpInput =
|
|
43
|
-
| { readonly kind: 'putSave'; readonly slot: string; readonly data: Json; readonly label: string | null }
|
|
44
|
-
| { readonly kind: 'deleteSave'; readonly slot: string }
|
|
45
|
-
| { readonly kind: 'setStat'; readonly key: string; readonly value: number }
|
|
46
|
-
| { readonly kind: 'maxStat'; readonly key: string; readonly value: number }
|
|
47
|
-
| { readonly kind: 'incrStat'; readonly key: string; readonly amount: number }
|
|
48
|
-
| { readonly kind: 'submitScore'; readonly key: string; readonly score: number; readonly opts: QueuedScoreOptions }
|
|
49
|
-
| { readonly kind: 'setProfile'; readonly patch: ProfilePatch };
|
|
50
|
-
|
|
51
|
-
/** A queued write, as stored under `hub:<slug>:queue`. */
|
|
52
|
-
export type QueueOp = QueueOpInput & QueueStamp;
|
|
53
|
-
|
|
54
|
-
/** The score options in a stored op, or `undefined` when they are malformed. */
|
|
55
|
-
function readScoreOptions(stored: UncheckedRecord): QueuedScoreOptions | undefined {
|
|
56
|
-
const title = optionalString(stored.title);
|
|
57
|
-
const sort = stored.sort === 'asc' || stored.sort === 'desc' ? stored.sort : undefined;
|
|
58
|
-
if (stored.meta !== undefined && !isJson(stored.meta)) {
|
|
59
|
-
return undefined;
|
|
60
|
-
}
|
|
61
|
-
return { ...(title === undefined ? {} : { title }), ...(sort === undefined ? {} : { sort }), ...(stored.meta === undefined ? {} : { meta: stored.meta }) };
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** The profile patch in a stored op, or `undefined` when it is malformed. */
|
|
65
|
-
function readProfilePatch(stored: UncheckedRecord): ProfilePatch | undefined {
|
|
66
|
-
if ((stored.displayName !== undefined && !isNullableString(stored.displayName)) || (stored.data !== undefined && !isJson(stored.data))) {
|
|
67
|
-
return undefined;
|
|
68
|
-
}
|
|
69
|
-
const patch: ProfilePatch = {};
|
|
70
|
-
if (stored.displayName !== undefined) {
|
|
71
|
-
patch.displayName = stored.displayName;
|
|
72
|
-
}
|
|
73
|
-
if (stored.data !== undefined) {
|
|
74
|
-
patch.data = stored.data;
|
|
75
|
-
}
|
|
76
|
-
return patch;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** One stored op's write, or `undefined` when it is not a known, well-formed op. */
|
|
80
|
-
function readOpInput(stored: UncheckedRecord): QueueOpInput | undefined {
|
|
81
|
-
const slot = optionalString(stored.slot);
|
|
82
|
-
const key = optionalString(stored.key);
|
|
83
|
-
const statValue = optionalNumber(stored.value);
|
|
84
|
-
switch (stored.kind) {
|
|
85
|
-
case 'putSave':
|
|
86
|
-
return slot !== undefined && isJson(stored.data) && isNullableString(stored.label) ? { kind: 'putSave', slot, data: stored.data, label: stored.label } : undefined;
|
|
87
|
-
case 'deleteSave':
|
|
88
|
-
return slot === undefined ? undefined : { kind: 'deleteSave', slot };
|
|
89
|
-
case 'setStat':
|
|
90
|
-
case 'maxStat':
|
|
91
|
-
return key !== undefined && statValue !== undefined ? { kind: stored.kind, key, value: statValue } : undefined;
|
|
92
|
-
case 'incrStat': {
|
|
93
|
-
const amount = optionalNumber(stored.amount);
|
|
94
|
-
return key !== undefined && amount !== undefined ? { kind: 'incrStat', key, amount } : undefined;
|
|
95
|
-
}
|
|
96
|
-
case 'submitScore': {
|
|
97
|
-
const score = optionalNumber(stored.score);
|
|
98
|
-
const opts = isRecord(stored.opts) ? readScoreOptions(stored.opts) : undefined;
|
|
99
|
-
return key !== undefined && score !== undefined && opts !== undefined ? { kind: 'submitScore', key, score, opts } : undefined;
|
|
100
|
-
}
|
|
101
|
-
case 'setProfile': {
|
|
102
|
-
const patch = isRecord(stored.patch) ? readProfilePatch(stored.patch) : undefined;
|
|
103
|
-
return patch === undefined ? undefined : { kind: 'setProfile', patch };
|
|
104
|
-
}
|
|
105
|
-
default:
|
|
106
|
-
return undefined;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** The well-formed ops of a stored queue, in order; anything malformed is dropped. */
|
|
111
|
-
// eslint-disable-next-line no-restricted-syntax -- trust boundary: a queue read back from localStorage (possibly written by a v1 client)
|
|
112
|
-
export function readQueue(stored: unknown): QueueOp[] {
|
|
113
|
-
if (!Array.isArray(stored)) {
|
|
114
|
-
return [];
|
|
115
|
-
}
|
|
116
|
-
return stored.flatMap((entry): QueueOp[] => {
|
|
117
|
-
if (!isRecord(entry)) {
|
|
118
|
-
return [];
|
|
119
|
-
}
|
|
120
|
-
const input = readOpInput(entry);
|
|
121
|
-
const id = optionalString(entry.id);
|
|
122
|
-
const ts = optionalNumber(entry.ts);
|
|
123
|
-
return input === undefined || id === undefined || ts === undefined ? [] : [{ ...input, id, ts }];
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** The better of two scores on a board sorted `sort` (`desc` by default). */
|
|
128
|
-
export function betterScore(first: number, second: number, sort: SortDir | undefined): number {
|
|
129
|
-
return sort === 'asc' ? Math.min(first, second) : Math.max(first, second);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** The op that `queued` becomes when `incoming` collapses into it, or `undefined` when they don't collapse. */
|
|
133
|
-
function collapsedOp(queued: QueueOp, incoming: QueueOp): QueueOp | undefined {
|
|
134
|
-
if (queued.kind === 'incrStat' && incoming.kind === 'incrStat' && queued.key === incoming.key) {
|
|
135
|
-
return { ...queued, amount: queued.amount + incoming.amount };
|
|
136
|
-
}
|
|
137
|
-
if (queued.kind === 'maxStat' && incoming.kind === 'maxStat' && queued.key === incoming.key) {
|
|
138
|
-
return { ...queued, value: Math.max(queued.value, incoming.value) };
|
|
139
|
-
}
|
|
140
|
-
if (queued.kind === 'setStat' && incoming.kind === 'setStat' && queued.key === incoming.key) {
|
|
141
|
-
return { ...queued, value: incoming.value };
|
|
142
|
-
}
|
|
143
|
-
if (queued.kind === 'submitScore' && incoming.kind === 'submitScore' && queued.key === incoming.key) {
|
|
144
|
-
return { ...queued, score: betterScore(queued.score, incoming.score, incoming.opts.sort), opts: incoming.opts };
|
|
145
|
-
}
|
|
146
|
-
if (queued.kind === 'setProfile' && incoming.kind === 'setProfile') {
|
|
147
|
-
return { ...queued, patch: { ...queued.patch, ...incoming.patch } };
|
|
148
|
-
}
|
|
149
|
-
return undefined;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Whether an op writes save slot `slot`. */
|
|
153
|
-
function writesSlot(op: QueueOp, slot: string): boolean {
|
|
154
|
-
return (op.kind === 'putSave' || op.kind === 'deleteSave') && op.slot === slot;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* The queue with `incoming` added: a save or delete replaces any queued
|
|
159
|
-
* write to the same slot (and goes last); a compatible op merges into the
|
|
160
|
-
* first queued one in place; anything else is appended.
|
|
161
|
-
*/
|
|
162
|
-
export function enqueueOp(queue: readonly QueueOp[], incoming: QueueOp): QueueOp[] {
|
|
163
|
-
if (incoming.kind === 'putSave' || incoming.kind === 'deleteSave') {
|
|
164
|
-
return [...queue.filter((op) => !writesSlot(op, incoming.slot)), incoming];
|
|
165
|
-
}
|
|
166
|
-
const mergeAt = queue.findIndex((op) => collapsedOp(op, incoming) !== undefined);
|
|
167
|
-
if (mergeAt < 0) {
|
|
168
|
-
return [...queue, incoming];
|
|
169
|
-
}
|
|
170
|
-
return queue.map((op, i) => (i === mergeAt ? (collapsedOp(op, incoming) ?? op) : op));
|
|
171
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The offline mutation queue. Writes apply to the local cache at once and
|
|
3
|
+
* queue a durable op; when the hub is reachable again the queue replays in
|
|
4
|
+
* order. Ops collapse as they are queued (a save's last write wins, `incr`s
|
|
5
|
+
* add up, `max`es keep the largest, a board keeps its best, profile patches
|
|
6
|
+
* merge), so the backlog stays small.
|
|
7
|
+
*
|
|
8
|
+
* The op shapes are the v1 client's, unchanged: a page upgraded from a
|
|
9
|
+
* vendored copy replays the queue that copy left in `localStorage`.
|
|
10
|
+
*/
|
|
11
|
+
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
12
|
+
import type { SortDir } from '@diffenderfer-games/hub-contract';
|
|
13
|
+
import { isJson, isNullableString, isRecord, optionalNumber, optionalString, type UncheckedRecord } from '../storage/json-guards';
|
|
14
|
+
|
|
15
|
+
/** A score submission's options as queued (the metadata already JSON). */
|
|
16
|
+
export interface QueuedScoreOptions {
|
|
17
|
+
/** The board's title; only used when the board key is first created. */
|
|
18
|
+
readonly title?: string;
|
|
19
|
+
/** The sort direction; only used on board creation. Default `desc`. */
|
|
20
|
+
readonly sort?: SortDir;
|
|
21
|
+
/** Any JSON stored with the entry (a character, a run seed). */
|
|
22
|
+
readonly meta?: Json;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A profile change: either part may be left out. */
|
|
26
|
+
export interface ProfilePatch {
|
|
27
|
+
/** Shown to other players; `null` clears it. */
|
|
28
|
+
displayName?: string | null;
|
|
29
|
+
/** The game's own profile data. */
|
|
30
|
+
data?: Json;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A queued op's id and when it was queued. */
|
|
34
|
+
interface QueueStamp {
|
|
35
|
+
/** Unique within this page: `<epoch ms>-<sequence>`. */
|
|
36
|
+
readonly id: string;
|
|
37
|
+
/** When it was queued (epoch ms). */
|
|
38
|
+
readonly ts: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A queued write, before it is stamped. */
|
|
42
|
+
export type QueueOpInput =
|
|
43
|
+
| { readonly kind: 'putSave'; readonly slot: string; readonly data: Json; readonly label: string | null }
|
|
44
|
+
| { readonly kind: 'deleteSave'; readonly slot: string }
|
|
45
|
+
| { readonly kind: 'setStat'; readonly key: string; readonly value: number }
|
|
46
|
+
| { readonly kind: 'maxStat'; readonly key: string; readonly value: number }
|
|
47
|
+
| { readonly kind: 'incrStat'; readonly key: string; readonly amount: number }
|
|
48
|
+
| { readonly kind: 'submitScore'; readonly key: string; readonly score: number; readonly opts: QueuedScoreOptions }
|
|
49
|
+
| { readonly kind: 'setProfile'; readonly patch: ProfilePatch };
|
|
50
|
+
|
|
51
|
+
/** A queued write, as stored under `hub:<slug>:queue`. */
|
|
52
|
+
export type QueueOp = QueueOpInput & QueueStamp;
|
|
53
|
+
|
|
54
|
+
/** The score options in a stored op, or `undefined` when they are malformed. */
|
|
55
|
+
function readScoreOptions(stored: UncheckedRecord): QueuedScoreOptions | undefined {
|
|
56
|
+
const title = optionalString(stored.title);
|
|
57
|
+
const sort = stored.sort === 'asc' || stored.sort === 'desc' ? stored.sort : undefined;
|
|
58
|
+
if (stored.meta !== undefined && !isJson(stored.meta)) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
return { ...(title === undefined ? {} : { title }), ...(sort === undefined ? {} : { sort }), ...(stored.meta === undefined ? {} : { meta: stored.meta }) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The profile patch in a stored op, or `undefined` when it is malformed. */
|
|
65
|
+
function readProfilePatch(stored: UncheckedRecord): ProfilePatch | undefined {
|
|
66
|
+
if ((stored.displayName !== undefined && !isNullableString(stored.displayName)) || (stored.data !== undefined && !isJson(stored.data))) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const patch: ProfilePatch = {};
|
|
70
|
+
if (stored.displayName !== undefined) {
|
|
71
|
+
patch.displayName = stored.displayName;
|
|
72
|
+
}
|
|
73
|
+
if (stored.data !== undefined) {
|
|
74
|
+
patch.data = stored.data;
|
|
75
|
+
}
|
|
76
|
+
return patch;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One stored op's write, or `undefined` when it is not a known, well-formed op. */
|
|
80
|
+
function readOpInput(stored: UncheckedRecord): QueueOpInput | undefined {
|
|
81
|
+
const slot = optionalString(stored.slot);
|
|
82
|
+
const key = optionalString(stored.key);
|
|
83
|
+
const statValue = optionalNumber(stored.value);
|
|
84
|
+
switch (stored.kind) {
|
|
85
|
+
case 'putSave':
|
|
86
|
+
return slot !== undefined && isJson(stored.data) && isNullableString(stored.label) ? { kind: 'putSave', slot, data: stored.data, label: stored.label } : undefined;
|
|
87
|
+
case 'deleteSave':
|
|
88
|
+
return slot === undefined ? undefined : { kind: 'deleteSave', slot };
|
|
89
|
+
case 'setStat':
|
|
90
|
+
case 'maxStat':
|
|
91
|
+
return key !== undefined && statValue !== undefined ? { kind: stored.kind, key, value: statValue } : undefined;
|
|
92
|
+
case 'incrStat': {
|
|
93
|
+
const amount = optionalNumber(stored.amount);
|
|
94
|
+
return key !== undefined && amount !== undefined ? { kind: 'incrStat', key, amount } : undefined;
|
|
95
|
+
}
|
|
96
|
+
case 'submitScore': {
|
|
97
|
+
const score = optionalNumber(stored.score);
|
|
98
|
+
const opts = isRecord(stored.opts) ? readScoreOptions(stored.opts) : undefined;
|
|
99
|
+
return key !== undefined && score !== undefined && opts !== undefined ? { kind: 'submitScore', key, score, opts } : undefined;
|
|
100
|
+
}
|
|
101
|
+
case 'setProfile': {
|
|
102
|
+
const patch = isRecord(stored.patch) ? readProfilePatch(stored.patch) : undefined;
|
|
103
|
+
return patch === undefined ? undefined : { kind: 'setProfile', patch };
|
|
104
|
+
}
|
|
105
|
+
default:
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The well-formed ops of a stored queue, in order; anything malformed is dropped. */
|
|
111
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: a queue read back from localStorage (possibly written by a v1 client)
|
|
112
|
+
export function readQueue(stored: unknown): QueueOp[] {
|
|
113
|
+
if (!Array.isArray(stored)) {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
return stored.flatMap((entry): QueueOp[] => {
|
|
117
|
+
if (!isRecord(entry)) {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
const input = readOpInput(entry);
|
|
121
|
+
const id = optionalString(entry.id);
|
|
122
|
+
const ts = optionalNumber(entry.ts);
|
|
123
|
+
return input === undefined || id === undefined || ts === undefined ? [] : [{ ...input, id, ts }];
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The better of two scores on a board sorted `sort` (`desc` by default). */
|
|
128
|
+
export function betterScore(first: number, second: number, sort: SortDir | undefined): number {
|
|
129
|
+
return sort === 'asc' ? Math.min(first, second) : Math.max(first, second);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The op that `queued` becomes when `incoming` collapses into it, or `undefined` when they don't collapse. */
|
|
133
|
+
function collapsedOp(queued: QueueOp, incoming: QueueOp): QueueOp | undefined {
|
|
134
|
+
if (queued.kind === 'incrStat' && incoming.kind === 'incrStat' && queued.key === incoming.key) {
|
|
135
|
+
return { ...queued, amount: queued.amount + incoming.amount };
|
|
136
|
+
}
|
|
137
|
+
if (queued.kind === 'maxStat' && incoming.kind === 'maxStat' && queued.key === incoming.key) {
|
|
138
|
+
return { ...queued, value: Math.max(queued.value, incoming.value) };
|
|
139
|
+
}
|
|
140
|
+
if (queued.kind === 'setStat' && incoming.kind === 'setStat' && queued.key === incoming.key) {
|
|
141
|
+
return { ...queued, value: incoming.value };
|
|
142
|
+
}
|
|
143
|
+
if (queued.kind === 'submitScore' && incoming.kind === 'submitScore' && queued.key === incoming.key) {
|
|
144
|
+
return { ...queued, score: betterScore(queued.score, incoming.score, incoming.opts.sort), opts: incoming.opts };
|
|
145
|
+
}
|
|
146
|
+
if (queued.kind === 'setProfile' && incoming.kind === 'setProfile') {
|
|
147
|
+
return { ...queued, patch: { ...queued.patch, ...incoming.patch } };
|
|
148
|
+
}
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Whether an op writes save slot `slot`. */
|
|
153
|
+
function writesSlot(op: QueueOp, slot: string): boolean {
|
|
154
|
+
return (op.kind === 'putSave' || op.kind === 'deleteSave') && op.slot === slot;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The queue with `incoming` added: a save or delete replaces any queued
|
|
159
|
+
* write to the same slot (and goes last); a compatible op merges into the
|
|
160
|
+
* first queued one in place; anything else is appended.
|
|
161
|
+
*/
|
|
162
|
+
export function enqueueOp(queue: readonly QueueOp[], incoming: QueueOp): QueueOp[] {
|
|
163
|
+
if (incoming.kind === 'putSave' || incoming.kind === 'deleteSave') {
|
|
164
|
+
return [...queue.filter((op) => !writesSlot(op, incoming.slot)), incoming];
|
|
165
|
+
}
|
|
166
|
+
const mergeAt = queue.findIndex((op) => collapsedOp(op, incoming) !== undefined);
|
|
167
|
+
if (mergeAt < 0) {
|
|
168
|
+
return [...queue, incoming];
|
|
169
|
+
}
|
|
170
|
+
return queue.map((op, i) => (i === mergeAt ? (collapsedOp(op, incoming) ?? op) : op));
|
|
171
|
+
}
|
package/src/core/page.ts
CHANGED
|
@@ -1,74 +1,74 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* What the page tells the client: the host's injected `window.__HUB__`
|
|
3
|
-
* (contract `HubInject`), the game's slug, and the API base. A page the host
|
|
4
|
-
* did not inject (`game.hub: false`, local development) has no `__HUB__`.
|
|
5
|
-
*/
|
|
6
|
-
import type { HubInject } from '@diffenderfer-games/hub-contract';
|
|
7
|
-
import { isRecord, optionalString } from './storage/json-guards';
|
|
8
|
-
|
|
9
|
-
declare global {
|
|
10
|
-
interface Window {
|
|
11
|
-
/** The host's injected config (contract `HubInject`); read through {@link injectedConfig}. */
|
|
12
|
-
// eslint-disable-next-line no-restricted-syntax -- trust boundary: written by the host's inline script, read field by field
|
|
13
|
-
__HUB__?: unknown;
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** The fields of `window.__HUB__` the core client reads. */
|
|
18
|
-
export type InjectedConfig = Partial<Pick<HubInject, 'slug'>> & { readonly apiBase?: string };
|
|
19
|
-
|
|
20
|
-
/** Whether this code runs in a browser page. */
|
|
21
|
-
export function isBrowserPage(): boolean {
|
|
22
|
-
return typeof window !== 'undefined';
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Whether the host injected this page (`window.__HUB__` exists), so the menu and social runtime can arrive. */
|
|
26
|
-
export function isHubInjectedPage(): boolean {
|
|
27
|
-
return isBrowserPage() && isRecord(window.__HUB__);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** The injected config's slug and API base, read defensively (empty outside a hub page). */
|
|
31
|
-
export function injectedConfig(): InjectedConfig {
|
|
32
|
-
if (!isBrowserPage() || !isRecord(window.__HUB__)) {
|
|
33
|
-
return {};
|
|
34
|
-
}
|
|
35
|
-
const slug = optionalString(window.__HUB__.slug);
|
|
36
|
-
const apiBase = optionalString(window.__HUB__.apiBase);
|
|
37
|
-
return { ...(slug === undefined ? {} : { slug }), ...(apiBase === undefined ? {} : { apiBase }) };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** The game's slug: the injected one, else the first URL path segment, else `null`. */
|
|
41
|
-
export function detectSlug(): string | null {
|
|
42
|
-
const injectedSlug = injectedConfig().slug;
|
|
43
|
-
if (injectedSlug !== undefined && injectedSlug !== '') {
|
|
44
|
-
return injectedSlug;
|
|
45
|
-
}
|
|
46
|
-
if (typeof location === 'undefined') {
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
49
|
-
return location.pathname.split('/').find((segment) => segment !== '') ?? null;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** An API base made absolute against the page origin (`http://localhost` outside a browser), without a trailing slash. */
|
|
53
|
-
export function absoluteApiBase(apiBase: string): string {
|
|
54
|
-
const origin = typeof location === 'undefined' ? 'http://localhost' : location.origin;
|
|
55
|
-
return new URL(apiBase, origin).toString().replace(/\/$/, '');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** The page's API base: the injected one, else `/_api`, made absolute. */
|
|
59
|
-
export function detectApiBase(): string {
|
|
60
|
-
return absoluteApiBase(injectedConfig().apiBase ?? '/_api');
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Dispatch a `CustomEvent` on `window` (no-op outside a browser; listener errors never reach the caller). */
|
|
64
|
-
// eslint-disable-next-line no-restricted-syntax -- trust boundary: event details are whatever the event documents; listeners read them
|
|
65
|
-
export function emitWindowEvent(eventName: string, detail?: unknown): void {
|
|
66
|
-
if (!isBrowserPage()) {
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
try {
|
|
70
|
-
window.dispatchEvent(new CustomEvent(eventName, { detail }));
|
|
71
|
-
} catch {
|
|
72
|
-
// A page without CustomEvent (very old browsers) misses the event.
|
|
73
|
-
}
|
|
74
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* What the page tells the client: the host's injected `window.__HUB__`
|
|
3
|
+
* (contract `HubInject`), the game's slug, and the API base. A page the host
|
|
4
|
+
* did not inject (`game.hub: false`, local development) has no `__HUB__`.
|
|
5
|
+
*/
|
|
6
|
+
import type { HubInject } from '@diffenderfer-games/hub-contract';
|
|
7
|
+
import { isRecord, optionalString } from './storage/json-guards';
|
|
8
|
+
|
|
9
|
+
declare global {
|
|
10
|
+
interface Window {
|
|
11
|
+
/** The host's injected config (contract `HubInject`); read through {@link injectedConfig}. */
|
|
12
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: written by the host's inline script, read field by field
|
|
13
|
+
__HUB__?: unknown;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The fields of `window.__HUB__` the core client reads. */
|
|
18
|
+
export type InjectedConfig = Partial<Pick<HubInject, 'slug'>> & { readonly apiBase?: string };
|
|
19
|
+
|
|
20
|
+
/** Whether this code runs in a browser page. */
|
|
21
|
+
export function isBrowserPage(): boolean {
|
|
22
|
+
return typeof window !== 'undefined';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Whether the host injected this page (`window.__HUB__` exists), so the menu and social runtime can arrive. */
|
|
26
|
+
export function isHubInjectedPage(): boolean {
|
|
27
|
+
return isBrowserPage() && isRecord(window.__HUB__);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The injected config's slug and API base, read defensively (empty outside a hub page). */
|
|
31
|
+
export function injectedConfig(): InjectedConfig {
|
|
32
|
+
if (!isBrowserPage() || !isRecord(window.__HUB__)) {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
const slug = optionalString(window.__HUB__.slug);
|
|
36
|
+
const apiBase = optionalString(window.__HUB__.apiBase);
|
|
37
|
+
return { ...(slug === undefined ? {} : { slug }), ...(apiBase === undefined ? {} : { apiBase }) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The game's slug: the injected one, else the first URL path segment, else `null`. */
|
|
41
|
+
export function detectSlug(): string | null {
|
|
42
|
+
const injectedSlug = injectedConfig().slug;
|
|
43
|
+
if (injectedSlug !== undefined && injectedSlug !== '') {
|
|
44
|
+
return injectedSlug;
|
|
45
|
+
}
|
|
46
|
+
if (typeof location === 'undefined') {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return location.pathname.split('/').find((segment) => segment !== '') ?? null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** An API base made absolute against the page origin (`http://localhost` outside a browser), without a trailing slash. */
|
|
53
|
+
export function absoluteApiBase(apiBase: string): string {
|
|
54
|
+
const origin = typeof location === 'undefined' ? 'http://localhost' : location.origin;
|
|
55
|
+
return new URL(apiBase, origin).toString().replace(/\/$/, '');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The page's API base: the injected one, else `/_api`, made absolute. */
|
|
59
|
+
export function detectApiBase(): string {
|
|
60
|
+
return absoluteApiBase(injectedConfig().apiBase ?? '/_api');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Dispatch a `CustomEvent` on `window` (no-op outside a browser; listener errors never reach the caller). */
|
|
64
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: event details are whatever the event documents; listeners read them
|
|
65
|
+
export function emitWindowEvent(eventName: string, detail?: unknown): void {
|
|
66
|
+
if (!isBrowserPage()) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
window.dispatchEvent(new CustomEvent(eventName, { detail }));
|
|
71
|
+
} catch {
|
|
72
|
+
// A page without CustomEvent (very old browsers) misses the event.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Per-endpoint request and reply types, derived from the contract's spec
|
|
3
|
-
* **type** only (`import type`, erased at build): the browser bundle carries
|
|
4
|
-
* the route table, never the schemas (plan D16).
|
|
5
|
-
*/
|
|
6
|
-
import type { CallReturn, ClientData } from '@ayepi/core/client';
|
|
7
|
-
import type { PublicEndpoints } from '@diffenderfer-games/hub-contract';
|
|
8
|
-
|
|
9
|
-
/** The name of a public REST endpoint (`getCurrentUser`, `writeSave`, ...). */
|
|
10
|
-
export type EndpointName = keyof PublicEndpoints;
|
|
11
|
-
|
|
12
|
-
/** One endpoint's request fields: path params, query and body merged into one object. */
|
|
13
|
-
export type EndpointFields<K extends EndpointName> = ClientData<PublicEndpoints[K]>;
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* An endpoint that declares several success statuses comes back from ayepi's
|
|
17
|
-
* client as `{ status, data }`; this client always hands back the body.
|
|
18
|
-
*/
|
|
19
|
-
type ReplyBody<Reply> = Reply extends { readonly status: number; readonly data: infer Body } ? Body : Reply;
|
|
20
|
-
|
|
21
|
-
/** One endpoint's success reply body. */
|
|
22
|
-
export type EndpointReply<K extends EndpointName> = ReplyBody<Awaited<CallReturn<PublicEndpoints[K]>>>;
|
|
1
|
+
/**
|
|
2
|
+
* Per-endpoint request and reply types, derived from the contract's spec
|
|
3
|
+
* **type** only (`import type`, erased at build): the browser bundle carries
|
|
4
|
+
* the route table, never the schemas (plan D16).
|
|
5
|
+
*/
|
|
6
|
+
import type { CallReturn, ClientData } from '@ayepi/core/client';
|
|
7
|
+
import type { PublicEndpoints } from '@diffenderfer-games/hub-contract';
|
|
8
|
+
|
|
9
|
+
/** The name of a public REST endpoint (`getCurrentUser`, `writeSave`, ...). */
|
|
10
|
+
export type EndpointName = keyof PublicEndpoints;
|
|
11
|
+
|
|
12
|
+
/** One endpoint's request fields: path params, query and body merged into one object. */
|
|
13
|
+
export type EndpointFields<K extends EndpointName> = ClientData<PublicEndpoints[K]>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* An endpoint that declares several success statuses comes back from ayepi's
|
|
17
|
+
* client as `{ status, data }`; this client always hands back the body.
|
|
18
|
+
*/
|
|
19
|
+
type ReplyBody<Reply> = Reply extends { readonly status: number; readonly data: infer Body } ? Body : Reply;
|
|
20
|
+
|
|
21
|
+
/** One endpoint's success reply body. */
|
|
22
|
+
export type EndpointReply<K extends EndpointName> = ReplyBody<Awaited<CallReturn<PublicEndpoints[K]>>>;
|