@diffenderfer-games/hub-contract 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.
- package/LICENSE +21 -0
- package/README.md +25 -2
- package/dist/index.d.ts +8454 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4855 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -4
- package/src/api/analytics.ts +81 -0
- package/src/api/auth.ts +77 -0
- package/src/api/catalog.ts +171 -0
- package/src/api/daily.ts +122 -0
- package/src/api/economy/bundle.ts +69 -0
- package/src/api/economy/inventory.ts +57 -0
- package/src/api/economy/trades.ts +154 -0
- package/src/api/economy.ts +15 -0
- package/src/api/index.ts +85 -0
- package/src/api/leaderboards.ts +151 -0
- package/src/api/messages.ts +142 -0
- package/src/api/mp/chat.ts +36 -0
- package/src/api/mp/parties.ts +108 -0
- package/src/api/mp/races.ts +211 -0
- package/src/api/mp/rooms.ts +90 -0
- package/src/api/mp/snapshot.ts +17 -0
- package/src/api/mp.ts +110 -0
- package/src/api/notify.ts +111 -0
- package/src/api/progress/game-data.ts +189 -0
- package/src/api/progress.ts +59 -0
- package/src/api/race.ts +47 -0
- package/src/api/reports.ts +208 -0
- package/src/api/social/config.ts +47 -0
- package/src/api/social/endpoints.ts +179 -0
- package/src/api/social/people.ts +118 -0
- package/src/api/social/presence.ts +95 -0
- package/src/api/social.ts +9 -0
- package/src/api/version.ts +40 -0
- package/src/coerce.ts +50 -0
- package/src/common.ts +42 -0
- package/src/entities.ts +62 -0
- package/src/errors/codes/core.ts +10 -0
- package/src/errors/codes/multiplayer.ts +43 -0
- package/src/errors/codes/play.ts +5 -0
- package/src/errors/codes/social.ts +31 -0
- package/src/errors/index.ts +88 -0
- package/src/index.ts +16 -0
- package/src/limits.ts +100 -0
- package/src/logic/daily.ts +54 -0
- package/src/logic/index.ts +8 -0
- package/src/logic/presence.ts +62 -0
- package/src/logic/race.ts +169 -0
- package/src/logic/time.ts +81 -0
- package/src/manifest/changes.ts +55 -0
- package/src/manifest/controls.ts +42 -0
- package/src/manifest/fields.ts +89 -0
- package/src/manifest/game.ts +132 -0
- package/src/manifest/index.ts +12 -0
- package/src/manifest/inject.ts +31 -0
- package/src/manifest/leaderboards.ts +29 -0
- package/src/manifest/multiplayer.ts +71 -0
- package/src/manifest/race.ts +93 -0
- package/src/middleware.ts +47 -0
- package/src/realtime/define.ts +52 -0
- package/src/realtime/envelope.ts +67 -0
- package/src/realtime/events/multiplayer.ts +8 -0
- package/src/realtime/events/parties.ts +31 -0
- package/src/realtime/events/races.ts +27 -0
- package/src/realtime/events/rooms.ts +48 -0
- package/src/realtime/events/social.ts +81 -0
- package/src/realtime/index.ts +24 -0
- package/src/realtime/ops/empty.ts +8 -0
- package/src/realtime/ops/hello.ts +40 -0
- package/src/realtime/ops/multiplayer.ts +8 -0
- package/src/realtime/ops/parties.ts +13 -0
- package/src/realtime/ops/races.ts +99 -0
- package/src/realtime/ops/rooms.ts +122 -0
- package/src/realtime/ops/social.ts +58 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The caller's own per-game data: profile, save slots, numeric stats, and
|
|
3
|
+
* resetting a game. Guests count: a guest is a real account row.
|
|
4
|
+
*/
|
|
5
|
+
import { use } from '@ayepi/core';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { Json, Key, Millis, Ok, Slug } from '../../common';
|
|
8
|
+
import { ContentRejected, ErrorBody } from '../../errors';
|
|
9
|
+
import { game, requireUser, session } from '../../middleware';
|
|
10
|
+
import { LooseText, requiredText } from '../../coerce';
|
|
11
|
+
|
|
12
|
+
/** A player's profile in one game. */
|
|
13
|
+
export const Profile = z.object({
|
|
14
|
+
game: Slug,
|
|
15
|
+
/** Shown to other players; `null` when never set. */
|
|
16
|
+
displayName: z.string().nullable(),
|
|
17
|
+
/** Any JSON the game stores (`{}` by default). */
|
|
18
|
+
data: Json,
|
|
19
|
+
/** Epoch ms. */
|
|
20
|
+
updatedAt: Millis,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** A game profile. */
|
|
24
|
+
export type Profile = z.infer<typeof Profile>;
|
|
25
|
+
|
|
26
|
+
/** A save slot's metadata (no payload). */
|
|
27
|
+
export const SaveMeta = z.object({
|
|
28
|
+
slot: Key,
|
|
29
|
+
label: z.string().nullable(),
|
|
30
|
+
/** Epoch ms. */
|
|
31
|
+
updatedAt: Millis,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** A save slot's metadata. */
|
|
35
|
+
export type SaveMeta = z.infer<typeof SaveMeta>;
|
|
36
|
+
|
|
37
|
+
/** A save slot with its payload. */
|
|
38
|
+
export const Save = SaveMeta.extend({
|
|
39
|
+
/** The stored JSON (`null` if it could not be read back). */
|
|
40
|
+
data: Json,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** A save slot. */
|
|
44
|
+
export type Save = z.infer<typeof Save>;
|
|
45
|
+
|
|
46
|
+
/** A player's numeric stats in one game: stat {@link Key} to value. */
|
|
47
|
+
export const Stats = z.record(Key, z.number());
|
|
48
|
+
|
|
49
|
+
/** A player's stats. */
|
|
50
|
+
export type Stats = z.infer<typeof Stats>;
|
|
51
|
+
|
|
52
|
+
/** How a stat update combines with the stored value. */
|
|
53
|
+
export const StatMode = z.enum(['set', 'incr', 'max']);
|
|
54
|
+
|
|
55
|
+
/** A stat update mode. */
|
|
56
|
+
export type StatMode = z.infer<typeof StatMode>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A {@link Key} read like the hub's `validKey` (`String(x || '')`), failing
|
|
60
|
+
* with its message: "<label> must match /^[A-Za-z0-9_.:-]{1,64}$/ (1–64 chars)."
|
|
61
|
+
*/
|
|
62
|
+
function keyField(label: string) {
|
|
63
|
+
const message = `${label} must match /^[A-Za-z0-9_.:-]{1,64}$/ (1–64 chars).`;
|
|
64
|
+
return requiredText(message).refine((text) => Key.safeParse(text).success, message);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A number read like the hub's `finiteNumber` (`Number(x)`), failing with "<label> must be a finite number.". */
|
|
68
|
+
function finiteNumberField(label: string) {
|
|
69
|
+
return z.coerce.number<number>({ error: `${label} must be a finite number.` });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A stat update's mode. The hub reads `body.mode || 'set'`, so a falsy value
|
|
74
|
+
* (`null`, `''`, `0`, `false`) also means `set`. Clients should send a
|
|
75
|
+
* {@link StatMode} or nothing.
|
|
76
|
+
*/
|
|
77
|
+
const StatModeField = z
|
|
78
|
+
.union([StatMode, z.literal(['', 0, false]), z.null()], { error: 'mode must be set, incr, or max.' })
|
|
79
|
+
.optional()
|
|
80
|
+
.transform((mode) => mode || 'set');
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A save's payload: any JSON, but present. (The union with `never` exists only
|
|
84
|
+
* so a missing value fails with the hub's message rather than zod's.)
|
|
85
|
+
*/
|
|
86
|
+
const SaveData = z.union([Json, z.never()], { error: 'Missing "data".' });
|
|
87
|
+
|
|
88
|
+
/** `:slot` of a save. */
|
|
89
|
+
const SlotParams = z.object({ slot: keyField('slot') });
|
|
90
|
+
|
|
91
|
+
/** The per-game data endpoints. */
|
|
92
|
+
export const gameDataEndpoints = {
|
|
93
|
+
...use(session, game).path('/games/:slug').group({
|
|
94
|
+
/**
|
|
95
|
+
* A player's stats. Defaults to the caller's own (401 when signed out);
|
|
96
|
+
* `?user=<id>` reads anyone's, since stats are public like a profile.
|
|
97
|
+
*/
|
|
98
|
+
getGameStats: {
|
|
99
|
+
method: 'GET', path: '/stats',
|
|
100
|
+
query: z.object({
|
|
101
|
+
/** Whose stats to read (a user id); omit for the caller's. */
|
|
102
|
+
user: finiteNumberField('user').optional(),
|
|
103
|
+
}),
|
|
104
|
+
response: z.object({ stats: Stats }),
|
|
105
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 404: ErrorBody },
|
|
106
|
+
doc: { summary: "A player's stats in this game", tags: ['progress'] },
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
...use(requireUser, game).path('/games/:slug').group({
|
|
110
|
+
getGameProfile: {
|
|
111
|
+
method: 'GET', path: '/profile',
|
|
112
|
+
response: z.object({ profile: Profile.nullable() }),
|
|
113
|
+
errors: { 401: ErrorBody, 404: ErrorBody },
|
|
114
|
+
doc: { summary: "The caller's profile in this game, or null", tags: ['progress'] },
|
|
115
|
+
},
|
|
116
|
+
/**
|
|
117
|
+
* Create or replace the caller's profile. A new or changed display name
|
|
118
|
+
* is moderated (422) because other players see it.
|
|
119
|
+
*/
|
|
120
|
+
setGameProfile: {
|
|
121
|
+
method: 'PUT', path: '/profile',
|
|
122
|
+
body: z.object({
|
|
123
|
+
/** Up to 60 characters (longer is clipped); `null` or missing clears it. */
|
|
124
|
+
displayName: LooseText.nullish(),
|
|
125
|
+
/** Replaces the stored data; missing stores `{}`. */
|
|
126
|
+
data: Json.optional(),
|
|
127
|
+
}),
|
|
128
|
+
response: z.object({ profile: Profile }),
|
|
129
|
+
errors: { 401: ErrorBody, 404: ErrorBody, 422: ContentRejected },
|
|
130
|
+
doc: { summary: "Create or replace the caller's profile in this game", tags: ['progress'] },
|
|
131
|
+
},
|
|
132
|
+
listSaves: {
|
|
133
|
+
method: 'GET', path: '/saves',
|
|
134
|
+
response: z.object({ saves: z.array(SaveMeta) }),
|
|
135
|
+
errors: { 401: ErrorBody, 404: ErrorBody },
|
|
136
|
+
doc: { summary: "The caller's save slots, newest first (no payloads)", tags: ['progress'] },
|
|
137
|
+
},
|
|
138
|
+
getSave: {
|
|
139
|
+
method: 'GET', path: '/saves/:slot', params: SlotParams,
|
|
140
|
+
response: z.object({ save: Save.nullable() }),
|
|
141
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 404: ErrorBody },
|
|
142
|
+
doc: { summary: 'One save slot, or null when empty', tags: ['progress'] },
|
|
143
|
+
},
|
|
144
|
+
writeSave: {
|
|
145
|
+
method: 'PUT', path: '/saves/:slot', params: SlotParams,
|
|
146
|
+
body: z.object({
|
|
147
|
+
/** Any JSON up to 64 KB serialized (`HUB_MAX_SAVE_BYTES`); 413 over. */
|
|
148
|
+
data: SaveData,
|
|
149
|
+
/** Up to 80 characters (longer is clipped). */
|
|
150
|
+
label: LooseText.nullish(),
|
|
151
|
+
}),
|
|
152
|
+
response: z.object({
|
|
153
|
+
ok: z.literal(true),
|
|
154
|
+
/** Epoch ms. */
|
|
155
|
+
updatedAt: Millis,
|
|
156
|
+
}),
|
|
157
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 404: ErrorBody, 413: ErrorBody },
|
|
158
|
+
doc: { summary: 'Create or overwrite a save slot', tags: ['progress'] },
|
|
159
|
+
},
|
|
160
|
+
deleteSave: {
|
|
161
|
+
method: 'DELETE', path: '/saves/:slot', params: SlotParams, response: Ok,
|
|
162
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 404: ErrorBody },
|
|
163
|
+
doc: { summary: 'Delete a save slot (idempotent)', tags: ['progress'] },
|
|
164
|
+
},
|
|
165
|
+
/** Update one stat. Backs the client's `setStat`, `incrStat` and `maxStat`. */
|
|
166
|
+
updateGameStat: {
|
|
167
|
+
method: 'POST', path: '/stats',
|
|
168
|
+
body: z.object({
|
|
169
|
+
key: keyField('key'),
|
|
170
|
+
value: finiteNumberField('value'),
|
|
171
|
+
/** Default `set`. */
|
|
172
|
+
mode: StatModeField,
|
|
173
|
+
}),
|
|
174
|
+
response: z.object({ key: Key, value: z.number() }),
|
|
175
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 404: ErrorBody },
|
|
176
|
+
doc: { summary: 'Set, add to, or raise one stat; returns its new value', tags: ['progress'] },
|
|
177
|
+
},
|
|
178
|
+
/**
|
|
179
|
+
* Wipe the caller's saves, stats, inventory and coins in this game.
|
|
180
|
+
* Pending trades are closed first, refunding other players' escrow.
|
|
181
|
+
* Leaderboard entries and the profile are kept.
|
|
182
|
+
*/
|
|
183
|
+
resetMyGameProgress: {
|
|
184
|
+
method: 'POST', path: '/reset', response: Ok,
|
|
185
|
+
errors: { 401: ErrorBody, 404: ErrorBody },
|
|
186
|
+
doc: { summary: "Reset the caller's progress in this game", tags: ['progress'] },
|
|
187
|
+
},
|
|
188
|
+
}),
|
|
189
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-game player data: profiles, saves, stats, game reset, and the player's
|
|
3
|
+
* records and snapshot. The per-game endpoints live in `progress/game-data.ts`.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { Slug } from '../common';
|
|
7
|
+
import { SessionUser } from '../entities';
|
|
8
|
+
import { session } from '../middleware';
|
|
9
|
+
import { Inventory } from './economy/bundle';
|
|
10
|
+
import { GameLeaderboards, GameRecords, TopQuery } from './leaderboards';
|
|
11
|
+
import { gameDataEndpoints, Profile, Save, Stats } from './progress/game-data';
|
|
12
|
+
|
|
13
|
+
export { Profile, Save, SaveMeta, StatMode, Stats } from './progress/game-data';
|
|
14
|
+
|
|
15
|
+
/** Everything the caller has in one game, as the snapshot carries it. */
|
|
16
|
+
export const GameSnapshot = z.object({
|
|
17
|
+
profile: Profile.nullable(),
|
|
18
|
+
/** Newest first, with payloads. */
|
|
19
|
+
saves: z.array(Save),
|
|
20
|
+
stats: Stats,
|
|
21
|
+
inventory: Inventory,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** One game's slice of the snapshot. */
|
|
25
|
+
export type GameSnapshot = z.infer<typeof GameSnapshot>;
|
|
26
|
+
|
|
27
|
+
/** The progress endpoints. */
|
|
28
|
+
export const progressEndpoints = {
|
|
29
|
+
...gameDataEndpoints,
|
|
30
|
+
...session.group({
|
|
31
|
+
/**
|
|
32
|
+
* The caller's leaderboard standings, grouped by game (empty when
|
|
33
|
+
* signed out). `?game=` limits it to one game; empty means every game,
|
|
34
|
+
* and text that is no game's slug matches nothing, as in the old hub.
|
|
35
|
+
*/
|
|
36
|
+
listMyLeaderboardRecords: {
|
|
37
|
+
method: 'GET', path: '/me/records',
|
|
38
|
+
query: z.object({ game: z.string().optional() }),
|
|
39
|
+
response: z.object({ games: z.array(GameRecords) }),
|
|
40
|
+
doc: { summary: "The caller's leaderboard standings", tags: ['progress'] },
|
|
41
|
+
},
|
|
42
|
+
/**
|
|
43
|
+
* Everything the caller has, in one request, to warm the offline cache
|
|
44
|
+
* for every game (the client's `prefetchAll`). Signed out, only the
|
|
45
|
+
* global boards are filled in.
|
|
46
|
+
*/
|
|
47
|
+
getMyOfflineSnapshot: {
|
|
48
|
+
method: 'GET', path: '/me/snapshot', query: TopQuery,
|
|
49
|
+
response: z.object({
|
|
50
|
+
user: SessionUser.nullable(),
|
|
51
|
+
/** By slug: every game the caller has any profile, save, stat or holding in. */
|
|
52
|
+
games: z.record(Slug, GameSnapshot),
|
|
53
|
+
records: z.array(GameRecords),
|
|
54
|
+
leaderboards: z.array(GameLeaderboards),
|
|
55
|
+
}),
|
|
56
|
+
doc: { summary: "The caller's data across every game, plus the global boards", tags: ['progress'] },
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
};
|
package/src/api/race.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Races: the signed-in player's own race history (docs/plans/race.md §7).
|
|
3
|
+
* The live race runs over the socket (`race.*` ops and events).
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { ErrorBody } from '../errors';
|
|
7
|
+
import { requireActive } from '../middleware';
|
|
8
|
+
import { RaceRecord } from './mp/races';
|
|
9
|
+
|
|
10
|
+
/** An optional positive-integer query param; empty means "not set". */
|
|
11
|
+
const OptionalCount = z.union([z.literal(''), z.coerce.number().int().positive()]).optional();
|
|
12
|
+
|
|
13
|
+
/** `GET /race/history` filters. */
|
|
14
|
+
export const RaceHistoryQuery = z.object({
|
|
15
|
+
/** Only this game (default: every game). */
|
|
16
|
+
game: z.union([z.literal(''), z.string().regex(/^[A-Za-z0-9_-]{1,64}$/)]).optional(),
|
|
17
|
+
/** Only races with this player (a user id). */
|
|
18
|
+
with: OptionalCount,
|
|
19
|
+
/** Only races older than this race id (paging). */
|
|
20
|
+
before: OptionalCount,
|
|
21
|
+
/** Page size, 1 to 50 (default 20; larger values are capped). */
|
|
22
|
+
limit: OptionalCount,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** A page of the player's races, newest first. */
|
|
26
|
+
export const RaceHistory = z.object({
|
|
27
|
+
races: z.array(RaceRecord),
|
|
28
|
+
/** More races exist before the last one. */
|
|
29
|
+
more: z.boolean(),
|
|
30
|
+
/** Totals over every race matching the filters (not just this page). */
|
|
31
|
+
summary: z.object({
|
|
32
|
+
races: z.number().int().nonnegative(),
|
|
33
|
+
wins: z.number().int().nonnegative(),
|
|
34
|
+
losses: z.number().int().nonnegative(),
|
|
35
|
+
}),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** The race endpoints. */
|
|
39
|
+
export const raceEndpoints = {
|
|
40
|
+
...requireActive.group({
|
|
41
|
+
listMyRaceHistory: {
|
|
42
|
+
method: 'GET', path: '/race/history', query: RaceHistoryQuery, response: RaceHistory,
|
|
43
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody },
|
|
44
|
+
doc: { summary: "The signed-in player's decided races, newest first", tags: ['race'] },
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
@@ -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>;
|