@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,90 +1,146 @@
1
- // Generated by tools/hub-client/generate.ts. Do not edit; run `npm run gen:hub-client` in next/.
2
- /** Every public REST endpoint's method, path (under `/_api`) and query keys, from the contract. */
1
+ // Generated by tools/hub-client/generate.ts. Do not edit; run `npm run gen:hub-client`.
2
+ /** Every public REST endpoint's method, path (under `/_api`), query keys and offline mode (`'replay'` when it may be queued offline), from the contract. */
3
3
  export const ROUTES = {
4
- ensureGuestSession: { method: 'POST', path: '/auth/guest', query: [] },
5
- signUpWithPassword: { method: 'POST', path: '/auth/signup', query: [] },
6
- logInWithPassword: { method: 'POST', path: '/auth/login', query: [] },
7
- logOut: { method: 'POST', path: '/auth/logout', query: [] },
8
- getCurrentUser: { method: 'GET', path: '/me', query: [] },
9
- claimGuestAccount: { method: 'POST', path: '/auth/claim', query: [] },
10
- listGames: { method: 'GET', path: '/games', query: [] },
11
- listWhatsNewNotes: { method: 'GET', path: '/changes', query: ['game', 'since'] },
12
- listFavoriteGames: { method: 'GET', path: '/favorites', query: [] },
13
- setGameFavorite: { method: 'PUT', path: '/favorites/:slug', query: [] },
14
- markWhatsNewSeen: { method: 'POST', path: '/changes/seen', query: [] },
15
- getGameStats: { method: 'GET', path: '/games/:slug/stats', query: ['user'] },
16
- getGameProfile: { method: 'GET', path: '/games/:slug/profile', query: [] },
17
- setGameProfile: { method: 'PUT', path: '/games/:slug/profile', query: [] },
18
- listSaves: { method: 'GET', path: '/games/:slug/saves', query: [] },
19
- getSave: { method: 'GET', path: '/games/:slug/saves/:slot', query: [] },
20
- writeSave: { method: 'PUT', path: '/games/:slug/saves/:slot', query: [] },
21
- deleteSave: { method: 'DELETE', path: '/games/:slug/saves/:slot', query: [] },
22
- updateGameStat: { method: 'POST', path: '/games/:slug/stats', query: [] },
23
- resetMyGameProgress: { method: 'POST', path: '/games/:slug/reset', query: [] },
24
- listMyLeaderboardRecords: { method: 'GET', path: '/me/records', query: ['game'] },
25
- getMyOfflineSnapshot: { method: 'GET', path: '/me/snapshot', query: ['top'] },
26
- listTopOfEveryLeaderboard: { method: 'GET', path: '/leaderboards', query: ['top'] },
27
- listGameLeaderboards: { method: 'GET', path: '/games/:slug/leaderboards', query: [] },
28
- getLeaderboard: { method: 'GET', path: '/games/:slug/leaderboards/:key', query: ['limit'] },
29
- submitScore: { method: 'POST', path: '/games/:slug/leaderboards/:key/scores', query: [] },
30
- listDailyChallengesForAllGames: { method: 'GET', path: '/daily', query: [] },
31
- getGameDailyChallenges: { method: 'GET', path: '/games/:slug/daily', query: [] },
32
- startDailyChallenge: { method: 'POST', path: '/games/:slug/daily/:day/start', query: [] },
33
- completeDailyChallenge: { method: 'POST', path: '/games/:slug/daily/:day/complete', query: [] },
34
- getMyInventory: { method: 'GET', path: '/games/:slug/inventory', query: [] },
35
- grantInventoryItems: { method: 'POST', path: '/games/:slug/inventory/grant', query: [] },
36
- exchangeInventoryItems: { method: 'POST', path: '/games/:slug/inventory/exchange', query: [] },
37
- getPlayerInventory: { method: 'GET', path: '/games/:slug/players/:userId/inventory', query: [] },
38
- listTradePartners: { method: 'GET', path: '/games/:slug/trades/players', query: [] },
39
- listMyTrades: { method: 'GET', path: '/games/:slug/trades', query: ['box', 'status'] },
40
- declineTrade: { method: 'POST', path: '/games/:slug/trades/:id/decline', query: [] },
41
- cancelTrade: { method: 'POST', path: '/games/:slug/trades/:id/cancel', query: [] },
42
- createTrade: { method: 'POST', path: '/games/:slug/trades', query: [] },
43
- acceptTrade: { method: 'POST', path: '/games/:slug/trades/:id/accept', query: [] },
44
- counterTradeOffer: { method: 'POST', path: '/games/:slug/trades/:id/counter', query: [] },
45
- getSiteAnalytics: { method: 'GET', path: '/analytics', query: [] },
46
- reportClientError: { method: 'POST', path: '/client-log', query: [] },
47
- recordGameEvent: { method: 'POST', path: '/games/:slug/events', query: [] },
48
- getGameAnalytics: { method: 'GET', path: '/games/:slug/analytics', query: [] },
49
- getSocialConfig: { method: 'GET', path: '/social/config', query: [] },
50
- getOnlineCounts: { method: 'GET', path: '/social/counts', query: [] },
51
- getMySocialProfile: { method: 'GET', path: '/social/me', query: [] },
52
- listFriendsAndRequests: { method: 'GET', path: '/social/friends', query: [] },
53
- findPlayerByName: { method: 'GET', path: '/social/players/by-name/:name', query: [] },
54
- getPlayerCard: { method: 'GET', path: '/social/players/:id', query: [] },
55
- updateMySocialProfile: { method: 'PUT', path: '/social/me', query: [] },
56
- setParentChatLock: { method: 'POST', path: '/social/chat-lock', query: [] },
57
- sendFriendRequest: { method: 'POST', path: '/social/friends', query: [] },
58
- acceptFriendRequest: { method: 'POST', path: '/social/friends/:id/accept', query: [] },
59
- declineFriendRequest: { method: 'POST', path: '/social/friends/:id/decline', query: [] },
60
- removeFriend: { method: 'DELETE', path: '/social/friends/:id', query: [] },
61
- setFriendNotifications: { method: 'PUT', path: '/social/friends/:id/notify', query: [] },
62
- blockPlayer: { method: 'POST', path: '/social/blocks', query: [] },
63
- unblockPlayer: { method: 'DELETE', path: '/social/blocks/:id', query: [] },
64
- listRecentPlayers: { method: 'GET', path: '/social/recent', query: [] },
65
- listConversations: { method: 'GET', path: '/social/conversations', query: [] },
66
- listDirectMessages: { method: 'GET', path: '/social/messages/:userId', query: ['before'] },
67
- sendDirectMessage: { method: 'POST', path: '/social/messages/:userId', query: [] },
68
- markDirectMessagesRead: { method: 'POST', path: '/social/messages/:userId/read', query: [] },
69
- getNotificationPreferences: { method: 'GET', path: '/social/notify', query: [] },
70
- unsubscribeFromPush: { method: 'DELETE', path: '/social/push', query: [] },
71
- setNotificationPreferences: { method: 'PUT', path: '/social/notify', query: [] },
72
- subscribeToPush: { method: 'POST', path: '/social/push', query: [] },
73
- fileReport: { method: 'POST', path: '/social/reports', query: [] },
74
- listPublicSuggestions: { method: 'GET', path: '/suggestions', query: ['sort'] },
75
- submitSuggestion: { method: 'POST', path: '/suggestions', query: [] },
76
- voteForSuggestion: { method: 'POST', path: '/suggestions/:id/vote', query: [] },
77
- removeSuggestionVote: { method: 'DELETE', path: '/suggestions/:id/vote', query: [] },
78
- withdrawSuggestion: { method: 'DELETE', path: '/suggestions/:id', query: [] },
79
- sendGameInvite: { method: 'POST', path: '/mp/invites', query: [] },
80
- acceptGameInvite: { method: 'POST', path: '/mp/invites/:id/accept', query: [] },
81
- declineGameInvite: { method: 'POST', path: '/mp/invites/:id/decline', query: [] },
82
- cancelGameInvite: { method: 'DELETE', path: '/mp/invites/:id', query: [] },
83
- redeemLaunchToken: { method: 'GET', path: '/mp/launch/:token', query: [] },
84
- setPartyJoinInfo: { method: 'POST', path: '/mp/parties/:id/join-info', query: [] },
85
- leaveParty: { method: 'POST', path: '/mp/parties/:id/leave', query: [] },
86
- launchIntoFriendsRoom: { method: 'POST', path: '/mp/join', query: [] },
87
- getGameServerTicket: { method: 'POST', path: '/mp/ticket', query: [] },
88
- listMyRaceHistory: { method: 'GET', path: '/race/history', query: ['game', 'with', 'before', 'limit'] },
89
- getHubVersion: { method: 'GET', path: '/version', query: [] },
4
+ ensureGuestSession: { method: 'POST', path: '/auth/guest', query: [], offline: null },
5
+ signUpWithPassword: { method: 'POST', path: '/auth/signup', query: [], offline: null },
6
+ logInWithPassword: { method: 'POST', path: '/auth/login', query: [], offline: null },
7
+ logOut: { method: 'POST', path: '/auth/logout', query: [], offline: null },
8
+ getCurrentUser: { method: 'GET', path: '/me', query: [], offline: null },
9
+ claimGuestAccount: { method: 'POST', path: '/auth/claim', query: [], offline: null },
10
+ getGamePlayerCounts: { method: 'GET', path: '/catalog/players', query: [], offline: null },
11
+ getMyGameActivity: { method: 'GET', path: '/games/:slug/me/activity', query: [], offline: null },
12
+ listGames: { method: 'GET', path: '/games', query: [], offline: null },
13
+ listWhatsNewNotes: { method: 'GET', path: '/changes', query: ['game', 'since'], offline: null },
14
+ listFavoriteGames: { method: 'GET', path: '/favorites', query: [], offline: null },
15
+ setGameFavorite: { method: 'PUT', path: '/favorites/:slug', query: [], offline: 'replay' },
16
+ markWhatsNewSeen: { method: 'POST', path: '/changes/seen', query: [], offline: null },
17
+ getGameStats: { method: 'GET', path: '/games/:slug/stats', query: ['user'], offline: null },
18
+ getGameProfile: { method: 'GET', path: '/games/:slug/profile', query: [], offline: null },
19
+ setGameProfile: { method: 'PUT', path: '/games/:slug/profile', query: [], offline: 'replay' },
20
+ listSaves: { method: 'GET', path: '/games/:slug/saves', query: [], offline: null },
21
+ getSave: { method: 'GET', path: '/games/:slug/saves/:slot', query: [], offline: null },
22
+ writeSave: { method: 'PUT', path: '/games/:slug/saves/:slot', query: [], offline: 'replay' },
23
+ deleteSave: { method: 'DELETE', path: '/games/:slug/saves/:slot', query: [], offline: 'replay' },
24
+ updateGameStat: { method: 'POST', path: '/games/:slug/stats', query: [], offline: 'replay' },
25
+ resetMyGameProgress: { method: 'POST', path: '/games/:slug/reset', query: [], offline: null },
26
+ listMyLeaderboardRecords: { method: 'GET', path: '/me/records', query: ['game'], offline: null },
27
+ getMyOfflineSnapshot: { method: 'GET', path: '/me/snapshot', query: ['top'], offline: null },
28
+ listTopOfEveryLeaderboard: { method: 'GET', path: '/leaderboards', query: ['top'], offline: null },
29
+ listGameLeaderboards: { method: 'GET', path: '/games/:slug/leaderboards', query: [], offline: null },
30
+ getLeaderboard: { method: 'GET', path: '/games/:slug/leaderboards/:key', query: ['limit'], offline: null },
31
+ submitScore: { method: 'POST', path: '/games/:slug/leaderboards/:key/scores', query: [], offline: 'replay' },
32
+ listDailyChallengesForAllGames: { method: 'GET', path: '/daily', query: [], offline: null },
33
+ getGameDailyChallenges: { method: 'GET', path: '/games/:slug/daily', query: [], offline: null },
34
+ resumeDailyChallenge: { method: 'POST', path: '/daily/instances/:id/resume', query: [], offline: null },
35
+ startDailyChallenge: { method: 'POST', path: '/games/:slug/daily/:day/start', query: [], offline: null },
36
+ completeDailyChallenge: { method: 'POST', path: '/games/:slug/daily/:day/complete', query: [], offline: 'replay' },
37
+ getMyInventory: { method: 'GET', path: '/games/:slug/inventory', query: [], offline: null },
38
+ grantInventoryItems: { method: 'POST', path: '/games/:slug/inventory/grant', query: [], offline: 'replay' },
39
+ exchangeInventoryItems: { method: 'POST', path: '/games/:slug/inventory/exchange', query: [], offline: 'replay' },
40
+ getPlayerInventory: { method: 'GET', path: '/games/:slug/players/:userId/inventory', query: [], offline: null },
41
+ listTradePartners: { method: 'GET', path: '/games/:slug/trades/players', query: [], offline: null },
42
+ listMyTrades: { method: 'GET', path: '/games/:slug/trades', query: ['box', 'status'], offline: null },
43
+ declineTrade: { method: 'POST', path: '/games/:slug/trades/:id/decline', query: [], offline: null },
44
+ cancelTrade: { method: 'POST', path: '/games/:slug/trades/:id/cancel', query: [], offline: null },
45
+ createTrade: { method: 'POST', path: '/games/:slug/trades', query: [], offline: null },
46
+ acceptTrade: { method: 'POST', path: '/games/:slug/trades/:id/accept', query: [], offline: null },
47
+ counterTradeOffer: { method: 'POST', path: '/games/:slug/trades/:id/counter', query: [], offline: null },
48
+ getSiteAnalytics: { method: 'GET', path: '/analytics', query: [], offline: null },
49
+ reportClientError: { method: 'POST', path: '/client-log', query: [], offline: null },
50
+ recordGameEvent: { method: 'POST', path: '/games/:slug/events', query: [], offline: null },
51
+ getGameAnalytics: { method: 'GET', path: '/games/:slug/analytics', query: [], offline: null },
52
+ getSocialConfig: { method: 'GET', path: '/social/config', query: [], offline: null },
53
+ getOnlineCounts: { method: 'GET', path: '/social/counts', query: [], offline: null },
54
+ getMySocialProfile: { method: 'GET', path: '/social/me', query: [], offline: null },
55
+ listFriendsAndRequests: { method: 'GET', path: '/social/friends', query: [], offline: null },
56
+ findPlayerByName: { method: 'GET', path: '/social/players/by-name/:name', query: [], offline: null },
57
+ getPlayerCard: { method: 'GET', path: '/social/players/:id', query: [], offline: null },
58
+ updateMySocialProfile: { method: 'PUT', path: '/social/me', query: [], offline: null },
59
+ setParentChatLock: { method: 'POST', path: '/social/chat-lock', query: [], offline: null },
60
+ sendFriendRequest: { method: 'POST', path: '/social/friends', query: [], offline: null },
61
+ acceptFriendRequest: { method: 'POST', path: '/social/friends/:id/accept', query: [], offline: null },
62
+ declineFriendRequest: { method: 'POST', path: '/social/friends/:id/decline', query: [], offline: null },
63
+ removeFriend: { method: 'DELETE', path: '/social/friends/:id', query: [], offline: null },
64
+ setFriendNotifications: { method: 'PUT', path: '/social/friends/:id/notify', query: [], offline: null },
65
+ blockPlayer: { method: 'POST', path: '/social/blocks', query: [], offline: null },
66
+ unblockPlayer: { method: 'DELETE', path: '/social/blocks/:id', query: [], offline: null },
67
+ listRecentPlayers: { method: 'GET', path: '/social/recent', query: [], offline: null },
68
+ listConversations: { method: 'GET', path: '/social/conversations', query: [], offline: null },
69
+ listDirectMessages: { method: 'GET', path: '/social/messages/:userId', query: ['before'], offline: null },
70
+ sendDirectMessage: { method: 'POST', path: '/social/messages/:userId', query: [], offline: null },
71
+ markDirectMessagesRead: { method: 'POST', path: '/social/messages/:userId/read', query: [], offline: null },
72
+ getNotificationPreferences: { method: 'GET', path: '/social/notify', query: [], offline: null },
73
+ unsubscribeFromPush: { method: 'DELETE', path: '/social/push', query: [], offline: null },
74
+ setNotificationPreferences: { method: 'PUT', path: '/social/notify', query: [], offline: null },
75
+ setQuietHours: { method: 'PUT', path: '/social/notify/quiet', query: [], offline: null },
76
+ subscribeToPush: { method: 'POST', path: '/social/push', query: [], offline: null },
77
+ fileReport: { method: 'POST', path: '/social/reports', query: [], offline: null },
78
+ listPublicSuggestions: { method: 'GET', path: '/suggestions', query: ['sort'], offline: null },
79
+ submitSuggestion: { method: 'POST', path: '/suggestions', query: [], offline: null },
80
+ voteForSuggestion: { method: 'POST', path: '/suggestions/:id/vote', query: [], offline: null },
81
+ removeSuggestionVote: { method: 'DELETE', path: '/suggestions/:id/vote', query: [], offline: null },
82
+ withdrawSuggestion: { method: 'DELETE', path: '/suggestions/:id', query: [], offline: null },
83
+ sendGameInvite: { method: 'POST', path: '/mp/invites', query: [], offline: null },
84
+ acceptGameInvite: { method: 'POST', path: '/mp/invites/:id/accept', query: [], offline: null },
85
+ declineGameInvite: { method: 'POST', path: '/mp/invites/:id/decline', query: [], offline: null },
86
+ createInviteLink: { method: 'POST', path: '/mp/links', query: [], offline: null },
87
+ listInviteLinks: { method: 'GET', path: '/mp/links', query: ['game'], offline: null },
88
+ revokeInviteLink: { method: 'DELETE', path: '/mp/links/:token', query: [], offline: null },
89
+ cancelGameInvite: { method: 'DELETE', path: '/mp/invites/:id', query: [], offline: null },
90
+ redeemLaunchToken: { method: 'GET', path: '/mp/launch/:token', query: [], offline: null },
91
+ openInviteLink: { method: 'POST', path: '/mp/links/:token/open', query: [], offline: null },
92
+ setPartyJoinInfo: { method: 'POST', path: '/mp/parties/:id/join-info', query: [], offline: null },
93
+ leaveParty: { method: 'POST', path: '/mp/parties/:id/leave', query: [], offline: null },
94
+ launchIntoFriendsRoom: { method: 'POST', path: '/mp/join', query: [], offline: null },
95
+ getGameServerTicket: { method: 'POST', path: '/mp/ticket', query: [], offline: null },
96
+ listMyRaceHistory: { method: 'GET', path: '/race/history', query: ['game', 'with', 'before', 'limit'], offline: null },
97
+ listTournaments: { method: 'GET', path: '/tournaments', query: ['game'], offline: null },
98
+ getTournament: { method: 'GET', path: '/tournaments/:id', query: ['key'], offline: null },
99
+ createTournament: { method: 'POST', path: '/tournaments', query: [], offline: null },
100
+ inviteToTournament: { method: 'POST', path: '/tournaments/:id/invites', query: [], offline: null },
101
+ followTournament: { method: 'PUT', path: '/tournaments/:id/follow', query: [], offline: null },
102
+ unfollowTournament: { method: 'DELETE', path: '/tournaments/:id/follow', query: [], offline: null },
103
+ startTournament: { method: 'POST', path: '/tournaments/:id/start', query: [], offline: null },
104
+ cancelTournament: { method: 'DELETE', path: '/tournaments/:id', query: [], offline: null },
105
+ joinTournament: { method: 'POST', path: '/tournaments/:id/join', query: [], offline: null },
106
+ leaveTournament: { method: 'POST', path: '/tournaments/:id/leave', query: [], offline: null },
107
+ launchTournamentMatch: { method: 'POST', path: '/tournaments/:id/matches/:match/launch', query: [], offline: null },
108
+ getPlayOnline: { method: 'GET', path: '/play-online', query: [], offline: null },
109
+ getHubVersion: { method: 'GET', path: '/version', query: [], offline: null },
110
+ getResumeSlot: { method: 'GET', path: '/games/:slug/resume', query: [], offline: null },
111
+ saveResumeSlot: { method: 'PUT', path: '/games/:slug/resume', query: [], offline: 'replay' },
112
+ clearResumeSlot: { method: 'DELETE', path: '/games/:slug/resume', query: ['before'], offline: 'replay' },
113
+ listMyResumeSlots: { method: 'GET', path: '/me/resume', query: [], offline: null },
114
+ getMyAvatar: { method: 'GET', path: '/avatar/me', query: [], offline: null },
115
+ recordAvatarNudge: { method: 'POST', path: '/avatar/me/nudge', query: [], offline: null },
116
+ saveMyAvatar: { method: 'PUT', path: '/avatar/me', query: [], offline: null },
117
+ resetMyAvatar: { method: 'DELETE', path: '/avatar/me', query: [], offline: null },
118
+ getAvatar: { method: 'GET', path: '/avatar/of/:id', query: [], offline: null },
119
+ getAvatars: { method: 'POST', path: '/avatar/batch', query: [], offline: null },
120
+ listMySavedGames: { method: 'GET', path: '/saved', query: ['game', 'kinds'], offline: null },
121
+ getSavedGame: { method: 'GET', path: '/saved/:id', query: [], offline: null },
122
+ saveSavedGameState: { method: 'PUT', path: '/saved/:id/state', query: [], offline: 'replay' },
123
+ discardSavedGame: { method: 'POST', path: '/saved/:id/discard', query: [], offline: 'replay' },
124
+ discardGameSavedGames: { method: 'POST', path: '/games/:slug/saved/discard', query: [], offline: null },
125
+ createTurnGame: { method: 'POST', path: '/turns', query: [], offline: null },
126
+ joinTurnGame: { method: 'POST', path: '/turns/:id/join', query: [], offline: null },
127
+ joinTurnGameByLink: { method: 'POST', path: '/turns/join', query: [], offline: null },
128
+ startTurnGame: { method: 'POST', path: '/turns/:id/start', query: [], offline: null },
129
+ moveTurnGame: { method: 'POST', path: '/turns/:id/moves', query: [], offline: null },
130
+ leaveTurnGame: { method: 'POST', path: '/turns/:id/leave', query: [], offline: null },
131
+ nudgeTurnGame: { method: 'POST', path: '/turns/:id/nudge', query: [], offline: null },
132
+ setTurnGameNotify: { method: 'PUT', path: '/turns/:id/notify', query: [], offline: null },
133
+ rematchTurnGame: { method: 'POST', path: '/turns/:id/rematch', query: [], offline: null },
134
+ inviteToTurnGame: { method: 'POST', path: '/turns/:id/invite', query: [], offline: null },
135
+ shareTurnGame: { method: 'POST', path: '/turns/:id/share', query: [], offline: null },
136
+ autoPlayTurn: { method: 'POST', path: '/turns/:id/auto', query: [], offline: null },
137
+ chatInTurnGame: { method: 'POST', path: '/turns/:id/chat', query: [], offline: null },
138
+ getTurnGame: { method: 'GET', path: '/turns/:id', query: [], offline: null },
139
+ getTurnGameHistory: { method: 'GET', path: '/turns/:id/history', query: ['since'], offline: null },
140
+ listOpenTurnGames: { method: 'GET', path: '/open-turns', query: ['game'], offline: null },
141
+ listMyTurnGames: { method: 'GET', path: '/turns', query: ['game'], offline: null },
142
+ getWorldMap: { method: 'GET', path: '/world/maps/:key', query: ['rev'], offline: null },
90
143
  } as const;
