@diffenderfer-games/hub 2.0.0-next.1 → 2.0.0-next.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/CHANGELOG.md +160 -6
  2. package/README.md +27 -15
  3. package/dist/auto.d.ts +8 -8
  4. package/dist/auto.js +3 -3
  5. package/dist/avatar-pixi.d.ts +165 -0
  6. package/dist/avatar-pixi.d.ts.map +1 -0
  7. package/dist/avatar-pixi.js +299 -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 +500 -0
  17. package/dist/avatar.js.map +1 -0
  18. package/dist/daily.d.ts +2 -2
  19. package/dist/doctor/cli.mjs +71 -10
  20. package/dist/doctor/cli.mjs.map +1 -1
  21. package/dist/index.d.ts +8 -8
  22. package/dist/index.js +3 -3
  23. package/dist/input.d.ts +2 -2
  24. package/dist/input.js +2 -2
  25. package/dist/mp.d.ts +3 -3
  26. package/dist/race.d.ts +1 -1
  27. package/dist/rooms.d.ts +3 -3
  28. package/dist/rooms.js +1 -1
  29. package/dist/shared/animator.d.ts +104 -0
  30. package/dist/shared/animator.d.ts.map +1 -0
  31. package/dist/shared/art.js +1946 -0
  32. package/dist/shared/art.js.map +1 -0
  33. package/dist/shared/atlas.d.ts +138 -0
  34. package/dist/shared/atlas.d.ts.map +1 -0
  35. package/dist/shared/avatar.d.ts +123 -0
  36. package/dist/shared/avatar.d.ts.map +1 -0
  37. package/dist/shared/bridge.js +18 -4
  38. package/dist/shared/bridge.js.map +1 -1
  39. package/dist/shared/client-version.gen.js +1 -1
  40. package/dist/shared/daily.d.ts +145 -19
  41. package/dist/shared/daily.d.ts.map +1 -1
  42. package/dist/shared/daily.js +138 -25
  43. package/dist/shared/daily.js.map +1 -1
  44. package/dist/shared/defineProperty.js +39 -0
  45. package/dist/shared/describe.d.ts +32 -0
  46. package/dist/shared/describe.d.ts.map +1 -0
  47. package/dist/shared/describe.js +109 -0
  48. package/dist/shared/describe.js.map +1 -0
  49. package/dist/shared/globals.js +1 -39
  50. package/dist/shared/globals.js.map +1 -1
  51. package/dist/shared/index.d.ts +1122 -46
  52. package/dist/shared/index.d.ts.map +1 -1
  53. package/dist/shared/input.d.ts +74 -14
  54. package/dist/shared/input.d.ts.map +1 -1
  55. package/dist/shared/input.js +179 -25
  56. package/dist/shared/input.js.map +1 -1
  57. package/dist/shared/json-guards.js +48 -0
  58. package/dist/shared/json-guards.js.map +1 -0
  59. package/dist/shared/latest-state.js +1 -1
  60. package/dist/shared/live-canvas.js +62 -0
  61. package/dist/shared/live-canvas.js.map +1 -0
  62. package/dist/shared/mp.js +10 -2
  63. package/dist/shared/mp.js.map +1 -1
  64. package/dist/shared/multiplayer-types.d.ts +15 -2
  65. package/dist/shared/multiplayer-types.d.ts.map +1 -1
  66. package/dist/shared/overlays.js +800 -0
  67. package/dist/shared/overlays.js.map +1 -0
  68. package/dist/shared/page-stack.js +2 -1
  69. package/dist/shared/page-stack.js.map +1 -1
  70. package/dist/shared/pose-key.js +249 -0
  71. package/dist/shared/pose-key.js.map +1 -0
  72. package/dist/shared/race-types.d.ts +1 -1
  73. package/dist/shared/race.js +1 -1
  74. package/dist/shared/room-types.d.ts +28 -3
  75. package/dist/shared/room-types.d.ts.map +1 -1
  76. package/dist/shared/rooms.d.ts.map +1 -1
  77. package/dist/shared/rooms.js +23 -4
  78. package/dist/shared/rooms.js.map +1 -1
  79. package/dist/shared/runtime-opener.js +1 -1
  80. package/dist/shared/scene.d.ts +90 -0
  81. package/dist/shared/scene.d.ts.map +1 -0
  82. package/dist/shared/social.d.ts +90 -9
  83. package/dist/shared/social.d.ts.map +1 -1
  84. package/dist/shared/social.js +6 -1
  85. package/dist/shared/social.js.map +1 -1
  86. package/dist/shared/src.js +4563 -667
  87. package/dist/shared/src.js.map +1 -1
  88. package/dist/shared/svg-markup.js +2354 -0
  89. package/dist/shared/svg-markup.js.map +1 -0
  90. package/dist/shared/svg.js +27 -0
  91. package/dist/shared/svg.js.map +1 -0
  92. package/dist/shared/types.d.ts +174 -0
  93. package/dist/shared/types.d.ts.map +1 -0
  94. package/dist/shared/wire-types.d.ts +6 -2
  95. package/dist/shared/wire-types.d.ts.map +1 -1
  96. package/dist/social.d.ts +5 -5
  97. package/dist/social.js +2 -2
  98. package/dist/turns-rules.d.ts +2 -0
  99. package/dist/turns-rules.js +1 -0
  100. package/dist/turns.d.ts +61 -0
  101. package/dist/turns.d.ts.map +1 -0
  102. package/dist/turns.js +183 -0
  103. package/dist/turns.js.map +1 -0
  104. package/package.json +39 -3
  105. package/src/avatar/adapters/pixi-avatar.ts +73 -0
  106. package/src/avatar/adapters/pixi-crowd.ts +329 -0
  107. package/src/avatar/adapters/pixi-types.ts +84 -0
  108. package/src/avatar/adapters/three.ts +126 -0
  109. package/src/avatar/animator/animator.ts +285 -0
  110. package/src/avatar/animator/reactions.ts +44 -0
  111. package/src/avatar/animator/schedule.ts +73 -0
  112. package/src/avatar/art/beard.ts +49 -0
  113. package/src/avatar/art/glasses.ts +67 -0
  114. package/src/avatar/art/hair.ts +254 -0
  115. package/src/avatar/art/headwear.ts +75 -0
  116. package/src/avatar/art/index.ts +21 -0
  117. package/src/avatar/art/shape.ts +87 -0
  118. package/src/avatar/art-store.ts +36 -0
  119. package/src/avatar/atlas.ts +246 -0
  120. package/src/avatar/catalog-view.ts +14 -0
  121. package/src/avatar/colour.ts +109 -0
  122. package/src/avatar/describe.ts +79 -0
  123. package/src/avatar/face.ts +187 -0
  124. package/src/avatar/geom.ts +173 -0
  125. package/src/avatar/layout.ts +47 -0
  126. package/src/avatar/look-like.ts +52 -0
  127. package/src/avatar/outputs/bitmap.ts +75 -0
  128. package/src/avatar/outputs/canvas-paint.ts +177 -0
  129. package/src/avatar/outputs/canvas.ts +62 -0
  130. package/src/avatar/outputs/custom-element.ts +140 -0
  131. package/src/avatar/outputs/element.ts +58 -0
  132. package/src/avatar/outputs/live-canvas.ts +57 -0
  133. package/src/avatar/outputs/overlay-svg.ts +22 -0
  134. package/src/avatar/outputs/pose-key.ts +30 -0
  135. package/src/avatar/outputs/svg-markup.ts +113 -0
  136. package/src/avatar/outputs/svg.ts +41 -0
  137. package/src/avatar/overlays.ts +128 -0
  138. package/src/avatar/render/body.ts +116 -0
  139. package/src/avatar/render/colours.ts +81 -0
  140. package/src/avatar/render/compose.ts +320 -0
  141. package/src/avatar/render/context.ts +117 -0
  142. package/src/avatar/render/eyes.ts +78 -0
  143. package/src/avatar/render/eyewear.ts +118 -0
  144. package/src/avatar/render/frame.ts +73 -0
  145. package/src/avatar/render/head.ts +81 -0
  146. package/src/avatar/render/mouth.ts +86 -0
  147. package/src/avatar/render/props.ts +91 -0
  148. package/src/avatar/render/types.ts +93 -0
  149. package/src/avatar/render/walker-parts.ts +121 -0
  150. package/src/avatar/render/walker.ts +199 -0
  151. package/src/avatar/scene.ts +119 -0
  152. package/src/avatar-pixi.ts +20 -0
  153. package/src/avatar-svg.ts +21 -0
  154. package/src/avatar-three.ts +18 -0
  155. package/src/avatar.ts +63 -0
  156. package/src/core/avatar/avatar-cache.ts +95 -0
  157. package/src/core/avatar/avatar-ref.ts +34 -0
  158. package/src/core/avatar/avatar-types.ts +43 -0
  159. package/src/core/avatar/hub-avatar.ts +308 -0
  160. package/src/core/connection.ts +201 -92
  161. package/src/core/errors.ts +15 -2
  162. package/src/core/game-data.ts +11 -9
  163. package/src/core/hub-economy.ts +47 -10
  164. package/src/core/hub-progress.ts +94 -30
  165. package/src/core/hub.ts +228 -7
  166. package/src/core/offline/cloud-saves.ts +62 -0
  167. package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
  168. package/src/core/offline/hub-kinds/daily-kind.ts +68 -0
  169. package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
  170. package/src/core/offline/hub-kinds/game-fields.ts +2 -0
  171. package/src/core/offline/hub-kinds/index.ts +52 -0
  172. package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
  173. package/src/core/offline/hub-kinds/progress-kinds.ts +165 -0
  174. package/src/core/offline/hub-kinds/resume-kind.ts +108 -0
  175. package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
  176. package/src/core/offline/kind-adapters.ts +48 -0
  177. package/src/core/offline/local-inventory.ts +64 -0
  178. package/src/core/offline/local-store.ts +66 -23
  179. package/src/core/offline/offline-api.ts +65 -0
  180. package/src/core/offline/outbox-entry.ts +59 -0
  181. package/src/core/offline/outbox-kinds.ts +78 -0
  182. package/src/core/offline/outbox-merge.ts +39 -0
  183. package/src/core/offline/outbox-replayer.ts +141 -0
  184. package/src/core/offline/outbox-store.ts +85 -0
  185. package/src/core/offline/outbox.ts +232 -0
  186. package/src/core/offline/queue.ts +4 -49
  187. package/src/core/offline/replay-lease.ts +87 -0
  188. package/src/core/offline/replay-policy.ts +52 -0
  189. package/src/core/page-account.ts +121 -0
  190. package/src/core/page.ts +14 -1
  191. package/src/core/rest/rest-client.ts +14 -3
  192. package/src/core/rest/routes.gen.ts +144 -88
  193. package/src/core/resume/hub-resume.ts +286 -0
  194. package/src/core/resume/local-resume.ts +130 -0
  195. package/src/core/resume/resume-list.ts +65 -0
  196. package/src/core/resume/resume-request.ts +52 -0
  197. package/src/core/resume/resume-types.ts +64 -0
  198. package/src/core/saved/daily-saves.ts +196 -0
  199. package/src/core/saved/daily-seed.ts +16 -0
  200. package/src/core/saved/hub-saved.ts +221 -0
  201. package/src/core/saved/local-saved.ts +122 -0
  202. package/src/core/saved/saved-list.ts +129 -0
  203. package/src/core/saved/saved-open.ts +104 -0
  204. package/src/core/saved/saved-order.ts +49 -0
  205. package/src/core/saved/saved-types.ts +24 -0
  206. package/src/core/storage/json-guards.ts +6 -0
  207. package/src/core/turns/hub-turns.ts +161 -0
  208. package/src/core/turns/turn-game.ts +510 -0
  209. package/src/core/turns/your-turn-buzz.ts +69 -0
  210. package/src/core/version/client-version.gen.ts +2 -2
  211. package/src/daily/daily-system.ts +127 -12
  212. package/src/daily/page-daily.ts +2 -2
  213. package/src/daily/types.ts +49 -11
  214. package/src/doctor/checks.ts +12 -4
  215. package/src/doctor/cli.ts +5 -2
  216. package/src/doctor/hub-version-fetch.ts +43 -43
  217. package/src/doctor/world-check.ts +61 -0
  218. package/src/index.ts +20 -2
  219. package/src/input/focus-ring.ts +13 -0
  220. package/src/input/input-registry.ts +9 -2
  221. package/src/input/input-system.ts +14 -1
  222. package/src/input/modality.ts +122 -0
  223. package/src/input/nav-state.ts +13 -4
  224. package/src/input/navigator.ts +11 -0
  225. package/src/input/zero-config.ts +1 -14
  226. package/src/input.ts +4 -0
  227. package/src/mp.ts +1 -1
  228. package/src/overlay/overlay.ts +30 -4
  229. package/src/overlay/styles.ts +4 -3
  230. package/src/realtime/bridge.ts +5 -0
  231. package/src/realtime/multiplayer-forwarder.ts +13 -1
  232. package/src/realtime/multiplayer-types.ts +15 -1
  233. package/src/realtime/room-types.ts +28 -2
  234. package/src/realtime/rooms-forwarder.ts +27 -2
  235. package/src/realtime/runtime-api.ts +33 -8
  236. package/src/realtime/social-forwarder.ts +5 -0
  237. package/src/realtime/social-types.ts +8 -0
  238. package/src/realtime/types.ts +1 -0
  239. package/src/realtime/wire-types.ts +7 -0
  240. package/src/realtime/world-types.ts +20 -0
  241. package/src/rooms.ts +1 -1
  242. package/src/social.ts +1 -0
  243. package/src/turns-kit/check-rules.ts +281 -0
  244. package/src/turns-rules.ts +12 -0
  245. package/src/turns.ts +14 -0
