@diffenderfer-games/hub 2.0.0-next.0 → 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.
Files changed (293) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/LICENSE +21 -21
  3. package/README.md +203 -139
  4. package/dist/auto.d.ts +8 -8
  5. package/dist/auto.js +3 -3
  6. package/dist/avatar-pixi.d.ts +165 -0
  7. package/dist/avatar-pixi.d.ts.map +1 -0
  8. package/dist/avatar-pixi.js +299 -0
  9. package/dist/avatar-pixi.js.map +1 -0
  10. package/dist/avatar-svg.d.ts +4 -0
  11. package/dist/avatar-svg.js +5 -0
  12. package/dist/avatar-three.d.ts +69 -0
  13. package/dist/avatar-three.d.ts.map +1 -0
  14. package/dist/avatar-three.js +49 -0
  15. package/dist/avatar-three.js.map +1 -0
  16. package/dist/avatar.d.ts +7 -0
  17. package/dist/avatar.js +500 -0
  18. package/dist/avatar.js.map +1 -0
  19. package/dist/daily.d.ts +2 -2
  20. package/dist/doctor/cli.mjs +119 -28
  21. package/dist/doctor/cli.mjs.map +1 -1
  22. package/dist/index.d.ts +8 -8
  23. package/dist/index.js +3 -3
  24. package/dist/input.d.ts +2 -2
  25. package/dist/input.js +2 -2
  26. package/dist/mp.d.ts +3 -3
  27. package/dist/race.d.ts +1 -1
  28. package/dist/rooms.d.ts +3 -3
  29. package/dist/rooms.js +1 -1
  30. package/dist/shared/animator.d.ts +104 -0
  31. package/dist/shared/animator.d.ts.map +1 -0
  32. package/dist/shared/art.js +1946 -0
  33. package/dist/shared/art.js.map +1 -0
  34. package/dist/shared/atlas.d.ts +138 -0
  35. package/dist/shared/atlas.d.ts.map +1 -0
  36. package/dist/shared/avatar.d.ts +123 -0
  37. package/dist/shared/avatar.d.ts.map +1 -0
  38. package/dist/shared/bridge.js +18 -4
  39. package/dist/shared/bridge.js.map +1 -1
  40. package/dist/shared/client-version.gen.js +1 -1
  41. package/dist/shared/daily.d.ts +145 -19
  42. package/dist/shared/daily.d.ts.map +1 -1
  43. package/dist/shared/daily.js +138 -25
  44. package/dist/shared/daily.js.map +1 -1
  45. package/dist/shared/defineProperty.js +39 -0
  46. package/dist/shared/describe.d.ts +32 -0
  47. package/dist/shared/describe.d.ts.map +1 -0
  48. package/dist/shared/describe.js +109 -0
  49. package/dist/shared/describe.js.map +1 -0
  50. package/dist/shared/globals.js +9 -42
  51. package/dist/shared/globals.js.map +1 -1
  52. package/dist/shared/index.d.ts +1122 -46
  53. package/dist/shared/index.d.ts.map +1 -1
  54. package/dist/shared/input.d.ts +74 -14
  55. package/dist/shared/input.d.ts.map +1 -1
  56. package/dist/shared/input.js +179 -25
  57. package/dist/shared/input.js.map +1 -1
  58. package/dist/shared/json-guards.js +48 -0
  59. package/dist/shared/json-guards.js.map +1 -0
  60. package/dist/shared/latest-state.js +1 -1
  61. package/dist/shared/live-canvas.js +62 -0
  62. package/dist/shared/live-canvas.js.map +1 -0
  63. package/dist/shared/mp.js +10 -2
  64. package/dist/shared/mp.js.map +1 -1
  65. package/dist/shared/multiplayer-types.d.ts +15 -2
  66. package/dist/shared/multiplayer-types.d.ts.map +1 -1
  67. package/dist/shared/overlays.js +800 -0
  68. package/dist/shared/overlays.js.map +1 -0
  69. package/dist/shared/page-stack.js +2 -1
  70. package/dist/shared/page-stack.js.map +1 -1
  71. package/dist/shared/pose-key.js +249 -0
  72. package/dist/shared/pose-key.js.map +1 -0
  73. package/dist/shared/race-types.d.ts +1 -1
  74. package/dist/shared/race.js +1 -1
  75. package/dist/shared/room-types.d.ts +28 -3
  76. package/dist/shared/room-types.d.ts.map +1 -1
  77. package/dist/shared/rooms.d.ts.map +1 -1
  78. package/dist/shared/rooms.js +23 -4
  79. package/dist/shared/rooms.js.map +1 -1
  80. package/dist/shared/runtime-opener.js +1 -1
  81. package/dist/shared/scene.d.ts +90 -0
  82. package/dist/shared/scene.d.ts.map +1 -0
  83. package/dist/shared/social.d.ts +90 -9
  84. package/dist/shared/social.d.ts.map +1 -1
  85. package/dist/shared/social.js +6 -1
  86. package/dist/shared/social.js.map +1 -1
  87. package/dist/shared/src.js +4563 -667
  88. package/dist/shared/src.js.map +1 -1
  89. package/dist/shared/svg-markup.js +2354 -0
  90. package/dist/shared/svg-markup.js.map +1 -0
  91. package/dist/shared/svg.js +27 -0
  92. package/dist/shared/svg.js.map +1 -0
  93. package/dist/shared/types.d.ts +174 -0
  94. package/dist/shared/types.d.ts.map +1 -0
  95. package/dist/shared/wire-types.d.ts +6 -2
  96. package/dist/shared/wire-types.d.ts.map +1 -1
  97. package/dist/social.d.ts +5 -5
  98. package/dist/social.js +2 -2
  99. package/dist/turns-rules.d.ts +2 -0
  100. package/dist/turns-rules.js +1 -0
  101. package/dist/turns.d.ts +61 -0
  102. package/dist/turns.d.ts.map +1 -0
  103. package/dist/turns.js +183 -0
  104. package/dist/turns.js.map +1 -0
  105. package/package.json +125 -89
  106. package/src/auto.ts +39 -39
  107. package/src/avatar/adapters/pixi-avatar.ts +73 -0
  108. package/src/avatar/adapters/pixi-crowd.ts +329 -0
  109. package/src/avatar/adapters/pixi-types.ts +84 -0
  110. package/src/avatar/adapters/three.ts +126 -0
  111. package/src/avatar/animator/animator.ts +285 -0
  112. package/src/avatar/animator/reactions.ts +44 -0
  113. package/src/avatar/animator/schedule.ts +73 -0
  114. package/src/avatar/art/beard.ts +49 -0
  115. package/src/avatar/art/glasses.ts +67 -0
  116. package/src/avatar/art/hair.ts +254 -0
  117. package/src/avatar/art/headwear.ts +75 -0
  118. package/src/avatar/art/index.ts +21 -0
  119. package/src/avatar/art/shape.ts +87 -0
  120. package/src/avatar/art-store.ts +36 -0
  121. package/src/avatar/atlas.ts +246 -0
  122. package/src/avatar/catalog-view.ts +14 -0
  123. package/src/avatar/colour.ts +109 -0
  124. package/src/avatar/describe.ts +79 -0
  125. package/src/avatar/face.ts +187 -0
  126. package/src/avatar/geom.ts +173 -0
  127. package/src/avatar/layout.ts +47 -0
  128. package/src/avatar/look-like.ts +52 -0
  129. package/src/avatar/outputs/bitmap.ts +75 -0
  130. package/src/avatar/outputs/canvas-paint.ts +177 -0
  131. package/src/avatar/outputs/canvas.ts +62 -0
  132. package/src/avatar/outputs/custom-element.ts +140 -0
  133. package/src/avatar/outputs/element.ts +58 -0
  134. package/src/avatar/outputs/live-canvas.ts +57 -0
  135. package/src/avatar/outputs/overlay-svg.ts +22 -0
  136. package/src/avatar/outputs/pose-key.ts +30 -0
  137. package/src/avatar/outputs/svg-markup.ts +113 -0
  138. package/src/avatar/outputs/svg.ts +41 -0
  139. package/src/avatar/overlays.ts +128 -0
  140. package/src/avatar/render/body.ts +116 -0
  141. package/src/avatar/render/colours.ts +81 -0
  142. package/src/avatar/render/compose.ts +320 -0
  143. package/src/avatar/render/context.ts +117 -0
  144. package/src/avatar/render/eyes.ts +78 -0
  145. package/src/avatar/render/eyewear.ts +118 -0
  146. package/src/avatar/render/frame.ts +73 -0
  147. package/src/avatar/render/head.ts +81 -0
  148. package/src/avatar/render/mouth.ts +86 -0
  149. package/src/avatar/render/props.ts +91 -0
  150. package/src/avatar/render/types.ts +93 -0
  151. package/src/avatar/render/walker-parts.ts +121 -0
  152. package/src/avatar/render/walker.ts +199 -0
  153. package/src/avatar/scene.ts +119 -0
  154. package/src/avatar-pixi.ts +20 -0
  155. package/src/avatar-svg.ts +21 -0
  156. package/src/avatar-three.ts +18 -0
  157. package/src/avatar.ts +63 -0
  158. package/src/core/avatar/avatar-cache.ts +95 -0
  159. package/src/core/avatar/avatar-ref.ts +34 -0
  160. package/src/core/avatar/avatar-types.ts +43 -0
  161. package/src/core/avatar/hub-avatar.ts +308 -0
  162. package/src/core/connection.ts +408 -299
  163. package/src/core/errors.ts +160 -147
  164. package/src/core/game-data.ts +40 -38
  165. package/src/core/hub-economy.ts +216 -179
  166. package/src/core/hub-progress.ts +269 -205
  167. package/src/core/hub.ts +352 -131
  168. package/src/core/menu-control.ts +61 -61
  169. package/src/core/offline/cloud-saves.ts +62 -0
  170. package/src/core/offline/events.ts +50 -50
  171. package/src/core/offline/favorites.ts +21 -21
  172. package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
  173. package/src/core/offline/hub-kinds/daily-kind.ts +68 -0
  174. package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
  175. package/src/core/offline/hub-kinds/game-fields.ts +2 -0
  176. package/src/core/offline/hub-kinds/index.ts +52 -0
  177. package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
  178. package/src/core/offline/hub-kinds/progress-kinds.ts +165 -0
  179. package/src/core/offline/hub-kinds/resume-kind.ts +108 -0
  180. package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
  181. package/src/core/offline/kind-adapters.ts +48 -0
  182. package/src/core/offline/local-inventory.ts +64 -0
  183. package/src/core/offline/local-store.ts +304 -261
  184. package/src/core/offline/offline-api.ts +65 -0
  185. package/src/core/offline/outbox-entry.ts +59 -0
  186. package/src/core/offline/outbox-kinds.ts +78 -0
  187. package/src/core/offline/outbox-merge.ts +39 -0
  188. package/src/core/offline/outbox-replayer.ts +141 -0
  189. package/src/core/offline/outbox-store.ts +85 -0
  190. package/src/core/offline/outbox.ts +232 -0
  191. package/src/core/offline/queue.ts +126 -171
  192. package/src/core/offline/replay-lease.ts +87 -0
  193. package/src/core/offline/replay-policy.ts +52 -0
  194. package/src/core/page-account.ts +121 -0
  195. package/src/core/page.ts +87 -74
  196. package/src/core/rest/endpoint-types.ts +22 -22
  197. package/src/core/rest/rest-client.ts +163 -152
  198. package/src/core/rest/routes.gen.ts +144 -88
  199. package/src/core/resume/hub-resume.ts +286 -0
  200. package/src/core/resume/local-resume.ts +130 -0
  201. package/src/core/resume/resume-list.ts +65 -0
  202. package/src/core/resume/resume-request.ts +52 -0
  203. package/src/core/resume/resume-types.ts +64 -0
  204. package/src/core/saved/daily-saves.ts +196 -0
  205. package/src/core/saved/daily-seed.ts +16 -0
  206. package/src/core/saved/hub-saved.ts +221 -0
  207. package/src/core/saved/local-saved.ts +122 -0
  208. package/src/core/saved/saved-list.ts +129 -0
  209. package/src/core/saved/saved-open.ts +104 -0
  210. package/src/core/saved/saved-order.ts +49 -0
  211. package/src/core/saved/saved-types.ts +24 -0
  212. package/src/core/storage/json-guards.ts +80 -74
  213. package/src/core/storage/key-value.ts +66 -66
  214. package/src/core/ticket.ts +43 -43
  215. package/src/core/turns/hub-turns.ts +161 -0
  216. package/src/core/turns/turn-game.ts +510 -0
  217. package/src/core/turns/your-turn-buzz.ts +69 -0
  218. package/src/core/types.ts +134 -134
  219. package/src/core/version/client-version.gen.ts +2 -2
  220. package/src/core/version/hub-version.ts +62 -62
  221. package/src/core/version/semver.ts +97 -97
  222. package/src/daily/daily-system.ts +276 -161
  223. package/src/daily/page-daily.ts +36 -36
  224. package/src/daily/types.ts +105 -67
  225. package/src/daily.ts +10 -10
  226. package/src/doctor/checks.ts +86 -76
  227. package/src/doctor/cli.ts +101 -106
  228. package/src/doctor/hub-version-fetch.ts +43 -0
  229. package/src/doctor/tsconfig.json +12 -12
  230. package/src/doctor/world-check.ts +61 -0
  231. package/src/globals.ts +115 -110
  232. package/src/index.ts +68 -50
  233. package/src/input/device-state.ts +146 -146
  234. package/src/input/edges.ts +55 -55
  235. package/src/input/focus-ring.ts +13 -0
  236. package/src/input/gamepad.ts +61 -61
  237. package/src/input/input-events.ts +32 -32
  238. package/src/input/input-registry.ts +171 -164
  239. package/src/input/input-system.ts +406 -393
  240. package/src/input/legacy-controls.ts +64 -64
  241. package/src/input/modality.ts +122 -0
  242. package/src/input/nav-state.ts +200 -191
  243. package/src/input/navigator.ts +182 -171
  244. package/src/input/on-screen-controls.ts +101 -101
  245. package/src/input/page-control.ts +43 -43
  246. package/src/input/page-input.ts +193 -193
  247. package/src/input/player-remaps.ts +77 -77
  248. package/src/input/source-values.ts +116 -116
  249. package/src/input/spatial.ts +47 -47
  250. package/src/input/types.ts +331 -331
  251. package/src/input/zero-config.ts +164 -177
  252. package/src/input.ts +26 -22
  253. package/src/mp.ts +10 -10
  254. package/src/overlay/controls-panel.ts +163 -163
  255. package/src/overlay/dom.ts +46 -46
  256. package/src/overlay/layout-clusters.ts +162 -162
  257. package/src/overlay/layout-geometry.ts +136 -136
  258. package/src/overlay/layout.ts +203 -203
  259. package/src/overlay/listener-scope.ts +23 -23
  260. package/src/overlay/on-screen-keyboard.ts +137 -137
  261. package/src/overlay/overlay.ts +234 -208
  262. package/src/overlay/styles.ts +13 -12
  263. package/src/overlay/virtual-controls.ts +262 -262
  264. package/src/race.ts +10 -10
  265. package/src/realtime/bridge.ts +126 -121
  266. package/src/realtime/cross-bundle.ts +29 -29
  267. package/src/realtime/fallback-notice.ts +48 -48
  268. package/src/realtime/hub-link.ts +65 -65
  269. package/src/realtime/latest-state.ts +33 -33
  270. package/src/realtime/multiplayer-forwarder.ts +40 -28
  271. package/src/realtime/multiplayer-types.ts +66 -52
  272. package/src/realtime/page-runtime.ts +60 -60
  273. package/src/realtime/race-forwarder.ts +59 -59
  274. package/src/realtime/race-types.ts +132 -132
  275. package/src/realtime/room-types.ts +151 -125
  276. package/src/realtime/rooms-forwarder.ts +104 -79
  277. package/src/realtime/runtime-api.ts +74 -49
  278. package/src/realtime/runtime-opener.ts +61 -61
  279. package/src/realtime/social-forwarder.ts +77 -72
  280. package/src/realtime/social-types.ts +86 -78
  281. package/src/realtime/types.ts +54 -53
  282. package/src/realtime/wire-types.ts +79 -72
  283. package/src/realtime/world-types.ts +20 -0
  284. package/src/rooms.ts +10 -10
  285. package/src/social.ts +19 -18
  286. package/src/turns-kit/check-rules.ts +281 -0
  287. package/src/turns-rules.ts +12 -0
  288. package/src/turns.ts +14 -0
  289. package/src/types.ts +13 -13
  290. package/src/uistack/page-stack.ts +45 -45
  291. package/src/uistack/play-suspension.ts +82 -82
  292. package/src/uistack/types.ts +76 -76
  293. package/src/uistack/ui-stack.ts +144 -144