144
+
145
+ /** The request header that carries an offline operation key (the contract's `OFFLINE_OP_HEADER`). */
146
+ export const OFFLINE_OP_HEADER = 'x-hub-op';
@@ -0,0 +1,286 @@
1
+ /**
2
+ * `hub.resume`: auto-save a long single-player run so the player can pick it
3
+ * up again from the home page's Resume card, on any device. One slot per
4
+ * player per game; the latest save wins.
5
+ *
6
+ * - `save(run)` stores the run on the device at once and sends it to the hub
7
+ * after a short quiet spell (debounced), straight away when the page is
8
+ * hidden or closed, and through the offline outbox (`hub.resume`), so it
9
+ * works offline and lands once.
10
+ * - `clear()` when the run ends (won, lost or quit).
11
+ * - `load()` / `get()` read it back; `requested` says the page was opened to
12
+ * resume, so the game restores without asking.
13
+ *
14
+ * Saves are ignored while the page is in a race or an online room: those
15
+ * runs are never resumed. During a daily challenge they go to the day's own
16
+ * saved game (`hub.daily.save`), so the day can be picked up again.
17
+ */
18
+ import type { ResumeSlot, ResumeSummary } from '@diffenderfer-games/hub-contract';
19
+ import { HubError } from '../errors';
20
+ import { toGameData, toStoredJson, type GamePayload } from '../game-data';
21
+ import type { ResumePayload } from '../offline/hub-kinds';
22
+ import { isBrowserPage } from '../page';
23
+ import type { LocalResumeStore, LocalRun } from './local-resume';
24
+ import { mergeResumeLists } from './resume-list';
25
+ import { isResumeRequested } from './resume-request';
26
+ import type { ResumeBlocker, ResumeEntry, ResumeLoadOptions, ResumeRun, ResumeSaveInput } from './resume-types';
27
+
28
+ /** How long saves must be quiet before the latest is sent, ms. */
29
+ export const RESUME_DEBOUNCE_MS = 3000;
30
+
31
+ /** Largest state, bytes of JSON (the contract's `RESUME_MAX_STATE_BYTES`). */
32
+ export const RESUME_MAX_STATE_BYTES = 512 * 1024;
33
+
34
+ /** What a game should do when a save is ignored, by blocker (logged once per page). */
35
+ const BLOCKED_HINTS: Readonly<Record<ResumeBlocker, string>> = {
36
+ daily: 'a daily challenge is active. When the player leaves a daily without finishing it, call hub.daily.cancel().',
37
+ race: 'a race is on: races are never resumed.',
38
+ room: 'the page is in a hub room: online games are never resumed.',
39
+ turns: 'an online game is open: the hub keeps its state, so nothing is saved on the page.',
40
+ };
41
+
42
+ /** Longest label or title kept (the contract's `RESUME_TEXT_MAX`). */
43
+ const TEXT_MAX = 60;
44
+
45
+ /** What `hub.resume` needs from its client. */
46
+ export interface ResumeHost {
47
+ /** This game's slug; `null` on the home page. */
48
+ readonly slug: string | null;
49
+ readonly local: LocalResumeStore;
50
+ /** The signed-in player's id as the device knows it, or `null`. */
51
+ currentUserId(): number | null;
52
+ /** The game's display title. */
53
+ gameTitle(): string;
54
+ /** Why saving is off right now, or `null`. */
55
+ blocker(): ResumeBlocker | null;
56
+ /** The page's daily challenge while one is being played (saves go to the day instead of the run), else `null`. */
57
+ daily?(): DailyRoute | null;
58
+ /** Perform a resume op: at once when online with nothing waiting, else queued. */
59
+ send(payload: ResumePayload): Promise<void>;
60
+ /** This game's run on the hub, or `undefined` when the hub can't be reached. */
61
+ fetchSlot(): Promise<ResumeSlot | null | undefined>;
62
+ /** The player's runs on the hub, or `undefined` when it can't be reached. */
63
+ fetchList(): Promise<ResumeSummary[] | undefined>;
64
+ /** Clear another game's run on the hub (the home page's discard); throws offline. */
65
+ clearOther(slug: string): Promise<void>;
66
+ /** The run changed here (saved or cleared): `hub.saved` listeners hear it. */
67
+ onChange?(): void;
68
+ }
69
+
70
+ /** Where a save goes during a daily challenge (`hub.daily`). */
71
+ export interface DailyRoute {
72
+ save(input: ResumeSaveInput<GamePayload>): boolean;
73
+ }
74
+
75
+ /** The label a daily goes back to when the game clears its run mid-daily (the day restarts from its seed). */
76
+ const DAILY_RESTART_LABEL = 'Started';
77
+
78
+ /** A label or title trimmed and clipped, or `null` when blank. */
79
+ function shortText(text: string | null | undefined): string | null {
80
+ const trimmed = (text ?? '').trim();
81
+ return trimmed === '' ? null : trimmed.slice(0, TEXT_MAX);
82
+ }
83
+
84
+ /** The UTF-8 size of `text`. */
85
+ function byteSize(text: string): number {
86
+ return new TextEncoder().encode(text).length;
87
+ }
88
+
89
+ /** The device copy as a run read back. */
90
+ function runFromCopy<TState>(copy: LocalRun): ResumeRun<TState> {
91
+ return { state: toGameData<TState>(copy.state), progress: { label: copy.label, fraction: copy.fraction }, title: copy.title, version: copy.version, savedAt: copy.at };
92
+ }
93
+
94
+ /** The hub's slot as a run read back. */
95
+ function runFromSlot<TState>(slot: ResumeSlot): ResumeRun<TState> {
96
+ return { state: toGameData<TState>(slot.state), progress: slot.progress, title: slot.title, version: slot.version, savedAt: slot.updatedAt };
97
+ }
98
+
99
+ /** `hub.resume`. */
100
+ export class HubResume {
101
+ private readonly host: ResumeHost;
102
+ private timer: ReturnType<typeof setTimeout> | null = null;
103
+ private waiting: ResumePayload | null = null;
104
+ private isListening = false;
105
+ private readonly warnedBlockers = new Set<ResumeBlocker>();
106
+
107
+ constructor(host: ResumeHost) {
108
+ this.host = host;
109
+ }
110
+
111
+ /**
112
+ * `true` when the page was opened from the Resume card (`?hub_resume=1`,
113
+ * taken out of the address bar). Synchronous, so a game can restore before
114
+ * it routes or draws its title screen.
115
+ */
116
+ get requested(): boolean {
117
+ return isResumeRequested();
118
+ }
119
+
120
+ /** Why a save would be ignored right now (`race`, `daily`, `room`), or `null`. */
121
+ get blockedBy(): ResumeBlocker | null {
122
+ return this.host.blocker();
123
+ }
124
+
125
+ /**
126
+ * Save the run (the latest save wins). Stored on the device at once; sent
127
+ * after {@link RESUME_DEBOUNCE_MS} of quiet, or when the page is hidden.
128
+ * Returns `false` (and saves nothing) in a race or an online room; during
129
+ * a daily challenge the save goes to the day (`hub.daily.save`). Throws a
130
+ * `HubError` 413 over 512 KB, 400 without a label.
131
+ */
132
+ save<TState extends GamePayload>(run: ResumeSaveInput<TState>): boolean {
133
+ const blocker = this.host.blocker();
134
+ if (blocker !== null) {
135
+ this.warnBlocked(blocker);
136
+ return false;
137
+ }
138
+ const daily = this.host.daily?.() ?? null;
139
+ if (daily !== null) {
140
+ // During a daily challenge the run IS the day: it is saved to the day's saved game, not the game's run.
141
+ return daily.save(run);
142
+ }
143
+ const slug = this.requireSlug();
144
+ const label = shortText(run.progress.label);
145
+ if (label === null) {
146
+ throw new HubError('hub.resume.save: progress.label is required.', 400);
147
+ }
148
+ const state = toStoredJson(run.state);
149
+ if (byteSize(JSON.stringify(state)) > RESUME_MAX_STATE_BYTES) {
150
+ throw new HubError(`hub.resume.save: state exceeds ${String(RESUME_MAX_STATE_BYTES)} bytes.`, 413);
151
+ }
152
+ const fraction = typeof run.progress.fraction === 'number' && Number.isFinite(run.progress.fraction) ? Math.min(1, Math.max(0, run.progress.fraction)) : null;
153
+ const copy: LocalRun = {
154
+ status: 'saved', userId: this.host.currentUserId(), at: Date.now(), label, fraction, title: shortText(run.title),
155
+ version: Math.max(0, Math.floor(run.version ?? 0)), gameTitle: this.host.gameTitle(), state, isSynced: false,
156
+ };
157
+ const isStored = this.host.local.write(slug, copy);
158
+ this.host.onChange?.();
159
+ this.schedule({
160
+ action: 'save', savedAt: copy.at, label, fraction, title: copy.title, version: copy.version,
161
+ ...(isStored ? {} : { state }),
162
+ });
163
+ return true;
164
+ }
165
+
166
+ /** Send a waiting save now (the page does this itself when hidden or closed). */
167
+ async flush(): Promise<void> {
168
+ if (this.timer !== null) {
169
+ clearTimeout(this.timer);
170
+ this.timer = null;
171
+ }
172
+ const payload = this.waiting;
173
+ this.waiting = null;
174
+ if (payload !== null) {
175
+ await this.host.send(payload);
176
+ }
177
+ }
178
+
179
+ /** Clear the run: call it when the run ends (won, lost or quit). Works offline. */
180
+ async clear(): Promise<void> {
181
+ const slug = this.requireSlug();
182
+ const daily = this.host.daily?.() ?? null;
183
+ if (daily !== null) {
184
+ // The day's run ended without a win: the day stays in progress, starting over from its seed.
185
+ daily.save({ state: null, progress: { label: DAILY_RESTART_LABEL } });
186
+ return;
187
+ }
188
+ await this.clearRun(slug);
189
+ }
190
+
191
+ /** Delete the game's saved run (never the day being played). */
192
+ private async clearRun(slug: string): Promise<void> {
193
+ if (this.timer !== null) {
194
+ clearTimeout(this.timer);
195
+ this.timer = null;
196
+ }
197
+ this.waiting = null;
198
+ const at = Date.now();
199
+ this.host.local.write(slug, { status: 'cleared', userId: this.host.currentUserId(), at, isSynced: false });
200
+ this.host.onChange?.();
201
+ await this.host.send({ action: 'clear', before: at });
202
+ }
203
+
204
+ /** The saved run with its progress and time, or `null`. The newer of the device's copy and the hub's wins. */
205
+ async get<TState = GamePayload>(options: ResumeLoadOptions = {}): Promise<ResumeRun<TState> | null> {
206
+ const slug = this.requireSlug();
207
+ const copy = this.host.local.read(slug);
208
+ const mine = copy !== null && copy.userId === this.host.currentUserId() ? copy : null;
209
+ const slot = await this.host.fetchSlot();
210
+ let run: ResumeRun<TState> | null;
211
+ if (slot !== undefined && slot !== null && (mine === null || slot.updatedAt >= mine.at)) {
212
+ run = runFromSlot<TState>(slot);
213
+ } else {
214
+ // The device's run, unless the hub was asked, has none, and already had this one (it was cleared elsewhere).
215
+ const isClearedElsewhere = slot === null && mine?.isSynced === true;
216
+ run = mine?.status === 'saved' && !isClearedElsewhere ? runFromCopy<TState>(mine) : null;
217
+ }
218
+ if (run === null || options.version === undefined || run.version === options.version) {
219
+ return run;
220
+ }
221
+ if (run.version < options.version) {
222
+ // A stale saved run is dropped; a daily being played is left alone.
223
+ await this.clearRun(slug);
224
+ }
225
+ return null;
226
+ }
227
+
228
+ /** The saved state, or `null` (see {@link get} for the version rule). */
229
+ async load<TState = GamePayload>(options: ResumeLoadOptions = {}): Promise<TState | null> {
230
+ return (await this.get<TState>(options))?.state ?? null;
231
+ }
232
+
233
+ /** The player's saved runs in every resumable game, newest first (the home page's Resume card). */
234
+ async list(): Promise<ResumeEntry[]> {
235
+ return mergeResumeLists(await this.host.fetchList(), this.host.local.all(), this.host.currentUserId());
236
+ }
237
+
238
+ /** Discard another game's run from the home page (online only: throws a `HubError` offline). */
239
+ async discard(slug: string): Promise<void> {
240
+ await this.host.clearOther(slug);
241
+ this.host.local.remove(slug);
242
+ }
243
+
244
+ /** Say once per blocker why saves are ignored, so a game that forgot `hub.daily.cancel()` finds out. */
245
+ private warnBlocked(blocker: ResumeBlocker): void {
246
+ if (this.warnedBlockers.has(blocker)) {
247
+ return;
248
+ }
249
+ this.warnedBlockers.add(blocker);
250
+ // eslint-disable-next-line no-console -- a one-time hint to the game developer, with the fix
251
+ console.warn(`[hub] hub.resume.save ignored: ${BLOCKED_HINTS[blocker]}`);
252
+ }
253
+
254
+ private requireSlug(): string {
255
+ if (this.host.slug === null) {
256
+ throw new HubError('hub.resume: could not determine the game slug.');
257
+ }
258
+ return this.host.slug;
259
+ }
260
+
261
+ private schedule(payload: ResumePayload): void {
262
+ this.waiting = payload;
263
+ if (this.timer !== null) {
264
+ clearTimeout(this.timer);
265
+ }
266
+ this.timer = setTimeout(() => { void this.flush().catch(() => undefined); }, RESUME_DEBOUNCE_MS);
267
+ this.listenToPage();
268
+ }
269
+
270
+ /** Send a waiting save as soon as the page is hidden or closed. */
271
+ private listenToPage(): void {
272
+ if (this.isListening || !isBrowserPage()) {
273
+ return;
274
+ }
275
+ this.isListening = true;
276
+ const flushNow = (): void => { void this.flush().catch(() => undefined); };
277
+ window.addEventListener('pagehide', flushNow);
278
+ if (typeof document !== 'undefined') {
279
+ document.addEventListener('visibilitychange', () => {
280
+ if (document.visibilityState === 'hidden') {
281
+ flushNow();
282
+ }
283
+ });
284
+ }
285
+ }
286
+ }