@diffenderfer-games/hub 2.0.0-next.0 → 2.0.0-next.2
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 +36 -0
- package/LICENSE +21 -21
- package/README.md +206 -139
- package/dist/auto.d.ts +6 -6
- package/dist/auto.js +2 -2
- package/dist/doctor/cli.mjs +62 -25
- package/dist/doctor/cli.mjs.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.js +2 -2
- package/dist/mp.d.ts +3 -3
- package/dist/race.d.ts +1 -1
- package/dist/rooms.d.ts +3 -3
- package/dist/shared/bridge.js +9 -3
- package/dist/shared/bridge.js.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.d.ts +10 -4
- package/dist/shared/daily.d.ts.map +1 -1
- package/dist/shared/daily.js +12 -2
- package/dist/shared/daily.js.map +1 -1
- package/dist/shared/globals.js +8 -3
- package/dist/shared/globals.js.map +1 -1
- package/dist/shared/index.d.ts +314 -35
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/input.js +7 -2
- package/dist/shared/input.js.map +1 -1
- package/dist/shared/latest-state.js +1 -1
- package/dist/shared/mp.js +10 -2
- package/dist/shared/mp.js.map +1 -1
- package/dist/shared/multiplayer-types.d.ts +15 -2
- package/dist/shared/multiplayer-types.d.ts.map +1 -1
- package/dist/shared/race-types.d.ts +1 -1
- package/dist/shared/race.js +1 -1
- package/dist/shared/room-types.d.ts +28 -3
- package/dist/shared/room-types.d.ts.map +1 -1
- package/dist/shared/rooms.d.ts.map +1 -1
- package/dist/shared/rooms.js +10 -2
- package/dist/shared/rooms.js.map +1 -1
- package/dist/shared/runtime-opener.js +1 -1
- package/dist/shared/social.d.ts +23 -8
- package/dist/shared/social.d.ts.map +1 -1
- package/dist/shared/social.js +6 -1
- package/dist/shared/social.js.map +1 -1
- package/dist/shared/src.js +1588 -388
- package/dist/shared/src.js.map +1 -1
- package/dist/shared/wire-types.d.ts +6 -2
- package/dist/shared/wire-types.d.ts.map +1 -1
- package/dist/social.d.ts +5 -5
- package/dist/social.js +1 -1
- package/package.json +89 -89
- package/src/auto.ts +39 -39
- package/src/core/connection.ts +394 -299
- package/src/core/errors.ts +147 -147
- package/src/core/game-data.ts +40 -38
- package/src/core/hub-economy.ts +216 -179
- package/src/core/hub-progress.ts +202 -205
- package/src/core/hub.ts +133 -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/hub-kinds/daily-kind.ts +68 -0
- package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
- package/src/core/offline/hub-kinds/game-fields.ts +2 -0
- package/src/core/offline/hub-kinds/index.ts +35 -0
- package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
- package/src/core/offline/hub-kinds/progress-kinds.ts +148 -0
- package/src/core/offline/kind-adapters.ts +48 -0
- package/src/core/offline/local-inventory.ts +64 -0
- package/src/core/offline/local-store.ts +259 -261
- package/src/core/offline/offline-api.ts +65 -0
- package/src/core/offline/outbox-entry.ts +59 -0
- package/src/core/offline/outbox-kinds.ts +78 -0
- package/src/core/offline/outbox-merge.ts +39 -0
- package/src/core/offline/outbox-replayer.ts +141 -0
- package/src/core/offline/outbox-store.ts +85 -0
- package/src/core/offline/outbox.ts +232 -0
- package/src/core/offline/queue.ts +126 -171
- package/src/core/offline/replay-lease.ts +87 -0
- package/src/core/offline/replay-policy.ts +52 -0
- package/src/core/page.ts +74 -74
- package/src/core/rest/endpoint-types.ts +22 -22
- package/src/core/rest/rest-client.ts +163 -152
- package/src/core/rest/routes.gen.ts +108 -87
- package/src/core/storage/json-guards.ts +80 -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 +168 -161
- package/src/daily/page-daily.ts +36 -36
- package/src/daily/types.ts +68 -67
- package/src/daily.ts +10 -10
- package/src/doctor/checks.ts +86 -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 +55 -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 +171 -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 +126 -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 +40 -28
- package/src/realtime/multiplayer-types.ts +66 -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 +151 -125
- package/src/realtime/rooms-forwarder.ts +91 -79
- package/src/realtime/runtime-api.ts +60 -49
- package/src/realtime/runtime-opener.ts +61 -61
- package/src/realtime/social-forwarder.ts +77 -72
- package/src/realtime/social-types.ts +86 -78
- package/src/realtime/types.ts +53 -53
- package/src/realtime/wire-types.ts +79 -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,62 +1,62 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The hub's `GET /_api/version`, read at most once per page and API base
|
|
3
|
-
* (plan §4.3.3), and what it says about this client. Nothing reads it on
|
|
4
|
-
* load: it costs a request only when a game (or `hub-doctor`) asks.
|
|
5
|
-
*/
|
|
6
|
-
import type { HubVersion } from '@diffenderfer-games/hub-contract';
|
|
7
|
-
import { HubError, UNSUPPORTED_BY_HUB } from '../errors';
|
|
8
|
-
import type { RestClient } from '../rest/rest-client';
|
|
9
|
-
import { HUB_CLIENT_VERSION } from './client-version.gen';
|
|
10
|
-
import { satisfiesRange } from './semver';
|
|
11
|
-
|
|
12
|
-
/** How a hub regards a client version. */
|
|
13
|
-
export type ClientSupport =
|
|
14
|
-
/** In `supported` and not `deprecated`. */
|
|
15
|
-
| 'supported'
|
|
16
|
-
/** Still supported, but `deprecated`: update soon. */
|
|
17
|
-
| 'deprecated'
|
|
18
|
-
/** Outside `supported`: update now. */
|
|
19
|
-
| 'unsupported'
|
|
20
|
-
/** The hub predates versioning (the old hub): nothing to compare. */
|
|
21
|
-
| 'unknown';
|
|
22
|
-
|
|
23
|
-
/** How `version` fares against a hub's answer (`null`: a hub from before versioning). */
|
|
24
|
-
export function clientSupport(hubVersion: HubVersion | null, version: string = HUB_CLIENT_VERSION): ClientSupport {
|
|
25
|
-
if (hubVersion === null) {
|
|
26
|
-
return 'unknown';
|
|
27
|
-
}
|
|
28
|
-
if (!satisfiesRange(version, hubVersion.supported)) {
|
|
29
|
-
return 'unsupported';
|
|
30
|
-
}
|
|
31
|
-
return satisfiesRange(version, hubVersion.deprecated) ? 'deprecated' : 'supported';
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const versionsByApiBase = new Map<string, Promise<HubVersion | null>>();
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* The hub's version, fetched once per page and API base. Resolves `null` for
|
|
38
|
-
* a hub from before versioning (it has no `/version` route); a failed
|
|
39
|
-
* request is not cached, so a later call tries again.
|
|
40
|
-
*/
|
|
41
|
-
export function readHubVersion(rest: RestClient): Promise<HubVersion | null> {
|
|
42
|
-
const cached = versionsByApiBase.get(rest.apiBase);
|
|
43
|
-
if (cached !== undefined) {
|
|
44
|
-
return cached;
|
|
45
|
-
}
|
|
46
|
-
const pending = fetchHubVersion(rest);
|
|
47
|
-
versionsByApiBase.set(rest.apiBase, pending);
|
|
48
|
-
return pending;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** One `GET /version`; `null` when the hub has no such route. */
|
|
52
|
-
async function fetchHubVersion(rest: RestClient): Promise<HubVersion | null> {
|
|
53
|
-
try {
|
|
54
|
-
return await rest.call('getHubVersion');
|
|
55
|
-
} catch (failure) {
|
|
56
|
-
if (failure instanceof HubError && failure.code === UNSUPPORTED_BY_HUB) {
|
|
57
|
-
return null;
|
|
58
|
-
}
|
|
59
|
-
versionsByApiBase.delete(rest.apiBase);
|
|
60
|
-
throw failure;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The hub's `GET /_api/version`, read at most once per page and API base
|
|
3
|
+
* (plan §4.3.3), and what it says about this client. Nothing reads it on
|
|
4
|
+
* load: it costs a request only when a game (or `hub-doctor`) asks.
|
|
5
|
+
*/
|
|
6
|
+
import type { HubVersion } from '@diffenderfer-games/hub-contract';
|
|
7
|
+
import { HubError, UNSUPPORTED_BY_HUB } from '../errors';
|
|
8
|
+
import type { RestClient } from '../rest/rest-client';
|
|
9
|
+
import { HUB_CLIENT_VERSION } from './client-version.gen';
|
|
10
|
+
import { satisfiesRange } from './semver';
|
|
11
|
+
|
|
12
|
+
/** How a hub regards a client version. */
|
|
13
|
+
export type ClientSupport =
|
|
14
|
+
/** In `supported` and not `deprecated`. */
|
|
15
|
+
| 'supported'
|
|
16
|
+
/** Still supported, but `deprecated`: update soon. */
|
|
17
|
+
| 'deprecated'
|
|
18
|
+
/** Outside `supported`: update now. */
|
|
19
|
+
| 'unsupported'
|
|
20
|
+
/** The hub predates versioning (the old hub): nothing to compare. */
|
|
21
|
+
| 'unknown';
|
|
22
|
+
|
|
23
|
+
/** How `version` fares against a hub's answer (`null`: a hub from before versioning). */
|
|
24
|
+
export function clientSupport(hubVersion: HubVersion | null, version: string = HUB_CLIENT_VERSION): ClientSupport {
|
|
25
|
+
if (hubVersion === null) {
|
|
26
|
+
return 'unknown';
|
|
27
|
+
}
|
|
28
|
+
if (!satisfiesRange(version, hubVersion.supported)) {
|
|
29
|
+
return 'unsupported';
|
|
30
|
+
}
|
|
31
|
+
return satisfiesRange(version, hubVersion.deprecated) ? 'deprecated' : 'supported';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const versionsByApiBase = new Map<string, Promise<HubVersion | null>>();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The hub's version, fetched once per page and API base. Resolves `null` for
|
|
38
|
+
* a hub from before versioning (it has no `/version` route); a failed
|
|
39
|
+
* request is not cached, so a later call tries again.
|
|
40
|
+
*/
|
|
41
|
+
export function readHubVersion(rest: RestClient): Promise<HubVersion | null> {
|
|
42
|
+
const cached = versionsByApiBase.get(rest.apiBase);
|
|
43
|
+
if (cached !== undefined) {
|
|
44
|
+
return cached;
|
|
45
|
+
}
|
|
46
|
+
const pending = fetchHubVersion(rest);
|
|
47
|
+
versionsByApiBase.set(rest.apiBase, pending);
|
|
48
|
+
return pending;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One `GET /version`; `null` when the hub has no such route. */
|
|
52
|
+
async function fetchHubVersion(rest: RestClient): Promise<HubVersion | null> {
|
|
53
|
+
try {
|
|
54
|
+
return await rest.call('getHubVersion');
|
|
55
|
+
} catch (failure) {
|
|
56
|
+
if (failure instanceof HubError && failure.code === UNSUPPORTED_BY_HUB) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
versionsByApiBase.delete(rest.apiBase);
|
|
60
|
+
throw failure;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -1,97 +1,97 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Just enough semver for version ranges the hub sends (`>=2.0.0-0 <3.0.0`,
|
|
3
|
-
* `<2.2.0`): comparators separated by spaces must all hold, `||` separates
|
|
4
|
-
* alternatives. Versions compare in plain semver order, prereleases included
|
|
5
|
-
* (`2.0.0-next.1 < 2.0.0`); unlike npm, a prerelease is not excluded from a
|
|
6
|
-
* range of another release line, which is why the hub writes `-0` floors.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/** A parsed `major.minor.patch[-prerelease]` version. */
|
|
10
|
-
export interface ParsedVersion {
|
|
11
|
-
/** `[major, minor, patch]`. */
|
|
12
|
-
readonly release: readonly [number, number, number];
|
|
13
|
-
/** The dot-separated prerelease identifiers (empty for a release). */
|
|
14
|
-
readonly prerelease: readonly string[];
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const VERSION_PATTERN = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
18
|
-
|
|
19
|
-
/** Parse a version (`2`, `2.1`, `2.1.0`, `2.1.0-next.3`); `undefined` when it is not one. */
|
|
20
|
-
export function parseVersion(text: string): ParsedVersion | undefined {
|
|
21
|
-
const match = VERSION_PATTERN.exec(text.trim());
|
|
22
|
-
if (match === null) {
|
|
23
|
-
return undefined;
|
|
24
|
-
}
|
|
25
|
-
const [, major = '0', minor = '0', patch = '0', prerelease] = match;
|
|
26
|
-
return { release: [Number(major), Number(minor), Number(patch)], prerelease: prerelease === undefined ? [] : prerelease.split('.') };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Compare two prerelease identifiers: numbers by value and before words, words as text. */
|
|
30
|
-
function compareIdentifiers(first: string, second: string): number {
|
|
31
|
-
const isFirstNumeric = /^\d+$/.test(first);
|
|
32
|
-
const isSecondNumeric = /^\d+$/.test(second);
|
|
33
|
-
if (isFirstNumeric && isSecondNumeric) {
|
|
34
|
-
return Number(first) - Number(second);
|
|
35
|
-
}
|
|
36
|
-
if (isFirstNumeric !== isSecondNumeric) {
|
|
37
|
-
return isFirstNumeric ? -1 : 1;
|
|
38
|
-
}
|
|
39
|
-
return first < second ? -1 : first > second ? 1 : 0;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Negative when `first` is older, positive when newer, 0 when equal. */
|
|
43
|
-
export function compareVersions(first: ParsedVersion, second: ParsedVersion): number {
|
|
44
|
-
for (let i = 0; i < 3; i += 1) {
|
|
45
|
-
const difference = (first.release[i] ?? 0) - (second.release[i] ?? 0);
|
|
46
|
-
if (difference !== 0) {
|
|
47
|
-
return difference;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
if (first.prerelease.length === 0 || second.prerelease.length === 0) {
|
|
51
|
-
return second.prerelease.length - first.prerelease.length;
|
|
52
|
-
}
|
|
53
|
-
const length = Math.max(first.prerelease.length, second.prerelease.length);
|
|
54
|
-
for (let i = 0; i < length; i += 1) {
|
|
55
|
-
const firstIdentifier = first.prerelease[i];
|
|
56
|
-
const secondIdentifier = second.prerelease[i];
|
|
57
|
-
if (firstIdentifier === undefined || secondIdentifier === undefined) {
|
|
58
|
-
return firstIdentifier === undefined ? -1 : 1;
|
|
59
|
-
}
|
|
60
|
-
const difference = compareIdentifiers(firstIdentifier, secondIdentifier);
|
|
61
|
-
if (difference !== 0) {
|
|
62
|
-
return difference;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return 0;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const COMPARATOR_PATTERN = /^(>=|<=|>|<|=)?\s*(.+)$/;
|
|
69
|
-
|
|
70
|
-
/** Whether `version` meets one comparator (`>=2.0.0`); a malformed comparator never holds. */
|
|
71
|
-
function meetsComparator(version: ParsedVersion, comparator: string): boolean {
|
|
72
|
-
const match = COMPARATOR_PATTERN.exec(comparator);
|
|
73
|
-
const bound = match?.[2] === undefined ? undefined : parseVersion(match[2]);
|
|
74
|
-
if (match === null || bound === undefined) {
|
|
75
|
-
return false;
|
|
76
|
-
}
|
|
77
|
-
const order = compareVersions(version, bound);
|
|
78
|
-
switch (match[1]) {
|
|
79
|
-
case '>=': return order >= 0;
|
|
80
|
-
case '<=': return order <= 0;
|
|
81
|
-
case '>': return order > 0;
|
|
82
|
-
case '<': return order < 0;
|
|
83
|
-
default: return order === 0;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Whether `versionText` is in `range`; a malformed version or empty range never is. */
|
|
88
|
-
export function satisfiesRange(versionText: string, range: string): boolean {
|
|
89
|
-
const version = parseVersion(versionText);
|
|
90
|
-
if (version === undefined) {
|
|
91
|
-
return false;
|
|
92
|
-
}
|
|
93
|
-
return range.split('||').some((alternative) => {
|
|
94
|
-
const comparators = alternative.trim().split(/\s+/).filter((comparator) => comparator !== '');
|
|
95
|
-
return comparators.length > 0 && comparators.every((comparator) => meetsComparator(version, comparator));
|
|
96
|
-
});
|
|
97
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Just enough semver for version ranges the hub sends (`>=2.0.0-0 <3.0.0`,
|
|
3
|
+
* `<2.2.0`): comparators separated by spaces must all hold, `||` separates
|
|
4
|
+
* alternatives. Versions compare in plain semver order, prereleases included
|
|
5
|
+
* (`2.0.0-next.1 < 2.0.0`); unlike npm, a prerelease is not excluded from a
|
|
6
|
+
* range of another release line, which is why the hub writes `-0` floors.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A parsed `major.minor.patch[-prerelease]` version. */
|
|
10
|
+
export interface ParsedVersion {
|
|
11
|
+
/** `[major, minor, patch]`. */
|
|
12
|
+
readonly release: readonly [number, number, number];
|
|
13
|
+
/** The dot-separated prerelease identifiers (empty for a release). */
|
|
14
|
+
readonly prerelease: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const VERSION_PATTERN = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
18
|
+
|
|
19
|
+
/** Parse a version (`2`, `2.1`, `2.1.0`, `2.1.0-next.3`); `undefined` when it is not one. */
|
|
20
|
+
export function parseVersion(text: string): ParsedVersion | undefined {
|
|
21
|
+
const match = VERSION_PATTERN.exec(text.trim());
|
|
22
|
+
if (match === null) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
const [, major = '0', minor = '0', patch = '0', prerelease] = match;
|
|
26
|
+
return { release: [Number(major), Number(minor), Number(patch)], prerelease: prerelease === undefined ? [] : prerelease.split('.') };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Compare two prerelease identifiers: numbers by value and before words, words as text. */
|
|
30
|
+
function compareIdentifiers(first: string, second: string): number {
|
|
31
|
+
const isFirstNumeric = /^\d+$/.test(first);
|
|
32
|
+
const isSecondNumeric = /^\d+$/.test(second);
|
|
33
|
+
if (isFirstNumeric && isSecondNumeric) {
|
|
34
|
+
return Number(first) - Number(second);
|
|
35
|
+
}
|
|
36
|
+
if (isFirstNumeric !== isSecondNumeric) {
|
|
37
|
+
return isFirstNumeric ? -1 : 1;
|
|
38
|
+
}
|
|
39
|
+
return first < second ? -1 : first > second ? 1 : 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Negative when `first` is older, positive when newer, 0 when equal. */
|
|
43
|
+
export function compareVersions(first: ParsedVersion, second: ParsedVersion): number {
|
|
44
|
+
for (let i = 0; i < 3; i += 1) {
|
|
45
|
+
const difference = (first.release[i] ?? 0) - (second.release[i] ?? 0);
|
|
46
|
+
if (difference !== 0) {
|
|
47
|
+
return difference;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (first.prerelease.length === 0 || second.prerelease.length === 0) {
|
|
51
|
+
return second.prerelease.length - first.prerelease.length;
|
|
52
|
+
}
|
|
53
|
+
const length = Math.max(first.prerelease.length, second.prerelease.length);
|
|
54
|
+
for (let i = 0; i < length; i += 1) {
|
|
55
|
+
const firstIdentifier = first.prerelease[i];
|
|
56
|
+
const secondIdentifier = second.prerelease[i];
|
|
57
|
+
if (firstIdentifier === undefined || secondIdentifier === undefined) {
|
|
58
|
+
return firstIdentifier === undefined ? -1 : 1;
|
|
59
|
+
}
|
|
60
|
+
const difference = compareIdentifiers(firstIdentifier, secondIdentifier);
|
|
61
|
+
if (difference !== 0) {
|
|
62
|
+
return difference;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const COMPARATOR_PATTERN = /^(>=|<=|>|<|=)?\s*(.+)$/;
|
|
69
|
+
|
|
70
|
+
/** Whether `version` meets one comparator (`>=2.0.0`); a malformed comparator never holds. */
|
|
71
|
+
function meetsComparator(version: ParsedVersion, comparator: string): boolean {
|
|
72
|
+
const match = COMPARATOR_PATTERN.exec(comparator);
|
|
73
|
+
const bound = match?.[2] === undefined ? undefined : parseVersion(match[2]);
|
|
74
|
+
if (match === null || bound === undefined) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
const order = compareVersions(version, bound);
|
|
78
|
+
switch (match[1]) {
|
|
79
|
+
case '>=': return order >= 0;
|
|
80
|
+
case '<=': return order <= 0;
|
|
81
|
+
case '>': return order > 0;
|
|
82
|
+
case '<': return order < 0;
|
|
83
|
+
default: return order === 0;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Whether `versionText` is in `range`; a malformed version or empty range never is. */
|
|
88
|
+
export function satisfiesRange(versionText: string, range: string): boolean {
|
|
89
|
+
const version = parseVersion(versionText);
|
|
90
|
+
if (version === undefined) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return range.split('||').some((alternative) => {
|
|
94
|
+
const comparators = alternative.trim().split(/\s+/).filter((comparator) => comparator !== '');
|
|
95
|
+
return comparators.length > 0 && comparators.every((comparator) => meetsComparator(version, comparator));
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -1,161 +1,168 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Daily challenges, client side. A daily-enabled game calls
|
|
3
|
-
* `hub.daily.define({ play })` once at load; `play` receives a
|
|
4
|
-
* {@link DailyChallenge} and starts exactly that challenge. When the player
|
|
5
|
-
* finishes, the game calls `hub.daily.complete({ score, … })`.
|
|
6
|
-
*
|
|
7
|
-
* The system is a page-wide singleton shared by the game's client and the
|
|
8
|
-
* injected menu, so the menu can start a day on the instance the game
|
|
9
|
-
* registered `play` on. Deep links (`/<slug>/?daily=YYYY-MM-DD`) auto-start.
|
|
10
|
-
*/
|
|
11
|
-
import { readField } from '../realtime/cross-bundle';
|
|
12
|
-
import type { DailyAll, DailyChallenge, DailyCompleteResponse, DailyCompletionMeta, DailyDefinition, DailyHost, DailyStatus } from './types';
|
|
13
|
-
|
|
14
|
-
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
15
|
-
|
|
16
|
-
/** The window event the injected menu fires to start a day on the game's instance. */
|
|
17
|
-
export const DAILY_START_EVENT = 'hub:daily-start';
|
|
18
|
-
|
|
19
|
-
/** A day key, or `null` when `candidate` isn't one. */
|
|
20
|
-
// eslint-disable-next-line no-restricted-syntax -- trust boundary: a URL parameter or a window global set by the host page
|
|
21
|
-
function asDayKey(candidate: unknown): string | null {
|
|
22
|
-
return typeof candidate === 'string' && DAY_PATTERN.test(candidate) ? candidate : null;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** The day the page was deep-linked to, if any. */
|
|
26
|
-
function readDeepLinkDay(): string | null {
|
|
27
|
-
// Prefer the value the host's injected snippet captured at parse time: it
|
|
28
|
-
// survives games that rewrite their own URL on boot.
|
|
29
|
-
const captured = typeof window === 'undefined' ? null : asDayKey(readField(window, '__hubDailyDeep'));
|
|
30
|
-
if (captured !== null || typeof location === 'undefined') {
|
|
31
|
-
return captured;
|
|
32
|
-
}
|
|
33
|
-
return asDayKey(new URL(location.href).searchParams.get('daily'));
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** The message of a thrown value. */
|
|
37
|
-
// eslint-disable-next-line no-restricted-syntax -- trust boundary: anything a REST call can throw
|
|
38
|
-
function messageOf(error: unknown): string {
|
|
39
|
-
return error instanceof Error ? error.message : String(error);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Daily challenges (`hub.daily`). */
|
|
43
|
-
export class DailySystem {
|
|
44
|
-
/**
|
|
45
|
-
* The challenge in progress (set when a day starts, cleared on complete).
|
|
46
|
-
* A game's win handler checks it: `if (hub.daily.active) hub.daily.complete(…)`.
|
|
47
|
-
*/
|
|
48
|
-
active: DailyChallenge | null = null;
|
|
49
|
-
private readonly host: DailyHost;
|
|
50
|
-
private play: DailyDefinition['play'] | null = null;
|
|
51
|
-
private pendingDay: string | null = null;
|
|
52
|
-
private isRelaying = false;
|
|
53
|
-
|
|
54
|
-
constructor(host: DailyHost) {
|
|
55
|
-
this.host = host;
|
|
56
|
-
if (typeof window !== 'undefined') {
|
|
57
|
-
// The injected menu (another module instance) asks us to start a day.
|
|
58
|
-
window.addEventListener(DAILY_START_EVENT, (event) => {
|
|
59
|
-
if (this.isRelaying) {
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
const day = event instanceof CustomEvent ? readField(event.detail, 'day') : undefined;
|
|
63
|
-
if (typeof day === 'string') {
|
|
64
|
-
void this.startAndPlay(day);
|
|
65
|
-
}
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Whether a game registered a daily-challenge handler on this page. */
|
|
71
|
-
get isDefined(): boolean {
|
|
72
|
-
return this.play !== null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Register the game's challenge runner. Call once at load. If the page was
|
|
77
|
-
* deep-linked with `?daily=DATE` (or a start was requested before this),
|
|
78
|
-
* that day starts now.
|
|
79
|
-
*/
|
|
80
|
-
define(definition: DailyDefinition): void {
|
|
81
|
-
this.play = definition.play;
|
|
82
|
-
const day = readDeepLinkDay() ?? this.pendingDay;
|
|
83
|
-
this.pendingDay = null;
|
|
84
|
-
if (day !== null) {
|
|
85
|
-
void this.startAndPlay(day);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Fetch the signed token for `day`, make it active and call the game's
|
|
91
|
-
* `play`. Before `define`, the day is kept and played then, so the menu's
|
|
92
|
-
* own instance never fetches a token; it is also relayed to any other
|
|
93
|
-
* daily system on the page (a client copy that couldn't share this one).
|
|
94
|
-
*/
|
|
95
|
-
async startAndPlay(day: string): Promise<void> {
|
|
96
|
-
const play = this.play;
|
|
97
|
-
if (play === null) {
|
|
98
|
-
this.pendingDay = day;
|
|
99
|
-
this.relayStart(day);
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
try {
|
|
103
|
-
const started = await this.host.start(day);
|
|
104
|
-
const challenge: DailyChallenge = { day: started.day, seed: started.seed, token: started.token, alreadyCompleted: started.alreadyCompleted };
|
|
105
|
-
this.active = challenge;
|
|
106
|
-
this.host.emit('hub:daily-started', { day: started.day, seed: started.seed, alreadyCompleted: started.alreadyCompleted });
|
|
107
|
-
play({ ...challenge });
|
|
108
|
-
} catch (error) {
|
|
109
|
-
this.host.emit('hub:daily-error', { day, error: messageOf(error) });
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Record completion of the active challenge with optional metadata.
|
|
115
|
-
* Resolves with the new completion count and rank, or `null` when no
|
|
116
|
-
* challenge is active or the hub
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
this.host.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Daily challenges, client side. A daily-enabled game calls
|
|
3
|
+
* `hub.daily.define({ play })` once at load; `play` receives a
|
|
4
|
+
* {@link DailyChallenge} and starts exactly that challenge. When the player
|
|
5
|
+
* finishes, the game calls `hub.daily.complete({ score, … })`.
|
|
6
|
+
*
|
|
7
|
+
* The system is a page-wide singleton shared by the game's client and the
|
|
8
|
+
* injected menu, so the menu can start a day on the instance the game
|
|
9
|
+
* registered `play` on. Deep links (`/<slug>/?daily=YYYY-MM-DD`) auto-start.
|
|
10
|
+
*/
|
|
11
|
+
import { readField } from '../realtime/cross-bundle';
|
|
12
|
+
import type { DailyAll, DailyChallenge, DailyCompleteResponse, DailyCompletionMeta, DailyDefinition, DailyHost, DailyStatus } from './types';
|
|
13
|
+
|
|
14
|
+
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
15
|
+
|
|
16
|
+
/** The window event the injected menu fires to start a day on the game's instance. */
|
|
17
|
+
export const DAILY_START_EVENT = 'hub:daily-start';
|
|
18
|
+
|
|
19
|
+
/** A day key, or `null` when `candidate` isn't one. */
|
|
20
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: a URL parameter or a window global set by the host page
|
|
21
|
+
function asDayKey(candidate: unknown): string | null {
|
|
22
|
+
return typeof candidate === 'string' && DAY_PATTERN.test(candidate) ? candidate : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The day the page was deep-linked to, if any. */
|
|
26
|
+
function readDeepLinkDay(): string | null {
|
|
27
|
+
// Prefer the value the host's injected snippet captured at parse time: it
|
|
28
|
+
// survives games that rewrite their own URL on boot.
|
|
29
|
+
const captured = typeof window === 'undefined' ? null : asDayKey(readField(window, '__hubDailyDeep'));
|
|
30
|
+
if (captured !== null || typeof location === 'undefined') {
|
|
31
|
+
return captured;
|
|
32
|
+
}
|
|
33
|
+
return asDayKey(new URL(location.href).searchParams.get('daily'));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The message of a thrown value. */
|
|
37
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: anything a REST call can throw
|
|
38
|
+
function messageOf(error: unknown): string {
|
|
39
|
+
return error instanceof Error ? error.message : String(error);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Daily challenges (`hub.daily`). */
|
|
43
|
+
export class DailySystem {
|
|
44
|
+
/**
|
|
45
|
+
* The challenge in progress (set when a day starts, cleared on complete).
|
|
46
|
+
* A game's win handler checks it: `if (hub.daily.active) hub.daily.complete(…)`.
|
|
47
|
+
*/
|
|
48
|
+
active: DailyChallenge | null = null;
|
|
49
|
+
private readonly host: DailyHost;
|
|
50
|
+
private play: DailyDefinition['play'] | null = null;
|
|
51
|
+
private pendingDay: string | null = null;
|
|
52
|
+
private isRelaying = false;
|
|
53
|
+
|
|
54
|
+
constructor(host: DailyHost) {
|
|
55
|
+
this.host = host;
|
|
56
|
+
if (typeof window !== 'undefined') {
|
|
57
|
+
// The injected menu (another module instance) asks us to start a day.
|
|
58
|
+
window.addEventListener(DAILY_START_EVENT, (event) => {
|
|
59
|
+
if (this.isRelaying) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const day = event instanceof CustomEvent ? readField(event.detail, 'day') : undefined;
|
|
63
|
+
if (typeof day === 'string') {
|
|
64
|
+
void this.startAndPlay(day);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Whether a game registered a daily-challenge handler on this page. */
|
|
71
|
+
get isDefined(): boolean {
|
|
72
|
+
return this.play !== null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Register the game's challenge runner. Call once at load. If the page was
|
|
77
|
+
* deep-linked with `?daily=DATE` (or a start was requested before this),
|
|
78
|
+
* that day starts now.
|
|
79
|
+
*/
|
|
80
|
+
define(definition: DailyDefinition): void {
|
|
81
|
+
this.play = definition.play;
|
|
82
|
+
const day = readDeepLinkDay() ?? this.pendingDay;
|
|
83
|
+
this.pendingDay = null;
|
|
84
|
+
if (day !== null) {
|
|
85
|
+
void this.startAndPlay(day);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Fetch the signed token for `day`, make it active and call the game's
|
|
91
|
+
* `play`. Before `define`, the day is kept and played then, so the menu's
|
|
92
|
+
* own instance never fetches a token; it is also relayed to any other
|
|
93
|
+
* daily system on the page (a client copy that couldn't share this one).
|
|
94
|
+
*/
|
|
95
|
+
async startAndPlay(day: string): Promise<void> {
|
|
96
|
+
const play = this.play;
|
|
97
|
+
if (play === null) {
|
|
98
|
+
this.pendingDay = day;
|
|
99
|
+
this.relayStart(day);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const started = await this.host.start(day);
|
|
104
|
+
const challenge: DailyChallenge = { day: started.day, seed: started.seed, token: started.token, alreadyCompleted: started.alreadyCompleted };
|
|
105
|
+
this.active = challenge;
|
|
106
|
+
this.host.emit('hub:daily-started', { day: started.day, seed: started.seed, alreadyCompleted: started.alreadyCompleted });
|
|
107
|
+
play({ ...challenge });
|
|
108
|
+
} catch (error) {
|
|
109
|
+
this.host.emit('hub:daily-error', { day, error: messageOf(error) });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Record completion of the active challenge with optional metadata.
|
|
115
|
+
* Resolves with the new completion count and rank, or `null` when no
|
|
116
|
+
* challenge is active, the hub refused, or the hub can't be reached: then
|
|
117
|
+
* the completion is queued (`hub:daily-queued`) and replays when it is
|
|
118
|
+
* back (`hub:daily-complete` follows).
|
|
119
|
+
*/
|
|
120
|
+
async complete(meta: DailyCompletionMeta = {}): Promise<DailyCompleteResponse | null> {
|
|
121
|
+
const challenge = this.active;
|
|
122
|
+
if (challenge === null) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const completed = await this.host.complete(challenge.day, challenge.token, meta);
|
|
127
|
+
this.active = null;
|
|
128
|
+
if (completed === null) {
|
|
129
|
+
// Queued offline: `hub:daily-complete` follows when it replays.
|
|
130
|
+
this.host.emit('hub:daily-queued', { day: challenge.day, queued: true, meta });
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
this.host.emit('hub:daily-complete', { day: challenge.day, count: completed.count, rank: completed.rank, meta });
|
|
134
|
+
return completed;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
this.host.emit('hub:daily-error', { day: challenge.day, error: messageOf(error) });
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Ask the page's other daily systems to start `day` (this one ignores its own event). */
|
|
142
|
+
private relayStart(day: string): void {
|
|
143
|
+
if (typeof window === 'undefined') {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
this.isRelaying = true;
|
|
147
|
+
try {
|
|
148
|
+
window.dispatchEvent(new CustomEvent(DAILY_START_EVENT, { detail: { day } }));
|
|
149
|
+
} finally {
|
|
150
|
+
this.isRelaying = false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Abandon the active challenge without recording it (e.g. the player quit). */
|
|
155
|
+
cancel(): void {
|
|
156
|
+
this.active = null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** This game's window and the player's completions. */
|
|
160
|
+
status(): Promise<DailyStatus> {
|
|
161
|
+
return this.host.status();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Every daily-enabled game and the global standings (for the home hub). */
|
|
165
|
+
all(): Promise<DailyAll> {
|
|
166
|
+
return this.host.all();
|
|
167
|
+
}
|
|
168
|
+
}
|