@@ -1,161 +1,276 @@
1
- /**
2
- * Daily challenges, client side. A daily-enabled game calls
3
- * `hub.daily.define({ play })` once at load; `play` receives a
4
- * {@link DailyChallenge} and starts exactly that challenge. When the player
5
- * finishes, the game calls `hub.daily.complete({ score, … })`.
6
- *
7
- * The system is a page-wide singleton shared by the game's client and the
8
- * injected menu, so the menu can start a day on the instance the game
9
- * registered `play` on. Deep links (`/<slug>/?daily=YYYY-MM-DD`) auto-start.
10
- */
11
- import { readField } from '../realtime/cross-bundle';
12
- import type { DailyAll, DailyChallenge, DailyCompleteResponse, DailyCompletionMeta, DailyDefinition, DailyHost, DailyStatus } from './types';
13
-
14
- const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
15
-
16
- /** The window event the injected menu fires to start a day on the game's instance. */
17
- export const DAILY_START_EVENT = 'hub:daily-start';
18
-
19
- /** A day key, or `null` when `candidate` isn't one. */
20
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: a URL parameter or a window global set by the host page
21
- function asDayKey(candidate: unknown): string | null {
22
- return typeof candidate === 'string' && DAY_PATTERN.test(candidate) ? candidate : null;
23
- }
24
-
25
- /** The day the page was deep-linked to, if any. */
26
- function readDeepLinkDay(): string | null {
27
- // Prefer the value the host's injected snippet captured at parse time: it
28
- // survives games that rewrite their own URL on boot.
29
- const captured = typeof window === 'undefined' ? null : asDayKey(readField(window, '__hubDailyDeep'));
30
- if (captured !== null || typeof location === 'undefined') {
31
- return captured;
32
- }
33
- return asDayKey(new URL(location.href).searchParams.get('daily'));
34
- }
35
-
36
- /** The message of a thrown value. */
37
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: anything a REST call can throw
38
- function messageOf(error: unknown): string {
39
- return error instanceof Error ? error.message : String(error);
40
- }
41
-
42
- /** Daily challenges (`hub.daily`). */
43
- export class DailySystem {
44
- /**
45
- * The challenge in progress (set when a day starts, cleared on complete).
46
- * A game's win handler checks it: `if (hub.daily.active) hub.daily.complete(…)`.
47
- */
48
- active: DailyChallenge | null = null;
49
- private readonly host: DailyHost;
50
- private play: DailyDefinition['play'] | null = null;
51
- private pendingDay: string | null = null;
52
- private isRelaying = false;
53
-
54
- constructor(host: DailyHost) {
55
- this.host = host;
56
- if (typeof window !== 'undefined') {
57
- // The injected menu (another module instance) asks us to start a day.
58
- window.addEventListener(DAILY_START_EVENT, (event) => {
59
- if (this.isRelaying) {
60
- return;
61
- }
62
- const day = event instanceof CustomEvent ? readField(event.detail, 'day') : undefined;
63
- if (typeof day === 'string') {
64
- void this.startAndPlay(day);
65
- }
66
- });
67
- }
68
- }
69
-
70
- /** Whether a game registered a daily-challenge handler on this page. */
71
- get isDefined(): boolean {
72
- return this.play !== null;
73
- }
74
-
75
- /**
76
- * Register the game's challenge runner. Call once at load. If the page was
77
- * deep-linked with `?daily=DATE` (or a start was requested before this),
78
- * that day starts now.
79
- */
80
- define(definition: DailyDefinition): void {
81
- this.play = definition.play;
82
- const day = readDeepLinkDay() ?? this.pendingDay;
83
- this.pendingDay = null;
84
- if (day !== null) {
85
- void this.startAndPlay(day);
86
- }
87
- }
88
-
89
- /**
90
- * Fetch the signed token for `day`, make it active and call the game's
91
- * `play`. Before `define`, the day is kept and played then, so the menu's
92
- * own instance never fetches a token; it is also relayed to any other
93
- * daily system on the page (a client copy that couldn't share this one).
94
- */
95
- async startAndPlay(day: string): Promise<void> {
96
- const play = this.play;
97
- if (play === null) {
98
- this.pendingDay = day;
99
- this.relayStart(day);
100
- return;
101
- }
102
- try {
103
- const started = await this.host.start(day);
104
- const challenge: DailyChallenge = { day: started.day, seed: started.seed, token: started.token, alreadyCompleted: started.alreadyCompleted };
105
- this.active = challenge;
106
- this.host.emit('hub:daily-started', { day: started.day, seed: started.seed, alreadyCompleted: started.alreadyCompleted });
107
- play({ ...challenge });
108
- } catch (error) {
109
- this.host.emit('hub:daily-error', { day, error: messageOf(error) });
110
- }
111
- }
112
-
113
- /**
114
- * Record completion of the active challenge with optional metadata.
115
- * Resolves with the new completion count and rank, or `null` when no
116
- * challenge is active or the hub refused.
117
- */
118
- async complete(meta: DailyCompletionMeta = {}): Promise<DailyCompleteResponse | null> {
119
- const challenge = this.active;
120
- if (challenge === null) {
121
- return null;
122
- }
123
- try {
124
- const completed = await this.host.complete(challenge.day, challenge.token, meta);
125
- this.active = null;
126
- this.host.emit('hub:daily-complete', { day: challenge.day, count: completed.count, rank: completed.rank, meta });
127
- return completed;
128
- } catch (error) {
129
- this.host.emit('hub:daily-error', { day: challenge.day, error: messageOf(error) });
130
- return null;
131
- }
132
- }
133
-
134
- /** Ask the page's other daily systems to start `day` (this one ignores its own event). */
135
- private relayStart(day: string): void {
136
- if (typeof window === 'undefined') {
137
- return;
138
- }
139
- this.isRelaying = true;
140
- try {
141
- window.dispatchEvent(new CustomEvent(DAILY_START_EVENT, { detail: { day } }));
142
- } finally {
143
- this.isRelaying = false;
144
- }
145
- }
146
-
147
- /** Abandon the active challenge without recording it (e.g. the player quit). */
148
- cancel(): void {
149
- this.active = null;
150
- }
151
-
152
- /** This game's window and the player's completions. */
153
- status(): Promise<DailyStatus> {
154
- return this.host.status();
155
- }
156
-
157
- /** Every daily-enabled game and the global standings (for the home hub). */
158
- all(): Promise<DailyAll> {
159
- return this.host.all();
160
- }
161
- }
1
+ /**
2
+ * Daily challenges, client side. A daily-enabled game calls
3
+ * `hub.daily.define({ play })` once at load; `play` receives a
4
+ * {@link DailyChallenge} and starts exactly that challenge. When the player
5
+ * finishes, the game calls `hub.daily.complete({ score, … })`.
6
+ *
7
+ * The system is a page-wide singleton shared by the game's client and the
8
+ * injected menu, so the menu can start a day on the instance the game
9
+ * registered `play` on. Deep links (`/<slug>/?daily=YYYY-MM-DD`) auto-start.
10
+ */
11
+ import { readField } from '../realtime/cross-bundle';
12
+ import type { SavedEntry } from '../core/saved/saved-types';
13
+ import type { DailyAll, DailyChallenge, DailyCompleteResponse, DailyCompletionMeta, DailyDefinition, DailyHost, DailySaveInput, DailyStartResponse, DailyStatus } from './types';
14
+
15
+ const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
16
+
17
+ /** The window event the injected menu fires to start a day on the game's instance. */
18
+ export const DAILY_START_EVENT = 'hub:daily-start';
19
+
20
+ /** A day key, or `null` when `candidate` isn't one. */
21
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: a URL parameter or a window global set by the host page
22
+ function asDayKey(candidate: unknown): string | null {
23
+ return typeof candidate === 'string' && DAY_PATTERN.test(candidate) ? candidate : null;
24
+ }
25
+
26
+ /** The day the page was deep-linked to, if any. */
27
+ function readDeepLinkDay(): string | null {
28
+ // Prefer the value the host's injected snippet captured at parse time: it
29
+ // survives games that rewrite their own URL on boot.
30
+ const captured = typeof window === 'undefined' ? null : asDayKey(readField(window, '__hubDailyDeep'));
31
+ if (captured !== null || typeof location === 'undefined') {
32
+ return captured;
33
+ }
34
+ return asDayKey(new URL(location.href).searchParams.get('daily'));
35
+ }
36
+
37
+ /** The message of a thrown value. */
38
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: anything a REST call can throw
39
+ function messageOf(error: unknown): string {
40
+ return error instanceof Error ? error.message : String(error);
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
+
51
+ /** Daily challenges (`hub.daily`). */
52
+ export class DailySystem {
53
+ /**
54
+ * The challenge in progress: set when a day starts; cleared the moment the
55
+ * game calls `complete` (whatever the hub answers, and before it answers)
56
+ * or `cancel` (or `discard`). A game's win handler checks it:
57
+ * `if (hub.daily.active) hub.daily.complete(…)`. While it is set the page
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()`.
61
+ */
62
+ active: DailyChallenge | null = null;
63
+ private readonly host: DailyHost;
64
+ private play: DailyDefinition['play'] | null = null;
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;
69
+ private isRelaying = false;
70
+
71
+ constructor(host: DailyHost) {
72
+ this.host = host;
73
+ if (typeof window !== 'undefined') {
74
+ // The injected menu (another module instance) asks us to start a day.
75
+ window.addEventListener(DAILY_START_EVENT, (event) => {
76
+ if (this.isRelaying) {
77
+ return;
78
+ }
79
+ const day = event instanceof CustomEvent ? readField(event.detail, 'day') : undefined;
80
+ if (typeof day === 'string') {
81
+ void this.startAndPlay(day);
82
+ }
83
+ });
84
+ }
85
+ }
86
+
87
+ /** Whether a game registered a daily-challenge handler on this page. */
88
+ get isDefined(): boolean {
89
+ return this.play !== null;
90
+ }
91
+
92
+ /**
93
+ * Register the game's challenge runner. Call once at load. If the page was
94
+ * deep-linked with `?daily=DATE` (or a start was requested before this),
95
+ * that day starts now.
96
+ */
97
+ define(definition: DailyDefinition): void {
98
+ this.play = definition.play;
99
+ const day = readDeepLinkDay() ?? this.pendingDay;
100
+ const instanceId = this.pendingInstance;
101
+ this.pendingDay = null;
102
+ this.pendingInstance = null;
103
+ if (day !== null) {
104
+ void this.startAndPlay(day);
105
+ } else if (instanceId !== null) {
106
+ void this.resume(instanceId);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Fetch the signed token for `day`, make it active and call the game's
112
+ * `play`. Before `define`, the day is kept and played then, so the menu's
113
+ * own instance never fetches a token; it is also relayed to any other
114
+ * daily system on the page (a client copy that couldn't share this one).
115
+ */
116
+ async startAndPlay(day: string): Promise<void> {
117
+ const play = this.play;
118
+ if (play === null) {
119
+ this.pendingDay = day;
120
+ this.relayStart(day);
121
+ return;
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
+ }
154
+ try {
155
+ this.begin(play, challengeOf(await this.host.resume(instanceId)));
156
+ } catch (error) {
157
+ this.host.emit('hub:daily-error', { day: '', error: messageOf(error) });
158
+ }
159
+ }
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
+
208
+ /**
209
+ * Record completion of the active challenge with optional metadata.
210
+ * Resolves with the new completion count and rank, or `null` when no
211
+ * challenge is active, the hub refused, or the hub can't be reached: then
212
+ * the completion is queued (`hub:daily-queued`) and replays when it is
213
+ * back (`hub:daily-complete` follows). The daily run ends at the call,
214
+ * so `active` is `null` at once, even if the hub then refuses it.
215
+ */
216
+ async complete(meta: DailyCompletionMeta = {}): Promise<DailyCompleteResponse | null> {
217
+ const challenge = this.active;
218
+ if (challenge === null) {
219
+ return null;
220
+ }
221
+ this.active = null;
222
+ try {
223
+ const completed = await this.host.complete(challenge.day, challenge.token, meta);
224
+ if (completed === null) {
225
+ // Queued offline: `hub:daily-complete` follows when it replays.
226
+ this.host.emit('hub:daily-queued', { day: challenge.day, queued: true, meta });
227
+ return null;
228
+ }
229
+ this.host.emit('hub:daily-complete', { day: challenge.day, count: completed.count, rank: completed.rank, meta });
230
+ return completed;
231
+ } catch (error) {
232
+ this.host.emit('hub:daily-error', { day: challenge.day, error: messageOf(error) });
233
+ return null;
234
+ }
235
+ }
236
+
237
+ /** Ask the page's other daily systems to start `day` (this one ignores its own event). */
238
+ private relayStart(day: string): void {
239
+ if (typeof window === 'undefined') {
240
+ return;
241
+ }
242
+ this.isRelaying = true;
243
+ try {
244
+ window.dispatchEvent(new CustomEvent(DAILY_START_EVENT, { detail: { day } }));
245
+ } finally {
246
+ this.isRelaying = false;
247
+ }
248
+ }
249
+
250
+ /**
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()`.
257
+ */
258
+ cancel(): void {
259
+ const challenge = this.active;
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
+ }
265
+ }
266
+
267
+ /** This game's window and the player's completions. */
268
+ status(): Promise<DailyStatus> {
269
+ return this.host.status();
270
+ }
271
+
272
+ /** Every daily-enabled game and the global standings (for the home hub). */
273
+ all(): Promise<DailyAll> {
274
+ return this.host.all();
275
+ }
276
+ }
@@ -1,36 +1,36 @@
1
- /**
2
- * The page's one daily-challenge system (plan §4.4): the first bundle to
3
- * create it wins, so the menu starts days on the instance the game defined.
4
- */
5
- import { HUB_CLIENT_VERSION } from '../core/version/client-version.gen';
6
- import { claimSingleton } from '../globals';
7
- import { hasMethods } from '../realtime/cross-bundle';
8
- import { DailySystem } from './daily-system';
9
- import type { DailyHost } from './types';
10
-
11
- /** The cross-bundle shape number of the `daily` singleton. Bump only on an incompatible change. */
12
- export const DAILY_SINGLETON_API = 1;
13
-
14
- /** Whether `candidate` is some bundle's daily system (v1 copies included). */
15
- // eslint-disable-next-line no-restricted-syntax -- trust boundary: the instance may come from another bundle
16
- export function isDailySystem(candidate: unknown): candidate is DailySystem {
17
- return hasMethods(candidate, ['define', 'startAndPlay', 'complete', 'cancel', 'status', 'all']);
18
- }
19
-
20
- let claimedDaily: DailySystem | null = null;
21
-
22
- /**
23
- * The page-wide daily system, created with `host` if no bundle has made one
24
- * yet. On a shape mismatch the registered one is kept and this bundle gets a
25
- * private instance. The answer is kept for this bundle.
26
- */
27
- export function getDaily(host: DailyHost): DailySystem {
28
- claimedDaily ??= claimSingleton({
29
- name: 'daily',
30
- version: HUB_CLIENT_VERSION,
31
- api: DAILY_SINGLETON_API,
32
- create: () => new DailySystem(host),
33
- isInstance: isDailySystem,
34
- });
35
- return claimedDaily;
36
- }
1
+ /**
2
+ * The page's one daily-challenge system (plan §4.4): the first bundle to
3
+ * create it wins, so the menu starts days on the instance the game defined.
4
+ */
5
+ import { HUB_CLIENT_VERSION } from '../core/version/client-version.gen';
6
+ import { claimSingleton } from '../globals';
7
+ import { hasMethods } from '../realtime/cross-bundle';
8
+ import { DailySystem } from './daily-system';
9
+ import type { DailyHost } from './types';
10
+
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
+
14
+ /** Whether `candidate` is some bundle's daily system (v1 copies included). */
15
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: the instance may come from another bundle
16
+ export function isDailySystem(candidate: unknown): candidate is DailySystem {
17
+ return hasMethods(candidate, ['define', 'startAndPlay', 'complete', 'cancel', 'status', 'all']);
18
+ }
19
+
20
+ let claimedDaily: DailySystem | null = null;
21
+
22
+ /**
23
+ * The page-wide daily system, created with `host` if no bundle has made one
24
+ * yet. On a shape mismatch the registered one is kept and this bundle gets a
25
+ * private instance. The answer is kept for this bundle.
26
+ */
27
+ export function getDaily(host: DailyHost): DailySystem {
28
+ claimedDaily ??= claimSingleton({
29
+ name: 'daily',
30
+ version: HUB_CLIENT_VERSION,
31
+ api: DAILY_SINGLETON_API,
32
+ create: () => new DailySystem(host),
33
+ isInstance: isDailySystem,
34
+ });
35
+ return claimedDaily;
36
+ }