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

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 (76) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +26 -3
  4. package/dist/index.d.ts +8454 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +4855 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +53 -6
  9. package/src/api/analytics.ts +81 -0
  10. package/src/api/auth.ts +77 -0
  11. package/src/api/catalog.ts +171 -0
  12. package/src/api/daily.ts +122 -0
  13. package/src/api/economy/bundle.ts +69 -0
  14. package/src/api/economy/inventory.ts +57 -0
  15. package/src/api/economy/trades.ts +154 -0
  16. package/src/api/economy.ts +15 -0
  17. package/src/api/index.ts +85 -0
  18. package/src/api/leaderboards.ts +151 -0
  19. package/src/api/messages.ts +142 -0
  20. package/src/api/mp/chat.ts +36 -0
  21. package/src/api/mp/parties.ts +108 -0
  22. package/src/api/mp/races.ts +211 -0
  23. package/src/api/mp/rooms.ts +90 -0
  24. package/src/api/mp/snapshot.ts +17 -0
  25. package/src/api/mp.ts +110 -0
  26. package/src/api/notify.ts +111 -0
  27. package/src/api/progress/game-data.ts +189 -0
  28. package/src/api/progress.ts +59 -0
  29. package/src/api/race.ts +47 -0
  30. package/src/api/reports.ts +208 -0
  31. package/src/api/social/config.ts +47 -0
  32. package/src/api/social/endpoints.ts +179 -0
  33. package/src/api/social/people.ts +118 -0
  34. package/src/api/social/presence.ts +95 -0
  35. package/src/api/social.ts +9 -0
  36. package/src/api/version.ts +40 -0
  37. package/src/coerce.ts +50 -0
  38. package/src/common.ts +42 -0
  39. package/src/entities.ts +62 -0
  40. package/src/errors/codes/core.ts +10 -0
  41. package/src/errors/codes/multiplayer.ts +43 -0
  42. package/src/errors/codes/play.ts +5 -0
  43. package/src/errors/codes/social.ts +31 -0
  44. package/src/errors/index.ts +88 -0
  45. package/src/index.ts +16 -0
  46. package/src/limits.ts +100 -0
  47. package/src/logic/daily.ts +54 -0
  48. package/src/logic/index.ts +8 -0
  49. package/src/logic/presence.ts +62 -0
  50. package/src/logic/race.ts +169 -0
  51. package/src/logic/time.ts +81 -0
  52. package/src/manifest/changes.ts +55 -0
  53. package/src/manifest/controls.ts +42 -0
  54. package/src/manifest/fields.ts +89 -0
  55. package/src/manifest/game.ts +132 -0
  56. package/src/manifest/index.ts +12 -0
  57. package/src/manifest/inject.ts +31 -0
  58. package/src/manifest/leaderboards.ts +29 -0
  59. package/src/manifest/multiplayer.ts +71 -0
  60. package/src/manifest/race.ts +93 -0
  61. package/src/middleware.ts +47 -0
  62. package/src/realtime/define.ts +52 -0
  63. package/src/realtime/envelope.ts +67 -0
  64. package/src/realtime/events/multiplayer.ts +8 -0
  65. package/src/realtime/events/parties.ts +31 -0
  66. package/src/realtime/events/races.ts +27 -0
  67. package/src/realtime/events/rooms.ts +48 -0
  68. package/src/realtime/events/social.ts +81 -0
  69. package/src/realtime/index.ts +24 -0
  70. package/src/realtime/ops/empty.ts +8 -0
  71. package/src/realtime/ops/hello.ts +40 -0
  72. package/src/realtime/ops/multiplayer.ts +8 -0
  73. package/src/realtime/ops/parties.ts +13 -0
  74. package/src/realtime/ops/races.ts +99 -0
  75. package/src/realtime/ops/rooms.ts +122 -0
  76. package/src/realtime/ops/social.ts +58 -0
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Reports and suggestions: players flagging problems (multiplayer-impl.md
3
+ * §5.3) and proposing game ideas (§5.5).
4
+ *
5
+ * A report only says *what* is reported; the hub builds the evidence
6
+ * snapshot from its own records. Suggestions are filtered, reviewed by an
7
+ * admin, and only approved ones reach the public board.
8
+ */
9
+ import { z } from 'zod';
10
+ import { Millis, Ok } from '../common';
11
+ import { LooseNumber } from '../coerce';
12
+ import { ContentRejected, ErrorBody } from '../errors';
13
+ import { requireActive, requireClaimed, session } from '../middleware';
14
+ import { Presence } from './social/presence';
15
+
16
+ /** What is being reported. */
17
+ export const ReportKind = z.enum(['message', 'user', 'username', 'suggestion', 'room_chat', 'presence'], {
18
+ error: 'kind must be one of message, user, username, suggestion, room_chat, presence.',
19
+ });
20
+
21
+ /** A report kind. */
22
+ export type ReportKind = z.infer<typeof ReportKind>;
23
+
24
+ /** Why, from a fixed list (there is no free text). */
25
+ export const ReportReason = z.enum(['mean', 'bad_words', 'personal', 'inappropriate', 'spam', 'other'], {
26
+ error: 'reason must be one of mean, bad_words, personal, inappropriate, spam, other.',
27
+ });
28
+
29
+ /** A report reason. */
30
+ export type ReportReason = z.infer<typeof ReportReason>;
31
+
32
+ /**
33
+ * `POST /social/reports`. Loose on purpose: anything else the client sends
34
+ * (an evidence snapshot, quoted text) is ignored, because the hub builds the
35
+ * snapshot from its own records.
36
+ */
37
+ export const ReportInput = z.looseObject({
38
+ kind: ReportKind,
39
+ /**
40
+ * The reported player. `0` for a suggestion: the hub finds its author from
41
+ * `refId`. Read like `Number(x)`: anything that is not a player's id is a 404.
42
+ */
43
+ targetUserId: LooseNumber.catch(Number.NaN),
44
+ /**
45
+ * What exactly: the message id (`message`), the suggestion id
46
+ * (`suggestion`) or the room code (`room_chat`). Kept to 64 characters.
47
+ */
48
+ refId: z.union([z.string(), z.number()]).nullable().optional(),
49
+ reason: ReportReason,
50
+ });
51
+
52
+ /** A report. */
53
+ export type ReportInput = z.infer<typeof ReportInput>;
54
+
55
+ /** A direct message as a report snapshot keeps it. */
56
+ export const ReportedMessage = z.object({
57
+ id: z.number().int().positive(),
58
+ /** The sender's user id. */
59
+ from: z.number().int(),
60
+ /** The recipient's user id. */
61
+ to: z.number().int(),
62
+ /** When it was sent (epoch ms). */
63
+ at: Millis,
64
+ /** The text, or the quick-chat phrase's text (`''` for an unknown phrase). */
65
+ text: z.string(),
66
+ /** The quick-chat phrase id, when it was one. */
67
+ quick: z.string().optional(),
68
+ });
69
+
70
+ /** A reported direct message. */
71
+ export type ReportedMessage = z.infer<typeof ReportedMessage>;
72
+
73
+ /**
74
+ * The evidence the hub records with a report, from its own records (stored
75
+ * as `snapshot_json`; the admin console shows it). Always the reported
76
+ * player; then per kind: `message` adds the message and up to 5 before it in
77
+ * the conversation (oldest first), `suggestion` its title, body and status,
78
+ * `presence` their status line (`null` when offline), `room_chat` the room
79
+ * code (room chat isn't stored).
80
+ */
81
+ export const ReportSnapshot = z.object({
82
+ userId: z.number().int(),
83
+ username: z.string(),
84
+ message: ReportedMessage.optional(),
85
+ context: z.array(ReportedMessage).optional(),
86
+ title: z.string().optional(),
87
+ body: z.string().optional(),
88
+ status: z.string().optional(),
89
+ detail: z.string().optional(),
90
+ presence: Presence.nullable().optional(),
91
+ room: z.string().optional(),
92
+ });
93
+
94
+ /** A report's evidence snapshot. */
95
+ export type ReportSnapshot = z.infer<typeof ReportSnapshot>;
96
+
97
+ /** A suggestion's review status. Only `approved`, `planned` and `done` are on the public board. */
98
+ export const SuggestionStatus = z.enum(['pending', 'approved', 'planned', 'done', 'removed', 'withdrawn']);
99
+
100
+ /** A suggestion status. */
101
+ export type SuggestionStatus = z.infer<typeof SuggestionStatus>;
102
+
103
+ /** A suggestion id. */
104
+ export const SuggestionId = z.number().int().positive();
105
+
106
+ /** A game suggestion. */
107
+ export const Suggestion = z.object({
108
+ id: SuggestionId,
109
+ title: z.string(),
110
+ /** The description. */
111
+ body: z.string(),
112
+ status: SuggestionStatus,
113
+ /** Upvotes. */
114
+ votes: z.number().int().nonnegative(),
115
+ /** You upvoted it. */
116
+ voted: z.boolean(),
117
+ /** You suggested it. */
118
+ mine: z.boolean(),
119
+ /** The reviewer's note to the author, `''` when none. */
120
+ adminNote: z.string(),
121
+ /** When it was suggested (epoch ms). */
122
+ createdAt: Millis,
123
+ /** The author's username: only once it is on the public board (and always in admin views). */
124
+ author: z.string().optional(),
125
+ });
126
+
127
+ /** A game suggestion. */
128
+ export type Suggestion = z.infer<typeof Suggestion>;
129
+
130
+ /** A vote's result: the new count and whether you are voting. */
131
+ export const VoteResult = z.object({ votes: z.number().int().nonnegative(), voted: z.boolean() });
132
+
133
+ /**
134
+ * `:id`, a suggestion: one on the public board to vote, your own pending one
135
+ * to withdraw. Read like `Number(x)`: anything that isn't one is a 404.
136
+ */
137
+ const SuggestionParams = z.object({ id: LooseNumber.catch(Number.NaN) });
138
+
139
+ /** The old hub's one message for a missing or non-text title or description. */
140
+ const SuggestionText = z.string({ error: 'A suggestion needs a title and a description.' });
141
+
142
+ /** The report and suggestion endpoints. */
143
+ export const reportsEndpoints = {
144
+ ...requireActive.group({
145
+ fileReport: {
146
+ method: 'POST', path: '/social/reports', body: ReportInput,
147
+ response: z.object({
148
+ ok: z.literal(true),
149
+ /** The report's id (the open one, when you already reported the same thing). */
150
+ id: z.number().int().positive(),
151
+ }),
152
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 429: ErrorBody },
153
+ doc: {
154
+ summary: 'Report a player, a message, a suggestion or a status line',
155
+ description: '404 when `refId` is nothing you could have seen from that player.',
156
+ tags: ['reports'],
157
+ },
158
+ },
159
+ }),
160
+ ...session.group({
161
+ listPublicSuggestions: {
162
+ method: 'GET', path: '/suggestions',
163
+ query: z.object({
164
+ /** `votes` (default) or `new`. */
165
+ sort: z.enum(['votes', 'new']).optional().catch(undefined),
166
+ }),
167
+ response: z.object({
168
+ /** The public board (approved, planned, done), at most 200. Ideas from suspended authors drop off. */
169
+ approved: z.array(Suggestion),
170
+ /** Your own ideas except withdrawn ones, newest first; empty unless signed in to a claimed account. */
171
+ mine: z.array(Suggestion),
172
+ }),
173
+ errors: { 403: ErrorBody },
174
+ doc: { summary: 'The ideas board, plus your own ideas', tags: ['suggestions'] },
175
+ },
176
+ }),
177
+ ...requireClaimed.group({
178
+ submitSuggestion: {
179
+ method: 'POST', path: '/suggestions',
180
+ body: z.object({ title: SuggestionText, body: SuggestionText }),
181
+ response: z.object({ suggestion: Suggestion }),
182
+ errors: {
183
+ 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 409: ErrorBody,
184
+ 422: ContentRejected, 429: ErrorBody,
185
+ },
186
+ doc: {
187
+ summary: 'Suggest a game idea (filtered; a few a day, a capped number pending)',
188
+ description: '409 `too_many_pending`; 429 `rate_limited` with `retryAfterMs` past the daily limit.',
189
+ tags: ['suggestions'],
190
+ },
191
+ },
192
+ voteForSuggestion: {
193
+ method: 'POST', path: '/suggestions/:id/vote', params: SuggestionParams, response: VoteResult,
194
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
195
+ doc: { summary: 'Upvote an idea (idempotent)', tags: ['suggestions'] },
196
+ },
197
+ removeSuggestionVote: {
198
+ method: 'DELETE', path: '/suggestions/:id/vote', params: SuggestionParams, response: VoteResult,
199
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
200
+ doc: { summary: 'Take an upvote back (idempotent)', tags: ['suggestions'] },
201
+ },
202
+ withdrawSuggestion: {
203
+ method: 'DELETE', path: '/suggestions/:id', params: SuggestionParams, response: Ok,
204
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody },
205
+ doc: { summary: 'Withdraw your own idea while it is still pending', tags: ['suggestions'] },
206
+ },
207
+ }),
208
+ };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The site-wide social configuration every social UI renders from
3
+ * (`GET /social/config`, `ev:config`, the `hello` snapshot).
4
+ */
5
+ import { z } from 'zod';
6
+ import { ChatMode } from '../../entities';
7
+
8
+
9
+ /** A quick-chat phrase id: a hub phrase (`gg`), an emote (`e:<emoji>`) or a game phrase (`g:<slug>:<n>`). */
10
+ export const QuickId = z.string().min(1);
11
+
12
+ /** A non-negative character limit. */
13
+ const CharLimit = z.number().int().nonnegative();
14
+
15
+ /** Text length limits, in characters. */
16
+ export const SocialLimits = z.object({
17
+ dmMaxChars: CharLimit,
18
+ roomChatMaxChars: CharLimit,
19
+ suggestionTitleMax: CharLimit,
20
+ suggestionBodyMax: CharLimit,
21
+ });
22
+
23
+ /** The site-wide social configuration. */
24
+ export const SocialConfig = z.object({
25
+ /** The site-wide chat mode (operator setting tightened by the admin switch). */
26
+ chatMode: ChatMode,
27
+ /** The AI safety reviewer is configured. */
28
+ ai: z.boolean(),
29
+ /** Where parents can reach the operator, `''` when unset. */
30
+ contactUrl: z.string(),
31
+ /** The public "how chat is kept safe" page. */
32
+ parentsUrl: z.string(),
33
+ /** The Web Push VAPID public key (base64url), `''` when push isn't configured. */
34
+ vapidPublicKey: z.string(),
35
+ /** Every quick-chat phrase, id to text: hub phrases, emotes and every game's phrases. */
36
+ quickChat: z.record(QuickId, z.string()),
37
+ /** The emote emoji (also in `quickChat` as `e:<emoji>` ids). */
38
+ emotes: z.array(z.string()),
39
+ limits: SocialLimits,
40
+ /** The preset avatar ids (`a0` to `a23`). */
41
+ avatars: z.array(z.string()),
42
+ /** Present (`true`) only when the host runs in test mode (`HUB_TEST=1`). */
43
+ test: z.literal(true).optional(),
44
+ });
45
+
46
+ /** The social configuration. */
47
+ export type SocialConfig = z.infer<typeof SocialConfig>;
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Social core REST (multiplayer-impl.md §5.1): config, your own social
3
+ * settings, friends and requests, blocks, player cards, recent players and
4
+ * online counts. Every route here is closed to suspended accounts (403
5
+ * `suspended`) except `GET /social/config` and `GET /social/me`, which a
6
+ * suspended player's UI needs to explain what happened.
7
+ */
8
+ import { z } from 'zod';
9
+ import { Ok } from '../../common';
10
+ import { requiredId } from '../../coerce';
11
+ import { ContentRejected, ErrorBody } from '../../errors';
12
+ import { requireActive, requireClaimed, requireUser, session } from '../../middleware';
13
+ import { NotifyPrefs } from '../notify';
14
+ import { SocialConfig } from './config';
15
+ import { Audience, FriendsList, PlayerCard, RecentPlayer, SocialMe } from './people';
16
+ import { Counts } from './presence';
17
+
18
+ /** `{ me }`: your social settings after a change. */
19
+ export const SocialMeResult = z.object({ me: SocialMe });
20
+
21
+ /** `:id`, another player's user id. A malformed id is a 400. */
22
+ const OtherUser = z.object({ id: requiredId('id must be a user id.') });
23
+
24
+ /** An {@link Audience} field, with the old hub's message for anything else. */
25
+ const audienceField = (field: string) => z.enum(Audience.options, { error: `${field} must be friends or nobody.` });
26
+
27
+ /**
28
+ * `POST /social/friends`: who to befriend, by id or by name, and an optional
29
+ * first message. `userId` wins when both are given; with neither, the player
30
+ * is not found (404 `no_player`).
31
+ */
32
+ export const FriendRequestInput = z.object({
33
+ userId: requiredId('userId must be a user id.').nullish(),
34
+ /** A username, case-insensitive. */
35
+ username: z.string().optional(),
36
+ message: z.string().optional(),
37
+ });
38
+
39
+ /** A friend request. */
40
+ export type FriendRequestInput = z.infer<typeof FriendRequestInput>;
41
+
42
+ /** The result of `POST /social/friends`. */
43
+ export const FriendRequestStatus = z.enum(['requested', 'friends']);
44
+
45
+ /** The social core endpoints. */
46
+ export const socialEndpoints = {
47
+ ...session.group({
48
+ getSocialConfig: {
49
+ method: 'GET', path: '/social/config', response: SocialConfig,
50
+ doc: { summary: 'The site-wide social configuration', tags: ['social'] },
51
+ },
52
+ getOnlineCounts: {
53
+ method: 'GET', path: '/social/counts', response: Counts,
54
+ errors: { 403: ErrorBody },
55
+ doc: { summary: 'Anonymous online counts, overall and per game', tags: ['social'] },
56
+ },
57
+ }),
58
+ ...requireUser.group({
59
+ getMySocialProfile: {
60
+ method: 'GET', path: '/social/me',
61
+ response: z.object({
62
+ me: SocialMe,
63
+ notify: NotifyPrefs,
64
+ /** How many browsers have a push subscription for this account. */
65
+ pushSubs: z.number().int().nonnegative(),
66
+ }),
67
+ errors: { 401: ErrorBody },
68
+ doc: { summary: 'Your social settings, notification preferences and push subscription count', tags: ['social'] },
69
+ },
70
+ }),
71
+ ...requireActive.group({
72
+ listFriendsAndRequests: {
73
+ method: 'GET', path: '/social/friends', response: FriendsList,
74
+ errors: { 401: ErrorBody, 403: ErrorBody },
75
+ doc: { summary: 'Friends (online first), requests both ways and blocks', tags: ['social'] },
76
+ },
77
+ findPlayerByName: {
78
+ method: 'GET', path: '/social/players/by-name/:name',
79
+ params: z.object({ name: z.string() }), response: PlayerCard,
80
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
81
+ doc: { summary: 'A player card, by username', tags: ['social'] },
82
+ },
83
+ getPlayerCard: {
84
+ method: 'GET', path: '/social/players/:id', params: OtherUser, response: PlayerCard,
85
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
86
+ doc: { summary: 'A player card, by id (404 `no_player` when blocked by them or suspended)', tags: ['social'] },
87
+ },
88
+ }),
89
+ ...requireClaimed.group({
90
+ updateMySocialProfile: {
91
+ method: 'PUT', path: '/social/me',
92
+ body: z.object({
93
+ /** One of the config's preset avatar ids (else 400 `bad_avatar`). */
94
+ avatar: z.string().optional(),
95
+ privacy: audienceField('privacy').optional(),
96
+ dmPolicy: audienceField('dmPolicy').optional(),
97
+ }),
98
+ response: SocialMeResult,
99
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody },
100
+ doc: { summary: 'Change your avatar, presence privacy or who may message you', tags: ['social'] },
101
+ },
102
+ setParentChatLock: {
103
+ method: 'POST', path: '/social/chat-lock',
104
+ body: z.union([
105
+ z.object({ on: z.literal(true) }),
106
+ z.object({
107
+ on: z.literal(false),
108
+ /** The account password; required to turn the lock off. */
109
+ password: z.string().optional(),
110
+ }),
111
+ ], { error: 'on must be true or false.' }),
112
+ response: SocialMeResult,
113
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 429: ErrorBody },
114
+ doc: {
115
+ summary: 'Parent chat lock: on with one tap, off only with the account password',
116
+ description: 'A wrong password is 403 `bad_password` (logged and rate-limited).',
117
+ tags: ['social'],
118
+ },
119
+ },
120
+ sendFriendRequest: {
121
+ method: 'POST', path: '/social/friends', body: FriendRequestInput,
122
+ response: z.object({ status: FriendRequestStatus }),
123
+ errors: {
124
+ 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody,
125
+ 422: ContentRejected, 429: ErrorBody,
126
+ },
127
+ doc: {
128
+ summary: 'Send a friend request (a reverse request accepts at once)',
129
+ description: 'Answers `requested` without doing anything when the target blocked you or is suspended. '
130
+ + '`message` is moderated now and delivered as the first DM once you are friends.',
131
+ tags: ['social'],
132
+ },
133
+ },
134
+ acceptFriendRequest: {
135
+ method: 'POST', path: '/social/friends/:id/accept', params: OtherUser, response: Ok,
136
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody },
137
+ doc: { summary: 'Accept the request from `:id`', tags: ['social'] },
138
+ },
139
+ declineFriendRequest: {
140
+ method: 'POST', path: '/social/friends/:id/decline', params: OtherUser, response: Ok,
141
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody },
142
+ doc: { summary: 'Quietly drop the request from `:id`', tags: ['social'] },
143
+ },
144
+ removeFriend: {
145
+ method: 'DELETE', path: '/social/friends/:id', params: OtherUser, response: Ok,
146
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody },
147
+ doc: { summary: 'Unfriend `:id`, or cancel your request to them', tags: ['social'] },
148
+ },
149
+ setFriendNotifications: {
150
+ method: 'PUT', path: '/social/friends/:id/notify', params: OtherUser,
151
+ body: z.object({ online: z.boolean({ error: 'online must be true or false.' }) }), response: Ok,
152
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
153
+ doc: { summary: "Turn a friend's \"tell me when they're online\" bell on or off", tags: ['social'] },
154
+ },
155
+ blockPlayer: {
156
+ method: 'POST', path: '/social/blocks', body: z.object({ userId: requiredId('userId must be a user id.') }), response: Ok,
157
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody },
158
+ doc: {
159
+ summary: 'Block a player',
160
+ description: 'Also unfriends, drops requests both ways and splits a shared party.',
161
+ tags: ['social'],
162
+ },
163
+ },
164
+ unblockPlayer: {
165
+ method: 'DELETE', path: '/social/blocks/:id', params: OtherUser, response: Ok,
166
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody },
167
+ doc: { summary: 'Unblock a player', tags: ['social'] },
168
+ },
169
+ listRecentPlayers: {
170
+ method: 'GET', path: '/social/recent',
171
+ response: z.object({
172
+ /** Newest first, at most 50. */
173
+ players: z.array(RecentPlayer),
174
+ }),
175
+ errors: { 401: ErrorBody, 403: ErrorBody },
176
+ doc: { summary: 'People you recently played with', tags: ['social'] },
177
+ },
178
+ }),
179
+ };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * People shapes: your own social settings, friends, requests, player cards
3
+ * and recent players. Several are also part of the socket `hello` snapshot.
4
+ *
5
+ * Privacy rules behind them: a block in either direction looks to the
6
+ * blocked side like an ordinary unfriend (or a missing player), suspended
7
+ * players are invisible, and guests have no friends.
8
+ */
9
+ import { z } from 'zod';
10
+ import { Millis, Slug } from '../../common';
11
+ import { PlayerLite, SessionUser } from '../../entities';
12
+ import { Presence } from './presence';
13
+
14
+ /** Who may see my presence, or message me: friends, or nobody. */
15
+ export const Audience = z.enum(['friends', 'nobody']);
16
+
17
+ /** An audience setting. */
18
+ export type Audience = z.infer<typeof Audience>;
19
+
20
+ /**
21
+ * Your own account's social fields (`GET`/`PUT /social/me`, the `hello`
22
+ * snapshot's `me`). Never shown to other players.
23
+ */
24
+ export const SocialMe = SessionUser.extend({
25
+ /** Avatar token; never empty (a default is derived from the id). */
26
+ avatar: PlayerLite.shape.avatar,
27
+ /** Who sees my presence and last-seen time. */
28
+ privacy: Audience,
29
+ /** Who may message me (and invite me). */
30
+ dmPolicy: Audience,
31
+ });
32
+
33
+ /** Your own social settings. */
34
+ export type SocialMe = z.infer<typeof SocialMe>;
35
+
36
+ /** A friend, with what you may see of their presence. Online friends sort first. */
37
+ export const FriendEntry = PlayerLite.extend({
38
+ /** `null` when offline or hiding their presence. */
39
+ presence: Presence.nullable(),
40
+ /** "Tell me when they come online" is on for this friend. */
41
+ notifyOnline: z.boolean(),
42
+ /** When you became friends (epoch ms). */
43
+ since: Millis,
44
+ /** When they were last online (epoch ms); `0` when unknown or they hide their presence. */
45
+ lastSeenAt: Millis,
46
+ });
47
+
48
+ /** A friend. */
49
+ export type FriendEntry = z.infer<typeof FriendEntry>;
50
+
51
+ /** Pending friend requests both ways, newest first (blocked and suspended players left out). */
52
+ export const FriendRequests = z.object({
53
+ /** Requests waiting for you. */
54
+ incoming: z.array(PlayerLite),
55
+ /** Requests you sent. */
56
+ outgoing: z.array(PlayerLite),
57
+ });
58
+
59
+ /** Pending friend requests. */
60
+ export type FriendRequests = z.infer<typeof FriendRequests>;
61
+
62
+ /** `GET /social/friends`: friends, requests and the players you blocked. Guests get empty lists. */
63
+ export const FriendsList = FriendRequests.extend({
64
+ friends: z.array(FriendEntry),
65
+ /** Players you blocked, newest first. */
66
+ blocked: z.array(PlayerLite),
67
+ });
68
+
69
+ /** Friends, requests and blocks. */
70
+ export type FriendsList = z.infer<typeof FriendsList>;
71
+
72
+ /**
73
+ * What changed in `ev:friends` (the client refetches `GET /social/friends`).
74
+ * `restored`: a friend's suspension ended. A player you blocked only ever
75
+ * sees `removed`.
76
+ */
77
+ export const FriendChange = z.enum(['request', 'accepted', 'removed', 'declined', 'cancelled', 'blocked', 'restored']);
78
+
79
+ /** A friends-list change. */
80
+ export type FriendChange = z.infer<typeof FriendChange>;
81
+
82
+ /** My relation to another player, as their card shows it. */
83
+ export const Relation = z.enum(['self', 'friend', 'incoming', 'outgoing', 'blocked', 'none']);
84
+
85
+ /** A relation. */
86
+ export type Relation = z.infer<typeof Relation>;
87
+
88
+ /**
89
+ * A player card (`GET /social/players/:id`). Someone who blocked you, or who
90
+ * is suspended, answers 404 exactly like a player who doesn't exist.
91
+ */
92
+ export const PlayerCard = PlayerLite.extend({
93
+ relation: Relation,
94
+ /** Friends only; `null` otherwise, or when offline or hidden. */
95
+ presence: Presence.nullable(),
96
+ /** A friend request can be sent (both claimed, no relation yet or theirs is pending, requests on). */
97
+ canFriend: z.boolean(),
98
+ /** Friends who take messages, while DMs are on. */
99
+ canMessage: z.boolean(),
100
+ /** Friends who take messages. */
101
+ canInvite: z.boolean(),
102
+ /** Anyone but yourself. */
103
+ canReport: z.boolean(),
104
+ });
105
+
106
+ /** A player card. */
107
+ export type PlayerCard = z.infer<typeof PlayerCard>;
108
+
109
+ /** Someone you recently played with (`GET /social/recent`). */
110
+ export const RecentPlayer = PlayerLite.extend({
111
+ /** The game you played together. */
112
+ game: Slug,
113
+ /** When you last played together (epoch ms). */
114
+ lastAt: Millis,
115
+ });
116
+
117
+ /** A recent player. */
118
+ export type RecentPlayer = z.infer<typeof RecentPlayer>;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Presence: what a player is doing right now, as their friends see it, and
3
+ * the anonymous online counts (multiplayer-impl.md §4.4). Games report a
4
+ * {@link PresenceInput} per tab (socket op `presence`); the hub folds a
5
+ * user's tabs into one {@link Presence} and pushes it to friends
6
+ * (`ev:presence`).
7
+ */
8
+ import { z } from 'zod';
9
+ import { Millis, Slug } from '../../common';
10
+
11
+ /** Activity kinds, least engaged first. The most engaged tab wins; hidden or idle tabs count as `away`. */
12
+ export const PresenceKind = z.enum(['away', 'menu', 'lobby', 'watching', 'playing']);
13
+
14
+ /** An activity kind. */
15
+ export type PresenceKind = z.infer<typeof PresenceKind>;
16
+
17
+ /** A room reference friends can join with: 1 to 64 of `A-Z a-z 0-9 _ : -`. */
18
+ export const PresenceRoom = z.string().regex(/^[A-Za-z0-9_:-]{1,64}$/);
19
+
20
+ /** A game mode key: 1 to 32 of `A-Z a-z 0-9 _ -`. */
21
+ export const PresenceMode = z.string().regex(/^[A-Za-z0-9_-]{1,32}$/);
22
+
23
+ /** The fields a tab reports and friends see back, with the limits the hub enforces. */
24
+ const PresenceFields = z.object({
25
+ kind: PresenceKind,
26
+ /** A short status line (at most 40 characters), filtered; a rejected one is dropped. */
27
+ detail: z.string().max(40).optional(),
28
+ /** Friends may join. Only ever `true` when present. */
29
+ joinable: z.boolean().optional(),
30
+ /** Friends may watch. Only ever `true` when present. */
31
+ watchable: z.boolean().optional(),
32
+ /** Join info for friends: the game's own room reference, or the hub room's code. */
33
+ room: PresenceRoom.optional(),
34
+ /** A public lobby: friends who follow the game may get a `lobby_open` alert. */
35
+ public: z.boolean().optional(),
36
+ /** Free seats, 0 to 64. */
37
+ openSeats: z.number().int().min(0).max(64).optional(),
38
+ mode: PresenceMode.optional(),
39
+ });
40
+
41
+ /**
42
+ * What a game reports for its tab (socket op `presence`). The hub is
43
+ * lenient: an unknown `kind`, or input that isn't an object at all (`null`),
44
+ * counts as `{kind:'menu'}`; unknown fields, and fields that break a rule (a
45
+ * bad `room`, a fractional `openSeats`, a field of the wrong type), are
46
+ * ignored one by one, so friends only ever
47
+ * see {@link Presence}'s fields; `detail` is trimmed to 40 characters before
48
+ * filtering.
49
+ */
50
+ export const PresenceInput = z.looseObject({
51
+ kind: PresenceKind.catch('menu'),
52
+ detail: z.string().optional().catch(undefined),
53
+ joinable: z.boolean().optional().catch(undefined),
54
+ watchable: z.boolean().optional().catch(undefined),
55
+ room: z.string().optional().catch(undefined),
56
+ public: z.boolean().optional().catch(undefined),
57
+ openSeats: z.number().optional().catch(undefined),
58
+ mode: z.string().optional().catch(undefined),
59
+ }).catch({ kind: 'menu' });
60
+
61
+ /** A presence report. */
62
+ export type PresenceInput = z.input<typeof PresenceInput>;
63
+
64
+ /** A user's aggregated presence: their most engaged tab, decorated by the hub. */
65
+ export const Presence = PresenceFields.extend({
66
+ /** The game the tab is on, `null` on the catalog and other hub pages. */
67
+ game: Slug.nullable(),
68
+ /** That game's title, `null` with `game`. */
69
+ gameTitle: z.string().nullable(),
70
+ /** When this activity (kind + game) started (epoch ms). */
71
+ since: Millis,
72
+ /** Party size, present when the user is in a party of two or more. */
73
+ partySize: z.number().int().min(2).optional(),
74
+ });
75
+
76
+ /** A user's aggregated presence. */
77
+ export type Presence = z.infer<typeof Presence>;
78
+
79
+ /** A non-negative head count. */
80
+ const HeadCount = z.number().int().nonnegative();
81
+
82
+ /**
83
+ * Anonymous online counts (`GET /social/counts`, `ev:counts`, the `hello`
84
+ * snapshot): everyone with a live presence, guests and hidden players
85
+ * included.
86
+ */
87
+ export const Counts = z.object({
88
+ /** Everyone online. */
89
+ online: HeadCount,
90
+ /** Players per game slug (games with nobody are absent). */
91
+ byGame: z.record(Slug, HeadCount),
92
+ });
93
+
94
+ /** Online counts. */
95
+ export type Counts = z.infer<typeof Counts>;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Friends, blocks, the social profile and settings, player cards and counts,
3
+ * plus the shared social shapes (config, presence, people) the socket
4
+ * `hello` snapshot and events reuse. Split by topic under `social/`.
5
+ */
6
+ export * from './social/config';
7
+ export * from './social/presence';
8
+ export * from './social/people';
9
+ export * from './social/endpoints';