@@ -0,0 +1,69 @@
1
+ /**
2
+ * "Your turn" buzz: a short vibration when this player becomes due in an
3
+ * online game whose page they have open and visible (a phone held while
4
+ * waiting). Every online game gets it from `TurnGame`, with no game code.
5
+ *
6
+ * It buzzes only when the player **becomes** due on a live update: not for
7
+ * the next step of their own turn, not for a move they may make out of turn
8
+ * (`mayAct`), not for a game they just opened or a catch-up after being
9
+ * away, and not in a game they muted. It stays quiet when the player turned
10
+ * it off (the menu's Settings, stored on this device), when the page asks
11
+ * for less motion (the hub's `data-hub-motion="reduce"` or the system's
12
+ * reduced motion), and where `navigator.vibrate` doesn't exist (iOS Safari).
13
+ * A page can turn it off for itself (`hub.turns.setYourTurnBuzz(false)`) or
14
+ * for one game (`TurnGame.setYourTurnBuzz(false)`).
15
+ */
16
+ import type { TurnSummary } from '@diffenderfer-games/hub-contract';
17
+
18
+ /** The pattern: two short pulses, ms. */
19
+ export const YOUR_TURN_BUZZ: readonly number[] = [60, 40, 60];
20
+
21
+ /** Where this device keeps the player's choice (`off` turns it off). */
22
+ export const YOUR_TURN_BUZZ_KEY = 'dg:your-turn-buzz';
23
+
24
+ /** Whether going from `previous` to `next` makes this player newly due (the moment to buzz). */
25
+ export function isNewlyDue(previous: Pick<TurnSummary, 'phase' | 'isMyTurn'>, next: Pick<TurnSummary, 'phase' | 'isMyTurn' | 'isMuted'>): boolean {
26
+ const wasDue = previous.phase === 'playing' && previous.isMyTurn;
27
+ return next.phase === 'playing' && next.isMyTurn && !wasDue && !next.isMuted;
28
+ }
29
+
30
+ /** The player's own switch on this device (on unless they turned it off). */
31
+ export function readYourTurnBuzz(): boolean {
32
+ try {
33
+ return localStorage.getItem(YOUR_TURN_BUZZ_KEY) !== 'off';
34
+ } catch {
35
+ return true;
36
+ }
37
+ }
38
+
39
+ /** Save the player's switch on this device. */
40
+ export function saveYourTurnBuzz(isOn: boolean): void {
41
+ try {
42
+ localStorage.setItem(YOUR_TURN_BUZZ_KEY, isOn ? 'on' : 'off');
43
+ } catch {
44
+ // No storage: it stays on.
45
+ }
46
+ }
47
+
48
+ /** Whether the page asks for less motion (the hub's setting, else the system's). */
49
+ function prefersLessMotion(): boolean {
50
+ if (typeof document !== 'undefined' && document.documentElement.dataset.hubMotion === 'reduce') {
51
+ return true;
52
+ }
53
+ return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
54
+ }
55
+
56
+ /** Buzz now, when this page is visible and nothing says not to; fails silently. Answers whether it buzzed. */
57
+ export function buzzYourTurn(): boolean {
58
+ if (typeof document === 'undefined' || document.visibilityState !== 'visible' || !readYourTurnBuzz() || prefersLessMotion()) {
59
+ return false;
60
+ }
61
+ if (typeof navigator === 'undefined' || typeof navigator.vibrate !== 'function') {
62
+ return false;
63
+ }
64
+ try {
65
+ return navigator.vibrate([...YOUR_TURN_BUZZ]);
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
@@ -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.1';
3
+ export const HUB_CLIENT_VERSION = '2.0.0-next.10';
@@ -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,16 +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
- * 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(…)`.
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()`.
47
61
  */
48
62
  active: DailyChallenge | null = null;
49
63
  private readonly host: DailyHost;
50
64
  private play: DailyDefinition['play'] | null = null;
51
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;
52
69
  private isRelaying = false;
53
70
 
54
71
  constructor(host: DailyHost) {
@@ -80,9 +97,13 @@ export class DailySystem {
80
97
  define(definition: DailyDefinition): void {
81
98
  this.play = definition.play;
82
99
  const day = readDeepLinkDay() ?? this.pendingDay;
100
+ const instanceId = this.pendingInstance;
83
101
  this.pendingDay = null;
102
+ this.pendingInstance = null;
84
103
  if (day !== null) {
85
104
  void this.startAndPlay(day);
105
+ } else if (instanceId !== null) {
106
+ void this.resume(instanceId);
86
107
  }
87
108
  }
88
109
 
@@ -99,30 +120,112 @@ export class DailySystem {
99
120
  this.relayStart(day);
100
121
  return;
101
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
+ }
102
154
  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 });
155
+ this.begin(play, challengeOf(await this.host.resume(instanceId)));
108
156
  } catch (error) {
109
- this.host.emit('hub:daily-error', { day, error: messageOf(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 });
110
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 });
111
206
  }
