@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
@@ -1,10 +1,21 @@
1
1
  /**
2
2
  * `hub.rooms`: hub relay rooms, forwarded to the social runtime. Each room is
3
- * wrapped so its rejections use the core client's error classes.
3
+ * wrapped so its rejections use the core client's error classes, and the
4
+ * page's open rooms are tracked (`isInHubRoom`), so `hub.resume` can refuse
5
+ * to save an online run.
4
6
  */
5
7
  import type { Json, RoomInfo } from '@diffenderfer-games/hub-contract';
6
- import { pageBridge, rethrowAsHubError } from './bridge';
8
+ import { outdatedRuntime, pageBridge, rethrowAsHubError } from './bridge';
7
9
  import type { HubRoom, HubRoomsApi, RoomEvents, RoomSelf, RoomSendOptions, RoomState, RoomStateOptions } from './room-types';
10
+ import type { RoomSeat } from './wire-types';
11
+
12
+ /** The rooms this page is in (created, joined or reclaimed, and not yet left or closed). */
13
+ const openRooms = new Set<HubRoom>();
14
+
15
+ /** Whether this page is in a hub room right now. */
16
+ export function isInHubRoom(): boolean {
17
+ return openRooms.size > 0;
18
+ }
8
19
 
9
20
  /** A runtime room whose rejections are re-created as the core client's errors. */
