@diffenderfer-games/hub 2.0.0-next.5 → 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.
Files changed (170) hide show
  1. package/CHANGELOG.md +54 -19
  2. package/README.md +3 -6
  3. package/dist/auto.d.ts +5 -5
  4. package/dist/auto.js +2 -2
  5. package/dist/avatar-pixi.d.ts +147 -0
  6. package/dist/avatar-pixi.d.ts.map +1 -0
  7. package/dist/avatar-pixi.js +280 -0
  8. package/dist/avatar-pixi.js.map +1 -0
  9. package/dist/avatar-svg.d.ts +4 -0
  10. package/dist/avatar-svg.js +5 -0
  11. package/dist/avatar-three.d.ts +69 -0
  12. package/dist/avatar-three.d.ts.map +1 -0
  13. package/dist/avatar-three.js +49 -0
  14. package/dist/avatar-three.js.map +1 -0
  15. package/dist/avatar.d.ts +7 -0
  16. package/dist/avatar.js +487 -0
  17. package/dist/avatar.js.map +1 -0
  18. package/dist/daily.d.ts +2 -2
  19. package/dist/doctor/cli.mjs +1 -1
  20. package/dist/index.d.ts +5 -5
  21. package/dist/index.js +2 -2
  22. package/dist/input.d.ts +2 -2
  23. package/dist/shared/animator.d.ts +104 -0
  24. package/dist/shared/animator.d.ts.map +1 -0
  25. package/dist/shared/art.js +1804 -0
  26. package/dist/shared/art.js.map +1 -0
  27. package/dist/shared/atlas.d.ts +134 -0
  28. package/dist/shared/atlas.d.ts.map +1 -0
  29. package/dist/shared/avatar.d.ts +117 -0
  30. package/dist/shared/avatar.d.ts.map +1 -0
  31. package/dist/shared/bridge.js +13 -5
  32. package/dist/shared/bridge.js.map +1 -1
  33. package/dist/shared/client-version.gen.js +1 -1
  34. package/dist/shared/daily.d.ts +129 -18
  35. package/dist/shared/daily.d.ts.map +1 -1
  36. package/dist/shared/daily.js +120 -26
  37. package/dist/shared/daily.js.map +1 -1
  38. package/dist/shared/defineProperty.js +39 -0
  39. package/dist/shared/describe.d.ts +32 -0
  40. package/dist/shared/describe.d.ts.map +1 -0
  41. package/dist/shared/describe.js +101 -0
  42. package/dist/shared/describe.js.map +1 -0
  43. package/dist/shared/globals.js +1 -39
  44. package/dist/shared/globals.js.map +1 -1
  45. package/dist/shared/index.d.ts +503 -14
  46. package/dist/shared/index.d.ts.map +1 -1
  47. package/dist/shared/input.d.ts +31 -13
  48. package/dist/shared/input.d.ts.map +1 -1
  49. package/dist/shared/input.js +40 -2
  50. package/dist/shared/input.js.map +1 -1
  51. package/dist/shared/json-guards.js +48 -0
  52. package/dist/shared/json-guards.js.map +1 -0
  53. package/dist/shared/live-canvas.js +62 -0
  54. package/dist/shared/live-canvas.js.map +1 -0
  55. package/dist/shared/mp.js +1 -1
  56. package/dist/shared/overlays.js +800 -0
  57. package/dist/shared/overlays.js.map +1 -0
  58. package/dist/shared/page-stack.js +2 -1
  59. package/dist/shared/page-stack.js.map +1 -1
  60. package/dist/shared/pose-key.js +249 -0
  61. package/dist/shared/pose-key.js.map +1 -0
  62. package/dist/shared/race.js +1 -1
  63. package/dist/shared/rooms.js +1 -1
  64. package/dist/shared/scene.d.ts +90 -0
  65. package/dist/shared/scene.d.ts.map +1 -0
  66. package/dist/shared/social.d.ts +55 -4
  67. package/dist/shared/social.d.ts.map +1 -1
  68. package/dist/shared/src.js +2020 -144
  69. package/dist/shared/src.js.map +1 -1
  70. package/dist/shared/svg-markup.js +1860 -0
  71. package/dist/shared/svg-markup.js.map +1 -0
  72. package/dist/shared/svg.js +27 -0
  73. package/dist/shared/svg.js.map +1 -0
  74. package/dist/shared/types.d.ts +166 -0
  75. package/dist/shared/types.d.ts.map +1 -0
  76. package/dist/social.d.ts +2 -2
  77. package/dist/social.js +2 -2
  78. package/dist/turns.d.ts +39 -0
  79. package/dist/turns.d.ts.map +1 -0
  80. package/dist/turns.js +117 -0
  81. package/dist/turns.js.map +1 -0
  82. package/package.json +33 -3
  83. package/src/avatar/adapters/pixi-avatar.ts +73 -0
  84. package/src/avatar/adapters/pixi-crowd.ts +302 -0
  85. package/src/avatar/adapters/pixi-types.ts +84 -0
  86. package/src/avatar/adapters/three.ts +126 -0
  87. package/src/avatar/animator/animator.ts +285 -0
  88. package/src/avatar/animator/reactions.ts +44 -0
  89. package/src/avatar/animator/schedule.ts +73 -0
  90. package/src/avatar/art/beard.ts +49 -0
  91. package/src/avatar/art/glasses.ts +9 -0
  92. package/src/avatar/art/hair.ts +254 -0
  93. package/src/avatar/art/headwear.ts +75 -0
  94. package/src/avatar/art/index.ts +21 -0
  95. package/src/avatar/art/shape.ts +68 -0
  96. package/src/avatar/art-store.ts +36 -0
  97. package/src/avatar/atlas.ts +236 -0
  98. package/src/avatar/catalog-view.ts +14 -0
  99. package/src/avatar/colour.ts +109 -0
  100. package/src/avatar/describe.ts +76 -0
  101. package/src/avatar/face.ts +187 -0
  102. package/src/avatar/geom.ts +168 -0
  103. package/src/avatar/layout.ts +47 -0
  104. package/src/avatar/look-like.ts +52 -0
  105. package/src/avatar/outputs/bitmap.ts +75 -0
  106. package/src/avatar/outputs/canvas-paint.ts +177 -0
  107. package/src/avatar/outputs/canvas.ts +62 -0
  108. package/src/avatar/outputs/custom-element.ts +140 -0
  109. package/src/avatar/outputs/element.ts +58 -0
  110. package/src/avatar/outputs/live-canvas.ts +57 -0
  111. package/src/avatar/outputs/overlay-svg.ts +22 -0
  112. package/src/avatar/outputs/pose-key.ts +30 -0
  113. package/src/avatar/outputs/svg-markup.ts +113 -0
  114. package/src/avatar/outputs/svg.ts +41 -0
  115. package/src/avatar/overlays.ts +128 -0
  116. package/src/avatar/render/body.ts +116 -0
  117. package/src/avatar/render/colours.ts +81 -0
  118. package/src/avatar/render/compose.ts +258 -0
  119. package/src/avatar/render/context.ts +117 -0
  120. package/src/avatar/render/eyes.ts +78 -0
  121. package/src/avatar/render/eyewear.ts +56 -0
  122. package/src/avatar/render/frame.ts +60 -0
  123. package/src/avatar/render/head.ts +81 -0
  124. package/src/avatar/render/mouth.ts +86 -0
  125. package/src/avatar/render/props.ts +91 -0
  126. package/src/avatar/render/types.ts +84 -0
  127. package/src/avatar/scene.ts +119 -0
  128. package/src/avatar-pixi.ts +20 -0
  129. package/src/avatar-svg.ts +21 -0
  130. package/src/avatar-three.ts +18 -0
  131. package/src/avatar.ts +62 -0
  132. package/src/core/avatar/avatar-cache.ts +95 -0
  133. package/src/core/avatar/avatar-ref.ts +34 -0
  134. package/src/core/avatar/avatar-types.ts +43 -0
  135. package/src/core/avatar/hub-avatar.ts +308 -0
  136. package/src/core/connection.ts +8 -0
  137. package/src/core/errors.ts +7 -1
  138. package/src/core/hub-progress.ts +269 -238
  139. package/src/core/hub.ts +174 -6
  140. package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
  141. package/src/core/offline/hub-kinds/index.ts +9 -0
  142. package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
  143. package/src/core/page-account.ts +121 -0
  144. package/src/core/rest/routes.gen.ts +31 -1
  145. package/src/core/resume/hub-resume.ts +39 -5
  146. package/src/core/resume/resume-types.ts +2 -2
  147. package/src/core/saved/daily-saves.ts +196 -0
  148. package/src/core/saved/daily-seed.ts +16 -0
  149. package/src/core/saved/hub-saved.ts +221 -0
  150. package/src/core/saved/local-saved.ts +122 -0
  151. package/src/core/saved/saved-list.ts +129 -0
  152. package/src/core/saved/saved-open.ts +104 -0
  153. package/src/core/saved/saved-order.ts +49 -0
  154. package/src/core/saved/saved-types.ts +24 -0
  155. package/src/core/turns/hub-turns.ts +140 -0
  156. package/src/core/turns/turn-game.ts +388 -0
  157. package/src/core/version/client-version.gen.ts +2 -2
  158. package/src/daily/daily-system.ts +112 -13
  159. package/src/daily/page-daily.ts +2 -2
  160. package/src/daily/types.ts +46 -9
  161. package/src/doctor/hub-version-fetch.ts +43 -43
  162. package/src/index.ts +10 -1
  163. package/src/input/input-system.ts +10 -1
  164. package/src/input/navigator.ts +11 -0
  165. package/src/input.ts +1 -0
  166. package/src/overlay/overlay.ts +25 -1
  167. package/src/realtime/runtime-api.ts +14 -3
  168. package/src/social.ts +1 -0
  169. package/src/turns-kit/check-rules.ts +182 -0
  170. package/src/turns.ts +13 -0
