@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
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The hub's resume kind (`hub.resume`): send a game's saved run, or clear it.
3
+ * One slot per game, so a new op replaces any waiting one (a clear after a
4
+ * save drops the save, and the other way round).
5
+ *
6
+ * A save op carries only the run's facts and time: when it runs it sends
7
+ * the newest run in the device copy (`LocalResumeStore`), which keeps the
8
+ * outbox small. When the device copy couldn't be stored, the op carries the
9
+ * state itself. A session that lapsed (401) gets a guest session and tries
10
+ * once more, so a player's very first save can't be lost to it.
11
+ */
12
+ import type { Json } from '@diffenderfer-games/hub-contract';
13
+ import { HubError } from '../../errors';
14
+ import type { LocalResumeStore } from '../../resume/local-resume';
15
+ import { isJson, isNullableString, isRecord, optionalNumber, optionalString } from '../../storage/json-guards';
16
+ import { checkedKind } from '../kind-adapters';
17
+ import type { RegisteredKind, ReplayContext } from '../outbox-kinds';
18
+ import type { GameFieldsSource } from './game-fields';
19
+
20
+ /** The kind name. */
21
+ export const RESUME_KIND = 'hub.resume';
22
+
23
+ /** Send the run saved at `savedAt`; `state` only when the device copy couldn't hold it. */
24
+ export type ResumeSavePayload = {
25
+ readonly action: 'save';
26
+ readonly savedAt: number;
27
+ readonly label: string;
28
+ readonly fraction: number | null;
29
+ readonly title: string | null;
30
+ readonly version: number;
31
+ readonly state?: Json;
32
+ };
33
+
34
+ /** Clear the run, unless one was saved after `before`. */
35
+ export type ResumeClearPayload = { readonly action: 'clear'; readonly before: number };
36
+
37
+ /** One resume op. */
38
+ export type ResumePayload = ResumeSavePayload | ResumeClearPayload;
39
+
40
+ function readResume(stored: Json): ResumePayload | undefined {
41
+ if (!isRecord(stored)) {
42
+ return undefined;
43
+ }
44
+ if (stored.action === 'clear') {
45
+ const before = optionalNumber(stored.before);
46
+ return before === undefined ? undefined : { action: 'clear', before };
47
+ }
48
+ const savedAt = optionalNumber(stored.savedAt);
49
+ const label = optionalString(stored.label);
50
+ const version = optionalNumber(stored.version);
51
+ const fraction = stored.fraction === null ? null : optionalNumber(stored.fraction);
52
+ if (stored.action !== 'save' || savedAt === undefined || label === undefined || version === undefined || fraction === undefined
53
+ || !isNullableString(stored.title) || (stored.state !== undefined && !isJson(stored.state))) {
54
+ return undefined;
55
+ }
56
+ const base = { action: 'save', savedAt, label, fraction, title: stored.title, version } as const;
57
+ return stored.state === undefined ? base : { ...base, state: stored.state };
58
+ }
59
+
60
+ /** The run to send: the device copy when it is at least as new as the op, else the op's own state; `undefined` when there is none. */
61
+ function runToSend(save: ResumeSavePayload, local: LocalResumeStore, slug: string): ResumeSavePayload | undefined {
62
+ const copy = local.read(slug);
63
+ if (copy?.status === 'saved' && copy.at >= save.savedAt) {
64
+ return { action: 'save', savedAt: copy.at, label: copy.label, fraction: copy.fraction, title: copy.title, version: copy.version, state: copy.state };
65
+ }
66
+ return save.state === undefined ? undefined : save;
67
+ }
68
+
69
+ /** Run `call`; on a lapsed session (401) start a guest session and run it once more. */
70
+ async function withSession<Reply>(context: ReplayContext, call: () => Promise<Reply>): Promise<Reply> {
71
+ try {
72
+ return await call();
73
+ } catch (failure) {
74
+ if (!(failure instanceof HubError) || failure.status !== 401) {
75
+ throw failure;
76
+ }
77
+ await context.call('ensureGuestSession');
78
+ return call();
79
+ }
80
+ }
81
+
82
+ /** The resume kind, by name, over the device copies in `local`. */
83
+ export function resumeKinds(game: GameFieldsSource, local: LocalResumeStore): Record<string, RegisteredKind> {
84
+ return {
85
+ [RESUME_KIND]: checkedKind(readResume, {
86
+ mergeKey: () => 'resume',
87
+ replaces: true,
88
+ run: async (op, context) => {
89
+ const { slug } = game();
90
+ if (op.action === 'clear') {
91
+ await withSession(context, () => context.call('clearResumeSlot', { slug, before: op.before }));
92
+ local.markSynced(slug, op.before);
93
+ return undefined;
94
+ }
95
+ const run = runToSend(op, local, slug);
96
+ if (run?.state === undefined) {
97
+ return undefined;
98
+ }
99
+ const { state } = run;
100
+ await withSession(context, () => context.call('saveResumeSlot', {
101
+ slug, state, progress: { label: run.label, fraction: run.fraction }, title: run.title, version: run.version, savedAt: run.savedAt,
102
+ }));
103
+ local.markSynced(slug, run.savedAt);
104
+ return undefined;
105
+ },
106
+ }),
107
+ };
108
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The hub's saved-games kind (`hub.saved`): send a daily challenge's state,
3
+ * or discard the day, from the device copy (`LocalDailyStore`). One op per
4
+ * (game, day): a newer one replaces a waiting one, so a discard after a
5
+ * save drops the save. The op carries only which day and when; when it runs
6
+ * it sends the newest state the device holds, so the outbox stays small.
7
+ *
8
+ * A day started offline has a device-only id (`local:<slug>:<day>`): the op
9
+ * first starts the day on the hub, which finds or makes its saved game (one
10
+ * per player, game and day), then saves to that. A save or discard that
11
+ * finds the day already finished or discarded elsewhere (409, 404) is done:
12
+ * the device copy is dropped.
13
+ */
14
+ import type { Json } from '@diffenderfer-games/hub-contract';
15
+ import { HubError } from '../../errors';
16
+ import type { LocalDaily, LocalDailyStore } from '../../saved/local-saved';
17
+ import { isRecord, optionalNumber, optionalString } from '../../storage/json-guards';
18
+ import { checkedKind } from '../kind-adapters';
19
+ import type { RegisteredKind, ReplayContext } from '../outbox-kinds';
20
+
21
+ /** The kind name. */
22
+ export const SAVED_KIND = 'hub.saved';
23
+
24
+ /** Send (`save`) or discard a day's saved game; `at` is when the device copy changed. */
25
+ export type SavedPayload = { readonly action: 'save' | 'discard'; readonly game: string; readonly day: string; readonly at: number };
26
+
27
+ function readSaved(stored: Json): SavedPayload | undefined {
28
+ if (!isRecord(stored) || (stored.action !== 'save' && stored.action !== 'discard')) {
29
+ return undefined;
30
+ }
31
+ const game = optionalString(stored.game);
32
+ const day = optionalString(stored.day);
33
+ const at = optionalNumber(stored.at);
34
+ return game === undefined || day === undefined || at === undefined ? undefined : { action: stored.action, game, day, at };
35
+ }
36
+
37
+ /** Whether a failure means the instance is gone or closed on the hub (the change is moot). */
38
+ function isGoneOrClosed(failure: Error): boolean {
39
+ return failure instanceof HubError && (failure.status === 404 || failure.status === 409);
40
+ }
41
+
42
+ /** The hub's id for the copy's day: its own, or the one the hub gives the day when it was started offline (`null`: already completed). */
43
+ async function resolveId(copy: LocalDaily, context: ReplayContext): Promise<string | null> {
44
+ if (!copy.id.startsWith('local:')) {
45
+ return copy.id;
46
+ }
47
+ const started = await context.call('startDailyChallenge', { slug: copy.game, day: copy.day });
48
+ return started.instanceId;
49
+ }
50
+
51
+ /** Run one op against the copy it names. */
52
+ async function runSaved(op: SavedPayload, local: LocalDailyStore, context: ReplayContext): Promise<undefined> {
53
+ const copy = local.read(op.game, op.day);
54
+ if (copy === null) {
55
+ return undefined;
56
+ }
57
+ try {
58
+ const id = await resolveId(copy, context);
59
+ if (id === null) {
60
+ local.remove(op.game, op.day);
61
+ return undefined;
62
+ }
63
+ if (op.action === 'discard') {
64
+ await context.call('discardSavedGame', { id });
65
+ local.remove(op.game, op.day);
66
+ return undefined;
67
+ }
68
+ // A null state is sent too: it is hub.resume.clear() during the day (start over from the seed).
69
+ if (copy.status === 'active') {
70
+ await context.call('saveSavedGameState', {
71
+ id, state: copy.state, progress: { label: copy.label, fraction: copy.fraction }, title: copy.title, version: copy.version, savedAt: copy.at,
72
+ });
73
+ }
74
+ local.markSynced(op.game, op.day, copy.at, id);
75
+ } catch (failure) {
76
+ if (!(failure instanceof Error) || !isGoneOrClosed(failure)) {
77
+ throw failure;
78
+ }
79
+ local.remove(op.game, op.day);
80
+ }
81
+ return undefined;
82
+ }
83
+
84
+ /** The saved-games kind, by name, over the device copies in `local`. */
85
+ export function savedKinds(local: LocalDailyStore): Record<string, RegisteredKind> {
86
+ return {
87
+ [SAVED_KIND]: checkedKind(readSaved, {
88
+ mergeKey: (op) => `daily:${op.game}#${op.day}`,
89
+ replaces: true,
90
+ run: (op, context) => runSaved(op, local, context),
91
+ }),
92
+ };
93
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Turning a typed kind definition into the JSON-in, JSON-out form the outbox
3
+ * runs: the hub's kinds check their stored payloads with a reader; a game's
4
+ * kinds vouch for their own payload type, as with saves.
5
+ */
6
+ import type { Json } from '@diffenderfer-games/hub-contract';
7
+ import { toGameData, toStoredJson, type GamePayload } from '../game-data';
8
+ import type { OfflineHandler, OfflineKindDefinition, RegisteredKind } from './outbox-kinds';
9
+
10
+ /** Reads a stored payload back, or `undefined` when it is malformed. */
11
+ export type PayloadReader<Payload> = (stored: Json) => Payload | undefined;
12
+
13
+ /** A kind whose payloads are checked by `read` (the hub's own kinds). */
14
+ export function checkedKind<Payload extends Json>(read: PayloadReader<Payload>, definition: OfflineKindDefinition<Payload>): RegisteredKind {
15
+ const readOrThrow = (stored: Json): Payload => {
16
+ const payload = read(stored);
17
+ if (payload === undefined) {
18
+ throw new Error('Malformed offline operation payload.');
19
+ }
20
+ return payload;
21
+ };
22
+ const { mergeKey, merge } = definition;
23
+ return {
24
+ run: (stored, context) => definition.run(readOrThrow(stored), context),
25
+ accepts: (stored) => read(stored) !== undefined,
26
+ ...(mergeKey === undefined ? {} : { mergeKey: (stored: Json) => mergeKey(readOrThrow(stored)) }),
27
+ ...(merge === undefined ? {} : { merge: (waiting: Json, incoming: Json) => merge(readOrThrow(waiting), readOrThrow(incoming)) }),
28
+ replaces: definition.replaces === true,
29
+ };
30
+ }
31
+
32
+ /** A game's kind: a bare handler or a full definition, its payload typed by the game. */
33
+ export function gameKind<Payload extends GamePayload>(definition: OfflineHandler<Payload> | OfflineKindDefinition<Payload>): RegisteredKind {
34
+ const full: OfflineKindDefinition<Payload> = typeof definition === 'function' ? { run: definition } : definition;
35
+ const { mergeKey, merge } = full;
36
+ return {
37
+ run: (stored, context) => full.run(toGameData<Payload>(stored), context),
38
+ accepts: () => true,
39
+ ...(mergeKey === undefined ? {} : { mergeKey: (stored: Json) => mergeKey(toGameData<Payload>(stored)) }),
40
+ ...(merge === undefined ? {} : {
41
+ merge: (waiting: Json, incoming: Json) => {
42
+ const merged = merge(toGameData<Payload>(waiting), toGameData<Payload>(incoming));
43
+ return merged === undefined ? undefined : toStoredJson(merged);
44
+ },
45
+ }),
46
+ replaces: full.replaces === true,
47
+ };
48
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The cached inventory, changed the way the hub would change it, so grants
3
+ * and house exchanges made offline show at once. The hub has the last word
4
+ * when they replay.
5
+ */
6
+ import type { Inventory } from '@diffenderfer-games/hub-contract';
7
+ import { isRecord, optionalNumber, optionalString } from '../storage/json-guards';
8
+
9
+ /** Goods moved by a grant or an exchange: item key to quantity, and coins. */
10
+ export type GoodsPayload = { readonly items?: { readonly [key: string]: number }; readonly coins?: number };
11
+
12
+ /** An empty inventory. */
13
+ export const EMPTY_INVENTORY: Inventory = { items: [], coins: 0 };
14
+
15
+ /** A cached inventory, or `null` when there is none or it is malformed. */
16
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: an inventory read back from localStorage
17
+ export function readInventory(stored: unknown): Inventory | null {
18
+ if (!isRecord(stored) || !Array.isArray(stored.items)) {
19
+ return null;
20
+ }
21
+ const coins = optionalNumber(stored.coins);
22
+ const items = stored.items.flatMap((item): Inventory['items'] => {
23
+ const key = isRecord(item) ? optionalString(item.key) : undefined;
24
+ const qty = isRecord(item) ? optionalNumber(item.qty) : undefined;
25
+ return key === undefined || qty === undefined ? [] : [{ key, qty }];
26
+ });
27
+ return coins === undefined ? null : { items, coins };
28
+ }
29
+
30
+ /** The holdings as a key-to-quantity map. */
31
+ function quantities(inventory: Inventory): Map<string, number> {
32
+ return new Map(inventory.items.map((item) => [item.key, item.qty]));
33
+ }
34
+
35
+ /** The holdings from a map (positive counts only, sorted by key). */
36
+ function fromQuantities(held: ReadonlyMap<string, number>, coins: number): Inventory {
37
+ const items = [...held].filter(([, qty]) => qty > 0).sort(([first], [second]) => first.localeCompare(second)).map(([key, qty]) => ({ key, qty }));
38
+ return { items, coins };
39
+ }
40
+
41
+ /** `inventory` with `goods` added. */
42
+ export function withGoods(inventory: Inventory, goods: GoodsPayload): Inventory {
43
+ const held = quantities(inventory);
44
+ for (const [key, qty] of Object.entries(goods.items ?? {})) {
45
+ held.set(key, (held.get(key) ?? 0) + qty);
46
+ }
47
+ return fromQuantities(held, inventory.coins + (goods.coins ?? 0));
48
+ }
49
+
50
+ /** `inventory` with `goods` removed, or the hub's refusal message when it can't cover them. */
51
+ export function withoutGoods(inventory: Inventory, goods: GoodsPayload): Inventory | string {
52
+ if ((goods.coins ?? 0) > inventory.coins) {
53
+ return "You don't have enough coins.";
54
+ }
55
+ const held = quantities(inventory);
56
+ for (const [key, qty] of Object.entries(goods.items ?? {})) {
57
+ const have = held.get(key) ?? 0;
58
+ if (qty > have) {
59
+ return `You don't have ${String(qty)}× "${key}".`;
60
+ }
61
+ held.set(key, have - qty);
62
+ }
63
+ return fromQuantities(held, inventory.coins - (goods.coins ?? 0));
64
+ }