@diffenderfer-games/hub-contract 2.0.0-next.0 → 2.0.0-next.2

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 (81) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -21
  3. package/README.md +26 -26
  4. package/dist/index.d.ts +2683 -39
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +862 -18
  7. package/dist/index.js.map +1 -1
  8. package/package.json +53 -53
  9. package/src/api/analytics.ts +81 -81
  10. package/src/api/auth.ts +77 -77
  11. package/src/api/catalog-activity.ts +66 -0
  12. package/src/api/catalog.ts +177 -171
  13. package/src/api/daily.ts +122 -122
  14. package/src/api/economy/bundle.ts +69 -69
  15. package/src/api/index.ts +92 -85
  16. package/src/api/leaderboards.ts +151 -151
  17. package/src/api/messages.ts +142 -142
  18. package/src/api/mp/chat.ts +36 -36
  19. package/src/api/mp/invite-links.ts +42 -0
  20. package/src/api/mp/parties.ts +5 -0
  21. package/src/api/mp/races.ts +211 -211
  22. package/src/api/mp/rooms.ts +122 -90
  23. package/src/api/mp/snapshot.ts +17 -17
  24. package/src/api/mp.ts +135 -110
  25. package/src/api/notify.ts +114 -111
  26. package/src/api/offline.ts +45 -0
  27. package/src/api/progress/game-data.ts +189 -189
  28. package/src/api/progress.ts +59 -59
  29. package/src/api/race.ts +47 -47
  30. package/src/api/reports.ts +208 -208
  31. package/src/api/social/config.ts +47 -47
  32. package/src/api/social/endpoints.ts +179 -179
  33. package/src/api/social/people.ts +118 -118
  34. package/src/api/social/presence.ts +95 -95
  35. package/src/api/social.ts +9 -9
  36. package/src/api/tournaments/endpoints.ts +102 -0
  37. package/src/api/tournaments/play-online.ts +71 -0
  38. package/src/api/tournaments/shapes.ts +202 -0
  39. package/src/api/tournaments.ts +7 -0
  40. package/src/api/version.ts +40 -40
  41. package/src/coerce.ts +50 -50
  42. package/src/common.ts +42 -42
  43. package/src/entities.ts +62 -62
  44. package/src/errors/codes/core.ts +10 -10
  45. package/src/errors/codes/multiplayer.ts +56 -43
  46. package/src/errors/codes/play.ts +5 -5
  47. package/src/errors/codes/social.ts +31 -31
  48. package/src/errors/index.ts +88 -88
  49. package/src/index.ts +16 -16
  50. package/src/limits.ts +125 -100
  51. package/src/logic/daily.ts +54 -54
  52. package/src/logic/index.ts +8 -8
  53. package/src/logic/presence.ts +62 -62
  54. package/src/logic/time.ts +81 -81
  55. package/src/manifest/changes.ts +55 -55
  56. package/src/manifest/controls.ts +42 -42
  57. package/src/manifest/fields.ts +89 -89
  58. package/src/manifest/game.ts +139 -132
  59. package/src/manifest/index.ts +13 -12
  60. package/src/manifest/inject.ts +31 -31
  61. package/src/manifest/labels.ts +99 -0
  62. package/src/manifest/leaderboards.ts +29 -29
  63. package/src/manifest/multiplayer.ts +79 -71
  64. package/src/manifest/race.ts +93 -93
  65. package/src/middleware.ts +47 -47
  66. package/src/realtime/define.ts +52 -52
  67. package/src/realtime/envelope.ts +67 -67
  68. package/src/realtime/events/multiplayer.ts +9 -8
  69. package/src/realtime/events/parties.ts +31 -31
  70. package/src/realtime/events/races.ts +27 -27
  71. package/src/realtime/events/rooms.ts +52 -48
  72. package/src/realtime/events/social.ts +81 -81
  73. package/src/realtime/events/tournaments.ts +12 -0
  74. package/src/realtime/index.ts +24 -24
  75. package/src/realtime/ops/empty.ts +8 -8
  76. package/src/realtime/ops/hello.ts +40 -40
  77. package/src/realtime/ops/multiplayer.ts +8 -8
  78. package/src/realtime/ops/parties.ts +13 -13
  79. package/src/realtime/ops/races.ts +99 -99
  80. package/src/realtime/ops/rooms.ts +22 -8
  81. package/src/realtime/ops/social.ts +58 -58