@@ -0,0 +1,388 @@
1
+ /**
2
+ * One online game, as a seated player's page holds it (`hub.turns.open`,
3
+ * `create`, `join`). The hub runs the game's rules: the page only ever has
4
+ * its own `view` and sends moves.
5
+ *
6
+ * Live changes arrive as the social runtime's `hub:turn` window events
7
+ * (from `/_ws`): `on('move')` for each move (with its events as this seat may
8
+ * see them, to animate), `on('turn')` whenever whose move it is or the seats
9
+ * change, `on('ended')` at the end, `on('chat')` for chat lines. Moves made
10
+ * while the page was away are replayed from `history` when it comes back
11
+ * (and, without the runtime, every so often while the page is visible).
12
+ */
13
+ import type { Json, TurnChatLine, TurnGameView, TurnSeat, TurnSummary } from '@diffenderfer-games/hub-contract';
14
+ import { HubError } from '../errors';
15
+ import { toGameData, type GamePayload } from '../game-data';
16
+ import { isBrowserPage } from '../page';
17
+ import { isJson, parseJsonText } from '../storage/json-guards';
18
+ import { readField } from '../../realtime/cross-bundle';
19
+
20
+ /** The window event the social runtime fires for each `ev:turn`. */
21
+ export const TURN_EVENT = 'hub:turn';
22
+
23
+ /** The runtime's live connection (re)opened: anything sent while it was down is fetched again. */
24
+ export const TURN_RESYNC_EVENT = 'hub:turn-resync';
25
+
26
+ /** The window event the social runtime fires for each `ev:turn.chat`. */
27
+ export const TURN_CHAT_EVENT = 'hub:turn-chat';
28
+
29
+ /** How often a page without the live runtime looks for moves, ms. */
30
+ export const TURN_POLL_MS = 20_000;
31
+
32
+ /** A game's end: the winning seats, and optionally places and scores by seat. */
33
+ export type TurnOutcome = NonNullable<TurnGameView['outcome']>;
34
+
35
+ /** One move as this seat saw it. */
36
+ export interface TurnMoveEvent<View, Ev> {
37
+ readonly seq: number;
38
+ readonly seat: number;
39
+ /** The view after the move (for a replayed move, the view now). */
40
+ readonly view: View;
41
+ readonly events: readonly Ev[];
42
+ }
43
+
44
+ /** What a game's listeners hear. */
45
+ export interface TurnGameEvents<View, Ev> {
46
+ move: TurnMoveEvent<View, Ev>;
47
+ turn: TurnSummary;
48
+ ended: { readonly outcome: TurnOutcome | null; readonly view: View };
49
+ chat: TurnChatLine;
50
+ }
51
+
52
+ /** What a game needs from its client: the REST calls, by name. */
53
+ export interface TurnGameHost {
54
+ get(id: string): Promise<TurnGameView>;
55
+ start(id: string): Promise<TurnGameView>;
56
+ move(id: string, seq: number, move: Json): Promise<{ game: TurnGameView; events: Json[] }>;
57
+ history(id: string, since: number): Promise<{ seq: number; seat: number; events: Json[] }[]>;
58
+ leave(id: string): Promise<void>;
59
+ nudge(id: string): Promise<void>;
60
+ setNotify(id: string, mode: 'on' | 'off'): Promise<void>;
61
+ rematch(id: string): Promise<TurnGameView>;
62
+ invite(id: string, userIds: readonly number[]): Promise<TurnGameView>;
63
+ share(id: string, share: { readonly link?: boolean; readonly public?: boolean }): Promise<TurnGameView>;
64
+ autoPlay(id: string): Promise<TurnGameView>;
65
+ chat(id: string, send: { readonly text?: string; readonly quick?: string }): Promise<TurnChatLine>;
66
+ /** The page has a game open (saves of a run are off while it does). */
67
+ opened(game: object): void;
68
+ closed(game: object): void;
69
+ /** Whether the live runtime is on the page (else the game polls). */
70
+ isLive(): boolean;
71
+ }
72
+
73
+ type Listener<Payload> = (payload: Payload) => void;
74
+
75
+ /** One online game. `View`, `Move` and `Ev` (its events) are the game's own JSON types from its rules. */
76
+ export class TurnGame<View = GamePayload, Move extends GamePayload = GamePayload, Ev = GamePayload> {
77
+ readonly id: string;
78
+ private state: TurnGameView;
79
+ private readonly host: TurnGameHost;
80
+ private readonly listeners: { [K in keyof TurnGameEvents<View, Ev>]: Set<Listener<TurnGameEvents<View, Ev>[K]>> } = {
81
+ move: new Set(), turn: new Set(), ended: new Set(), chat: new Set(),
82
+ };
83
+ private readonly stopListening: (() => void)[] = [];
84
+ private isClosed = false;
85
+ private catchingUp: Promise<void> | null = null;
86
+ /** The move count this page's own move in flight will make (its live echo is not another player's move). */
87
+ private ownSeq: number | null = null;
88
+
89
+ constructor(host: TurnGameHost, initial: TurnGameView) {
90
+ this.host = host;
91
+ this.id = initial.id;
92
+ this.state = initial;
93
+ host.opened(this);
94
+ this.listen();
95
+ }
96
+
97
+ /** Whose move it is, the seats and the deadline. */
98
+ get summary(): TurnSummary {
99
+ return this.state.summary;
100
+ }
101
+
102
+ /** This seat's view, from the game's rules (`null` before the game starts). */
103
+ get view(): View {
104
+ return toGameData<View>(this.state.view);
105
+ }
106
+
107
+ /** This player's seat, or `null` (they left). */
108
+ get mySeat(): number | null {
109
+ return this.state.mySeat;
110
+ }
111
+
112
+ /** The end, once the game is over. */
113
+ get outcome(): TurnOutcome | null {
114
+ return this.state.outcome;
115
+ }
116
+
117
+ /** The last chat lines, oldest first. */
118
+ get chat(): readonly TurnChatLine[] {
119
+ return this.state.chat;
120
+ }
121
+
122
+ /** `on`: friends may type (moderated); `quick`: quick-chat and emotes only; `off`. */
123
+ get chatMode(): TurnGameView['chatMode'] {
124
+ return this.state.chatMode;
125
+ }
126
+
127
+ /** The link others can take a seat with (the creator's, while seats are open), else `null`. */
128
+ get link(): string | null {
129
+ return this.state.link === null ? null : new URL(this.state.link, location.origin).href;
130
+ }
131
+
132
+ /** Creator: start the game (empty seats go to the computer when the game allows). */
133
+ async start(): Promise<void> {
134
+ this.update(await this.host.start(this.id));
135
+ }
136
+
137
+ /**
138
+ * Make a move. Rejects with a `HubError`: 403 `not_your_turn`, 409
139
+ * `stale` (the game moved on: the view is refreshed first), 422 `illegal`
140
+ * with the rules' own code in `rule`.
141
+ */
142
+ async move(move: Move): Promise<{ seq: number; view: View; events: Ev[] }> {
143
+ this.ownSeq = this.state.summary.seq + 1;
144
+ try {
145
+ const answer = await this.host.move(this.id, this.state.summary.seq, toPayload(move));
146
+ this.update(answer.game);
147
+ return { seq: answer.game.summary.seq, view: this.view, events: answer.events.map((event) => toGameData<Ev>(event)) };
148
+ } catch (failure) {
149
+ if (failure instanceof HubError && failure.code === 'stale') {
150
+ await this.refresh();
151
+ }
152
+ throw failure;
153
+ } finally {
154
+ this.ownSeq = null;
155
+ }
156
+ }
157
+
158
+ /** The moves after `sinceSeq`, with their events as this seat may see them ("what happened while you were away"). */
159
+ async history(sinceSeq: number): Promise<{ seq: number; seat: number; events: Ev[] }[]> {
160
+ const moves = await this.host.history(this.id, sinceSeq);
161
+ return moves.map((move) => ({ seq: move.seq, seat: move.seat, events: move.events.map((event) => toGameData<Ev>(event)) }));
162
+ }
163
+
164
+ /** Leave: the computer plays the seat (or, without bots, the others win). The player can't come back. */
165
+ async leave(): Promise<void> {
166
+ await this.host.leave(this.id);
167
+ this.close();
168
+ }
169
+
170
+ /** Remind whoever must move (once a day). */
171
+ nudge(): Promise<void> {
172
+ return this.host.nudge(this.id);
173
+ }
174
+
175
+ /** This game's "your turn" alerts. */
176
+ setNotify(mode: 'on' | 'off'): Promise<void> {
177
+ return this.host.setNotify(this.id, mode);
178
+ }
179
+
180
+ /** A finished game: a new one with the same friends (they get an invite). */
181
+ async rematch(): Promise<TurnGame<View, Move, Ev>> {
182
+ return new TurnGame<View, Move, Ev>(this.host, await this.host.rematch(this.id));
183
+ }
184
+
185
+ /** Creator, before the start: add an invite link (`link: true`, then read `link`) or list the open seats for anyone (`public`). */
186
+ async share(share: { readonly link?: boolean; readonly public?: boolean }): Promise<void> {
187
+ this.update(await this.host.share(this.id, share));
188
+ }
189
+
190
+ /** Invite friends to free seats (before the start). */
191
+ async invite(userIds: readonly number[]): Promise<void> {
192
+ this.update(await this.host.invite(this.id, userIds));
193
+ }
194
+
195
+ /** The turn's deadline passed (or its player is suspended): let the computer play it. */
196
+ async letComputerPlay(): Promise<void> {
197
+ this.update(await this.host.autoPlay(this.id));
198
+ }
199
+
200
+ /** Say something: `{ text }` between friends (moderated), `{ quick }` (a quick-chat phrase id) with anyone. */
201
+ say(send: { readonly text?: string; readonly quick?: string }): Promise<TurnChatLine> {
202
+ return this.host.chat(this.id, send);
203
+ }
204
+
205
+ /** Fetch the game again, and replay moves missed since the last one this page saw. */
206
+ async refresh(): Promise<void> {
207
+ const before = this.state.summary.seq;
208
+ const fresh = await this.host.get(this.id);
209
+ if (fresh.summary.seq > before) {
210
+ const missed = await this.host.history(this.id, before);
211
+ this.update(fresh);
212
+ for (const move of missed) {
213
+ this.emit('move', { seq: move.seq, seat: move.seat, view: this.view, events: move.events.map((event) => toGameData<Ev>(event)) });
214
+ }
215
+ } else {
216
+ this.update(fresh);
217
+ }
218
+ }
219
+
220
+ /** Hear moves, turn changes, the end and chat; returns a function that stops listening. */
221
+ on<K extends keyof TurnGameEvents<View, Ev>>(eventName: K, listener: Listener<TurnGameEvents<View, Ev>[K]>): () => void {
222
+ const set: Set<Listener<TurnGameEvents<View, Ev>[K]>> = this.listeners[eventName];
223
+ set.add(listener);
224
+ return () => {
225
+ set.delete(listener);
226
+ };
227
+ }
228
+
229
+ /** Stop live updates for this page (the game itself goes on). */
230
+ close(): void {
231
+ if (this.isClosed) {
232
+ return;
233
+ }
234
+ this.isClosed = true;
235
+ for (const stop of this.stopListening) {
236
+ stop();
237
+ }
238
+ this.host.closed(this);
239
+ }
240
+
241
+ /** Take a fresh answer from the hub; fire `turn` and `ended` when they changed. */
242
+ private update(next: TurnGameView): void {
243
+ const previous = this.state;
244
+ this.state = next;
245
+ const isChanged = previous.summary.seq !== next.summary.seq || previous.summary.phase !== next.summary.phase || !sameSeats(previous.summary, next.summary)
246
+ || previous.chatMode !== next.chatMode || previous.link !== next.link;
247
+ if (isChanged) {
248
+ this.emit('turn', next.summary);
249
+ }
250
+ if (previous.summary.phase !== 'ended' && next.summary.phase === 'ended') {
251
+ this.emit('ended', { outcome: next.outcome, view: this.view });
252
+ }
253
+ }
254
+
255
+ private emit<K extends keyof TurnGameEvents<View, Ev>>(eventName: K, payload: TurnGameEvents<View, Ev>[K]): void {
256
+ const set: Set<Listener<TurnGameEvents<View, Ev>[K]>> = this.listeners[eventName];
257
+ for (const listener of set) {
258
+ listener(payload);
259
+ }
260
+ }
261
+
262
+ /** A live `hub:turn` for this game: a move (or a missed run of them), or another change. */
263
+ private onLive(detail: object): void {
264
+ const seq = readField(detail, 'seq');
265
+ const view = readField(detail, 'view');
266
+ const summary = readField(detail, 'summary');
267
+ if (typeof seq !== 'number' || !isSummary(summary) || view === undefined) {
268
+ return;
269
+ }
270
+ if (seq > this.state.summary.seq + 1) {
271
+ void this.catchUp();
272
+ return;
273
+ }
274
+ const seat = readField(detail, 'seat');
275
+ const events = readField(detail, 'events');
276
+ const outcome = readField(detail, 'outcome');
277
+ const isMove = typeof seat === 'number' && seq === this.state.summary.seq + 1 && seq !== this.ownSeq;
278
+ if (summary.phase !== this.state.summary.phase || !sameSeating(this.state.summary, summary)) {
279
+ // Who sits where changed (a join, the start, a leave): the chat mode and link may have too, and the live event doesn't carry them.
280
+ void this.catchUp();
281
+ }
282
+ this.update({ ...this.state, summary, view: toPayloadJson(view), outcome: isOutcome(outcome) ? outcome : this.state.outcome });
283
+ if (isMove) {
284
+ this.emit('move', { seq, seat, view: this.view, events: Array.isArray(events) ? events.map((event) => toGameData<Ev>(toPayloadJson(event))) : [] });
285
+ }
286
+ }
287
+
288
+ /** Replay what was missed (one catch-up at a time). */
289
+ private catchUp(): Promise<void> {
290
+ this.catchingUp ??= this.refresh().catch(() => undefined).finally(() => {
291
+ this.catchingUp = null;
292
+ });
293
+ return this.catchingUp;
294
+ }
295
+
296
+ private listen(): void {
297
+ if (!isBrowserPage()) {
298
+ return;
299
+ }
300
+ const onTurn = (event: Event): void => {
301
+ if (!(event instanceof CustomEvent) || readField(event.detail, 'id') !== this.id) {
302
+ return;
303
+ }
304
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: another bundle's event detail, read field by field
305
+ const detail: unknown = event.detail;
306
+ if (typeof detail === 'object' && detail !== null) {
307
+ this.onLive(detail);
308
+ }
309
+ };
310
+ const onChat = (event: Event): void => {
311
+ if (event instanceof CustomEvent && readField(event.detail, 'id') === this.id) {
312
+ const line = readField(event.detail, 'line');
313
+ if (isChatLine(line)) {
314
+ this.state = { ...this.state, chat: [...this.state.chat, line].slice(-50) };
315
+ this.emit('chat', line);
316
+ }
317
+ }
318
+ };
319
+ const onVisible = (): void => {
320
+ if (document.visibilityState === 'visible') {
321
+ void this.catchUp();
322
+ }
323
+ };
324
+ const onResync = (): void => { void this.catchUp(); };
325
+ window.addEventListener(TURN_EVENT, onTurn);
326
+ window.addEventListener(TURN_CHAT_EVENT, onChat);
327
+ window.addEventListener(TURN_RESYNC_EVENT, onResync);
328
+ document.addEventListener('visibilitychange', onVisible);
329
+ const poll = setInterval(() => {
330
+ if (!this.host.isLive() && document.visibilityState === 'visible') {
331
+ void this.catchUp();
332
+ }
333
+ }, TURN_POLL_MS);
334
+ this.stopListening.push(
335
+ () => { window.removeEventListener(TURN_EVENT, onTurn); },
336
+ () => { window.removeEventListener(TURN_CHAT_EVENT, onChat); },
337
+ () => { window.removeEventListener(TURN_RESYNC_EVENT, onResync); },
338
+ () => { document.removeEventListener('visibilitychange', onVisible); },
339
+ () => { clearInterval(poll); },
340
+ );
341
+ }
342
+ }
343
+
344
+ /** Every seat matches its counterpart by `same`. */
345
+ function eachSeat(a: TurnSummary, b: TurnSummary, same: (seat: TurnSeat, other: TurnSeat) => boolean): boolean {
346
+ return a.seats.length === b.seats.length && a.seats.every((seat, index) => {
347
+ const other = b.seats[index];
348
+ return other !== undefined && same(seat, other);
349
+ });
350
+ }
351
+
352
+ /** The same players in the same seats with the same statuses (who sits where). */
353
+ function sameSeating(a: TurnSummary, b: TurnSummary): boolean {
354
+ return eachSeat(a, b, (seat, other) => seat.status === other.status && seat.userId === other.userId);
355
+ }
356
+
357
+ /** The same seating, and the same looks (a player's new avatar is a change: `turn` fires). */
358
+ function sameSeats(a: TurnSummary, b: TurnSummary): boolean {
359
+ return sameSeating(a, b) && eachSeat(a, b, (seat, other) => seat.look === other.look);
360
+ }
361
+
362
+ /** A game's move as the JSON the hub takes. */
363
+ function toPayload(move: GamePayload): Json {
364
+ return toPayloadJson(move);
365
+ }
366
+
367
+ /** Any JSON-safe value as `Json` (a JSON round trip). */
368
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: a game's own move, or a value from another bundle's event
369
+ function toPayloadJson(value: unknown): Json {
370
+ const parsed = parseJsonText(JSON.stringify(value ?? null));
371
+ return isJson(parsed) ? parsed : null;
372
+ }
373
+
374
+ /** Shape checks for a live event's parts (it comes from another bundle). */
375
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: another bundle's event detail
376
+ function isSummary(value: unknown): value is TurnSummary {
377
+ return typeof readField(value, 'phase') === 'string' && Array.isArray(readField(value, 'seats')) && Array.isArray(readField(value, 'toAct'));
378
+ }
379
+
380
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: another bundle's event detail
381
+ function isOutcome(value: unknown): value is TurnOutcome | null {
382
+ return value === null || Array.isArray(readField(value, 'winners'));
383
+ }
384
+
385
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: another bundle's event detail
386
+ function isChatLine(value: unknown): value is TurnChatLine {
387
+ return typeof readField(value, 'text') === 'string' && typeof readField(value, 'from') === 'number';
388
+ }
@@ -1,3 +1,3 @@
1
- // Generated by tools/hub-client/generate.ts. Do not edit; run `npm run gen:hub-client` in next/.
1
+ // Generated by tools/hub-client/generate.ts. Do not edit; run `npm run gen:hub-client`.
2
2
  /** This client's package version, sent as `x-hub-client` on REST calls and `client` in `hello`. */
