@diffenderfer-games/hub 2.0.0-next.5 → 2.0.0-next.7
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 +65 -19
- package/README.md +3 -6
- package/dist/auto.d.ts +5 -5
- package/dist/auto.js +2 -2
- package/dist/avatar-pixi.d.ts +147 -0
- package/dist/avatar-pixi.d.ts.map +1 -0
- package/dist/avatar-pixi.js +280 -0
- package/dist/avatar-pixi.js.map +1 -0
- package/dist/avatar-svg.d.ts +4 -0
- package/dist/avatar-svg.js +5 -0
- package/dist/avatar-three.d.ts +69 -0
- package/dist/avatar-three.d.ts.map +1 -0
- package/dist/avatar-three.js +49 -0
- package/dist/avatar-three.js.map +1 -0
- package/dist/avatar.d.ts +7 -0
- package/dist/avatar.js +487 -0
- package/dist/avatar.js.map +1 -0
- package/dist/daily.d.ts +2 -2
- package/dist/doctor/cli.mjs +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/input.d.ts +2 -2
- package/dist/shared/animator.d.ts +104 -0
- package/dist/shared/animator.d.ts.map +1 -0
- package/dist/shared/art.js +1946 -0
- package/dist/shared/art.js.map +1 -0
- package/dist/shared/atlas.d.ts +134 -0
- package/dist/shared/atlas.d.ts.map +1 -0
- package/dist/shared/avatar.d.ts +117 -0
- package/dist/shared/avatar.d.ts.map +1 -0
- package/dist/shared/bridge.js +13 -5
- package/dist/shared/bridge.js.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.d.ts +129 -18
- package/dist/shared/daily.d.ts.map +1 -1
- package/dist/shared/daily.js +120 -26
- package/dist/shared/daily.js.map +1 -1
- package/dist/shared/defineProperty.js +39 -0
- package/dist/shared/describe.d.ts +32 -0
- package/dist/shared/describe.d.ts.map +1 -0
- package/dist/shared/describe.js +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 +503 -14
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/input.d.ts +31 -13
- package/dist/shared/input.d.ts.map +1 -1
- package/dist/shared/input.js +40 -2
- package/dist/shared/input.js.map +1 -1
- package/dist/shared/json-guards.js +48 -0
- package/dist/shared/json-guards.js.map +1 -0
- package/dist/shared/live-canvas.js +62 -0
- package/dist/shared/live-canvas.js.map +1 -0
- package/dist/shared/mp.js +1 -1
- package/dist/shared/overlays.js +800 -0
- package/dist/shared/overlays.js.map +1 -0
- package/dist/shared/page-stack.js +2 -1
- package/dist/shared/page-stack.js.map +1 -1
- package/dist/shared/pose-key.js +249 -0
- package/dist/shared/pose-key.js.map +1 -0
- package/dist/shared/race.js +1 -1
- package/dist/shared/rooms.js +1 -1
- package/dist/shared/scene.d.ts +90 -0
- package/dist/shared/scene.d.ts.map +1 -0
- package/dist/shared/social.d.ts +55 -4
- package/dist/shared/social.d.ts.map +1 -1
- package/dist/shared/src.js +2020 -144
- package/dist/shared/src.js.map +1 -1
- package/dist/shared/svg-markup.js +1945 -0
- package/dist/shared/svg-markup.js.map +1 -0
- package/dist/shared/svg.js +27 -0
- package/dist/shared/svg.js.map +1 -0
- package/dist/shared/types.d.ts +166 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/social.d.ts +2 -2
- package/dist/social.js +2 -2
- package/dist/turns.d.ts +39 -0
- package/dist/turns.d.ts.map +1 -0
- package/dist/turns.js +117 -0
- package/dist/turns.js.map +1 -0
- package/package.json +33 -3
- package/src/avatar/adapters/pixi-avatar.ts +73 -0
- package/src/avatar/adapters/pixi-crowd.ts +302 -0
- package/src/avatar/adapters/pixi-types.ts +84 -0
- package/src/avatar/adapters/three.ts +126 -0
- package/src/avatar/animator/animator.ts +285 -0
- package/src/avatar/animator/reactions.ts +44 -0
- package/src/avatar/animator/schedule.ts +73 -0
- package/src/avatar/art/beard.ts +49 -0
- package/src/avatar/art/glasses.ts +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 +236 -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 +303 -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 +60 -0
- package/src/avatar/render/head.ts +81 -0
- package/src/avatar/render/mouth.ts +86 -0
- package/src/avatar/render/props.ts +91 -0
- package/src/avatar/render/types.ts +84 -0
- package/src/avatar/scene.ts +119 -0
- package/src/avatar-pixi.ts +20 -0
- package/src/avatar-svg.ts +21 -0
- package/src/avatar-three.ts +18 -0
- package/src/avatar.ts +62 -0
- package/src/core/avatar/avatar-cache.ts +95 -0
- package/src/core/avatar/avatar-ref.ts +34 -0
- package/src/core/avatar/avatar-types.ts +43 -0
- package/src/core/avatar/hub-avatar.ts +308 -0
- package/src/core/connection.ts +8 -0
- package/src/core/errors.ts +7 -1
- package/src/core/hub-progress.ts +39 -8
- package/src/core/hub.ts +174 -6
- package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
- package/src/core/offline/hub-kinds/index.ts +9 -0
- package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
- package/src/core/page-account.ts +121 -0
- package/src/core/rest/routes.gen.ts +31 -1
- package/src/core/resume/hub-resume.ts +39 -5
- package/src/core/resume/resume-types.ts +2 -2
- package/src/core/saved/daily-saves.ts +196 -0
- package/src/core/saved/daily-seed.ts +16 -0
- package/src/core/saved/hub-saved.ts +221 -0
- package/src/core/saved/local-saved.ts +122 -0
- package/src/core/saved/saved-list.ts +129 -0
- package/src/core/saved/saved-open.ts +104 -0
- package/src/core/saved/saved-order.ts +49 -0
- package/src/core/saved/saved-types.ts +24 -0
- package/src/core/turns/hub-turns.ts +140 -0
- package/src/core/turns/turn-game.ts +388 -0
- package/src/core/version/client-version.gen.ts +2 -2
- package/src/daily/daily-system.ts +112 -13
- package/src/daily/page-daily.ts +2 -2
- package/src/daily/types.ts +46 -9
- package/src/doctor/hub-version-fetch.ts +43 -43
- package/src/index.ts +10 -1
- package/src/input/input-system.ts +10 -1
- package/src/input/navigator.ts +11 -0
- package/src/input.ts +1 -0
- package/src/overlay/overlay.ts +25 -1
- package/src/realtime/runtime-api.ts +14 -3
- package/src/social.ts +1 -0
- package/src/turns-kit/check-rules.ts +182 -0
- package/src/turns.ts +13 -0
package/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,17 +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>;
|
|
82
|
+
/** Pick a day in progress up again by its saved game's id. */
|
|
83
|
+
resume(instanceId: string): Promise<DailyStartResponse>;
|
|
57
84
|
/** `POST /games/:slug/daily/:day/complete`; `null` when it was queued offline, to replay when the hub is back. */
|
|
58
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 }
|
|
67
102
|
| { day: string; queued: true; meta: DailyCompletionMeta }
|
|
103
|
+
| { day: string; paused: true }
|
|
104
|
+
| { day: string; discarded: true }
|
|
68
105
|
| { day: string; error: string };
|
|
@@ -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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* queue their own operations with `hub.offline` (docs: "Offline and replay").
|
|
18
18
|
*
|
|
19
19
|
* The page-wide namespaces hang off `hub` as lazy getters (`hub.input`,
|
|
20
|
-
* `hub.daily`, `hub.resume`, `hub.social`, `hub.mp`, `hub.rooms`, `hub.race`), and each has
|
|
20
|
+
* `hub.daily`, `hub.resume`, `hub.saved`, `hub.social`, `hub.mp`, `hub.rooms`, `hub.race`), and each has
|
|
21
21
|
* its own entry (`@diffenderfer-games/hub/input`, ...) for games that want
|
|
22
22
|
* only that part.
|
|
23
23
|
*
|
|
@@ -38,7 +38,16 @@ export type { AuthResponse, ProfileUpdate, StatResult } from './core/hub-progres
|
|
|
38
38
|
export type { GrantBundle, TradeBundle, TradeId, TradeListOptions, TradeNoteOptions } from './core/hub-economy';
|
|
39
39
|
export type { HubMenuControl } from './core/menu-control';
|
|
40
40
|
export { HubResume, RESUME_DEBOUNCE_MS, RESUME_MAX_STATE_BYTES } from './core/resume/hub-resume';
|
|
41
|
+
export { HubAvatar, type AvatarHost, type AvatarModule } from './core/avatar/hub-avatar';
|
|
42
|
+
export type { AvatarChange, AvatarRef, DesignerResult, OpenDesignerOptions, RuntimeAvatarApi } from './core/avatar/avatar-types';
|
|
43
|
+
export type { CarriesLook } from './core/avatar/avatar-ref';
|
|
41
44
|
export type { ResumeBlocker, ResumeEntry, ResumeLoadOptions, ResumeProgress, ResumeRun, ResumeSaveInput } from './core/resume/resume-types';
|
|
45
|
+
export { HubSaved, SAVED_CHANGE_QUIET_MS, type SavedChangeListener, type SavedOpenDetail, type SavedOpenHandler } from './core/saved/hub-saved';
|
|
46
|
+
export { SAVED_CHANGE_EVENT, SAVED_KIND_PARAM, SAVED_OPEN_EVENT, SAVED_OPEN_PARAM, savedGameUrl } from './core/saved/saved-open';
|
|
47
|
+
export type * from './core/saved/saved-types';
|
|
48
|
+
export { HubTurns, TURN_LINK_PARAM, type TurnCreateOptions } from './core/turns/hub-turns';
|
|
49
|
+
export { TURN_CHAT_EVENT, TURN_EVENT, TURN_POLL_MS, TURN_RESYNC_EVENT, TurnGame, type TurnGameEvents, type TurnMoveEvent, type TurnOutcome } from './core/turns/turn-game';
|
|
50
|
+
export type { OpenTurnGame, TurnChatLine, TurnDeadline, TurnGameView } from '@diffenderfer-games/hub-contract';
|
|
42
51
|
export type { QueueOp, QueueOpInput, QueuedScoreOptions, ProfilePatch } from './core/offline/queue';
|
|
43
52
|
export { OfflineApi } from './core/offline/offline-api';
|
|
44
53
|
export { OUTBOX_FULL_CODE, type OfflineEventDetails, type OfflineEventName, type OfflineListener, type OfflineOp } from './core/offline/outbox';
|
|
@@ -18,7 +18,7 @@ import type { ModalityTracker } from './modality';
|
|
|
18
18
|
import { parseLegacyControls } from './legacy-controls';
|
|
19
19
|
import type { LegacyControls } from './legacy-controls';
|
|
20
20
|
import { Navigator } from './navigator';
|
|
21
|
-
import type { NavigatorContext } from './navigator';
|
|
21
|
+
import type { AutoNavigateTarget, NavigatorContext } from './navigator';
|
|
22
22
|
import { feedDeviceState, OnScreenControls } from './on-screen-controls';
|
|
23
23
|
import type { PageInputControl } from './page-control';
|
|
24
24
|
import { describeBinding, PlayerRemaps } from './player-remaps';
|
|
@@ -286,6 +286,15 @@ export class InputSystem implements ControlsPanelHost, PageInputControl {
|
|
|
286
286
|
this.navigator.setAutoNavigate(Navigator.queryFor(selector), options?.back ?? null);
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
+
/**
|
|
290
|
+
* The game's current `autoNavigate` target (its query and back action), or
|
|
291
|
+
* `null`. Hub UI that takes the gamepad for a while (a dialog) reads it,
|
|
292
|
+
* and gives it back with `autoNavigate(target.query, { back })` when done.
|
|
293
|
+
*/
|
|
294
|
+
autoNavigateTarget(): AutoNavigateTarget | null {
|
|
295
|
+
return this.navigator.autoNavigateTarget();
|
|
296
|
+
}
|
|
297
|
+
|
|
289
298
|
/** Stop navigating the game's own menu. */
|
|
290
299
|
clearAutoNavigate(): void {
|
|
291
300
|
this.navigator.clearAutoNavigate(this.controls.existingOverlay);
|
package/src/input/navigator.ts
CHANGED
|
@@ -40,6 +40,12 @@ function visibleMatches(selector: string): HTMLElement[] {
|
|
|
40
40
|
return [...document.querySelectorAll<HTMLElement>(selector)].filter((element) => element.getClientRects().length > 0);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** What `autoNavigate` was last given: the targets' query and the back action. */
|
|
44
|
+
export interface AutoNavigateTarget {
|
|
45
|
+
readonly query: () => NavTarget[];
|
|
46
|
+
readonly back: (() => void) | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
43
49
|
/** A navigable group with its highlight state. */
|
|
44
50
|
interface GroupNavigation {
|
|
45
51
|
readonly definition: NavigableDef;
|
|
@@ -81,6 +87,11 @@ export class Navigator {
|
|
|
81
87
|
this.autoNavigateState = null;
|
|
82
88
|
}
|
|
83
89
|
|
|
90
|
+
/** The current auto-navigate target and back action, or `null` when none is set. */
|
|
91
|
+
autoNavigateTarget(): AutoNavigateTarget | null {
|
|
92
|
+
return this.autoNavigateQuery === null ? null : { query: this.autoNavigateQuery, back: this.autoNavigateBack };
|
|
93
|
+
}
|
|
94
|
+
|
|
84
95
|
/** Make a CSS selector's visible matches the auto-navigate targets. */
|
|
85
96
|
static queryFor(selector: string | (() => NavTarget[])): () => NavTarget[] {
|
|
86
97
|
return typeof selector === 'string' ? () => visibleMatches(selector) : selector;
|
package/src/input.ts
CHANGED
|
@@ -15,6 +15,7 @@ export type { InputModality } from './input/modality';
|
|
|
15
15
|
export { isPageInputControl } from './input/page-control';
|
|
16
16
|
export type { PageInputControl } from './input/page-control';
|
|
17
17
|
export type * from './input/types';
|
|
18
|
+
export type { AutoNavigateTarget } from './input/navigator';
|
|
18
19
|
export { Overlay } from './overlay/overlay';
|
|
19
20
|
export { layoutControls } from './overlay/layout';
|
|
20
21
|
export type { LayoutOptions } from './overlay/layout';
|
package/src/overlay/overlay.ts
CHANGED
|
@@ -123,9 +123,15 @@ export class Overlay implements NavHighlighter {
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
/**
|
|
126
|
+
/**
|
|
127
|
+
* Game insets (px) auto-placed controls keep clear of, e.g. a top HUD bar.
|
|
128
|
+
* Merged with earlier calls. Also published on the page as
|
|
129
|
+
* `--hub-inset-top` (and right, bottom, left), so the hub's toasts sit
|
|
130
|
+
* below a top HUD too.
|
|
131
|
+
*/
|
|
127
132
|
setInsets(insets: Partial<Insets>): void {
|
|
128
133
|
this.gameInsets = { ...this.gameInsets, ...insets };
|
|
134
|
+
publishInsets(this.gameInsets);
|
|
129
135
|
this.layout();
|
|
130
136
|
}
|
|
131
137
|
|
|
@@ -208,3 +214,21 @@ export class Overlay implements NavHighlighter {
|
|
|
208
214
|
return { top: pixels(style.paddingTop), right: pixels(style.paddingRight), bottom: pixels(style.paddingBottom), left: pixels(style.paddingLeft) };
|
|
209
215
|
}
|
|
210
216
|
}
|
|
217
|
+
|
|
218
|
+
/** Every inset side. */
|
|
219
|
+
const INSET_SIDES: readonly (keyof Insets)[] = ['top', 'right', 'bottom', 'left'];
|
|
220
|
+
|
|
221
|
+
/** The page-wide CSS variables other hub UI (the runtime's toasts) keeps clear of. */
|
|
222
|
+
function publishInsets(insets: Partial<Insets>): void {
|
|
223
|
+
if (typeof document === 'undefined') {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
for (const side of INSET_SIDES) {
|
|
227
|
+
const px = insets[side];
|
|
228
|
+
if (px === undefined) {
|
|
229
|
+
document.documentElement.style.removeProperty(`--hub-inset-${side}`);
|
|
230
|
+
} else {
|
|
231
|
+
document.documentElement.style.setProperty(`--hub-inset-${side}`, `${String(Math.max(0, Math.round(px)))}px`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* the page, and that `hub.presence`, `hub.social`, `hub.mp`, `hub.rooms`,
|
|
4
4
|
* `hub.notify` and `hub.race` forward to.
|
|
5
5
|
*/
|
|
6
|
+
import type { RuntimeAvatarApi } from '../core/avatar/avatar-types';
|
|
6
7
|
import type { HubMpApi, InviteLinkMethods } from './multiplayer-types';
|
|
7
8
|
import type { HubRaceApi } from './race-types';
|
|
8
9
|
import type { HubRoomsApi, RoomReclaimMethods } from './room-types';
|
|
@@ -11,10 +12,10 @@ import type { HubNotifyApi, HubPresenceApi, HubSocialApi, PlayOnlineMethods } fr
|
|
|
11
12
|
/**
|
|
12
13
|
* The realtime feature version the runtime reports in {@link RealtimeApi.version}.
|
|
13
14
|
* 2 added races; 3 added invite links and taking back a held seat; 4 added
|
|
14
|
-
* "Play online" (tournaments)
|
|
15
|
-
* it (or by the optional method).
|
|
15
|
+
* "Play online" (tournaments); 5 added avatars (the designer and `ev:avatar`).
|
|
16
|
+
* A client feature-detects newer additions with it (or by the optional method).
|
|
16
17
|
*/
|
|
17
|
-
export const REALTIME_FEATURE_VERSION =
|
|
18
|
+
export const REALTIME_FEATURE_VERSION = 5;
|
|
18
19
|
|
|
19
20
|
/** `hub.social` as a runtime installs it: version 4's `openPlayOnline` is missing on older runtimes. */
|
|
20
21
|
export type RuntimeSocialApi = Omit<HubSocialApi, keyof PlayOnlineMethods> & Partial<PlayOnlineMethods>;
|
|
@@ -35,6 +36,14 @@ export const REALTIME_SINGLETON_API = 1;
|
|
|
35
36
|
/** The window event the runtime fires once it has installed itself. */
|
|
36
37
|
export const REALTIME_READY_EVENT = 'hub:rt-ready';
|
|
37
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The window event the runtime fires each time its socket says hello (it
|
|
41
|
+
* connected, or connected again after a drop), with `{ user: { id, look } }`
|
|
42
|
+
* from the hello snapshot. `hub.avatar` and `hub.userId()` follow it; changes
|
|
43
|
+
* sent while the socket was down are fetched again.
|
|
44
|
+
*/
|
|
45
|
+
export const SESSION_EVENT = 'hub:session';
|
|
46
|
+
|
|
38
47
|
/** The window event the menu fires when the runtime failed to load (its reason is on `window.__HUB_RT_ERROR__`). */
|
|
39
48
|
export const REALTIME_FAILED_EVENT = 'hub:rt-failed';
|
|
40
49
|
|
|
@@ -57,4 +66,6 @@ export interface RealtimeApi {
|
|
|
57
66
|
notify: HubNotifyApi;
|
|
58
67
|
/** Races (feature version 2 and later). */
|
|
59
68
|
race?: HubRaceApi;
|
|
69
|
+
/** Avatars: the designer and live changes (feature version 5 and later). */
|
|
70
|
+
avatar?: RuntimeAvatarApi;
|
|
60
71
|
}
|
package/src/social.ts
CHANGED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules conformance kit (docs/multiplayer.md "Turn games: testing"):
|
|
3
|
+
* `checkTurnRules(rules, options)` plays many seeded games through a rules
|
|
4
|
+
* module the way the hub does and throws on the first broken promise:
|
|
5
|
+
*
|
|
6
|
+
* - every game reaches an outcome within `maxMoves`, and `toAct` is never
|
|
7
|
+
* empty before it;
|
|
8
|
+
* - the state survives a JSON round trip after every move, and the same seed
|
|
9
|
+
* plays the same game (no `Math.random`, no hidden globals);
|
|
10
|
+
* - `autoMove` (when the rules have it) is always legal;
|
|
11
|
+
* - no seat's `view` or `eventView` shows another seat's secrets: markers
|
|
12
|
+
* planted with `options.plant` never appear in anyone else's view;
|
|
13
|
+
* - stored fixtures from older versions `migrate` to a state that views;
|
|
14
|
+
* - views, events and moves stay within the hub's size caps.
|
|
15
|
+
*
|
|
16
|
+
* It needs no hub: run it in a game's own unit tests (vitest, node:test).
|
|
17
|
+
*/
|
|
18
|
+
import { createTurnRng, turnRngStateFromSeed, type TurnRng, type TurnRules } from '@diffenderfer-games/hub-contract';
|
|
19
|
+
import { parseJsonText } from '../core/storage/json-guards';
|
|
20
|
+
|
|
21
|
+
/** The hub's caps. */
|
|
22
|
+
const STATE_MAX_BYTES = 64 * 1024;
|
|
23
|
+
const MOVE_MAX_BYTES = 2 * 1024;
|
|
24
|
+
|
|
25
|
+
/** How to check a rules module. */
|
|
26
|
+
export interface TurnRulesCheckOptions<State, Move, Options = never> {
|
|
27
|
+
/** Seat counts to play (default: 2). */
|
|
28
|
+
readonly seats?: readonly number[];
|
|
29
|
+
/** Games per seat count (default: 25). */
|
|
30
|
+
readonly games?: number;
|
|
31
|
+
/** A game that runs longer than this many moves fails (default: 2000). */
|
|
32
|
+
readonly maxMoves?: number;
|
|
33
|
+
/** Options and mode to set games up with. */
|
|
34
|
+
readonly setupOptions?: Options;
|
|
35
|
+
readonly mode?: string | null;
|
|
36
|
+
/** A legal move for `seat` (default: the rules' own `autoMove`). At least one of the two is needed. */
|
|
37
|
+
readonly randomMove?: (state: State, seat: number, rng: TurnRng) => Move;
|
|
38
|
+
/**
|
|
39
|
+
* Put `marker` somewhere only `seat` may see (a card in their hand, their
|
|
40
|
+
* secret goal) and return the state; the kit then checks no other seat's
|
|
41
|
+
* view or events ever contain it. Leave it out for a game with no hidden
|
|
42
|
+
* information.
|
|
43
|
+
*/
|
|
44
|
+
readonly plant?: (state: State, seat: number, marker: string) => State;
|
|
45
|
+
/** States saved by older rules versions, which `migrate` must bring up to date. */
|
|
46
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: states stored by older rules versions
|
|
47
|
+
readonly fixtures?: readonly { readonly version: number; readonly state: unknown }[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What the kit played. */
|
|
51
|
+
export interface TurnRulesReport {
|
|
52
|
+
readonly games: number;
|
|
53
|
+
readonly moves: number;
|
|
54
|
+
readonly longestGame: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The UTF-8 bytes of a JSON text. */
|
|
58
|
+
function bytesOf(json: string): number {
|
|
59
|
+
return new TextEncoder().encode(json).length;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Throw a kit failure. */
|
|
63
|
+
function fail(message: string): never {
|
|
64
|
+
throw new Error(`checkTurnRules: ${message}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A JSON copy (what the hub stores and reads back). */
|
|
68
|
+
function roundTrip<T>(value: T): T {
|
|
69
|
+
const copy = parseJsonText(JSON.stringify(value));
|
|
70
|
+
if (copy === null && value !== null) {
|
|
71
|
+
fail('a state or move is not JSON');
|
|
72
|
+
}
|
|
73
|
+
return isGamesOwn(copy, value) ? copy : fail('a state or move is not JSON');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Always true: JSON a game's rules made is read back as the game's own type (the game vouches for it, as `toGameData` does). */
|
|
77
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: the game's own JSON, round-tripped
|
|
78
|
+
function isGamesOwn<T>(_value: unknown, _like: T): _value is T {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A move for `seat`: the check's own chooser, else the rules' autoMove. */
|
|
83
|
+
function pickMove<State, Move, View, Event, Options>(rules: TurnRules<State, Move, View, Event, Options>, options: TurnRulesCheckOptions<State, Move, Options>, state: State, seat: number, rng: TurnRng): Move {
|
|
84
|
+
if (options.randomMove !== undefined) {
|
|
85
|
+
return options.randomMove(state, seat, rng);
|
|
86
|
+
}
|
|
87
|
+
if (rules.autoMove === undefined) {
|
|
88
|
+
return fail('the rules have no autoMove: pass options.randomMove');
|
|
89
|
+
}
|
|
90
|
+
return rules.autoMove(state, seat, rng);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Play one seeded game; answers its moves and a trace for the determinism check. */
|
|
94
|
+
function playGame<State, Move, View, Event, Options>(
|
|
95
|
+
rules: TurnRules<State, Move, View, Event, Options>, seats: number, seed: number, options: TurnRulesCheckOptions<State, Move, Options>,
|
|
96
|
+
): { moves: number; trace: string } {
|
|
97
|
+
const rng = createTurnRng(turnRngStateFromSeed(seed));
|
|
98
|
+
const marker = `MARKER-${String(seed)}-`;
|
|
99
|
+
let state = rules.setup({ seats, options: options.setupOptions ?? null, mode: options.mode ?? null, rng });
|
|
100
|
+
if (options.plant !== undefined) {
|
|
101
|
+
for (let seat = 0; seat < seats; seat++) {
|
|
102
|
+
state = options.plant(state, seat, `${marker}${String(seat)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const maxMoves = options.maxMoves ?? 2000;
|
|
106
|
+
const trace: string[] = [];
|
|
107
|
+
const checkViews = (events: readonly Event[]): void => {
|
|
108
|
+
for (let viewer = 0; viewer < seats; viewer++) {
|
|
109
|
+
const seen = JSON.stringify(rules.view(state, viewer)) + JSON.stringify(events.map((event) => rules.eventView?.(event, viewer) ?? null));
|
|
110
|
+
for (let owner = 0; owner < seats; owner++) {
|
|
111
|
+
if (owner !== viewer && options.plant !== undefined && seen.includes(`${marker}${String(owner)}`)) {
|
|
112
|
+
fail(`seat ${String(viewer)} can see seat ${String(owner)}'s secret (seed ${String(seed)})`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (options.plant !== undefined && JSON.stringify(rules.view(state, null)).includes(marker)) {
|
|
117
|
+
fail(`the public view shows a seat's secret (seed ${String(seed)})`);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
checkViews([]);
|
|
121
|
+
for (let moves = 0; moves < maxMoves; moves++) {
|
|
122
|
+
if (rules.outcome(state) !== null) {
|
|
123
|
+
return { moves, trace: trace.join('|') };
|
|
124
|
+
}
|
|
125
|
+
const toAct = rules.toAct(state);
|
|
126
|
+
const seat = toAct[0];
|
|
127
|
+
if (seat === undefined) {
|
|
128
|
+
fail(`toAct is empty but there is no outcome yet (seed ${String(seed)}, move ${String(moves)})`);
|
|
129
|
+
}
|
|
130
|
+
const move = pickMove(rules, options, state, seat, rng);
|
|
131
|
+
if (bytesOf(JSON.stringify(move)) > MOVE_MAX_BYTES) {
|
|
132
|
+
fail(`a move is over ${String(MOVE_MAX_BYTES)} bytes`);
|
|
133
|
+
}
|
|
134
|
+
const result = rules.apply(roundTrip(state), seat, roundTrip(move), rng);
|
|
135
|
+
if ('error' in result) {
|
|
136
|
+
fail(`${options.randomMove === undefined ? 'autoMove' : 'randomMove'} made an illegal move for seat ${String(seat)}: ${result.error} (seed ${String(seed)}, move ${String(moves)})`);
|
|
137
|
+
}
|
|
138
|
+
state = roundTrip(result.state);
|
|
139
|
+
if (bytesOf(JSON.stringify(state)) > STATE_MAX_BYTES) {
|
|
140
|
+
fail(`the state is over ${String(STATE_MAX_BYTES)} bytes`);
|
|
141
|
+
}
|
|
142
|
+
checkViews(result.events ?? []);
|
|
143
|
+
rules.progress(state, seat);
|
|
144
|
+
trace.push(JSON.stringify(move));
|
|
145
|
+
}
|
|
146
|
+
return fail(`a game did not end within ${String(maxMoves)} moves (seed ${String(seed)})`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Check a rules module (see the file comment); throws on the first failure. */
|
|
150
|
+
export function checkTurnRules<State, Move, View, Event, Options>(
|
|
151
|
+
rules: TurnRules<State, Move, View, Event, Options>, options: TurnRulesCheckOptions<State, Move, Options> = {},
|
|
152
|
+
): TurnRulesReport {
|
|
153
|
+
if (!Number.isInteger(rules.version) || rules.version < 0) {
|
|
154
|
+
fail('rules.version must be a whole number');
|
|
155
|
+
}
|
|
156
|
+
let games = 0;
|
|
157
|
+
let moves = 0;
|
|
158
|
+
let longestGame = 0;
|
|
159
|
+
for (const seats of options.seats ?? [2]) {
|
|
160
|
+
for (let game = 0; game < (options.games ?? 25); game++) {
|
|
161
|
+
const seed = seats * 100_003 + game;
|
|
162
|
+
const played = playGame(rules, seats, seed, options);
|
|
163
|
+
if (playGame(rules, seats, seed, options).trace !== played.trace) {
|
|
164
|
+
fail(`the same seed played a different game (seed ${String(seed)}): use the rng you are given, never Math.random`);
|
|
165
|
+
}
|
|
166
|
+
games += 1;
|
|
167
|
+
moves += played.moves;
|
|
168
|
+
longestGame = Math.max(longestGame, played.moves);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (const fixture of options.fixtures ?? []) {
|
|
172
|
+
if (fixture.version < rules.version) {
|
|
173
|
+
if (rules.migrate === undefined) {
|
|
174
|
+
fail(`a version ${String(fixture.version)} fixture needs rules.migrate`);
|
|
175
|
+
}
|
|
176
|
+
const migrated = rules.migrate(roundTrip(fixture.state), fixture.version);
|
|
177
|
+
rules.view(migrated, null);
|
|
178
|
+
rules.toAct(migrated);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { games, moves, longestGame };
|
|
182
|
+
}
|
package/src/turns.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@diffenderfer-games/hub/turns`: writing and testing an online turn
|
|
3
|
+
* game's rules module (docs/multiplayer.md "Turn games"). The types of the
|
|
4
|
+
* rules contract, `defineTurnRules` to type a rules object, the seeded RNG
|
|
5
|
+
* the hub hands the rules (to replay a game in tests), and the conformance
|
|
6
|
+
* kit, `checkTurnRules`, for the game's own CI. The page-side API is
|
|
7
|
+
* `hub.turns` on the main entry.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
export { createTurnRng, defineTurnRules, turnRngStateFromSeed } from '@diffenderfer-games/hub-contract';
|
|
12
|
+
export type { TurnApplyResult, TurnOutcome, TurnProgress, TurnRng, TurnRngState, TurnRules, TurnSetupContext } from '@diffenderfer-games/hub-contract';
|
|
13
|
+
export { checkTurnRules, type TurnRulesCheckOptions, type TurnRulesReport } from './turns-kit/check-rules';
|