@@ -1,40 +1,40 @@
1
- /**
2
- * `hello`: the first op on every socket (multiplayer-impl.md §4.5). It marks
3
- * the tab live for events and presence and answers with the snapshot a
4
- * client needs to draw the Friends drawer, invites and any room it still holds.
5
- */
6
- import { z } from 'zod';
7
- import { UnreadSummary } from '../../api/messages';
8
- import { MultiplayerSnapshot } from '../../api/mp/snapshot';
9
- import { SocialConfig } from '../../api/social/config';
10
- import { FriendEntry, FriendRequests, SocialMe } from '../../api/social/people';
11
- import { Counts } from '../../api/social/presence';
12
- import { op } from '../define';
13
-
14
- /** `hello` input. Anything unrecognised is ignored rather than rejected. */
15
- export const HelloInput = z.object({
16
- /** The game this tab is in, or `null` on the home page. Unknown slugs count as `null`. */
17
- slug: z.string().nullable().optional(),
18
- /** A per-tab id (`[A-Za-z0-9_-]{1,64}`); other values are ignored. */
19
- tab: z.string().optional(),
20
- /** The document is visible (default `true`). */
21
- visible: z.boolean().optional(),
22
- /** The client package version that opened the socket (`2.3.0`); v1 vendored copies send none. */
23
- client: z.string().optional(),
24
- });
25
-
26
- /** The `hello` snapshot. Guests get no friends and empty request lists. */
27
- export const HelloSnapshot = z.object({
28
- me: SocialMe,
29
- config: SocialConfig,
30
- counts: Counts,
31
- friends: z.array(FriendEntry),
32
- requests: FriendRequests,
33
- unread: UnreadSummary,
34
- ...MultiplayerSnapshot.shape,
35
- });
36
-
37
- /** The `hello` op (allowed before `hello`, obviously). */
38
- export const helloOps = {
39
- hello: op({ d: HelloInput, ack: HelloSnapshot, errors: [], doc: 'Start the session on this socket; answers with the snapshot.' }),
40
- };
1
+ /**
2
+ * `hello`: the first op on every socket (multiplayer-impl.md §4.5). It marks
3
+ * the tab live for events and presence and answers with the snapshot a
4
+ * client needs to draw the Friends drawer, invites and any room it still holds.
5
+ */
6
+ import { z } from 'zod';
7
+ import { UnreadSummary } from '../../api/messages';
8
+ import { MultiplayerSnapshot } from '../../api/mp/snapshot';
9
+ import { SocialConfig } from '../../api/social/config';
10
+ import { FriendEntry, FriendRequests, SocialMe } from '../../api/social/people';
11
+ import { Counts } from '../../api/social/presence';
12
+ import { op } from '../define';
13
+
14
+ /** `hello` input. Anything unrecognised is ignored rather than rejected. */
15
+ export const HelloInput = z.object({
16
+ /** The game this tab is in, or `null` on the home page. Unknown slugs count as `null`. */
17
+ slug: z.string().nullable().optional(),
18
+ /** A per-tab id (`[A-Za-z0-9_-]{1,64}`); other values are ignored. */
19
+ tab: z.string().optional(),
20
+ /** The document is visible (default `true`). */
21
+ visible: z.boolean().optional(),
22
+ /** The client package version that opened the socket (`2.3.0`); v1 vendored copies send none. */
23
+ client: z.string().optional(),
24
+ });
25
+
26
+ /** The `hello` snapshot. Guests get no friends and empty request lists. */
27
+ export const HelloSnapshot = z.object({
28
+ me: SocialMe,
29
+ config: SocialConfig,
30
+ counts: Counts,
31
+ friends: z.array(FriendEntry),
32
+ requests: FriendRequests,
33
+ unread: UnreadSummary,
34
+ ...MultiplayerSnapshot.shape,
35
+ });
36
+
37
+ /** The `hello` op (allowed before `hello`, obviously). */
38
+ export const helloOps = {
39
+ hello: op({ d: HelloInput, ack: HelloSnapshot, errors: [], doc: 'Start the session on this socket; answers with the snapshot.' }),
40
+ };
@@ -1,8 +1,8 @@
1
- /** Socket ops for invites, parties, hub rooms and races. */
2
- import type { OpDef } from '../define';
3
- import { partyOps } from './parties';
4
- import { raceOps } from './races';
5
- import { roomOps } from './rooms';
6
-
7
- /** The multiplayer ops, by op name. */
8
- export const multiplayerOps = { ...partyOps, ...roomOps, ...raceOps } satisfies Record<string, OpDef>;
1
+ /** Socket ops for invites, parties, hub rooms and races. */
2
+ import type { OpDef } from '../define';
3
+ import { partyOps } from './parties';
4
+ import { raceOps } from './races';
5
+ import { roomOps } from './rooms';
6
+
7
+ /** The multiplayer ops, by op name. */
8
+ export const multiplayerOps = { ...partyOps, ...roomOps, ...raceOps } satisfies Record<string, OpDef>;
@@ -1,13 +1,13 @@
1
- /** The party socket op: party chat. Invites and party membership are REST (`/mp/*`). */
2
- import { CHAT_ERRORS, ChatLine, ChatSend } from '../../api/mp/chat';
3
- import { op, type OpDef } from '../define';
4
-
5
- /** The party ops, by op name. */
6
- export const partyOps = {
7
- 'party.chat': op({
8
- d: ChatSend,
9
- ack: ChatLine,
10
- errors: ['party_gone', ...CHAT_ERRORS],
11
- doc: "Chat to your party: free text only when every member is a mutual friend, quick-chat otherwise.",
12
- }),
13
- } satisfies Record<string, OpDef>;
1
+ /** The party socket op: party chat. Invites and party membership are REST (`/mp/*`). */
2
+ import { CHAT_ERRORS, ChatLine, ChatSend } from '../../api/mp/chat';
3
+ import { op, type OpDef } from '../define';
4
+
5
+ /** The party ops, by op name. */
6
+ export const partyOps = {
7
+ 'party.chat': op({
8
+ d: ChatSend,
9
+ ack: ChatLine,
10
+ errors: ['party_gone', ...CHAT_ERRORS],
11
+ doc: "Chat to your party: free text only when every member is a mutual friend, quick-chat otherwise.",
12
+ }),
13
+ } satisfies Record<string, OpDef>;
@@ -1,99 +1,99 @@
1
- /**
2
- * Race ops (docs/plans/race.md §4). A race is a hub room with a referee: the
3
- * countdown starts when every seat is filled, the seed is revealed with it,
4
- * and the first valid finish wins. Reports come from browsers, so the hub
5
- * measures time itself and enforces what it can (`minMs`, one result each).
6
- */
7
- import { z } from 'zod';
8
- import { Slug } from '../../common';
9
- import { OpaqueId } from '../../api/mp/parties';
10
- import { RaceParams, RaceProgress, RaceResult, RaceStats } from '../../api/mp/races';
11
- import { RoomInfo, RoomTarget } from '../../api/mp/rooms';
12
- import { op, type OpDef } from '../define';
13
- import { EmptyAck, NoInput } from './empty';
14
-
15
- /** A racer's report (status, finish, lose): stats (`null` means none) and optional 0..1 progress. */
16
- const RaceReport = z.object({
17
- stats: RaceStats.nullish(),
18
- progress: RaceProgress.nullish(),
19
- });
20
-
21
- /** The refusal a malformed report gets (old wording). */
22
- const BAD_STATS = { code: 'bad_stats', error: 'Race stats must be numbers, true/false or #rrggbb colours.' } as const;
23
-
24
- /** A race room ack. */
25
- const RoomAck = z.object({ room: RoomInfo });
26
-
27
- /** The race ops, by op name. */
28
- export const raceOps = {
29
- 'race.create': op({
30
- d: z.object({
31
- game: Slug,
32
- /** The creator's choices; undeclared keys and values fall back to the defaults. */
33
- params: RaceParams.optional(),
34
- /** Shown in Open races and can alert followers (default true). */
35
- public: z.boolean().optional(),
36
- /** Seats, within the game's `race.players` (default its min). */
37
- maxPlayers: z.number().optional(),
38
- /** The caller's party: when they lead it, its join info becomes `{ room: code }`. */
39
- partyId: OpaqueId.optional(),
40
- }),
41
- ack: RoomAck,
42
- errors: ['not_race', 'too_many_rooms'],
43
- doc: 'Open a race room with the caller seated; the countdown starts once every seat is filled.',
44
- }),
45
- 'race.join': op({
46
- d: RoomTarget,
47
- ack: RoomAck,
48
- errors: ['room_not_found', 'banned', 'in_progress', 'room_full', 'not_multiplayer', 'not_supported'],
49
- doc: 'Join (or watch) a race room, like `room.join` but only for races.',
50
- }),
51
- 'race.list': op({
52
- d: z.object({ game: Slug }),
53
- ack: z.object({ rooms: z.array(RoomInfo) }),
54
- errors: [],
55
- doc: 'Public races waiting for racers (at most 50), minus rooms with blocked players.',
56
- }),
57
- 'race.start': op({
58
- d: NoInput,
59
- ack: EmptyAck,
60
- errors: ['not_in_race', 'not_host', 'in_progress', 'not_ready'],
61
- doc: 'Creator: start the countdown early once the minimum racers are in.',
62
- }),
63
- 'race.status': op({
64
- d: RaceReport,
65
- ack: EmptyAck,
66
- errors: ['not_in_race', 'not_started', 'race_over', 'rate_limited', 'bad_stats'],
67
- invalid: BAD_STATS,
68
- doc: 'Live status for the race HUD. Numbers, booleans and #rrggbb only: never text.',
69
- }),
70
- 'race.finish': op({
71
- d: RaceReport,
72
- ack: z.object({ result: RaceResult }),
73
- errors: ['not_in_race', 'not_started', 'race_over', 'too_fast', 'bad_stats'],
74
- invalid: BAD_STATS,
75
- doc: 'Report a finish with final stats; the first valid finish wins the race.',
76
- }),
77
- 'race.lose': op({
78
- d: RaceReport,
79
- ack: EmptyAck,
80
- errors: ['not_in_race', 'not_started', 'race_over', 'bad_stats'],
81
- invalid: BAD_STATS,
82
- doc: 'Game over for this racer (out of lives, stuck…); the last racer left wins.',
83
- }),
84
- 'race.forfeit': op({
85
- d: NoInput,
86
- ack: EmptyAck,
87
- errors: ['not_in_race', 'race_over'],
88
- doc: 'Give up (leaving the room mid-race counts the same).',
89
- }),
90
- 'race.rematch': op({
91
- d: z.object({
92
- /** Creator only: params for the next round. */
93
- params: RaceParams.optional(),
94
- }),
95
- ack: EmptyAck,
96
- errors: ['not_in_race', 'in_progress'],
97
- doc: 'Ask for another round; it starts when every seated racer has asked.',
98
- }),
99
- } satisfies Record<string, OpDef>;
1
+ /**
2
+ * Race ops (docs/plans/race.md §4). A race is a hub room with a referee: the
3
+ * countdown starts when every seat is filled, the seed is revealed with it,
4
+ * and the first valid finish wins. Reports come from browsers, so the hub
5
+ * measures time itself and enforces what it can (`minMs`, one result each).
6
+ */
7
+ import { z } from 'zod';
8
+ import { Slug } from '../../common';
9
+ import { OpaqueId } from '../../api/mp/parties';
10
+ import { RaceParams, RaceProgress, RaceResult, RaceStats } from '../../api/mp/races';
11
+ import { RoomInfo, RoomTarget } from '../../api/mp/rooms';
12
+ import { op, type OpDef } from '../define';
13
+ import { EmptyAck, NoInput } from './empty';
14
+
15
+ /** A racer's report (status, finish, lose): stats (`null` means none) and optional 0..1 progress. */
16
+ const RaceReport = z.object({
17
+ stats: RaceStats.nullish(),
18
+ progress: RaceProgress.nullish(),
19
+ });
20
+
21
+ /** The refusal a malformed report gets (old wording). */
22
+ const BAD_STATS = { code: 'bad_stats', error: 'Race stats must be numbers, true/false or #rrggbb colours.' } as const;
23
+
24
+ /** A race room ack. */
25
+ const RoomAck = z.object({ room: RoomInfo });
26
+
27
+ /** The race ops, by op name. */
28
+ export const raceOps = {
29
+ 'race.create': op({
30
+ d: z.object({
31
+ game: Slug,
32
+ /** The creator's choices; undeclared keys and values fall back to the defaults. */
33
+ params: RaceParams.optional(),
34
+ /** Shown in Open races and can alert followers (default true). */
35
+ public: z.boolean().optional(),
36
+ /** Seats, within the game's `race.players` (default its min). */
37
+ maxPlayers: z.number().optional(),
38
+ /** The caller's party: when they lead it, its join info becomes `{ room: code }`. */
39
+ partyId: OpaqueId.optional(),
40
+ }),
41
+ ack: RoomAck,
42
+ errors: ['not_race', 'too_many_rooms'],
43
+ doc: 'Open a race room with the caller seated; the countdown starts once every seat is filled.',
44
+ }),
45
+ 'race.join': op({
46
+ d: RoomTarget,
47
+ ack: RoomAck,
48
+ errors: ['room_not_found', 'banned', 'in_progress', 'room_full', 'not_multiplayer', 'not_supported'],
49
+ doc: 'Join (or watch) a race room, like `room.join` but only for races.',
50
+ }),
51
+ 'race.list': op({
52
+ d: z.object({ game: Slug }),
53
+ ack: z.object({ rooms: z.array(RoomInfo) }),
54
+ errors: [],
55
+ doc: 'Public races waiting for racers (at most 50), minus rooms with blocked players.',
56
+ }),
57
+ 'race.start': op({
58
+ d: NoInput,
59
+ ack: EmptyAck,
60
+ errors: ['not_in_race', 'not_host', 'in_progress', 'not_ready'],
61
+ doc: 'Creator: start the countdown early once the minimum racers are in.',
62
+ }),
63
+ 'race.status': op({
64
+ d: RaceReport,
65
+ ack: EmptyAck,
66
+ errors: ['not_in_race', 'not_started', 'race_over', 'rate_limited', 'bad_stats'],
67
+ invalid: BAD_STATS,
68
+ doc: 'Live status for the race HUD. Numbers, booleans and #rrggbb only: never text.',
69
+ }),
70
+ 'race.finish': op({
71
+ d: RaceReport,
72
+ ack: z.object({ result: RaceResult }),
73
+ errors: ['not_in_race', 'not_started', 'race_over', 'too_fast', 'bad_stats'],
74
+ invalid: BAD_STATS,
75
+ doc: 'Report a finish with final stats; the first valid finish wins the race.',
76
+ }),
77
+ 'race.lose': op({
78
+ d: RaceReport,
79
+ ack: EmptyAck,
80
+ errors: ['not_in_race', 'not_started', 'race_over', 'bad_stats'],
81
+ invalid: BAD_STATS,
82
+ doc: 'Game over for this racer (out of lives, stuck…); the last racer left wins.',
83
+ }),
84
+ 'race.forfeit': op({
85
+ d: NoInput,
86
+ ack: EmptyAck,
87
+ errors: ['not_in_race', 'race_over'],
88
+ doc: 'Give up (leaving the room mid-race counts the same).',
89
+ }),
90
+ 'race.rematch': op({
91
+ d: z.object({
92
+ /** Creator only: params for the next round. */
93
+ params: RaceParams.optional(),
94
+ }),
95
+ ack: EmptyAck,
96
+ errors: ['not_in_race', 'in_progress'],
97
+ doc: 'Ask for another round; it starts when every seated racer has asked.',
98
+ }),
99
+ } satisfies Record<string, OpDef>;
@@ -8,13 +8,21 @@ import { z } from 'zod';
8
8
  import { Json, Slug, UserId } from '../../common';
