@diffenderfer-games/hub 2.0.0-next.1 → 2.0.0-next.11
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 +173 -5
- package/README.md +27 -15
- package/dist/auto.d.ts +8 -8
- package/dist/auto.js +3 -3
- package/dist/avatar-pixi.d.ts +165 -0
- package/dist/avatar-pixi.d.ts.map +1 -0
- package/dist/avatar-pixi.js +299 -0
- package/dist/avatar-pixi.js.map +1 -0
- package/dist/avatar-svg.d.ts +4 -0
- package/dist/avatar-svg.js +5 -0
- package/dist/avatar-three.d.ts +69 -0
- package/dist/avatar-three.d.ts.map +1 -0
- package/dist/avatar-three.js +49 -0
- package/dist/avatar-three.js.map +1 -0
- package/dist/avatar.d.ts +7 -0
- package/dist/avatar.js +500 -0
- package/dist/avatar.js.map +1 -0
- package/dist/daily.d.ts +2 -2
- package/dist/doctor/cli.mjs +134 -10
- package/dist/doctor/cli.mjs.map +1 -1
- package/dist/index.d.ts +8 -8
- package/dist/index.js +3 -3
- package/dist/input.d.ts +2 -2
- package/dist/input.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/rooms.js +1 -1
- package/dist/shared/animator.d.ts +104 -0
- package/dist/shared/animator.d.ts.map +1 -0
- package/dist/shared/art.js +1946 -0
- package/dist/shared/art.js.map +1 -0
- package/dist/shared/atlas.d.ts +138 -0
- package/dist/shared/atlas.d.ts.map +1 -0
- package/dist/shared/avatar.d.ts +123 -0
- package/dist/shared/avatar.d.ts.map +1 -0
- package/dist/shared/bridge.js +18 -4
- package/dist/shared/bridge.js.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.d.ts +145 -19
- package/dist/shared/daily.d.ts.map +1 -1
- package/dist/shared/daily.js +138 -25
- package/dist/shared/daily.js.map +1 -1
- package/dist/shared/defineProperty.js +39 -0
- package/dist/shared/describe.d.ts +32 -0
- package/dist/shared/describe.d.ts.map +1 -0
- package/dist/shared/describe.js +109 -0
- package/dist/shared/describe.js.map +1 -0
- package/dist/shared/globals.js +1 -39
- package/dist/shared/globals.js.map +1 -1
- package/dist/shared/index.d.ts +1122 -46
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/input.d.ts +74 -14
- package/dist/shared/input.d.ts.map +1 -1
- package/dist/shared/input.js +179 -25
- package/dist/shared/input.js.map +1 -1
- package/dist/shared/json-guards.js +48 -0
- package/dist/shared/json-guards.js.map +1 -0
- package/dist/shared/latest-state.js +1 -1
- package/dist/shared/live-canvas.js +62 -0
- package/dist/shared/live-canvas.js.map +1 -0
- 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/overlays.js +800 -0
- package/dist/shared/overlays.js.map +1 -0
- package/dist/shared/page-stack.js +2 -1
- package/dist/shared/page-stack.js.map +1 -1
- package/dist/shared/pose-key.js +249 -0
- package/dist/shared/pose-key.js.map +1 -0
- package/dist/shared/race-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 +23 -4
- package/dist/shared/rooms.js.map +1 -1
- package/dist/shared/runtime-opener.js +1 -1
- package/dist/shared/scene.d.ts +90 -0
- package/dist/shared/scene.d.ts.map +1 -0
- package/dist/shared/social.d.ts +90 -9
- 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 +4563 -667
- package/dist/shared/src.js.map +1 -1
- package/dist/shared/svg-markup.js +2411 -0
- package/dist/shared/svg-markup.js.map +1 -0
- package/dist/shared/svg.js +27 -0
- package/dist/shared/svg.js.map +1 -0
- package/dist/shared/types.d.ts +174 -0
- package/dist/shared/types.d.ts.map +1 -0
- 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 +2 -2
- package/dist/turns-rules.d.ts +2 -0
- package/dist/turns-rules.js +1 -0
- package/dist/turns.d.ts +61 -0
- package/dist/turns.d.ts.map +1 -0
- package/dist/turns.js +183 -0
- package/dist/turns.js.map +1 -0
- package/package.json +39 -3
- package/src/avatar/adapters/pixi-avatar.ts +73 -0
- package/src/avatar/adapters/pixi-crowd.ts +329 -0
- package/src/avatar/adapters/pixi-types.ts +84 -0
- package/src/avatar/adapters/three.ts +126 -0
- package/src/avatar/animator/animator.ts +285 -0
- package/src/avatar/animator/reactions.ts +44 -0
- package/src/avatar/animator/schedule.ts +73 -0
- package/src/avatar/art/beard.ts +49 -0
- package/src/avatar/art/glasses.ts +67 -0
- package/src/avatar/art/hair.ts +254 -0
- package/src/avatar/art/headwear.ts +75 -0
- package/src/avatar/art/index.ts +21 -0
- package/src/avatar/art/shape.ts +87 -0
- package/src/avatar/art-store.ts +36 -0
- package/src/avatar/atlas.ts +246 -0
- package/src/avatar/catalog-view.ts +14 -0
- package/src/avatar/colour.ts +109 -0
- package/src/avatar/describe.ts +79 -0
- package/src/avatar/face.ts +187 -0
- package/src/avatar/geom.ts +173 -0
- package/src/avatar/layout.ts +47 -0
- package/src/avatar/look-like.ts +52 -0
- package/src/avatar/outputs/bitmap.ts +75 -0
- package/src/avatar/outputs/canvas-paint.ts +177 -0
- package/src/avatar/outputs/canvas.ts +62 -0
- package/src/avatar/outputs/custom-element.ts +140 -0
- package/src/avatar/outputs/element.ts +58 -0
- package/src/avatar/outputs/live-canvas.ts +57 -0
- package/src/avatar/outputs/overlay-svg.ts +22 -0
- package/src/avatar/outputs/pose-key.ts +30 -0
- package/src/avatar/outputs/svg-markup.ts +113 -0
- package/src/avatar/outputs/svg.ts +41 -0
- package/src/avatar/overlays.ts +128 -0
- package/src/avatar/render/body.ts +116 -0
- package/src/avatar/render/colours.ts +81 -0
- package/src/avatar/render/compose.ts +346 -0
- package/src/avatar/render/context.ts +117 -0
- package/src/avatar/render/eyes.ts +78 -0
- package/src/avatar/render/eyewear.ts +118 -0
- package/src/avatar/render/frame.ts +73 -0
- package/src/avatar/render/head.ts +81 -0
- package/src/avatar/render/mouth.ts +86 -0
- package/src/avatar/render/props.ts +91 -0
- package/src/avatar/render/types.ts +93 -0
- package/src/avatar/render/walker-parts.ts +124 -0
- package/src/avatar/render/walker.ts +239 -0
- package/src/avatar/scene.ts +119 -0
- package/src/avatar-pixi.ts +20 -0
- package/src/avatar-svg.ts +21 -0
- package/src/avatar-three.ts +18 -0
- package/src/avatar.ts +63 -0
- package/src/core/avatar/avatar-cache.ts +95 -0
- package/src/core/avatar/avatar-ref.ts +34 -0
- package/src/core/avatar/avatar-types.ts +43 -0
- package/src/core/avatar/hub-avatar.ts +308 -0
- package/src/core/connection.ts +201 -92
- package/src/core/errors.ts +15 -2
- package/src/core/game-data.ts +11 -9
- package/src/core/hub-economy.ts +47 -10
- package/src/core/hub-progress.ts +94 -30
- package/src/core/hub.ts +228 -7
- package/src/core/offline/cloud-saves.ts +62 -0
- package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
- package/src/core/offline/hub-kinds/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 +52 -0
- package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
- package/src/core/offline/hub-kinds/progress-kinds.ts +165 -0
- package/src/core/offline/hub-kinds/resume-kind.ts +108 -0
- package/src/core/offline/hub-kinds/saved-kind.ts +93 -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 +66 -23
- 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 +4 -49
- package/src/core/offline/replay-lease.ts +87 -0
- package/src/core/offline/replay-policy.ts +52 -0
- package/src/core/page-account.ts +121 -0
- package/src/core/page.ts +14 -1
- package/src/core/rest/rest-client.ts +14 -3
- package/src/core/rest/routes.gen.ts +144 -88
- package/src/core/resume/hub-resume.ts +286 -0
- package/src/core/resume/local-resume.ts +130 -0
- package/src/core/resume/resume-list.ts +65 -0
- package/src/core/resume/resume-request.ts +52 -0
- package/src/core/resume/resume-types.ts +64 -0
- package/src/core/saved/daily-saves.ts +196 -0
- package/src/core/saved/daily-seed.ts +16 -0
- package/src/core/saved/hub-saved.ts +221 -0
- package/src/core/saved/local-saved.ts +122 -0
- package/src/core/saved/saved-list.ts +129 -0
- package/src/core/saved/saved-open.ts +104 -0
- package/src/core/saved/saved-order.ts +49 -0
- package/src/core/saved/saved-types.ts +24 -0
- package/src/core/storage/json-guards.ts +6 -0
- package/src/core/turns/hub-turns.ts +161 -0
- package/src/core/turns/turn-game.ts +510 -0
- package/src/core/turns/your-turn-buzz.ts +69 -0
- package/src/core/version/client-version.gen.ts +2 -2
- package/src/daily/daily-system.ts +127 -12
- package/src/daily/page-daily.ts +2 -2
- package/src/daily/types.ts +49 -11
- package/src/doctor/checks.ts +12 -4
- package/src/doctor/cli.ts +8 -2
- package/src/doctor/embed-check.ts +61 -0
- package/src/doctor/hub-version-fetch.ts +43 -43
- package/src/doctor/world-check.ts +61 -0
- package/src/index.ts +20 -2
- package/src/input/focus-ring.ts +13 -0
- package/src/input/input-registry.ts +9 -2
- package/src/input/input-system.ts +14 -1
- package/src/input/modality.ts +122 -0
- package/src/input/nav-state.ts +13 -4
- package/src/input/navigator.ts +11 -0
- package/src/input/zero-config.ts +1 -14
- package/src/input.ts +4 -0
- package/src/mp.ts +1 -1
- package/src/overlay/overlay.ts +30 -4
- package/src/overlay/styles.ts +4 -3
- package/src/realtime/bridge.ts +5 -0
- package/src/realtime/multiplayer-forwarder.ts +13 -1
- package/src/realtime/multiplayer-types.ts +15 -1
- package/src/realtime/room-types.ts +28 -2
- package/src/realtime/rooms-forwarder.ts +27 -2
- package/src/realtime/runtime-api.ts +33 -8
- package/src/realtime/social-forwarder.ts +5 -0
- package/src/realtime/social-types.ts +8 -0
- package/src/realtime/types.ts +1 -0
- package/src/realtime/wire-types.ts +7 -0
- package/src/realtime/world-types.ts +20 -0
- package/src/rooms.ts +1 -1
- package/src/social.ts +1 -0
- package/src/turns-kit/check-rules.ts +281 -0
- package/src/turns-rules.ts +12 -0
- package/src/turns.ts +14 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Your turn" buzz: a short vibration when this player becomes due in an
|
|
3
|
+
* online game whose page they have open and visible (a phone held while
|
|
4
|
+
* waiting). Every online game gets it from `TurnGame`, with no game code.
|
|
5
|
+
*
|
|
6
|
+
* It buzzes only when the player **becomes** due on a live update: not for
|
|
7
|
+
* the next step of their own turn, not for a move they may make out of turn
|
|
8
|
+
* (`mayAct`), not for a game they just opened or a catch-up after being
|
|
9
|
+
* away, and not in a game they muted. It stays quiet when the player turned
|
|
10
|
+
* it off (the menu's Settings, stored on this device), when the page asks
|
|
11
|
+
* for less motion (the hub's `data-hub-motion="reduce"` or the system's
|
|
12
|
+
* reduced motion), and where `navigator.vibrate` doesn't exist (iOS Safari).
|
|
13
|
+
* A page can turn it off for itself (`hub.turns.setYourTurnBuzz(false)`) or
|
|
14
|
+
* for one game (`TurnGame.setYourTurnBuzz(false)`).
|
|
15
|
+
*/
|
|
16
|
+
import type { TurnSummary } from '@diffenderfer-games/hub-contract';
|
|
17
|
+
|
|
18
|
+
/** The pattern: two short pulses, ms. */
|
|
19
|
+
export const YOUR_TURN_BUZZ: readonly number[] = [60, 40, 60];
|
|
20
|
+
|
|
21
|
+
/** Where this device keeps the player's choice (`off` turns it off). */
|
|
22
|
+
export const YOUR_TURN_BUZZ_KEY = 'dg:your-turn-buzz';
|
|
23
|
+
|
|
24
|
+
/** Whether going from `previous` to `next` makes this player newly due (the moment to buzz). */
|
|
25
|
+
export function isNewlyDue(previous: Pick<TurnSummary, 'phase' | 'isMyTurn'>, next: Pick<TurnSummary, 'phase' | 'isMyTurn' | 'isMuted'>): boolean {
|
|
26
|
+
const wasDue = previous.phase === 'playing' && previous.isMyTurn;
|
|
27
|
+
return next.phase === 'playing' && next.isMyTurn && !wasDue && !next.isMuted;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The player's own switch on this device (on unless they turned it off). */
|
|
31
|
+
export function readYourTurnBuzz(): boolean {
|
|
32
|
+
try {
|
|
33
|
+
return localStorage.getItem(YOUR_TURN_BUZZ_KEY) !== 'off';
|
|
34
|
+
} catch {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Save the player's switch on this device. */
|
|
40
|
+
export function saveYourTurnBuzz(isOn: boolean): void {
|
|
41
|
+
try {
|
|
42
|
+
localStorage.setItem(YOUR_TURN_BUZZ_KEY, isOn ? 'on' : 'off');
|
|
43
|
+
} catch {
|
|
44
|
+
// No storage: it stays on.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Whether the page asks for less motion (the hub's setting, else the system's). */
|
|
49
|
+
function prefersLessMotion(): boolean {
|
|
50
|
+
if (typeof document !== 'undefined' && document.documentElement.dataset.hubMotion === 'reduce') {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Buzz now, when this page is visible and nothing says not to; fails silently. Answers whether it buzzed. */
|
|
57
|
+
export function buzzYourTurn(): boolean {
|
|
58
|
+
if (typeof document === 'undefined' || document.visibilityState !== 'visible' || !readYourTurnBuzz() || prefersLessMotion()) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
if (typeof navigator === 'undefined' || typeof navigator.vibrate !== 'function') {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return navigator.vibrate([...YOUR_TURN_BUZZ]);
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
// Generated by tools/hub-client/generate.ts. Do not edit; run `npm run gen:hub-client
|
|
1
|
+
// Generated by tools/hub-client/generate.ts. Do not edit; run `npm run gen:hub-client`.
|
|
2
2
|
/** This client's package version, sent as `x-hub-client` on REST calls and `client` in `hello`. */
|
|
3
|
-
export const HUB_CLIENT_VERSION = '2.0.0-next.
|
|
3
|
+
export const HUB_CLIENT_VERSION = '2.0.0-next.11';
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* registered `play` on. Deep links (`/<slug>/?daily=YYYY-MM-DD`) auto-start.
|
|
10
10
|
*/
|
|
11
11
|
import { readField } from '../realtime/cross-bundle';
|
|
12
|
-
import type {
|
|
12
|
+
import type { SavedEntry } from '../core/saved/saved-types';
|
|
13
|
+
import type { DailyAll, DailyChallenge, DailyCompleteResponse, DailyCompletionMeta, DailyDefinition, DailyHost, DailySaveInput, DailyStartResponse, DailyStatus } from './types';
|
|
13
14
|
|
|
14
15
|
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
15
16
|
|
|
@@ -39,16 +40,32 @@ function messageOf(error: unknown): string {
|
|
|
39
40
|
return error instanceof Error ? error.message : String(error);
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/** The challenge a start or resume answered. */
|
|
44
|
+
function challengeOf(started: DailyStartResponse): DailyChallenge {
|
|
45
|
+
return {
|
|
46
|
+
day: started.day, seed: started.seed, token: started.token, alreadyCompleted: started.alreadyCompleted,
|
|
47
|
+
instanceId: started.instanceId, saved: started.saved, savedVersion: started.savedVersion, resumed: started.resumed,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
42
51
|
/** Daily challenges (`hub.daily`). */
|
|
43
52
|
export class DailySystem {
|
|
44
53
|
/**
|
|
45
|
-
* The challenge in progress
|
|
46
|
-
*
|
|
54
|
+
* The challenge in progress: set when a day starts; cleared the moment the
|
|
55
|
+
* game calls `complete` (whatever the hub answers, and before it answers)
|
|
56
|
+
* or `cancel` (or `discard`). A game's win handler checks it:
|
|
57
|
+
* `if (hub.daily.active) hub.daily.complete(…)`. While it is set the page
|
|
58
|
+
* is in a daily run, so `hub.resume.save` saves to the day instead of the
|
|
59
|
+
* game's run: a game whose player leaves a daily unfinished must call
|
|
60
|
+
* `cancel()`.
|
|
47
61
|
*/
|
|
48
62
|
active: DailyChallenge | null = null;
|
|
49
63
|
private readonly host: DailyHost;
|
|
50
64
|
private play: DailyDefinition['play'] | null = null;
|
|
51
65
|
private pendingDay: string | null = null;
|
|
66
|
+
private pendingInstance: string | null = null;
|
|
67
|
+
/** A start on its way to the hub, so a resume of the same day waits for it instead of starting it twice. */
|
|
68
|
+
private inFlight: Promise<void> | null = null;
|
|
52
69
|
private isRelaying = false;
|
|
53
70
|
|
|
54
71
|
constructor(host: DailyHost) {
|
|
@@ -80,9 +97,13 @@ export class DailySystem {
|
|
|
80
97
|
define(definition: DailyDefinition): void {
|
|
81
98
|
this.play = definition.play;
|
|
82
99
|
const day = readDeepLinkDay() ?? this.pendingDay;
|
|
100
|
+
const instanceId = this.pendingInstance;
|
|
83
101
|
this.pendingDay = null;
|
|
102
|
+
this.pendingInstance = null;
|
|
84
103
|
if (day !== null) {
|
|
85
104
|
void this.startAndPlay(day);
|
|
105
|
+
} else if (instanceId !== null) {
|
|
106
|
+
void this.resume(instanceId);
|
|
86
107
|
}
|
|
87
108
|
}
|
|
88
109
|
|
|
@@ -99,30 +120,112 @@ export class DailySystem {
|
|
|
99
120
|
this.relayStart(day);
|
|
100
121
|
return;
|
|
101
122
|
}
|
|
123
|
+
const starting = (async (): Promise<void> => {
|
|
124
|
+
try {
|
|
125
|
+
this.begin(play, challengeOf(await this.host.start(day)));
|
|
126
|
+
} catch (error) {
|
|
127
|
+
this.host.emit('hub:daily-error', { day, error: messageOf(error) });
|
|
128
|
+
}
|
|
129
|
+
})();
|
|
130
|
+
this.inFlight = starting;
|
|
131
|
+
await starting;
|
|
132
|
+
if (this.inFlight === starting) {
|
|
133
|
+
this.inFlight = null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Pick up a day in progress by its saved game's id (from `hub.saved`):
|
|
139
|
+
* a fresh token, then the game's `play` with `challenge.saved`. Finishing
|
|
140
|
+
* it any time later gives that day's credit. A no-op while that day is
|
|
141
|
+
* already being played.
|
|
142
|
+
*/
|
|
143
|
+
async resume(instanceId: string): Promise<void> {
|
|
144
|
+
// A start may be on its way for the same day (the `?daily=` link of the same open): let it land first.
|
|
145
|
+
await this.inFlight;
|
|
146
|
+
if (this.active?.instanceId === instanceId) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const play = this.play;
|
|
150
|
+
if (play === null) {
|
|
151
|
+
this.pendingInstance = instanceId;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
102
154
|
try {
|
|
103
|
-
|
|
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 });
|
|
155
|
+
this.begin(play, challengeOf(await this.host.resume(instanceId)));
|
|
108
156
|
} catch (error) {
|
|
109
|
-
this.host.emit('hub:daily-error', { day, error: messageOf(error) });
|
|
157
|
+
this.host.emit('hub:daily-error', { day: '', error: messageOf(error) });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Make `challenge` the active day and hand it to the game. */
|
|
162
|
+
private begin(play: DailyDefinition['play'], challenge: DailyChallenge): void {
|
|
163
|
+
this.active = challenge;
|
|
164
|
+
this.host.emit('hub:daily-started', { day: challenge.day, seed: challenge.seed, alreadyCompleted: challenge.alreadyCompleted === true, resumed: challenge.resumed === true });
|
|
165
|
+
if (challenge.resumed === true) {
|
|
166
|
+
this.host.emit('hub:daily-resumed', { day: challenge.day, seed: challenge.seed, alreadyCompleted: false, resumed: true });
|
|
110
167
|
}
|
|
168
|
+
play({ ...challenge });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Save the active day's state, so it can be picked up later (a reload,
|
|
173
|
+
* another device, next week) and still give that day's credit. Kept on
|
|
174
|
+
* the device at once and sent after a short quiet spell (works offline).
|
|
175
|
+
* `hub.resume.save` during a daily lands here too. Returns `false` with
|
|
176
|
+
* no daily active, or for a replay of a completed day (nothing to save).
|
|
177
|
+
*/
|
|
178
|
+
save(input: DailySaveInput): boolean {
|
|
179
|
+
const challenge = this.active;
|
|
180
|
+
if (challenge === null || challenge.instanceId === null) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
return this.host.save(challenge, input);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** This game's days in progress (the calendar marks them, the game can list them). */
|
|
187
|
+
inProgress(): Promise<SavedEntry[]> {
|
|
188
|
+
return this.host.inProgress();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Give up the active day: its progress is discarded and nothing is
|
|
193
|
+
* recorded. The day can be started again fresh while it is in the 30-day
|
|
194
|
+
* window. (The hub's lists ask the player first; a game asks for itself.)
|
|
195
|
+
*/
|
|
196
|
+
async discard(): Promise<void> {
|
|
197
|
+
const challenge = this.active;
|
|
198
|
+
if (challenge === null) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
this.active = null;
|
|
202
|
+
if (challenge.instanceId !== null) {
|
|
203
|
+
await this.host.discard(challenge);
|
|
204
|
+
}
|
|
205
|
+
this.host.emit('hub:daily-discarded', { day: challenge.day, discarded: true });
|
|
111
206
|
}
|
|
112
207
|
|
|
113
208
|
/**
|
|
114
209
|
* Record completion of the active challenge with optional metadata.
|
|
115
210
|
* Resolves with the new completion count and rank, or `null` when no
|
|
116
|
-
* challenge is active or the hub
|
|
211
|
+
* challenge is active, the hub refused, or the hub can't be reached: then
|
|
212
|
+
* the completion is queued (`hub:daily-queued`) and replays when it is
|
|
213
|
+
* back (`hub:daily-complete` follows). The daily run ends at the call,
|
|
214
|
+
* so `active` is `null` at once, even if the hub then refuses it.
|
|
117
215
|
*/
|
|
118
216
|
async complete(meta: DailyCompletionMeta = {}): Promise<DailyCompleteResponse | null> {
|
|
119
217
|
const challenge = this.active;
|
|
120
218
|
if (challenge === null) {
|
|
121
219
|
return null;
|
|
122
220
|
}
|
|
221
|
+
this.active = null;
|
|
123
222
|
try {
|
|
124
223
|
const completed = await this.host.complete(challenge.day, challenge.token, meta);
|
|
125
|
-
|
|
224
|
+
if (completed === null) {
|
|
225
|
+
// Queued offline: `hub:daily-complete` follows when it replays.
|
|
226
|
+
this.host.emit('hub:daily-queued', { day: challenge.day, queued: true, meta });
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
126
229
|
this.host.emit('hub:daily-complete', { day: challenge.day, count: completed.count, rank: completed.rank, meta });
|
|
127
230
|
return completed;
|
|
128
231
|
} catch (error) {
|
|
@@ -144,9 +247,21 @@ export class DailySystem {
|
|
|
144
247
|
}
|
|
145
248
|
}
|
|
146
249
|
|
|
147
|
-
/**
|
|
250
|
+
/**
|
|
251
|
+
* Pause the active day: the player left it unfinished (quit, picked
|
|
252
|
+
* another level, started a normal game). Nothing is recorded, and the day
|
|
253
|
+
* stays in progress in "Your games" and the calendar, to finish later
|
|
254
|
+
* (its last save is sent now). Until then the page counts as in a daily,
|
|
255
|
+
* so `hub.resume.save` saves to the day: call `cancel()` on every path
|
|
256
|
+
* that leaves a daily without `complete`. To give a day up, `discard()`.
|
|
257
|
+
*/
|
|
148
258
|
cancel(): void {
|
|
259
|
+
const challenge = this.active;
|
|
149
260
|
this.active = null;
|
|
261
|
+
if (challenge !== null) {
|
|
262
|
+
void this.host.flush().catch(() => undefined);
|
|
263
|
+
this.host.emit('hub:daily-paused', { day: challenge.day, paused: true });
|
|
264
|
+
}
|
|
150
265
|
}
|
|
151
266
|
|
|
152
267
|
/** This game's window and the player's completions. */
|
package/src/daily/page-daily.ts
CHANGED
|
@@ -8,8 +8,8 @@ import { hasMethods } from '../realtime/cross-bundle';
|
|
|
8
8
|
import { DailySystem } from './daily-system';
|
|
9
9
|
import type { DailyHost } from './types';
|
|
10
10
|
|
|
11
|
-
/** The cross-bundle shape number of the `daily` singleton. Bump only on an incompatible change. */
|
|
12
|
-
export const DAILY_SINGLETON_API =
|
|
11
|
+
/** The cross-bundle shape number of the `daily` singleton (2 added save, resume, discard and inProgress). Bump only on an incompatible change. */
|
|
12
|
+
export const DAILY_SINGLETON_API = 2;
|
|
13
13
|
|
|
14
14
|
/** Whether `candidate` is some bundle's daily system (v1 copies included). */
|
|
15
15
|
// eslint-disable-next-line no-restricted-syntax -- trust boundary: the instance may come from another bundle
|
package/src/daily/types.ts
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
* answers (typed from the contract's daily endpoints).
|
|
4
4
|
*/
|
|
5
5
|
import type { dailyEndpoints, Json } from '@diffenderfer-games/hub-contract';
|
|
6
|
+
import type { GamePayload } from '../core/game-data';
|
|
7
|
+
import type { SavedEntry } from '../core/saved/saved-types';
|
|
6
8
|
import type { SchemaOutput } from '../realtime/wire-types';
|
|
7
9
|
|
|
8
10
|
/** The response type of a daily endpoint. */
|
|
9
11
|
type DailyResponse<Name extends keyof typeof dailyEndpoints> = SchemaOutput<(typeof dailyEndpoints)[Name]['cfg']['response']>;
|
|
10
12
|
|
|
11
|
-
/** This game's daily window
|
|
13
|
+
/** This game's daily window, the player's completions and days in progress (`GET /games/:slug/daily`). */
|
|
12
14
|
export type DailyStatus = DailyResponse<'getGameDailyChallenges'>;
|
|
13
15
|
|
|
14
16
|
/** Every daily game and the global standings (`GET /daily`). */
|
|
@@ -17,7 +19,7 @@ export type DailyAll = DailyResponse<'listDailyChallengesForAllGames'>;
|
|
|
17
19
|
/** One game's row in {@link DailyAll}. */
|
|
18
20
|
export type DailyGameStatus = DailyAll['games'][number];
|
|
19
21
|
|
|
20
|
-
/** The answer to starting a day: its seed
|
|
22
|
+
/** The answer to starting (or resuming) a day: its seed, the signed token to present on completion, and its saved game. */
|
|
21
23
|
export type DailyStartResponse = DailyResponse<'startDailyChallenge'>;
|
|
22
24
|
|
|
23
25
|
/** The answer to completing a day: the player's new completion count and rank. */
|
|
@@ -25,26 +27,49 @@ export type DailyCompleteResponse = DailyResponse<'completeDailyChallenge'>;
|
|
|
25
27
|
|
|
26
28
|
/** The challenge a game's `play` receives: set up and start exactly this day (derive the level from `seed`). */
|
|
27
29
|
export interface DailyChallenge {
|
|
28
|
-
/** The day, `YYYY-MM-DD
|
|
30
|
+
/** The day, `YYYY-MM-DD` (a UTC day key). */
|
|
29
31
|
day: string;
|
|
30
32
|
/** Deterministic per game and day. */
|
|
31
33
|
seed: number;
|
|
32
|
-
/** The signed token `complete` presents. */
|
|
34
|
+
/** The signed token `complete` presents (empty for a day started offline: it is fetched when the hub is back). */
|
|
33
35
|
token: string;
|
|
34
|
-
/** The player already completed this day (a replay). */
|
|
36
|
+
/** The player already completed this day (a replay for fun: nothing is saved or recorded). */
|
|
35
37
|
alreadyCompleted?: boolean;
|
|
38
|
+
/** The day's saved game; `null` for a replay of a completed day. */
|
|
39
|
+
instanceId: string | null;
|
|
40
|
+
/**
|
|
41
|
+
* The state last saved for this day (`hub.daily.save`, or `hub.resume.save`
|
|
42
|
+
* during the daily), or `null` when the day starts fresh. A game that
|
|
43
|
+
* saves restores from it: `if (challenge.saved) startFrom(challenge.saved)`.
|
|
44
|
+
*/
|
|
45
|
+
saved: GamePayload | null;
|
|
46
|
+
/** The game's state version of `saved` (0 when none). */
|
|
47
|
+
savedVersion: number;
|
|
48
|
+
/** The day was already in progress and was picked up again. */
|
|
49
|
+
resumed?: boolean;
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
/** What `hub.daily.define` takes. */
|
|
39
53
|
export interface DailyDefinition {
|
|
40
|
-
/** Start this challenge now. */
|
|
54
|
+
/** Start this challenge now (restore `challenge.saved` when it has one). */
|
|
41
55
|
play: (challenge: DailyChallenge) => void;
|
|
42
56
|
}
|
|
43
57
|
|
|
44
58
|
/** Metadata a game records with a completion (`{ score, difficulty, timeMs, … }`). */
|
|
45
59
|
export type DailyCompletionMeta = Record<string, Json>;
|
|
46
60
|
|
|
47
|
-
/**
|
|
61
|
+
/** A daily state save (the same shape as `hub.resume.save`). */
|
|
62
|
+
export interface DailySaveInput {
|
|
63
|
+
/** Everything needed to rebuild the day's run (up to 512 KB as JSON). */
|
|
64
|
+
readonly state: GamePayload;
|
|
65
|
+
/** A short line ("34 cards left") and an optional 0 to 1 fraction. */
|
|
66
|
+
readonly progress: { readonly label: string; readonly fraction?: number | null };
|
|
67
|
+
readonly title?: string | null;
|
|
68
|
+
/** The game's state version (default 0). */
|
|
69
|
+
readonly version?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What the daily system needs from the hub client: the REST calls, the device copy and its window events. */
|
|
48
73
|
export interface DailyHost {
|
|
49
74
|
/** This page's game slug. */
|
|
50
75
|
readonly slug: string | null;
|
|
@@ -52,16 +77,29 @@ export interface DailyHost {
|
|
|
52
77
|
status(): Promise<DailyStatus>;
|
|
53
78
|
/** `GET /daily`. */
|
|
54
79
|
all(): Promise<DailyAll>;
|
|
55
|
-
/** `POST /games/:slug/daily/:day/start
|
|
80
|
+
/** Start (or pick up) a day: `POST /games/:slug/daily/:day/start`, or a device-only start while offline. */
|
|
56
81
|
start(day: string): Promise<DailyStartResponse>;
|
|
57
|
-
/**
|
|
58
|
-
|
|
82
|
+
/** Pick a day in progress up again by its saved game's id. */
|
|
83
|
+
resume(instanceId: string): Promise<DailyStartResponse>;
|
|
84
|
+
/** `POST /games/:slug/daily/:day/complete`; `null` when it was queued offline, to replay when the hub is back. */
|
|
85
|
+
complete(day: string, token: string, meta: DailyCompletionMeta): Promise<DailyCompleteResponse | null>;
|
|
86
|
+
/** Keep the day's state on the device at once and send it soon (works offline); `false` when it can't be stored. */
|
|
87
|
+
save(challenge: DailyChallenge, input: DailySaveInput): boolean;
|
|
88
|
+
/** Send a waiting save now. */
|
|
89
|
+
flush(): Promise<void>;
|
|
90
|
+
/** Give the day up: discard its saved game (works offline). */
|
|
91
|
+
discard(challenge: DailyChallenge): Promise<void>;
|
|
92
|
+
/** This game's days in progress (the saved games of kind daily). */
|
|
93
|
+
inProgress(): Promise<SavedEntry[]>;
|
|
59
94
|
/** Fire a `hub:*` window event (the menu listens). */
|
|
60
95
|
emit(name: string, detail: DailyEventDetail): void;
|
|
61
96
|
}
|
|
62
97
|
|
|
63
98
|
/** The detail of the `hub:daily-*` window events. */
|
|
64
99
|
export type DailyEventDetail =
|
|
65
|
-
| { day: string; seed: number; alreadyCompleted: boolean }
|
|
100
|
+
| { day: string; seed: number; alreadyCompleted: boolean; resumed?: boolean }
|
|
66
101
|
| { day: string; count: number; rank: number | null; meta: DailyCompletionMeta }
|
|
102
|
+
| { day: string; queued: true; meta: DailyCompletionMeta }
|
|
103
|
+
| { day: string; paused: true }
|
|
104
|
+
| { day: string; discarded: true }
|
|
67
105
|
| { day: string; error: string };
|
package/src/doctor/checks.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
-
import { GamePackageJson, type HubVersion } from '@diffenderfer-games/hub-contract';
|
|
7
|
+
import { GAME_LABELS, GamePackageJson, splitGameLabels, type HubVersion } from '@diffenderfer-games/hub-contract';
|
|
8
8
|
import { clientSupport } from '../core/version/hub-version';
|
|
9
9
|
import { isHubUnreachable, type HubUnreachable } from './hub-version-fetch';
|
|
10
10
|
|
|
@@ -15,8 +15,8 @@ export type FindingLevel = 'error' | 'warning' | 'info';
|
|
|
15
15
|
export interface Finding {
|
|
16
16
|
/** How serious it is. */
|
|
17
17
|
readonly level: FindingLevel;
|
|
18
|
-
/** Which check found it: `version`, `vendored` or `
|
|
19
|
-
readonly check: 'version' | 'vendored' | 'manifest';
|
|
18
|
+
/** Which check found it: `version`, `vendored`, `manifest`, `world` or `embed`. */
|
|
19
|
+
readonly check: 'version' | 'vendored' | 'manifest' | 'world' | 'embed';
|
|
20
20
|
/** What to tell the developer, with the fix. */
|
|
21
21
|
readonly message: string;
|
|
22
22
|
}
|
|
@@ -68,7 +68,15 @@ export function checkVendoredCopies(gameDir: string): Finding[] {
|
|
|
68
68
|
export function checkManifest(packageJson: unknown): Finding[] {
|
|
69
69
|
const parsed = GamePackageJson.safeParse(packageJson);
|
|
70
70
|
if (parsed.success) {
|
|
71
|
-
|
|
71
|
+
const unknownLabels = splitGameLabels(parsed.data.game.labels ?? []).unknown;
|
|
72
|
+
return [
|
|
73
|
+
{ level: 'info', check: 'manifest', message: `the game.* block is valid (${parsed.data.game.type})` },
|
|
74
|
+
...unknownLabels.map((label): Finding => ({
|
|
75
|
+
level: 'warning',
|
|
76
|
+
check: 'manifest',
|
|
77
|
+
message: `game.labels: "${label}" is not a known label (the catalog ignores it); use any of ${GAME_LABELS.join(', ')}`,
|
|
78
|
+
})),
|
|
79
|
+
];
|
|
72
80
|
}
|
|
73
81
|
return parsed.error.issues.map((issue) => ({
|
|
74
82
|
level: 'error',
|
package/src/doctor/cli.ts
CHANGED
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
* `/_api/version` (`--hub`, default `https://diffenderfer.games`): outside
|
|
12
12
|
* `supported` fails, `deprecated` warns (`--offline` skips this check);
|
|
13
13
|
* - a stale vendored `src/hub/` copy or `scripts/sync-hub.mjs` warns;
|
|
14
|
-
* - the `package.json` `game.*` block must match the contract's schema
|
|
14
|
+
* - the `package.json` `game.*` block must match the contract's schema;
|
|
15
|
+
* - a `game.world.map` file must exist and be a valid `dg-world@1` map within the size limit;
|
|
16
|
+
* - a `game.embed.frame` must exist and be a valid, safe frame SVG within the size limit, its screen
|
|
17
|
+
* shaped like `game.embed.size` (docs/hub.md "Playing over the world").
|
|
15
18
|
*
|
|
16
19
|
* Exits 1 when any check fails.
|
|
17
20
|
*/
|
|
@@ -21,6 +24,8 @@ import { parseArgs } from 'node:util';
|
|
|
21
24
|
import { HUB_CLIENT_VERSION } from '../core/version/client-version.gen';
|
|
22
25
|
import { checkClientVersion, checkManifest, checkVendoredCopies, type Finding } from './checks';
|
|
23
26
|
import { DEFAULT_HUB_TIMEOUT_MS, fetchHubVersion } from './hub-version-fetch';
|
|
27
|
+
import { checkEmbed } from './embed-check';
|
|
28
|
+
import { checkWorld } from './world-check';
|
|
24
29
|
|
|
25
30
|
const DEFAULT_HUB_URL = 'https://diffenderfer.games';
|
|
26
31
|
|
|
@@ -79,7 +84,8 @@ async function runDoctor(options: DoctorOptions): Promise<Finding[]> {
|
|
|
79
84
|
}
|
|
80
85
|
findings.push(...checkVendoredCopies(options.gameDir));
|
|
81
86
|
try {
|
|
82
|
-
|
|
87
|
+
const packageJson = readJsonFile(join(options.gameDir, 'package.json'));
|
|
88
|
+
findings.push(...checkManifest(packageJson), ...checkWorld(options.gameDir, packageJson), ...checkEmbed(options.gameDir, packageJson));
|
|
83
89
|
} catch (failure) {
|
|
84
90
|
findings.push({ level: 'error', check: 'manifest', message: `could not read package.json: ${failure instanceof Error ? failure.message : String(failure)}` });
|
|
85
91
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hub-doctor`'s embed check (docs/hub.md "Playing over the world"): a
|
|
3
|
+
* `game.embed.frame` must exist inside the game, be within the size limit
|
|
4
|
+
* and pass the same sanitiser the hub runs at load (a valid SVG with one
|
|
5
|
+
* screen rect, nothing that runs or fetches anything); its screen should be
|
|
6
|
+
* shaped like `game.embed.size`.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { FRAME_LIMITS, GamePackageJson, embedSizeOf, frameAspectMismatch, sanitizeFrameSvg } from '@diffenderfer-games/hub-contract';
|
|
11
|
+
import type { Finding } from './checks';
|
|
12
|
+
|
|
13
|
+
/** How far the screen's shape may be from `game.embed.size` before a warning. */
|
|
14
|
+
const ASPECT_TOLERANCE = 0.03;
|
|
15
|
+
|
|
16
|
+
/** Checks the frame `game.embed` names. A manifest that doesn't parse is left to `checkManifest`. */
|
|
17
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: the game's package.json, parsed by GamePackageJson next
|
|
18
|
+
export function checkEmbed(gameDir: string, packageJson: unknown): Finding[] {
|
|
19
|
+
const parsed = GamePackageJson.safeParse(packageJson);
|
|
20
|
+
const embed = parsed.success ? parsed.data.game.embed : undefined;
|
|
21
|
+
if (embed === undefined) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
const size = embedSizeOf(embed.size);
|
|
25
|
+
const summary = `game.embed: ${embed.supported ? 'plays over the world' : 'opens as its own page from the world'} at ${String(size.w)} × ${String(size.h)}`;
|
|
26
|
+
if (embed.frame === undefined) {
|
|
27
|
+
return [{ level: 'info', check: 'embed', message: `${summary}, in the hub's bezel` }];
|
|
28
|
+
}
|
|
29
|
+
return [{ level: 'info', check: 'embed', message: summary }, ...checkFrameFile(gameDir, embed.frame, size)];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One frame file's findings. */
|
|
33
|
+
export function checkFrameFile(gameDir: string, frameFile: string, size: { readonly w: number; readonly h: number }): Finding[] {
|
|
34
|
+
const path = join(gameDir, frameFile);
|
|
35
|
+
const label = `game.embed.frame (${frameFile})`;
|
|
36
|
+
const error = (message: string): Finding => ({ level: 'error', check: 'embed', message: `${label}: ${message}` });
|
|
37
|
+
if (!existsSync(path) || !statSync(path).isFile()) {
|
|
38
|
+
return [error('the file does not exist (commit it)')];
|
|
39
|
+
}
|
|
40
|
+
const bytes = statSync(path).size;
|
|
41
|
+
if (bytes > FRAME_LIMITS.bytes) {
|
|
42
|
+
return [error(`${String(bytes)} bytes is over the ${String(FRAME_LIMITS.bytes)}-byte limit (simplify the paths or drop embedded data)`)];
|
|
43
|
+
}
|
|
44
|
+
const result = sanitizeFrameSvg(readFileSync(path, 'utf8'));
|
|
45
|
+
const listed = result.problems.map((problem): Finding => ({ level: problem.severity, check: 'embed', message: `${label}: ${problem.message}` }));
|
|
46
|
+
if (!result.ok) {
|
|
47
|
+
return listed;
|
|
48
|
+
}
|
|
49
|
+
const { viewBox, screen } = result.geometry;
|
|
50
|
+
const mismatch = frameAspectMismatch(result.geometry, size);
|
|
51
|
+
if (mismatch > ASPECT_TOLERANCE) {
|
|
52
|
+
listed.push({
|
|
53
|
+
level: 'warning', check: 'embed',
|
|
54
|
+
message: `${label}: the screen is ${screen.w.toFixed(0)} × ${screen.h.toFixed(0)}, ${(mismatch * 100).toFixed(0)} % off the shape of game.embed.size (${String(size.w)} × ${String(size.h)}); the game will be shown at the screen's shape`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return [...listed, {
|
|
58
|
+
level: 'info', check: 'embed',
|
|
59
|
+
message: `${label} is a valid frame: viewBox ${String(viewBox.w)} × ${String(viewBox.h)}, screen at ${String(screen.x)}, ${String(screen.y)} (${String(screen.w)} × ${String(screen.h)}), ${String(bytes)} bytes`,
|
|
60
|
+
}];
|
|
61
|
+
}
|
|
@@ -1,43 +1,43 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reading the hub's `GET /_api/version` for `hub-doctor`, with a timeout so a
|
|
3
|
-
* hung or unreachable hub fails a game's CI quickly instead of stalling it.
|
|
4
|
-
*/
|
|
5
|
-
import { HubVersion } from '@diffenderfer-games/hub-contract';
|
|
6
|
-
|
|
7
|
-
/** How long the doctor waits for the hub by default, ms. */
|
|
8
|
-
export const DEFAULT_HUB_TIMEOUT_MS = 10_000;
|
|
9
|
-
|
|
10
|
-
/** The hub could not be asked: down, slow, or answering with an error. */
|
|
11
|
-
export interface HubUnreachable {
|
|
12
|
-
/** The URL that was asked. */
|
|
13
|
-
readonly unreachableUrl: string;
|
|
14
|
-
/** Why, for the message (`timed out after 10000 ms`, `HTTP 502`, a network error). */
|
|
15
|
-
readonly reason: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** Whether a version lookup failed. */
|
|
19
|
-
export function isHubUnreachable(lookup: HubVersion | null | HubUnreachable): lookup is HubUnreachable {
|
|
20
|
-
return lookup !== null && 'unreachableUrl' in lookup;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* The hub's version: `null` for a hub from before versioning (404), a
|
|
25
|
-
* {@link HubUnreachable} when it can't be read within `timeoutMs`.
|
|
26
|
-
*/
|
|
27
|
-
export async function fetchHubVersion(hubUrl: string, timeoutMs: number = DEFAULT_HUB_TIMEOUT_MS): Promise<HubVersion | null | HubUnreachable> {
|
|
28
|
-
const url = new URL('/_api/version', hubUrl).toString();
|
|
29
|
-
try {
|
|
30
|
-
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
31
|
-
if (response.status === 404) {
|
|
32
|
-
return null;
|
|
33
|
-
}
|
|
34
|
-
if (!response.ok) {
|
|
35
|
-
return { unreachableUrl: url, reason: `HTTP ${String(response.status)}` };
|
|
36
|
-
}
|
|
37
|
-
return HubVersion.parse(await response.json());
|
|
38
|
-
} catch (failure) {
|
|
39
|
-
const isTimeout = failure instanceof Error && (failure.name === 'TimeoutError' || failure.name === 'AbortError');
|
|
40
|
-
const reason = isTimeout ? `timed out after ${String(timeoutMs)} ms` : failure instanceof Error ? failure.message : String(failure);
|
|
41
|
-
return { unreachableUrl: url, reason };
|
|
42
|
-
}
|
|
43
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Reading the hub's `GET /_api/version` for `hub-doctor`, with a timeout so a
|
|
3
|
+
* hung or unreachable hub fails a game's CI quickly instead of stalling it.
|
|
4
|
+
*/
|
|
5
|
+
import { HubVersion } from '@diffenderfer-games/hub-contract';
|
|
6
|
+
|
|
7
|
+
/** How long the doctor waits for the hub by default, ms. */
|
|
8
|
+
export const DEFAULT_HUB_TIMEOUT_MS = 10_000;
|
|
9
|
+
|
|
10
|
+
/** The hub could not be asked: down, slow, or answering with an error. */
|
|
11
|
+
export interface HubUnreachable {
|
|
12
|
+
/** The URL that was asked. */
|
|
13
|
+
readonly unreachableUrl: string;
|
|
14
|
+
/** Why, for the message (`timed out after 10000 ms`, `HTTP 502`, a network error). */
|
|
15
|
+
readonly reason: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Whether a version lookup failed. */
|
|
19
|
+
export function isHubUnreachable(lookup: HubVersion | null | HubUnreachable): lookup is HubUnreachable {
|
|
20
|
+
return lookup !== null && 'unreachableUrl' in lookup;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The hub's version: `null` for a hub from before versioning (404), a
|
|
25
|
+
* {@link HubUnreachable} when it can't be read within `timeoutMs`.
|
|
26
|
+
*/
|
|
27
|
+
export async function fetchHubVersion(hubUrl: string, timeoutMs: number = DEFAULT_HUB_TIMEOUT_MS): Promise<HubVersion | null | HubUnreachable> {
|
|
28
|
+
const url = new URL('/_api/version', hubUrl).toString();
|
|
29
|
+
try {
|
|
30
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
31
|
+
if (response.status === 404) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
return { unreachableUrl: url, reason: `HTTP ${String(response.status)}` };
|
|
36
|
+
}
|
|
37
|
+
return HubVersion.parse(await response.json());
|
|
38
|
+
} catch (failure) {
|
|
39
|
+
const isTimeout = failure instanceof Error && (failure.name === 'TimeoutError' || failure.name === 'AbortError');
|
|
40
|
+
const reason = isTimeout ? `timed out after ${String(timeoutMs)} ms` : failure instanceof Error ? failure.message : String(failure);
|
|
41
|
+
return { unreachableUrl: url, reason };
|
|
42
|
+
}
|
|
43
|
+
}
|