@diffenderfer-games/hub 0.0.0-stage → 2.0.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +191 -3
- package/dist/auto.d.ts +36 -0
- package/dist/auto.d.ts.map +1 -0
- package/dist/auto.js +27 -0
- package/dist/auto.js.map +1 -0
- package/dist/daily.d.ts +2 -0
- package/dist/daily.js +1 -0
- package/dist/doctor/cli.mjs +268 -0
- package/dist/doctor/cli.mjs.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +6 -0
- package/dist/input.d.ts +2 -0
- package/dist/input.js +3 -0
- package/dist/mp.d.ts +4 -0
- package/dist/mp.js +1 -0
- package/dist/race.d.ts +4 -0
- package/dist/race.js +1 -0
- package/dist/rooms.d.ts +4 -0
- package/dist/rooms.js +1 -0
- package/dist/shared/bridge.js +209 -0
- package/dist/shared/bridge.js.map +1 -0
- package/dist/shared/client-version.gen.js +7 -0
- package/dist/shared/client-version.gen.js.map +1 -0
- package/dist/shared/daily.d.ts +123 -0
- package/dist/shared/daily.d.ts.map +1 -0
- package/dist/shared/daily.js +191 -0
- package/dist/shared/daily.js.map +1 -0
- package/dist/shared/dom.js +48 -0
- package/dist/shared/dom.js.map +1 -0
- package/dist/shared/globals.js +102 -0
- package/dist/shared/globals.js.map +1 -0
- package/dist/shared/index.d.ts +706 -0
- package/dist/shared/index.d.ts.map +1 -0
- package/dist/shared/input.d.ts +730 -0
- package/dist/shared/input.d.ts.map +1 -0
- package/dist/shared/input.js +2718 -0
- package/dist/shared/input.js.map +1 -0
- package/dist/shared/latest-state.js +17 -0
- package/dist/shared/latest-state.js.map +1 -0
- package/dist/shared/mp.d.ts +9 -0
- package/dist/shared/mp.d.ts.map +1 -0
- package/dist/shared/mp.js +30 -0
- package/dist/shared/mp.js.map +1 -0
- package/dist/shared/multiplayer-types.d.ts +47 -0
- package/dist/shared/multiplayer-types.d.ts.map +1 -0
- package/dist/shared/page-stack.js +207 -0
- package/dist/shared/page-stack.js.map +1 -0
- package/dist/shared/race-types.d.ts +126 -0
- package/dist/shared/race-types.d.ts.map +1 -0
- package/dist/shared/race.d.ts +9 -0
- package/dist/shared/race.d.ts.map +1 -0
- package/dist/shared/race.js +53 -0
- package/dist/shared/race.js.map +1 -0
- package/dist/shared/room-types.d.ts +125 -0
- package/dist/shared/room-types.d.ts.map +1 -0
- package/dist/shared/rooms.d.ts +9 -0
- package/dist/shared/rooms.d.ts.map +1 -0
- package/dist/shared/rooms.js +62 -0
- package/dist/shared/rooms.js.map +1 -0
- package/dist/shared/runtime-opener.js +84 -0
- package/dist/shared/runtime-opener.js.map +1 -0
- package/dist/shared/social.d.ts +148 -0
- package/dist/shared/social.d.ts.map +1 -0
- package/dist/shared/social.js +73 -0
- package/dist/shared/social.js.map +1 -0
- package/dist/shared/src.js +2164 -0
- package/dist/shared/src.js.map +1 -0
- package/dist/shared/wire-types.d.ts +51 -0
- package/dist/shared/wire-types.d.ts.map +1 -0
- package/dist/social.d.ts +6 -0
- package/dist/social.js +3 -0
- package/dist/types.d.ts +1 -0
- package/dist/types.js +0 -0
- package/package.json +89 -6
- package/src/auto.ts +39 -0
- package/src/core/connection.ts +299 -0
- package/src/core/errors.ts +147 -0
- package/src/core/game-data.ts +38 -0
- package/src/core/hub-economy.ts +179 -0
- package/src/core/hub-progress.ts +205 -0
- package/src/core/hub.ts +131 -0
- package/src/core/menu-control.ts +61 -0
- package/src/core/offline/events.ts +50 -0
- package/src/core/offline/favorites.ts +21 -0
- package/src/core/offline/local-store.ts +261 -0
- package/src/core/offline/queue.ts +171 -0
- package/src/core/page.ts +74 -0
- package/src/core/rest/endpoint-types.ts +22 -0
- package/src/core/rest/rest-client.ts +152 -0
- package/src/core/rest/routes.gen.ts +90 -0
- package/src/core/storage/json-guards.ts +74 -0
- package/src/core/storage/key-value.ts +66 -0
- package/src/core/ticket.ts +43 -0
- package/src/core/types.ts +134 -0
- package/src/core/version/client-version.gen.ts +3 -0
- package/src/core/version/hub-version.ts +62 -0
- package/src/core/version/semver.ts +97 -0
- package/src/daily/daily-system.ts +161 -0
- package/src/daily/page-daily.ts +36 -0
- package/src/daily/types.ts +67 -0
- package/src/daily.ts +10 -0
- package/src/doctor/checks.ts +78 -0
- package/src/doctor/cli.ts +98 -0
- package/src/doctor/hub-version-fetch.ts +43 -0
- package/src/doctor/tsconfig.json +12 -0
- package/src/globals.ts +115 -0
- package/src/index.ts +50 -0
- package/src/input/device-state.ts +146 -0
- package/src/input/edges.ts +55 -0
- package/src/input/gamepad.ts +61 -0
- package/src/input/input-events.ts +32 -0
- package/src/input/input-registry.ts +164 -0
- package/src/input/input-system.ts +393 -0
- package/src/input/legacy-controls.ts +64 -0
- package/src/input/nav-state.ts +191 -0
- package/src/input/navigator.ts +171 -0
- package/src/input/on-screen-controls.ts +101 -0
- package/src/input/page-control.ts +43 -0
- package/src/input/page-input.ts +193 -0
- package/src/input/player-remaps.ts +77 -0
- package/src/input/source-values.ts +116 -0
- package/src/input/spatial.ts +47 -0
- package/src/input/types.ts +331 -0
- package/src/input/zero-config.ts +177 -0
- package/src/input.ts +22 -0
- package/src/mp.ts +10 -0
- package/src/overlay/controls-panel.ts +163 -0
- package/src/overlay/dom.ts +46 -0
- package/src/overlay/layout-clusters.ts +162 -0
- package/src/overlay/layout-geometry.ts +136 -0
- package/src/overlay/layout.ts +203 -0
- package/src/overlay/listener-scope.ts +23 -0
- package/src/overlay/on-screen-keyboard.ts +137 -0
- package/src/overlay/overlay.ts +208 -0
- package/src/overlay/styles.ts +54 -0
- package/src/overlay/virtual-controls.ts +262 -0
- package/src/race.ts +10 -0
- package/src/realtime/bridge.ts +121 -0
- package/src/realtime/cross-bundle.ts +29 -0
- package/src/realtime/fallback-notice.ts +48 -0
- package/src/realtime/hub-link.ts +65 -0
- package/src/realtime/latest-state.ts +33 -0
- package/src/realtime/multiplayer-forwarder.ts +28 -0
- package/src/realtime/multiplayer-types.ts +52 -0
- package/src/realtime/page-runtime.ts +60 -0
- package/src/realtime/race-forwarder.ts +59 -0
- package/src/realtime/race-types.ts +132 -0
- package/src/realtime/room-types.ts +125 -0
- package/src/realtime/rooms-forwarder.ts +79 -0
- package/src/realtime/runtime-api.ts +49 -0
- package/src/realtime/runtime-opener.ts +61 -0
- package/src/realtime/social-forwarder.ts +72 -0
- package/src/realtime/social-types.ts +78 -0
- package/src/realtime/types.ts +53 -0
- package/src/realtime/wire-types.ts +72 -0
- package/src/rooms.ts +10 -0
- package/src/social.ts +18 -0
- package/src/types.ts +13 -0
- package/src/uistack/page-stack.ts +45 -0
- package/src/uistack/play-suspension.ts +82 -0
- package/src/uistack/types.ts +76 -0
- package/src/uistack/ui-stack.ts +144 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The errors every hub call throws: {@link HubError} and its two kinds,
|
|
3
|
+
* {@link OfflineError} and {@link ContentRejectedError}. The fields come from
|
|
4
|
+
* the contract's error body (`{ error, code, reason, hint, retryAfterMs, until }`).
|
|
5
|
+
*/
|
|
6
|
+
import type { ErrorCode, RejectReason } from '@diffenderfer-games/hub-contract';
|
|
7
|
+
import { isRecord, optionalNumber, optionalString } from './storage/json-guards';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The code a client gives a call the hub does not have yet (a newer client on
|
|
11
|
+
* an older hub, plan §4.3.3). Deploy the hub first, or use an older client.
|
|
12
|
+
*/
|
|
13
|
+
export const UNSUPPORTED_BY_HUB = 'unsupported_by_hub';
|
|
14
|
+
|
|
15
|
+
/** The code the social runtime gives a call it could not send (no connection). */
|
|
16
|
+
export const OFFLINE_CODE = 'offline';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A machine-readable error code: one of the contract's public codes
|
|
20
|
+
* (`content_rejected`, `rate_limited`, `suspended`, `claim_required`, ...),
|
|
21
|
+
* {@link UNSUPPORTED_BY_HUB}, or a code a newer hub added.
|
|
22
|
+
*/
|
|
23
|
+
export type HubErrorCode = ErrorCode | typeof UNSUPPORTED_BY_HUB | typeof OFFLINE_CODE | (string & Record<never, never>);
|
|
24
|
+
|
|
25
|
+
/** The machine-readable extras the hub attaches to an error body. */
|
|
26
|
+
export interface HubErrorDetails {
|
|
27
|
+
/** A stable error code, for code to branch on. */
|
|
28
|
+
readonly code?: HubErrorCode | undefined;
|
|
29
|
+
/** Why content was refused (`link`, `language`, `muted`, `chat_off`, ...). */
|
|
30
|
+
readonly reason?: RejectReason | (string & Record<never, never>) | undefined;
|
|
31
|
+
/** A kid-friendly sentence to show the player. */
|
|
32
|
+
readonly hint?: string | undefined;
|
|
33
|
+
/** How long to wait before retrying, in ms (rate limits, mutes). */
|
|
34
|
+
readonly retryAfterMs?: number | undefined;
|
|
35
|
+
/** When a suspension or mute ends (epoch ms). */
|
|
36
|
+
readonly until?: number | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Thrown by every hub call that fails. `status` is the HTTP status when the
|
|
41
|
+
* hub answered (401 = not signed in, 404 = unknown game or board, 413 = too
|
|
42
|
+
* large), or `undefined` for a transport failure (offline, no host). Social
|
|
43
|
+
* and multiplayer errors also carry {@link HubErrorDetails}.
|
|
44
|
+
*/
|
|
45
|
+
export class HubError extends Error {
|
|
46
|
+
/** The HTTP status, or `undefined` when the hub could not be reached. */
|
|
47
|
+
readonly status: number | undefined;
|
|
48
|
+
/** A stable error code (see {@link HubErrorCode}). */
|
|
49
|
+
readonly code: HubErrorCode | undefined;
|
|
50
|
+
/** Why content was refused. */
|
|
51
|
+
readonly reason: string | undefined;
|
|
52
|
+
/** A kid-friendly sentence to show the player. */
|
|
53
|
+
readonly hint: string | undefined;
|
|
54
|
+
/** How long to wait before retrying, in ms. */
|
|
55
|
+
readonly retryAfterMs: number | undefined;
|
|
56
|
+
/** When a suspension or mute ends (epoch ms). */
|
|
57
|
+
readonly until: number | undefined;
|
|
58
|
+
|
|
59
|
+
/** An error with a player-safe `message`, the HTTP `status` (if any) and the body's extras. */
|
|
60
|
+
constructor(message: string, status?: number, details: HubErrorDetails = {}) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.name = 'HubError';
|
|
63
|
+
this.status = status;
|
|
64
|
+
this.code = details.code;
|
|
65
|
+
this.reason = details.reason;
|
|
66
|
+
this.hint = details.hint;
|
|
67
|
+
this.retryAfterMs = details.retryAfterMs;
|
|
68
|
+
this.until = details.until;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `true` when the failure was a network or transport error, not an HTTP status. */
|
|
72
|
+
get isOffline(): boolean {
|
|
73
|
+
return this.status === undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Thrown when an action that genuinely needs the hub is attempted offline. */
|
|
78
|
+
export class OfflineError extends HubError {
|
|
79
|
+
/** An offline error; the default message is "This needs a connection.". */
|
|
80
|
+
constructor(message = 'This needs a connection.') {
|
|
81
|
+
super(message);
|
|
82
|
+
this.name = 'OfflineError';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Text was refused by the safety filter (HTTP 422, or a socket ack with
|
|
88
|
+
* `code: 'content_rejected'`). Keep the player's draft and show `hint`; when
|
|
89
|
+
* `reason` is `muted` or `rate_limited`, `retryAfterMs` says how long to wait.
|
|
90
|
+
*/
|
|
91
|
+
export class ContentRejectedError extends HubError {
|
|
92
|
+
/** A content reject with the hub's message, status and extras. */
|
|
93
|
+
constructor(message: string, status: number | undefined, details: HubErrorDetails) {
|
|
94
|
+
super(message, status, details);
|
|
95
|
+
this.name = 'ContentRejectedError';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** An error body's fields, read without trusting its shape. */
|
|
100
|
+
export interface ErrorBodyFields extends HubErrorDetails {
|
|
101
|
+
/** The player-safe sentence, when the body had one. */
|
|
102
|
+
readonly error?: string | undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Read the fields this client understands from an error body (or any thrown object). */
|
|
106
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: an error body from the hub or an error from another bundle, read field by field
|
|
107
|
+
export function readErrorFields(body: unknown): ErrorBodyFields {
|
|
108
|
+
if (!isRecord(body)) {
|
|
109
|
+
return {};
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
error: optionalString(body.error),
|
|
113
|
+
code: optionalString(body.code),
|
|
114
|
+
reason: optionalString(body.reason),
|
|
115
|
+
hint: optionalString(body.hint),
|
|
116
|
+
retryAfterMs: optionalNumber(body.retryAfterMs),
|
|
117
|
+
until: optionalNumber(body.until),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The right error class for a failure: a 422 or `content_rejected` is a {@link ContentRejectedError}. */
|
|
122
|
+
export function errorFor(message: string, status: number | undefined, details: HubErrorDetails): HubError {
|
|
123
|
+
return status === 422 || details.code === 'content_rejected'
|
|
124
|
+
? new ContentRejectedError(message, status, details)
|
|
125
|
+
: new HubError(message, status, details);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Re-create an error from another bundle (the social runtime, or another
|
|
130
|
+
* client copy on the page) as this bundle's class, so `instanceof` works in
|
|
131
|
+
* games.
|
|
132
|
+
*/
|
|
133
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: whatever another bundle threw
|
|
134
|
+
export function toHubError(thrown: unknown): HubError {
|
|
135
|
+
if (thrown instanceof HubError) {
|
|
136
|
+
return thrown;
|
|
137
|
+
}
|
|
138
|
+
if (!isRecord(thrown)) {
|
|
139
|
+
return new HubError(String(thrown));
|
|
140
|
+
}
|
|
141
|
+
const fields = readErrorFields(thrown);
|
|
142
|
+
const message = optionalString(thrown.message) ?? 'Request failed';
|
|
143
|
+
if (fields.code === OFFLINE_CODE) {
|
|
144
|
+
return new OfflineError(message);
|
|
145
|
+
}
|
|
146
|
+
return errorFor(message, optionalNumber(thrown.status), fields);
|
|
147
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A game's own JSON (save payloads, profile data, score metadata) is typed by
|
|
3
|
+
* the game: `hub.putSave<MySave>(...)`, `hub.getSave<MySave>(...)`. The hub
|
|
4
|
+
* stores it as opaque JSON, so neither the contract nor this client can check
|
|
5
|
+
* it against `MySave`: the game vouches for its own type, exactly as with the
|
|
6
|
+
* v1 client. These two functions are the only places that hand-over happens.
|
|
7
|
+
*/
|
|
8
|
+
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
9
|
+
|
|
10
|
+
/** Anything a game may store as JSON: an object (interfaces included), an array, text, a number, a boolean or `null`. */
|
|
11
|
+
export type GamePayload = object | string | number | boolean | null;
|
|
12
|
+
|
|
13
|
+
/** Always true: a game's payload is sent as JSON (`JSON.stringify` drops what JSON can't hold). */
|
|
14
|
+
function isSentAsJson(_payload: GamePayload): _payload is GamePayload & Json {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Always true: JSON the game stored is read back as the game's type. */
|
|
19
|
+
function isGameOwnData<TData>(_stored: Json): _stored is Json & TData {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A game's payload as the JSON the hub stores. */
|
|
24
|
+
export function toStoredJson(payload: GamePayload): Json {
|
|
25
|
+
if (isSentAsJson(payload)) {
|
|
26
|
+
return payload;
|
|
27
|
+
}
|
|
28
|
+
throw new TypeError('unreachable: every payload is sent as JSON');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** JSON the game stored, typed as the game's own type. */
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- the caller names the game's own type; the game vouches for it (see above)
|
|
33
|
+
export function toGameData<TData>(stored: Json): TData {
|
|
34
|
+
if (isGameOwnData<TData>(stored)) {
|
|
35
|
+
return stored;
|
|
36
|
+
}
|
|
37
|
+
throw new TypeError('unreachable: stored game data is the game\'s own');
|
|
38
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analytics, the catalog, favorites, records, inventory and trades. Analytics
|
|
3
|
+
* calls are fire-and-forget and never throw (telemetry must not break a game);
|
|
4
|
+
* offline they are buffered and sent when the hub is back. Method names are
|
|
5
|
+
* the v1 client's, unchanged.
|
|
6
|
+
*/
|
|
7
|
+
import { HubProgress } from './hub-progress';
|
|
8
|
+
import { readLocalFavorites, writeLocalFavorites } from './offline/favorites';
|
|
9
|
+
import { HubError } from './errors';
|
|
10
|
+
import type { EndpointFields, EndpointReply } from './rest/endpoint-types';
|
|
11
|
+
import type { GameAnalytics, GameInfo, GlobalAnalytics, InventorySnapshot } from './types';
|
|
12
|
+
|
|
13
|
+
/** Goods to grant: item key to quantity, and coins. */
|
|
14
|
+
export type GrantBundle = Omit<EndpointFields<'grantInventoryItems'>, 'slug'>;
|
|
15
|
+
|
|
16
|
+
/** Goods taken or given in an exchange or a trade. */
|
|
17
|
+
export type TradeBundle = EndpointFields<'exchangeInventoryItems'>['take'];
|
|
18
|
+
|
|
19
|
+
/** A trade offer's id, as a number or its text. */
|
|
20
|
+
export type TradeId = number | string;
|
|
21
|
+
|
|
22
|
+
/** Extras for creating or countering a trade. */
|
|
23
|
+
export interface TradeNoteOptions {
|
|
24
|
+
/** A short note to the other player (moderated). */
|
|
25
|
+
readonly note?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Which trades `listTrades` lists. */
|
|
29
|
+
export type TradeListOptions = Omit<EndpointFields<'listMyTrades'>, 'slug'>;
|
|
30
|
+
|
|
31
|
+
/** Analytics, catalog, favorites, records, inventory and trades. */
|
|
32
|
+
export class HubEconomy extends HubProgress {
|
|
33
|
+
/** Record a "play". Call once when the game starts. */
|
|
34
|
+
recordPlay(): Promise<void> {
|
|
35
|
+
return this.event('play');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Record a "heartbeat". Call periodically while the game is active. */
|
|
39
|
+
heartbeat(): Promise<void> {
|
|
40
|
+
return this.event('heartbeat');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Record a custom event. Never throws; offline, events are buffered and sent on reconnect. */
|
|
44
|
+
async event(type: string): Promise<void> {
|
|
45
|
+
if (this.slug === null) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (this.isOffline) {
|
|
49
|
+
this.store.bufferEvent(type);
|
|
50
|
+
this.notifyStatus();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
await this.rest.call('recordGameEvent', { ...this.gameFields(), type });
|
|
55
|
+
} catch (failure) {
|
|
56
|
+
if (failure instanceof HubError && failure.isOffline) {
|
|
57
|
+
this.setReachable(false);
|
|
58
|
+
this.store.bufferEvent(type);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** This game's play counts. */
|
|
64
|
+
analytics(): Promise<GameAnalytics> {
|
|
65
|
+
return this.withFallback(() => this.rest.call('getGameAnalytics', this.gameFields()), () => ({ slug: this.slug ?? 'app', totalPlays: 0, uniquePlayers: 0, activeNow: 0 }));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Catalog-wide play counts with a per-game breakdown. */
|
|
69
|
+
globalAnalytics(): Promise<GlobalAnalytics> {
|
|
70
|
+
return this.withFallback(() => this.rest.call('getSiteAnalytics'), () => ({ global: { totalPlays: 0, uniquePlayers: 0, activeNow: 0 }, games: [] }));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The listed games, for "jump to another game" navigation. */
|
|
74
|
+
listGames(): Promise<{ games: GameInfo[] }> {
|
|
75
|
+
return this.withFallback(() => this.rest.call('listGames'), () => ({ games: [] }));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Every board of every game, each with its top `top` entries (default 5). */
|
|
79
|
+
allLeaderboards(top = 5): Promise<EndpointReply<'listTopOfEveryLeaderboard'>> {
|
|
80
|
+
return this.withFallback(() => this.rest.call('listTopOfEveryLeaderboard', { top }), () => ({ games: [] }));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The starred games (slugs), shown first in the menu's game list. Kept per
|
|
85
|
+
* account by the hub and mirrored to `localStorage`, so it shows offline.
|
|
86
|
+
*/
|
|
87
|
+
favorites(): Promise<{ games: string[] }> {
|
|
88
|
+
return this.withFallback(async () => {
|
|
89
|
+
const reply = await this.rest.call('listFavoriteGames');
|
|
90
|
+
writeLocalFavorites(reply.games);
|
|
91
|
+
return reply;
|
|
92
|
+
}, () => ({ games: readLocalFavorites() }));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Star (`isOn = true`) or unstar a game. Needs a connection. */
|
|
96
|
+
async setFavorite(slug: string, isOn: boolean): Promise<{ games: string[] }> {
|
|
97
|
+
const reply = await this.rest.call('setGameFavorite', { slug, on: isOn });
|
|
98
|
+
writeLocalFavorites(reply.games);
|
|
99
|
+
return reply;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The player's leaderboard standings, grouped by game (every game, or one via `forSlug`). Empty offline or signed out. */
|
|
103
|
+
myRecords(forSlug?: string): Promise<EndpointReply<'listMyLeaderboardRecords'>> {
|
|
104
|
+
return this.withFallback(
|
|
105
|
+
() => this.rest.call('listMyLeaderboardRecords', forSlug === undefined || forSlug === '' ? {} : { game: forSlug }),
|
|
106
|
+
() => ({ games: [] }),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** This game's inventory and coin balance for the current user. */
|
|
111
|
+
getInventory(): Promise<InventorySnapshot> {
|
|
112
|
+
return this.withFallback(() => this.rest.call('getMyInventory', this.gameFields()), () => ({ items: [], coins: 0 }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Another player's available holdings (to compose a trade). */
|
|
116
|
+
playerInventory(userId: number): Promise<InventorySnapshot> {
|
|
117
|
+
return this.withFallback(() => this.rest.call('getPlayerInventory', { ...this.gameFields(), userId }), () => ({ items: [], coins: 0 }));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Add goods to the player (gameplay rewards); answers the new holdings. */
|
|
121
|
+
grant(bundle: GrantBundle): Promise<EndpointReply<'grantInventoryItems'>> {
|
|
122
|
+
return this.rest.call('grantInventoryItems', { ...this.gameFields(), ...bundle });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** An atomic swap with the house: remove `take`, add `give`. */
|
|
126
|
+
exchange(take?: TradeBundle, give?: TradeBundle): Promise<EndpointReply<'exchangeInventoryItems'>> {
|
|
127
|
+
return this.rest.call('exchangeInventoryItems', { ...this.gameFields(), ...(take === undefined ? {} : { take }), ...(give === undefined ? {} : { give }) });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Sell items to the house for coins. */
|
|
131
|
+
sell(items: Record<string, number>, coins: number): Promise<EndpointReply<'exchangeInventoryItems'>> {
|
|
132
|
+
return this.exchange({ items }, { coins });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Buy items from the house with coins. */
|
|
136
|
+
buy(coins: number, items: Record<string, number>): Promise<EndpointReply<'exchangeInventoryItems'>> {
|
|
137
|
+
return this.exchange({ coins }, { items });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Wipe the player's progress in this game (saves, stats, inventory and coins). */
|
|
141
|
+
resetGame(): Promise<{ ok: true }> {
|
|
142
|
+
return this.rest.call('resetMyGameProgress', this.gameFields());
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Players this player can trade with in this game. */
|
|
146
|
+
listPlayers(): Promise<EndpointReply<'listTradePartners'>> {
|
|
147
|
+
return this.withFallback(() => this.rest.call('listTradePartners', this.gameFields()), () => ({ players: [] }));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The player's trades (`box`: `incoming` or `outgoing`; `status` filters). */
|
|
151
|
+
listTrades(opts: TradeListOptions = {}): Promise<EndpointReply<'listMyTrades'>> {
|
|
152
|
+
return this.withFallback(() => this.rest.call('listMyTrades', { ...this.gameFields(), ...opts }), () => ({ offers: [] }));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Offer a trade to another player (by id or name). */
|
|
156
|
+
createTrade(toUser: number | string, give?: TradeBundle, want?: TradeBundle, opts: TradeNoteOptions = {}): Promise<EndpointReply<'createTrade'>> {
|
|
157
|
+
return this.rest.call('createTrade', { ...this.gameFields(), toUser: String(toUser), ...(give === undefined ? {} : { give }), ...(want === undefined ? {} : { want }), ...opts });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Accept a trade offered to the player. */
|
|
161
|
+
acceptTrade(id: TradeId): Promise<EndpointReply<'acceptTrade'>> {
|
|
162
|
+
return this.rest.call('acceptTrade', { ...this.gameFields(), id: Number(id) });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Decline a trade offered to the player. */
|
|
166
|
+
declineTrade(id: TradeId): Promise<EndpointReply<'declineTrade'>> {
|
|
167
|
+
return this.rest.call('declineTrade', { ...this.gameFields(), id: Number(id) });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Cancel a trade the player offered. */
|
|
171
|
+
cancelTrade(id: TradeId): Promise<EndpointReply<'cancelTrade'>> {
|
|
172
|
+
return this.rest.call('cancelTrade', { ...this.gameFields(), id: Number(id) });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Answer a trade with a counter-offer. */
|
|
176
|
+
counterTrade(id: TradeId, give?: TradeBundle, want?: TradeBundle, opts: TradeNoteOptions = {}): Promise<EndpointReply<'counterTradeOffer'>> {
|
|
177
|
+
return this.rest.call('counterTradeOffer', { ...this.gameFields(), id: Number(id), ...(give === undefined ? {} : { give }), ...(want === undefined ? {} : { want }), ...opts });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accounts and the player's progress in this game: sign-in, the profile,
|
|
3
|
+
* saves, stats and leaderboards. Reads fall back to the offline cache; writes
|
|
4
|
+
* apply to the cache at once and are queued while offline. Method names are
|
|
5
|
+
* the v1 client's, unchanged.
|
|
6
|
+
*/
|
|
7
|
+
import type { Board, Json, Stats } from '@diffenderfer-games/hub-contract';
|
|
8
|
+
import { HubConnection } from './connection';
|
|
9
|
+
import { toGameData, toStoredJson, type GamePayload } from './game-data';
|
|
10
|
+
import type { ProfilePatch, QueuedScoreOptions } from './offline/queue';
|
|
11
|
+
import type { LeaderboardResult, MeResponse, Profile, Save, SaveMeta, SubmitScoreOptions, SubmitScoreResult, User } from './types';
|
|
12
|
+
|
|
13
|
+
/** A signed-in user, as the auth calls answer. */
|
|
14
|
+
export interface AuthResponse {
|
|
15
|
+
/** The signed-in user. */
|
|
16
|
+
readonly user: User;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A stat's new value, as the stat calls answer. */
|
|
20
|
+
export interface StatResult {
|
|
21
|
+
/** The stat key. */
|
|
22
|
+
readonly key: string;
|
|
23
|
+
/** Its value after the update. */
|
|
24
|
+
readonly value: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A profile change with the game's own data typed as `TData`. */
|
|
28
|
+
export interface ProfileUpdate<TData extends GamePayload = Json> {
|
|
29
|
+
/** Shown to other players; `null` clears it. */
|
|
30
|
+
readonly displayName?: string | null;
|
|
31
|
+
/** The game's own profile data. */
|
|
32
|
+
readonly data?: TData;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A profile with the game's own data typed as `TData`. */
|
|
36
|
+
function typedProfile<TData>(profile: Profile | null): Profile<TData> | null {
|
|
37
|
+
return profile === null ? null : { ...profile, data: toGameData<TData>(profile.data) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A save with the game's own data typed as `TData`. */
|
|
41
|
+
function typedSave<TData>(save: Save | null): Save<TData> | null {
|
|
42
|
+
return save === null ? null : { ...save, data: toGameData<TData>(save.data) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Accounts, profile, saves, stats and leaderboards. */
|
|
46
|
+
export class HubProgress extends HubConnection {
|
|
47
|
+
/** The current user (or `null`) and the games they have a profile in. */
|
|
48
|
+
me(): Promise<MeResponse> {
|
|
49
|
+
return this.withFallback(() => this.rest.call('getCurrentUser'), () => {
|
|
50
|
+
const profile = this.store.getProfile();
|
|
51
|
+
return { user: this.store.getUser(), profiles: profile === null ? [] : [{ game: profile.game, displayName: profile.displayName }] };
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Make sure a session exists, creating a guest (a random name, no
|
|
57
|
+
* password) when the player isn't signed in, so progress saves without a
|
|
58
|
+
* sign-in. Idempotent. Offline, a local guest is used.
|
|
59
|
+
*/
|
|
60
|
+
ensureGuest(): Promise<AuthResponse> {
|
|
61
|
+
return this.withFallback<AuthResponse>(() => this.rest.call('ensureGuestSession'), () => {
|
|
62
|
+
const known = this.store.getUser();
|
|
63
|
+
if (known !== null) {
|
|
64
|
+
return { user: known };
|
|
65
|
+
}
|
|
66
|
+
const guest: User = { id: 0, username: 'Guest-local', guest: true };
|
|
67
|
+
this.store.setUser(guest);
|
|
68
|
+
return { user: guest };
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Turn the current guest into a registered account (the same identity, so
|
|
74
|
+
* all progress carries over). Rejects with 409 when already registered or
|
|
75
|
+
* the name is taken; the player should `login` instead.
|
|
76
|
+
*/
|
|
77
|
+
claim(username: string, password: string): Promise<AuthResponse> {
|
|
78
|
+
return this.withFallback<AuthResponse>(() => this.rest.call('claimGuestAccount', { username, password }), () => this.signInLocally({ id: 0, username, guest: false }));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Create an account and sign in. (Offline: a local user is stored.) */
|
|
82
|
+
signup(username: string, password: string): Promise<AuthResponse> {
|
|
83
|
+
return this.withFallback<AuthResponse>(() => this.rest.call('signUpWithPassword', { username, password }), () => this.signInLocally({ id: 0, username }));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Sign in. (Offline: a local user is stored so signed-in flows work.) */
|
|
87
|
+
login(username: string, password: string): Promise<AuthResponse> {
|
|
88
|
+
return this.withFallback<AuthResponse>(() => this.rest.call('logInWithPassword', { username, password }), () => this.signInLocally({ id: 0, username }));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Sign out (ends the session shared by every game). */
|
|
92
|
+
logout(): Promise<{ ok: true }> {
|
|
93
|
+
return this.withFallback(() => this.rest.call('logOut'), () => {
|
|
94
|
+
this.store.clearUser();
|
|
95
|
+
return { ok: true } as const;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private signInLocally(user: User): AuthResponse {
|
|
100
|
+
this.store.setUser(user);
|
|
101
|
+
return { user };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** This game's profile for the current user, or `profile: null`. `TData` is the game's own data type. */
|
|
105
|
+
getProfile<TData = Json>(): Promise<{ profile: Profile<TData> | null }> {
|
|
106
|
+
return this.withFallback(
|
|
107
|
+
async () => ({ profile: typedProfile<TData>((await this.rest.call('getGameProfile', this.gameFields())).profile) }),
|
|
108
|
+
() => ({ profile: typedProfile<TData>(this.store.getProfile()) }),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Create or update this game's profile for the current user. */
|
|
113
|
+
setProfile<TData extends GamePayload = Json>(patch: ProfileUpdate<TData>): Promise<{ profile: Profile<TData> }> {
|
|
114
|
+
const queued: ProfilePatch = {};
|
|
115
|
+
if (patch.displayName !== undefined) {
|
|
116
|
+
queued.displayName = patch.displayName;
|
|
117
|
+
}
|
|
118
|
+
if (patch.data !== undefined) {
|
|
119
|
+
queued.data = toStoredJson(patch.data);
|
|
120
|
+
}
|
|
121
|
+
return this.mutateQueued({ kind: 'setProfile', patch: queued }, () => {
|
|
122
|
+
const stored = this.store.setProfile(this.slug ?? 'app', patch.displayName ?? null, queued.data ?? {});
|
|
123
|
+
return { profile: { ...stored, data: toGameData<TData>(stored.data) } };
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Metadata for every save slot (no payloads), newest first. */
|
|
128
|
+
listSaves(): Promise<{ saves: SaveMeta[] }> {
|
|
129
|
+
return this.withFallback(() => this.rest.call('listSaves', this.gameFields()), () => ({ saves: this.store.listSaves() }));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** One slot's contents, or `save: null` when the slot is empty. `TData` is the game's own save type. */
|
|
133
|
+
getSave<TData = Json>(slot: string): Promise<{ save: Save<TData> | null }> {
|
|
134
|
+
return this.withFallback(
|
|
135
|
+
async () => ({ save: typedSave<TData>((await this.rest.call('getSave', { ...this.gameFields(), slot })).save) }),
|
|
136
|
+
() => ({ save: typedSave<TData>(this.store.getSave(slot)) }),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Create or overwrite a save slot. `saveData` is any JSON up to 64 KB. */
|
|
141
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- kept so v1 calls such as putSave<MySave>(...) still compile
|
|
142
|
+
putSave<TData extends GamePayload = Json>(slot: string, saveData: TData, label?: string): Promise<{ ok: true; updatedAt: number }> {
|
|
143
|
+
const stored = toStoredJson(saveData);
|
|
144
|
+
return this.mutateQueued({ kind: 'putSave', slot, data: stored, label: label ?? null }, () => ({ ok: true as const, updatedAt: this.store.putSave(slot, stored, label ?? null) }));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Delete a save slot. */
|
|
148
|
+
deleteSave(slot: string): Promise<{ ok: true }> {
|
|
149
|
+
return this.mutateQueued({ kind: 'deleteSave', slot }, () => {
|
|
150
|
+
this.store.deleteSave(slot);
|
|
151
|
+
return { ok: true as const };
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Numeric stats for the current user, or another player's via `userId`. */
|
|
156
|
+
getStats(userId?: number): Promise<{ stats: Stats }> {
|
|
157
|
+
return this.withFallback(
|
|
158
|
+
() => this.rest.call('getGameStats', { ...this.gameFields(), ...(userId === undefined ? {} : { user: userId }) }),
|
|
159
|
+
() => ({ stats: this.store.getStats() }),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Overwrite a stat. */
|
|
164
|
+
setStat(key: string, value: number): Promise<StatResult> {
|
|
165
|
+
return this.mutateQueued({ kind: 'setStat', key, value }, () => ({ key, value: this.store.updateStat(key, value, 'set') }));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Add to a stat (creating it at `amount` when absent). */
|
|
169
|
+
incrStat(key: string, amount = 1): Promise<StatResult> {
|
|
170
|
+
return this.mutateQueued({ kind: 'incrStat', key, amount }, () => ({ key, value: this.store.updateStat(key, amount, 'incr') }));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Keep the larger of the stored and the given value. */
|
|
174
|
+
maxStat(key: string, value: number): Promise<StatResult> {
|
|
175
|
+
return this.mutateQueued({ kind: 'maxStat', key, value }, () => ({ key, value: this.store.updateStat(key, value, 'max') }));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Submit a score. The board is created on first use; only a player's best score sticks. */
|
|
179
|
+
submitScore<TMeta extends GamePayload = Json>(boardKey: string, score: number, opts: SubmitScoreOptions<TMeta> = {}): Promise<SubmitScoreResult> {
|
|
180
|
+
const queued: QueuedScoreOptions = {
|
|
181
|
+
...(opts.title === undefined ? {} : { title: opts.title }),
|
|
182
|
+
...(opts.sort === undefined ? {} : { sort: opts.sort }),
|
|
183
|
+
...(opts.meta === undefined ? {} : { meta: toStoredJson(opts.meta) }),
|
|
184
|
+
};
|
|
185
|
+
return this.mutateQueued({ kind: 'submitScore', key: boardKey, score, opts: queued }, () => ({ ...this.store.submitScore(boardKey, score, queued), rank: 1 }));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** One board's ranked entries (top `limit`, default 50). Offline: only the player's own best. */
|
|
189
|
+
getLeaderboard(boardKey: string, limit = 50): Promise<LeaderboardResult> {
|
|
190
|
+
return this.withFallback(() => this.rest.call('getLeaderboard', { ...this.gameFields(), key: boardKey, limit }), () => {
|
|
191
|
+
const local = this.store.getBoard(boardKey);
|
|
192
|
+
if (local === null) {
|
|
193
|
+
return { board: { key: boardKey, title: boardKey, sortDir: 'desc' }, entries: [] };
|
|
194
|
+
}
|
|
195
|
+
const user = this.store.getUser();
|
|
196
|
+
return { board: local.board, entries: [{ rank: 1, userId: user?.id ?? 0, username: user?.username ?? 'you', score: local.best, meta: null }] };
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The boards this game owns (keys and titles, no entries). */
|
|
201
|
+
listLeaderboards(): Promise<{ boards: Board[] }> {
|
|
202
|
+
return this.withFallback(() => this.rest.call('listGameLeaderboards', this.gameFields()), () => ({ boards: this.store.listBoards() }));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
}
|