@diffenderfer-games/hub 2.0.0-next.4 → 2.0.0-next.6
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 +54 -11
- 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 +1804 -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 +101 -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 +537 -17
- 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 +2217 -199
- package/dist/shared/src.js.map +1 -1
- package/dist/shared/svg-markup.js +1860 -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 +9 -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 +68 -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 +76 -0
- package/src/avatar/face.ts +187 -0
- package/src/avatar/geom.ts +168 -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 +258 -0
- package/src/avatar/render/context.ts +117 -0
- package/src/avatar/render/eyes.ts +78 -0
- package/src/avatar/render/eyewear.ts +56 -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 +9 -0
- package/src/core/errors.ts +15 -2
- package/src/core/hub-progress.ts +269 -202
- package/src/core/hub.ts +174 -6
- 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/index.ts +14 -3
- package/src/core/offline/hub-kinds/progress-kinds.ts +21 -4
- package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
- package/src/core/offline/local-store.ts +46 -1
- 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 +11 -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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who the page is signed in as, as the hub tells it, so `hub.userId()` and
|
|
3
|
+
* `hub.avatar` follow the account without the game calling `hub.me()`:
|
|
4
|
+
*
|
|
5
|
+
* - the menu's `hub:account` (every refresh: after it makes a new guest's
|
|
6
|
+
* account, after sign-in) and `hub:auth` (the account changed);
|
|
7
|
+
* - the social runtime's {@link SESSION_EVENT} each time its socket says
|
|
8
|
+
* hello (connected, or connected again after a drop);
|
|
9
|
+
* - this client's own sign-in, sign-up, claim and sign-out.
|
|
10
|
+
*
|
|
11
|
+
* One per page: every bundle's copy hears the same window events.
|
|
12
|
+
*/
|
|
13
|
+
import { readField } from '../realtime/cross-bundle';
|
|
14
|
+
import { SESSION_EVENT } from '../realtime/runtime-api';
|
|
15
|
+
import { isBrowserPage } from './page';
|
|
16
|
+
|
|
17
|
+
/** The window events that say who is signed in (`{ user }`). */
|
|
18
|
+
const ACCOUNT_EVENTS = ['hub:account', 'hub:auth'] as const;
|
|
19
|
+
|
|
20
|
+
/** The signed-in player as the page heard it. */
|
|
21
|
+
export interface PageUser {
|
|
22
|
+
readonly id: number;
|
|
23
|
+
/** Their look code when the source carried it, else `null`. */
|
|
24
|
+
readonly look: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Where the news came from: an account event, or a socket hello (a time to catch up on what was missed). */
|
|
28
|
+
export type AccountSource = 'account' | 'session';
|
|
29
|
+
|
|
30
|
+
/** Hears every report of who is signed in. */
|
|
31
|
+
export type AccountListener = (user: PageUser | null, source: AccountSource) => void;
|
|
32
|
+
|
|
33
|
+
/** The page's account, as last heard. */
|
|
34
|
+
export class PageAccount {
|
|
35
|
+
/** `undefined` until something said; `null` when signed out. */
|
|
36
|
+
private heard: PageUser | null | undefined = undefined;
|
|
37
|
+
private readonly listeners = new Set<AccountListener>();
|
|
38
|
+
private isListening = false;
|
|
39
|
+
|
|
40
|
+
constructor() {
|
|
41
|
+
this.listen();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The signed-in player, `null` when signed out, `undefined` before anything said. */
|
|
45
|
+
get user(): PageUser | null | undefined {
|
|
46
|
+
this.listen();
|
|
47
|
+
return this.heard;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Record who is signed in (this client's own sign-in or sign-out) and tell the listeners. */
|
|
51
|
+
note(user: PageUser | null, source: AccountSource = 'account'): void {
|
|
52
|
+
this.heard = user;
|
|
53
|
+
for (const listener of [...this.listeners]) {
|
|
54
|
+
listener(user, source);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Hear every report; returns the unsubscribe function. */
|
|
59
|
+
on(listener: AccountListener): () => void {
|
|
60
|
+
this.listen();
|
|
61
|
+
this.listeners.add(listener);
|
|
62
|
+
return () => {
|
|
63
|
+
this.listeners.delete(listener);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Listen to the window events (at once in a page; made before a window existed, on first use). */
|
|
68
|
+
private listen(): void {
|
|
69
|
+
if (this.isListening || !isBrowserPage()) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
this.isListening = true;
|
|
73
|
+
for (const eventName of ACCOUNT_EVENTS) {
|
|
74
|
+
window.addEventListener(eventName, (event) => { this.onEvent(event, 'account'); });
|
|
75
|
+
}
|
|
76
|
+
window.addEventListener(SESSION_EVENT, (event) => { this.onEvent(event, 'session'); });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private onEvent(event: Event, source: AccountSource): void {
|
|
80
|
+
if (!(event instanceof CustomEvent)) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const user = readUser(event.detail);
|
|
84
|
+
if (user !== undefined) {
|
|
85
|
+
this.note(user, source);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A page user from an account the hub sent (`look` when it has one). */
|
|
91
|
+
export function toPageUser(user: { readonly id: number; readonly look?: string | undefined }): PageUser {
|
|
92
|
+
return { id: user.id, look: lookOrNull(user.look) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A non-empty look code, else `null`. */
|
|
96
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: a field of another bundle's event detail
|
|
97
|
+
function lookOrNull(look: unknown): string | null {
|
|
98
|
+
return typeof look === 'string' && look !== '' ? look : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The user in a `{ user }` detail: the user, `null` (signed out), or `undefined` (it doesn't say). */
|
|
102
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: another bundle's event detail, read field by field
|
|
103
|
+
function readUser(detail: unknown): PageUser | null | undefined {
|
|
104
|
+
const user = readField(detail, 'user');
|
|
105
|
+
if (user === null) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const id = readField(user, 'id');
|
|
109
|
+
if (typeof id !== 'number' || !Number.isInteger(id)) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
return { id, look: lookOrNull(readField(user, 'look')) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let pageAccountSingleton: PageAccount | null = null;
|
|
116
|
+
|
|
117
|
+
/** The page's account tracker (made on first use; the hub client makes it at load). */
|
|
118
|
+
export function pageAccount(): PageAccount {
|
|
119
|
+
pageAccountSingleton ??= new PageAccount();
|
|
120
|
+
return pageAccountSingleton;
|
|
121
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Generated by tools/hub-client/generate.ts. Do not edit; run `npm run gen:hub-client
|
|
1
|
+
// Generated by tools/hub-client/generate.ts. Do not edit; run `npm run gen:hub-client`.
|
|
2
2
|
/** Every public REST endpoint's method, path (under `/_api`), query keys and offline mode (`'replay'` when it may be queued offline), from the contract. */
|
|
3
3
|
export const ROUTES = {
|
|
4
4
|
ensureGuestSession: { method: 'POST', path: '/auth/guest', query: [], offline: null },
|
|
@@ -31,6 +31,7 @@ export const ROUTES = {
|
|
|
31
31
|
submitScore: { method: 'POST', path: '/games/:slug/leaderboards/:key/scores', query: [], offline: 'replay' },
|
|
32
32
|
listDailyChallengesForAllGames: { method: 'GET', path: '/daily', query: [], offline: null },
|
|
33
33
|
getGameDailyChallenges: { method: 'GET', path: '/games/:slug/daily', query: [], offline: null },
|
|
34
|
+
resumeDailyChallenge: { method: 'POST', path: '/daily/instances/:id/resume', query: [], offline: null },
|
|
34
35
|
startDailyChallenge: { method: 'POST', path: '/games/:slug/daily/:day/start', query: [], offline: null },
|
|
35
36
|
completeDailyChallenge: { method: 'POST', path: '/games/:slug/daily/:day/complete', query: [], offline: 'replay' },
|
|
36
37
|
getMyInventory: { method: 'GET', path: '/games/:slug/inventory', query: [], offline: null },
|
|
@@ -71,6 +72,7 @@ export const ROUTES = {
|
|
|
71
72
|
getNotificationPreferences: { method: 'GET', path: '/social/notify', query: [], offline: null },
|
|
72
73
|
unsubscribeFromPush: { method: 'DELETE', path: '/social/push', query: [], offline: null },
|
|
73
74
|
setNotificationPreferences: { method: 'PUT', path: '/social/notify', query: [], offline: null },
|
|
75
|
+
setQuietHours: { method: 'PUT', path: '/social/notify/quiet', query: [], offline: null },
|
|
74
76
|
subscribeToPush: { method: 'POST', path: '/social/push', query: [], offline: null },
|
|
75
77
|
fileReport: { method: 'POST', path: '/social/reports', query: [], offline: null },
|
|
76
78
|
listPublicSuggestions: { method: 'GET', path: '/suggestions', query: ['sort'], offline: null },
|
|
@@ -109,6 +111,34 @@ export const ROUTES = {
|
|
|
109
111
|
saveResumeSlot: { method: 'PUT', path: '/games/:slug/resume', query: [], offline: 'replay' },
|
|
110
112
|
clearResumeSlot: { method: 'DELETE', path: '/games/:slug/resume', query: ['before'], offline: 'replay' },
|
|
111
113
|
listMyResumeSlots: { method: 'GET', path: '/me/resume', query: [], offline: null },
|
|
114
|
+
getMyAvatar: { method: 'GET', path: '/avatar/me', query: [], offline: null },
|
|
115
|
+
recordAvatarNudge: { method: 'POST', path: '/avatar/me/nudge', query: [], offline: null },
|
|
116
|
+
saveMyAvatar: { method: 'PUT', path: '/avatar/me', query: [], offline: null },
|
|
117
|
+
resetMyAvatar: { method: 'DELETE', path: '/avatar/me', query: [], offline: null },
|
|
118
|
+
getAvatar: { method: 'GET', path: '/avatar/of/:id', query: [], offline: null },
|
|
119
|
+
getAvatars: { method: 'POST', path: '/avatar/batch', query: [], offline: null },
|
|
120
|
+
listMySavedGames: { method: 'GET', path: '/saved', query: ['game', 'kinds'], offline: null },
|
|
121
|
+
getSavedGame: { method: 'GET', path: '/saved/:id', query: [], offline: null },
|
|
122
|
+
saveSavedGameState: { method: 'PUT', path: '/saved/:id/state', query: [], offline: 'replay' },
|
|
123
|
+
discardSavedGame: { method: 'POST', path: '/saved/:id/discard', query: [], offline: 'replay' },
|
|
124
|
+
discardGameSavedGames: { method: 'POST', path: '/games/:slug/saved/discard', query: [], offline: null },
|
|
125
|
+
createTurnGame: { method: 'POST', path: '/turns', query: [], offline: null },
|
|
126
|
+
joinTurnGame: { method: 'POST', path: '/turns/:id/join', query: [], offline: null },
|
|
127
|
+
joinTurnGameByLink: { method: 'POST', path: '/turns/join', query: [], offline: null },
|
|
128
|
+
startTurnGame: { method: 'POST', path: '/turns/:id/start', query: [], offline: null },
|
|
129
|
+
moveTurnGame: { method: 'POST', path: '/turns/:id/moves', query: [], offline: null },
|
|
130
|
+
leaveTurnGame: { method: 'POST', path: '/turns/:id/leave', query: [], offline: null },
|
|
131
|
+
nudgeTurnGame: { method: 'POST', path: '/turns/:id/nudge', query: [], offline: null },
|
|
132
|
+
setTurnGameNotify: { method: 'PUT', path: '/turns/:id/notify', query: [], offline: null },
|
|
133
|
+
rematchTurnGame: { method: 'POST', path: '/turns/:id/rematch', query: [], offline: null },
|
|
134
|
+
inviteToTurnGame: { method: 'POST', path: '/turns/:id/invite', query: [], offline: null },
|
|
135
|
+
shareTurnGame: { method: 'POST', path: '/turns/:id/share', query: [], offline: null },
|
|
136
|
+
autoPlayTurn: { method: 'POST', path: '/turns/:id/auto', query: [], offline: null },
|
|
137
|
+
chatInTurnGame: { method: 'POST', path: '/turns/:id/chat', query: [], offline: null },
|
|
138
|
+
getTurnGame: { method: 'GET', path: '/turns/:id', query: [], offline: null },
|
|
139
|
+
getTurnGameHistory: { method: 'GET', path: '/turns/:id/history', query: ['since'], offline: null },
|
|
140
|
+
listOpenTurnGames: { method: 'GET', path: '/open-turns', query: ['game'], offline: null },
|
|
141
|
+
listMyTurnGames: { method: 'GET', path: '/turns', query: ['game'], offline: null },
|
|
112
142
|
} as const;
|
|
113
143
|
|
|
114
144
|
/** The request header that carries an offline operation key (the contract's `OFFLINE_OP_HEADER`). */
|
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* - `load()` / `get()` read it back; `requested` says the page was opened to
|
|
12
12
|
* resume, so the game restores without asking.
|
|
13
13
|
*
|
|
14
|
-
* Saves are ignored while the page is in a race
|
|
15
|
-
*
|
|
14
|
+
* Saves are ignored while the page is in a race or an online room: those
|
|
15
|
+
* runs are never resumed. During a daily challenge they go to the day's own
|
|
16
|
+
* saved game (`hub.daily.save`), so the day can be picked up again.
|
|
16
17
|
*/
|
|
17
18
|
import type { ResumeSlot, ResumeSummary } from '@diffenderfer-games/hub-contract';
|
|
18
19
|
import { HubError } from '../errors';
|
|
@@ -35,6 +36,7 @@ const BLOCKED_HINTS: Readonly<Record<ResumeBlocker, string>> = {
|
|
|
35
36
|
daily: 'a daily challenge is active. When the player leaves a daily without finishing it, call hub.daily.cancel().',
|
|
36
37
|
race: 'a race is on: races are never resumed.',
|
|
37
38
|
room: 'the page is in a hub room: online games are never resumed.',
|
|
39
|
+
turns: 'an online game is open: the hub keeps its state, so nothing is saved on the page.',
|
|
38
40
|
};
|
|
39
41
|
|
|
40
42
|
/** Longest label or title kept (the contract's `RESUME_TEXT_MAX`). */
|
|
@@ -51,6 +53,8 @@ export interface ResumeHost {
|
|
|
51
53
|
gameTitle(): string;
|
|
52
54
|
/** Why saving is off right now, or `null`. */
|
|
53
55
|
blocker(): ResumeBlocker | null;
|
|
56
|
+
/** The page's daily challenge while one is being played (saves go to the day instead of the run), else `null`. */
|
|
57
|
+
daily?(): DailyRoute | null;
|
|
54
58
|
/** Perform a resume op: at once when online with nothing waiting, else queued. */
|
|
55
59
|
send(payload: ResumePayload): Promise<void>;
|
|
56
60
|
/** This game's run on the hub, or `undefined` when the hub can't be reached. */
|
|
@@ -59,8 +63,18 @@ export interface ResumeHost {
|
|
|
59
63
|
fetchList(): Promise<ResumeSummary[] | undefined>;
|
|
60
64
|
/** Clear another game's run on the hub (the home page's discard); throws offline. */
|
|
61
65
|
clearOther(slug: string): Promise<void>;
|
|
66
|
+
/** The run changed here (saved or cleared): `hub.saved` listeners hear it. */
|
|
67
|
+
onChange?(): void;
|
|
62
68
|
}
|
|
63
69
|
|
|
70
|
+
/** Where a save goes during a daily challenge (`hub.daily`). */
|
|
71
|
+
export interface DailyRoute {
|
|
72
|
+
save(input: ResumeSaveInput<GamePayload>): boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The label a daily goes back to when the game clears its run mid-daily (the day restarts from its seed). */
|
|
76
|
+
const DAILY_RESTART_LABEL = 'Started';
|
|
77
|
+
|
|
64
78
|
/** A label or title trimmed and clipped, or `null` when blank. */
|
|
65
79
|
function shortText(text: string | null | undefined): string | null {
|
|
66
80
|
const trimmed = (text ?? '').trim();
|
|
@@ -111,8 +125,9 @@ export class HubResume {
|
|
|
111
125
|
/**
|
|
112
126
|
* Save the run (the latest save wins). Stored on the device at once; sent
|
|
113
127
|
* after {@link RESUME_DEBOUNCE_MS} of quiet, or when the page is hidden.
|
|
114
|
-
* Returns `false` (and saves nothing) in a race
|
|
115
|
-
*
|
|
128
|
+
* Returns `false` (and saves nothing) in a race or an online room; during
|
|
129
|
+
* a daily challenge the save goes to the day (`hub.daily.save`). Throws a
|
|
130
|
+
* `HubError` 413 over 512 KB, 400 without a label.
|
|
116
131
|
*/
|
|
117
132
|
save<TState extends GamePayload>(run: ResumeSaveInput<TState>): boolean {
|
|
118
133
|
const blocker = this.host.blocker();
|
|
@@ -120,6 +135,11 @@ export class HubResume {
|
|
|
120
135
|
this.warnBlocked(blocker);
|
|
121
136
|
return false;
|
|
122
137
|
}
|
|
138
|
+
const daily = this.host.daily?.() ?? null;
|
|
139
|
+
if (daily !== null) {
|
|
140
|
+
// During a daily challenge the run IS the day: it is saved to the day's saved game, not the game's run.
|
|
141
|
+
return daily.save(run);
|
|
142
|
+
}
|
|
123
143
|
const slug = this.requireSlug();
|
|
124
144
|
const label = shortText(run.progress.label);
|
|
125
145
|
if (label === null) {
|
|
@@ -135,6 +155,7 @@ export class HubResume {
|
|
|
135
155
|
version: Math.max(0, Math.floor(run.version ?? 0)), gameTitle: this.host.gameTitle(), state, isSynced: false,
|
|
136
156
|
};
|
|
137
157
|
const isStored = this.host.local.write(slug, copy);
|
|
158
|
+
this.host.onChange?.();
|
|
138
159
|
this.schedule({
|
|
139
160
|
action: 'save', savedAt: copy.at, label, fraction, title: copy.title, version: copy.version,
|
|
140
161
|
...(isStored ? {} : { state }),
|
|
@@ -158,6 +179,17 @@ export class HubResume {
|
|
|
158
179
|
/** Clear the run: call it when the run ends (won, lost or quit). Works offline. */
|
|
159
180
|
async clear(): Promise<void> {
|
|
160
181
|
const slug = this.requireSlug();
|
|
182
|
+
const daily = this.host.daily?.() ?? null;
|
|
183
|
+
if (daily !== null) {
|
|
184
|
+
// The day's run ended without a win: the day stays in progress, starting over from its seed.
|
|
185
|
+
daily.save({ state: null, progress: { label: DAILY_RESTART_LABEL } });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
await this.clearRun(slug);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Delete the game's saved run (never the day being played). */
|
|
192
|
+
private async clearRun(slug: string): Promise<void> {
|
|
161
193
|
if (this.timer !== null) {
|
|
162
194
|
clearTimeout(this.timer);
|
|
163
195
|
this.timer = null;
|
|
@@ -165,6 +197,7 @@ export class HubResume {
|
|
|
165
197
|
this.waiting = null;
|
|
166
198
|
const at = Date.now();
|
|
167
199
|
this.host.local.write(slug, { status: 'cleared', userId: this.host.currentUserId(), at, isSynced: false });
|
|
200
|
+
this.host.onChange?.();
|
|
168
201
|
await this.host.send({ action: 'clear', before: at });
|
|
169
202
|
}
|
|
170
203
|
|
|
@@ -186,7 +219,8 @@ export class HubResume {
|
|
|
186
219
|
return run;
|
|
187
220
|
}
|
|
188
221
|
if (run.version < options.version) {
|
|
189
|
-
|
|
222
|
+
// A stale saved run is dropped; a daily being played is left alone.
|
|
223
|
+
await this.clearRun(slug);
|
|
190
224
|
}
|
|
191
225
|
return null;
|
|
192
226
|
}
|
|
@@ -60,5 +60,5 @@ export interface ResumeEntry {
|
|
|
60
60
|
readonly isLocalOnly: boolean;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/** Why a save was ignored: the page is in a race
|
|
64
|
-
export type ResumeBlocker = 'race' | 'daily' | 'room';
|
|
63
|
+
/** Why a save was ignored: the page is in a race or an online room. (`'daily'` is no longer returned: a save during a daily goes to the day.) */
|
|
64
|
+
export type ResumeBlocker = 'race' | 'daily' | 'room' | 'turns';
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A game's daily challenges in progress on this device: what `hub.daily`
|
|
3
|
+
* saves and discards, kept in the device copy at once (`LocalDailyStore`)
|
|
4
|
+
* and sent through the `hub.saved` outbox kind after a short quiet spell
|
|
5
|
+
* (at once when the page hides), so a day survives a reload, offline play
|
|
6
|
+
* and a tab closed mid-move. A day started while the hub can't be reached
|
|
7
|
+
* gets a device-only id; its first send starts it on the hub.
|
|
8
|
+
*/
|
|
9
|
+
import type { DailyChallenge, DailySaveInput, DailyStartResponse } from '../../daily/types';
|
|
10
|
+
import { HubError } from '../errors';
|
|
11
|
+
import { toStoredJson } from '../game-data';
|
|
12
|
+
import type { SavedPayload } from '../offline/hub-kinds';
|
|
13
|
+
import { isBrowserPage } from '../page';
|
|
14
|
+
import { dailySeed } from './daily-seed';
|
|
15
|
+
import type { LocalDaily, LocalDailyStore } from './local-saved';
|
|
16
|
+
import { localDailyId } from './saved-list';
|
|
17
|
+
|
|
18
|
+
/** How long saves must be quiet before the latest is sent, ms (the same as `hub.resume`). */
|
|
19
|
+
export const DAILY_SAVE_DEBOUNCE_MS = 3000;
|
|
20
|
+
|
|
21
|
+
/** Largest state, bytes of JSON (the contract's `SAVED_MAX_STATE_BYTES`). */
|
|
22
|
+
const MAX_STATE_BYTES = 512 * 1024;
|
|
23
|
+
|
|
24
|
+
/** Longest label or title kept. */
|
|
25
|
+
const TEXT_MAX = 60;
|
|
26
|
+
|
|
27
|
+
/** The label of a day started and not saved yet. */
|
|
28
|
+
const STARTED_LABEL = 'Started';
|
|
29
|
+
|
|
30
|
+
/** What the daily saves need from their client. */
|
|
31
|
+
export interface DailySavesHost {
|
|
32
|
+
readonly slug: string;
|
|
33
|
+
readonly local: LocalDailyStore;
|
|
34
|
+
currentUserId(): number | null;
|
|
35
|
+
gameTitle(): string;
|
|
36
|
+
/** Queue (or send at once) a `hub.saved` op. */
|
|
37
|
+
send(payload: SavedPayload): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A label or title trimmed and clipped, or `null` when blank. */
|
|
41
|
+
function shortText(text: string | null | undefined): string | null {
|
|
42
|
+
const trimmed = (text ?? '').trim();
|
|
43
|
+
return trimmed === '' ? null : trimmed.slice(0, TEXT_MAX);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A game's daily saves. */
|
|
47
|
+
export class DailySaves {
|
|
48
|
+
private readonly host: DailySavesHost;
|
|
49
|
+
private readonly timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
50
|
+
private readonly waiting = new Map<string, SavedPayload>();
|
|
51
|
+
private isListening = false;
|
|
52
|
+
|
|
53
|
+
constructor(host: DailySavesHost) {
|
|
54
|
+
this.host = host;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The answer a device-only start gives while the hub can't be reached:
|
|
59
|
+
* the day's copy here (its saved state) or a fresh one. No token: the
|
|
60
|
+
* completion fetches one when the hub is back.
|
|
61
|
+
*/
|
|
62
|
+
startLocally(day: string): DailyStartResponse {
|
|
63
|
+
const userId = this.host.currentUserId();
|
|
64
|
+
const existing = this.host.local.read(this.host.slug, day);
|
|
65
|
+
const copy: LocalDaily = existing !== null && existing.status === 'active' && existing.userId === userId ? existing : {
|
|
66
|
+
id: localDailyId(this.host.slug, day), game: this.host.slug, day, seed: dailySeed(this.host.slug, day), status: 'active', userId, at: Date.now(),
|
|
67
|
+
label: STARTED_LABEL, fraction: null, title: null, version: 0, gameTitle: this.host.gameTitle(), state: null, isSynced: false,
|
|
68
|
+
};
|
|
69
|
+
if (copy !== existing) {
|
|
70
|
+
this.host.local.write(copy);
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
token: '', day, seed: copy.seed, expiresAt: 0, alreadyCompleted: false, completedAt: null, meta: null,
|
|
74
|
+
instanceId: copy.id, saved: copy.state, savedVersion: copy.version, resumed: existing !== null,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The hub started (or resumed) a day: keep a copy that knows its id, unless the device has a newer save. */
|
|
79
|
+
remember(started: DailyStartResponse): DailyStartResponse {
|
|
80
|
+
if (started.instanceId === null) {
|
|
81
|
+
return started;
|
|
82
|
+
}
|
|
83
|
+
const existing = this.host.local.read(this.host.slug, started.day);
|
|
84
|
+
if (existing !== null && existing.status === 'active' && existing.userId === this.host.currentUserId() && !existing.isSynced) {
|
|
85
|
+
// This device saved after its last send: play on from that.
|
|
86
|
+
this.host.local.write({ ...existing, id: started.instanceId });
|
|
87
|
+
return { ...started, saved: existing.state, savedVersion: existing.version };
|
|
88
|
+
}
|
|
89
|
+
return started;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Save the active day's state: on the device now, to the hub soon. */
|
|
93
|
+
save(challenge: DailyChallenge, input: DailySaveInput): boolean {
|
|
94
|
+
const label = shortText(input.progress.label);
|
|
95
|
+
if (label === null) {
|
|
96
|
+
throw new HubError('hub.daily.save: progress.label is required.', 400);
|
|
97
|
+
}
|
|
98
|
+
const state = toStoredJson(input.state);
|
|
99
|
+
if (new TextEncoder().encode(JSON.stringify(state)).length > MAX_STATE_BYTES) {
|
|
100
|
+
throw new HubError(`hub.daily.save: state exceeds ${String(MAX_STATE_BYTES)} bytes.`, 413);
|
|
101
|
+
}
|
|
102
|
+
if (challenge.instanceId === null) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
const fraction = typeof input.progress.fraction === 'number' && Number.isFinite(input.progress.fraction) ? Math.min(1, Math.max(0, input.progress.fraction)) : null;
|
|
106
|
+
const copy: LocalDaily = {
|
|
107
|
+
id: challenge.instanceId, game: this.host.slug, day: challenge.day, seed: challenge.seed, status: 'active', userId: this.host.currentUserId(),
|
|
108
|
+
at: Date.now(), label, fraction, title: shortText(input.title), version: Math.max(0, Math.floor(input.version ?? 0)),
|
|
109
|
+
gameTitle: this.host.gameTitle(), state, isSynced: false,
|
|
110
|
+
};
|
|
111
|
+
if (!this.host.local.write(copy)) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
this.schedule({ action: 'save', game: copy.game, day: copy.day, at: copy.at });
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Give the day up: the copy says so at once, the hub hears it now (or when back online). */
|
|
119
|
+
async discard(challenge: DailyChallenge): Promise<void> {
|
|
120
|
+
this.dropWaiting(challenge.day);
|
|
121
|
+
const at = Date.now();
|
|
122
|
+
const base = this.host.local.read(this.host.slug, challenge.day) ?? this.blankCopy(challenge);
|
|
123
|
+
this.host.local.write({ ...base, id: challenge.instanceId ?? base.id, status: 'discarded', at, isSynced: false });
|
|
124
|
+
await this.host.send({ action: 'discard', game: this.host.slug, day: challenge.day, at });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The day was won: no save is waiting for it any more, and the lists hide it until the hub confirms. */
|
|
128
|
+
finished(day: string): void {
|
|
129
|
+
this.dropWaiting(day);
|
|
130
|
+
const existing = this.host.local.read(this.host.slug, day);
|
|
131
|
+
if (existing !== null) {
|
|
132
|
+
this.host.local.write({ ...existing, status: 'finished', at: Date.now(), isSynced: false });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The hub recorded the day: forget the copy. */
|
|
137
|
+
completed(day: string): void {
|
|
138
|
+
this.host.local.remove(this.host.slug, day);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Send every waiting save now. */
|
|
142
|
+
async flush(): Promise<void> {
|
|
143
|
+
const payloads = [...this.waiting.values()];
|
|
144
|
+
for (const timer of this.timers.values()) {
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
}
|
|
147
|
+
this.timers.clear();
|
|
148
|
+
this.waiting.clear();
|
|
149
|
+
await Promise.all(payloads.map((payload) => this.host.send(payload)));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private blankCopy(challenge: DailyChallenge): LocalDaily {
|
|
153
|
+
return {
|
|
154
|
+
id: challenge.instanceId ?? localDailyId(this.host.slug, challenge.day), game: this.host.slug, day: challenge.day, seed: challenge.seed,
|
|
155
|
+
status: 'active', userId: this.host.currentUserId(), at: Date.now(), label: STARTED_LABEL, fraction: null, title: null, version: 0,
|
|
156
|
+
gameTitle: this.host.gameTitle(), state: null, isSynced: false,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private dropWaiting(day: string): void {
|
|
161
|
+
const timer = this.timers.get(day);
|
|
162
|
+
if (timer !== undefined) {
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
}
|
|
165
|
+
this.timers.delete(day);
|
|
166
|
+
this.waiting.delete(day);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private schedule(payload: SavedPayload): void {
|
|
170
|
+
this.dropWaiting(payload.day);
|
|
171
|
+
this.waiting.set(payload.day, payload);
|
|
172
|
+
this.timers.set(payload.day, setTimeout(() => {
|
|
173
|
+
this.timers.delete(payload.day);
|
|
174
|
+
this.waiting.delete(payload.day);
|
|
175
|
+
void this.host.send(payload).catch(() => undefined);
|
|
176
|
+
}, DAILY_SAVE_DEBOUNCE_MS));
|
|
177
|
+
this.listenToPage();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Send waiting saves as soon as the page is hidden or closed. */
|
|
181
|
+
private listenToPage(): void {
|
|
182
|
+
if (this.isListening || !isBrowserPage()) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
this.isListening = true;
|
|
186
|
+
const flushNow = (): void => { void this.flush().catch(() => undefined); };
|
|
187
|
+
window.addEventListener('pagehide', flushNow);
|
|
188
|
+
if (typeof document !== 'undefined') {
|
|
189
|
+
document.addEventListener('visibilitychange', () => {
|
|
190
|
+
if (document.visibilityState === 'hidden') {
|
|
191
|
+
flushNow();
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The deterministic seed of a (game, day), the same FNV-1a as the
|
|
3
|
+
* contract's `seedForDay` (the browser client carries no contract code; a
|
|
4
|
+
* test keeps the two in step). Used for a day started offline.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** The uint32 seed for `game` on `day`. */
|
|
8
|
+
export function dailySeed(game: string, day: string): number {
|
|
9
|
+
let hash = 2166136261 >>> 0;
|
|
10
|
+
const seedInput = `${game}#${day}`;
|
|
11
|
+
for (let i = 0; i < seedInput.length; i++) {
|
|
12
|
+
hash ^= seedInput.charCodeAt(i);
|
|
13
|
+
hash = Math.imul(hash, 16777619);
|
|
14
|
+
}
|
|
15
|
+
return hash >>> 0;
|
|
16
|
+
}
|