9
9
  import { CHAT_ERRORS, ChatLine, ChatSend } from '../../api/mp/chat';
10
10
  import { GameMode, OpaqueId } from '../../api/mp/parties';
11
- import { RoomInfo, RoomMessage, RoomModel, RoomState, RoomTarget } from '../../api/mp/rooms';
11
+ import { HostMigration, RoomInfo, RoomMessage, RoomModel, RoomState, RoomTarget } from '../../api/mp/rooms';
12
12
  import { op, type OpDef } from '../define';
13
13
  import { EmptyAck, NoInput } from './empty';
14
14
 
15
15
  /** A per-room sequence number ack (`room.send`, `room.state`). */
16
16
  const SeqAck = z.object({ seq: z.number().int().positive() });
17
17
 
18
+ /** A join's ack: the room, its state and the history this member may replay. */
19
+ const RoomJoinAck = z.object({
20
+ room: RoomInfo,
21
+ state: RoomState,
22
+ /** Recent messages this member may see, oldest first. */
23
+ history: z.array(RoomMessage),
24
+ });
25
+
18
26
  /** The room ops, by op name. */
19
27
  export const roomOps = {
20
28
  'room.create': op({
@@ -27,6 +35,8 @@ export const roomOps = {
27
35
  /** Seats; clamped to the game's max (and 8). Defaults to the max. */
28
36
  maxPlayers: z.number().optional(),
29
37
  model: RoomModel.optional(),
38
+ /** When the host role moves on mid-game (default `grace`). */
39
+ hostMigration: HostMigration.optional(),
30
40
  /** The caller's party: when they lead it, its join info becomes `{ room: code }`. */
31
41
  partyId: OpaqueId.optional(),
32
42
  }),
@@ -36,15 +46,19 @@ export const roomOps = {
36
46
  }),
37
47
  'room.join': op({
38
48
  d: RoomTarget,
39
- ack: z.object({
40
- room: RoomInfo,
41
- state: RoomState,
42
- /** Recent messages this member may see, oldest first. */
43
- history: z.array(RoomMessage),
44
- }),
49
+ ack: RoomJoinAck,
45
50
  errors: ['room_not_found', 'banned', 'in_progress', 'room_full', 'not_multiplayer', 'not_supported'],
46
51
  doc: 'Take a seat (lobby only), reclaim your own seat (any phase, any tab), or watch.',
47
52
  }),
53
+ 'room.reclaim': op({
54
+ d: z.object({ game: Slug }),
55
+ ack: z.object({
56
+ /** The seat I still hold in this game, now reclaimed by this tab; `null` when there is none. */
57
+ held: RoomJoinAck.nullable(),
58
+ }),
59
+ errors: [],
60
+ doc: 'Take back the seat I still hold in this game (after a reload or from another tab); the room hears `room.seat` `back`.',
61
+ }),
48
62
  'room.leave': op({
49
63
  d: NoInput,
50
64
  ack: EmptyAck,
@@ -88,7 +102,7 @@ export const roomOps = {
88
102
  replace: z.boolean().optional(),
89
103
  }),
90
104
  ack: SeqAck,
91
- errors: ['not_in_room', 'race_room', 'not_host', 'bad_patch', 'too_large'],
105
+ errors: ['not_in_room', 'race_room', 'not_host', 'bad_patch', 'too_large'],
92
106
  invalid: { code: 'bad_patch', error: 'patch must be an object.' },
93
107
  doc: 'Update the shared state (host only in the host model), at most 32 KB.',
94
108
  }),
