@diffenderfer-games/hub 2.0.0-next.1 → 2.0.0-next.10
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 +160 -6
- 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 +71 -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 +2354 -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 +320 -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 +121 -0
- package/src/avatar/render/walker.ts +199 -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 +5 -2
- 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
package/src/core/hub-progress.ts
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { Board, Json, Stats } from '@diffenderfer-games/hub-contract';
|
|
8
8
|
import { HubConnection } from './connection';
|
|
9
|
+
import { HubError, SAVE_UNKNOWN_CODE } from './errors';
|
|
9
10
|
import { toGameData, toStoredJson, type GamePayload } from './game-data';
|
|
10
|
-
import
|
|
11
|
+
import { PROGRESS_KINDS, type ProfilePayload, type ScorePayload } from './offline/hub-kinds';
|
|
12
|
+
import { pageAccount, toPageUser } from './page-account';
|
|
13
|
+
import { isRecord } from './storage/json-guards';
|
|
11
14
|
import type { LeaderboardResult, MeResponse, Profile, Save, SaveMeta, SubmitScoreOptions, SubmitScoreResult, User } from './types';
|
|
12
15
|
|
|
13
16
|
/** A signed-in user, as the auth calls answer. */
|
|
@@ -44,9 +47,30 @@ function typedSave<TData>(save: Save | null): Save<TData> | null {
|
|
|
44
47
|
|
|
45
48
|
/** Accounts, profile, saves, stats and leaderboards. */
|
|
46
49
|
export class HubProgress extends HubConnection {
|
|
50
|
+
/** Who the page is signed in as, as the hub's events and this client's sign-ins tell it. */
|
|
51
|
+
protected readonly account = pageAccount();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Your user id, or `null` when nobody is signed in (also before an offline
|
|
55
|
+
* first visit has an account). Synchronous, and it follows the account as
|
|
56
|
+
* the hub changes it (a new guest's account made by the menu, sign-in,
|
|
57
|
+
* sign-out) with no `hub.me()` needed. Compare it with a seat's `userId`
|
|
58
|
+
* to find yours.
|
|
59
|
+
*/
|
|
60
|
+
userId(): number | null {
|
|
61
|
+
const heard = this.account.user;
|
|
62
|
+
const id = heard === undefined ? this.store.getUser()?.id ?? null : heard?.id ?? null;
|
|
63
|
+
// 0 is an offline local guest: no account on the hub yet.
|
|
64
|
+
return id === 0 ? null : id;
|
|
65
|
+
}
|
|
66
|
+
|
|
47
67
|
/** The current user (or `null`) and the games they have a profile in. */
|
|
48
68
|
me(): Promise<MeResponse> {
|
|
49
|
-
return this.withFallback(
|
|
69
|
+
return this.withFallback(async () => {
|
|
70
|
+
const reply = await this.rest.call('getCurrentUser');
|
|
71
|
+
this.account.note(reply.user === null ? null : toPageUser(reply.user));
|
|
72
|
+
return reply;
|
|
73
|
+
}, () => {
|
|
50
74
|
const profile = this.store.getProfile();
|
|
51
75
|
return { user: this.store.getUser(), profiles: profile === null ? [] : [{ game: profile.game, displayName: profile.displayName }] };
|
|
52
76
|
});
|
|
@@ -58,14 +82,12 @@ export class HubProgress extends HubConnection {
|
|
|
58
82
|
* sign-in. Idempotent. Offline, a local guest is used.
|
|
59
83
|
*/
|
|
60
84
|
ensureGuest(): Promise<AuthResponse> {
|
|
61
|
-
return this.withFallback<AuthResponse>(() => this.rest.call('ensureGuestSession'), () => {
|
|
85
|
+
return this.withFallback<AuthResponse>(async () => this.signedIn(await this.rest.call('ensureGuestSession')), () => {
|
|
62
86
|
const known = this.store.getUser();
|
|
63
87
|
if (known !== null) {
|
|
64
88
|
return { user: known };
|
|
65
89
|
}
|
|
66
|
-
|
|
67
|
-
this.store.setUser(guest);
|
|
68
|
-
return { user: guest };
|
|
90
|
+
return this.signInLocally({ id: 0, username: 'Guest-local', guest: true });
|
|
69
91
|
});
|
|
70
92
|
}
|
|
71
93
|
|
|
@@ -75,32 +97,53 @@ export class HubProgress extends HubConnection {
|
|
|
75
97
|
* the name is taken; the player should `login` instead.
|
|
76
98
|
*/
|
|
77
99
|
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 }));
|
|
100
|
+
return this.withFallback<AuthResponse>(async () => this.signedIn(await this.rest.call('claimGuestAccount', { username, password })), () => this.signInLocally({ id: 0, username, guest: false }));
|
|
79
101
|
}
|
|
80
102
|
|
|
81
103
|
/** Create an account and sign in. (Offline: a local user is stored.) */
|
|
82
104
|
signup(username: string, password: string): Promise<AuthResponse> {
|
|
83
|
-
return this.withFallback<AuthResponse>(() => this.rest.call('signUpWithPassword', { username, password }), () => this.signInLocally({ id: 0, username }));
|
|
105
|
+
return this.withFallback<AuthResponse>(async () => this.signedIn(await this.rest.call('signUpWithPassword', { username, password })), () => this.signInLocally({ id: 0, username }));
|
|
84
106
|
}
|
|
85
107
|
|
|
86
108
|
/** Sign in. (Offline: a local user is stored so signed-in flows work.) */
|
|
87
109
|
login(username: string, password: string): Promise<AuthResponse> {
|
|
88
|
-
return this.withFallback<AuthResponse>(() => this.rest.call('logInWithPassword', { username, password }), () => this.signInLocally({ id: 0, username }));
|
|
110
|
+
return this.withFallback<AuthResponse>(async () => this.signedIn(await this.rest.call('logInWithPassword', { username, password })), () => this.signInLocally({ id: 0, username }));
|
|
89
111
|
}
|
|
90
112
|
|
|
91
113
|
/** Sign out (ends the session shared by every game). */
|
|
92
114
|
logout(): Promise<{ ok: true }> {
|
|
93
|
-
return this.withFallback(
|
|
94
|
-
this.
|
|
115
|
+
return this.withFallback(async () => {
|
|
116
|
+
const reply = await this.rest.call('logOut');
|
|
117
|
+
this.signedOut();
|
|
118
|
+
return reply;
|
|
119
|
+
}, () => {
|
|
120
|
+
this.signedOut();
|
|
95
121
|
return { ok: true } as const;
|
|
96
122
|
});
|
|
97
123
|
}
|
|
98
124
|
|
|
99
125
|
private signInLocally(user: User): AuthResponse {
|
|
100
|
-
this.
|
|
126
|
+
this.remember(user);
|
|
101
127
|
return { user };
|
|
102
128
|
}
|
|
103
129
|
|
|
130
|
+
/** Remember who the hub signed in, so the cache's notes about the hub's copies are read for the right player. */
|
|
131
|
+
private signedIn(reply: AuthResponse): AuthResponse {
|
|
132
|
+
this.remember(reply.user);
|
|
133
|
+
return reply;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The cache and the page (`hub.userId()`, `hub.avatar`) learn who is signed in. */
|
|
137
|
+
private remember(user: User): void {
|
|
138
|
+
this.store.setUser(user);
|
|
139
|
+
this.account.note(toPageUser(user));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private signedOut(): void {
|
|
143
|
+
this.store.clearUser();
|
|
144
|
+
this.account.note(null);
|
|
145
|
+
}
|
|
146
|
+
|
|
104
147
|
/** This game's profile for the current user, or `profile: null`. `TData` is the game's own data type. */
|
|
105
148
|
getProfile<TData = Json>(): Promise<{ profile: Profile<TData> | null }> {
|
|
106
149
|
return this.withFallback(
|
|
@@ -111,14 +154,11 @@ export class HubProgress extends HubConnection {
|
|
|
111
154
|
|
|
112
155
|
/** Create or update this game's profile for the current user. */
|
|
113
156
|
setProfile<TData extends GamePayload = Json>(patch: ProfileUpdate<TData>): Promise<{ profile: Profile<TData> }> {
|
|
114
|
-
const queued:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
queued.data = toStoredJson(patch.data);
|
|
120
|
-
}
|
|
121
|
-
return this.mutateQueued({ kind: 'setProfile', patch: queued }, () => {
|
|
157
|
+
const queued: ProfilePayload = {
|
|
158
|
+
...(patch.displayName === undefined ? {} : { displayName: patch.displayName }),
|
|
159
|
+
...(patch.data === undefined ? {} : { data: toStoredJson(patch.data) }),
|
|
160
|
+
};
|
|
161
|
+
return this.mutateOffline(PROGRESS_KINDS.profile, queued, () => {
|
|
122
162
|
const stored = this.store.setProfile(this.slug ?? 'app', patch.displayName ?? null, queued.data ?? {});
|
|
123
163
|
return { profile: { ...stored, data: toGameData<TData>(stored.data) } };
|
|
124
164
|
});
|
|
@@ -129,24 +169,48 @@ export class HubProgress extends HubConnection {
|
|
|
129
169
|
return this.withFallback(() => this.rest.call('listSaves', this.gameFields()), () => ({ saves: this.store.listSaves() }));
|
|
130
170
|
}
|
|
131
171
|
|
|
132
|
-
/**
|
|
172
|
+
/**
|
|
173
|
+
* One slot's contents, or `save: null` when the slot is empty. `TData` is
|
|
174
|
+
* the game's own save type. Offline it answers from the device's copy, but
|
|
175
|
+
* only when the device knows the hub's copy of the slot; otherwise (a fresh
|
|
176
|
+
* device or cleared browser) it rejects with a {@link HubError} whose
|
|
177
|
+
* `code` is `save_unknown` (`isOffline` is `true`), never a false "empty".
|
|
178
|
+
*/
|
|
133
179
|
getSave<TData = Json>(slot: string): Promise<{ save: Save<TData> | null }> {
|
|
134
180
|
return this.withFallback(
|
|
135
|
-
async () =>
|
|
136
|
-
|
|
181
|
+
async () => {
|
|
182
|
+
const { save } = await this.rest.call('getSave', { ...this.gameFields(), slot });
|
|
183
|
+
if (this.outbox.count() === 0) {
|
|
184
|
+
this.store.cacheCloudSave(slot, save);
|
|
185
|
+
}
|
|
186
|
+
return { save: typedSave<TData>(save) };
|
|
187
|
+
},
|
|
188
|
+
() => ({ save: typedSave<TData>(this.offlineSave(slot)) }),
|
|
137
189
|
);
|
|
138
190
|
}
|
|
139
191
|
|
|
192
|
+
/** The device's copy of `slot`, when it can be trusted offline (see {@link getSave}). */
|
|
193
|
+
private offlineSave(slot: string): Save | null {
|
|
194
|
+
// Trusted: a slot whose hub copy the device has seen, a slot it wrote itself and hasn't sent
|
|
195
|
+
// yet, or any slot of a player signed in only on this device (offline, never reached the hub).
|
|
196
|
+
const isLocalOnly = this.store.getUser()?.id === 0;
|
|
197
|
+
const hasQueuedWrite = this.outbox.pending().some((op) => op.kind === PROGRESS_KINDS.save && isRecord(op.payload) && op.payload.slot === slot);
|
|
198
|
+
if (!isLocalOnly && !hasQueuedWrite && this.store.cloudSave(slot) === undefined) {
|
|
199
|
+
throw new HubError(`Hub: offline, and this device hasn't seen save "${slot}" yet.`, undefined, { code: SAVE_UNKNOWN_CODE });
|
|
200
|
+
}
|
|
201
|
+
return this.store.getSave(slot);
|
|
202
|
+
}
|
|
203
|
+
|
|
140
204
|
/** Create or overwrite a save slot. `saveData` is any JSON up to 64 KB. */
|
|
141
205
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- kept so v1 calls such as putSave<MySave>(...) still compile
|
|
142
206
|
putSave<TData extends GamePayload = Json>(slot: string, saveData: TData, label?: string): Promise<{ ok: true; updatedAt: number }> {
|
|
143
207
|
const stored = toStoredJson(saveData);
|
|
144
|
-
return this.
|
|
208
|
+
return this.mutateOffline(PROGRESS_KINDS.save, { slot, data: stored, label: label ?? null }, () => ({ ok: true as const, updatedAt: this.store.putSave(slot, stored, label ?? null) }));
|
|
145
209
|
}
|
|
146
210
|
|
|
147
211
|
/** Delete a save slot. */
|
|
148
212
|
deleteSave(slot: string): Promise<{ ok: true }> {
|
|
149
|
-
return this.
|
|
213
|
+
return this.mutateOffline(PROGRESS_KINDS.save, { slot, deleted: true }, () => {
|
|
150
214
|
this.store.deleteSave(slot);
|
|
151
215
|
return { ok: true as const };
|
|
152
216
|
});
|
|
@@ -162,27 +226,27 @@ export class HubProgress extends HubConnection {
|
|
|
162
226
|
|
|
163
227
|
/** Overwrite a stat. */
|
|
164
228
|
setStat(key: string, value: number): Promise<StatResult> {
|
|
165
|
-
return this.
|
|
229
|
+
return this.mutateOffline(PROGRESS_KINDS.stat, { key, mode: 'set', value }, () => ({ key, value: this.store.updateStat(key, value, 'set') }));
|
|
166
230
|
}
|
|
167
231
|
|
|
168
232
|
/** Add to a stat (creating it at `amount` when absent). */
|
|
169
233
|
incrStat(key: string, amount = 1): Promise<StatResult> {
|
|
170
|
-
return this.
|
|
234
|
+
return this.mutateOffline(PROGRESS_KINDS.stat, { key, mode: 'incr', value: amount }, () => ({ key, value: this.store.updateStat(key, amount, 'incr') }));
|
|
171
235
|
}
|
|
172
236
|
|
|
173
237
|
/** Keep the larger of the stored and the given value. */
|
|
174
238
|
maxStat(key: string, value: number): Promise<StatResult> {
|
|
175
|
-
return this.
|
|
239
|
+
return this.mutateOffline(PROGRESS_KINDS.stat, { key, mode: 'max', value }, () => ({ key, value: this.store.updateStat(key, value, 'max') }));
|
|
176
240
|
}
|
|
177
241
|
|
|
178
242
|
/** Submit a score. The board is created on first use; only a player's best score sticks. */
|
|
179
243
|
submitScore<TMeta extends GamePayload = Json>(boardKey: string, score: number, opts: SubmitScoreOptions<TMeta> = {}): Promise<SubmitScoreResult> {
|
|
180
|
-
const queued:
|
|
244
|
+
const queued: ScorePayload['opts'] = {
|
|
181
245
|
...(opts.title === undefined ? {} : { title: opts.title }),
|
|
182
246
|
...(opts.sort === undefined ? {} : { sort: opts.sort }),
|
|
183
247
|
...(opts.meta === undefined ? {} : { meta: toStoredJson(opts.meta) }),
|
|
184
248
|
};
|
|
185
|
-
return this.
|
|
249
|
+
return this.mutateOffline(PROGRESS_KINDS.score, { key: boardKey, score, opts: queued }, () => ({ ...this.store.submitScore(boardKey, score, queued), rank: 1 }));
|
|
186
250
|
}
|
|
187
251
|
|
|
188
252
|
/** One board's ranked entries (top `limit`, default 50). Offline: only the player's own best. */
|
package/src/core/hub.ts
CHANGED
|
@@ -4,17 +4,29 @@
|
|
|
4
4
|
* getters (`hub.input`, `hub.daily`, `hub.social`, `hub.mp`, `hub.rooms`,
|
|
5
5
|
* `hub.race`, ...), so v1 code such as `hub.input.define(...)` works unchanged.
|
|
6
6
|
*/
|
|
7
|
-
import type { HubVersion } from '@diffenderfer-games/hub-contract';
|
|
8
|
-
import { getDaily, type DailyHost, type DailySystem } from '../daily';
|
|
7
|
+
import type { HubVersion, ResumeSlot } from '@diffenderfer-games/hub-contract';
|
|
8
|
+
import { getDaily, type DailyCompleteResponse, type DailyHost, type DailySystem } from '../daily';
|
|
9
9
|
import { getInput, getUiStack, type InputSystem } from '../input';
|
|
10
10
|
import { mp } from '../mp';
|
|
11
11
|
import { race } from '../race';
|
|
12
12
|
import { rooms } from '../rooms';
|
|
13
|
+
import { pageBridge } from '../realtime/bridge';
|
|
14
|
+
import { isInHubRoom } from '../realtime/rooms-forwarder';
|
|
13
15
|
import { linkRealtimeToHub, notify, presence, social } from '../social';
|
|
14
|
-
import {
|
|
16
|
+
import { HubAvatar } from './avatar/hub-avatar';
|
|
17
|
+
import { HubError, OfflineError, toHubError } from './errors';
|
|
15
18
|
import { HubEconomy } from './hub-economy';
|
|
19
|
+
import { AVATAR_KIND, DAILY_KIND, RESUME_KIND, SAVED_KIND } from './offline/hub-kinds';
|
|
20
|
+
import { readDailyCompletion } from './offline/hub-kinds/daily-kind';
|
|
16
21
|
import { hubMenu, type HubMenuControl } from './menu-control';
|
|
17
|
-
import { emitWindowEvent } from './page';
|
|
22
|
+
import { detectGameTitle, emitWindowEvent } from './page';
|
|
23
|
+
import { HubResume, type ResumeHost } from './resume/hub-resume';
|
|
24
|
+
import { isResumeRequested } from './resume/resume-request';
|
|
25
|
+
import { DailySaves } from './saved/daily-saves';
|
|
26
|
+
import { HubSaved, type SavedHost } from './saved/hub-saved';
|
|
27
|
+
import { HubTurns, type TurnsHost } from './turns/hub-turns';
|
|
28
|
+
import { savedOpenedOnPage } from './saved/saved-open';
|
|
29
|
+
import type { SavedEntry } from './saved/saved-types';
|
|
18
30
|
import { fetchGameServerTicket } from './ticket';
|
|
19
31
|
import { clientSupport, readHubVersion, type ClientSupport } from './version/hub-version';
|
|
20
32
|
import { HUB_CLIENT_VERSION } from './version/client-version.gen';
|
|
@@ -25,6 +37,14 @@ export class Hub extends HubEconomy {
|
|
|
25
37
|
/** This client's package version (sent as `x-hub-client`). */
|
|
26
38
|
readonly clientVersion: string = HUB_CLIENT_VERSION;
|
|
27
39
|
|
|
40
|
+
private resumeApi: HubResume | undefined;
|
|
41
|
+
private avatarApi: HubAvatar | undefined;
|
|
42
|
+
private savedApi: HubSaved | undefined;
|
|
43
|
+
private dailySavesApi: DailySaves | undefined;
|
|
44
|
+
private turnsApi: HubTurns | undefined;
|
|
45
|
+
/** The online games open on this page (a run isn't saved while one is). */
|
|
46
|
+
private readonly openTurnGames = new Set<object>();
|
|
47
|
+
|
|
28
48
|
/**
|
|
29
49
|
* The hub's version and the client versions it supports (`GET /_api/version`),
|
|
30
50
|
* read once per page. `null` for a hub from before versioning.
|
|
@@ -101,14 +121,210 @@ export class Hub extends HubEconomy {
|
|
|
101
121
|
return race;
|
|
102
122
|
}
|
|
103
123
|
|
|
104
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* Resume a saved game: `hub.resume.save({ state, progress })` during a long
|
|
126
|
+
* single-player run, `clear()` when it ends, `load()` and `requested` to
|
|
127
|
+
* pick it up again (docs: "Resume saved games"). Needs `game.resumable: true`.
|
|
128
|
+
*/
|
|
129
|
+
get resume(): HubResume {
|
|
130
|
+
this.resumeApi ??= new HubResume(this.resumeHost());
|
|
131
|
+
return this.resumeApi;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Players' avatars: `hub.avatar.mine()`, `of(userId)`, `from(seat)`,
|
|
136
|
+
* `bot(seed)`, `on('change')`, `openDesigner()` and `load()` for the renderer
|
|
137
|
+
* (docs: "Avatars"). No art or renderer until `load()`.
|
|
138
|
+
*/
|
|
139
|
+
get avatar(): HubAvatar {
|
|
140
|
+
this.avatarApi ??= new HubAvatar({
|
|
141
|
+
currentUserId: () => this.userId(),
|
|
142
|
+
onAccount: (listener) => this.account.on(listener),
|
|
143
|
+
fetchMine: () => this.rest.call('getMyAvatar'),
|
|
144
|
+
fetchCodes: async (ids) => (await this.rest.call('getAvatars', { ids: [...ids] })).codes,
|
|
145
|
+
queueSave: async (code, savedAt) => {
|
|
146
|
+
await this.mutateOffline(AVATAR_KIND, { code, savedAt }, () => undefined);
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
return this.avatarApi;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Every game the player has in progress, of every kind: `hub.saved.list()`
|
|
154
|
+
* for this game's "Continue" list, `opened` and `onOpen` to open the one
|
|
155
|
+
* the player picked (docs: "Saved games").
|
|
156
|
+
*/
|
|
157
|
+
get saved(): HubSaved {
|
|
158
|
+
this.savedApi ??= new HubSaved(this.savedHost());
|
|
159
|
+
return this.savedApi;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Online games played over minutes or days, run by the hub from the
|
|
164
|
+
* game's rules module (`game.turns`): `hub.turns.create`, `join`, `open`
|
|
165
|
+
* (docs/multiplayer.md "Turn games").
|
|
166
|
+
*/
|
|
167
|
+
get turns(): HubTurns {
|
|
168
|
+
this.turnsApi ??= new HubTurns(this.turnsHost());
|
|
169
|
+
return this.turnsApi;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** What `hub.turns` needs from this client: its REST calls. */
|
|
173
|
+
private turnsHost(): TurnsHost {
|
|
174
|
+
const { rest } = this;
|
|
175
|
+
return {
|
|
176
|
+
slug: this.slug,
|
|
177
|
+
create: (body) => rest.call('createTurnGame', body),
|
|
178
|
+
join: (id) => rest.call('joinTurnGame', { id }),
|
|
179
|
+
joinByLink: (key) => rest.call('joinTurnGameByLink', { key }),
|
|
180
|
+
get: (id) => rest.call('getTurnGame', { id }),
|
|
181
|
+
start: (id) => rest.call('startTurnGame', { id }),
|
|
182
|
+
move: (id, seq, move) => rest.call('moveTurnGame', { id, seq, move }),
|
|
183
|
+
history: async (id, since) => (await rest.call('getTurnGameHistory', { id, since })).moves,
|
|
184
|
+
leave: async (id) => { await rest.call('leaveTurnGame', { id }); },
|
|
185
|
+
nudge: async (id) => { await rest.call('nudgeTurnGame', { id }); },
|
|
186
|
+
setNotify: async (id, mode) => { await rest.call('setTurnGameNotify', { id, mode }); },
|
|
187
|
+
rematch: (id) => rest.call('rematchTurnGame', { id }),
|
|
188
|
+
invite: (id, userIds) => rest.call('inviteToTurnGame', { id, userIds: [...userIds] }),
|
|
189
|
+
share: (id, share) => rest.call('shareTurnGame', { id, ...share }),
|
|
190
|
+
autoPlay: (id) => rest.call('autoPlayTurn', { id }),
|
|
191
|
+
chat: (id, send) => rest.call('chatInTurnGame', { id, ...send }),
|
|
192
|
+
listOpen: async (game) => (await rest.call('listOpenTurnGames', game === null ? {} : { game })).games,
|
|
193
|
+
list: () => this.saved.list({ kinds: ['turns'] }),
|
|
194
|
+
opened: (game) => { this.openTurnGames.add(game); },
|
|
195
|
+
closed: (game) => { this.openTurnGames.delete(game); },
|
|
196
|
+
isLive: () => pageBridge.current() !== null,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** What `hub.saved` needs from this client. */
|
|
201
|
+
private savedHost(): SavedHost {
|
|
202
|
+
return {
|
|
203
|
+
slug: this.slug,
|
|
204
|
+
fetchList: (game) => this.withFallback<readonly SavedEntry[] | undefined>(
|
|
205
|
+
async () => (await this.rest.call('listMySavedGames', game === null ? {} : { game })).games,
|
|
206
|
+
() => undefined,
|
|
207
|
+
),
|
|
208
|
+
deviceCopies: () => {
|
|
209
|
+
const userId = this.store.getUser()?.id ?? null;
|
|
210
|
+
return {
|
|
211
|
+
runs: new Map([...this.resumeStore.all()].filter(([, copy]) => copy.userId === userId)),
|
|
212
|
+
dailies: this.dailyStore.all(userId),
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
discard: (entry) => this.discardSaved(entry),
|
|
216
|
+
navigate: (url) => {
|
|
217
|
+
location.assign(url);
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Discard a daily or run: this game's run through `hub.resume` (works offline), others on the hub. */
|
|
223
|
+
private async discardSaved(entry: SavedEntry): Promise<void> {
|
|
224
|
+
if (entry.kind === 'run' && entry.game === this.slug) {
|
|
225
|
+
await this.resume.clear();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (!entry.id.startsWith('local:')) {
|
|
229
|
+
await this.rest.call('discardSavedGame', { id: entry.id });
|
|
230
|
+
}
|
|
231
|
+
if (entry.kind === 'run') {
|
|
232
|
+
this.resumeStore.remove(entry.game);
|
|
233
|
+
} else if (entry.daily !== undefined) {
|
|
234
|
+
this.dailyStore.remove(entry.game, entry.daily.day);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** What `hub.resume` needs from this client. */
|
|
239
|
+
private resumeHost(): ResumeHost {
|
|
240
|
+
return {
|
|
241
|
+
slug: this.slug,
|
|
242
|
+
local: this.resumeStore,
|
|
243
|
+
currentUserId: () => this.store.getUser()?.id ?? null,
|
|
244
|
+
gameTitle: () => detectGameTitle(this.slug ?? ''),
|
|
245
|
+
blocker: () => {
|
|
246
|
+
if (race.current() !== null) {
|
|
247
|
+
return 'race';
|
|
248
|
+
}
|
|
249
|
+
if (this.openTurnGames.size > 0) {
|
|
250
|
+
return 'turns';
|
|
251
|
+
}
|
|
252
|
+
return isInHubRoom() ? 'room' : null;
|
|
253
|
+
},
|
|
254
|
+
daily: () => {
|
|
255
|
+
const daily = this.daily;
|
|
256
|
+
return daily.active === null ? null : daily;
|
|
257
|
+
},
|
|
258
|
+
send: async (payload) => {
|
|
259
|
+
await this.mutateOffline(RESUME_KIND, payload, () => undefined);
|
|
260
|
+
},
|
|
261
|
+
fetchSlot: () => this.withFallback<ResumeSlot | null | undefined>(async () => {
|
|
262
|
+
try {
|
|
263
|
+
return (await this.rest.call('getResumeSlot', this.gameFields())).resume;
|
|
264
|
+
} catch (failure) {
|
|
265
|
+
if (failure instanceof HubError && failure.status === 401) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
throw failure;
|
|
269
|
+
}
|
|
270
|
+
}, () => undefined),
|
|
271
|
+
fetchList: () => this.withFallback(async () => (await this.rest.call('listMyResumeSlots')).games, () => undefined),
|
|
272
|
+
clearOther: async (slug) => {
|
|
273
|
+
await this.rest.call('clearResumeSlot', { slug });
|
|
274
|
+
},
|
|
275
|
+
onChange: () => {
|
|
276
|
+
this.savedApi?.changed();
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** This game's daily saves (device copies and their sends). */
|
|
282
|
+
private dailySaves(): DailySaves {
|
|
283
|
+
const slug = this.slug;
|
|
284
|
+
if (slug === null) {
|
|
285
|
+
throw new HubError('hub.daily: could not determine the game slug.');
|
|
286
|
+
}
|
|
287
|
+
this.dailySavesApi ??= new DailySaves({
|
|
288
|
+
slug,
|
|
289
|
+
local: this.dailyStore,
|
|
290
|
+
currentUserId: () => this.store.getUser()?.id ?? null,
|
|
291
|
+
gameTitle: () => detectGameTitle(slug),
|
|
292
|
+
send: async (payload) => {
|
|
293
|
+
await this.mutateOffline(SAVED_KIND, payload, () => undefined);
|
|
294
|
+
this.savedApi?.changed();
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
return this.dailySavesApi;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** What the daily system needs from this client: its REST calls, the device copies and window events. */
|
|
105
301
|
private dailyHost(): DailyHost {
|
|
106
302
|
return {
|
|
107
303
|
slug: this.slug,
|
|
108
304
|
status: () => this.rest.call('getGameDailyChallenges', this.gameFields()),
|
|
109
305
|
all: () => this.rest.call('listDailyChallengesForAllGames'),
|
|
110
|
-
start: (day) => this.
|
|
111
|
-
|
|
306
|
+
start: (day) => this.withFallback(
|
|
307
|
+
async () => this.dailySaves().remember(await this.rest.call('startDailyChallenge', { ...this.gameFields(), day })),
|
|
308
|
+
() => this.dailySaves().startLocally(day),
|
|
309
|
+
),
|
|
310
|
+
resume: async (instanceId) => this.dailySaves().remember(await this.rest.call('resumeDailyChallenge', { id: instanceId })),
|
|
311
|
+
complete: async (day, token, meta) => {
|
|
312
|
+
this.dailySaves().finished(day);
|
|
313
|
+
const completed = await this.mutateOffline<DailyCompleteResponse | null>(DAILY_KIND, { day, token, meta }, () => null, (result) => readDailyCompletion(result));
|
|
314
|
+
if (completed !== null) {
|
|
315
|
+
this.dailySaves().completed(day);
|
|
316
|
+
}
|
|
317
|
+
this.savedApi?.changed();
|
|
318
|
+
return completed;
|
|
319
|
+
},
|
|
320
|
+
save: (challenge, input) => {
|
|
321
|
+
const isSaved = this.dailySaves().save(challenge, input);
|
|
322
|
+
this.savedApi?.changed();
|
|
323
|
+
return isSaved;
|
|
324
|
+
},
|
|
325
|
+
flush: () => this.dailySaves().flush(),
|
|
326
|
+
discard: (challenge) => this.dailySaves().discard(challenge),
|
|
327
|
+
inProgress: () => this.saved.list({ kinds: ['daily'] }),
|
|
112
328
|
emit: (eventName, detail) => {
|
|
113
329
|
emitWindowEvent(eventName, detail);
|
|
114
330
|
},
|
|
@@ -125,6 +341,11 @@ linkRealtimeToHub({
|
|
|
125
341
|
/** A ready-to-use client, with the slug and API base detected from the page. */
|
|
126
342
|
export const hub: Hub = new Hub();
|
|
127
343
|
|
|
344
|
+
// Take `?hub_resume`, `?hub_open` and `?hub_kind` out of the address bar before the game's router sees them
|
|
345
|
+
// (the answers stay on `hub.resume.requested` and `hub.saved.opened`).
|
|
346
|
+
isResumeRequested();
|
|
347
|
+
savedOpenedOnPage();
|
|
348
|
+
|
|
128
349
|
/** Create a client for another slug or API base, or with other offline behaviour. */
|
|
129
350
|
export function createHub(options: HubOptions = {}): Hub {
|
|
130
351
|
return new Hub(options);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this device last saw of a game's save slots on the hub, per player:
|
|
3
|
+
* each slot's `updatedAt` there, or `null` for a slot known to be empty. It
|
|
4
|
+
* decides two things offline. `getSave` answers from the cache only when
|
|
5
|
+
* the device knows the hub's copy of that slot (so a fresh device or cleared
|
|
6
|
+
* browser never reports "no save" for a slot it simply hasn't seen), and a
|
|
7
|
+
* queued save write sends the hub's `updatedAt` it was built on, so the hub
|
|
8
|
+
* refuses it when the slot has changed since.
|
|
9
|
+
*
|
|
10
|
+
* A snapshot gives the full list (`complete`: any other slot is empty); a
|
|
11
|
+
* single read or write adds that slot to a partial list.
|
|
12
|
+
*/
|
|
13
|
+
import { isRecord, optionalNumber } from '../storage/json-guards';
|
|
14
|
+
|
|
15
|
+
/** The hub's save slots for one game, as this device last saw them. */
|
|
16
|
+
export interface CloudSaveIndex {
|
|
17
|
+
/** Whose slots these are. */
|
|
18
|
+
readonly userId: number;
|
|
19
|
+
/** `true` when every slot the hub had is listed (from a snapshot). */
|
|
20
|
+
readonly complete: boolean;
|
|
21
|
+
/** Each slot's `updatedAt` on the hub, or `null` when it was empty. */
|
|
22
|
+
readonly saves: Readonly<Record<string, number | null>>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A stored index, or `null` when missing or malformed. */
|
|
26
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: an index read back from localStorage
|
|
27
|
+
export function readCloudSaveIndex(stored: unknown): CloudSaveIndex | null {
|
|
28
|
+
if (!isRecord(stored) || typeof stored.complete !== 'boolean' || !isRecord(stored.saves)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const userId = optionalNumber(stored.userId);
|
|
32
|
+
if (userId === undefined) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const saves: Record<string, number | null> = {};
|
|
36
|
+
for (const [slot, updatedAt] of Object.entries(stored.saves)) {
|
|
37
|
+
const known = updatedAt === null ? null : optionalNumber(updatedAt);
|
|
38
|
+
if (known === undefined) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
saves[slot] = known;
|
|
42
|
+
}
|
|
43
|
+
return { userId, complete: stored.complete, saves };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The hub's `updatedAt` for `slot` as `userId` last saw it: a number, `null`
|
|
48
|
+
* when the slot was empty, or `undefined` when this device doesn't know.
|
|
49
|
+
*/
|
|
50
|
+
export function cloudSaveOf(index: CloudSaveIndex | null, userId: number, slot: string): number | null | undefined {
|
|
51
|
+
if (index?.userId !== userId) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
const updatedAt = index.saves[slot];
|
|
55
|
+
return updatedAt === undefined && index.complete ? null : updatedAt;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `index` with `slot` seen at `updatedAt` (`null`: empty) by `userId`; another player's index starts over. */
|
|
59
|
+
export function withCloudSave(index: CloudSaveIndex | null, userId: number, slot: string, updatedAt: number | null): CloudSaveIndex {
|
|
60
|
+
const base = index?.userId === userId ? index : { userId, complete: false, saves: {} };
|
|
61
|
+
return { ...base, saves: { ...base.saves, [slot]: updatedAt } };
|
|
62
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hub's avatar kind (`hub.avatar`, avatars.md §5.6): save your look.
|
|
3
|
+
* Latest wins: a newer save replaces a waiting one, and the hub keeps the
|
|
4
|
+
* newer of the replayed save and one another device made since (`savedAt`).
|
|
5
|
+
*/
|
|
6
|
+
import type { Json } from '@diffenderfer-games/hub-contract';
|
|
7
|
+
import { decodeLook } from '@diffenderfer-games/hub-contract/avatar';
|
|
8
|
+
import { isRecord, optionalNumber, optionalString } from '../../storage/json-guards';
|
|
9
|
+
import { checkedKind } from '../kind-adapters';
|
|
10
|
+
import type { RegisteredKind } from '../outbox-kinds';
|
|
11
|
+
|
|
12
|
+
/** The kind name. */
|
|
13
|
+
export const AVATAR_KIND = 'hub.avatar';
|
|
14
|
+
|
|
15
|
+
/** Save this look (its code) as of `savedAt`. */
|
|
16
|
+
export type AvatarPayload = { readonly code: string; readonly savedAt: number };
|
|
17
|
+
|
|
18
|
+
function readAvatar(stored: Json): AvatarPayload | undefined {
|
|
19
|
+
if (!isRecord(stored)) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const code = optionalString(stored.code);
|
|
23
|
+
const savedAt = optionalNumber(stored.savedAt);
|
|
24
|
+
return code === undefined || savedAt === undefined ? undefined : { code, savedAt };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The avatar kind, by name. */
|
|
28
|
+
export function avatarKinds(): Record<string, RegisteredKind> {
|
|
29
|
+
return {
|
|
30
|
+
[AVATAR_KIND]: checkedKind(readAvatar, {
|
|
31
|
+
mergeKey: () => 'avatar',
|
|
32
|
+
replaces: true,
|
|
33
|
+
run: async (op, context) => {
|
|
34
|
+
const look = decodeLook(op.code)?.look;
|
|
35
|
+
if (look !== undefined) {
|
|
36
|
+
await context.call('saveMyAvatar', { look: { ...look, accessories: [...look.accessories] }, savedAt: op.savedAt });
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
};
|
|
42
|
+
}
|