3
- export const HUB_CLIENT_VERSION = '2.0.0-next.5';
3
+ export const HUB_CLIENT_VERSION = '2.0.0-next.6';
@@ -9,7 +9,8 @@
9
9
  * registered `play` on. Deep links (`/<slug>/?daily=YYYY-MM-DD`) auto-start.
10
10
  */
11
11
  import { readField } from '../realtime/cross-bundle';
12
- import type { DailyAll, DailyChallenge, DailyCompleteResponse, DailyCompletionMeta, DailyDefinition, DailyHost, DailyStatus } from './types';
12
+ import type { SavedEntry } from '../core/saved/saved-types';
13
+ import type { DailyAll, DailyChallenge, DailyCompleteResponse, DailyCompletionMeta, DailyDefinition, DailyHost, DailySaveInput, DailyStartResponse, DailyStatus } from './types';
13
14
 
14
15
  const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
15
16
 
@@ -39,20 +40,32 @@ function messageOf(error: unknown): string {
39
40
  return error instanceof Error ? error.message : String(error);
40
41
  }
41
42
 
43
+ /** The challenge a start or resume answered. */
44
+ function challengeOf(started: DailyStartResponse): DailyChallenge {
45
+ return {
46
+ day: started.day, seed: started.seed, token: started.token, alreadyCompleted: started.alreadyCompleted,
47
+ instanceId: started.instanceId, saved: started.saved, savedVersion: started.savedVersion, resumed: started.resumed,
48
+ };
49
+ }
50
+
42
51
  /** Daily challenges (`hub.daily`). */
