@diffenderfer-games/hub 0.0.0-stage → 2.0.0-next.0

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 (162) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +138 -2
  3. package/dist/auto.d.ts +36 -0
  4. package/dist/auto.d.ts.map +1 -0
  5. package/dist/auto.js +27 -0
  6. package/dist/auto.js.map +1 -0
  7. package/dist/daily.d.ts +2 -0
  8. package/dist/daily.js +1 -0
  9. package/dist/doctor/cli.mjs +238 -0
  10. package/dist/doctor/cli.mjs.map +1 -0
  11. package/dist/index.d.ts +12 -0
  12. package/dist/index.js +6 -0
  13. package/dist/input.d.ts +2 -0
  14. package/dist/input.js +3 -0
  15. package/dist/mp.d.ts +4 -0
  16. package/dist/mp.js +1 -0
  17. package/dist/race.d.ts +4 -0
  18. package/dist/race.js +1 -0
  19. package/dist/rooms.d.ts +4 -0
  20. package/dist/rooms.js +1 -0
  21. package/dist/shared/bridge.js +209 -0
  22. package/dist/shared/bridge.js.map +1 -0
  23. package/dist/shared/client-version.gen.js +7 -0
  24. package/dist/shared/client-version.gen.js.map +1 -0
  25. package/dist/shared/daily.d.ts +123 -0
  26. package/dist/shared/daily.d.ts.map +1 -0
  27. package/dist/shared/daily.js +191 -0
  28. package/dist/shared/daily.js.map +1 -0
  29. package/dist/shared/dom.js +48 -0
  30. package/dist/shared/dom.js.map +1 -0
  31. package/dist/shared/globals.js +97 -0
  32. package/dist/shared/globals.js.map +1 -0
  33. package/dist/shared/index.d.ts +706 -0
  34. package/dist/shared/index.d.ts.map +1 -0
  35. package/dist/shared/input.d.ts +730 -0
  36. package/dist/shared/input.d.ts.map +1 -0
  37. package/dist/shared/input.js +2718 -0
  38. package/dist/shared/input.js.map +1 -0
  39. package/dist/shared/latest-state.js +17 -0
  40. package/dist/shared/latest-state.js.map +1 -0
  41. package/dist/shared/mp.d.ts +9 -0
  42. package/dist/shared/mp.d.ts.map +1 -0
  43. package/dist/shared/mp.js +30 -0
  44. package/dist/shared/mp.js.map +1 -0
  45. package/dist/shared/multiplayer-types.d.ts +47 -0
  46. package/dist/shared/multiplayer-types.d.ts.map +1 -0
  47. package/dist/shared/page-stack.js +207 -0
  48. package/dist/shared/page-stack.js.map +1 -0
  49. package/dist/shared/race-types.d.ts +126 -0
  50. package/dist/shared/race-types.d.ts.map +1 -0
  51. package/dist/shared/race.d.ts +9 -0
  52. package/dist/shared/race.d.ts.map +1 -0
  53. package/dist/shared/race.js +53 -0
  54. package/dist/shared/race.js.map +1 -0
  55. package/dist/shared/room-types.d.ts +125 -0
  56. package/dist/shared/room-types.d.ts.map +1 -0
  57. package/dist/shared/rooms.d.ts +9 -0
  58. package/dist/shared/rooms.d.ts.map +1 -0
  59. package/dist/shared/rooms.js +62 -0
  60. package/dist/shared/rooms.js.map +1 -0
  61. package/dist/shared/runtime-opener.js +84 -0
  62. package/dist/shared/runtime-opener.js.map +1 -0
  63. package/dist/shared/social.d.ts +148 -0
  64. package/dist/shared/social.d.ts.map +1 -0
  65. package/dist/shared/social.js +73 -0
  66. package/dist/shared/social.js.map +1 -0
  67. package/dist/shared/src.js +2164 -0
  68. package/dist/shared/src.js.map +1 -0
  69. package/dist/shared/wire-types.d.ts +51 -0
  70. package/dist/shared/wire-types.d.ts.map +1 -0
  71. package/dist/social.d.ts +6 -0
  72. package/dist/social.js +3 -0
  73. package/dist/types.d.ts +1 -0
  74. package/dist/types.js +0 -0
  75. package/package.json +87 -4
  76. package/src/auto.ts +39 -0
  77. package/src/core/connection.ts +299 -0
  78. package/src/core/errors.ts +147 -0
  79. package/src/core/game-data.ts +38 -0
  80. package/src/core/hub-economy.ts +179 -0
  81. package/src/core/hub-progress.ts +205 -0
  82. package/src/core/hub.ts +131 -0
  83. package/src/core/menu-control.ts +61 -0
  84. package/src/core/offline/events.ts +50 -0
  85. package/src/core/offline/favorites.ts +21 -0
  86. package/src/core/offline/local-store.ts +261 -0
  87. package/src/core/offline/queue.ts +171 -0
  88. package/src/core/page.ts +74 -0
  89. package/src/core/rest/endpoint-types.ts +22 -0
  90. package/src/core/rest/rest-client.ts +152 -0
  91. package/src/core/rest/routes.gen.ts +90 -0
  92. package/src/core/storage/json-guards.ts +74 -0
  93. package/src/core/storage/key-value.ts +66 -0
  94. package/src/core/ticket.ts +43 -0
  95. package/src/core/types.ts +134 -0
  96. package/src/core/version/client-version.gen.ts +3 -0
  97. package/src/core/version/hub-version.ts +62 -0
  98. package/src/core/version/semver.ts +97 -0
  99. package/src/daily/daily-system.ts +161 -0
  100. package/src/daily/page-daily.ts +36 -0
  101. package/src/daily/types.ts +67 -0
  102. package/src/daily.ts +10 -0
  103. package/src/doctor/checks.ts +76 -0
  104. package/src/doctor/cli.ts +106 -0
  105. package/src/doctor/tsconfig.json +12 -0
  106. package/src/globals.ts +110 -0
  107. package/src/index.ts +50 -0
  108. package/src/input/device-state.ts +146 -0
  109. package/src/input/edges.ts +55 -0
  110. package/src/input/gamepad.ts +61 -0
  111. package/src/input/input-events.ts +32 -0
  112. package/src/input/input-registry.ts +164 -0
  113. package/src/input/input-system.ts +393 -0
  114. package/src/input/legacy-controls.ts +64 -0
  115. package/src/input/nav-state.ts +191 -0
  116. package/src/input/navigator.ts +171 -0
  117. package/src/input/on-screen-controls.ts +101 -0
  118. package/src/input/page-control.ts +43 -0
  119. package/src/input/page-input.ts +193 -0
  120. package/src/input/player-remaps.ts +77 -0
  121. package/src/input/source-values.ts +116 -0
  122. package/src/input/spatial.ts +47 -0
  123. package/src/input/types.ts +331 -0
  124. package/src/input/zero-config.ts +177 -0
  125. package/src/input.ts +22 -0
  126. package/src/mp.ts +10 -0
  127. package/src/overlay/controls-panel.ts +163 -0
  128. package/src/overlay/dom.ts +46 -0
  129. package/src/overlay/layout-clusters.ts +162 -0
  130. package/src/overlay/layout-geometry.ts +136 -0
  131. package/src/overlay/layout.ts +203 -0
  132. package/src/overlay/listener-scope.ts +23 -0
  133. package/src/overlay/on-screen-keyboard.ts +137 -0
  134. package/src/overlay/overlay.ts +208 -0
  135. package/src/overlay/styles.ts +54 -0
  136. package/src/overlay/virtual-controls.ts +262 -0
  137. package/src/race.ts +10 -0
  138. package/src/realtime/bridge.ts +121 -0
  139. package/src/realtime/cross-bundle.ts +29 -0
  140. package/src/realtime/fallback-notice.ts +48 -0
  141. package/src/realtime/hub-link.ts +65 -0
  142. package/src/realtime/latest-state.ts +33 -0
  143. package/src/realtime/multiplayer-forwarder.ts +28 -0
  144. package/src/realtime/multiplayer-types.ts +52 -0
  145. package/src/realtime/page-runtime.ts +60 -0
  146. package/src/realtime/race-forwarder.ts +59 -0
  147. package/src/realtime/race-types.ts +132 -0
  148. package/src/realtime/room-types.ts +125 -0
  149. package/src/realtime/rooms-forwarder.ts +79 -0
  150. package/src/realtime/runtime-api.ts +49 -0
  151. package/src/realtime/runtime-opener.ts +61 -0
  152. package/src/realtime/social-forwarder.ts +72 -0
  153. package/src/realtime/social-types.ts +78 -0
  154. package/src/realtime/types.ts +53 -0
  155. package/src/realtime/wire-types.ts +72 -0
  156. package/src/rooms.ts +10 -0
  157. package/src/social.ts +18 -0
  158. package/src/types.ts +13 -0
  159. package/src/uistack/page-stack.ts +45 -0
  160. package/src/uistack/play-suspension.ts +82 -0
  161. package/src/uistack/types.ts +76 -0
  162. package/src/uistack/ui-stack.ts +144 -0
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Friends, presence and notification shapes as games see them through
3
+ * `hub.social`, `hub.presence` and `hub.notify`.
4
+ */
5
+ import type { ChatMode, FriendEntry, Invite, Party, Presence, PresenceInput } from '@diffenderfer-games/hub-contract';
6
+
7
+ /** Online counts as a game sees them. */
8
+ export interface SocialCounts {
9
+ /** Everyone online on the site. */
10
+ online: number;
11
+ /** Players in the current game (0 on the catalog page). */
12
+ inGame: number;
13
+ /** My friends currently online. */
14
+ friendsOnline: number;
15
+ }
16
+
17
+ /** Events `hub.social.on` delivers, name → payload. */
18
+ export interface SocialEvents {
19
+ /** The friends list changed. */
20
+ friends: FriendEntry[];
21
+ /** A friend's presence changed (`null` when they went offline). */
22
+ presence: { userId: number; p: Presence | null };
23
+ /** The online counts changed. */
24
+ counts: SocialCounts;
25
+ /** An invite arrived. */
26
+ invite: Invite;
27
+ /** My party changed (`null` when I left it). */
28
+ party: Party | null;
29
+ }
30
+
31
+ /** Who to open a player card for. */
32
+ export type PlayerRef = { userId: number } | { username: string };
33
+
34
+ /** Options for the invite picker. */
35
+ export interface OpenInviteOptions {
36
+ /** Preselect this friend. */
37
+ userId?: number;
38
+ /** The game mode to invite to. */
39
+ mode?: string;
40
+ }
41
+
42
+ /** `hub.presence`: what you're doing, shown to friends (privacy is automatic). */
43
+ export interface HubPresenceApi {
44
+ /** Report an activity, e.g. `{ kind: 'playing', detail: 'Level 3' }`. Coalesced; safe to call often. */
45
+ set(presence: PresenceInput): void;
46
+ /** Back to "in the menu". */
47
+ clear(): void;
48
+ }
49
+
50
+ /** `hub.social`: friends and the hub's social dialogs. */
51
+ export interface HubSocialApi {
52
+ /** Open a player card (add friend, message, invite, block, report). */
53
+ openPlayer(ref: PlayerRef): void;
54
+ /** Open the hub menu on the Friends section. */
55
+ openFriends(): void;
56
+ /** Open the chat with a friend. */
57
+ openChat(userId: number): void;
58
+ /** Open the invite picker (current game by default). */
59
+ openInvite(options?: OpenInviteOptions): void;
60
+ /** Open the game-suggestion form and board. */
61
+ openSuggest(): void;
62
+ /** Open notification settings, optionally focused on a game's lobby bell. */
63
+ openNotify(options?: { game?: string }): void;
64
+ /** Friends with live presence. Rejects with `OfflineError` without the runtime. */
65
+ friends(): Promise<FriendEntry[]>;
66
+ /** Live counts; all zero until the runtime is up. */
67
+ counts(): SocialCounts;
68
+ /** The effective chat mode (with these players); `'off'` until the runtime is up. */
69
+ chatMode(userIds?: number[]): ChatMode;
70
+ /** Subscribe to social events; works before the runtime loads. Returns the unsubscribe function. */
71
+ on<Name extends keyof SocialEvents>(event: Name, listener: (payload: SocialEvents[Name]) => void): () => void;
72
+ }
73
+
74
+ /** `hub.notify`: toast behaviour during intense play. */
75
+ export interface HubNotifyApi {
76
+ /** Quiet: toasts collapse into the menu badge; invites still show as a small pill. */
77
+ setQuiet(isQuiet: boolean): void;
78
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Every public realtime type in one place: the realtime API the social
3
+ * runtime implements, the shapes `hub.social`, `hub.mp`, `hub.rooms` and
4
+ * `hub.race` use, and the contract entities they carry. Types only.
5
+ */
6
+ export type {
7
+ Audience,
8
+ ChatMode,
9
+ Conversation,
10
+ Counts,
11
+ ErrorBody,
12
+ FriendEntry,
13
+ Invite,
14
+ JoinInfo,
15
+ Launch,
16
+ LaunchTicket,
17
+ Message,
18
+ MessagesPage,
19
+ NotifyChange,
20
+ NotifyPrefs,
21
+ Party,
22
+ PlayerCard,
23
+ PlayerLite,
24
+ Presence,
25
+ PresenceInput,
26
+ PresenceKind,
27
+ RaceOutcome,
28
+ RaceParams,
29
+ RaceRecord,
30
+ RaceResult,
31
+ RaceState,
32
+ RaceStats,
33
+ RaceStatValue,
34
+ Racer,
35
+ RacerResult,
36
+ RacerState,
37
+ RecentPlayer,
38
+ Relation,
39
+ ReportInput,
40
+ ReportKind,
41
+ ReportReason,
42
+ RoomInfo,
43
+ SocialConfig,
44
+ SocialMe,
45
+ Suggestion,
46
+ SuggestionStatus,
47
+ } from '@diffenderfer-games/hub-contract';
48
+ export type * from './wire-types';
49
+ export type * from './social-types';
50
+ export type * from './multiplayer-types';
51
+ export type * from './room-types';
52
+ export type * from './race-types';
53
+ export type { RealtimeApi } from './runtime-api';
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The `/_ws` wire shapes the realtime runtime and the forwarders share,
3
+ * derived from the hub contract so they can never drift from what the server
4
+ * sends. Types only: nothing here costs a byte at runtime.
5
+ */
6
+ import type {
7
+ EventData,
8
+ events,
9
+ OpAck,
10
+ ops,
11
+ InviteCreated as InviteCreatedSchema,
12
+ RaceHistory as RaceHistorySchema,
13
+ RaceResultPlayer as RaceResultPlayerSchema,
14
+ RaceRecordPlayer as RaceRecordPlayerSchema,
15
+ RoomSeat as RoomSeatSchema,
16
+ } from '@diffenderfer-games/hub-contract';
17
+
18
+ /** The parsed (output) type of a contract schema, without importing zod. */
19
+ export type SchemaOutput<Schema> = Schema extends { readonly _zod: { readonly output: infer Output } } ? Output : never;
20
+
21
+ /** Every server event, name → payload (the contract's `events`). */
22
+ export type ServerEvents = { [Name in keyof typeof events]: EventData<(typeof events)[Name]> };
23
+
24
+ /** The `hello` ack: everything the Friends drawer needs in one round-trip. */
25
+ export type Snapshot = OpAck<(typeof ops)['hello']>;
26
+
27
+ /** A generic server notice rendered as a toast (`ev:notice`). */
28
+ export type Notice = ServerEvents['notice'];
29
+
30
+ /** A seat in a hub room. */
31
+ export type RoomSeat = SchemaOutput<typeof RoomSeatSchema>;
32
+
33
+ /** One racer's line in a decided race. */
34
+ export type RaceResultPlayer = SchemaOutput<typeof RaceResultPlayerSchema>;
35
+
36
+ /** A racer in a history record. Suspended opponents are `hidden` (id 0, no name). */
37
+ export type RaceRecordPlayer = SchemaOutput<typeof RaceRecordPlayerSchema>;
38
+
39
+ /** A page of the player's races, newest first (`GET /race/history`). */
40
+ export type RaceHistory = SchemaOutput<typeof RaceHistorySchema>;
41
+
42
+ /** `POST /mp/invites`: the invite plus the inviter's own launch. */
43
+ export type InviteCreated = SchemaOutput<typeof InviteCreatedSchema>;
44
+
45
+ /** A live status line from another racer (`ev:race.status`); `at` is ms since the go on the server clock. */
46
+ export type RaceStatusEvent = ServerEvents['race.status'];
47
+
48
+ /** A relayed game message (`ev:room.msg`). */
49
+ export type RoomMessage = ServerEvents['room.msg'];
50
+
51
+ /** A shared-state change (`ev:room.state`); patches are shallow-merged unless `replace`. */
52
+ export type RoomStateChange = ServerEvents['room.state'];
53
+
54
+ /** A moderated room chat line (`ev:room.chat`). */
55
+ export type RoomChatLine = ServerEvents['room.chat'];
56
+
57
+ /**
58
+ * What a rejected realtime call throws. The runtime is another bundle, so its
59
+ * errors are re-created as the game's own `HubError` classes by the forwarders.
60
+ */
61
+ export interface RealtimeErrorShape extends Error {
62
+ /** HTTP status (REST), absent for transport and socket errors. */
63
+ status?: number;
64
+ /** The machine-readable code from the error body. */
65
+ code?: string;
66
+ /** Why content was rejected. */
67
+ reason?: string;
68
+ /** A player-facing hint. */
69
+ hint?: string;
70
+ /** How long to wait before retrying, when rate limited. */
71
+ retryAfterMs?: number;
72
+ }
package/src/rooms.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `@diffenderfer-games/hub/rooms`: the hub relay rooms forwarder (`hub.rooms`),
3
+ * for games that want only this namespace.
4
+ *
5
+ * @packageDocumentation
6
+ */
7
+ export { roomsForwarder as rooms } from './realtime/rooms-forwarder';
8
+ export type * from './realtime/room-types';
9
+ export type { RoomChatLine, RoomMessage, RoomSeat, RoomStateChange } from './realtime/wire-types';
10
+ export type { RoomInfo } from '@diffenderfer-games/hub-contract';
package/src/social.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `@diffenderfer-games/hub/social`: the social forwarders (`hub.social`,
3
+ * `hub.presence`, `hub.notify`), and every realtime type: the API the hub's
4
+ * social runtime implements and the shapes it carries.
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+ export { notifyForwarder as notify, presenceForwarder as presence, socialForwarder as social } from './realtime/social-forwarder';
9
+ export { linkRealtimeToHub } from './realtime/hub-link';
10
+ export type { RealtimeHubLink } from './realtime/hub-link';
11
+ export { findRealtimeApi, isRealtimeApi } from './realtime/page-runtime';
12
+ export {
13
+ REALTIME_FAILED_EVENT,
14
+ REALTIME_FEATURE_VERSION,
15
+ REALTIME_READY_EVENT,
16
+ REALTIME_SINGLETON_API,
17
+ } from './realtime/runtime-api';
18
+ export type * from './realtime/types';
package/src/types.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `@diffenderfer-games/hub/types`: a type-only re-export of every contract
3
+ * type a game may use (request and reply shapes, entities, error bodies and
4
+ * codes, socket ops and events, the `game.*` manifest), so a game can name a
5
+ * hub type without depending on the contract package itself. Importing it
6
+ * adds nothing to a bundle.
7
+ *
8
+ * The client's own types (`User`, `Save<T>`, `HubStatus`, ...) come from the
9
+ * main entry.
10
+ *
11
+ * @packageDocumentation
12
+ */
13
+ export type * from '@diffenderfer-games/hub-contract';
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The overlay stack is one per page, on `window.__HUB_UI__`, because several
3
+ * bundles share it: the host's `/_hub/hub.js`, a game's bundled client and
4
+ * the social runtime. It has its own handover rule (older than plan §4.4's
5
+ * registry, and kept for v1 copies): a newer implementation replaces an older
6
+ * one only while it is idle, adopting its listeners, so open entries are
7
+ * never orphaned.
8
+ */
9
+ import { hasMethods, readField } from '../realtime/cross-bundle';
10
+ import type { UiStack } from './types';
11
+ import { UI_STACK_VERSION, UiStackImpl } from './ui-stack';
12
+
13
+ declare global {
14
+ interface Window {
15
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: set by any bundle on the page, including v1 copies
16
+ __HUB_UI__?: unknown;
17
+ }
18
+ }
19
+
20
+ /** Whether `candidate` is some bundle's overlay stack. */
21
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: the stack may come from another bundle
22
+ export function isUiStack(candidate: unknown): candidate is UiStack {
23
+ return typeof readField(candidate, 'version') === 'number' && hasMethods(candidate, ['push', 'on']);
24
+ }
25
+
26
+ let pagelessStack: UiStack | null = null;
27
+
28
+ /**
29
+ * The page-wide overlay stack, created on first use. Outside a browser a
30
+ * module-local instance is returned.
31
+ */
32
+ export function getUiStack(): UiStack {
33
+ if (typeof window === 'undefined') {
34
+ pagelessStack ??= new UiStackImpl();
35
+ return pagelessStack;
36
+ }
37
+ const existing = window.__HUB_UI__;
38
+ if (isUiStack(existing) && (existing.version >= UI_STACK_VERSION || existing.isOpen)) {
39
+ return existing;
40
+ }
41
+ const handoff = isUiStack(existing) && hasMethods(existing, ['handoff']) ? existing.handoff() : undefined;
42
+ const stack = new UiStackImpl(handoff);
43
+ window.__HUB_UI__ = stack;
44
+ return stack;
45
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * While hub UI covers the game, its `play` input group is held disabled (so a
3
+ * paddle doesn't move while the player types), even for games that ignore
4
+ * the overlay events. The game's own enable/disable calls for `play` are
5
+ * recorded instead of applied, and the last one wins on release, so a game
6
+ * that pauses itself on `open` stays paused afterwards.
7
+ */
8
+ import { hasMethods } from '../realtime/cross-bundle';
9
+
10
+ /** The input group the stack suspends. */
11
+ export const PLAY_GROUP = 'play';
12
+
13
+ /** The slice of the page's input system the stack needs (any bundle's). */
14
+ export interface GroupSwitch {
15
+ /** Whether a group is on. */
16
+ isEnabled(group: string): boolean;
17
+ /** Turn a group on. */
18
+ enable(group: string): void;
19
+ /** Turn a group off. */
20
+ disable(group: string): void;
21
+ }
22
+
23
+ /** Whether `candidate` can switch input groups. */
24
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: the page's input system may come from another bundle
25
+ export function isGroupSwitch(candidate: unknown): candidate is GroupSwitch {
26
+ return hasMethods(candidate, ['isEnabled', 'enable', 'disable']);
27
+ }
28
+
29
+ /** The intercepted method names. */
30
+ const SWITCH_METHODS = ['enable', 'disable'] as const;
31
+
32
+ /** Holds `play` disabled for one overlay visit. */
33
+ export class PlaySuspension {
34
+ private readonly input: GroupSwitch;
35
+ private shouldPlayAfter: boolean;
36
+ /** The own-property descriptors replaced (`undefined` = the method lived on the prototype). */
37
+ private readonly savedMethods: Record<(typeof SWITCH_METHODS)[number], PropertyDescriptor | undefined>;
38
+
39
+ constructor(input: GroupSwitch) {
40
+ this.input = input;
41
+ this.shouldPlayAfter = input.isEnabled(PLAY_GROUP);
42
+ if (this.shouldPlayAfter) {
43
+ input.disable(PLAY_GROUP);
44
+ }
45
+ this.savedMethods = {
46
+ enable: Object.getOwnPropertyDescriptor(input, 'enable'),
47
+ disable: Object.getOwnPropertyDescriptor(input, 'disable'),
48
+ };
49
+ const enableGroup = input.enable.bind(input);
50
+ const disableGroup = input.disable.bind(input);
51
+ // Own-property overrides shadow the real methods until release().
52
+ input.enable = (group: string): void => {
53
+ if (group === PLAY_GROUP) {
54
+ this.shouldPlayAfter = true;
55
+ } else {
56
+ enableGroup(group);
57
+ }
58
+ };
59
+ input.disable = (group: string): void => {
60
+ if (group === PLAY_GROUP) {
61
+ this.shouldPlayAfter = false;
62
+ } else {
63
+ disableGroup(group);
64
+ }
65
+ };
66
+ }
67
+
68
+ /** Remove the interception and apply the state the game last asked for. */
69
+ release(): void {
70
+ for (const method of SWITCH_METHODS) {
71
+ const saved = this.savedMethods[method];
72
+ if (saved === undefined) {
73
+ Reflect.deleteProperty(this.input, method);
74
+ } else {
75
+ Object.defineProperty(this.input, method, saved);
76
+ }
77
+ }
78
+ if (this.shouldPlayAfter) {
79
+ this.input.enable(PLAY_GROUP);
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The overlay stack's public shapes: "is the player looking at hub UI right
3
+ * now?" (`hub.overlay`, docs/multiplayer.md §5.7).
4
+ */
5
+
6
+ /** Why the overlay opened, so a game can word its pause screen. */
7
+ export type OverlayReason = 'menu' | 'chat' | 'dialog' | 'keyboard' | 'invite' | 'notify';
8
+
9
+ /** An overlay edge. */
10
+ export type OverlayEdge = 'open' | 'close';
11
+
12
+ /** Payload of `open` and `close` (and of the `hub:overlay-open` / `hub:overlay-close` window events). */
13
+ export interface OverlayEvent {
14
+ /** The reason of the entry that opened (for `open`) or closed last (for `close`). */
15
+ reason: OverlayReason;
16
+ /**
17
+ * False during a live online match (`hub.mp.setBusy(true)`): the game must
18
+ * keep simulating, so it should not pause.
19
+ */
20
+ canPause: boolean;
21
+ }
22
+
23
+ /** Options for {@link UiStack.autoPause}. */
24
+ export interface AutoPauseOptions {
25
+ /** Pause exactly the way the game's own pause does. */
26
+ pause(): void;
27
+ /** Resume play. Only called when `resumeOnClose` is true. */
28
+ resume?(): void;
29
+ /**
30
+ * Resume automatically when the hub UI closes. Default false: action games
31
+ * should show their pause screen so the player resumes deliberately;
32
+ * turn-based and idle games may pass true.
33
+ */
34
+ resumeOnClose?: boolean;
35
+ }
36
+
37
+ /** A listener for overlay edges. */
38
+ export type OverlayListener = (event: OverlayEvent) => void;
39
+
40
+ /** What an older, idle stack hands to a newer one replacing it. */
41
+ export interface UiStackHandoff {
42
+ /** Its listeners, by edge. */
43
+ listeners: { open: OverlayListener[]; close: OverlayListener[] };
44
+ /** Its pause permission. */
45
+ canPause: boolean;
46
+ }
47
+
48
+ /**
49
+ * The page-wide overlay stack. Every piece of hub UI that covers the game
50
+ * (the menu drawer, a dialog, the chat sheet…) pushes an entry while it shows
51
+ * and pops it when it goes away; the game sees one `open` / `close` pair for
52
+ * the whole visit, however the UI nests.
53
+ */
54
+ export interface UiStack {
55
+ /** Implementation version; a newer bundle replaces an older, idle instance. */
56
+ readonly version: number;
57
+ /** Register a covering piece of UI. Returns its idempotent `pop()`. */
58
+ push(reason: OverlayReason): () => void;
59
+ /** True while any hub UI covers the game. */
60
+ readonly isOpen: boolean;
61
+ /** Number of open entries (nesting depth). */
62
+ readonly depth: number;
63
+ /** Whether the game may pause right now (false during an online match). */
64
+ readonly canPause: boolean;
65
+ /** Subscribe to an edge. Returns the unsubscribe function. */
66
+ on(edge: OverlayEdge, listener: OverlayListener): () => void;
67
+ /**
68
+ * One-line adoption for games: pause on open (unless `canPause` is false),
69
+ * optionally resume on close. Returns a function that detaches it.
70
+ */
71
+ autoPause(options: AutoPauseOptions): () => void;
72
+ /** Driven by `hub.mp.setBusy`: false while a live match can't pause. */
73
+ setCanPause(canPause: boolean): void;
74
+ /** Hand listeners to a newer version replacing this (idle) instance. */
75
+ handoff(): UiStackHandoff;
76
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The overlay stack implementation: two edges (`open` when depth goes
3
+ * 0 → 1, `close` when it returns to 0) mirrored as window events, the
4
+ * `autoPause` helper, and the `play` suspension.
5
+ */
6
+ import { LEGACY_GLOBAL_NAMES } from '../globals';
7
+ import { isGroupSwitch, PlaySuspension } from './play-suspension';
8
+ import type { AutoPauseOptions, OverlayEdge, OverlayEvent, OverlayListener, OverlayReason, UiStack, UiStackHandoff } from './types';
9
+
10
+ /** Bump when the UiStack contract gains behaviour; idle older instances are replaced. */
11
+ export const UI_STACK_VERSION = 1;
12
+
13
+ /** One open entry (its identity is what `pop` removes). */
14
+ interface StackEntry {
15
+ readonly reason: OverlayReason;
16
+ }
17
+
18
+ /** The page-wide overlay stack (see {@link UiStack}). */
19
+ export class UiStackImpl implements UiStack {
20
+ readonly version = UI_STACK_VERSION;
21
+ private readonly entries: StackEntry[] = [];
22
+ private readonly listeners: Record<OverlayEdge, Set<OverlayListener>> = { open: new Set(), close: new Set() };
23
+ private isPausable = true;
24
+ private suspension: PlaySuspension | null = null;
25
+
26
+ constructor(handoff?: UiStackHandoff) {
27
+ if (handoff === undefined) {
28
+ return;
29
+ }
30
+ handoff.listeners.open.forEach((listener) => this.listeners.open.add(listener));
31
+ handoff.listeners.close.forEach((listener) => this.listeners.close.add(listener));
32
+ this.isPausable = handoff.canPause;
33
+ }
34
+
35
+ get isOpen(): boolean {
36
+ return this.entries.length > 0;
37
+ }
38
+
39
+ get depth(): number {
40
+ return this.entries.length;
41
+ }
42
+
43
+ get canPause(): boolean {
44
+ return this.isPausable;
45
+ }
46
+
47
+ push(reason: OverlayReason): () => void {
48
+ const entry: StackEntry = { reason };
49
+ this.entries.push(entry);
50
+ if (this.entries.length === 1) {
51
+ this.suspendPlay();
52
+ this.emit('open', { reason, canPause: this.isPausable });
53
+ }
54
+ let wasPopped = false;
55
+ return () => {
56
+ if (wasPopped) {
57
+ return;
58
+ }
59
+ wasPopped = true;
60
+ const index = this.entries.indexOf(entry);
61
+ if (index < 0) {
62
+ return;
63
+ }
64
+ this.entries.splice(index, 1);
65
+ if (this.entries.length === 0) {
66
+ // Listeners run before `play` is restored, so a game that pauses in its close handler is honoured.
67
+ this.emit('close', { reason, canPause: this.isPausable });
68
+ this.suspension?.release();
69
+ this.suspension = null;
70
+ }
71
+ };
72
+ }
73
+
74
+ on(edge: OverlayEdge, listener: OverlayListener): () => void {
75
+ this.listeners[edge].add(listener);
76
+ return () => {
77
+ this.listeners[edge].delete(listener);
78
+ };
79
+ }
80
+
81
+ autoPause(options: AutoPauseOptions): () => void {
82
+ let wasPausedByUs = false;
83
+ const pauseOnOpen = (event: OverlayEvent): void => {
84
+ if (!event.canPause) {
85
+ return;
86
+ }
87
+ wasPausedByUs = true;
88
+ options.pause();
89
+ };
90
+ const resumeOnClose = (): void => {
91
+ if (wasPausedByUs && options.resumeOnClose === true) {
92
+ options.resume?.();
93
+ }
94
+ wasPausedByUs = false;
95
+ };
96
+ const stopOpen = this.on('open', pauseOnOpen);
97
+ const stopClose = this.on('close', resumeOnClose);
98
+ // Registered while hub UI is already up: pause now, as if it just opened.
99
+ const firstEntry = this.entries[0];
100
+ if (firstEntry !== undefined) {
101
+ pauseOnOpen({ reason: firstEntry.reason, canPause: this.isPausable });
102
+ }
103
+ return () => {
104
+ stopOpen();
105
+ stopClose();
106
+ };
107
+ }
108
+
109
+ setCanPause(canPause: boolean): void {
110
+ this.isPausable = canPause;
111
+ }
112
+
113
+ handoff(): UiStackHandoff {
114
+ const handoff: UiStackHandoff = {
115
+ listeners: { open: [...this.listeners.open], close: [...this.listeners.close] },
116
+ canPause: this.isPausable,
117
+ };
118
+ this.listeners.open.clear();
119
+ this.listeners.close.clear();
120
+ return handoff;
121
+ }
122
+
123
+ private suspendPlay(): void {
124
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: the page input system may be another bundle's
125
+ const input: unknown = typeof window === 'undefined' ? undefined : window[LEGACY_GLOBAL_NAMES.input];
126
+ if (this.suspension === null && isGroupSwitch(input)) {
127
+ this.suspension = new PlaySuspension(input);
128
+ }
129
+ }
130
+
131
+ private emit(edge: OverlayEdge, event: OverlayEvent): void {
132
+ for (const listener of [...this.listeners[edge]]) {
133
+ try {
134
+ listener(event);
135
+ } catch (error) {
136
+ // eslint-disable-next-line no-console -- a game's listener failing must not break the hub UI; surface it
137
+ console.error('[hub] overlay listener failed', error);
138
+ }
139
+ }
140
+ if (typeof window !== 'undefined') {
141
+ window.dispatchEvent(new CustomEvent(`hub:overlay-${edge}`, { detail: event }));
142
+ }
143
+ }
144
+ }