112
207
 
113
208
  /**
114
209
  * Record completion of the active challenge with optional metadata.
115
210
  * Resolves with the new completion count and rank, or `null` when no
116
- * challenge is active or the hub refused.
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.
117
215
  */
118
216
  async complete(meta: DailyCompletionMeta = {}): Promise<DailyCompleteResponse | null> {
119
217
  const challenge = this.active;
120
218
  if (challenge === null) {
121
219
  return null;
122
220
  }
221
+ this.active = null;
123
222
  try {
124
223
  const completed = await this.host.complete(challenge.day, challenge.token, meta);
125
- this.active = null;
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
+ }
126
229
  this.host.emit('hub:daily-complete', { day: challenge.day, count: completed.count, rank: completed.rank, meta });
127
230
  return completed;
128
231
  } catch (error) {
@@ -144,9 +247,21 @@ export class DailySystem {
144
247
  }
145
248
  }
146
249
 
147
- /** Abandon the active challenge without recording it (e.g. the player quit). */
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
+ */
148
258
  cancel(): void {
259
+ const challenge = this.active;
149
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
+ }
150
265
  }
151
266
 
152
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
@@ -3,12 +3,14 @@
3
3
  * answers (typed from the contract's daily endpoints).
4
4
  */
5
5
  import type { dailyEndpoints, Json } from '@diffenderfer-games/hub-contract';
6
+ import type { GamePayload } from '../core/game-data';
7
+ import type { SavedEntry } from '../core/saved/saved-types';
6
8
  import type { SchemaOutput } from '../realtime/wire-types';
7
9
 
8
10
  /** The response type of a daily endpoint. */
9
11
  type DailyResponse<Name extends keyof typeof dailyEndpoints> = SchemaOutput<(typeof dailyEndpoints)[Name]['cfg']['response']>;
10
12
 
11
- /** This game's daily window and the player's completions (`GET /games/:slug/daily`). */
13
+ /** This game's daily window, the player's completions and days in progress (`GET /games/:slug/daily`). */
12
14
  export type DailyStatus = DailyResponse<'getGameDailyChallenges'>;
13
15
 
14
16
  /** Every daily game and the global standings (`GET /daily`). */
@@ -17,7 +19,7 @@ export type DailyAll = DailyResponse<'listDailyChallengesForAllGames'>;
17
19
  /** One game's row in {@link DailyAll}. */
18
20
  export type DailyGameStatus = DailyAll['games'][number];
19
21
 
20
- /** The answer to starting a day: its seed and the signed token to present on completion. */
22
+ /** The answer to starting (or resuming) a day: its seed, the signed token to present on completion, and its saved game. */
21
23
  export type DailyStartResponse = DailyResponse<'startDailyChallenge'>;
22
24
 
23
25
  /** The answer to completing a day: the player's new completion count and rank. */
@@ -25,26 +27,49 @@ export type DailyCompleteResponse = DailyResponse<'completeDailyChallenge'>;
25
27
 
26
28
  /** The challenge a game's `play` receives: set up and start exactly this day (derive the level from `seed`). */
27
29
  export interface DailyChallenge {
28
- /** The day, `YYYY-MM-DD`. */
30
+ /** The day, `YYYY-MM-DD` (a UTC day key). */
29
31
  day: string;
30
32
  /** Deterministic per game and day. */
31
33
  seed: number;
32
- /** The signed token `complete` presents. */
34
+ /** The signed token `complete` presents (empty for a day started offline: it is fetched when the hub is back). */
33
35
  token: string;
34
- /** The player already completed this day (a replay). */
36
+ /** The player already completed this day (a replay for fun: nothing is saved or recorded). */
35
37
  alreadyCompleted?: boolean;
38
+ /** The day's saved game; `null` for a replay of a completed day. */
39
+ instanceId: string | null;
40
+ /**
41
+ * The state last saved for this day (`hub.daily.save`, or `hub.resume.save`
42
+ * during the daily), or `null` when the day starts fresh. A game that
43
+ * saves restores from it: `if (challenge.saved) startFrom(challenge.saved)`.
44
+ */
45
+ saved: GamePayload | null;
46
+ /** The game's state version of `saved` (0 when none). */
47
+ savedVersion: number;
48
+ /** The day was already in progress and was picked up again. */
49
+ resumed?: boolean;
36
50
  }
37
51
 
38
52
  /** What `hub.daily.define` takes. */
39
53
  export interface DailyDefinition {
40
- /** Start this challenge now. */
54
+ /** Start this challenge now (restore `challenge.saved` when it has one). */
41
55
  play: (challenge: DailyChallenge) => void;
42
56
  }
43
57
 
44
58
  /** Metadata a game records with a completion (`{ score, difficulty, timeMs, … }`). */
45
59
  export type DailyCompletionMeta = Record<string, Json>;
46
60
 
47
- /** What the daily system needs from the hub client: the REST calls and its window events. */
61
+ /** A daily state save (the same shape as `hub.resume.save`). */
62
+ export interface DailySaveInput {
63
+ /** Everything needed to rebuild the day's run (up to 512 KB as JSON). */
64
+ readonly state: GamePayload;
65
+ /** A short line ("34 cards left") and an optional 0 to 1 fraction. */
66
+ readonly progress: { readonly label: string; readonly fraction?: number | null };
67
+ readonly title?: string | null;
68
+ /** The game's state version (default 0). */
69
+ readonly version?: number;
70
+ }
71
+
72
+ /** What the daily system needs from the hub client: the REST calls, the device copy and its window events. */
48
73
  export interface DailyHost {
49
74
  /** This page's game slug. */
50
75
  readonly slug: string | null;
@@ -52,16 +77,29 @@ export interface DailyHost {
52
77
  status(): Promise<DailyStatus>;
53
78
  /** `GET /daily`. */
54
79
  all(): Promise<DailyAll>;
55
- /** `POST /games/:slug/daily/:day/start`. */
80
+ /** Start (or pick up) a day: `POST /games/:slug/daily/:day/start`, or a device-only start while offline. */
56
81
  start(day: string): Promise<DailyStartResponse>;
57
- /** `POST /games/:slug/daily/:day/complete`. */
58
- complete(day: string, token: string, meta: DailyCompletionMeta): Promise<DailyCompleteResponse>;
82
+ /** Pick a day in progress up again by its saved game's id. */
83
+ resume(instanceId: string): Promise<DailyStartResponse>;
84
+ /** `POST /games/:slug/daily/:day/complete`; `null` when it was queued offline, to replay when the hub is back. */
85
+ complete(day: string, token: string, meta: DailyCompletionMeta): Promise<DailyCompleteResponse | null>;
86
+ /** Keep the day's state on the device at once and send it soon (works offline); `false` when it can't be stored. */
87
+ save(challenge: DailyChallenge, input: DailySaveInput): boolean;
88
+ /** Send a waiting save now. */
89
+ flush(): Promise<void>;
90
+ /** Give the day up: discard its saved game (works offline). */
91
+ discard(challenge: DailyChallenge): Promise<void>;
92
+ /** This game's days in progress (the saved games of kind daily). */
93
+ inProgress(): Promise<SavedEntry[]>;
59
94
  /** Fire a `hub:*` window event (the menu listens). */
60
95
  emit(name: string, detail: DailyEventDetail): void;
61
96
  }
62
97
 
63
98
  /** The detail of the `hub:daily-*` window events. */
64
99
  export type DailyEventDetail =
65
- | { day: string; seed: number; alreadyCompleted: boolean }
100
+ | { day: string; seed: number; alreadyCompleted: boolean; resumed?: boolean }
66
101
  | { day: string; count: number; rank: number | null; meta: DailyCompletionMeta }
102
+ | { day: string; queued: true; meta: DailyCompletionMeta }
103
+ | { day: string; paused: true }
104
+ | { day: string; discarded: true }
67
105
  | { day: string; error: string };
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { existsSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
- import { GamePackageJson, type HubVersion } from '@diffenderfer-games/hub-contract';
7
+ import { GAME_LABELS, GamePackageJson, splitGameLabels, type HubVersion } from '@diffenderfer-games/hub-contract';
8
8
  import { clientSupport } from '../core/version/hub-version';
9
9
  import { isHubUnreachable, type HubUnreachable } from './hub-version-fetch';
10
10
 
@@ -15,8 +15,8 @@ export type FindingLevel = 'error' | 'warning' | 'info';
15
15
  export interface Finding {
16
16
  /** How serious it is. */
17
17
  readonly level: FindingLevel;
18
- /** Which check found it: `version`, `vendored` or `manifest`. */
19
- readonly check: 'version' | 'vendored' | 'manifest';
18
+ /** Which check found it: `version`, `vendored`, `manifest` or `world`. */
19
+ readonly check: 'version' | 'vendored' | 'manifest' | 'world';
20
20
  /** What to tell the developer, with the fix. */
21
21
  readonly message: string;
22
22
  }
@@ -68,7 +68,15 @@ export function checkVendoredCopies(gameDir: string): Finding[] {
68
68
  export function checkManifest(packageJson: unknown): Finding[] {
69
69
  const parsed = GamePackageJson.safeParse(packageJson);
70
70
  if (parsed.success) {
71
- return [{ level: 'info', check: 'manifest', message: `the game.* block is valid (${parsed.data.game.type})` }];
71
+ const unknownLabels = splitGameLabels(parsed.data.game.labels ?? []).unknown;
72
+ return [
73
+ { level: 'info', check: 'manifest', message: `the game.* block is valid (${parsed.data.game.type})` },
74
+ ...unknownLabels.map((label): Finding => ({
75
+ level: 'warning',
76
+ check: 'manifest',
77
+ message: `game.labels: "${label}" is not a known label (the catalog ignores it); use any of ${GAME_LABELS.join(', ')}`,
78
+ })),
79
+ ];
72
80
  }
73
81
  return parsed.error.issues.map((issue) => ({
74
82
  level: 'error',
package/src/doctor/cli.ts CHANGED
@@ -11,7 +11,8 @@
11
11
  * `/_api/version` (`--hub`, default `https://diffenderfer.games`): outside
12
12
  * `supported` fails, `deprecated` warns (`--offline` skips this check);
13
13
  * - a stale vendored `src/hub/` copy or `scripts/sync-hub.mjs` warns;
14
- * - the `package.json` `game.*` block must match the contract's schema.
14
+ * - the `package.json` `game.*` block must match the contract's schema;
15
+ * - a `game.world.map` file must exist and be a valid `dg-world@1` map within the size limit.
15
16
  *
16
17
  * Exits 1 when any check fails.
17
18
  */
@@ -21,6 +22,7 @@ import { parseArgs } from 'node:util';
21
22
  import { HUB_CLIENT_VERSION } from '../core/version/client-version.gen';
22
23
  import { checkClientVersion, checkManifest, checkVendoredCopies, type Finding } from './checks';
23
24
  import { DEFAULT_HUB_TIMEOUT_MS, fetchHubVersion } from './hub-version-fetch';
25
+ import { checkWorld } from './world-check';
24
26
 
25
27
  const DEFAULT_HUB_URL = 'https://diffenderfer.games';
26
28
 
@@ -79,7 +81,8 @@ async function runDoctor(options: DoctorOptions): Promise<Finding[]> {
79
81
  }
80
82
  findings.push(...checkVendoredCopies(options.gameDir));
81
83
  try {
82
- findings.push(...checkManifest(readJsonFile(join(options.gameDir, 'package.json'))));
84
+ const packageJson = readJsonFile(join(options.gameDir, 'package.json'));
85
+ findings.push(...checkManifest(packageJson), ...checkWorld(options.gameDir, packageJson));
83
86
  } catch (failure) {
84
87
  findings.push({ level: 'error', check: 'manifest', message: `could not read package.json: ${failure instanceof Error ? failure.message : String(failure)}` });
85
88
  }
@@ -1,43 +1,43 @@
1
- /**
2
- * Reading the hub's `GET /_api/version` for `hub-doctor`, with a timeout so a
3
- * hung or unreachable hub fails a game's CI quickly instead of stalling it.
4
- */
5
- import { HubVersion } from '@diffenderfer-games/hub-contract';
6
-
7
- /** How long the doctor waits for the hub by default, ms. */
8
- export const DEFAULT_HUB_TIMEOUT_MS = 10_000;
9
-
10
- /** The hub could not be asked: down, slow, or answering with an error. */
11
- export interface HubUnreachable {
12
- /** The URL that was asked. */
13
- readonly unreachableUrl: string;
14
- /** Why, for the message (`timed out after 10000 ms`, `HTTP 502`, a network error). */
15
- readonly reason: string;
16
- }
17
-
18
- /** Whether a version lookup failed. */
19
- export function isHubUnreachable(lookup: HubVersion | null | HubUnreachable): lookup is HubUnreachable {
20
- return lookup !== null && 'unreachableUrl' in lookup;
21
- }
22
-
23
- /**
24
- * The hub's version: `null` for a hub from before versioning (404), a
25
- * {@link HubUnreachable} when it can't be read within `timeoutMs`.
26
- */
27
- export async function fetchHubVersion(hubUrl: string, timeoutMs: number = DEFAULT_HUB_TIMEOUT_MS): Promise<HubVersion | null | HubUnreachable> {
28
- const url = new URL('/_api/version', hubUrl).toString();
29
- try {
30
- const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
31
- if (response.status === 404) {
32
- return null;
33
- }
34
- if (!response.ok) {
35
- return { unreachableUrl: url, reason: `HTTP ${String(response.status)}` };
36
- }
37
- return HubVersion.parse(await response.json());
38
- } catch (failure) {
39
- const isTimeout = failure instanceof Error && (failure.name === 'TimeoutError' || failure.name === 'AbortError');
40
- const reason = isTimeout ? `timed out after ${String(timeoutMs)} ms` : failure instanceof Error ? failure.message : String(failure);
41
- return { unreachableUrl: url, reason };
42
- }
43
- }
1
+ /**
2
+ * Reading the hub's `GET /_api/version` for `hub-doctor`, with a timeout so a
3
+ * hung or unreachable hub fails a game's CI quickly instead of stalling it.
4
+ */
5
+ import { HubVersion } from '@diffenderfer-games/hub-contract';
6
+
7
+ /** How long the doctor waits for the hub by default, ms. */
8
+ export const DEFAULT_HUB_TIMEOUT_MS = 10_000;
9
+
10
+ /** The hub could not be asked: down, slow, or answering with an error. */
11
+ export interface HubUnreachable {
12
+ /** The URL that was asked. */
13
+ readonly unreachableUrl: string;
14
+ /** Why, for the message (`timed out after 10000 ms`, `HTTP 502`, a network error). */
15
+ readonly reason: string;
16
+ }
17
+
18
+ /** Whether a version lookup failed. */
19
+ export function isHubUnreachable(lookup: HubVersion | null | HubUnreachable): lookup is HubUnreachable {
20
+ return lookup !== null && 'unreachableUrl' in lookup;
21
+ }
22
+
23
+ /**
24
+ * The hub's version: `null` for a hub from before versioning (404), a
25
+ * {@link HubUnreachable} when it can't be read within `timeoutMs`.
26
+ */
27
+ export async function fetchHubVersion(hubUrl: string, timeoutMs: number = DEFAULT_HUB_TIMEOUT_MS): Promise<HubVersion | null | HubUnreachable> {
28
+ const url = new URL('/_api/version', hubUrl).toString();
29
+ try {
30
+ const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
31
+ if (response.status === 404) {
32
+ return null;
33
+ }
34
+ if (!response.ok) {
35
+ return { unreachableUrl: url, reason: `HTTP ${String(response.status)}` };
36
+ }
37
+ return HubVersion.parse(await response.json());
38
+ } catch (failure) {
39
+ const isTimeout = failure instanceof Error && (failure.name === 'TimeoutError' || failure.name === 'AbortError');
40
+ const reason = isTimeout ? `timed out after ${String(timeoutMs)} ms` : failure instanceof Error ? failure.message : String(failure);
41
+ return { unreachableUrl: url, reason };
42
+ }
43
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `hub-doctor`'s world check (docs/plans/world.md §14.6): a `game.world.map`
3
+ * file must exist inside the game, be within the size limit and be a valid
4
+ * `dg-world@1` map (the same `parseWorldMapText` the hub runs at load); a
5
+ * `game.world.generate.type` the hub doesn't know is a warning.
6
+ */
7
+ import { existsSync, readFileSync, statSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { GamePackageJson } from '@diffenderfer-games/hub-contract';
10
+ import { BUILT_IN_WORLD_TYPES, WORLD_LIMITS, parseWorldMapText } from '@diffenderfer-games/world';
11
+ import type { Finding } from './checks';
12
+
13
+ /** The most map problems listed (the rest are counted). */
14
+ const MAX_LISTED_PROBLEMS = 20;
15
+
16
+ /** Checks the map file and world type `game.world` names. A manifest that doesn't parse is left to `checkManifest`. */
17
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: the game's package.json, parsed by GamePackageJson next
18
+ export function checkWorld(gameDir: string, packageJson: unknown): Finding[] {
19
+ const parsed = GamePackageJson.safeParse(packageJson);
20
+ const world = parsed.success ? parsed.data.game.world : undefined;
21
+ if (world === undefined) {
22
+ return [];
23
+ }
24
+ const findings: Finding[] = [];
25
+ const type = world.generate?.type;
26
+ if (type !== undefined && !BUILT_IN_WORLD_TYPES.some((known) => known.id === type)) {
27
+ findings.push({ level: 'warning', check: 'world', message: `game.world.generate.type: the hub has no world type "${type}"; use one of ${BUILT_IN_WORLD_TYPES.map((known) => known.id).join(', ')}` });
28
+ }
29
+ return world.map === undefined ? findings : [...findings, ...checkMapFile(gameDir, world.map)];
30
+ }
31
+
32
+ /** One map file's findings. */
33
+ export function checkMapFile(gameDir: string, mapFile: string): Finding[] {
34
+ const path = join(gameDir, mapFile);
35
+ const error = (message: string): Finding => ({ level: 'error', check: 'world', message: `game.world.map (${mapFile}): ${message}` });
36
+ if (!existsSync(path) || !statSync(path).isFile()) {
37
+ return [error('the file does not exist (build or commit it)')];
38
+ }
39
+ const bytes = statSync(path).size;
40
+ if (bytes > WORLD_LIMITS.bytes) {
41
+ return [error(`${String(bytes)} bytes is over the ${String(WORLD_LIMITS.bytes)}-byte limit (save layers as "rle")`)];
42
+ }
43
+ const result = parseWorldMapText(readFileSync(path, 'utf8'));
44
+ const listed = result.problems.slice(0, MAX_LISTED_PROBLEMS).map((problem): Finding => ({
45
+ level: problem.severity === 'error' ? 'error' : 'warning',
46
+ check: 'world',
47
+ message: `game.world.map (${mapFile}): ${problem.message}`,
48
+ }));
49
+ const more = result.problems.length - listed.length;
50
+ if (more > 0) {
51
+ listed.push(error(`and ${String(more)} more problems`));
52
+ }
53
+ if (!result.ok) {
54
+ return listed;
55
+ }
56
+ const { map } = result;
57
+ return [...listed, {
58
+ level: 'info', check: 'world',
59
+ message: `game.world.map (${mapFile}) is a valid dg-world@1 map: ${map.title}, ${String(map.width)} × ${String(map.height)}, ${String(map.objects.length)} objects, ${String(bytes)} bytes`,
60
+ }];
61
+ }