10
21
  class ForwardedRoom implements HubRoom {
@@ -12,6 +23,8 @@ class ForwardedRoom implements HubRoom {
12
23
 
13
24
  constructor(room: HubRoom) {
14
25
  this.room = room;
26
+ openRooms.add(room);
27
+ room.on('closed', () => { openRooms.delete(room); });
15
28
  }
16
29
 
17
30
  get info(): RoomInfo {
@@ -26,6 +39,10 @@ class ForwardedRoom implements HubRoom {
26
39
  return this.room.me;
27
40
  }
28
41
 
42
+ seats(): RoomSeat[] {
43
+ return this.room.info.seats;
44
+ }
45
+
29
46
  on<Name extends keyof RoomEvents>(event: Name, listener: (payload: RoomEvents[Name]) => void): () => void {
30
47
  return this.room.on(event, listener);
31
48
  }
@@ -63,6 +80,7 @@ class ForwardedRoom implements HubRoom {
63
80
  }
64
81
 
65
82
  leave(): Promise<void> {
83
+ openRooms.delete(this.room);
66
84
  return rethrowAsHubError(this.room.leave());
67
85
  }
68
86
 
@@ -76,4 +94,11 @@ export const roomsForwarder: HubRoomsApi = {
76
94
  create: (options) => pageBridge.call(async (realtime) => new ForwardedRoom(await realtime.rooms.create(options))),
77
95
  join: (codeOrId, options) => pageBridge.call(async (realtime) => new ForwardedRoom(await realtime.rooms.join(codeOrId, options))),
78
96
  list: (options) => pageBridge.call((realtime) => realtime.rooms.list(options)),
97
+ reclaim: () => pageBridge.call(async (realtime) => {
98
+ if (realtime.rooms.reclaim === undefined) {
99
+ throw outdatedRuntime('Taking back a seat');
100
+ }
101
+ const held = await realtime.rooms.reclaim();
102
+ return held === null ? null : new ForwardedRoom(held);
103
+ }),
79
104
  };
@@ -3,16 +3,29 @@
3
3
  * the page, and that `hub.presence`, `hub.social`, `hub.mp`, `hub.rooms`,
4
4
  * `hub.notify` and `hub.race` forward to.
5
5
  */
6
- import type { HubMpApi } from './multiplayer-types';
6
+ import type { RuntimeAvatarApi } from '../core/avatar/avatar-types';
7
+ import type { HubMpApi, InviteLinkMethods } from './multiplayer-types';
7
8
  import type { HubRaceApi } from './race-types';
8
- import type { HubRoomsApi } from './room-types';
9
- import type { HubNotifyApi, HubPresenceApi, HubSocialApi } from './social-types';
9
+ import type { HubRoomsApi, RoomReclaimMethods } from './room-types';
10
+ import type { HubNotifyApi, HubPresenceApi, HubSocialApi, PlayOnlineMethods } from './social-types';
11
+ import type { RuntimeWorldApi } from './world-types';
10
12
 
11
13
  /**
12
14
  * The realtime feature version the runtime reports in {@link RealtimeApi.version}.
13
- * 2 added races; a client feature-detects newer additions with it.
15
+ * 2 added races; 3 added invite links and taking back a held seat; 4 added
16
+ * "Play online" (tournaments); 5 added avatars (the designer and `ev:avatar`); 6 added the world (`world`).
17
+ * A client feature-detects newer additions with it (or by the optional method).
14
18
  */
15
- export const REALTIME_FEATURE_VERSION = 2;
19
+ export const REALTIME_FEATURE_VERSION = 6;
20
+
21
+ /** `hub.social` as a runtime installs it: version 4's `openPlayOnline` is missing on older runtimes. */
22
+ export type RuntimeSocialApi = Omit<HubSocialApi, keyof PlayOnlineMethods> & Partial<PlayOnlineMethods>;
23
+
24
+ /** `hub.mp` as a runtime installs it: version 3's invite-link methods are missing on older runtimes. */
25
+ export type RuntimeMpApi = Omit<HubMpApi, keyof InviteLinkMethods> & Partial<InviteLinkMethods>;
26
+
27
+ /** `hub.rooms` as a runtime installs it: version 3's `reclaim` is missing on older runtimes. */
28
+ export type RuntimeRoomsApi = Omit<HubRoomsApi, keyof RoomReclaimMethods> & Partial<RoomReclaimMethods>;
16
29
 
17
30
  /**
18
31
  * The cross-bundle shape number of the `rt` page singleton (plan §4.4). The
@@ -24,6 +37,14 @@ export const REALTIME_SINGLETON_API = 1;
24
37
  /** The window event the runtime fires once it has installed itself. */
25
38
  export const REALTIME_READY_EVENT = 'hub:rt-ready';
26
39
 
40
+ /**
41
+ * The window event the runtime fires each time its socket says hello (it
42
+ * connected, or connected again after a drop), with `{ user: { id, look } }`
43
+ * from the hello snapshot. `hub.avatar` and `hub.userId()` follow it; changes
44
+ * sent while the socket was down are fetched again.
45
+ */
46
+ export const SESSION_EVENT = 'hub:session';
47
+
27
48
  /** The window event the menu fires when the runtime failed to load (its reason is on `window.__HUB_RT_ERROR__`). */
28
49
  export const REALTIME_FAILED_EVENT = 'hub:rt-failed';
29
50
 
@@ -37,13 +58,17 @@ export interface RealtimeApi {
37
58
  /** Presence reporting. */
38
59
  presence: HubPresenceApi;
39
60
  /** Friends and the social dialogs. */
40
- social: HubSocialApi;
61
+ social: RuntimeSocialApi;
41
62
  /** Invites, launches, the leave guard and tickets. */
42
- mp: HubMpApi;
63
+ mp: RuntimeMpApi;
43
64
  /** Hub relay rooms. */
44
- rooms: HubRoomsApi;
65
+ rooms: RuntimeRoomsApi;
45
66
  /** Toast behaviour. */
46
67
  notify: HubNotifyApi;
47
68
  /** Races (feature version 2 and later). */
48
69
  race?: HubRaceApi;
70
+ /** Avatars: the designer and live changes (feature version 5 and later). */
71
+ avatar?: RuntimeAvatarApi;
72
+ /** The world's ops and events (feature version 6 and later). */
73
+ world?: RuntimeWorldApi;
49
74
  }
@@ -65,6 +65,11 @@ export const socialForwarder: HubSocialApi = {
65
65
  realtime.social.openNotify(options);
66
66
  });
67
67
  },
68
+ openPlayOnline: () => {
69
+ openWithRuntime((realtime) => {
70
+ realtime.social.openPlayOnline?.();
71
+ });
72
+ },
68
73
  friends: () => pageBridge.call((realtime) => realtime.social.friends()),
69
74
  counts: () => pageBridge.current()?.social.counts() ?? { ...NO_COUNTS },
70
75
  chatMode: (userIds) => pageBridge.current()?.social.chatMode(userIds) ?? 'off',
@@ -61,6 +61,11 @@ export interface HubSocialApi {
61
61
  openSuggest(): void;
62
62
  /** Open notification settings, optionally focused on a game's lobby bell. */
63
63
  openNotify(options?: { game?: string }): void;
64
+ /**
65
+ * Open "Play online": games waiting for a player, invites, tournaments and
66
+ * the quick 1v1 (feature version 4; a no-op on older runtimes).
67
+ */
68
+ openPlayOnline(): void;
64
69
  /** Friends with live presence. Rejects with `OfflineError` without the runtime. */
65
70
  friends(): Promise<FriendEntry[]>;
66
71
  /** Live counts; all zero until the runtime is up. */
@@ -71,6 +76,9 @@ export interface HubSocialApi {
71
76
  on<Name extends keyof SocialEvents>(event: Name, listener: (payload: SocialEvents[Name]) => void): () => void;
72
77
  }
73
78
 
79
+ /** What feature version 4 added to `hub.social`. */
80
+ export type PlayOnlineMethods = Pick<HubSocialApi, 'openPlayOnline'>;
81
+
74
82
  /** `hub.notify`: toast behaviour during intense play. */
75
83
  export interface HubNotifyApi {
76
84
  /** Quiet: toasts collapse into the menu badge; invites still show as a small pill. */
@@ -51,3 +51,4 @@ export type * from './multiplayer-types';
51
51
  export type * from './room-types';
52
52
  export type * from './race-types';
53
53
  export type { RealtimeApi } from './runtime-api';
54
+ export type { RuntimeWorldApi, WorldEventName, WorldOpName } from './world-types';
@@ -9,6 +9,7 @@ import type {
9
9
  OpAck,
10
10
  ops,
11
11
  InviteCreated as InviteCreatedSchema,
12
+ InviteLink as InviteLinkSchema,
12
13
  RaceHistory as RaceHistorySchema,
13
14
  RaceResultPlayer as RaceResultPlayerSchema,
14
15
  RaceRecordPlayer as RaceRecordPlayerSchema,
@@ -51,6 +52,12 @@ export type RoomMessage = ServerEvents['room.msg'];
51
52
  /** A shared-state change (`ev:room.state`); patches are shallow-merged unless `replace`. */
52
53
  export type RoomStateChange = ServerEvents['room.state'];
53
54
 
55
+ /** A seat went away, came back or is gone (`ev:room.seat`). */
56
+ export type RoomSeatChange = ServerEvents['room.seat'];
57
+
58
+ /** A shareable invite link into a room (`POST /mp/links`). */
59
+ export type InviteLink = SchemaOutput<typeof InviteLinkSchema>;
60
+
54
61
  /** A moderated room chat line (`ev:room.chat`). */
55
62
  export type RoomChatLine = ServerEvents['room.chat'];
56
63
 
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The world over the runtime's socket (docs/plans/world.md §8.3): the
3
+ * `world.*` ops and events, typed from the contract. The hub's world client
4
+ * calls it; games don't need it.
5
+ */
6
+ import type { EventData, EventName, events, OpAck, OpInput, OpName, ops } from '@diffenderfer-games/hub-contract';
7
+
8
+ /** A `world.*` op name. */
9
+ export type WorldOpName = Extract<OpName, `world.${string}`>;
10
+
11
+ /** A `world.*` event name. */
12
+ export type WorldEventName = Extract<EventName, `world.${string}`>;
13
+
14
+ /** `rt.world` (realtime feature version 6 and later). */
15
+ export interface RuntimeWorldApi {
16
+ /** Send a world op on the page's socket; rejects with the hub's refusal. */
17
+ op<Name extends WorldOpName>(name: Name, input: OpInput<(typeof ops)[Name]>): Promise<OpAck<(typeof ops)[Name]>>;
18
+ /** Hear a world event; returns the unsubscribe. */
19
+ on<Name extends WorldEventName>(name: Name, listener: (payload: EventData<(typeof events)[Name]>) => void): () => void;
20
+ }
package/src/rooms.ts CHANGED
@@ -6,5 +6,5 @@
6
6
  */
7
7
  export { roomsForwarder as rooms } from './realtime/rooms-forwarder';
8
8
  export type * from './realtime/room-types';
9
- export type { RoomChatLine, RoomMessage, RoomSeat, RoomStateChange } from './realtime/wire-types';
9
+ export type { RoomChatLine, RoomMessage, RoomSeat, RoomSeatChange, RoomStateChange } from './realtime/wire-types';
10
10
  export type { RoomInfo } from '@diffenderfer-games/hub-contract';
package/src/social.ts CHANGED
@@ -14,5 +14,6 @@ export {
14
14
  REALTIME_FEATURE_VERSION,
15
15
  REALTIME_READY_EVENT,
16
16
  REALTIME_SINGLETON_API,
17
+ SESSION_EVENT,
17
18
  } from './realtime/runtime-api';
18
19
  export type * from './realtime/types';
@@ -0,0 +1,281 @@
1
+ /**
2
+ * The rules conformance kit (docs/multiplayer.md "Turn games: testing"):
3
+ * `checkTurnRules(rules, options)` plays many seeded games through a rules
4
+ * module the way the hub does and throws on the first broken promise:
5
+ *
6
+ * - every game reaches an outcome within `maxMoves`, and `toAct` is never
7
+ * empty before it; every seat in `toAct` gets to move (not only the
8
+ * first), and so do the seats in `mayAct` (out of turn: a jump-in, a
9
+ * catch) whose `autoReact` (or `options.reactMove`) has a move;
10
+ * - `toAct` and `mayAct` only name seats that exist;
11
+ * - the state survives a JSON round trip after every move, and the same seed
12
+ * plays the same game (no `Math.random`, no hidden globals);
13
+ * - `autoMove` and `autoReact` (when the rules have them) are always legal;
14
+ * - no seat's `view` or `eventView` shows another seat's secrets: markers
15
+ * planted with `options.plant` never appear in anyone else's view until
16
+ * the rules reveal them (`options.revealed`: a card played face up);
17
+ * - with `options.shuffleHidden` (the "twin table"): shuffling everything a
18
+ * seat may not see never changes that seat's view;
19
+ * - stored fixtures from older versions `migrate` to a state that views;
20
+ * - views, events and moves stay within the hub's size caps.
21
+ *
22
+ * It needs no hub: run it in a game's own unit tests (vitest, node:test).
23
+ */
24
+ import { createTurnRng, turnRngStateFromSeed, type TurnRng, type TurnRules } from '@diffenderfer-games/hub-contract/turns-rules';
25
+ import { parseJsonText } from '../core/storage/json-guards';
26
+
27
+ /** The hub's caps. */
28
+ const STATE_MAX_BYTES = 64 * 1024;
29
+ const MOVE_MAX_BYTES = 2 * 1024;
30
+
31
+ /** How to check a rules module. */
32
+ export interface TurnRulesCheckOptions<State, Move, Options = never> {
33
+ /** Seat counts to play (default: 2). */
34
+ readonly seats?: readonly number[];
35
+ /** Games per seat count (default: 25). */
36
+ readonly games?: number;
37
+ /** A game that runs longer than this many moves fails (default: 2000). */
38
+ readonly maxMoves?: number;
39
+ /** Options and mode to set games up with. */
40
+ readonly setupOptions?: Options;
41
+ readonly mode?: string | null;
42
+ /** A legal move for `seat` (default: the rules' own `autoMove`). At least one of the two is needed. */
43
+ readonly randomMove?: (state: State, seat: number, rng: TurnRng) => Move;
44
+ /** A legal out-of-turn move for `seat` while it is in `mayAct`, or `null` to pass (default: the rules' own `autoReact`). */
45
+ readonly reactMove?: (state: State, seat: number, rng: TurnRng) => Move | null;
46
+ /**
47
+ * Put `marker` somewhere only `seat` may see (a card in their hand, their
48
+ * secret goal) and return the state; the kit then checks no other seat's
49
+ * view or events ever contain it. Leave it out for a game with no hidden
50
+ * information.
51
+ */
52
+ readonly plant?: (state: State, seat: number, marker: string) => State;
53
+ /**
54
+ * Whether a planted `marker` is now public by the rules (the card was
55
+ * played or discarded face up, a secret was shown at the end): from then
56
+ * on the kit stops checking it in that game, so a card that is rightly
57
+ * revealed isn't a leak. Leave it out when planted markers never become
58
+ * public.
59
+ */
60
+ readonly revealed?: (state: State, marker: string) => boolean;
61
+ /**
62
+ * The "twin table": a copy of `state` with everything `seat` may not see
63
+ * shuffled or redealt (the deck's order, the other hands' cards, keeping
64
+ * their counts), drawing from `rng`. After every move the kit checks
65
+ * that each seat's `view` of the twin is exactly its view of the real
66
+ * state: a view that changes depends on something that seat must not
67
+ * know. Catches leaks `plant` can't (a count, an order, a derived hint).
68
+ */
69
+ readonly shuffleHidden?: (state: State, seat: number, rng: TurnRng) => State;
70
+ /** States saved by older rules versions, which `migrate` must bring up to date. */
71
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: states stored by older rules versions
72
+ readonly fixtures?: readonly { readonly version: number; readonly state: unknown }[];
73
+ }
74
+
75
+ /** What the kit played. */
76
+ export interface TurnRulesReport {
77
+ readonly games: number;
78
+ readonly moves: number;
79
+ readonly longestGame: number;
80
+ /** Moves made out of turn (by a seat in `mayAct`, not `toAct`). */
81
+ readonly outOfTurn: number;
82
+ }
83
+
84
+ /** The UTF-8 bytes of a JSON text. */
85
+ function bytesOf(json: string): number {
86
+ return new TextEncoder().encode(json).length;
87
+ }
88
+
89
+ /** Throw a kit failure. */
90
+ function fail(message: string): never {
91
+ throw new Error(`checkTurnRules: ${message}`);
92
+ }
93
+
94
+ /** A JSON copy (what the hub stores and reads back). */
95
+ function roundTrip<T>(value: T): T {
96
+ const copy = parseJsonText(JSON.stringify(value));
97
+ if (copy === null && value !== null) {
98
+ fail('a state or move is not JSON');
99
+ }
100
+ return isGamesOwn(copy, value) ? copy : fail('a state or move is not JSON');
101
+ }
102
+
103
+ /** Always true: JSON a game's rules made is read back as the game's own type (the game vouches for it, as `toGameData` does). */
104
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: the game's own JSON, round-tripped
105
+ function isGamesOwn<T>(_value: unknown, _like: T): _value is T {
106
+ return true;
107
+ }
108
+
109
+ /** A move for `seat`: the check's own chooser, else the rules' autoMove. */
110
+ function pickMove<State, Move, View, Event, Options>(rules: TurnRules<State, Move, View, Event, Options>, options: TurnRulesCheckOptions<State, Move, Options>, state: State, seat: number, rng: TurnRng): Move {
111
+ if (options.randomMove !== undefined) {
112
+ return options.randomMove(state, seat, rng);
113
+ }
114
+ if (rules.autoMove === undefined) {
115
+ return fail('the rules have no autoMove: pass options.randomMove');
116
+ }
117
+ return rules.autoMove(state, seat, rng);
118
+ }
119
+
120
+ /** An out-of-turn move for `seat`: the check's own chooser, else the rules' autoReact, else none. */
121
+ function pickReaction<State, Move, View, Event, Options>(rules: TurnRules<State, Move, View, Event, Options>, options: TurnRulesCheckOptions<State, Move, Options>, state: State, seat: number, rng: TurnRng): Move | null {
122
+ if (options.reactMove !== undefined) {
123
+ return options.reactMove(state, seat, rng);
124
+ }
125
+ return rules.autoReact?.(state, seat, rng) ?? null;
126
+ }
127
+
128
+ /** Fail unless every seat in `list` exists. */
129
+ function checkSeats(list: readonly number[], seats: number, what: string, seed: number): void {
130
+ if (list.some((seat) => !Number.isInteger(seat) || seat < 0 || seat >= seats)) {
131
+ fail(`${what} named a seat that doesn't exist: [${list.join(', ')}] (seed ${String(seed)})`);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * The next move of a game: half the time a seat in `mayAct` that has an
137
+ * out-of-turn move, else a seat in `toAct` (any of them, not only the
138
+ * first). The kit's own `pick` RNG chooses, so the game's RNG sees only
139
+ * the rules' own draws.
140
+ */
141
+ function nextMove<State, Move, View, Event, Options>(
142
+ rules: TurnRules<State, Move, View, Event, Options>, options: TurnRulesCheckOptions<State, Move, Options>, state: State, toAct: readonly number[], rng: TurnRng, pick: TurnRng,
143
+ ): { seat: number; move: Move; by: string } | null {
144
+ const optional = (rules.mayAct?.(state) ?? []).filter((seat) => !toAct.includes(seat));
145
+ if (optional.length > 0 && pick.int(2) === 0) {
146
+ const seat = pick.pick(optional);
147
+ const move = pickReaction(rules, options, state, seat, rng);
148
+ if (move !== null) {
149
+ return { seat, move, by: options.reactMove === undefined ? 'autoReact' : 'reactMove' };
150
+ }
151
+ }
152
+ const seat = toAct.length === 0 ? undefined : pick.pick(toAct);
153
+ return seat === undefined ? null : { seat, move: pickMove(rules, options, state, seat, rng), by: options.randomMove === undefined ? 'autoMove' : 'randomMove' };
154
+ }
155
+
156
+ /** Play one seeded game; answers its moves and a trace for the determinism check. */
157
+ function playGame<State, Move, View, Event, Options>(
158
+ rules: TurnRules<State, Move, View, Event, Options>, seats: number, seed: number, options: TurnRulesCheckOptions<State, Move, Options>,
159
+ ): { moves: number; outOfTurn: number; trace: string } {
160
+ const rng = createTurnRng(turnRngStateFromSeed(seed));
161
+ const pick = createTurnRng(turnRngStateFromSeed(seed ^ 0x5bd1e995));
162
+ const twinRng = createTurnRng(turnRngStateFromSeed(seed ^ 0x27d4eb2f));
163
+ const marker = `MARKER-${String(seed)}-`;
164
+ const markerOf = (owner: number): string => `${marker}${String(owner)}`;
165
+ let state = rules.setup({ seats, options: options.setupOptions ?? null, mode: options.mode ?? null, rng });
166
+ if (options.plant !== undefined) {
167
+ for (let seat = 0; seat < seats; seat++) {
168
+ state = options.plant(state, seat, markerOf(seat));
169
+ }
170
+ }
171
+ const maxMoves = options.maxMoves ?? 2000;
172
+ const trace: string[] = [];
173
+ let outOfTurn = 0;
174
+ /** The twin table: each seat's view is the same after what it can't see is shuffled. */
175
+ const checkTwin = (): void => {
176
+ if (options.shuffleHidden === undefined) {
177
+ return;
178
+ }
179
+ for (let viewer = 0; viewer < seats; viewer++) {
180
+ const real = JSON.stringify(rules.view(state, viewer));
181
+ const twin = JSON.stringify(rules.view(roundTrip(options.shuffleHidden(roundTrip(state), viewer, twinRng)), viewer));
182
+ if (twin !== real) {
183
+ fail(`seat ${String(viewer)}'s view changed when what it can't see was shuffled: it shows something hidden (seed ${String(seed)})`);
184
+ }
185
+ }
186
+ };
187
+ /** The seats whose planted marker is still secret (the rules haven't revealed it). */
188
+ const secret = new Set(options.plant === undefined ? [] : Array.from({ length: seats }, (_, seat) => seat));
189
+ const checkViews = (events: readonly Event[]): void => {
190
+ for (const owner of secret) {
191
+ if (options.revealed?.(state, markerOf(owner)) === true) {
192
+ secret.delete(owner);
193
+ }
194
+ }
195
+ for (let viewer = 0; viewer < seats; viewer++) {
196
+ const seen = JSON.stringify(rules.view(state, viewer)) + JSON.stringify(events.map((event) => rules.eventView?.(event, viewer) ?? null));
197
+ for (const owner of secret) {
198
+ if (owner !== viewer && seen.includes(markerOf(owner))) {
199
+ fail(`seat ${String(viewer)} can see seat ${String(owner)}'s secret (seed ${String(seed)})`);
200
+ }
201
+ }
202
+ }
203
+ const shown = JSON.stringify(rules.view(state, null));
204
+ for (const owner of secret) {
205
+ if (shown.includes(markerOf(owner))) {
206
+ fail(`the public view shows seat ${String(owner)}'s secret (seed ${String(seed)})`);
207
+ }
208
+ }
209
+ checkTwin();
210
+ };
211
+ checkViews([]);
212
+ for (let moves = 0; moves < maxMoves; moves++) {
213
+ if (rules.outcome(state) !== null) {
214
+ return { moves, outOfTurn, trace: trace.join('|') };
215
+ }
216
+ const toAct = rules.toAct(state);
217
+ checkSeats(toAct, seats, 'toAct', seed);
218
+ checkSeats(rules.mayAct?.(state) ?? [], seats, 'mayAct', seed);
219
+ if (toAct.length === 0) {
220
+ fail(`toAct is empty but there is no outcome yet (seed ${String(seed)}, move ${String(moves)})`);
221
+ }
222
+ const next = nextMove(rules, options, state, toAct, rng, pick);
223
+ if (next === null) {
224
+ return fail(`no seat could move (seed ${String(seed)}, move ${String(moves)})`);
225
+ }
226
+ const { seat, move } = next;
227
+ outOfTurn += toAct.includes(seat) ? 0 : 1;
228
+ if (bytesOf(JSON.stringify(move)) > MOVE_MAX_BYTES) {
229
+ fail(`a move is over ${String(MOVE_MAX_BYTES)} bytes`);
230
+ }
231
+ const result = rules.apply(roundTrip(state), seat, roundTrip(move), rng);
232
+ if ('error' in result) {
233
+ fail(`${next.by} made an illegal move for seat ${String(seat)}: ${result.error} (seed ${String(seed)}, move ${String(moves)})`);
234
+ }
235
+ state = roundTrip(result.state);
236
+ if (bytesOf(JSON.stringify(state)) > STATE_MAX_BYTES) {
237
+ fail(`the state is over ${String(STATE_MAX_BYTES)} bytes`);
238
+ }
239
+ checkViews(result.events ?? []);
240
+ rules.progress(state, seat);
241
+ trace.push(`${String(seat)}:${JSON.stringify(move)}`);
242
+ }
243
+ return fail(`a game did not end within ${String(maxMoves)} moves (seed ${String(seed)})`);
244
+ }
245
+
246
+ /** Check a rules module (see the file comment); throws on the first failure. */
247
+ export function checkTurnRules<State, Move, View, Event, Options>(
248
+ rules: TurnRules<State, Move, View, Event, Options>, options: TurnRulesCheckOptions<State, Move, Options> = {},
249
+ ): TurnRulesReport {
250
+ if (!Number.isInteger(rules.version) || rules.version < 0) {
251
+ fail('rules.version must be a whole number');
252
+ }
253
+ let games = 0;
254
+ let moves = 0;
255
+ let longestGame = 0;
256
+ let outOfTurn = 0;
257
+ for (const seats of options.seats ?? [2]) {
258
+ for (let game = 0; game < (options.games ?? 25); game++) {
259
+ const seed = seats * 100_003 + game;
260
+ const played = playGame(rules, seats, seed, options);
261
+ if (playGame(rules, seats, seed, options).trace !== played.trace) {
262
+ fail(`the same seed played a different game (seed ${String(seed)}): use the rng you are given, never Math.random`);
263
+ }
264
+ games += 1;
265
+ moves += played.moves;
266
+ longestGame = Math.max(longestGame, played.moves);
267
+ outOfTurn += played.outOfTurn;
268
+ }
269
+ }
270
+ for (const fixture of options.fixtures ?? []) {
271
+ if (fixture.version < rules.version) {
272
+ if (rules.migrate === undefined) {
273
+ fail(`a version ${String(fixture.version)} fixture needs rules.migrate`);
274
+ }
275
+ const migrated = rules.migrate(roundTrip(fixture.state), fixture.version);
276
+ rules.view(migrated, null);
277
+ rules.toAct(migrated);
278
+ }
279
+ }
280
+ return { games, moves, longestGame, outOfTurn };
281
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `@diffenderfer-games/hub/turns-rules`: what an online turn game's rules
3
+ * module imports (docs/multiplayer.md §16): `defineTurnRules` to type the
4
+ * rules object, the rules contract's types, and the seeded RNG the hub
5
+ * hands the rules. Nothing else comes with it (no zod, no client), so a
6
+ * rules bundle that imports it stays a few KB. The conformance kit for
7
+ * tests is `@diffenderfer-games/hub/turns`.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ export { createTurnRng, defineTurnRules, turnRngStateFromSeed } from '@diffenderfer-games/hub-contract/turns-rules';
12
+ export type { TurnApplyResult, TurnOutcome, TurnProgress, TurnRng, TurnRngState, TurnRules, TurnSetupContext } from '@diffenderfer-games/hub-contract/turns-rules';
package/src/turns.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `@diffenderfer-games/hub/turns`: writing and testing an online turn
3
+ * game's rules module (docs/multiplayer.md "Turn games"). The types of the
4
+ * rules contract, `defineTurnRules` to type a rules object, the seeded RNG
5
+ * the hub hands the rules (to replay a game in tests), and the conformance
6
+ * kit, `checkTurnRules`, for the game's own CI. The page-side API is
7
+ * `hub.turns` on the main entry. A rules module itself imports
8
+ * `@diffenderfer-games/hub/turns-rules` (the same `defineTurnRules` and
9
+ * types, nothing else), so its bundle stays a few KB.
10
+ *
11
+ * @packageDocumentation
12
+ */
13
+ export * from './turns-rules';
14
+ export { checkTurnRules, type TurnRulesCheckOptions, type TurnRulesReport } from './turns-kit/check-rules';