43
52
  export class DailySystem {
44
53
  /**
45
54
  * The challenge in progress: set when a day starts; cleared the moment the
46
55
  * game calls `complete` (whatever the hub answers, and before it answers)
47
- * or `cancel`. A game's win handler checks it:
56
+ * or `cancel` (or `discard`). A game's win handler checks it:
48
57
  * `if (hub.daily.active) hub.daily.complete(…)`. While it is set the page
49
- * is in a daily run, so `hub.resume.save` saves nothing: a game whose
50
- * player leaves a daily unfinished must call `cancel()`.
58
+ * is in a daily run, so `hub.resume.save` saves to the day instead of the
59
+ * game's run: a game whose player leaves a daily unfinished must call
60
+ * `cancel()`.
51
61
  */
52
62
  active: DailyChallenge | null = null;
53
63
  private readonly host: DailyHost;
54
64
  private play: DailyDefinition['play'] | null = null;
55
65
  private pendingDay: string | null = null;
66
+ private pendingInstance: string | null = null;
67
+ /** A start on its way to the hub, so a resume of the same day waits for it instead of starting it twice. */
68
+ private inFlight: Promise<void> | null = null;
56
69
  private isRelaying = false;
57
70
 
58
71
  constructor(host: DailyHost) {
@@ -84,9 +97,13 @@ export class DailySystem {
84
97
  define(definition: DailyDefinition): void {
85
98
  this.play = definition.play;
86
99
  const day = readDeepLinkDay() ?? this.pendingDay;
100
+ const instanceId = this.pendingInstance;
87
101
  this.pendingDay = null;
102
+ this.pendingInstance = null;
88
103
  if (day !== null) {
89
104
  void this.startAndPlay(day);
105
+ } else if (instanceId !== null) {
106
+ void this.resume(instanceId);
90
107
  }
91
108
  }
92
109
 
@@ -103,17 +120,91 @@ export class DailySystem {
103
120
  this.relayStart(day);
104
121
  return;
105
122
  }
123
+ const starting = (async (): Promise<void> => {
124
+ try {
125
+ this.begin(play, challengeOf(await this.host.start(day)));
126
+ } catch (error) {
127
+ this.host.emit('hub:daily-error', { day, error: messageOf(error) });
128
+ }
129
+ })();
130
+ this.inFlight = starting;
131
+ await starting;
132
+ if (this.inFlight === starting) {
133
+ this.inFlight = null;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Pick up a day in progress by its saved game's id (from `hub.saved`):
139
+ * a fresh token, then the game's `play` with `challenge.saved`. Finishing
140
+ * it any time later gives that day's credit. A no-op while that day is
141
+ * already being played.
142
+ */
143
+ async resume(instanceId: string): Promise<void> {
144
+ // A start may be on its way for the same day (the `?daily=` link of the same open): let it land first.
145
+ await this.inFlight;
146
+ if (this.active?.instanceId === instanceId) {
147
+ return;
148
+ }
149
+ const play = this.play;
150
+ if (play === null) {
151
+ this.pendingInstance = instanceId;
152
+ return;
153
+ }
106
154
  try {
107
- const started = await this.host.start(day);
108
- const challenge: DailyChallenge = { day: started.day, seed: started.seed, token: started.token, alreadyCompleted: started.alreadyCompleted };
109
- this.active = challenge;
110
- this.host.emit('hub:daily-started', { day: started.day, seed: started.seed, alreadyCompleted: started.alreadyCompleted });
111
- play({ ...challenge });
155
+ this.begin(play, challengeOf(await this.host.resume(instanceId)));
112
156
  } catch (error) {
113
- this.host.emit('hub:daily-error', { day, error: messageOf(error) });
157
+ this.host.emit('hub:daily-error', { day: '', error: messageOf(error) });
114
158
  }
115
159
  }
116
160
 
161
+ /** Make `challenge` the active day and hand it to the game. */
162
+ private begin(play: DailyDefinition['play'], challenge: DailyChallenge): void {
163
+ this.active = challenge;
164
+ this.host.emit('hub:daily-started', { day: challenge.day, seed: challenge.seed, alreadyCompleted: challenge.alreadyCompleted === true, resumed: challenge.resumed === true });
165
+ if (challenge.resumed === true) {
166
+ this.host.emit('hub:daily-resumed', { day: challenge.day, seed: challenge.seed, alreadyCompleted: false, resumed: true });
167
+ }
168
+ play({ ...challenge });
169
+ }
170
+
171
+ /**
172
+ * Save the active day's state, so it can be picked up later (a reload,
173
+ * another device, next week) and still give that day's credit. Kept on
174
+ * the device at once and sent after a short quiet spell (works offline).
175
+ * `hub.resume.save` during a daily lands here too. Returns `false` with
176
+ * no daily active, or for a replay of a completed day (nothing to save).
177
+ */
178
+ save(input: DailySaveInput): boolean {
179
+ const challenge = this.active;
180
+ if (challenge === null || challenge.instanceId === null) {
181
+ return false;
182
+ }
183
+ return this.host.save(challenge, input);
184
+ }
185
+
186
+ /** This game's days in progress (the calendar marks them, the game can list them). */
187
+ inProgress(): Promise<SavedEntry[]> {
188
+ return this.host.inProgress();
189
+ }
190
+
191
+ /**
192
+ * Give up the active day: its progress is discarded and nothing is
193
+ * recorded. The day can be started again fresh while it is in the 30-day
194
+ * window. (The hub's lists ask the player first; a game asks for itself.)
195
+ */
196
+ async discard(): Promise<void> {
197
+ const challenge = this.active;
198
+ if (challenge === null) {
199
+ return;
200
+ }
201
+ this.active = null;
202
+ if (challenge.instanceId !== null) {
203
+ await this.host.discard(challenge);
204
+ }
205
+ this.host.emit('hub:daily-discarded', { day: challenge.day, discarded: true });
206
+ }
207
+
117
208
  /**
118
209
  * Record completion of the active challenge with optional metadata.
119
210
  * Resolves with the new completion count and rank, or `null` when no
@@ -157,12 +248,20 @@ export class DailySystem {
157
248
  }
158
249
 
159
250
  /**
160
- * Abandon the active challenge without recording it: the player quit, or
161
- * left the daily for normal play (another level, a new game). Until then
162
- * the page counts as in a daily run and `hub.resume.save` saves nothing.
251
+ * Pause the active day: the player left it unfinished (quit, picked
252
+ * another level, started a normal game). Nothing is recorded, and the day
253
+ * stays in progress in "Your games" and the calendar, to finish later
254
+ * (its last save is sent now). Until then the page counts as in a daily,
255
+ * so `hub.resume.save` saves to the day: call `cancel()` on every path
256
+ * that leaves a daily without `complete`. To give a day up, `discard()`.
163
257
  */
164
258
  cancel(): void {
259
+ const challenge = this.active;
165
260
  this.active = null;
261
+ if (challenge !== null) {
262
+ void this.host.flush().catch(() => undefined);
263
+ this.host.emit('hub:daily-paused', { day: challenge.day, paused: true });
264
+ }
166
265
  }
167
266
 
168
267
  /** This game's window and the player's completions. */
@@ -8,8 +8,8 @@ import { hasMethods } from '../realtime/cross-bundle';
8
8
  import { DailySystem } from './daily-system';
9
9
  import type { DailyHost } from './types';
10
10
 
11
- /** The cross-bundle shape number of the `daily` singleton. Bump only on an incompatible change. */
12
- export const DAILY_SINGLETON_API = 1;
11
+ /** The cross-bundle shape number of the `daily` singleton (2 added save, resume, discard and inProgress). Bump only on an incompatible change. */
12
+ export const DAILY_SINGLETON_API = 2;
13
13
 
14
14
  /** Whether `candidate` is some bundle's daily system (v1 copies included). */
15
15
  // eslint-disable-next-line no-restricted-syntax -- trust boundary: the instance may come from another bundle