@@ -1,58 +1,58 @@
1
- /**
2
- * Socket ops for presence, visibility, typing and the heartbeat
3
- * (multiplayer-impl.md §4.3). `hello` is declared with the snapshot.
4
- */
5
- import { z } from 'zod';
6
- import { Millis, UserId } from '../../common';
7
- import { PresenceInput } from '../../api/social/presence';
8
- import { op, type OpDef } from '../define';
9
- import { EmptyAck, NoInput } from './empty';
10
-
11
- /**
12
- * `visibility` input: the tab's document visibility and idle state. Input
13
- * that isn't an object counts as `{}` (visible, not idle); a field that isn't a
14
- * boolean takes its default.
15
- */
16
- export const VisibilityInput = z.object({
17
- /** The document is visible (default `true`). */
18
- visible: z.boolean().optional().catch(undefined),
19
- /** The player has been idle (default `false`). Hidden or idle tabs count as `away`. */
20
- idle: z.boolean().optional().catch(undefined),
21
- }).catch({});
22
-
23
- /** The social ops, by op name. */
24
- export const socialOps = {
25
- presence: op({
26
- d: PresenceInput,
27
- ack: EmptyAck,
28
- errors: ['rate_limited'],
29
- doc: "Report this tab's activity. Rate-limited per user; a rejected `detail` is dropped silently.",
30
- }),
31
- visibility: op({
32
- d: VisibilityInput,
33
- ack: EmptyAck,
34
- errors: [],
35
- doc: "Report this tab's visibility and idle state (hidden or idle tabs count as `away`).",
36
- }),
37
- typing: op({
38
- d: z.object({
39
- /**
40
- * The friend you're typing to. Anything that isn't a friend's id is a
41
- * silent no-op; a value that isn't a user id at all parses as `0` (no one).
42
- */
43
- to: UserId.catch(0),
44
- }),
45
- ack: EmptyAck,
46
- errors: [],
47
- doc: 'Tell a friend you are typing (`ev:typing`), at most once per 2 s per pair.',
48
- }),
49
- ping: op({
50
- d: NoInput,
51
- ack: z.object({
52
- /** The server clock (epoch ms). */
53
- t: Millis,
54
- }),
55
- errors: [],
56
- doc: 'Heartbeat and clock check; allowed before `hello`.',
57
- }),
58
- } satisfies Record<string, OpDef>;
1
+ /**
2
+ * Socket ops for presence, visibility, typing and the heartbeat
3
+ * (multiplayer-impl.md §4.3). `hello` is declared with the snapshot.
4
+ */
5
+ import { z } from 'zod';
6
+ import { Millis, UserId } from '../../common';
7
+ import { PresenceInput } from '../../api/social/presence';
8
+ import { op, type OpDef } from '../define';
9
+ import { EmptyAck, NoInput } from './empty';
10
+
11
+ /**
12
+ * `visibility` input: the tab's document visibility and idle state. Input
13
+ * that isn't an object counts as `{}` (visible, not idle); a field that isn't a
14
+ * boolean takes its default.
15
+ */
16
+ export const VisibilityInput = z.object({
17
+ /** The document is visible (default `true`). */
18
+ visible: z.boolean().optional().catch(undefined),
19
+ /** The player has been idle (default `false`). Hidden or idle tabs count as `away`. */
20
+ idle: z.boolean().optional().catch(undefined),
21
+ }).catch({});
22
+
23
+ /** The social ops, by op name. */
24
+ export const socialOps = {
25
+ presence: op({
26
+ d: PresenceInput,
27
+ ack: EmptyAck,
28
+ errors: ['rate_limited'],
29
+ doc: "Report this tab's activity. Rate-limited per user; a rejected `detail` is dropped silently.",
30
+ }),
31
+ visibility: op({
32
+ d: VisibilityInput,
33
+ ack: EmptyAck,
34
+ errors: [],
35
+ doc: "Report this tab's visibility and idle state (hidden or idle tabs count as `away`).",
36
+ }),
37
+ typing: op({
38
+ d: z.object({
39
+ /**
40
+ * The friend you're typing to. Anything that isn't a friend's id is a
41
+ * silent no-op; a value that isn't a user id at all parses as `0` (no one).
42
+ */
43
+ to: UserId.catch(0),
44
+ }),
45
+ ack: EmptyAck,
46
+ errors: [],
47
+ doc: 'Tell a friend you are typing (`ev:typing`), at most once per 2 s per pair.',
48
+ }),
49
+ ping: op({
50
+ d: NoInput,
51
+ ack: z.object({
52
+ /** The server clock (epoch ms). */
53
+ t: Millis,
54
+ }),
55
+ errors: [],
56
+ doc: 'Heartbeat and clock check; allowed before `hello`.',
57
+ }),
58
+ } satisfies Record<string, OpDef>;