@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,151 +1,151 @@
1
- /**
2
- * Leaderboards: score submission, boards and standings. A board belongs to a
3
- * game and is keyed by an app-chosen {@link Key}; it keeps each player's best
4
- * score only (highest for `desc` boards, lowest for `asc`).
5
- */
6
- import { use } from '@ayepi/core';
7
- import { z } from 'zod';
8
- import { Json, Key, Slug, UserId } from '../common';
9
- import { ErrorBody } from '../errors';
10
- import { game, requireUser, session } from '../middleware';
11
- import { RowCount, Truthy } from '../coerce';
12
-
13
- /** A board key in a path, with the old hub's message when it is malformed. */
14
- const BoardKeyParams = z.object({
15
- key: z.string().refine((key) => Key.safeParse(key).success, { error: 'board key must match /^[A-Za-z0-9_.:-]{1,64}$/ (1–64 chars).' }),
16
- });
17
-
18
- /**
19
- * A row count the hub reads like `Number(x) || default`: anything that is not
20
- * an integer falls back to the default instead of failing.
21
- */
22
- const LenientRowCount = RowCount.optional().catch(undefined);
23
-
24
- /** A board's sort direction: `desc` ranks high scores first, `asc` low scores (times) first. */
25
- export const SortDir = z.enum(['asc', 'desc']);
26
-
27
- /** A board's sort direction. */
28
- export type SortDir = z.infer<typeof SortDir>;
29
-
30
- /** A 1-based rank on a board. */
31
- export const Rank = z.number().int().positive();
32
-
33
- /** A leaderboard's identity, without entries. */
34
- export const Board = z.object({
35
- key: Key,
36
- /** Set by the first submitter or the game's `package.json`; defaults to the key. */
37
- title: z.string(),
38
- sortDir: SortDir,
39
- });
40
-
41
- /** A leaderboard's identity. */
42
- export type Board = z.infer<typeof Board>;
43
-
44
- /**
45
- * One ranked row of a board. Suspended players and (unless the board opts
46
- * in) zero scores are left out.
47
- */
48
- export const LeaderboardEntry = z.object({
49
- rank: Rank,
50
- userId: UserId,
51
- username: z.string(),
52
- score: z.number(),
53
- /** The JSON the game stored with the score, or `null`. */
54
- meta: Json,
55
- });
56
-
57
- /** A ranked board row. */
58
- export type LeaderboardEntry = z.infer<typeof LeaderboardEntry>;
59
-
60
- /** Every board of one game, each with its top entries (`GET /leaderboards`, the snapshot). */
61
- export const GameLeaderboards = z.object({
62
- slug: Slug,
63
- /** The game's title (the slug when the game is no longer listed). */
64
- title: z.string(),
65
- boards: z.array(Board.extend({ top: z.array(LeaderboardEntry) })),
66
- });
67
-
68
- /** One game's boards with their top entries. */
69
- export type GameLeaderboards = z.infer<typeof GameLeaderboards>;
70
-
71
- /**
72
- * The player's own standings in one game (`GET /me/records`, the snapshot):
73
- * their score and rank on each board they have an entry on.
74
- */
75
- export const GameRecords = z.object({
76
- slug: Slug,
77
- /** The game's title (the slug when the game is no longer listed). */
78
- title: z.string(),
79
- records: z.array(Board.extend({ score: z.number(), rank: Rank })),
80
- });
81
-
82
- /** The player's standings in one game. */
83
- export type GameRecords = z.infer<typeof GameRecords>;
84
-
85
- /**
86
- * `?top=` for the cross-game views (`GET /leaderboards`, `GET /me/snapshot`):
87
- * entries per board, 1 to 50 (clamped), default 5.
88
- */
89
- export const TopQuery = z.object({ top: LenientRowCount });
90
-
91
- /** The leaderboards endpoints. */
92
- export const leaderboardsEndpoints = {
93
- ...session.group({
94
- /** Every board of every game, grouped by game, each with its top entries. */
95
- listTopOfEveryLeaderboard: {
96
- method: 'GET', path: '/leaderboards', query: TopQuery,
97
- response: z.object({ games: z.array(GameLeaderboards) }),
98
- doc: { summary: 'Every board across every game, with top entries', tags: ['leaderboards'] },
99
- },
100
- }),
101
- ...use(session, game).path('/games/:slug').group({
102
- listGameLeaderboards: {
103
- method: 'GET', path: '/leaderboards',
104
- response: z.object({ boards: z.array(Board) }),
105
- errors: { 404: ErrorBody },
106
- doc: { summary: "The game's boards (no entries)", tags: ['leaderboards'] },
107
- },
108
- getLeaderboard: {
109
- method: 'GET', path: '/leaderboards/:key',
110
- params: BoardKeyParams,
111
- /** `limit`: rows to return, 1 to 500 (clamped), default 50. */
112
- query: z.object({ limit: LenientRowCount }),
113
- response: z.object({ board: Board, entries: z.array(LeaderboardEntry) }),
114
- errors: { 400: ErrorBody, 404: ErrorBody },
115
- doc: { summary: "One board's ranked entries (404 if the board doesn't exist)", tags: ['leaderboards'] },
116
- },
117
- }),
118
- ...use(requireUser, game).path('/games/:slug').group({
119
- /**
120
- * Submit a score. The first submission creates the board (with `title`,
121
- * `sort` and `showZeros`; later values are ignored). Only an improvement
122
- * replaces the player's entry. Boards a game declares with
123
- * `source: "server"` refuse client scores (403 `server_board`).
124
- */
125
- submitScore: {
126
- method: 'POST', path: '/leaderboards/:key/scores',
127
- params: BoardKeyParams,
128
- body: z.object({
129
- /** A finite number (numeric strings pass, as for the old hub's `Number(x)`). */
130
- score: z.coerce.number<number>({ error: 'score must be a finite number.' }),
131
- /** Board title, used only when this call creates the board (non-text is ignored). */
132
- title: z.string().optional().catch(undefined),
133
- /** Sort direction, used only on creation. Anything but `asc` means `desc`. */
134
- sort: SortDir.optional().catch(undefined),
135
- /** Keep zero scores visible, used only on creation. */
136
- showZeros: Truthy.optional(),
137
- /** Any JSON stored with the entry (a character, a run seed). */
138
- meta: Json.optional(),
139
- }),
140
- response: z.object({
141
- board: Board,
142
- /** The player's stored best after this submission. */
143
- best: z.number().nullable(),
144
- /** The player's rank on the board (`null` if they have no entry). */
145
- rank: Rank.nullable(),
146
- }),
147
- errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
148
- doc: { summary: 'Submit a score (best-only; creates the board on first use)', tags: ['leaderboards'] },
149
- },
150
- }),
151
- };
1
+ /**
2
+ * Leaderboards: score submission, boards and standings. A board belongs to a
3
+ * game and is keyed by an app-chosen {@link Key}; it keeps each player's best
4
+ * score only (highest for `desc` boards, lowest for `asc`).
5
+ */
6
+ import { use } from '@ayepi/core';
7
+ import { z } from 'zod';
8
+ import { Json, Key, Slug, UserId } from '../common';
9
+ import { ErrorBody } from '../errors';
10
+ import { game, requireUser, session } from '../middleware';
11
+ import { RowCount, Truthy } from '../coerce';
12
+
13
+ /** A board key in a path, with the old hub's message when it is malformed. */
14
+ const BoardKeyParams = z.object({
15
+ key: z.string().refine((key) => Key.safeParse(key).success, { error: 'board key must match /^[A-Za-z0-9_.:-]{1,64}$/ (1–64 chars).' }),
16
+ });
17
+
18
+ /**
19
+ * A row count the hub reads like `Number(x) || default`: anything that is not
20
+ * an integer falls back to the default instead of failing.
21
+ */
22
+ const LenientRowCount = RowCount.optional().catch(undefined);
23
+
24
+ /** A board's sort direction: `desc` ranks high scores first, `asc` low scores (times) first. */
25
+ export const SortDir = z.enum(['asc', 'desc']);
26
+
27
+ /** A board's sort direction. */
28
+ export type SortDir = z.infer<typeof SortDir>;
29
+
30
+ /** A 1-based rank on a board. */
31
+ export const Rank = z.number().int().positive();
32
+
33
+ /** A leaderboard's identity, without entries. */
34
+ export const Board = z.object({
35
+ key: Key,
36
+ /** Set by the first submitter or the game's `package.json`; defaults to the key. */
37
+ title: z.string(),
38
+ sortDir: SortDir,
39
+ });
40
+
41
+ /** A leaderboard's identity. */
42
+ export type Board = z.infer<typeof Board>;
43
+
44
+ /**
45
+ * One ranked row of a board. Suspended players and (unless the board opts
46
+ * in) zero scores are left out.
47
+ */
48
+ export const LeaderboardEntry = z.object({
49
+ rank: Rank,
50
+ userId: UserId,
51
+ username: z.string(),
52
+ score: z.number(),
53
+ /** The JSON the game stored with the score, or `null`. */
54
+ meta: Json,
55
+ });
56
+
57
+ /** A ranked board row. */
58
+ export type LeaderboardEntry = z.infer<typeof LeaderboardEntry>;
59
+
60
+ /** Every board of one game, each with its top entries (`GET /leaderboards`, the snapshot). */
61
+ export const GameLeaderboards = z.object({
62
+ slug: Slug,
63
+ /** The game's title (the slug when the game is no longer listed). */
64
+ title: z.string(),
65
+ boards: z.array(Board.extend({ top: z.array(LeaderboardEntry) })),
66
+ });
67
+
68
+ /** One game's boards with their top entries. */
69
+ export type GameLeaderboards = z.infer<typeof GameLeaderboards>;
70
+
71
+ /**
72
+ * The player's own standings in one game (`GET /me/records`, the snapshot):
73
+ * their score and rank on each board they have an entry on.
74
+ */
75
+ export const GameRecords = z.object({
76
+ slug: Slug,
77
+ /** The game's title (the slug when the game is no longer listed). */
78
+ title: z.string(),
79
+ records: z.array(Board.extend({ score: z.number(), rank: Rank })),
80
+ });
81
+
82
+ /** The player's standings in one game. */
83
+ export type GameRecords = z.infer<typeof GameRecords>;
84
+
85
+ /**
86
+ * `?top=` for the cross-game views (`GET /leaderboards`, `GET /me/snapshot`):
87
+ * entries per board, 1 to 50 (clamped), default 5.
88
+ */
89
+ export const TopQuery = z.object({ top: LenientRowCount });
90
+
91
+ /** The leaderboards endpoints. */
92
+ export const leaderboardsEndpoints = {
93
+ ...session.group({
94
+ /** Every board of every game, grouped by game, each with its top entries. */
95
+ listTopOfEveryLeaderboard: {
96
+ method: 'GET', path: '/leaderboards', query: TopQuery,
97
+ response: z.object({ games: z.array(GameLeaderboards) }),
98
+ doc: { summary: 'Every board across every game, with top entries', tags: ['leaderboards'] },
99
+ },
100
+ }),
101
+ ...use(session, game).path('/games/:slug').group({
102
+ listGameLeaderboards: {
103
+ method: 'GET', path: '/leaderboards',
104
+ response: z.object({ boards: z.array(Board) }),
105
+ errors: { 404: ErrorBody },
106
+ doc: { summary: "The game's boards (no entries)", tags: ['leaderboards'] },
107
+ },
108
+ getLeaderboard: {
109
+ method: 'GET', path: '/leaderboards/:key',
110
+ params: BoardKeyParams,
111
+ /** `limit`: rows to return, 1 to 500 (clamped), default 50. */
112
+ query: z.object({ limit: LenientRowCount }),
113
+ response: z.object({ board: Board, entries: z.array(LeaderboardEntry) }),
114
+ errors: { 400: ErrorBody, 404: ErrorBody },
115
+ doc: { summary: "One board's ranked entries (404 if the board doesn't exist)", tags: ['leaderboards'] },
116
+ },
117
+ }),
118
+ ...use(requireUser, game).path('/games/:slug').group({
119
+ /**
120
+ * Submit a score. The first submission creates the board (with `title`,
121
+ * `sort` and `showZeros`; later values are ignored). Only an improvement
122
+ * replaces the player's entry. Boards a game declares with
123
+ * `source: "server"` refuse client scores (403 `server_board`).
124
+ */
125
+ submitScore: {
126
+ method: 'POST', path: '/leaderboards/:key/scores',
127
+ params: BoardKeyParams,
128
+ body: z.object({
129
+ /** A finite number (numeric strings pass, as for the old hub's `Number(x)`). */
130
+ score: z.coerce.number<number>({ error: 'score must be a finite number.' }),
131
+ /** Board title, used only when this call creates the board (non-text is ignored). */
132
+ title: z.string().optional().catch(undefined),
133
+ /** Sort direction, used only on creation. Anything but `asc` means `desc`. */
134
+ sort: SortDir.optional().catch(undefined),
135
+ /** Keep zero scores visible, used only on creation. */
136
+ showZeros: Truthy.optional(),
137
+ /** Any JSON stored with the entry (a character, a run seed). */
138
+ meta: Json.optional(),
139
+ }),
140
+ response: z.object({
141
+ board: Board,
142
+ /** The player's stored best after this submission. */
143
+ best: z.number().nullable(),
144
+ /** The player's rank on the board (`null` if they have no entry). */
145
+ rank: Rank.nullable(),
146
+ }),
147
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
148
+ doc: { summary: 'Submit a score (best-only; creates the board on first use)', tags: ['leaderboards'] },
149
+ },
150
+ }),
151
+ };
@@ -1,142 +1,142 @@
1
- /**
2
- * Direct messages between mutual friends (multiplayer-impl.md §5.2).
3
- *
4
- * "Checked before delivered": text is moderated before it is stored or
5
- * pushed. Free text needs the conversation's chat mode to be `on` (both
6
- * claimed, friends, no parent lock, site switch on); otherwise only
7
- * quick-chat ids go through. Hidden messages and messages from currently
8
- * suspended senders are left out of every read.
9
- */
10
- import { z } from 'zod';
11
- import { Millis, Ok, UserId } from '../common';
12
- import { LooseNumber, requiredId } from '../coerce';
13
- import { PlayerLite } from '../entities';
14
- import { ContentRejected, ErrorBody } from '../errors';
15
- import { requireClaimed } from '../middleware';
16
- import { ChatMode } from '../entities';
17
- import { QuickId } from './social/config';
18
-
19
- /** A message id: a positive integer, increasing over time. */
20
- export const MessageId = z.number().int().positive();
21
-
22
- /** A direct message. */
23
- export const Message = z.object({
24
- id: MessageId,
25
- /** The sender. */
26
- from: UserId,
27
- /** The recipient. */
28
- to: UserId,
29
- /** The delivered (possibly softened) text; a quick message carries its phrase's text. */
30
- text: z.string(),
31
- /** The quick-chat phrase id, for quick messages only. */
32
- quick: QuickId.optional(),
33
- /** When it was sent (epoch ms). */
34
- at: Millis,
35
- /** The recipient has read it. */
36
- read: z.boolean(),
37
- /** Words were swapped by the filter. Only on the sender's own copy. */
38
- softened: z.literal(true).optional(),
39
- });
40
-
41
- /** A direct message. */
42
- export type Message = z.infer<typeof Message>;
43
-
44
- /** A conversation summary: the other player, the last message and how many of theirs you haven't read. */
45
- export const Conversation = z.object({
46
- user: PlayerLite,
47
- last: Message,
48
- unread: z.number().int().nonnegative(),
49
- });
50
-
51
- /** A conversation summary. */
52
- export type Conversation = z.infer<typeof Conversation>;
53
-
54
- /** One page of a conversation's history. */
55
- export const MessagesPage = z.object({
56
- /** Oldest to newest, at most 50. */
57
- messages: z.array(Message),
58
- /** Older messages exist (page with `?before=` the first id). */
59
- more: z.boolean(),
60
- /** The mode the chat UI should render: `off` when DMs are off, `quick` while you are muted. */
61
- mode: ChatMode,
62
- });
63
-
64
- /** A page of history. */
65
- export type MessagesPage = z.infer<typeof MessagesPage>;
66
-
67
- /** A DM to send: free text, or a quick-chat phrase id (exactly one). */
68
- export const SendMessageInput = z.object({
69
- /** Free text (moderated; only when the conversation's mode is `on`). */
70
- text: z.string().optional(),
71
- /** A quick-chat phrase id (any mode but `off`). */
72
- quick: QuickId.optional(),
73
- }).refine((b) => (b.text === undefined) !== (b.quick === undefined), 'Send either text or a quick-chat id.');
74
-
75
- /** A DM to send. */
76
- export type SendMessageInput = z.infer<typeof SendMessageInput>;
77
-
78
- /**
79
- * Unread DMs (the `hello` snapshot's `unread`), ignoring hidden messages and
80
- * suspended senders.
81
- */
82
- export const UnreadSummary = z.object({
83
- /** Total unread. */
84
- dms: z.number().int().nonnegative(),
85
- /** Unread per sender, keyed by the sender's user id (as a decimal string). */
86
- byUser: z.record(z.string().regex(/^\d+$/), z.number().int().positive()),
87
- });
88
-
89
- /** Unread DM counts. */
90
- export type UnreadSummary = z.infer<typeof UnreadSummary>;
91
-
92
- /**
93
- * `:userId`, the other side of the conversation: a claimed, non-suspended
94
- * player other than you, else 404 "No such player." Read like `Number(x)`:
95
- * anything that is not a player's id is the same 404.
96
- */
97
- const Partner = z.object({ userId: LooseNumber.catch(Number.NaN) });
98
-
99
- /** The direct-message endpoints. */
100
- export const messagesEndpoints = {
101
- ...requireClaimed.group({
102
- listConversations: {
103
- method: 'GET', path: '/social/conversations',
104
- response: z.object({
105
- /** Newest first, one per partner, at most 200. */
106
- conversations: z.array(Conversation),
107
- }),
108
- errors: { 401: ErrorBody, 403: ErrorBody },
109
- doc: { summary: 'Your conversations, newest first', tags: ['messages'] },
110
- },
111
- listDirectMessages: {
112
- method: 'GET', path: '/social/messages/:userId', params: Partner,
113
- query: z.object({
114
- /** Only messages older than this message id; `0` or anything not a number means from the newest. */
115
- before: LooseNumber.optional().catch(undefined),
116
- }),
117
- response: MessagesPage,
118
- errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
119
- doc: { summary: 'One page of history with a player', tags: ['messages'] },
120
- },
121
- sendDirectMessage: {
122
- method: 'POST', path: '/social/messages/:userId', params: Partner, body: SendMessageInput,
123
- response: z.object({ message: Message }),
124
- errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 422: ContentRejected },
125
- doc: {
126
- summary: 'Send a DM to a friend',
127
- description: '403 `not_friends` or `dms_closed`; 422 when the text is rejected, the chat mode forbids it, or you are sending too fast.',
128
- tags: ['messages'],
129
- },
130
- },
131
- markDirectMessagesRead: {
132
- method: 'POST', path: '/social/messages/:userId/read', params: Partner,
133
- body: z.object({
134
- /** Mark their messages to you up to and including this id. */
135
- upTo: requiredId('upTo must be a message id.'),
136
- }),
137
- response: Ok,
138
- errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
139
- doc: { summary: "Mark a friend's messages to you as read", tags: ['messages'] },
140
- },
141
- }),
142
- };
1
+ /**
2
+ * Direct messages between mutual friends (multiplayer-impl.md §5.2).
3
+ *
4
+ * "Checked before delivered": text is moderated before it is stored or
5
+ * pushed. Free text needs the conversation's chat mode to be `on` (both
6
+ * claimed, friends, no parent lock, site switch on); otherwise only
7
+ * quick-chat ids go through. Hidden messages and messages from currently
8
+ * suspended senders are left out of every read.
9
+ */
10
+ import { z } from 'zod';
11
+ import { Millis, Ok, UserId } from '../common';
12
+ import { LooseNumber, requiredId } from '../coerce';
13
+ import { PlayerLite } from '../entities';
14
+ import { ContentRejected, ErrorBody } from '../errors';
15
+ import { requireClaimed } from '../middleware';
16
+ import { ChatMode } from '../entities';
17
+ import { QuickId } from './social/config';
18
+
19
+ /** A message id: a positive integer, increasing over time. */
20
+ export const MessageId = z.number().int().positive();
21
+
22
+ /** A direct message. */
23
+ export const Message = z.object({
24
+ id: MessageId,
25
+ /** The sender. */
26
+ from: UserId,
27
+ /** The recipient. */
28
+ to: UserId,
29
+ /** The delivered (possibly softened) text; a quick message carries its phrase's text. */
30
+ text: z.string(),
31
+ /** The quick-chat phrase id, for quick messages only. */
32
+ quick: QuickId.optional(),
33
+ /** When it was sent (epoch ms). */
34
+ at: Millis,
35
+ /** The recipient has read it. */
36
+ read: z.boolean(),
37
+ /** Words were swapped by the filter. Only on the sender's own copy. */
38
+ softened: z.literal(true).optional(),
39
+ });
40
+
41
+ /** A direct message. */
42
+ export type Message = z.infer<typeof Message>;
43
+
44
+ /** A conversation summary: the other player, the last message and how many of theirs you haven't read. */
45
+ export const Conversation = z.object({
46
+ user: PlayerLite,
47
+ last: Message,
48
+ unread: z.number().int().nonnegative(),
49
+ });
50
+
51
+ /** A conversation summary. */
52
+ export type Conversation = z.infer<typeof Conversation>;
53
+
54
+ /** One page of a conversation's history. */
55
+ export const MessagesPage = z.object({
56
+ /** Oldest to newest, at most 50. */
57
+ messages: z.array(Message),
58
+ /** Older messages exist (page with `?before=` the first id). */
59
+ more: z.boolean(),
60
+ /** The mode the chat UI should render: `off` when DMs are off, `quick` while you are muted. */
61
+ mode: ChatMode,
62
+ });
63
+
64
+ /** A page of history. */
65
+ export type MessagesPage = z.infer<typeof MessagesPage>;
66
+
67
+ /** A DM to send: free text, or a quick-chat phrase id (exactly one). */
68
+ export const SendMessageInput = z.object({
69
+ /** Free text (moderated; only when the conversation's mode is `on`). */
70
+ text: z.string().optional(),
71
+ /** A quick-chat phrase id (any mode but `off`). */
72
+ quick: QuickId.optional(),
73
+ }).refine((b) => (b.text === undefined) !== (b.quick === undefined), 'Send either text or a quick-chat id.');
74
+
75
+ /** A DM to send. */
76
+ export type SendMessageInput = z.infer<typeof SendMessageInput>;
77
+
78
+ /**
79
+ * Unread DMs (the `hello` snapshot's `unread`), ignoring hidden messages and
80
+ * suspended senders.
81
+ */
82
+ export const UnreadSummary = z.object({
83
+ /** Total unread. */
84
+ dms: z.number().int().nonnegative(),
85
+ /** Unread per sender, keyed by the sender's user id (as a decimal string). */
86
+ byUser: z.record(z.string().regex(/^\d+$/), z.number().int().positive()),
87
+ });
88
+
89
+ /** Unread DM counts. */
90
+ export type UnreadSummary = z.infer<typeof UnreadSummary>;
91
+
92
+ /**
93
+ * `:userId`, the other side of the conversation: a claimed, non-suspended
94
+ * player other than you, else 404 "No such player." Read like `Number(x)`:
95
+ * anything that is not a player's id is the same 404.
96
+ */
97
+ const Partner = z.object({ userId: LooseNumber.catch(Number.NaN) });
98
+
99
+ /** The direct-message endpoints. */
100
+ export const messagesEndpoints = {
101
+ ...requireClaimed.group({
102
+ listConversations: {
103
+ method: 'GET', path: '/social/conversations',
104
+ response: z.object({
105
+ /** Newest first, one per partner, at most 200. */
106
+ conversations: z.array(Conversation),
107
+ }),
108
+ errors: { 401: ErrorBody, 403: ErrorBody },
109
+ doc: { summary: 'Your conversations, newest first', tags: ['messages'] },
110
+ },
111
+ listDirectMessages: {
112
+ method: 'GET', path: '/social/messages/:userId', params: Partner,
113
+ query: z.object({
114
+ /** Only messages older than this message id; `0` or anything not a number means from the newest. */
115
+ before: LooseNumber.optional().catch(undefined),
116
+ }),
117
+ response: MessagesPage,
118
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
119
+ doc: { summary: 'One page of history with a player', tags: ['messages'] },
120
+ },
121
+ sendDirectMessage: {
122
+ method: 'POST', path: '/social/messages/:userId', params: Partner, body: SendMessageInput,
123
+ response: z.object({ message: Message }),
124
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 422: ContentRejected },
125
+ doc: {
126
+ summary: 'Send a DM to a friend',
127
+ description: '403 `not_friends` or `dms_closed`; 422 when the text is rejected, the chat mode forbids it, or you are sending too fast.',
128
+ tags: ['messages'],
129
+ },
130
+ },
131
+ markDirectMessagesRead: {
132
+ method: 'POST', path: '/social/messages/:userId/read', params: Partner,
133
+ body: z.object({
134
+ /** Mark their messages to you up to and including this id. */
135
+ upTo: requiredId('upTo must be a message id.'),
136
+ }),
137
+ response: Ok,
138
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
139
+ doc: { summary: "Mark a friend's messages to you as read", tags: ['messages'] },
140
+ },
141
+ }),
142
+ };
@@ -1,36 +1,36 @@
1
- /**
2
- * Group chat in hub rooms and parties (multiplayer.md §4.3, §4.8): what a
3
- * player sends, and the moderated line everyone receives.
4
- */
5
- import { z } from 'zod';
6
- import { UserId } from '../../common';
7
-
8
-
9
- /**
10
- * A chat send: a quick-chat phrase id, or free text. When `quick` is set it
11
- * wins and `text` is ignored.
12
- */
13
- export const ChatSend = z.object({
14
- /** Free text; only allowed in mode `on`, then moderated. */
15
- text: z.string().optional(),
16
- /** A quick-chat phrase id (hub phrase, `e:<emoji>` emote or game phrase). */
17
- quick: z.string().optional(),
18
- });
19
-
20
- /** The line as delivered: the (possibly softened) text, and the phrase id for quick-chat. */
21
- export const ChatLine = z.object({
22
- text: z.string(),
23
- /** Set when the line is a quick-chat phrase. */
24
- quick: z.string().optional(),
25
- /** Moderation reworded the text. */
26
- softened: z.literal(true).optional(),
27
- });
28
-
29
- /** A delivered chat line. */
30
- export type ChatLine = z.infer<typeof ChatLine>;
31
-
32
- /** A chat line pushed to a room or party, with its sender. */
33
- export const ChatLineEvent = ChatLine.extend({ from: UserId });
34
-
35
- /** The error codes a chat send can fail with (besides its surface's own). */
36
- export const CHAT_ERRORS = ['rate_limited', 'content_rejected', 'bad_quick'] as const;
1
+ /**
2
+ * Group chat in hub rooms and parties (multiplayer.md §4.3, §4.8): what a
3
+ * player sends, and the moderated line everyone receives.
4
+ */
5
+ import { z } from 'zod';
6
+ import { UserId } from '../../common';
7
+
8
+
9
+ /**
10
+ * A chat send: a quick-chat phrase id, or free text. When `quick` is set it
11
+ * wins and `text` is ignored.
12
+ */
13
+ export const ChatSend = z.object({
14
+ /** Free text; only allowed in mode `on`, then moderated. */
15
+ text: z.string().optional(),
16
+ /** A quick-chat phrase id (hub phrase, `e:<emoji>` emote or game phrase). */
17
+ quick: z.string().optional(),
18
+ });
19
+
20
+ /** The line as delivered: the (possibly softened) text, and the phrase id for quick-chat. */
21
+ export const ChatLine = z.object({
22
+ text: z.string(),
23
+ /** Set when the line is a quick-chat phrase. */
24
+ quick: z.string().optional(),
25
+ /** Moderation reworded the text. */
26
+ softened: z.literal(true).optional(),
27
+ });
28
+
29
+ /** A delivered chat line. */
30
+ export type ChatLine = z.infer<typeof ChatLine>;
31
+
32
+ /** A chat line pushed to a room or party, with its sender. */
33
+ export const ChatLineEvent = ChatLine.extend({ from: UserId });
34
+
35
+ /** The error codes a chat send can fail with (besides its surface's own). */
36
+ export const CHAT_ERRORS = ['rate_limited', 'content_rejected', 'bad_quick'] as const;