@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
package/dist/index.js
ADDED
|
@@ -0,0 +1,4855 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ctx, middleware, spec, use } from "@ayepi/core";
|
|
3
|
+
//#region src/common.ts
|
|
4
|
+
/**
|
|
5
|
+
* Primitives shared by every part of the contract. Each one is the single
|
|
6
|
+
* definition of its rule: the server validates with it, the client's types
|
|
7
|
+
* come from it, and the docs are generated from it.
|
|
8
|
+
*/
|
|
9
|
+
/** A user id: a positive integer. In paths it arrives as a string and is coerced. */
|
|
10
|
+
const UserId = z.number().int().positive();
|
|
11
|
+
/** A user id taken from a URL path segment (`/players/:userId`). */
|
|
12
|
+
const UserIdParam = z.coerce.number().int().positive();
|
|
13
|
+
/** A numeric row id taken from a URL path segment (`/trades/:id`, `/reports/:id`). */
|
|
14
|
+
const IdParam = z.coerce.number().int().positive();
|
|
15
|
+
/** Milliseconds since the Unix epoch. */
|
|
16
|
+
const Millis = z.number().int().nonnegative();
|
|
17
|
+
/** A game's slug: its folder name under `apps/`, as used in `/<slug>/` URLs. */
|
|
18
|
+
const Slug = z.string().min(1).max(64);
|
|
19
|
+
/**
|
|
20
|
+
* An app-chosen identifier: a save slot, leaderboard key or stat name.
|
|
21
|
+
* Letters, digits and `_ . : -`, 1 to 64 characters.
|
|
22
|
+
*/
|
|
23
|
+
const Key = z.string().regex(/^[A-Za-z0-9_.:-]{1,64}$/);
|
|
24
|
+
/** Any JSON value (saves, metadata, room state). */
|
|
25
|
+
const Json = z.json();
|
|
26
|
+
/** The `{ ok: true }` body many write endpoints answer with. */
|
|
27
|
+
const Ok = z.object({ ok: z.literal(true) });
|
|
28
|
+
/** A UTC day key, `YYYY-MM-DD`. */
|
|
29
|
+
const DayKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
|
30
|
+
/** A `#rrggbb` colour. */
|
|
31
|
+
const HexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/);
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/entities.ts
|
|
34
|
+
/**
|
|
35
|
+
* Entity shapes that several areas of the API share. Shapes used by only one
|
|
36
|
+
* area live in that area's `api/` file.
|
|
37
|
+
*/
|
|
38
|
+
/** Account roles. An empty string is a normal player. */
|
|
39
|
+
const Role = z.enum(["", "admin"]);
|
|
40
|
+
/**
|
|
41
|
+
* The signed-in user as the hub sees it (`GET /me`, socket `hello`).
|
|
42
|
+
* `id`, `username` and `guest` are the long-standing fields; the rest drive
|
|
43
|
+
* social permissions and are only ever sent to the user themself.
|
|
44
|
+
*/
|
|
45
|
+
const SessionUser = z.object({
|
|
46
|
+
id: UserId,
|
|
47
|
+
username: z.string(),
|
|
48
|
+
/** A temporary account with no password yet; it can be claimed. */
|
|
49
|
+
guest: z.boolean(),
|
|
50
|
+
role: Role,
|
|
51
|
+
/** Avatar token, `''` for the default. */
|
|
52
|
+
avatar: z.string(),
|
|
53
|
+
/** A parent locked chat for this account. */
|
|
54
|
+
chatLock: z.boolean(),
|
|
55
|
+
/** Muted until this time (epoch ms), `0` when not muted. */
|
|
56
|
+
mutedUntil: Millis,
|
|
57
|
+
/** Suspended until this time (epoch ms), `0` when not suspended. */
|
|
58
|
+
suspendedUntil: Millis
|
|
59
|
+
});
|
|
60
|
+
/** The short account shape the auth endpoints return for a new session. */
|
|
61
|
+
const AuthUser = SessionUser.pick({
|
|
62
|
+
id: true,
|
|
63
|
+
username: true,
|
|
64
|
+
guest: true
|
|
65
|
+
});
|
|
66
|
+
/**
|
|
67
|
+
* The effective chat mode of a conversation, room or the whole site: free
|
|
68
|
+
* text (still filtered), quick-chat phrases only, or no messaging at all.
|
|
69
|
+
* The strictest of the operator setting, the admin switch and every
|
|
70
|
+
* participant (guests and parent-locked accounts are `quick`) applies.
|
|
71
|
+
*/
|
|
72
|
+
const ChatMode = z.enum([
|
|
73
|
+
"on",
|
|
74
|
+
"quick",
|
|
75
|
+
"off"
|
|
76
|
+
]);
|
|
77
|
+
/** Another player as lists and cards show them. */
|
|
78
|
+
const PlayerLite = z.object({
|
|
79
|
+
id: UserId,
|
|
80
|
+
username: z.string(),
|
|
81
|
+
/** Avatar token; never empty (a default is derived from the id). */
|
|
82
|
+
avatar: z.string(),
|
|
83
|
+
guest: z.boolean()
|
|
84
|
+
});
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/errors/codes/core.ts
|
|
87
|
+
/** Error codes any endpoint or socket op can return. */
|
|
88
|
+
const CORE_CODES = [
|
|
89
|
+
"bad_request",
|
|
90
|
+
"signed_out",
|
|
91
|
+
"suspended",
|
|
92
|
+
"claim_required",
|
|
93
|
+
"rate_limited",
|
|
94
|
+
"content_rejected",
|
|
95
|
+
"too_large"
|
|
96
|
+
];
|
|
97
|
+
//#endregion
|
|
98
|
+
//#region src/errors/codes/multiplayer.ts
|
|
99
|
+
/** Error codes from invites, parties, hub rooms, races and the socket envelope. */
|
|
100
|
+
const MULTIPLAYER_CODES = [
|
|
101
|
+
"unknown_op",
|
|
102
|
+
"hello_required",
|
|
103
|
+
"not_multiplayer",
|
|
104
|
+
"not_supported",
|
|
105
|
+
"bad_mode",
|
|
106
|
+
"bad_target",
|
|
107
|
+
"cannot_invite",
|
|
108
|
+
"already_member",
|
|
109
|
+
"party_full",
|
|
110
|
+
"invite_gone",
|
|
111
|
+
"party_gone",
|
|
112
|
+
"not_leader",
|
|
113
|
+
"bad_info",
|
|
114
|
+
"launch_invalid",
|
|
115
|
+
"not_joinable",
|
|
116
|
+
"bad_quick",
|
|
117
|
+
"room_not_found",
|
|
118
|
+
"not_in_room",
|
|
119
|
+
"not_host",
|
|
120
|
+
"too_many_rooms",
|
|
121
|
+
"banned",
|
|
122
|
+
"in_progress",
|
|
123
|
+
"room_full",
|
|
124
|
+
"not_ready",
|
|
125
|
+
"ended",
|
|
126
|
+
"bad_patch",
|
|
127
|
+
"race_room",
|
|
128
|
+
"not_race",
|
|
129
|
+
"not_in_race",
|
|
130
|
+
"not_started",
|
|
131
|
+
"race_over",
|
|
132
|
+
"too_fast",
|
|
133
|
+
"bad_stats",
|
|
134
|
+
"bad_param"
|
|
135
|
+
];
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region src/errors/codes/play.ts
|
|
138
|
+
/** Error codes from accounts and the single-player API (saves, stats, boards, daily, economy, trades). */
|
|
139
|
+
const PLAY_CODES = ["server_board"];
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region src/errors/codes/social.ts
|
|
142
|
+
/** Error codes from friends, messages, notifications, reports and suggestions. */
|
|
143
|
+
const SOCIAL_CODES = [
|
|
144
|
+
"no_player",
|
|
145
|
+
"bad_avatar",
|
|
146
|
+
"bad_password",
|
|
147
|
+
"friend_requests_off",
|
|
148
|
+
"bad_target",
|
|
149
|
+
"guest_target",
|
|
150
|
+
"blocked",
|
|
151
|
+
"friends_limit",
|
|
152
|
+
"pending_limit",
|
|
153
|
+
"no_request",
|
|
154
|
+
"not_friends",
|
|
155
|
+
"blocks_limit",
|
|
156
|
+
"dms_closed",
|
|
157
|
+
"too_many_pending"
|
|
158
|
+
];
|
|
159
|
+
/** Every public error code. */
|
|
160
|
+
const ErrorCode = z.enum([
|
|
161
|
+
...CORE_CODES,
|
|
162
|
+
...PLAY_CODES,
|
|
163
|
+
...SOCIAL_CODES,
|
|
164
|
+
...MULTIPLAYER_CODES
|
|
165
|
+
]);
|
|
166
|
+
/**
|
|
167
|
+
* Why text was rejected or a chat action was refused. Coarse on purpose: a
|
|
168
|
+
* reason names a category, never the word or list that matched.
|
|
169
|
+
*/
|
|
170
|
+
const RejectReason = z.enum([
|
|
171
|
+
"link",
|
|
172
|
+
"personal_info",
|
|
173
|
+
"contact",
|
|
174
|
+
"language",
|
|
175
|
+
"slur",
|
|
176
|
+
"sexual",
|
|
177
|
+
"grown_up",
|
|
178
|
+
"unkind",
|
|
179
|
+
"threat",
|
|
180
|
+
"drugs",
|
|
181
|
+
"spam",
|
|
182
|
+
"shouting",
|
|
183
|
+
"too_long",
|
|
184
|
+
"empty",
|
|
185
|
+
"chars",
|
|
186
|
+
"reserved",
|
|
187
|
+
"rate_limited",
|
|
188
|
+
"muted",
|
|
189
|
+
"check_unavailable",
|
|
190
|
+
"chat_off",
|
|
191
|
+
"not_friends",
|
|
192
|
+
"suspended",
|
|
193
|
+
"quick_only",
|
|
194
|
+
"dms_closed",
|
|
195
|
+
"other"
|
|
196
|
+
]);
|
|
197
|
+
const errorBody = z.object({
|
|
198
|
+
/** A sentence that is always safe to show the player. */
|
|
199
|
+
error: z.string(),
|
|
200
|
+
/** What went wrong, for code to branch on. */
|
|
201
|
+
code: ErrorCode.optional(),
|
|
202
|
+
/** Content rejects and suspensions: the category. */
|
|
203
|
+
reason: RejectReason.optional(),
|
|
204
|
+
/** A kid-friendly suggestion, e.g. "try saying it another way". */
|
|
205
|
+
hint: z.string().optional(),
|
|
206
|
+
/** Rate limits: how long to wait, in ms (0 when not limited). */
|
|
207
|
+
retryAfterMs: z.number().int().nonnegative().optional(),
|
|
208
|
+
/** Suspensions and mutes: when they end (epoch ms). */
|
|
209
|
+
until: Millis.optional()
|
|
210
|
+
});
|
|
211
|
+
/** The body of every error response and failed socket ack. */
|
|
212
|
+
const ErrorBody = errorBody;
|
|
213
|
+
/**
|
|
214
|
+
* The 422 content-rejected body (multiplayer.md Β§4.6), shared by DMs, room
|
|
215
|
+
* chat, suggestions, names and trade notes.
|
|
216
|
+
*/
|
|
217
|
+
const ContentRejected = errorBody.extend({
|
|
218
|
+
code: z.literal("content_rejected"),
|
|
219
|
+
reason: RejectReason,
|
|
220
|
+
hint: z.string()
|
|
221
|
+
});
|
|
222
|
+
//#endregion
|
|
223
|
+
//#region src/middleware.ts
|
|
224
|
+
/**
|
|
225
|
+
* Middleware **defs** for the public API (plan Β§6.1). A def is a contract
|
|
226
|
+
* only: a name, the context it provides and its dependencies. It holds no
|
|
227
|
+
* code, so it is safe in a published package; the server binds the real
|
|
228
|
+
* implementation. Each endpoint's guards are part of its declaration, which
|
|
229
|
+
* lets tests check rules such as "every social endpoint requires an active
|
|
230
|
+
* account" (plan Β§10.3).
|
|
231
|
+
*/
|
|
232
|
+
/**
|
|
233
|
+
* Resolves the `hub_session` cookie (the user, or `null`) and the anonymous
|
|
234
|
+
* `hub_cid` client id. Every endpoint has it.
|
|
235
|
+
*/
|
|
236
|
+
const session = middleware("session", {
|
|
237
|
+
provides: ctx(),
|
|
238
|
+
doc: { security: { cookieAuth: {
|
|
239
|
+
type: "apiKey",
|
|
240
|
+
in: "cookie",
|
|
241
|
+
name: "hub_session"
|
|
242
|
+
} } }
|
|
243
|
+
});
|
|
244
|
+
/** Signed in, as a guest or an account. Otherwise 401 `signed_out`. */
|
|
245
|
+
const requireUser = middleware("requireUser", {
|
|
246
|
+
provides: ctx(),
|
|
247
|
+
requires: [session]
|
|
248
|
+
});
|
|
249
|
+
/**
|
|
250
|
+
* Signed in and not suspended. Otherwise 401 `signed_out` or 403
|
|
251
|
+
* `suspended`. Every social, multiplayer, race and trade endpoint has it.
|
|
252
|
+
*/
|
|
253
|
+
const requireActive = middleware("requireActive", {
|
|
254
|
+
provides: ctx(),
|
|
255
|
+
requires: [requireUser]
|
|
256
|
+
});
|
|
257
|
+
/** Active and a claimed account (not a guest). Otherwise 403 `claim_required`. */
|
|
258
|
+
const requireClaimed = middleware("requireClaimed", {
|
|
259
|
+
provides: ctx(),
|
|
260
|
+
requires: [requireActive]
|
|
261
|
+
});
|
|
262
|
+
/**
|
|
263
|
+
* Owns the `:slug` path segment and checks it names a known game.
|
|
264
|
+
* Otherwise 404 "No such game".
|
|
265
|
+
*/
|
|
266
|
+
const game = middleware.loader("slug", Slug, { requires: [session] });
|
|
267
|
+
//#endregion
|
|
268
|
+
//#region src/coerce.ts
|
|
269
|
+
/**
|
|
270
|
+
* Request coercions the single-player handlers apply today. The old hub reads
|
|
271
|
+
* many request fields loosely (`Number(x)`, `String(x)`, truthiness), and the
|
|
272
|
+
* contract must accept every request the hub accepts, so these schemas
|
|
273
|
+
* coerce the same way while keeping the input type clients should send.
|
|
274
|
+
*/
|
|
275
|
+
/**
|
|
276
|
+
* A finite number, read like the hub's `Number(x)`: numeric strings (and
|
|
277
|
+
* query-string values) pass. Clients should send a JSON number.
|
|
278
|
+
*/
|
|
279
|
+
const LooseNumber = z.coerce.number();
|
|
280
|
+
/**
|
|
281
|
+
* A flag read by truthiness, like the hub's `if (body.on)`: any value is
|
|
282
|
+
* accepted and becomes `true` or `false`. Clients should send a boolean.
|
|
283
|
+
*/
|
|
284
|
+
const Truthy = z.coerce.boolean();
|
|
285
|
+
/**
|
|
286
|
+
* Free text read with `String(x)`; the hub trims or clips it to its own limit
|
|
287
|
+
* rather than rejecting it. Clients should send a string.
|
|
288
|
+
*/
|
|
289
|
+
const LooseText = z.coerce.string();
|
|
290
|
+
/**
|
|
291
|
+
* A row count from the query string (`?limit=`, `?top=`). The hub clamps it to
|
|
292
|
+
* its own range and falls back to a default for `0` or a missing value.
|
|
293
|
+
*/
|
|
294
|
+
const RowCount = LooseNumber.int();
|
|
295
|
+
/**
|
|
296
|
+
* Required text read like the hub's `String(x || '')`, with the old hub's
|
|
297
|
+
* message for a missing or invalid value. Numbers are accepted and become
|
|
298
|
+
* strings; anything else fails with `message`. Refine the result with
|
|
299
|
+
* `.pipe(...)` (e.g. a pattern carrying the same message).
|
|
300
|
+
*/
|
|
301
|
+
function requiredText(message) {
|
|
302
|
+
return z.union([z.string(), z.number()], { error: message }).transform(String);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* A positive integer id read like the hub's `Number(x)` (numeric strings
|
|
306
|
+
* pass), with the old hub's message for any bad value (`"id must be a user
|
|
307
|
+
* id."`). Clients should send a JSON number.
|
|
308
|
+
*/
|
|
309
|
+
function requiredId(message) {
|
|
310
|
+
return z.coerce.number({ error: message }).int({ error: message }).positive({ error: message });
|
|
311
|
+
}
|
|
312
|
+
//#endregion
|
|
313
|
+
//#region src/api/analytics.ts
|
|
314
|
+
/**
|
|
315
|
+
* Gameplay events and client error logs sent by games and the menu, and the
|
|
316
|
+
* play counts built from them. Events need no account: they carry the user id
|
|
317
|
+
* when signed in and always the anonymous `hub_cid` client id.
|
|
318
|
+
*/
|
|
319
|
+
/** Play counts for one game or the whole catalog. A player is an account, else a client id. */
|
|
320
|
+
const Metrics = z.object({
|
|
321
|
+
/** `play` events recorded. */
|
|
322
|
+
totalPlays: z.number().int().nonnegative(),
|
|
323
|
+
/** Distinct players who sent any event. */
|
|
324
|
+
uniquePlayers: z.number().int().nonnegative(),
|
|
325
|
+
/** Distinct players with a `play` or `heartbeat` in the last 5 minutes (`HUB_ACTIVE_WINDOW_MS`). */
|
|
326
|
+
activeNow: z.number().int().nonnegative()
|
|
327
|
+
});
|
|
328
|
+
/** The analytics endpoints. */
|
|
329
|
+
const analyticsEndpoints = {
|
|
330
|
+
...session.group({
|
|
331
|
+
getSiteAnalytics: {
|
|
332
|
+
method: "GET",
|
|
333
|
+
path: "/analytics",
|
|
334
|
+
response: z.object({
|
|
335
|
+
global: Metrics,
|
|
336
|
+
games: z.array(Metrics.extend({
|
|
337
|
+
slug: Slug,
|
|
338
|
+
title: z.string()
|
|
339
|
+
}))
|
|
340
|
+
}),
|
|
341
|
+
doc: {
|
|
342
|
+
summary: "Catalog-wide play counts with a per-game breakdown",
|
|
343
|
+
tags: ["analytics"]
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
/**
|
|
347
|
+
* Report a client-side failure (for example the social runtime failing
|
|
348
|
+
* to load) to the admin console. Open to signed-out visitors; limited to
|
|
349
|
+
* 20 an hour per IP, after which it answers `{ ok: false }` and drops the
|
|
350
|
+
* report. Fields are clipped, never rejected.
|
|
351
|
+
*/
|
|
352
|
+
reportClientError: {
|
|
353
|
+
method: "POST",
|
|
354
|
+
path: "/client-log",
|
|
355
|
+
body: z.object({
|
|
356
|
+
/** Which part failed, up to 40 characters (`unknown` when empty). */
|
|
357
|
+
where: LooseText.optional(),
|
|
358
|
+
/** Up to 300 characters. */
|
|
359
|
+
message: LooseText.optional(),
|
|
360
|
+
/** Up to 1200 characters. */
|
|
361
|
+
stack: LooseText.optional(),
|
|
362
|
+
/** The page, up to 200 characters. */
|
|
363
|
+
url: LooseText.optional()
|
|
364
|
+
}),
|
|
365
|
+
response: z.object({
|
|
366
|
+
/** `false` when the report was dropped by the rate limit. */
|
|
367
|
+
ok: z.boolean() }),
|
|
368
|
+
doc: {
|
|
369
|
+
summary: "Report a client error to the admin console",
|
|
370
|
+
tags: ["analytics"]
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}),
|
|
374
|
+
...use(session, game).path("/games/:slug").group({
|
|
375
|
+
/** Record one event. The menu sends `play` on load and periodic `heartbeat`s; games may add their own. */
|
|
376
|
+
recordGameEvent: {
|
|
377
|
+
method: "POST",
|
|
378
|
+
path: "/events",
|
|
379
|
+
body: z.object({
|
|
380
|
+
/** 1 to 32 of letters, digits and `_ . : -` (surrounding spaces are trimmed). */
|
|
381
|
+
type: requiredText("Invalid event type.").pipe(z.string().trim().regex(/^[a-z0-9_.:-]{1,32}$/i, "Invalid event type.")) }),
|
|
382
|
+
response: Ok,
|
|
383
|
+
errors: {
|
|
384
|
+
400: ErrorBody,
|
|
385
|
+
404: ErrorBody
|
|
386
|
+
},
|
|
387
|
+
doc: {
|
|
388
|
+
summary: "Record a gameplay event",
|
|
389
|
+
tags: ["analytics"]
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
getGameAnalytics: {
|
|
393
|
+
method: "GET",
|
|
394
|
+
path: "/analytics",
|
|
395
|
+
response: Metrics.extend({ slug: Slug }),
|
|
396
|
+
errors: { 404: ErrorBody },
|
|
397
|
+
doc: {
|
|
398
|
+
summary: "This game's play counts",
|
|
399
|
+
tags: ["analytics"]
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
})
|
|
403
|
+
};
|
|
404
|
+
//#endregion
|
|
405
|
+
//#region src/api/auth.ts
|
|
406
|
+
/**
|
|
407
|
+
* Accounts and sessions: guests, claiming, sign-up, log-in, log-out, and the
|
|
408
|
+
* current user (`/me`). Sessions ride the `hub_session` cookie.
|
|
409
|
+
*/
|
|
410
|
+
/**
|
|
411
|
+
* A credential field, read like the hub's `String(x ?? '')`: a missing field
|
|
412
|
+
* is empty text (the hub then answers with its own rule, or a failed log-in)
|
|
413
|
+
* and a number becomes text. Clients should send a string.
|
|
414
|
+
*/
|
|
415
|
+
function credentialText(message) {
|
|
416
|
+
return z.union([z.string(), z.number()], { error: message }).nullish().transform((value) => String(value ?? ""));
|
|
417
|
+
}
|
|
418
|
+
/** Username + password, as the sign-up, claim and log-in forms send them. */
|
|
419
|
+
const Credentials = z.object({
|
|
420
|
+
/** 2 to 24 characters: letters, digits, `_`, `-` and spaces. */
|
|
421
|
+
username: credentialText("Username can use letters, numbers, _, -, and spaces."),
|
|
422
|
+
/** At least 6 characters. */
|
|
423
|
+
password: credentialText("Password must be at least 6 characters.")
|
|
424
|
+
});
|
|
425
|
+
/** `{ user }`: the session's account after an auth call. */
|
|
426
|
+
const AuthResult = z.object({ user: AuthUser });
|
|
427
|
+
/** A 400 from sign-up or claim: a bad name or password, or a name the filter rejects. */
|
|
428
|
+
const CredentialsError = z.union([ContentRejected, ErrorBody]);
|
|
429
|
+
/** The auth endpoints. */
|
|
430
|
+
const authEndpoints = {
|
|
431
|
+
...session.group({
|
|
432
|
+
/**
|
|
433
|
+
* Make sure a session exists. Idempotent: returns the current user if
|
|
434
|
+
* signed in (the full {@link SessionUser}), else creates a guest.
|
|
435
|
+
*/
|
|
436
|
+
ensureGuestSession: {
|
|
437
|
+
method: "POST",
|
|
438
|
+
path: "/auth/guest",
|
|
439
|
+
response: z.object({ user: z.union([SessionUser, AuthUser]) }),
|
|
440
|
+
doc: {
|
|
441
|
+
summary: "Ensure a session (creates a guest if needed)",
|
|
442
|
+
tags: ["auth"]
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
signUpWithPassword: {
|
|
446
|
+
method: "POST",
|
|
447
|
+
path: "/auth/signup",
|
|
448
|
+
body: Credentials,
|
|
449
|
+
response: AuthResult,
|
|
450
|
+
errors: {
|
|
451
|
+
400: CredentialsError,
|
|
452
|
+
409: ErrorBody,
|
|
453
|
+
429: ErrorBody
|
|
454
|
+
},
|
|
455
|
+
doc: {
|
|
456
|
+
summary: "Create an account and sign in",
|
|
457
|
+
tags: ["auth"]
|
|
458
|
+
}
|
|
459
|
+
},
|
|
460
|
+
logInWithPassword: {
|
|
461
|
+
method: "POST",
|
|
462
|
+
path: "/auth/login",
|
|
463
|
+
body: Credentials,
|
|
464
|
+
response: AuthResult,
|
|
465
|
+
errors: {
|
|
466
|
+
401: ErrorBody,
|
|
467
|
+
429: ErrorBody
|
|
468
|
+
},
|
|
469
|
+
doc: {
|
|
470
|
+
summary: "Sign in; a guest session on this device is merged into the account",
|
|
471
|
+
tags: ["auth"]
|
|
472
|
+
}
|
|
473
|
+
},
|
|
474
|
+
logOut: {
|
|
475
|
+
method: "POST",
|
|
476
|
+
path: "/auth/logout",
|
|
477
|
+
response: Ok,
|
|
478
|
+
doc: {
|
|
479
|
+
summary: "Sign out and clear the session cookie",
|
|
480
|
+
tags: ["auth"]
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
getCurrentUser: {
|
|
484
|
+
method: "GET",
|
|
485
|
+
path: "/me",
|
|
486
|
+
response: z.object({
|
|
487
|
+
user: SessionUser.nullable(),
|
|
488
|
+
/** Games the user has a profile in. */
|
|
489
|
+
profiles: z.array(z.object({
|
|
490
|
+
game: Slug,
|
|
491
|
+
displayName: z.string().nullable()
|
|
492
|
+
}))
|
|
493
|
+
}),
|
|
494
|
+
doc: {
|
|
495
|
+
summary: "The current user (or null) and their game profiles",
|
|
496
|
+
tags: ["auth"]
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}),
|
|
500
|
+
...requireUser.group({ claimGuestAccount: {
|
|
501
|
+
method: "POST",
|
|
502
|
+
path: "/auth/claim",
|
|
503
|
+
body: Credentials,
|
|
504
|
+
response: AuthResult,
|
|
505
|
+
errors: {
|
|
506
|
+
400: CredentialsError,
|
|
507
|
+
401: ErrorBody,
|
|
508
|
+
409: ErrorBody,
|
|
509
|
+
429: ErrorBody
|
|
510
|
+
},
|
|
511
|
+
doc: {
|
|
512
|
+
summary: "Turn the current guest into an account, keeping its progress",
|
|
513
|
+
tags: ["auth"]
|
|
514
|
+
}
|
|
515
|
+
} })
|
|
516
|
+
};
|
|
517
|
+
//#endregion
|
|
518
|
+
//#region src/limits.ts
|
|
519
|
+
/**
|
|
520
|
+
* Limits a UI has to know about: text lengths, name rules, seat counts and
|
|
521
|
+
* the rules for a game's `package.json` `game.*` block. Each number is
|
|
522
|
+
* defined once here; the schemas and the server import it.
|
|
523
|
+
*
|
|
524
|
+
* Rate thresholds, caps and timers are server-only and not part of this package.
|
|
525
|
+
*/
|
|
526
|
+
/** Account name and password rules (sign-up, claim, rename). */
|
|
527
|
+
const ACCOUNT_LIMITS = {
|
|
528
|
+
/** Shortest username, after trimming. */
|
|
529
|
+
usernameMin: 2,
|
|
530
|
+
/** Longest username, after trimming. */
|
|
531
|
+
usernameMax: 24,
|
|
532
|
+
/** Shortest password. */
|
|
533
|
+
passwordMin: 6,
|
|
534
|
+
/** How many built-in avatars there are (`a0` β¦ `a23`). */
|
|
535
|
+
avatarCount: 24
|
|
536
|
+
};
|
|
537
|
+
/** The characters a username may use: letters, digits, `_`, `-` and spaces. */
|
|
538
|
+
const USERNAME_PATTERN = /^[A-Za-z0-9_\- ]+$/;
|
|
539
|
+
/**
|
|
540
|
+
* The text limits every social UI renders from. The hub sends the live values
|
|
541
|
+
* in `GET /social/config` and the socket `hello` (`limits`); the server can
|
|
542
|
+
* change them (`HUB_LIMITS_JSON`), so a UI should prefer the sent values.
|
|
543
|
+
*/
|
|
544
|
+
const UiLimits = z.object({
|
|
545
|
+
/** Longest direct message, in characters. */
|
|
546
|
+
dmMaxChars: z.number().int().positive(),
|
|
547
|
+
/** Longest room or party chat line, in characters. */
|
|
548
|
+
roomChatMaxChars: z.number().int().positive(),
|
|
549
|
+
/** Longest suggestion title, in characters. */
|
|
550
|
+
suggestionTitleMax: z.number().int().positive(),
|
|
551
|
+
/** Longest suggestion body, in characters. */
|
|
552
|
+
suggestionBodyMax: z.number().int().positive()
|
|
553
|
+
});
|
|
554
|
+
/** Today's default {@link UiLimits}, used until the hub sends its own. */
|
|
555
|
+
const UI_LIMIT_DEFAULTS = {
|
|
556
|
+
dmMaxChars: 200,
|
|
557
|
+
roomChatMaxChars: 120,
|
|
558
|
+
suggestionTitleMax: 60,
|
|
559
|
+
suggestionBodyMax: 500
|
|
560
|
+
};
|
|
561
|
+
/** Seats in hub rooms and parties. */
|
|
562
|
+
const SEAT_LIMITS = {
|
|
563
|
+
/** Most players in one hub room, and the top of any `players` range. */
|
|
564
|
+
maxSeats: 8,
|
|
565
|
+
/** Most members in one party. */
|
|
566
|
+
partyMax: 8
|
|
567
|
+
};
|
|
568
|
+
/** What a racer's live stats (`hub.race.status(stats)`) may hold. */
|
|
569
|
+
const RACE_STATS_LIMITS = {
|
|
570
|
+
/** Most stat keys in one update. */
|
|
571
|
+
maxKeys: 12,
|
|
572
|
+
/** Largest update, in bytes of JSON. */
|
|
573
|
+
maxBytes: 600
|
|
574
|
+
};
|
|
575
|
+
/** Limits on the `package.json` `game.*` block (see the manifest schemas). */
|
|
576
|
+
const MANIFEST_LIMITS = {
|
|
577
|
+
/** Most `game.changes` entries the hub reads. */
|
|
578
|
+
changesMax: 200,
|
|
579
|
+
/** Longest `game.changes` entry, in characters. */
|
|
580
|
+
changeTextMax: 280,
|
|
581
|
+
/** Longest leaderboard title, in characters. */
|
|
582
|
+
leaderboardTitleMax: 80,
|
|
583
|
+
/** Furthest the menu button may be pushed from its corner, in px. */
|
|
584
|
+
menuOffsetMax: 600,
|
|
585
|
+
/** Longest lobby URL suffix, in characters. */
|
|
586
|
+
lobbyMax: 200,
|
|
587
|
+
/** Most `game.multiplayer.modes`. */
|
|
588
|
+
modesMax: 16,
|
|
589
|
+
/** Longest mode title, in characters. */
|
|
590
|
+
modeTitleMax: 40,
|
|
591
|
+
/** Most `game.multiplayer.quickChat` phrases. */
|
|
592
|
+
quickChatMax: 24,
|
|
593
|
+
/** Longest quick-chat phrase, in characters. */
|
|
594
|
+
quickChatTextMax: 40,
|
|
595
|
+
/** Most `game.multiplayer.chatAllow` words. */
|
|
596
|
+
chatAllowMax: 20,
|
|
597
|
+
/** Most `game.race.params`. */
|
|
598
|
+
raceParamsMax: 6,
|
|
599
|
+
/** Most options in one race param. */
|
|
600
|
+
raceOptionsMax: 12,
|
|
601
|
+
/** Most `game.race.stats` (the live HUD shows the first 3). */
|
|
602
|
+
raceStatsMax: 8,
|
|
603
|
+
/** Longest race param, option or stat title, in characters. */
|
|
604
|
+
raceTitleMax: 24,
|
|
605
|
+
/** Largest `game.race.minMs`, in ms (10 minutes). */
|
|
606
|
+
raceMinMsMax: 6e5
|
|
607
|
+
};
|
|
608
|
+
//#endregion
|
|
609
|
+
//#region src/api/mp/races.ts
|
|
610
|
+
/**
|
|
611
|
+
* Race shapes (docs/plans/race.md): params, live stats, racers and decided
|
|
612
|
+
* results. Shared by the `race.*` socket ops and events, the race block of
|
|
613
|
+
* a room, and `GET /race/history`. Each rule here is defined once.
|
|
614
|
+
*/
|
|
615
|
+
/** A race param or stat key: a letter, then up to 23 letters, digits or `_`. */
|
|
616
|
+
const RaceKey = z.string().regex(/^[A-Za-z][A-Za-z0-9_]{0,23}$/);
|
|
617
|
+
/** A race param value, one of the game's declared options: a number, a boolean or a short token. */
|
|
618
|
+
const RaceParamValue = z.union([
|
|
619
|
+
z.number(),
|
|
620
|
+
z.boolean(),
|
|
621
|
+
z.string().regex(/^[A-Za-z0-9_-]{1,24}$/)
|
|
622
|
+
]);
|
|
623
|
+
/**
|
|
624
|
+
* The creator's choices, keyed by the manifest's `race.params[].key`. The
|
|
625
|
+
* hub keeps only declared keys and option values (anything else becomes the
|
|
626
|
+
* param's default), so everyone gets the same puzzle.
|
|
627
|
+
*/
|
|
628
|
+
const RaceParams = z.record(RaceKey, RaceParamValue);
|
|
629
|
+
/** Live stat values are clamped to Β±1e9. */
|
|
630
|
+
const STAT_LIMIT = 1e9;
|
|
631
|
+
/**
|
|
632
|
+
* A live stat value: a finite number (clamped to Β±1e9), a boolean or a
|
|
633
|
+
* `#rrggbb` colour (lower-cased). Never text.
|
|
634
|
+
*/
|
|
635
|
+
const RaceStatValue = z.union([
|
|
636
|
+
z.number().transform((n) => Math.max(-1e9, Math.min(STAT_LIMIT, n))),
|
|
637
|
+
z.boolean(),
|
|
638
|
+
HexColor.transform((c) => c.toLowerCase())
|
|
639
|
+
]);
|
|
640
|
+
/**
|
|
641
|
+
* A racer's live stats, keyed like the manifest's `race.stats[].key`: at
|
|
642
|
+
* most 12 keys and 600 bytes of JSON once cleaned.
|
|
643
|
+
*/
|
|
644
|
+
const RaceStats = z.record(RaceKey, RaceStatValue).refine((s) => Object.keys(s).length <= RACE_STATS_LIMITS.maxKeys, `at most ${String(RACE_STATS_LIMITS.maxKeys)} stats`).refine((s) => JSON.stringify(s).length <= RACE_STATS_LIMITS.maxBytes, "stats too large");
|
|
645
|
+
/** How far along a racer is, 0 to 1. The hub clamps values outside that range. */
|
|
646
|
+
const RaceProgress = z.number().transform((n) => Math.max(0, Math.min(1, n)));
|
|
647
|
+
/** A race room's phase: waiting for racers β countdown β racing β done (β countdown on a rematch). */
|
|
648
|
+
const RacePhase = z.enum([
|
|
649
|
+
"waiting",
|
|
650
|
+
"countdown",
|
|
651
|
+
"racing",
|
|
652
|
+
"done"
|
|
653
|
+
]);
|
|
654
|
+
/** A racer's state while the race runs. */
|
|
655
|
+
const RacerState = z.enum([
|
|
656
|
+
"racing",
|
|
657
|
+
"finished",
|
|
658
|
+
"lost",
|
|
659
|
+
"forfeit",
|
|
660
|
+
"disconnected"
|
|
661
|
+
]);
|
|
662
|
+
/** How a race ended: the winner finished, everyone else lost / gave up / dropped, or time ran out. */
|
|
663
|
+
const RaceOutcome = z.enum([
|
|
664
|
+
"finished",
|
|
665
|
+
"lost",
|
|
666
|
+
"forfeit",
|
|
667
|
+
"disconnected",
|
|
668
|
+
"timeout"
|
|
669
|
+
]);
|
|
670
|
+
/** One racer's result. `beaten`: still racing when someone else won. */
|
|
671
|
+
const RacerResult = z.enum([
|
|
672
|
+
"won",
|
|
673
|
+
"beaten",
|
|
674
|
+
"lost",
|
|
675
|
+
"forfeit",
|
|
676
|
+
"disconnected",
|
|
677
|
+
"timeout"
|
|
678
|
+
]);
|
|
679
|
+
/** Fields every live racer line has (room roster and result card). */
|
|
680
|
+
const RacerLine = z.object({
|
|
681
|
+
userId: UserId,
|
|
682
|
+
/** The racer's username when the race began. */
|
|
683
|
+
name: z.string(),
|
|
684
|
+
/** Avatar token; never empty. */
|
|
685
|
+
avatar: z.string(),
|
|
686
|
+
stats: RaceStats,
|
|
687
|
+
/** 0..1, when the game reports it. */
|
|
688
|
+
progress: RaceProgress.optional()
|
|
689
|
+
});
|
|
690
|
+
/** A racer in a race room's roster (RoomInfo.race.players). */
|
|
691
|
+
const Racer = RacerLine.extend({
|
|
692
|
+
state: RacerState,
|
|
693
|
+
/** Server-measured ms from the go to finishing or dropping out. */
|
|
694
|
+
ms: z.number().int().nonnegative().optional()
|
|
695
|
+
});
|
|
696
|
+
/** One racer's line in a {@link RaceResult}. */
|
|
697
|
+
const RaceResultPlayer = RacerLine.extend({
|
|
698
|
+
/** 1 for the winner. */
|
|
699
|
+
place: z.number().int().positive(),
|
|
700
|
+
result: RacerResult,
|
|
701
|
+
/** Server-measured ms from the go to finishing or dropping out (the race's end for those still in). */
|
|
702
|
+
ms: z.number().int().nonnegative()
|
|
703
|
+
});
|
|
704
|
+
/** Fields a decided race has both live and in the history. */
|
|
705
|
+
const DecidedRace = z.object({
|
|
706
|
+
game: Slug,
|
|
707
|
+
params: RaceParams,
|
|
708
|
+
outcome: RaceOutcome,
|
|
709
|
+
/** Epoch ms of the go. */
|
|
710
|
+
startedAt: Millis,
|
|
711
|
+
/** Epoch ms. */
|
|
712
|
+
endedAt: Millis,
|
|
713
|
+
/** ms from the go to the decision. */
|
|
714
|
+
durationMs: z.number().int().nonnegative()
|
|
715
|
+
});
|
|
716
|
+
/** A decided race: the `race.result` event, the `race.finish` ack and RoomInfo.race.result. */
|
|
717
|
+
const RaceResult = DecidedRace.extend({
|
|
718
|
+
/** The race's id in the history; `null` if recording it failed. */
|
|
719
|
+
id: z.number().int().positive().nullable(),
|
|
720
|
+
/** The room code. */
|
|
721
|
+
room: z.string(),
|
|
722
|
+
/** 1 for the room's first race, +1 per rematch. */
|
|
723
|
+
round: z.number().int().positive(),
|
|
724
|
+
seed: z.number().int().positive(),
|
|
725
|
+
/** `null` when nobody won (time ran out, or everyone dropped out). */
|
|
726
|
+
winnerId: UserId.nullable(),
|
|
727
|
+
/** In finishing order (place 1 first). */
|
|
728
|
+
players: z.array(RaceResultPlayer)
|
|
729
|
+
});
|
|
730
|
+
/** The referee's view of a race room (RoomInfo.race). */
|
|
731
|
+
const RaceState = z.object({
|
|
732
|
+
phase: RacePhase,
|
|
733
|
+
/** 1 for the first race in the room, +1 per rematch; 0 while waiting for the first. */
|
|
734
|
+
round: z.number().int().nonnegative(),
|
|
735
|
+
params: RaceParams,
|
|
736
|
+
/** The countdown can start once this many racers are seated. */
|
|
737
|
+
minPlayers: z.number().int().positive(),
|
|
738
|
+
/** Everyone racing this round (empty while waiting for the first). */
|
|
739
|
+
players: z.array(Racer),
|
|
740
|
+
/** Who asked for a rematch since the last race. */
|
|
741
|
+
rematch: z.array(UserId),
|
|
742
|
+
/** Present once the countdown starts (never before, so nobody can pre-solve). */
|
|
743
|
+
seed: z.number().int().positive().optional(),
|
|
744
|
+
/** Epoch ms of the go (server clock); present with `seed`. */
|
|
745
|
+
startAt: Millis.optional(),
|
|
746
|
+
/** Present once the round is decided. */
|
|
747
|
+
result: RaceResult.optional()
|
|
748
|
+
});
|
|
749
|
+
/** The countdown began (`race.start`): build the puzzle from `seed` + `params`. */
|
|
750
|
+
const RaceStart = z.object({
|
|
751
|
+
roomId: z.string(),
|
|
752
|
+
round: z.number().int().positive(),
|
|
753
|
+
seed: z.number().int().positive(),
|
|
754
|
+
params: RaceParams,
|
|
755
|
+
/** Epoch ms of the go (server clock). */
|
|
756
|
+
startAt: Millis,
|
|
757
|
+
/** ms until the go. */
|
|
758
|
+
startsIn: z.number().int().nonnegative(),
|
|
759
|
+
/** The racers' user ids. */
|
|
760
|
+
players: z.array(UserId)
|
|
761
|
+
});
|
|
762
|
+
/** A racer in a history record. Suspended opponents are `hidden`: id 0, name "A player", no avatar. */
|
|
763
|
+
const RaceRecordPlayer = z.object({
|
|
764
|
+
/** 0 when hidden. */
|
|
765
|
+
userId: z.number().int().nonnegative(),
|
|
766
|
+
username: z.string(),
|
|
767
|
+
/** Avatar token; `''` when hidden. */
|
|
768
|
+
avatar: z.string(),
|
|
769
|
+
guest: z.boolean(),
|
|
770
|
+
place: z.number().int().positive(),
|
|
771
|
+
result: RacerResult,
|
|
772
|
+
/** Server-measured ms from the go to finishing or dropping out. */
|
|
773
|
+
ms: z.number().int().nonnegative().optional(),
|
|
774
|
+
progress: RaceProgress.optional(),
|
|
775
|
+
stats: RaceStats,
|
|
776
|
+
hidden: z.literal(true).optional()
|
|
777
|
+
});
|
|
778
|
+
/** One past race the player was in (`GET /race/history`). */
|
|
779
|
+
const RaceRecord = DecidedRace.extend({
|
|
780
|
+
id: z.number().int().positive(),
|
|
781
|
+
gameTitle: z.string(),
|
|
782
|
+
/** `null` when nobody won; 0 when the winner is hidden (suspended). */
|
|
783
|
+
winnerId: z.number().int().nonnegative().nullable(),
|
|
784
|
+
/** The signed-in player's own line (`null` only if their row is missing). */
|
|
785
|
+
me: RaceRecordPlayer.nullable(),
|
|
786
|
+
/** In place order. */
|
|
787
|
+
players: z.array(RaceRecordPlayer)
|
|
788
|
+
});
|
|
789
|
+
//#endregion
|
|
790
|
+
//#region src/manifest/fields.ts
|
|
791
|
+
/**
|
|
792
|
+
* Field rules shared by several parts of the `game.*` manifest. Each rule is
|
|
793
|
+
* defined once here.
|
|
794
|
+
*/
|
|
795
|
+
/**
|
|
796
|
+
* A non-empty string with no leading or trailing whitespace. Used for fields
|
|
797
|
+
* the hub reads verbatim (commands, paths, titles), where stray spaces are a
|
|
798
|
+
* mistake rather than something the hub would trim away.
|
|
799
|
+
*/
|
|
800
|
+
const Trimmed = z.string().regex(/^\S(?:[\s\S]*\S)?$/, "must be non-empty, with no leading or trailing spaces");
|
|
801
|
+
/** A label the hub trims and shows to players, at most `max` characters. */
|
|
802
|
+
function trimmedText(max) {
|
|
803
|
+
return z.string().trim().min(1).max(max);
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* A URL suffix relative to `/<slug>/` that invites and Join open, such as
|
|
807
|
+
* `?lobby=1` or `play/online`. Not absolute, no `..`, no `//`.
|
|
808
|
+
*/
|
|
809
|
+
const LobbySuffix = z.string().trim().regex(new RegExp(`^[A-Za-z0-9_./?=&#%-]{0,${MANIFEST_LIMITS.lobbyMax}}$`), "may only use letters, digits and _ . / ? = & # % -").refine((s) => !s.startsWith("/") && !s.includes("..") && !s.includes("//"), "must be a relative URL suffix (no leading /, no .., no //)");
|
|
810
|
+
/**
|
|
811
|
+
* A `[min, max]` player range: whole numbers, `lowest <= min <= max <= 8`.
|
|
812
|
+
*
|
|
813
|
+
* @param lowest - the smallest `min` allowed (1 for multiplayer, 2 for races)
|
|
814
|
+
*/
|
|
815
|
+
function playerRange(lowest) {
|
|
816
|
+
const seatCount = z.number().int().min(lowest).max(SEAT_LIMITS.maxSeats);
|
|
817
|
+
return z.tuple([seatCount, seatCount]).refine(([min, max]) => min <= max, `must be [min, max] with ${lowest} <= min <= max <= ${SEAT_LIMITS.maxSeats}`);
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* An optional block that may also be written as `false` ("off"). `false`
|
|
821
|
+
* parses to `undefined`, the same as leaving the field out.
|
|
822
|
+
*
|
|
823
|
+
* (A `z.union` with `z.literal(false)` would work too, but it hides the
|
|
824
|
+
* block's own field errors behind a bare "Invalid input".)
|
|
825
|
+
*/
|
|
826
|
+
function orFalse(block) {
|
|
827
|
+
return z.preprocess((v) => v === false ? void 0 : v, block.optional()).optional();
|
|
828
|
+
}
|
|
829
|
+
/** True when no two items share a `key`. */
|
|
830
|
+
function hasUniqueKeys(items) {
|
|
831
|
+
return new Set(items.map((i) => i.key)).size === items.length;
|
|
832
|
+
}
|
|
833
|
+
/** The placeholders a `build` or `start` command may use. */
|
|
834
|
+
const COMMAND_PLACEHOLDERS = [
|
|
835
|
+
"PORT",
|
|
836
|
+
"GAME_PATH",
|
|
837
|
+
"GAME_SLUG"
|
|
838
|
+
];
|
|
839
|
+
const PLACEHOLDER = /\{\{(\w+)\}\}/g;
|
|
840
|
+
const KNOWN_PLACEHOLDERS = new Set(COMMAND_PLACEHOLDERS);
|
|
841
|
+
/**
|
|
842
|
+
* A shell command the host runs in the app folder. It may use the
|
|
843
|
+
* placeholders `{{PORT}}`, `{{GAME_PATH}}` and `{{GAME_SLUG}}`; any other
|
|
844
|
+
* `{{NAME}}` fails the build.
|
|
845
|
+
*/
|
|
846
|
+
const Command = Trimmed.refine((cmd) => [...cmd.matchAll(PLACEHOLDER)].every((m) => KNOWN_PLACEHOLDERS.has(m[1] ?? "")), `may only use the placeholders ${COMMAND_PLACEHOLDERS.map((p) => `{{${p}}}`).join(", ")}`);
|
|
847
|
+
/** True when a relative path names something strictly inside its folder. */
|
|
848
|
+
function staysInside(path) {
|
|
849
|
+
let depth = 0;
|
|
850
|
+
for (const part of path.split(/[\\/]/)) if (part === "..") {
|
|
851
|
+
depth -= 1;
|
|
852
|
+
if (depth < 0) return false;
|
|
853
|
+
} else if (part !== "" && part !== ".") depth += 1;
|
|
854
|
+
return depth > 0;
|
|
855
|
+
}
|
|
856
|
+
/** A file path relative to the app folder that stays inside it, e.g. `cover.png`. */
|
|
857
|
+
const AppFilePath = Trimmed.refine(staysInside, "must be a file inside the app folder");
|
|
858
|
+
//#endregion
|
|
859
|
+
//#region src/manifest/race.ts
|
|
860
|
+
/**
|
|
861
|
+
* `game.race`: the opt-in that turns a single-player game into a race target
|
|
862
|
+
* (docs/multiplayer.md Β§14, docs/plans/race.md Β§5).
|
|
863
|
+
*/
|
|
864
|
+
const RaceTitle = trimmedText(MANIFEST_LIMITS.raceTitleMax);
|
|
865
|
+
/** One choice of a race param: a bare value, or a value with a title. */
|
|
866
|
+
const RaceOption = z.union([RaceParamValue, z.strictObject({
|
|
867
|
+
/** The value the game receives in `r.params[key]`. */
|
|
868
|
+
value: RaceParamValue,
|
|
869
|
+
/** What the creator sees. Defaults to the value. Checked like quick-chat text. */
|
|
870
|
+
title: RaceTitle.optional()
|
|
871
|
+
})]);
|
|
872
|
+
/** The value of an option, bare or titled. */
|
|
873
|
+
function optionValue(option) {
|
|
874
|
+
return typeof option === "object" ? option.value : option;
|
|
875
|
+
}
|
|
876
|
+
/** A choice the race creator makes. The server only accepts declared values. */
|
|
877
|
+
const RaceParam = z.strictObject({
|
|
878
|
+
/** The key the game reads, `r.params[key]`. */
|
|
879
|
+
key: RaceKey,
|
|
880
|
+
/** What the creator sees. Defaults to the key. Checked like quick-chat text. */
|
|
881
|
+
title: RaceTitle.optional(),
|
|
882
|
+
/** The choices, 1 to 12. */
|
|
883
|
+
options: z.array(RaceOption).min(1).max(MANIFEST_LIMITS.raceOptionsMax),
|
|
884
|
+
/** The preselected value; one of the options' values. Defaults to the first. */
|
|
885
|
+
default: RaceParamValue.optional()
|
|
886
|
+
}).refine((p) => p.default === void 0 || p.options.some((option) => optionValue(option) === p.default), {
|
|
887
|
+
message: "default must be one of the option values",
|
|
888
|
+
path: ["default"]
|
|
889
|
+
});
|
|
890
|
+
/** How the live race HUD and the race history show a stat. */
|
|
891
|
+
const RaceStatFormat = z.enum([
|
|
892
|
+
"int",
|
|
893
|
+
"percent",
|
|
894
|
+
"time",
|
|
895
|
+
"bool",
|
|
896
|
+
"color"
|
|
897
|
+
]);
|
|
898
|
+
/** A stat the live race HUD and the race history show. */
|
|
899
|
+
const RaceStat = z.strictObject({
|
|
900
|
+
/** The key the game sends in `hub.race.status({ [key]: value })`. */
|
|
901
|
+
key: RaceKey,
|
|
902
|
+
/** The column label. Defaults to the key. Checked like quick-chat text. */
|
|
903
|
+
title: RaceTitle.optional(),
|
|
904
|
+
/** `int` (default), `percent`, `time` (ms), `bool` or `color` (`#rrggbb`). */
|
|
905
|
+
format: RaceStatFormat.default("int")
|
|
906
|
+
});
|
|
907
|
+
/** The `game.race` object form. */
|
|
908
|
+
const RaceBlock = z.strictObject({
|
|
909
|
+
/** URL suffix race invites and Join open, relative to `/<slug>/`. Default `?race=lobby`. */
|
|
910
|
+
lobby: LobbySuffix.default("?race=lobby"),
|
|
911
|
+
/** `[min, max]` racers, within 2..8. Default `[2, 2]`. */
|
|
912
|
+
players: playerRange(2).default([2, 2]),
|
|
913
|
+
/** What the creator picks (at most 6, unique keys). */
|
|
914
|
+
params: z.array(RaceParam).max(MANIFEST_LIMITS.raceParamsMax).refine(hasUniqueKeys, "param keys must be unique").default([]),
|
|
915
|
+
/** What the live HUD and the history show (at most 8, unique keys; the HUD shows the first 3). */
|
|
916
|
+
stats: z.array(RaceStat).max(MANIFEST_LIMITS.raceStatsMax).refine(hasUniqueKeys, "stat keys must be unique").default([]),
|
|
917
|
+
/**
|
|
918
|
+
* The earliest a finish may count after the go, in ms (0 to 600000). A
|
|
919
|
+
* sooner finish is held until then. Default: the hub's `raceMinFinishMs`.
|
|
920
|
+
*/
|
|
921
|
+
minMs: z.number().int().min(0).max(MANIFEST_LIMITS.raceMinMsMax).optional()
|
|
922
|
+
});
|
|
923
|
+
/**
|
|
924
|
+
* `game.race`: an object, `true` for all defaults (no params, no stats), or
|
|
925
|
+
* `false` for off. `true` parses to the object form, `false` to `undefined`.
|
|
926
|
+
*/
|
|
927
|
+
const GameRace = z.preprocess((v) => v === true ? {} : v === false ? void 0 : v, RaceBlock.optional()).optional();
|
|
928
|
+
//#endregion
|
|
929
|
+
//#region src/api/catalog.ts
|
|
930
|
+
/**
|
|
931
|
+
* The game catalog, favorites and What's new notes.
|
|
932
|
+
*/
|
|
933
|
+
/** A `[min, max]` player count, each 1 to 8. */
|
|
934
|
+
const PlayerRange = z.tuple([z.number().int().min(1).max(8), z.number().int().min(1).max(8)]);
|
|
935
|
+
/**
|
|
936
|
+
* What the menu needs to know about an online game (invite picker, Join and
|
|
937
|
+
* Watch buttons, the catalog's Online tag), not the full manifest.
|
|
938
|
+
*/
|
|
939
|
+
const MultiplayerSummary = z.object({
|
|
940
|
+
/** `own-server` only for process apps that run their own game server. */
|
|
941
|
+
transport: z.enum(["hub-rooms", "own-server"]),
|
|
942
|
+
players: PlayerRange,
|
|
943
|
+
invites: z.boolean(),
|
|
944
|
+
join: z.boolean(),
|
|
945
|
+
spectate: z.boolean(),
|
|
946
|
+
modes: z.array(z.object({
|
|
947
|
+
key: z.string(),
|
|
948
|
+
title: z.string()
|
|
949
|
+
})),
|
|
950
|
+
/** Present (and `true`) when the block was made for a race-only game. */
|
|
951
|
+
raceOnly: z.literal(true).optional()
|
|
952
|
+
});
|
|
953
|
+
/** A race game's manifest summary (players, the creator's param choices, the stats the HUD shows). */
|
|
954
|
+
const RaceSummary = z.object({
|
|
955
|
+
/** Each 2 to 8. */
|
|
956
|
+
players: PlayerRange,
|
|
957
|
+
/** Up to 6 choices the race creator picks. */
|
|
958
|
+
params: z.array(z.object({
|
|
959
|
+
key: z.string(),
|
|
960
|
+
title: z.string(),
|
|
961
|
+
/** Up to 12. */
|
|
962
|
+
options: z.array(z.object({
|
|
963
|
+
value: RaceParamValue,
|
|
964
|
+
title: z.string()
|
|
965
|
+
})),
|
|
966
|
+
/** One of the options' values. */
|
|
967
|
+
default: RaceParamValue
|
|
968
|
+
})),
|
|
969
|
+
/** Up to 8 stats the race HUD and history show. */
|
|
970
|
+
stats: z.array(z.object({
|
|
971
|
+
key: z.string(),
|
|
972
|
+
title: z.string(),
|
|
973
|
+
format: RaceStatFormat
|
|
974
|
+
}))
|
|
975
|
+
});
|
|
976
|
+
/** A listed game, as the menu's game list and the catalog show it. Broken apps are left out. */
|
|
977
|
+
const GameInfo = z.object({
|
|
978
|
+
slug: Slug,
|
|
979
|
+
title: z.string(),
|
|
980
|
+
/** Its URL path, e.g. `/duel`. */
|
|
981
|
+
path: z.string(),
|
|
982
|
+
/** `process` apps run their own server behind the host. */
|
|
983
|
+
kind: z.enum(["static", "process"]),
|
|
984
|
+
hasImage: z.boolean(),
|
|
985
|
+
/** `false` when the game opted out of the injected hub menu. */
|
|
986
|
+
hubEnabled: z.boolean(),
|
|
987
|
+
/** `null` for single-player games. */
|
|
988
|
+
multiplayer: MultiplayerSummary.nullable(),
|
|
989
|
+
/** `null` unless the game supports races. */
|
|
990
|
+
race: RaceSummary.nullable()
|
|
991
|
+
});
|
|
992
|
+
/** `{ games }`: the caller's starred slugs, oldest star first. */
|
|
993
|
+
const Favorites = z.object({ games: z.array(Slug) });
|
|
994
|
+
/** One What's new note. */
|
|
995
|
+
const ChangeNote = z.object({
|
|
996
|
+
id: z.number().int().positive(),
|
|
997
|
+
/** The game's slug, or `_hub` for the site's own notes. */
|
|
998
|
+
game: Slug,
|
|
999
|
+
/** The game's title, or `diffenderfer.games` for the site's own notes. */
|
|
1000
|
+
title: z.string(),
|
|
1001
|
+
text: z.string(),
|
|
1002
|
+
/** When the note went live (the deploy that first saw it), epoch ms. */
|
|
1003
|
+
at: Millis,
|
|
1004
|
+
/** Not yet seen by this player (or since `?since=` when signed out). */
|
|
1005
|
+
new: z.boolean()
|
|
1006
|
+
});
|
|
1007
|
+
/** The catalog endpoints. */
|
|
1008
|
+
const catalogEndpoints = {
|
|
1009
|
+
...session.group({
|
|
1010
|
+
listGames: {
|
|
1011
|
+
method: "GET",
|
|
1012
|
+
path: "/games",
|
|
1013
|
+
response: z.object({ games: z.array(GameInfo) }),
|
|
1014
|
+
doc: {
|
|
1015
|
+
summary: "The game catalog, sorted by title",
|
|
1016
|
+
tags: ["catalog"]
|
|
1017
|
+
}
|
|
1018
|
+
},
|
|
1019
|
+
/**
|
|
1020
|
+
* Notes from the last 30 days, newest first (at most 6 per game and 30
|
|
1021
|
+
* in all). In a game, `?game=` limits them to that game's and the site's.
|
|
1022
|
+
*/
|
|
1023
|
+
listWhatsNewNotes: {
|
|
1024
|
+
method: "GET",
|
|
1025
|
+
path: "/changes",
|
|
1026
|
+
query: z.object({
|
|
1027
|
+
/** A game's slug. Empty means everything; an unknown one lists only the site's notes. */
|
|
1028
|
+
game: z.string().optional(),
|
|
1029
|
+
/**
|
|
1030
|
+
* Signed out only: the device's last-cleared time (epoch ms); without
|
|
1031
|
+
* it (or when it isn't a number) nothing is new.
|
|
1032
|
+
*/
|
|
1033
|
+
since: z.preprocess((value) => value === "" ? void 0 : value, LooseNumber.optional()).catch(void 0)
|
|
1034
|
+
}),
|
|
1035
|
+
response: z.object({
|
|
1036
|
+
items: z.array(ChangeNote),
|
|
1037
|
+
/** How many of `items` are new. */
|
|
1038
|
+
unseen: z.number().int().nonnegative(),
|
|
1039
|
+
signedIn: z.boolean()
|
|
1040
|
+
}),
|
|
1041
|
+
doc: {
|
|
1042
|
+
summary: "What's new: recent change notes",
|
|
1043
|
+
tags: ["catalog"]
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}),
|
|
1047
|
+
...requireUser.group({
|
|
1048
|
+
listFavoriteGames: {
|
|
1049
|
+
method: "GET",
|
|
1050
|
+
path: "/favorites",
|
|
1051
|
+
response: Favorites,
|
|
1052
|
+
errors: { 401: ErrorBody },
|
|
1053
|
+
doc: {
|
|
1054
|
+
summary: "The caller's starred games",
|
|
1055
|
+
tags: ["catalog"]
|
|
1056
|
+
}
|
|
1057
|
+
},
|
|
1058
|
+
/**
|
|
1059
|
+
* Star or unstar a game (idempotent). Starring needs a known game (404);
|
|
1060
|
+
* unstarring any slug succeeds.
|
|
1061
|
+
*/
|
|
1062
|
+
setGameFavorite: {
|
|
1063
|
+
method: "PUT",
|
|
1064
|
+
path: "/favorites/:slug",
|
|
1065
|
+
params: z.object({ slug: Slug }),
|
|
1066
|
+
body: z.object({
|
|
1067
|
+
/** `true` stars, `false` (or missing) unstars. */
|
|
1068
|
+
on: Truthy.optional() }),
|
|
1069
|
+
response: Favorites,
|
|
1070
|
+
errors: {
|
|
1071
|
+
401: ErrorBody,
|
|
1072
|
+
404: ErrorBody
|
|
1073
|
+
},
|
|
1074
|
+
doc: {
|
|
1075
|
+
summary: "Star or unstar a game",
|
|
1076
|
+
tags: ["catalog"]
|
|
1077
|
+
}
|
|
1078
|
+
},
|
|
1079
|
+
/**
|
|
1080
|
+
* Mark notes seen now. With `game`: that game's and the site's. With
|
|
1081
|
+
* `all` (or neither): everything.
|
|
1082
|
+
*/
|
|
1083
|
+
markWhatsNewSeen: {
|
|
1084
|
+
method: "POST",
|
|
1085
|
+
path: "/changes/seen",
|
|
1086
|
+
body: z.object({
|
|
1087
|
+
/** A game's slug, read as text; an unknown one is a 404. Ignored with `all`. */
|
|
1088
|
+
game: LooseText.nullish(),
|
|
1089
|
+
all: Truthy.optional()
|
|
1090
|
+
}),
|
|
1091
|
+
response: z.object({
|
|
1092
|
+
ok: z.literal(true),
|
|
1093
|
+
/** The seen mark stored, epoch ms. */
|
|
1094
|
+
seenAt: Millis
|
|
1095
|
+
}),
|
|
1096
|
+
errors: {
|
|
1097
|
+
401: ErrorBody,
|
|
1098
|
+
404: ErrorBody
|
|
1099
|
+
},
|
|
1100
|
+
doc: {
|
|
1101
|
+
summary: "Mark What's new notes seen",
|
|
1102
|
+
tags: ["catalog"]
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
})
|
|
1106
|
+
};
|
|
1107
|
+
//#endregion
|
|
1108
|
+
//#region src/api/leaderboards.ts
|
|
1109
|
+
/**
|
|
1110
|
+
* Leaderboards: score submission, boards and standings. A board belongs to a
|
|
1111
|
+
* game and is keyed by an app-chosen {@link Key}; it keeps each player's best
|
|
1112
|
+
* score only (highest for `desc` boards, lowest for `asc`).
|
|
1113
|
+
*/
|
|
1114
|
+
/** A board key in a path, with the old hub's message when it is malformed. */
|
|
1115
|
+
const BoardKeyParams = z.object({ key: z.string().refine((key) => Key.safeParse(key).success, { error: "board key must match /^[A-Za-z0-9_.:-]{1,64}$/ (1β64 chars)." }) });
|
|
1116
|
+
/**
|
|
1117
|
+
* A row count the hub reads like `Number(x) || default`: anything that is not
|
|
1118
|
+
* an integer falls back to the default instead of failing.
|
|
1119
|
+
*/
|
|
1120
|
+
const LenientRowCount = RowCount.optional().catch(void 0);
|
|
1121
|
+
/** A board's sort direction: `desc` ranks high scores first, `asc` low scores (times) first. */
|
|
1122
|
+
const SortDir = z.enum(["asc", "desc"]);
|
|
1123
|
+
/** A 1-based rank on a board. */
|
|
1124
|
+
const Rank = z.number().int().positive();
|
|
1125
|
+
/** A leaderboard's identity, without entries. */
|
|
1126
|
+
const Board = z.object({
|
|
1127
|
+
key: Key,
|
|
1128
|
+
/** Set by the first submitter or the game's `package.json`; defaults to the key. */
|
|
1129
|
+
title: z.string(),
|
|
1130
|
+
sortDir: SortDir
|
|
1131
|
+
});
|
|
1132
|
+
/**
|
|
1133
|
+
* One ranked row of a board. Suspended players and (unless the board opts
|
|
1134
|
+
* in) zero scores are left out.
|
|
1135
|
+
*/
|
|
1136
|
+
const LeaderboardEntry = z.object({
|
|
1137
|
+
rank: Rank,
|
|
1138
|
+
userId: UserId,
|
|
1139
|
+
username: z.string(),
|
|
1140
|
+
score: z.number(),
|
|
1141
|
+
/** The JSON the game stored with the score, or `null`. */
|
|
1142
|
+
meta: Json
|
|
1143
|
+
});
|
|
1144
|
+
/** Every board of one game, each with its top entries (`GET /leaderboards`, the snapshot). */
|
|
1145
|
+
const GameLeaderboards = z.object({
|
|
1146
|
+
slug: Slug,
|
|
1147
|
+
/** The game's title (the slug when the game is no longer listed). */
|
|
1148
|
+
title: z.string(),
|
|
1149
|
+
boards: z.array(Board.extend({ top: z.array(LeaderboardEntry) }))
|
|
1150
|
+
});
|
|
1151
|
+
/**
|
|
1152
|
+
* The player's own standings in one game (`GET /me/records`, the snapshot):
|
|
1153
|
+
* their score and rank on each board they have an entry on.
|
|
1154
|
+
*/
|
|
1155
|
+
const GameRecords = z.object({
|
|
1156
|
+
slug: Slug,
|
|
1157
|
+
/** The game's title (the slug when the game is no longer listed). */
|
|
1158
|
+
title: z.string(),
|
|
1159
|
+
records: z.array(Board.extend({
|
|
1160
|
+
score: z.number(),
|
|
1161
|
+
rank: Rank
|
|
1162
|
+
}))
|
|
1163
|
+
});
|
|
1164
|
+
/**
|
|
1165
|
+
* `?top=` for the cross-game views (`GET /leaderboards`, `GET /me/snapshot`):
|
|
1166
|
+
* entries per board, 1 to 50 (clamped), default 5.
|
|
1167
|
+
*/
|
|
1168
|
+
const TopQuery = z.object({ top: LenientRowCount });
|
|
1169
|
+
/** The leaderboards endpoints. */
|
|
1170
|
+
const leaderboardsEndpoints = {
|
|
1171
|
+
...session.group({
|
|
1172
|
+
/** Every board of every game, grouped by game, each with its top entries. */
|
|
1173
|
+
listTopOfEveryLeaderboard: {
|
|
1174
|
+
method: "GET",
|
|
1175
|
+
path: "/leaderboards",
|
|
1176
|
+
query: TopQuery,
|
|
1177
|
+
response: z.object({ games: z.array(GameLeaderboards) }),
|
|
1178
|
+
doc: {
|
|
1179
|
+
summary: "Every board across every game, with top entries",
|
|
1180
|
+
tags: ["leaderboards"]
|
|
1181
|
+
}
|
|
1182
|
+
} }),
|
|
1183
|
+
...use(session, game).path("/games/:slug").group({
|
|
1184
|
+
listGameLeaderboards: {
|
|
1185
|
+
method: "GET",
|
|
1186
|
+
path: "/leaderboards",
|
|
1187
|
+
response: z.object({ boards: z.array(Board) }),
|
|
1188
|
+
errors: { 404: ErrorBody },
|
|
1189
|
+
doc: {
|
|
1190
|
+
summary: "The game's boards (no entries)",
|
|
1191
|
+
tags: ["leaderboards"]
|
|
1192
|
+
}
|
|
1193
|
+
},
|
|
1194
|
+
getLeaderboard: {
|
|
1195
|
+
method: "GET",
|
|
1196
|
+
path: "/leaderboards/:key",
|
|
1197
|
+
params: BoardKeyParams,
|
|
1198
|
+
/** `limit`: rows to return, 1 to 500 (clamped), default 50. */
|
|
1199
|
+
query: z.object({ limit: LenientRowCount }),
|
|
1200
|
+
response: z.object({
|
|
1201
|
+
board: Board,
|
|
1202
|
+
entries: z.array(LeaderboardEntry)
|
|
1203
|
+
}),
|
|
1204
|
+
errors: {
|
|
1205
|
+
400: ErrorBody,
|
|
1206
|
+
404: ErrorBody
|
|
1207
|
+
},
|
|
1208
|
+
doc: {
|
|
1209
|
+
summary: "One board's ranked entries (404 if the board doesn't exist)",
|
|
1210
|
+
tags: ["leaderboards"]
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
}),
|
|
1214
|
+
...use(requireUser, game).path("/games/:slug").group({
|
|
1215
|
+
/**
|
|
1216
|
+
* Submit a score. The first submission creates the board (with `title`,
|
|
1217
|
+
* `sort` and `showZeros`; later values are ignored). Only an improvement
|
|
1218
|
+
* replaces the player's entry. Boards a game declares with
|
|
1219
|
+
* `source: "server"` refuse client scores (403 `server_board`).
|
|
1220
|
+
*/
|
|
1221
|
+
submitScore: {
|
|
1222
|
+
method: "POST",
|
|
1223
|
+
path: "/leaderboards/:key/scores",
|
|
1224
|
+
params: BoardKeyParams,
|
|
1225
|
+
body: z.object({
|
|
1226
|
+
/** A finite number (numeric strings pass, as for the old hub's `Number(x)`). */
|
|
1227
|
+
score: z.coerce.number({ error: "score must be a finite number." }),
|
|
1228
|
+
/** Board title, used only when this call creates the board (non-text is ignored). */
|
|
1229
|
+
title: z.string().optional().catch(void 0),
|
|
1230
|
+
/** Sort direction, used only on creation. Anything but `asc` means `desc`. */
|
|
1231
|
+
sort: SortDir.optional().catch(void 0),
|
|
1232
|
+
/** Keep zero scores visible, used only on creation. */
|
|
1233
|
+
showZeros: Truthy.optional(),
|
|
1234
|
+
/** Any JSON stored with the entry (a character, a run seed). */
|
|
1235
|
+
meta: Json.optional()
|
|
1236
|
+
}),
|
|
1237
|
+
response: z.object({
|
|
1238
|
+
board: Board,
|
|
1239
|
+
/** The player's stored best after this submission. */
|
|
1240
|
+
best: z.number().nullable(),
|
|
1241
|
+
/** The player's rank on the board (`null` if they have no entry). */
|
|
1242
|
+
rank: Rank.nullable()
|
|
1243
|
+
}),
|
|
1244
|
+
errors: {
|
|
1245
|
+
400: ErrorBody,
|
|
1246
|
+
401: ErrorBody,
|
|
1247
|
+
403: ErrorBody,
|
|
1248
|
+
404: ErrorBody
|
|
1249
|
+
},
|
|
1250
|
+
doc: {
|
|
1251
|
+
summary: "Submit a score (best-only; creates the board on first use)",
|
|
1252
|
+
tags: ["leaderboards"]
|
|
1253
|
+
}
|
|
1254
|
+
} })
|
|
1255
|
+
};
|
|
1256
|
+
//#endregion
|
|
1257
|
+
//#region src/api/daily.ts
|
|
1258
|
+
/**
|
|
1259
|
+
* Daily challenges: one deterministic challenge per UTC day for games with
|
|
1260
|
+
* `game.dailyChallenges: true`. Starting a day mints a signed token; the game
|
|
1261
|
+
* presents it on completion. Completions are counted on the game's `daily`
|
|
1262
|
+
* leaderboard. The client owns the calendar (its local date); the hub only
|
|
1263
|
+
* reports which days are done.
|
|
1264
|
+
*/
|
|
1265
|
+
/** One completed day. */
|
|
1266
|
+
const DailyCompletion = z.object({
|
|
1267
|
+
/** Epoch ms (server clock). */
|
|
1268
|
+
completedAt: Millis,
|
|
1269
|
+
/** The JSON the game sent on completion, or `null`. */
|
|
1270
|
+
meta: Json
|
|
1271
|
+
});
|
|
1272
|
+
/** A player's completions in one game, keyed by `YYYY-MM-DD`. */
|
|
1273
|
+
const DailyCompletions = z.record(DayKey, DailyCompletion);
|
|
1274
|
+
/** `:day` of a challenge: within 30 days back and 1 day ahead of UTC today. */
|
|
1275
|
+
const DayParams = z.object({ day: z.string().refine((day) => DayKey.safeParse(day).success, { error: "Invalid day (expected YYYY-MM-DD)." }) });
|
|
1276
|
+
/** The daily endpoints. */
|
|
1277
|
+
const dailyEndpoints = {
|
|
1278
|
+
...session.group({
|
|
1279
|
+
/**
|
|
1280
|
+
* Every daily game with the caller's completions (empty maps when signed
|
|
1281
|
+
* out), plus the top 10 players by completions across all games.
|
|
1282
|
+
*/
|
|
1283
|
+
listDailyChallengesForAllGames: {
|
|
1284
|
+
method: "GET",
|
|
1285
|
+
path: "/daily",
|
|
1286
|
+
response: z.object({
|
|
1287
|
+
/** UTC today, a hint: clients use their local date. */
|
|
1288
|
+
today: DayKey,
|
|
1289
|
+
games: z.array(z.object({
|
|
1290
|
+
slug: Slug,
|
|
1291
|
+
title: z.string(),
|
|
1292
|
+
completions: DailyCompletions
|
|
1293
|
+
})),
|
|
1294
|
+
global: z.array(z.object({
|
|
1295
|
+
rank: Rank,
|
|
1296
|
+
userId: UserId,
|
|
1297
|
+
username: z.string(),
|
|
1298
|
+
/** Daily challenges completed, across all games. */
|
|
1299
|
+
score: z.number().int().nonnegative()
|
|
1300
|
+
}))
|
|
1301
|
+
}),
|
|
1302
|
+
doc: {
|
|
1303
|
+
summary: "Daily challenges across every game, plus global standings",
|
|
1304
|
+
tags: ["daily"]
|
|
1305
|
+
}
|
|
1306
|
+
} }),
|
|
1307
|
+
...use(session, game).path("/games/:slug").group({ getGameDailyChallenges: {
|
|
1308
|
+
method: "GET",
|
|
1309
|
+
path: "/daily",
|
|
1310
|
+
response: z.object({
|
|
1311
|
+
enabled: z.literal(true),
|
|
1312
|
+
/** UTC today, a hint: clients use their local date. */
|
|
1313
|
+
today: DayKey,
|
|
1314
|
+
/** Empty when signed out. */
|
|
1315
|
+
completions: DailyCompletions
|
|
1316
|
+
}),
|
|
1317
|
+
errors: { 404: ErrorBody },
|
|
1318
|
+
doc: {
|
|
1319
|
+
summary: "The caller's completions in this game (404 if it has no dailies)",
|
|
1320
|
+
tags: ["daily"]
|
|
1321
|
+
}
|
|
1322
|
+
} }),
|
|
1323
|
+
...use(requireUser, game).path("/games/:slug").group({
|
|
1324
|
+
/**
|
|
1325
|
+
* Start (or replay) a day: returns its seed and a signed token to present
|
|
1326
|
+
* on completion. 400 when the day is outside the window.
|
|
1327
|
+
*/
|
|
1328
|
+
startDailyChallenge: {
|
|
1329
|
+
method: "POST",
|
|
1330
|
+
path: "/daily/:day/start",
|
|
1331
|
+
params: DayParams,
|
|
1332
|
+
response: z.object({
|
|
1333
|
+
/** Present it to `dailyComplete` for the same day. */
|
|
1334
|
+
token: z.string(),
|
|
1335
|
+
day: DayKey,
|
|
1336
|
+
/** Deterministic per (game, day), an unsigned 32-bit integer. */
|
|
1337
|
+
seed: z.number().int().min(0).max(4294967295),
|
|
1338
|
+
/** When the token stops working (epoch ms), about 26 hours out. */
|
|
1339
|
+
expiresAt: Millis,
|
|
1340
|
+
alreadyCompleted: z.boolean(),
|
|
1341
|
+
/** Epoch ms, or `null` when not completed yet. */
|
|
1342
|
+
completedAt: Millis.nullable(),
|
|
1343
|
+
/** The earlier completion's JSON, or `null`. */
|
|
1344
|
+
meta: Json
|
|
1345
|
+
}),
|
|
1346
|
+
errors: {
|
|
1347
|
+
400: ErrorBody,
|
|
1348
|
+
401: ErrorBody,
|
|
1349
|
+
404: ErrorBody
|
|
1350
|
+
},
|
|
1351
|
+
doc: {
|
|
1352
|
+
summary: "Start a day's challenge and get its token",
|
|
1353
|
+
tags: ["daily"]
|
|
1354
|
+
}
|
|
1355
|
+
},
|
|
1356
|
+
/**
|
|
1357
|
+
* Record a completion with the token from `dailyStart`. Idempotent: a
|
|
1358
|
+
* replay of a done day changes nothing. 401 on a bad, expired or
|
|
1359
|
+
* mismatched token.
|
|
1360
|
+
*/
|
|
1361
|
+
completeDailyChallenge: {
|
|
1362
|
+
method: "POST",
|
|
1363
|
+
path: "/daily/:day/complete",
|
|
1364
|
+
params: DayParams,
|
|
1365
|
+
body: z.object({
|
|
1366
|
+
/** The token from `dailyStart`; a missing or non-text token is refused like a bad one (401). */
|
|
1367
|
+
token: z.string().optional().catch(void 0),
|
|
1368
|
+
/** Any JSON; a numeric `meta.score` is kept with the completion. */
|
|
1369
|
+
meta: Json.optional()
|
|
1370
|
+
}),
|
|
1371
|
+
response: z.object({
|
|
1372
|
+
ok: z.literal(true),
|
|
1373
|
+
day: DayKey,
|
|
1374
|
+
/** The caller's completions in this game. */
|
|
1375
|
+
count: z.number().int().positive(),
|
|
1376
|
+
/** The caller's rank on the game's `daily` board. */
|
|
1377
|
+
rank: Rank.nullable()
|
|
1378
|
+
}),
|
|
1379
|
+
errors: {
|
|
1380
|
+
400: ErrorBody,
|
|
1381
|
+
401: ErrorBody,
|
|
1382
|
+
404: ErrorBody
|
|
1383
|
+
},
|
|
1384
|
+
doc: {
|
|
1385
|
+
summary: "Complete a day with its token",
|
|
1386
|
+
tags: ["daily"]
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
})
|
|
1390
|
+
};
|
|
1391
|
+
//#endregion
|
|
1392
|
+
//#region src/api/economy/bundle.ts
|
|
1393
|
+
/**
|
|
1394
|
+
* Economy shapes: a player's per-game holdings and the bundles that move
|
|
1395
|
+
* goods. Items are opaque, app-defined {@link Key}s; the hub only counts them.
|
|
1396
|
+
*/
|
|
1397
|
+
/**
|
|
1398
|
+
* The default largest quantity (or coin amount) one bundle entry may carry.
|
|
1399
|
+
* The hub's limit is `HUB_MAX_ITEM_QTY`, which defaults to this.
|
|
1400
|
+
*/
|
|
1401
|
+
const MAX_ITEM_QTY = 1e6;
|
|
1402
|
+
/**
|
|
1403
|
+
* The default most distinct item keys one bundle may name. The hub's limit
|
|
1404
|
+
* is `HUB_MAX_BUNDLE_ITEMS`, which defaults to this.
|
|
1405
|
+
*/
|
|
1406
|
+
const MAX_BUNDLE_ITEMS = 50;
|
|
1407
|
+
/** A player's holdings in one game: items with a positive count, and coins. */
|
|
1408
|
+
const Inventory = z.object({
|
|
1409
|
+
/** Sorted by key. */
|
|
1410
|
+
items: z.array(z.object({
|
|
1411
|
+
key: Key,
|
|
1412
|
+
qty: z.number().int().positive()
|
|
1413
|
+
})),
|
|
1414
|
+
coins: z.number().int().nonnegative()
|
|
1415
|
+
});
|
|
1416
|
+
/**
|
|
1417
|
+
* A bundle quantity, read like the hub's `Number(x)` (numeric strings pass).
|
|
1418
|
+
* A value that is not a number reads as `NaN` here rather than failing, so
|
|
1419
|
+
* the hub's own range check, which knows its configured limits, answers with
|
|
1420
|
+
* its message (e.g. `grant.coins must be an integer in [0, 1000000].`).
|
|
1421
|
+
* Clients should send a JSON integer.
|
|
1422
|
+
*/
|
|
1423
|
+
const BundleQuantity = z.coerce.number().catch(NaN);
|
|
1424
|
+
/**
|
|
1425
|
+
* Goods to grant, spend or trade, as a request sends them, for the request
|
|
1426
|
+
* field named `label` (`grant`, `take`, `give`, `want`), which the error
|
|
1427
|
+
* messages name. Both parts are optional (`null` or missing means none).
|
|
1428
|
+
* Item keys must be {@link Key}s, at most `HUB_MAX_BUNDLE_ITEMS` of them,
|
|
1429
|
+
* each with a quantity from 1 to `HUB_MAX_ITEM_QTY`; coins run from 0 to
|
|
1430
|
+
* `HUB_MAX_ITEM_QTY`. The hub checks those limits and the keys itself.
|
|
1431
|
+
*/
|
|
1432
|
+
function bundleInput(label) {
|
|
1433
|
+
return z.object({
|
|
1434
|
+
/** 0 to `HUB_MAX_ITEM_QTY`. */
|
|
1435
|
+
coins: BundleQuantity.nullish(),
|
|
1436
|
+
/** Item key to quantity (1 to `HUB_MAX_ITEM_QTY`). */
|
|
1437
|
+
items: z.record(z.string(), BundleQuantity, { error: `${label}.items must be an object of keyβquantity.` }).nullish()
|
|
1438
|
+
}, { error: `${label} must be an object.` });
|
|
1439
|
+
}
|
|
1440
|
+
/** A request bundle, for documentation and clients. */
|
|
1441
|
+
const BundleInput = bundleInput("bundle");
|
|
1442
|
+
/** A bundle as the hub stores and returns it on a trade offer: both parts always present. */
|
|
1443
|
+
const Bundle = z.object({
|
|
1444
|
+
items: z.record(Key, z.number().int().positive()),
|
|
1445
|
+
coins: z.number().int().nonnegative()
|
|
1446
|
+
});
|
|
1447
|
+
//#endregion
|
|
1448
|
+
//#region src/api/economy/inventory.ts
|
|
1449
|
+
/**
|
|
1450
|
+
* Per-game inventory: the hub is the ledger (no negative balances, goods are
|
|
1451
|
+
* conserved in trades) but does not judge game rules, so granting is trusted
|
|
1452
|
+
* like scores are.
|
|
1453
|
+
*/
|
|
1454
|
+
const BAD_USER_ID = "Bad user id.";
|
|
1455
|
+
/** `:userId`: a positive integer, else 400 "Bad user id.". */
|
|
1456
|
+
const PlayerIdParam = z.coerce.number({ error: BAD_USER_ID }).int({ error: BAD_USER_ID }).positive({ error: BAD_USER_ID });
|
|
1457
|
+
/** The inventory endpoints. */
|
|
1458
|
+
const inventoryEndpoints = {
|
|
1459
|
+
...use(requireUser, game).path("/games/:slug").group({
|
|
1460
|
+
getMyInventory: {
|
|
1461
|
+
method: "GET",
|
|
1462
|
+
path: "/inventory",
|
|
1463
|
+
response: Inventory,
|
|
1464
|
+
errors: {
|
|
1465
|
+
401: ErrorBody,
|
|
1466
|
+
404: ErrorBody
|
|
1467
|
+
},
|
|
1468
|
+
doc: {
|
|
1469
|
+
summary: "The caller's items and coins in this game",
|
|
1470
|
+
tags: ["economy"]
|
|
1471
|
+
}
|
|
1472
|
+
},
|
|
1473
|
+
/** Add goods to the caller (gameplay rewards, opening a box). The body is the bundle. */
|
|
1474
|
+
grantInventoryItems: {
|
|
1475
|
+
method: "POST",
|
|
1476
|
+
path: "/inventory/grant",
|
|
1477
|
+
body: bundleInput("grant"),
|
|
1478
|
+
response: Inventory,
|
|
1479
|
+
errors: {
|
|
1480
|
+
400: ErrorBody,
|
|
1481
|
+
401: ErrorBody,
|
|
1482
|
+
404: ErrorBody
|
|
1483
|
+
},
|
|
1484
|
+
doc: {
|
|
1485
|
+
summary: "Grant goods to the caller; returns the new holdings",
|
|
1486
|
+
tags: ["economy"]
|
|
1487
|
+
}
|
|
1488
|
+
},
|
|
1489
|
+
/**
|
|
1490
|
+
* Atomically remove `take` from the caller and add `give`: selling (take
|
|
1491
|
+
* items, give coins) and buying from the house (take coins, give items).
|
|
1492
|
+
* 409 when the caller can't cover `take`.
|
|
1493
|
+
*/
|
|
1494
|
+
exchangeInventoryItems: {
|
|
1495
|
+
method: "POST",
|
|
1496
|
+
path: "/inventory/exchange",
|
|
1497
|
+
body: z.object({
|
|
1498
|
+
take: bundleInput("take").nullish(),
|
|
1499
|
+
give: bundleInput("give").nullish()
|
|
1500
|
+
}),
|
|
1501
|
+
response: Inventory,
|
|
1502
|
+
errors: {
|
|
1503
|
+
400: ErrorBody,
|
|
1504
|
+
401: ErrorBody,
|
|
1505
|
+
404: ErrorBody,
|
|
1506
|
+
409: ErrorBody
|
|
1507
|
+
},
|
|
1508
|
+
doc: {
|
|
1509
|
+
summary: "Swap goods with the house; returns the new holdings",
|
|
1510
|
+
tags: ["economy"]
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
}),
|
|
1514
|
+
...use(requireActive, game).path("/games/:slug").group({
|
|
1515
|
+
/**
|
|
1516
|
+
* Another claimed player's holdings in this game, for composing a trade
|
|
1517
|
+
* offer. 404 for a guest or unknown id.
|
|
1518
|
+
*/
|
|
1519
|
+
getPlayerInventory: {
|
|
1520
|
+
method: "GET",
|
|
1521
|
+
path: "/players/:userId/inventory",
|
|
1522
|
+
params: z.object({ userId: PlayerIdParam }),
|
|
1523
|
+
response: Inventory,
|
|
1524
|
+
errors: {
|
|
1525
|
+
400: ErrorBody,
|
|
1526
|
+
401: ErrorBody,
|
|
1527
|
+
403: ErrorBody,
|
|
1528
|
+
404: ErrorBody
|
|
1529
|
+
},
|
|
1530
|
+
doc: {
|
|
1531
|
+
summary: "Another player's holdings (to compose a trade)",
|
|
1532
|
+
tags: ["economy"]
|
|
1533
|
+
}
|
|
1534
|
+
} })
|
|
1535
|
+
};
|
|
1536
|
+
//#endregion
|
|
1537
|
+
//#region src/api/economy/trades.ts
|
|
1538
|
+
/**
|
|
1539
|
+
* Async player-to-player trades on the economy ledger. Making an offer
|
|
1540
|
+
* escrows its `give` side; the recipient accepts (atomic swap), declines or
|
|
1541
|
+
* counters, and the sender may cancel. Pending offers expire after 14 days
|
|
1542
|
+
* (`HUB_TRADE_TTL_MS`), refunding the escrow.
|
|
1543
|
+
*/
|
|
1544
|
+
/** Where an offer is in its life. Only `pending` offers can change. */
|
|
1545
|
+
const TradeStatus = z.enum([
|
|
1546
|
+
"pending",
|
|
1547
|
+
"accepted",
|
|
1548
|
+
"declined",
|
|
1549
|
+
"cancelled",
|
|
1550
|
+
"countered",
|
|
1551
|
+
"expired"
|
|
1552
|
+
]);
|
|
1553
|
+
/** A party to a trade, or a player the roster offers to trade with. */
|
|
1554
|
+
const TradePlayer = PlayerLite.pick({
|
|
1555
|
+
id: true,
|
|
1556
|
+
username: true
|
|
1557
|
+
});
|
|
1558
|
+
/** A trade offer, seen from the caller's side. */
|
|
1559
|
+
const TradeOffer = z.object({
|
|
1560
|
+
id: z.number().int().positive(),
|
|
1561
|
+
game: Slug,
|
|
1562
|
+
status: TradeStatus,
|
|
1563
|
+
/** `outgoing` when the caller sent it. */
|
|
1564
|
+
direction: z.enum(["incoming", "outgoing"]),
|
|
1565
|
+
from: TradePlayer,
|
|
1566
|
+
to: TradePlayer,
|
|
1567
|
+
/** What the sender offers (escrowed while pending). */
|
|
1568
|
+
give: Bundle,
|
|
1569
|
+
/** What the sender asks for in return. */
|
|
1570
|
+
want: Bundle,
|
|
1571
|
+
/** The sender's note, `''` when none. */
|
|
1572
|
+
note: z.string(),
|
|
1573
|
+
/** The offer this one counters, or `null`. */
|
|
1574
|
+
parentId: z.number().int().positive().nullable(),
|
|
1575
|
+
/** Epoch ms. */
|
|
1576
|
+
createdAt: Millis,
|
|
1577
|
+
/** When it stopped being pending (epoch ms), or `null`. */
|
|
1578
|
+
resolvedAt: Millis.nullable(),
|
|
1579
|
+
/** When a pending offer expires (epoch ms). */
|
|
1580
|
+
expiresAt: Millis
|
|
1581
|
+
});
|
|
1582
|
+
/**
|
|
1583
|
+
* Active and a claimed account, for making, accepting and countering trades
|
|
1584
|
+
* (so escrow is never stranded on a guest). A guest gets 403 "Create a free
|
|
1585
|
+
* account (via the menu) to trade." with no `code`, before the game or the
|
|
1586
|
+
* body is checked; unlike the shared `requireClaimed`, which says
|
|
1587
|
+
* `claim_required`.
|
|
1588
|
+
*/
|
|
1589
|
+
const requireTrader = middleware("requireTrader", {
|
|
1590
|
+
provides: ctx(),
|
|
1591
|
+
requires: [requireActive]
|
|
1592
|
+
});
|
|
1593
|
+
/** `{ offer }`: the offer after a trade action. */
|
|
1594
|
+
const OfferResult = z.object({ offer: TradeOffer });
|
|
1595
|
+
/** The two sides of a new offer or counter, plus an optional note (moderated, up to 200 characters). */
|
|
1596
|
+
const OfferTerms = z.object({
|
|
1597
|
+
give: bundleInput("give").nullish(),
|
|
1598
|
+
want: bundleInput("want").nullish(),
|
|
1599
|
+
/** Shown to the other player, so it is moderated. */
|
|
1600
|
+
note: LooseText.nullish()
|
|
1601
|
+
});
|
|
1602
|
+
/**
|
|
1603
|
+
* `:id` of a trade offer, read with `Number()`: an id that is not a number
|
|
1604
|
+
* names no offer, so it is a 404 "No such trade offer." like an unknown one.
|
|
1605
|
+
*/
|
|
1606
|
+
const OfferParams = z.object({ id: LooseNumber.catch(NaN) });
|
|
1607
|
+
/** Errors from resolving an offer: not yours (403), unknown (404), no longer pending (409). */
|
|
1608
|
+
const ResolveErrors = {
|
|
1609
|
+
401: ErrorBody,
|
|
1610
|
+
403: ErrorBody,
|
|
1611
|
+
404: ErrorBody,
|
|
1612
|
+
409: ErrorBody
|
|
1613
|
+
};
|
|
1614
|
+
/** The trade endpoints. */
|
|
1615
|
+
const tradeEndpoints = {
|
|
1616
|
+
...use(requireActive, game).path("/games/:slug").group({
|
|
1617
|
+
/** Claimed players with a footprint in this game, excluding the caller (up to 500, by name). */
|
|
1618
|
+
listTradePartners: {
|
|
1619
|
+
method: "GET",
|
|
1620
|
+
path: "/trades/players",
|
|
1621
|
+
response: z.object({ players: z.array(TradePlayer) }),
|
|
1622
|
+
errors: {
|
|
1623
|
+
401: ErrorBody,
|
|
1624
|
+
403: ErrorBody,
|
|
1625
|
+
404: ErrorBody
|
|
1626
|
+
},
|
|
1627
|
+
doc: {
|
|
1628
|
+
summary: "Players the caller can trade with in this game",
|
|
1629
|
+
tags: ["trades"]
|
|
1630
|
+
}
|
|
1631
|
+
},
|
|
1632
|
+
/** The caller's offers in this game, newest first (up to 100). Expired offers are settled first. */
|
|
1633
|
+
listMyTrades: {
|
|
1634
|
+
method: "GET",
|
|
1635
|
+
path: "/trades",
|
|
1636
|
+
query: z.object({
|
|
1637
|
+
/** Only offers sent to (`incoming`) or by (`outgoing`) the caller; omit (or anything else) for both. */
|
|
1638
|
+
box: z.enum(["incoming", "outgoing"]).optional().catch(void 0),
|
|
1639
|
+
/** Only offers with this {@link TradeStatus}; an unknown status matches nothing, `''` means any. */
|
|
1640
|
+
status: z.union([TradeStatus, z.string()]).optional()
|
|
1641
|
+
}),
|
|
1642
|
+
response: z.object({ offers: z.array(TradeOffer) }),
|
|
1643
|
+
errors: {
|
|
1644
|
+
401: ErrorBody,
|
|
1645
|
+
403: ErrorBody,
|
|
1646
|
+
404: ErrorBody
|
|
1647
|
+
},
|
|
1648
|
+
doc: {
|
|
1649
|
+
summary: "The caller's trade offers",
|
|
1650
|
+
tags: ["trades"]
|
|
1651
|
+
}
|
|
1652
|
+
},
|
|
1653
|
+
declineTrade: {
|
|
1654
|
+
method: "POST",
|
|
1655
|
+
path: "/trades/:id/decline",
|
|
1656
|
+
params: OfferParams,
|
|
1657
|
+
response: OfferResult,
|
|
1658
|
+
errors: ResolveErrors,
|
|
1659
|
+
doc: {
|
|
1660
|
+
summary: "Decline an offer sent to the caller (refunds the escrow)",
|
|
1661
|
+
tags: ["trades"]
|
|
1662
|
+
}
|
|
1663
|
+
},
|
|
1664
|
+
cancelTrade: {
|
|
1665
|
+
method: "POST",
|
|
1666
|
+
path: "/trades/:id/cancel",
|
|
1667
|
+
params: OfferParams,
|
|
1668
|
+
response: OfferResult,
|
|
1669
|
+
errors: ResolveErrors,
|
|
1670
|
+
doc: {
|
|
1671
|
+
summary: "Cancel an offer the caller sent (refunds the escrow)",
|
|
1672
|
+
tags: ["trades"]
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
}),
|
|
1676
|
+
...use(requireTrader, game).path("/games/:slug").group({
|
|
1677
|
+
/**
|
|
1678
|
+
* Offer a trade to the player named `toUser`. Escrows `give` (409 if the
|
|
1679
|
+
* caller can't cover it); at least one side must be non-empty.
|
|
1680
|
+
*/
|
|
1681
|
+
createTrade: {
|
|
1682
|
+
method: "POST",
|
|
1683
|
+
path: "/trades",
|
|
1684
|
+
body: OfferTerms.extend({
|
|
1685
|
+
/** The recipient's username (case-insensitive). Missing or blank is a 400. */
|
|
1686
|
+
toUser: LooseText.nullish() }),
|
|
1687
|
+
responses: { 201: OfferResult },
|
|
1688
|
+
errors: {
|
|
1689
|
+
400: ErrorBody,
|
|
1690
|
+
401: ErrorBody,
|
|
1691
|
+
403: ErrorBody,
|
|
1692
|
+
404: ErrorBody,
|
|
1693
|
+
409: ErrorBody,
|
|
1694
|
+
422: ContentRejected
|
|
1695
|
+
},
|
|
1696
|
+
doc: {
|
|
1697
|
+
summary: "Make a trade offer",
|
|
1698
|
+
tags: ["trades"]
|
|
1699
|
+
}
|
|
1700
|
+
},
|
|
1701
|
+
/** Accept an offer sent to the caller: they must own its `want` side (409 otherwise). */
|
|
1702
|
+
acceptTrade: {
|
|
1703
|
+
method: "POST",
|
|
1704
|
+
path: "/trades/:id/accept",
|
|
1705
|
+
params: OfferParams,
|
|
1706
|
+
response: OfferResult,
|
|
1707
|
+
errors: ResolveErrors,
|
|
1708
|
+
doc: {
|
|
1709
|
+
summary: "Accept an offer (atomic swap)",
|
|
1710
|
+
tags: ["trades"]
|
|
1711
|
+
}
|
|
1712
|
+
},
|
|
1713
|
+
/**
|
|
1714
|
+
* Counter an offer sent to the caller: the original is refunded and
|
|
1715
|
+
* closed as `countered`, and a linked offer goes back to its sender.
|
|
1716
|
+
*/
|
|
1717
|
+
counterTradeOffer: {
|
|
1718
|
+
method: "POST",
|
|
1719
|
+
path: "/trades/:id/counter",
|
|
1720
|
+
params: OfferParams,
|
|
1721
|
+
body: OfferTerms,
|
|
1722
|
+
responses: { 201: OfferResult },
|
|
1723
|
+
errors: {
|
|
1724
|
+
...ResolveErrors,
|
|
1725
|
+
400: ErrorBody,
|
|
1726
|
+
422: ContentRejected
|
|
1727
|
+
},
|
|
1728
|
+
doc: {
|
|
1729
|
+
summary: "Counter an offer with a new one",
|
|
1730
|
+
tags: ["trades"]
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
})
|
|
1734
|
+
};
|
|
1735
|
+
//#endregion
|
|
1736
|
+
//#region src/api/economy.ts
|
|
1737
|
+
/**
|
|
1738
|
+
* Per-game economy: inventory, grants, exchanges and trades. The endpoints
|
|
1739
|
+
* and shapes live in `economy/`.
|
|
1740
|
+
*/
|
|
1741
|
+
/** The economy endpoints. */
|
|
1742
|
+
const economyEndpoints = {
|
|
1743
|
+
...inventoryEndpoints,
|
|
1744
|
+
...tradeEndpoints
|
|
1745
|
+
};
|
|
1746
|
+
//#endregion
|
|
1747
|
+
//#region src/api/social/config.ts
|
|
1748
|
+
/**
|
|
1749
|
+
* The site-wide social configuration every social UI renders from
|
|
1750
|
+
* (`GET /social/config`, `ev:config`, the `hello` snapshot).
|
|
1751
|
+
*/
|
|
1752
|
+
/** A quick-chat phrase id: a hub phrase (`gg`), an emote (`e:<emoji>`) or a game phrase (`g:<slug>:<n>`). */
|
|
1753
|
+
const QuickId = z.string().min(1);
|
|
1754
|
+
/** A non-negative character limit. */
|
|
1755
|
+
const CharLimit = z.number().int().nonnegative();
|
|
1756
|
+
/** Text length limits, in characters. */
|
|
1757
|
+
const SocialLimits = z.object({
|
|
1758
|
+
dmMaxChars: CharLimit,
|
|
1759
|
+
roomChatMaxChars: CharLimit,
|
|
1760
|
+
suggestionTitleMax: CharLimit,
|
|
1761
|
+
suggestionBodyMax: CharLimit
|
|
1762
|
+
});
|
|
1763
|
+
/** The site-wide social configuration. */
|
|
1764
|
+
const SocialConfig = z.object({
|
|
1765
|
+
/** The site-wide chat mode (operator setting tightened by the admin switch). */
|
|
1766
|
+
chatMode: ChatMode,
|
|
1767
|
+
/** The AI safety reviewer is configured. */
|
|
1768
|
+
ai: z.boolean(),
|
|
1769
|
+
/** Where parents can reach the operator, `''` when unset. */
|
|
1770
|
+
contactUrl: z.string(),
|
|
1771
|
+
/** The public "how chat is kept safe" page. */
|
|
1772
|
+
parentsUrl: z.string(),
|
|
1773
|
+
/** The Web Push VAPID public key (base64url), `''` when push isn't configured. */
|
|
1774
|
+
vapidPublicKey: z.string(),
|
|
1775
|
+
/** Every quick-chat phrase, id to text: hub phrases, emotes and every game's phrases. */
|
|
1776
|
+
quickChat: z.record(QuickId, z.string()),
|
|
1777
|
+
/** The emote emoji (also in `quickChat` as `e:<emoji>` ids). */
|
|
1778
|
+
emotes: z.array(z.string()),
|
|
1779
|
+
limits: SocialLimits,
|
|
1780
|
+
/** The preset avatar ids (`a0` to `a23`). */
|
|
1781
|
+
avatars: z.array(z.string()),
|
|
1782
|
+
/** Present (`true`) only when the host runs in test mode (`HUB_TEST=1`). */
|
|
1783
|
+
test: z.literal(true).optional()
|
|
1784
|
+
});
|
|
1785
|
+
//#endregion
|
|
1786
|
+
//#region src/api/messages.ts
|
|
1787
|
+
/**
|
|
1788
|
+
* Direct messages between mutual friends (multiplayer-impl.md Β§5.2).
|
|
1789
|
+
*
|
|
1790
|
+
* "Checked before delivered": text is moderated before it is stored or
|
|
1791
|
+
* pushed. Free text needs the conversation's chat mode to be `on` (both
|
|
1792
|
+
* claimed, friends, no parent lock, site switch on); otherwise only
|
|
1793
|
+
* quick-chat ids go through. Hidden messages and messages from currently
|
|
1794
|
+
* suspended senders are left out of every read.
|
|
1795
|
+
*/
|
|
1796
|
+
/** A message id: a positive integer, increasing over time. */
|
|
1797
|
+
const MessageId = z.number().int().positive();
|
|
1798
|
+
/** A direct message. */
|
|
1799
|
+
const Message = z.object({
|
|
1800
|
+
id: MessageId,
|
|
1801
|
+
/** The sender. */
|
|
1802
|
+
from: UserId,
|
|
1803
|
+
/** The recipient. */
|
|
1804
|
+
to: UserId,
|
|
1805
|
+
/** The delivered (possibly softened) text; a quick message carries its phrase's text. */
|
|
1806
|
+
text: z.string(),
|
|
1807
|
+
/** The quick-chat phrase id, for quick messages only. */
|
|
1808
|
+
quick: QuickId.optional(),
|
|
1809
|
+
/** When it was sent (epoch ms). */
|
|
1810
|
+
at: Millis,
|
|
1811
|
+
/** The recipient has read it. */
|
|
1812
|
+
read: z.boolean(),
|
|
1813
|
+
/** Words were swapped by the filter. Only on the sender's own copy. */
|
|
1814
|
+
softened: z.literal(true).optional()
|
|
1815
|
+
});
|
|
1816
|
+
/** A conversation summary: the other player, the last message and how many of theirs you haven't read. */
|
|
1817
|
+
const Conversation = z.object({
|
|
1818
|
+
user: PlayerLite,
|
|
1819
|
+
last: Message,
|
|
1820
|
+
unread: z.number().int().nonnegative()
|
|
1821
|
+
});
|
|
1822
|
+
/** One page of a conversation's history. */
|
|
1823
|
+
const MessagesPage = z.object({
|
|
1824
|
+
/** Oldest to newest, at most 50. */
|
|
1825
|
+
messages: z.array(Message),
|
|
1826
|
+
/** Older messages exist (page with `?before=` the first id). */
|
|
1827
|
+
more: z.boolean(),
|
|
1828
|
+
/** The mode the chat UI should render: `off` when DMs are off, `quick` while you are muted. */
|
|
1829
|
+
mode: ChatMode
|
|
1830
|
+
});
|
|
1831
|
+
/** A DM to send: free text, or a quick-chat phrase id (exactly one). */
|
|
1832
|
+
const SendMessageInput = z.object({
|
|
1833
|
+
/** Free text (moderated; only when the conversation's mode is `on`). */
|
|
1834
|
+
text: z.string().optional(),
|
|
1835
|
+
/** A quick-chat phrase id (any mode but `off`). */
|
|
1836
|
+
quick: QuickId.optional()
|
|
1837
|
+
}).refine((b) => b.text === void 0 !== (b.quick === void 0), "Send either text or a quick-chat id.");
|
|
1838
|
+
/**
|
|
1839
|
+
* Unread DMs (the `hello` snapshot's `unread`), ignoring hidden messages and
|
|
1840
|
+
* suspended senders.
|
|
1841
|
+
*/
|
|
1842
|
+
const UnreadSummary = z.object({
|
|
1843
|
+
/** Total unread. */
|
|
1844
|
+
dms: z.number().int().nonnegative(),
|
|
1845
|
+
/** Unread per sender, keyed by the sender's user id (as a decimal string). */
|
|
1846
|
+
byUser: z.record(z.string().regex(/^\d+$/), z.number().int().positive())
|
|
1847
|
+
});
|
|
1848
|
+
/**
|
|
1849
|
+
* `:userId`, the other side of the conversation: a claimed, non-suspended
|
|
1850
|
+
* player other than you, else 404 "No such player." Read like `Number(x)`:
|
|
1851
|
+
* anything that is not a player's id is the same 404.
|
|
1852
|
+
*/
|
|
1853
|
+
const Partner = z.object({ userId: LooseNumber.catch(NaN) });
|
|
1854
|
+
/** The direct-message endpoints. */
|
|
1855
|
+
const messagesEndpoints = { ...requireClaimed.group({
|
|
1856
|
+
listConversations: {
|
|
1857
|
+
method: "GET",
|
|
1858
|
+
path: "/social/conversations",
|
|
1859
|
+
response: z.object({
|
|
1860
|
+
/** Newest first, one per partner, at most 200. */
|
|
1861
|
+
conversations: z.array(Conversation) }),
|
|
1862
|
+
errors: {
|
|
1863
|
+
401: ErrorBody,
|
|
1864
|
+
403: ErrorBody
|
|
1865
|
+
},
|
|
1866
|
+
doc: {
|
|
1867
|
+
summary: "Your conversations, newest first",
|
|
1868
|
+
tags: ["messages"]
|
|
1869
|
+
}
|
|
1870
|
+
},
|
|
1871
|
+
listDirectMessages: {
|
|
1872
|
+
method: "GET",
|
|
1873
|
+
path: "/social/messages/:userId",
|
|
1874
|
+
params: Partner,
|
|
1875
|
+
query: z.object({
|
|
1876
|
+
/** Only messages older than this message id; `0` or anything not a number means from the newest. */
|
|
1877
|
+
before: LooseNumber.optional().catch(void 0) }),
|
|
1878
|
+
response: MessagesPage,
|
|
1879
|
+
errors: {
|
|
1880
|
+
401: ErrorBody,
|
|
1881
|
+
403: ErrorBody,
|
|
1882
|
+
404: ErrorBody
|
|
1883
|
+
},
|
|
1884
|
+
doc: {
|
|
1885
|
+
summary: "One page of history with a player",
|
|
1886
|
+
tags: ["messages"]
|
|
1887
|
+
}
|
|
1888
|
+
},
|
|
1889
|
+
sendDirectMessage: {
|
|
1890
|
+
method: "POST",
|
|
1891
|
+
path: "/social/messages/:userId",
|
|
1892
|
+
params: Partner,
|
|
1893
|
+
body: SendMessageInput,
|
|
1894
|
+
response: z.object({ message: Message }),
|
|
1895
|
+
errors: {
|
|
1896
|
+
400: ErrorBody,
|
|
1897
|
+
401: ErrorBody,
|
|
1898
|
+
403: ErrorBody,
|
|
1899
|
+
404: ErrorBody,
|
|
1900
|
+
422: ContentRejected
|
|
1901
|
+
},
|
|
1902
|
+
doc: {
|
|
1903
|
+
summary: "Send a DM to a friend",
|
|
1904
|
+
description: "403 `not_friends` or `dms_closed`; 422 when the text is rejected, the chat mode forbids it, or you are sending too fast.",
|
|
1905
|
+
tags: ["messages"]
|
|
1906
|
+
}
|
|
1907
|
+
},
|
|
1908
|
+
markDirectMessagesRead: {
|
|
1909
|
+
method: "POST",
|
|
1910
|
+
path: "/social/messages/:userId/read",
|
|
1911
|
+
params: Partner,
|
|
1912
|
+
body: z.object({
|
|
1913
|
+
/** Mark their messages to you up to and including this id. */
|
|
1914
|
+
upTo: requiredId("upTo must be a message id.") }),
|
|
1915
|
+
response: Ok,
|
|
1916
|
+
errors: {
|
|
1917
|
+
400: ErrorBody,
|
|
1918
|
+
401: ErrorBody,
|
|
1919
|
+
403: ErrorBody,
|
|
1920
|
+
404: ErrorBody
|
|
1921
|
+
},
|
|
1922
|
+
doc: {
|
|
1923
|
+
summary: "Mark a friend's messages to you as read",
|
|
1924
|
+
tags: ["messages"]
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
}) };
|
|
1928
|
+
//#endregion
|
|
1929
|
+
//#region src/api/mp/parties.ts
|
|
1930
|
+
/**
|
|
1931
|
+
* Invites, parties and launches (multiplayer-impl.md Β§5.6): the shapes the
|
|
1932
|
+
* mp endpoints, the `invite` / `invite.update` / `party` socket events and
|
|
1933
|
+
* the `hello` snapshot share. All of it lives in the hub's memory; a restart
|
|
1934
|
+
* drops it.
|
|
1935
|
+
*/
|
|
1936
|
+
/** A game mode key, as the game's `multiplayer.modes[].key` declares it: `[A-Za-z0-9_-]{1,32}`. */
|
|
1937
|
+
const GameMode = z.string().regex(/^[A-Za-z0-9_-]{1,32}$/);
|
|
1938
|
+
/** An opaque hub-minted id (party, invite, room): base64url text. */
|
|
1939
|
+
const OpaqueId = z.string().min(1);
|
|
1940
|
+
/** A `joinInfo` key: `[A-Za-z0-9_]{1,32}`. */
|
|
1941
|
+
const JoinInfoKey = z.string().regex(/^[A-Za-z0-9_]{1,32}$/);
|
|
1942
|
+
/**
|
|
1943
|
+
* Where a party should connect, as its leader publishes it (e.g.
|
|
1944
|
+
* `{ room: 'ABCDE' }`): at most 8 short keys, string values of at most 64
|
|
1945
|
+
* characters. The hub accepts finite numbers and stores them as strings.
|
|
1946
|
+
*/
|
|
1947
|
+
const JoinInfo = z.record(JoinInfoKey, z.union([z.string().max(64), z.number().transform(String)])).refine((joinInfo) => Object.keys(joinInfo).length <= 8, "info may have at most 8 keys");
|
|
1948
|
+
/** A group of 2 to 8 players moving between games together. A user is in at most one. */
|
|
1949
|
+
const Party = z.object({
|
|
1950
|
+
id: OpaqueId,
|
|
1951
|
+
game: Slug,
|
|
1952
|
+
/** Present when the party formed for a specific mode (`race` for race invites). */
|
|
1953
|
+
mode: GameMode.optional(),
|
|
1954
|
+
leaderId: UserId,
|
|
1955
|
+
members: z.array(PlayerLite),
|
|
1956
|
+
/** `null` until the leader publishes where to connect. */
|
|
1957
|
+
joinInfo: JoinInfo.nullable(),
|
|
1958
|
+
/** Epoch ms. */
|
|
1959
|
+
createdAt: Millis
|
|
1960
|
+
});
|
|
1961
|
+
/** "Come play <game> with me": pending until accepted, declined, cancelled or expired. */
|
|
1962
|
+
const Invite = z.object({
|
|
1963
|
+
id: OpaqueId,
|
|
1964
|
+
from: PlayerLite,
|
|
1965
|
+
to: PlayerLite,
|
|
1966
|
+
game: Slug,
|
|
1967
|
+
gameTitle: z.string(),
|
|
1968
|
+
mode: GameMode.optional(),
|
|
1969
|
+
/** The inviter's party the invitee joins on accept. */
|
|
1970
|
+
partyId: OpaqueId,
|
|
1971
|
+
/** Epoch ms. */
|
|
1972
|
+
expiresAt: Millis
|
|
1973
|
+
});
|
|
1974
|
+
/** How an invite ended. */
|
|
1975
|
+
const InviteStatus = z.enum([
|
|
1976
|
+
"accepted",
|
|
1977
|
+
"declined",
|
|
1978
|
+
"expired",
|
|
1979
|
+
"cancelled"
|
|
1980
|
+
]);
|
|
1981
|
+
/** A single-use launch token plus the game URL that redeems it (`?hub_launch=<token>`). */
|
|
1982
|
+
const LaunchTicket = z.object({
|
|
1983
|
+
launchToken: z.string(),
|
|
1984
|
+
/** `/<slug>/<lobby>` with `hub_launch` appended. */
|
|
1985
|
+
url: z.string()
|
|
1986
|
+
});
|
|
1987
|
+
/** Why the hub is bringing a player into a game. */
|
|
1988
|
+
const LaunchKind = z.enum([
|
|
1989
|
+
"host",
|
|
1990
|
+
"guest",
|
|
1991
|
+
"join",
|
|
1992
|
+
"watch"
|
|
1993
|
+
]);
|
|
1994
|
+
/**
|
|
1995
|
+
* What a game's `hub.mp.onLaunch` receives after redeeming a launch token.
|
|
1996
|
+
* Party fields reflect the party as it is at redemption time.
|
|
1997
|
+
*/
|
|
1998
|
+
const Launch = z.object({
|
|
1999
|
+
kind: LaunchKind,
|
|
2000
|
+
game: Slug,
|
|
2001
|
+
mode: GameMode.optional(),
|
|
2002
|
+
/** Host and guest launches: the party they belong to. */
|
|
2003
|
+
partyId: OpaqueId.optional(),
|
|
2004
|
+
/** The party now; `null` when it has dissolved since the token was minted. */
|
|
2005
|
+
party: Party.nullable().optional(),
|
|
2006
|
+
/** The party's join info now; `null` when unset or the party is gone. */
|
|
2007
|
+
joinInfo: JoinInfo.nullable().optional(),
|
|
2008
|
+
/** Join and watch launches: the room code the friend's presence advertised. */
|
|
2009
|
+
room: z.string().optional(),
|
|
2010
|
+
/** Guest launches: the party leader (`null` if the account is gone). */
|
|
2011
|
+
host: PlayerLite.nullable().optional(),
|
|
2012
|
+
/** Join and watch launches: the friend being joined (`null` if the account is gone). */
|
|
2013
|
+
target: PlayerLite.nullable().optional()
|
|
2014
|
+
});
|
|
2015
|
+
//#endregion
|
|
2016
|
+
//#region src/api/mp/chat.ts
|
|
2017
|
+
/**
|
|
2018
|
+
* Group chat in hub rooms and parties (multiplayer.md Β§4.3, Β§4.8): what a
|
|
2019
|
+
* player sends, and the moderated line everyone receives.
|
|
2020
|
+
*/
|
|
2021
|
+
/**
|
|
2022
|
+
* A chat send: a quick-chat phrase id, or free text. When `quick` is set it
|
|
2023
|
+
* wins and `text` is ignored.
|
|
2024
|
+
*/
|
|
2025
|
+
const ChatSend = z.object({
|
|
2026
|
+
/** Free text; only allowed in mode `on`, then moderated. */
|
|
2027
|
+
text: z.string().optional(),
|
|
2028
|
+
/** A quick-chat phrase id (hub phrase, `e:<emoji>` emote or game phrase). */
|
|
2029
|
+
quick: z.string().optional()
|
|
2030
|
+
});
|
|
2031
|
+
/** The line as delivered: the (possibly softened) text, and the phrase id for quick-chat. */
|
|
2032
|
+
const ChatLine = z.object({
|
|
2033
|
+
text: z.string(),
|
|
2034
|
+
/** Set when the line is a quick-chat phrase. */
|
|
2035
|
+
quick: z.string().optional(),
|
|
2036
|
+
/** Moderation reworded the text. */
|
|
2037
|
+
softened: z.literal(true).optional()
|
|
2038
|
+
});
|
|
2039
|
+
/** A chat line pushed to a room or party, with its sender. */
|
|
2040
|
+
const ChatLineEvent = ChatLine.extend({ from: UserId });
|
|
2041
|
+
/** The error codes a chat send can fail with (besides its surface's own). */
|
|
2042
|
+
const CHAT_ERRORS = [
|
|
2043
|
+
"rate_limited",
|
|
2044
|
+
"content_rejected",
|
|
2045
|
+
"bad_quick"
|
|
2046
|
+
];
|
|
2047
|
+
//#endregion
|
|
2048
|
+
//#region src/api/mp/rooms.ts
|
|
2049
|
+
/**
|
|
2050
|
+
* Hub relay rooms (multiplayer-impl.md Β§6): realtime multiplayer for static
|
|
2051
|
+
* games, relayed (never refereed) by the hub. A race is a room with a
|
|
2052
|
+
* `race` block on top. Shared by the `room.*` / `race.*` ops and events and
|
|
2053
|
+
* the `hello` snapshot.
|
|
2054
|
+
*/
|
|
2055
|
+
/** A room's join code: 5 characters from `A-Z` and `2-9` (no `I`, `O`, `0`, `1`). */
|
|
2056
|
+
const RoomCode = z.string().regex(/^[A-HJ-NP-Z2-9]{5}$/);
|
|
2057
|
+
/** A room's lifecycle: new players join only in the lobby. */
|
|
2058
|
+
const RoomPhase = z.enum([
|
|
2059
|
+
"lobby",
|
|
2060
|
+
"playing",
|
|
2061
|
+
"ended"
|
|
2062
|
+
]);
|
|
2063
|
+
/**
|
|
2064
|
+
* Who hears a send. `host` (default): guests' unaddressed sends reach the
|
|
2065
|
+
* host only. `relay`: every unaddressed send reaches everyone, sender
|
|
2066
|
+
* included.
|
|
2067
|
+
*/
|
|
2068
|
+
const RoomModel = z.enum(["host", "relay"]);
|
|
2069
|
+
/** A seat in a room, keyed by user (any tab of that user reclaims it). */
|
|
2070
|
+
const RoomSeat = z.object({
|
|
2071
|
+
userId: UserId,
|
|
2072
|
+
/** The player's username when they sat down. */
|
|
2073
|
+
name: z.string(),
|
|
2074
|
+
/** Avatar token; never empty. */
|
|
2075
|
+
avatar: z.string(),
|
|
2076
|
+
ready: z.boolean(),
|
|
2077
|
+
/** `false` while the seat is held for a dropped player's reconnect grace. */
|
|
2078
|
+
connected: z.boolean(),
|
|
2079
|
+
/** Small per-seat game data (team, colourβ¦), at most 2 KB of JSON. */
|
|
2080
|
+
data: Json.optional()
|
|
2081
|
+
});
|
|
2082
|
+
/** The public description of a room (`room` event, room and race acks, the `hello` snapshot). */
|
|
2083
|
+
const RoomInfo = z.object({
|
|
2084
|
+
id: OpaqueId,
|
|
2085
|
+
code: RoomCode,
|
|
2086
|
+
game: Slug,
|
|
2087
|
+
/** `race` for race rooms. */
|
|
2088
|
+
mode: GameMode.optional(),
|
|
2089
|
+
hostId: UserId,
|
|
2090
|
+
/** Listed in `room.list` / `race.list`. */
|
|
2091
|
+
public: z.boolean(),
|
|
2092
|
+
phase: RoomPhase,
|
|
2093
|
+
maxPlayers: z.number().int().positive(),
|
|
2094
|
+
model: RoomModel,
|
|
2095
|
+
seats: z.array(RoomSeat),
|
|
2096
|
+
/** How many are watching. */
|
|
2097
|
+
spectators: z.number().int().nonnegative(),
|
|
2098
|
+
/** The chat mode for the seated players. */
|
|
2099
|
+
chatMode: ChatMode,
|
|
2100
|
+
/** Present on race rooms. */
|
|
2101
|
+
race: RaceState.optional()
|
|
2102
|
+
});
|
|
2103
|
+
/** A room's shared state for late joiners: a JSON object, at most 32 KB. */
|
|
2104
|
+
const RoomState = z.record(z.string(), Json);
|
|
2105
|
+
/** A relayed game message (`room.msg`, and a join's replay history). */
|
|
2106
|
+
const RoomMessage = z.object({
|
|
2107
|
+
from: UserId,
|
|
2108
|
+
data: Json,
|
|
2109
|
+
/** Per-room sequence number, shared with state patches. */
|
|
2110
|
+
seq: z.number().int().positive(),
|
|
2111
|
+
/** Set when the message went to one seat only. */
|
|
2112
|
+
to: UserId.optional()
|
|
2113
|
+
});
|
|
2114
|
+
/** How to find a room: by its code (case-insensitive) or its id. */
|
|
2115
|
+
const RoomTarget = z.object({
|
|
2116
|
+
code: z.string().optional(),
|
|
2117
|
+
id: z.string().optional(),
|
|
2118
|
+
/** Watch instead of taking a seat (if the game allows spectators). */
|
|
2119
|
+
spectate: z.boolean().optional()
|
|
2120
|
+
});
|
|
2121
|
+
/** Why a room went away for this tab (`room.closed`). */
|
|
2122
|
+
const RoomClosedReason = z.enum([
|
|
2123
|
+
"empty",
|
|
2124
|
+
"ended",
|
|
2125
|
+
"host_left",
|
|
2126
|
+
"moved",
|
|
2127
|
+
"other_tab",
|
|
2128
|
+
"removed"
|
|
2129
|
+
]);
|
|
2130
|
+
/** Why a seat was freed (`room.abandoned`). */
|
|
2131
|
+
const SeatGoneReason = z.enum([
|
|
2132
|
+
"left",
|
|
2133
|
+
"abandoned",
|
|
2134
|
+
"kicked"
|
|
2135
|
+
]);
|
|
2136
|
+
//#endregion
|
|
2137
|
+
//#region src/api/mp/snapshot.ts
|
|
2138
|
+
/** The multiplayer part of the socket `hello` snapshot (multiplayer-impl.md Β§4.5). */
|
|
2139
|
+
/**
|
|
2140
|
+
* The multiplayer fields of the `hello` ack, to spread into the snapshot
|
|
2141
|
+
* schema (`z.object({ ...other, ...MultiplayerSnapshot.shape })`).
|
|
2142
|
+
*/
|
|
2143
|
+
const MultiplayerSnapshot = z.object({
|
|
2144
|
+
/** Pending invites addressed to me. */
|
|
2145
|
+
invites: z.array(Invite),
|
|
2146
|
+
/** The party I'm in, or `null`. */
|
|
2147
|
+
party: Party.nullable(),
|
|
2148
|
+
/** The room seat I still hold (not ended), or `null`; `room.join` it again to reconnect. */
|
|
2149
|
+
room: RoomInfo.nullable()
|
|
2150
|
+
});
|
|
2151
|
+
//#endregion
|
|
2152
|
+
//#region src/api/mp.ts
|
|
2153
|
+
/**
|
|
2154
|
+
* Multiplayer REST (multiplayer-impl.md Β§5.6): invites, launch tokens,
|
|
2155
|
+
* parties, joining a friend's game, and game-server tickets. Every route
|
|
2156
|
+
* needs an active account; invites need a claimed one. The live state is in
|
|
2157
|
+
* the hub's memory and changes are pushed over `/_ws` (`invite`,
|
|
2158
|
+
* `invite.update`, `party`).
|
|
2159
|
+
*/
|
|
2160
|
+
/** `:id` of an invite or party. */
|
|
2161
|
+
const IdParams = z.object({ id: OpaqueId });
|
|
2162
|
+
/** The answer to `POST /mp/invites`: the invite, plus the inviter's own `host` launch. */
|
|
2163
|
+
const InviteCreated = LaunchTicket.extend({ invite: Invite });
|
|
2164
|
+
/** A game-server identity ticket. */
|
|
2165
|
+
const GameTicket = z.object({
|
|
2166
|
+
/** `base64url(payload).base64url(hmac)`, verified offline by the game's server with `HUB_APP_KEY`. */
|
|
2167
|
+
ticket: z.string(),
|
|
2168
|
+
/** Epoch ms; tickets live 60 s. */
|
|
2169
|
+
expiresAt: Millis
|
|
2170
|
+
});
|
|
2171
|
+
/** The mp endpoints. */
|
|
2172
|
+
const mpEndpoints = {
|
|
2173
|
+
...requireClaimed.group({
|
|
2174
|
+
sendGameInvite: {
|
|
2175
|
+
method: "POST",
|
|
2176
|
+
path: "/mp/invites",
|
|
2177
|
+
body: z.object({
|
|
2178
|
+
/** The friend to invite. */
|
|
2179
|
+
to: UserIdParam,
|
|
2180
|
+
game: Slug,
|
|
2181
|
+
/** One of the game's modes; race-only games default to `race`. */
|
|
2182
|
+
mode: GameMode.nullish()
|
|
2183
|
+
}),
|
|
2184
|
+
response: InviteCreated,
|
|
2185
|
+
errors: {
|
|
2186
|
+
400: ErrorBody,
|
|
2187
|
+
401: ErrorBody,
|
|
2188
|
+
403: ErrorBody,
|
|
2189
|
+
404: ErrorBody,
|
|
2190
|
+
409: ErrorBody,
|
|
2191
|
+
429: ErrorBody
|
|
2192
|
+
},
|
|
2193
|
+
doc: {
|
|
2194
|
+
summary: "Invite a friend to a game; creates or reuses your party and returns your host launch",
|
|
2195
|
+
tags: ["mp"]
|
|
2196
|
+
}
|
|
2197
|
+
},
|
|
2198
|
+
acceptGameInvite: {
|
|
2199
|
+
method: "POST",
|
|
2200
|
+
path: "/mp/invites/:id/accept",
|
|
2201
|
+
params: IdParams,
|
|
2202
|
+
response: LaunchTicket,
|
|
2203
|
+
errors: {
|
|
2204
|
+
401: ErrorBody,
|
|
2205
|
+
403: ErrorBody,
|
|
2206
|
+
404: ErrorBody,
|
|
2207
|
+
409: ErrorBody
|
|
2208
|
+
},
|
|
2209
|
+
doc: {
|
|
2210
|
+
summary: "Accept an invite: join the inviter's party and get a guest launch",
|
|
2211
|
+
tags: ["mp"]
|
|
2212
|
+
}
|
|
2213
|
+
},
|
|
2214
|
+
declineGameInvite: {
|
|
2215
|
+
method: "POST",
|
|
2216
|
+
path: "/mp/invites/:id/decline",
|
|
2217
|
+
params: IdParams,
|
|
2218
|
+
response: Ok,
|
|
2219
|
+
errors: {
|
|
2220
|
+
401: ErrorBody,
|
|
2221
|
+
403: ErrorBody,
|
|
2222
|
+
404: ErrorBody
|
|
2223
|
+
},
|
|
2224
|
+
doc: {
|
|
2225
|
+
summary: "Decline an invite addressed to you",
|
|
2226
|
+
tags: ["mp"]
|
|
2227
|
+
}
|
|
2228
|
+
},
|
|
2229
|
+
cancelGameInvite: {
|
|
2230
|
+
method: "DELETE",
|
|
2231
|
+
path: "/mp/invites/:id",
|
|
2232
|
+
params: IdParams,
|
|
2233
|
+
response: Ok,
|
|
2234
|
+
errors: {
|
|
2235
|
+
401: ErrorBody,
|
|
2236
|
+
403: ErrorBody,
|
|
2237
|
+
404: ErrorBody
|
|
2238
|
+
},
|
|
2239
|
+
doc: {
|
|
2240
|
+
summary: "Cancel an invite you sent",
|
|
2241
|
+
tags: ["mp"]
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
}),
|
|
2245
|
+
...requireActive.group({
|
|
2246
|
+
redeemLaunchToken: {
|
|
2247
|
+
method: "GET",
|
|
2248
|
+
path: "/mp/launch/:token",
|
|
2249
|
+
params: z.object({ token: z.string() }),
|
|
2250
|
+
response: z.object({ launch: Launch }),
|
|
2251
|
+
errors: {
|
|
2252
|
+
401: ErrorBody,
|
|
2253
|
+
403: ErrorBody,
|
|
2254
|
+
404: ErrorBody
|
|
2255
|
+
},
|
|
2256
|
+
doc: {
|
|
2257
|
+
summary: "Redeem a launch token (single use, bound to the signed-in user)",
|
|
2258
|
+
tags: ["mp"]
|
|
2259
|
+
}
|
|
2260
|
+
},
|
|
2261
|
+
setPartyJoinInfo: {
|
|
2262
|
+
method: "POST",
|
|
2263
|
+
path: "/mp/parties/:id/join-info",
|
|
2264
|
+
params: IdParams,
|
|
2265
|
+
body: z.object({ info: JoinInfo }),
|
|
2266
|
+
response: Ok,
|
|
2267
|
+
errors: {
|
|
2268
|
+
400: ErrorBody,
|
|
2269
|
+
401: ErrorBody,
|
|
2270
|
+
403: ErrorBody,
|
|
2271
|
+
404: ErrorBody
|
|
2272
|
+
},
|
|
2273
|
+
doc: {
|
|
2274
|
+
summary: "Party leader: publish where the party should connect",
|
|
2275
|
+
tags: ["mp"]
|
|
2276
|
+
}
|
|
2277
|
+
},
|
|
2278
|
+
leaveParty: {
|
|
2279
|
+
method: "POST",
|
|
2280
|
+
path: "/mp/parties/:id/leave",
|
|
2281
|
+
params: IdParams,
|
|
2282
|
+
response: Ok,
|
|
2283
|
+
errors: {
|
|
2284
|
+
401: ErrorBody,
|
|
2285
|
+
403: ErrorBody,
|
|
2286
|
+
404: ErrorBody
|
|
2287
|
+
},
|
|
2288
|
+
doc: {
|
|
2289
|
+
summary: "Leave a party (the leader role passes on; an empty party dissolves)",
|
|
2290
|
+
tags: ["mp"]
|
|
2291
|
+
}
|
|
2292
|
+
},
|
|
2293
|
+
launchIntoFriendsRoom: {
|
|
2294
|
+
method: "POST",
|
|
2295
|
+
path: "/mp/join",
|
|
2296
|
+
body: z.object({
|
|
2297
|
+
/** The player whose game to join or watch. */
|
|
2298
|
+
userId: UserIdParam,
|
|
2299
|
+
/** Watch instead of play. */
|
|
2300
|
+
watch: z.boolean().optional(),
|
|
2301
|
+
/** Joining a stranger's public lobby from a `lobby_open` alert (not a friend's game). */
|
|
2302
|
+
fromLobbyOpen: z.boolean().optional()
|
|
2303
|
+
}),
|
|
2304
|
+
response: LaunchTicket,
|
|
2305
|
+
errors: {
|
|
2306
|
+
400: ErrorBody,
|
|
2307
|
+
401: ErrorBody,
|
|
2308
|
+
403: ErrorBody,
|
|
2309
|
+
404: ErrorBody,
|
|
2310
|
+
409: ErrorBody,
|
|
2311
|
+
429: ErrorBody
|
|
2312
|
+
},
|
|
2313
|
+
doc: {
|
|
2314
|
+
summary: "A Join or Watch launch into the room a friend's presence advertises",
|
|
2315
|
+
tags: ["mp"]
|
|
2316
|
+
}
|
|
2317
|
+
},
|
|
2318
|
+
getGameServerTicket: {
|
|
2319
|
+
method: "POST",
|
|
2320
|
+
path: "/mp/ticket",
|
|
2321
|
+
body: z.object({ slug: Slug }),
|
|
2322
|
+
response: GameTicket,
|
|
2323
|
+
errors: {
|
|
2324
|
+
401: ErrorBody,
|
|
2325
|
+
403: ErrorBody,
|
|
2326
|
+
404: ErrorBody,
|
|
2327
|
+
429: ErrorBody
|
|
2328
|
+
},
|
|
2329
|
+
doc: {
|
|
2330
|
+
summary: "A 60 s identity ticket for a game's own server",
|
|
2331
|
+
tags: ["mp"]
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
})
|
|
2335
|
+
};
|
|
2336
|
+
//#endregion
|
|
2337
|
+
//#region src/api/notify.ts
|
|
2338
|
+
/**
|
|
2339
|
+
* Notification preferences and Web Push subscriptions
|
|
2340
|
+
* (multiplayer-impl.md Β§5.4). Everything is off by default and claimed-only.
|
|
2341
|
+
* Which channel an alert takes (a live `ev:notice` toast or a push) is the
|
|
2342
|
+
* hub's decision; see `ev:notice`.
|
|
2343
|
+
*/
|
|
2344
|
+
/** Notification preferences (`GET`/`PUT /social/notify`, `GET /social/me`). */
|
|
2345
|
+
const NotifyPrefs = z.object({
|
|
2346
|
+
/** Push a new DM when no tab is open. */
|
|
2347
|
+
dm: z.boolean(),
|
|
2348
|
+
/** Push a friend request when no tab is open. */
|
|
2349
|
+
friend_request: z.boolean(),
|
|
2350
|
+
/** "Tell me when any friend comes online", on top of the per-friend bells. */
|
|
2351
|
+
friendOnlineAll: z.boolean(),
|
|
2352
|
+
/** Friends whose bell is on, ascending. */
|
|
2353
|
+
friendOnline: z.array(UserId),
|
|
2354
|
+
/** Games followed for "someone's waiting in a lobby", sorted. */
|
|
2355
|
+
lobbyOpen: z.array(Slug)
|
|
2356
|
+
});
|
|
2357
|
+
/** The preference kinds `PUT /social/notify` can flip. */
|
|
2358
|
+
const NotifyPrefKind = z.enum([
|
|
2359
|
+
"dm",
|
|
2360
|
+
"friend_request",
|
|
2361
|
+
"friend_online",
|
|
2362
|
+
"lobby_open"
|
|
2363
|
+
], { error: "kind must be dm, friend_request, friend_online or lobby_open." });
|
|
2364
|
+
/**
|
|
2365
|
+
* One `PUT /social/notify` change. `target`: for `friend_online`, a friend's
|
|
2366
|
+
* id (a number, or its decimal string) for that friend's bell, or absent
|
|
2367
|
+
* (`''`, `null`) for the "any friend" switch; for `lobby_open`, the game slug.
|
|
2368
|
+
* Ignored for `dm` and `friend_request`. Any other number or text is a 404
|
|
2369
|
+
* (not a friend, no such game), as on the old hub. `enabled` is checked
|
|
2370
|
+
* first, so its message wins when both fields are wrong.
|
|
2371
|
+
*/
|
|
2372
|
+
const NotifyChange = z.object({
|
|
2373
|
+
enabled: z.boolean({ error: "enabled must be true or false." }),
|
|
2374
|
+
kind: NotifyPrefKind,
|
|
2375
|
+
target: z.union([z.string(), z.number()]).nullable().optional()
|
|
2376
|
+
});
|
|
2377
|
+
/** The 400 for a subscription the hub can't push to (malformed, or not on a known push service). */
|
|
2378
|
+
const UNSUPPORTED_SUBSCRIPTION = "That browser subscription isn't supported.";
|
|
2379
|
+
/**
|
|
2380
|
+
* A browser push subscription, as `PushSubscription.toJSON()` gives it. The
|
|
2381
|
+
* endpoint must be `https` on a known push service; `p256dh` is a 65-byte
|
|
2382
|
+
* and `auth` an at-least-16-byte base64url key.
|
|
2383
|
+
*/
|
|
2384
|
+
const PushSubscriptionJson = z.object({
|
|
2385
|
+
endpoint: z.string().max(1e3, { error: UNSUPPORTED_SUBSCRIPTION }),
|
|
2386
|
+
/** When the subscription expires (epoch ms), `null` when it doesn't. */
|
|
2387
|
+
expirationTime: z.number().nullable().optional(),
|
|
2388
|
+
keys: z.object({
|
|
2389
|
+
p256dh: z.string().max(200, { error: UNSUPPORTED_SUBSCRIPTION }),
|
|
2390
|
+
auth: z.string().max(100, { error: UNSUPPORTED_SUBSCRIPTION })
|
|
2391
|
+
})
|
|
2392
|
+
});
|
|
2393
|
+
/** The notification endpoints. */
|
|
2394
|
+
const notifyEndpoints = {
|
|
2395
|
+
...requireActive.group({
|
|
2396
|
+
getNotificationPreferences: {
|
|
2397
|
+
method: "GET",
|
|
2398
|
+
path: "/social/notify",
|
|
2399
|
+
response: NotifyPrefs,
|
|
2400
|
+
errors: {
|
|
2401
|
+
401: ErrorBody,
|
|
2402
|
+
403: ErrorBody
|
|
2403
|
+
},
|
|
2404
|
+
doc: {
|
|
2405
|
+
summary: "Your notification preferences (all off for guests)",
|
|
2406
|
+
tags: ["notify"]
|
|
2407
|
+
}
|
|
2408
|
+
},
|
|
2409
|
+
unsubscribeFromPush: {
|
|
2410
|
+
method: "DELETE",
|
|
2411
|
+
path: "/social/push",
|
|
2412
|
+
body: z.object({ endpoint: z.string({ error: "endpoint is required." }).min(1, { error: "endpoint is required." }) }),
|
|
2413
|
+
response: Ok,
|
|
2414
|
+
errors: {
|
|
2415
|
+
400: ErrorBody,
|
|
2416
|
+
401: ErrorBody,
|
|
2417
|
+
403: ErrorBody
|
|
2418
|
+
},
|
|
2419
|
+
doc: {
|
|
2420
|
+
summary: "Forget one of this account's push subscriptions",
|
|
2421
|
+
tags: ["notify"]
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
}),
|
|
2425
|
+
...requireClaimed.group({
|
|
2426
|
+
setNotificationPreferences: {
|
|
2427
|
+
method: "PUT",
|
|
2428
|
+
path: "/social/notify",
|
|
2429
|
+
body: NotifyChange,
|
|
2430
|
+
response: NotifyPrefs,
|
|
2431
|
+
errors: {
|
|
2432
|
+
400: ErrorBody,
|
|
2433
|
+
401: ErrorBody,
|
|
2434
|
+
403: ErrorBody,
|
|
2435
|
+
404: ErrorBody,
|
|
2436
|
+
409: ErrorBody
|
|
2437
|
+
},
|
|
2438
|
+
doc: {
|
|
2439
|
+
summary: "Flip one notification preference",
|
|
2440
|
+
description: "404 for a friend bell on a non-friend or an unknown game; 409 past 50 followed games.",
|
|
2441
|
+
tags: ["notify"]
|
|
2442
|
+
}
|
|
2443
|
+
},
|
|
2444
|
+
subscribeToPush: {
|
|
2445
|
+
method: "POST",
|
|
2446
|
+
path: "/social/push",
|
|
2447
|
+
body: z.object({ subscription: z.union([PushSubscriptionJson, z.never()], { error: UNSUPPORTED_SUBSCRIPTION }) }),
|
|
2448
|
+
response: Ok,
|
|
2449
|
+
errors: {
|
|
2450
|
+
400: ErrorBody,
|
|
2451
|
+
401: ErrorBody,
|
|
2452
|
+
403: ErrorBody
|
|
2453
|
+
},
|
|
2454
|
+
doc: {
|
|
2455
|
+
summary: "Store this browser's push subscription",
|
|
2456
|
+
description: "One browser is one endpoint: subscribing from another account moves it. Past the per-user cap the oldest is dropped.",
|
|
2457
|
+
tags: ["notify"]
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
})
|
|
2461
|
+
};
|
|
2462
|
+
//#endregion
|
|
2463
|
+
//#region src/api/progress/game-data.ts
|
|
2464
|
+
/**
|
|
2465
|
+
* The caller's own per-game data: profile, save slots, numeric stats, and
|
|
2466
|
+
* resetting a game. Guests count: a guest is a real account row.
|
|
2467
|
+
*/
|
|
2468
|
+
/** A player's profile in one game. */
|
|
2469
|
+
const Profile = z.object({
|
|
2470
|
+
game: Slug,
|
|
2471
|
+
/** Shown to other players; `null` when never set. */
|
|
2472
|
+
displayName: z.string().nullable(),
|
|
2473
|
+
/** Any JSON the game stores (`{}` by default). */
|
|
2474
|
+
data: Json,
|
|
2475
|
+
/** Epoch ms. */
|
|
2476
|
+
updatedAt: Millis
|
|
2477
|
+
});
|
|
2478
|
+
/** A save slot's metadata (no payload). */
|
|
2479
|
+
const SaveMeta = z.object({
|
|
2480
|
+
slot: Key,
|
|
2481
|
+
label: z.string().nullable(),
|
|
2482
|
+
/** Epoch ms. */
|
|
2483
|
+
updatedAt: Millis
|
|
2484
|
+
});
|
|
2485
|
+
/** A save slot with its payload. */
|
|
2486
|
+
const Save = SaveMeta.extend({
|
|
2487
|
+
/** The stored JSON (`null` if it could not be read back). */
|
|
2488
|
+
data: Json });
|
|
2489
|
+
/** A player's numeric stats in one game: stat {@link Key} to value. */
|
|
2490
|
+
const Stats = z.record(Key, z.number());
|
|
2491
|
+
/** How a stat update combines with the stored value. */
|
|
2492
|
+
const StatMode = z.enum([
|
|
2493
|
+
"set",
|
|
2494
|
+
"incr",
|
|
2495
|
+
"max"
|
|
2496
|
+
]);
|
|
2497
|
+
/**
|
|
2498
|
+
* A {@link Key} read like the hub's `validKey` (`String(x || '')`), failing
|
|
2499
|
+
* with its message: "<label> must match /^[A-Za-z0-9_.:-]{1,64}$/ (1β64 chars)."
|
|
2500
|
+
*/
|
|
2501
|
+
function keyField(label) {
|
|
2502
|
+
const message = `${label} must match /^[A-Za-z0-9_.:-]{1,64}$/ (1β64 chars).`;
|
|
2503
|
+
return requiredText(message).refine((text) => Key.safeParse(text).success, message);
|
|
2504
|
+
}
|
|
2505
|
+
/** A number read like the hub's `finiteNumber` (`Number(x)`), failing with "<label> must be a finite number.". */
|
|
2506
|
+
function finiteNumberField(label) {
|
|
2507
|
+
return z.coerce.number({ error: `${label} must be a finite number.` });
|
|
2508
|
+
}
|
|
2509
|
+
/**
|
|
2510
|
+
* A stat update's mode. The hub reads `body.mode || 'set'`, so a falsy value
|
|
2511
|
+
* (`null`, `''`, `0`, `false`) also means `set`. Clients should send a
|
|
2512
|
+
* {@link StatMode} or nothing.
|
|
2513
|
+
*/
|
|
2514
|
+
const StatModeField = z.union([
|
|
2515
|
+
StatMode,
|
|
2516
|
+
z.literal([
|
|
2517
|
+
"",
|
|
2518
|
+
0,
|
|
2519
|
+
false
|
|
2520
|
+
]),
|
|
2521
|
+
z.null()
|
|
2522
|
+
], { error: "mode must be set, incr, or max." }).optional().transform((mode) => mode || "set");
|
|
2523
|
+
/**
|
|
2524
|
+
* A save's payload: any JSON, but present. (The union with `never` exists only
|
|
2525
|
+
* so a missing value fails with the hub's message rather than zod's.)
|
|
2526
|
+
*/
|
|
2527
|
+
const SaveData = z.union([Json, z.never()], { error: "Missing \"data\"." });
|
|
2528
|
+
/** `:slot` of a save. */
|
|
2529
|
+
const SlotParams = z.object({ slot: keyField("slot") });
|
|
2530
|
+
/** The per-game data endpoints. */
|
|
2531
|
+
const gameDataEndpoints = {
|
|
2532
|
+
...use(session, game).path("/games/:slug").group({
|
|
2533
|
+
/**
|
|
2534
|
+
* A player's stats. Defaults to the caller's own (401 when signed out);
|
|
2535
|
+
* `?user=<id>` reads anyone's, since stats are public like a profile.
|
|
2536
|
+
*/
|
|
2537
|
+
getGameStats: {
|
|
2538
|
+
method: "GET",
|
|
2539
|
+
path: "/stats",
|
|
2540
|
+
query: z.object({
|
|
2541
|
+
/** Whose stats to read (a user id); omit for the caller's. */
|
|
2542
|
+
user: finiteNumberField("user").optional() }),
|
|
2543
|
+
response: z.object({ stats: Stats }),
|
|
2544
|
+
errors: {
|
|
2545
|
+
400: ErrorBody,
|
|
2546
|
+
401: ErrorBody,
|
|
2547
|
+
404: ErrorBody
|
|
2548
|
+
},
|
|
2549
|
+
doc: {
|
|
2550
|
+
summary: "A player's stats in this game",
|
|
2551
|
+
tags: ["progress"]
|
|
2552
|
+
}
|
|
2553
|
+
} }),
|
|
2554
|
+
...use(requireUser, game).path("/games/:slug").group({
|
|
2555
|
+
getGameProfile: {
|
|
2556
|
+
method: "GET",
|
|
2557
|
+
path: "/profile",
|
|
2558
|
+
response: z.object({ profile: Profile.nullable() }),
|
|
2559
|
+
errors: {
|
|
2560
|
+
401: ErrorBody,
|
|
2561
|
+
404: ErrorBody
|
|
2562
|
+
},
|
|
2563
|
+
doc: {
|
|
2564
|
+
summary: "The caller's profile in this game, or null",
|
|
2565
|
+
tags: ["progress"]
|
|
2566
|
+
}
|
|
2567
|
+
},
|
|
2568
|
+
/**
|
|
2569
|
+
* Create or replace the caller's profile. A new or changed display name
|
|
2570
|
+
* is moderated (422) because other players see it.
|
|
2571
|
+
*/
|
|
2572
|
+
setGameProfile: {
|
|
2573
|
+
method: "PUT",
|
|
2574
|
+
path: "/profile",
|
|
2575
|
+
body: z.object({
|
|
2576
|
+
/** Up to 60 characters (longer is clipped); `null` or missing clears it. */
|
|
2577
|
+
displayName: LooseText.nullish(),
|
|
2578
|
+
/** Replaces the stored data; missing stores `{}`. */
|
|
2579
|
+
data: Json.optional()
|
|
2580
|
+
}),
|
|
2581
|
+
response: z.object({ profile: Profile }),
|
|
2582
|
+
errors: {
|
|
2583
|
+
401: ErrorBody,
|
|
2584
|
+
404: ErrorBody,
|
|
2585
|
+
422: ContentRejected
|
|
2586
|
+
},
|
|
2587
|
+
doc: {
|
|
2588
|
+
summary: "Create or replace the caller's profile in this game",
|
|
2589
|
+
tags: ["progress"]
|
|
2590
|
+
}
|
|
2591
|
+
},
|
|
2592
|
+
listSaves: {
|
|
2593
|
+
method: "GET",
|
|
2594
|
+
path: "/saves",
|
|
2595
|
+
response: z.object({ saves: z.array(SaveMeta) }),
|
|
2596
|
+
errors: {
|
|
2597
|
+
401: ErrorBody,
|
|
2598
|
+
404: ErrorBody
|
|
2599
|
+
},
|
|
2600
|
+
doc: {
|
|
2601
|
+
summary: "The caller's save slots, newest first (no payloads)",
|
|
2602
|
+
tags: ["progress"]
|
|
2603
|
+
}
|
|
2604
|
+
},
|
|
2605
|
+
getSave: {
|
|
2606
|
+
method: "GET",
|
|
2607
|
+
path: "/saves/:slot",
|
|
2608
|
+
params: SlotParams,
|
|
2609
|
+
response: z.object({ save: Save.nullable() }),
|
|
2610
|
+
errors: {
|
|
2611
|
+
400: ErrorBody,
|
|
2612
|
+
401: ErrorBody,
|
|
2613
|
+
404: ErrorBody
|
|
2614
|
+
},
|
|
2615
|
+
doc: {
|
|
2616
|
+
summary: "One save slot, or null when empty",
|
|
2617
|
+
tags: ["progress"]
|
|
2618
|
+
}
|
|
2619
|
+
},
|
|
2620
|
+
writeSave: {
|
|
2621
|
+
method: "PUT",
|
|
2622
|
+
path: "/saves/:slot",
|
|
2623
|
+
params: SlotParams,
|
|
2624
|
+
body: z.object({
|
|
2625
|
+
/** Any JSON up to 64 KB serialized (`HUB_MAX_SAVE_BYTES`); 413 over. */
|
|
2626
|
+
data: SaveData,
|
|
2627
|
+
/** Up to 80 characters (longer is clipped). */
|
|
2628
|
+
label: LooseText.nullish()
|
|
2629
|
+
}),
|
|
2630
|
+
response: z.object({
|
|
2631
|
+
ok: z.literal(true),
|
|
2632
|
+
/** Epoch ms. */
|
|
2633
|
+
updatedAt: Millis
|
|
2634
|
+
}),
|
|
2635
|
+
errors: {
|
|
2636
|
+
400: ErrorBody,
|
|
2637
|
+
401: ErrorBody,
|
|
2638
|
+
404: ErrorBody,
|
|
2639
|
+
413: ErrorBody
|
|
2640
|
+
},
|
|
2641
|
+
doc: {
|
|
2642
|
+
summary: "Create or overwrite a save slot",
|
|
2643
|
+
tags: ["progress"]
|
|
2644
|
+
}
|
|
2645
|
+
},
|
|
2646
|
+
deleteSave: {
|
|
2647
|
+
method: "DELETE",
|
|
2648
|
+
path: "/saves/:slot",
|
|
2649
|
+
params: SlotParams,
|
|
2650
|
+
response: Ok,
|
|
2651
|
+
errors: {
|
|
2652
|
+
400: ErrorBody,
|
|
2653
|
+
401: ErrorBody,
|
|
2654
|
+
404: ErrorBody
|
|
2655
|
+
},
|
|
2656
|
+
doc: {
|
|
2657
|
+
summary: "Delete a save slot (idempotent)",
|
|
2658
|
+
tags: ["progress"]
|
|
2659
|
+
}
|
|
2660
|
+
},
|
|
2661
|
+
/** Update one stat. Backs the client's `setStat`, `incrStat` and `maxStat`. */
|
|
2662
|
+
updateGameStat: {
|
|
2663
|
+
method: "POST",
|
|
2664
|
+
path: "/stats",
|
|
2665
|
+
body: z.object({
|
|
2666
|
+
key: keyField("key"),
|
|
2667
|
+
value: finiteNumberField("value"),
|
|
2668
|
+
/** Default `set`. */
|
|
2669
|
+
mode: StatModeField
|
|
2670
|
+
}),
|
|
2671
|
+
response: z.object({
|
|
2672
|
+
key: Key,
|
|
2673
|
+
value: z.number()
|
|
2674
|
+
}),
|
|
2675
|
+
errors: {
|
|
2676
|
+
400: ErrorBody,
|
|
2677
|
+
401: ErrorBody,
|
|
2678
|
+
404: ErrorBody
|
|
2679
|
+
},
|
|
2680
|
+
doc: {
|
|
2681
|
+
summary: "Set, add to, or raise one stat; returns its new value",
|
|
2682
|
+
tags: ["progress"]
|
|
2683
|
+
}
|
|
2684
|
+
},
|
|
2685
|
+
/**
|
|
2686
|
+
* Wipe the caller's saves, stats, inventory and coins in this game.
|
|
2687
|
+
* Pending trades are closed first, refunding other players' escrow.
|
|
2688
|
+
* Leaderboard entries and the profile are kept.
|
|
2689
|
+
*/
|
|
2690
|
+
resetMyGameProgress: {
|
|
2691
|
+
method: "POST",
|
|
2692
|
+
path: "/reset",
|
|
2693
|
+
response: Ok,
|
|
2694
|
+
errors: {
|
|
2695
|
+
401: ErrorBody,
|
|
2696
|
+
404: ErrorBody
|
|
2697
|
+
},
|
|
2698
|
+
doc: {
|
|
2699
|
+
summary: "Reset the caller's progress in this game",
|
|
2700
|
+
tags: ["progress"]
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
})
|
|
2704
|
+
};
|
|
2705
|
+
//#endregion
|
|
2706
|
+
//#region src/api/progress.ts
|
|
2707
|
+
/**
|
|
2708
|
+
* Per-game player data: profiles, saves, stats, game reset, and the player's
|
|
2709
|
+
* records and snapshot. The per-game endpoints live in `progress/game-data.ts`.
|
|
2710
|
+
*/
|
|
2711
|
+
/** Everything the caller has in one game, as the snapshot carries it. */
|
|
2712
|
+
const GameSnapshot = z.object({
|
|
2713
|
+
profile: Profile.nullable(),
|
|
2714
|
+
/** Newest first, with payloads. */
|
|
2715
|
+
saves: z.array(Save),
|
|
2716
|
+
stats: Stats,
|
|
2717
|
+
inventory: Inventory
|
|
2718
|
+
});
|
|
2719
|
+
/** The progress endpoints. */
|
|
2720
|
+
const progressEndpoints = {
|
|
2721
|
+
...gameDataEndpoints,
|
|
2722
|
+
...session.group({
|
|
2723
|
+
/**
|
|
2724
|
+
* The caller's leaderboard standings, grouped by game (empty when
|
|
2725
|
+
* signed out). `?game=` limits it to one game; empty means every game,
|
|
2726
|
+
* and text that is no game's slug matches nothing, as in the old hub.
|
|
2727
|
+
*/
|
|
2728
|
+
listMyLeaderboardRecords: {
|
|
2729
|
+
method: "GET",
|
|
2730
|
+
path: "/me/records",
|
|
2731
|
+
query: z.object({ game: z.string().optional() }),
|
|
2732
|
+
response: z.object({ games: z.array(GameRecords) }),
|
|
2733
|
+
doc: {
|
|
2734
|
+
summary: "The caller's leaderboard standings",
|
|
2735
|
+
tags: ["progress"]
|
|
2736
|
+
}
|
|
2737
|
+
},
|
|
2738
|
+
/**
|
|
2739
|
+
* Everything the caller has, in one request, to warm the offline cache
|
|
2740
|
+
* for every game (the client's `prefetchAll`). Signed out, only the
|
|
2741
|
+
* global boards are filled in.
|
|
2742
|
+
*/
|
|
2743
|
+
getMyOfflineSnapshot: {
|
|
2744
|
+
method: "GET",
|
|
2745
|
+
path: "/me/snapshot",
|
|
2746
|
+
query: TopQuery,
|
|
2747
|
+
response: z.object({
|
|
2748
|
+
user: SessionUser.nullable(),
|
|
2749
|
+
/** By slug: every game the caller has any profile, save, stat or holding in. */
|
|
2750
|
+
games: z.record(Slug, GameSnapshot),
|
|
2751
|
+
records: z.array(GameRecords),
|
|
2752
|
+
leaderboards: z.array(GameLeaderboards)
|
|
2753
|
+
}),
|
|
2754
|
+
doc: {
|
|
2755
|
+
summary: "The caller's data across every game, plus the global boards",
|
|
2756
|
+
tags: ["progress"]
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
})
|
|
2760
|
+
};
|
|
2761
|
+
//#endregion
|
|
2762
|
+
//#region src/api/race.ts
|
|
2763
|
+
/**
|
|
2764
|
+
* Races: the signed-in player's own race history (docs/plans/race.md Β§7).
|
|
2765
|
+
* The live race runs over the socket (`race.*` ops and events).
|
|
2766
|
+
*/
|
|
2767
|
+
/** An optional positive-integer query param; empty means "not set". */
|
|
2768
|
+
const OptionalCount = z.union([z.literal(""), z.coerce.number().int().positive()]).optional();
|
|
2769
|
+
/** `GET /race/history` filters. */
|
|
2770
|
+
const RaceHistoryQuery = z.object({
|
|
2771
|
+
/** Only this game (default: every game). */
|
|
2772
|
+
game: z.union([z.literal(""), z.string().regex(/^[A-Za-z0-9_-]{1,64}$/)]).optional(),
|
|
2773
|
+
/** Only races with this player (a user id). */
|
|
2774
|
+
with: OptionalCount,
|
|
2775
|
+
/** Only races older than this race id (paging). */
|
|
2776
|
+
before: OptionalCount,
|
|
2777
|
+
/** Page size, 1 to 50 (default 20; larger values are capped). */
|
|
2778
|
+
limit: OptionalCount
|
|
2779
|
+
});
|
|
2780
|
+
/** A page of the player's races, newest first. */
|
|
2781
|
+
const RaceHistory = z.object({
|
|
2782
|
+
races: z.array(RaceRecord),
|
|
2783
|
+
/** More races exist before the last one. */
|
|
2784
|
+
more: z.boolean(),
|
|
2785
|
+
/** Totals over every race matching the filters (not just this page). */
|
|
2786
|
+
summary: z.object({
|
|
2787
|
+
races: z.number().int().nonnegative(),
|
|
2788
|
+
wins: z.number().int().nonnegative(),
|
|
2789
|
+
losses: z.number().int().nonnegative()
|
|
2790
|
+
})
|
|
2791
|
+
});
|
|
2792
|
+
/** The race endpoints. */
|
|
2793
|
+
const raceEndpoints = { ...requireActive.group({ listMyRaceHistory: {
|
|
2794
|
+
method: "GET",
|
|
2795
|
+
path: "/race/history",
|
|
2796
|
+
query: RaceHistoryQuery,
|
|
2797
|
+
response: RaceHistory,
|
|
2798
|
+
errors: {
|
|
2799
|
+
400: ErrorBody,
|
|
2800
|
+
401: ErrorBody,
|
|
2801
|
+
403: ErrorBody
|
|
2802
|
+
},
|
|
2803
|
+
doc: {
|
|
2804
|
+
summary: "The signed-in player's decided races, newest first",
|
|
2805
|
+
tags: ["race"]
|
|
2806
|
+
}
|
|
2807
|
+
} }) };
|
|
2808
|
+
//#endregion
|
|
2809
|
+
//#region src/api/social/presence.ts
|
|
2810
|
+
/**
|
|
2811
|
+
* Presence: what a player is doing right now, as their friends see it, and
|
|
2812
|
+
* the anonymous online counts (multiplayer-impl.md Β§4.4). Games report a
|
|
2813
|
+
* {@link PresenceInput} per tab (socket op `presence`); the hub folds a
|
|
2814
|
+
* user's tabs into one {@link Presence} and pushes it to friends
|
|
2815
|
+
* (`ev:presence`).
|
|
2816
|
+
*/
|
|
2817
|
+
/** Activity kinds, least engaged first. The most engaged tab wins; hidden or idle tabs count as `away`. */
|
|
2818
|
+
const PresenceKind = z.enum([
|
|
2819
|
+
"away",
|
|
2820
|
+
"menu",
|
|
2821
|
+
"lobby",
|
|
2822
|
+
"watching",
|
|
2823
|
+
"playing"
|
|
2824
|
+
]);
|
|
2825
|
+
/** A room reference friends can join with: 1 to 64 of `A-Z a-z 0-9 _ : -`. */
|
|
2826
|
+
const PresenceRoom = z.string().regex(/^[A-Za-z0-9_:-]{1,64}$/);
|
|
2827
|
+
/** A game mode key: 1 to 32 of `A-Z a-z 0-9 _ -`. */
|
|
2828
|
+
const PresenceMode = z.string().regex(/^[A-Za-z0-9_-]{1,32}$/);
|
|
2829
|
+
/** The fields a tab reports and friends see back, with the limits the hub enforces. */
|
|
2830
|
+
const PresenceFields = z.object({
|
|
2831
|
+
kind: PresenceKind,
|
|
2832
|
+
/** A short status line (at most 40 characters), filtered; a rejected one is dropped. */
|
|
2833
|
+
detail: z.string().max(40).optional(),
|
|
2834
|
+
/** Friends may join. Only ever `true` when present. */
|
|
2835
|
+
joinable: z.boolean().optional(),
|
|
2836
|
+
/** Friends may watch. Only ever `true` when present. */
|
|
2837
|
+
watchable: z.boolean().optional(),
|
|
2838
|
+
/** Join info for friends: the game's own room reference, or the hub room's code. */
|
|
2839
|
+
room: PresenceRoom.optional(),
|
|
2840
|
+
/** A public lobby: friends who follow the game may get a `lobby_open` alert. */
|
|
2841
|
+
public: z.boolean().optional(),
|
|
2842
|
+
/** Free seats, 0 to 64. */
|
|
2843
|
+
openSeats: z.number().int().min(0).max(64).optional(),
|
|
2844
|
+
mode: PresenceMode.optional()
|
|
2845
|
+
});
|
|
2846
|
+
/**
|
|
2847
|
+
* What a game reports for its tab (socket op `presence`). The hub is
|
|
2848
|
+
* lenient: an unknown `kind`, or input that isn't an object at all (`null`),
|
|
2849
|
+
* counts as `{kind:'menu'}`; unknown fields, and fields that break a rule (a
|
|
2850
|
+
* bad `room`, a fractional `openSeats`, a field of the wrong type), are
|
|
2851
|
+
* ignored one by one, so friends only ever
|
|
2852
|
+
* see {@link Presence}'s fields; `detail` is trimmed to 40 characters before
|
|
2853
|
+
* filtering.
|
|
2854
|
+
*/
|
|
2855
|
+
const PresenceInput = z.looseObject({
|
|
2856
|
+
kind: PresenceKind.catch("menu"),
|
|
2857
|
+
detail: z.string().optional().catch(void 0),
|
|
2858
|
+
joinable: z.boolean().optional().catch(void 0),
|
|
2859
|
+
watchable: z.boolean().optional().catch(void 0),
|
|
2860
|
+
room: z.string().optional().catch(void 0),
|
|
2861
|
+
public: z.boolean().optional().catch(void 0),
|
|
2862
|
+
openSeats: z.number().optional().catch(void 0),
|
|
2863
|
+
mode: z.string().optional().catch(void 0)
|
|
2864
|
+
}).catch({ kind: "menu" });
|
|
2865
|
+
/** A user's aggregated presence: their most engaged tab, decorated by the hub. */
|
|
2866
|
+
const Presence = PresenceFields.extend({
|
|
2867
|
+
/** The game the tab is on, `null` on the catalog and other hub pages. */
|
|
2868
|
+
game: Slug.nullable(),
|
|
2869
|
+
/** That game's title, `null` with `game`. */
|
|
2870
|
+
gameTitle: z.string().nullable(),
|
|
2871
|
+
/** When this activity (kind + game) started (epoch ms). */
|
|
2872
|
+
since: Millis,
|
|
2873
|
+
/** Party size, present when the user is in a party of two or more. */
|
|
2874
|
+
partySize: z.number().int().min(2).optional()
|
|
2875
|
+
});
|
|
2876
|
+
/** A non-negative head count. */
|
|
2877
|
+
const HeadCount = z.number().int().nonnegative();
|
|
2878
|
+
/**
|
|
2879
|
+
* Anonymous online counts (`GET /social/counts`, `ev:counts`, the `hello`
|
|
2880
|
+
* snapshot): everyone with a live presence, guests and hidden players
|
|
2881
|
+
* included.
|
|
2882
|
+
*/
|
|
2883
|
+
const Counts = z.object({
|
|
2884
|
+
/** Everyone online. */
|
|
2885
|
+
online: HeadCount,
|
|
2886
|
+
/** Players per game slug (games with nobody are absent). */
|
|
2887
|
+
byGame: z.record(Slug, HeadCount)
|
|
2888
|
+
});
|
|
2889
|
+
//#endregion
|
|
2890
|
+
//#region src/api/reports.ts
|
|
2891
|
+
/**
|
|
2892
|
+
* Reports and suggestions: players flagging problems (multiplayer-impl.md
|
|
2893
|
+
* Β§5.3) and proposing game ideas (Β§5.5).
|
|
2894
|
+
*
|
|
2895
|
+
* A report only says *what* is reported; the hub builds the evidence
|
|
2896
|
+
* snapshot from its own records. Suggestions are filtered, reviewed by an
|
|
2897
|
+
* admin, and only approved ones reach the public board.
|
|
2898
|
+
*/
|
|
2899
|
+
/** What is being reported. */
|
|
2900
|
+
const ReportKind = z.enum([
|
|
2901
|
+
"message",
|
|
2902
|
+
"user",
|
|
2903
|
+
"username",
|
|
2904
|
+
"suggestion",
|
|
2905
|
+
"room_chat",
|
|
2906
|
+
"presence"
|
|
2907
|
+
], { error: "kind must be one of message, user, username, suggestion, room_chat, presence." });
|
|
2908
|
+
/** Why, from a fixed list (there is no free text). */
|
|
2909
|
+
const ReportReason = z.enum([
|
|
2910
|
+
"mean",
|
|
2911
|
+
"bad_words",
|
|
2912
|
+
"personal",
|
|
2913
|
+
"inappropriate",
|
|
2914
|
+
"spam",
|
|
2915
|
+
"other"
|
|
2916
|
+
], { error: "reason must be one of mean, bad_words, personal, inappropriate, spam, other." });
|
|
2917
|
+
/**
|
|
2918
|
+
* `POST /social/reports`. Loose on purpose: anything else the client sends
|
|
2919
|
+
* (an evidence snapshot, quoted text) is ignored, because the hub builds the
|
|
2920
|
+
* snapshot from its own records.
|
|
2921
|
+
*/
|
|
2922
|
+
const ReportInput = z.looseObject({
|
|
2923
|
+
kind: ReportKind,
|
|
2924
|
+
/**
|
|
2925
|
+
* The reported player. `0` for a suggestion: the hub finds its author from
|
|
2926
|
+
* `refId`. Read like `Number(x)`: anything that is not a player's id is a 404.
|
|
2927
|
+
*/
|
|
2928
|
+
targetUserId: LooseNumber.catch(NaN),
|
|
2929
|
+
/**
|
|
2930
|
+
* What exactly: the message id (`message`), the suggestion id
|
|
2931
|
+
* (`suggestion`) or the room code (`room_chat`). Kept to 64 characters.
|
|
2932
|
+
*/
|
|
2933
|
+
refId: z.union([z.string(), z.number()]).nullable().optional(),
|
|
2934
|
+
reason: ReportReason
|
|
2935
|
+
});
|
|
2936
|
+
/** A direct message as a report snapshot keeps it. */
|
|
2937
|
+
const ReportedMessage = z.object({
|
|
2938
|
+
id: z.number().int().positive(),
|
|
2939
|
+
/** The sender's user id. */
|
|
2940
|
+
from: z.number().int(),
|
|
2941
|
+
/** The recipient's user id. */
|
|
2942
|
+
to: z.number().int(),
|
|
2943
|
+
/** When it was sent (epoch ms). */
|
|
2944
|
+
at: Millis,
|
|
2945
|
+
/** The text, or the quick-chat phrase's text (`''` for an unknown phrase). */
|
|
2946
|
+
text: z.string(),
|
|
2947
|
+
/** The quick-chat phrase id, when it was one. */
|
|
2948
|
+
quick: z.string().optional()
|
|
2949
|
+
});
|
|
2950
|
+
/**
|
|
2951
|
+
* The evidence the hub records with a report, from its own records (stored
|
|
2952
|
+
* as `snapshot_json`; the admin console shows it). Always the reported
|
|
2953
|
+
* player; then per kind: `message` adds the message and up to 5 before it in
|
|
2954
|
+
* the conversation (oldest first), `suggestion` its title, body and status,
|
|
2955
|
+
* `presence` their status line (`null` when offline), `room_chat` the room
|
|
2956
|
+
* code (room chat isn't stored).
|
|
2957
|
+
*/
|
|
2958
|
+
const ReportSnapshot = z.object({
|
|
2959
|
+
userId: z.number().int(),
|
|
2960
|
+
username: z.string(),
|
|
2961
|
+
message: ReportedMessage.optional(),
|
|
2962
|
+
context: z.array(ReportedMessage).optional(),
|
|
2963
|
+
title: z.string().optional(),
|
|
2964
|
+
body: z.string().optional(),
|
|
2965
|
+
status: z.string().optional(),
|
|
2966
|
+
detail: z.string().optional(),
|
|
2967
|
+
presence: Presence.nullable().optional(),
|
|
2968
|
+
room: z.string().optional()
|
|
2969
|
+
});
|
|
2970
|
+
/** A suggestion's review status. Only `approved`, `planned` and `done` are on the public board. */
|
|
2971
|
+
const SuggestionStatus = z.enum([
|
|
2972
|
+
"pending",
|
|
2973
|
+
"approved",
|
|
2974
|
+
"planned",
|
|
2975
|
+
"done",
|
|
2976
|
+
"removed",
|
|
2977
|
+
"withdrawn"
|
|
2978
|
+
]);
|
|
2979
|
+
/** A suggestion id. */
|
|
2980
|
+
const SuggestionId = z.number().int().positive();
|
|
2981
|
+
/** A game suggestion. */
|
|
2982
|
+
const Suggestion = z.object({
|
|
2983
|
+
id: SuggestionId,
|
|
2984
|
+
title: z.string(),
|
|
2985
|
+
/** The description. */
|
|
2986
|
+
body: z.string(),
|
|
2987
|
+
status: SuggestionStatus,
|
|
2988
|
+
/** Upvotes. */
|
|
2989
|
+
votes: z.number().int().nonnegative(),
|
|
2990
|
+
/** You upvoted it. */
|
|
2991
|
+
voted: z.boolean(),
|
|
2992
|
+
/** You suggested it. */
|
|
2993
|
+
mine: z.boolean(),
|
|
2994
|
+
/** The reviewer's note to the author, `''` when none. */
|
|
2995
|
+
adminNote: z.string(),
|
|
2996
|
+
/** When it was suggested (epoch ms). */
|
|
2997
|
+
createdAt: Millis,
|
|
2998
|
+
/** The author's username: only once it is on the public board (and always in admin views). */
|
|
2999
|
+
author: z.string().optional()
|
|
3000
|
+
});
|
|
3001
|
+
/** A vote's result: the new count and whether you are voting. */
|
|
3002
|
+
const VoteResult = z.object({
|
|
3003
|
+
votes: z.number().int().nonnegative(),
|
|
3004
|
+
voted: z.boolean()
|
|
3005
|
+
});
|
|
3006
|
+
/**
|
|
3007
|
+
* `:id`, a suggestion: one on the public board to vote, your own pending one
|
|
3008
|
+
* to withdraw. Read like `Number(x)`: anything that isn't one is a 404.
|
|
3009
|
+
*/
|
|
3010
|
+
const SuggestionParams = z.object({ id: LooseNumber.catch(NaN) });
|
|
3011
|
+
/** The old hub's one message for a missing or non-text title or description. */
|
|
3012
|
+
const SuggestionText = z.string({ error: "A suggestion needs a title and a description." });
|
|
3013
|
+
/** The report and suggestion endpoints. */
|
|
3014
|
+
const reportsEndpoints = {
|
|
3015
|
+
...requireActive.group({ fileReport: {
|
|
3016
|
+
method: "POST",
|
|
3017
|
+
path: "/social/reports",
|
|
3018
|
+
body: ReportInput,
|
|
3019
|
+
response: z.object({
|
|
3020
|
+
ok: z.literal(true),
|
|
3021
|
+
/** The report's id (the open one, when you already reported the same thing). */
|
|
3022
|
+
id: z.number().int().positive()
|
|
3023
|
+
}),
|
|
3024
|
+
errors: {
|
|
3025
|
+
400: ErrorBody,
|
|
3026
|
+
401: ErrorBody,
|
|
3027
|
+
403: ErrorBody,
|
|
3028
|
+
404: ErrorBody,
|
|
3029
|
+
429: ErrorBody
|
|
3030
|
+
},
|
|
3031
|
+
doc: {
|
|
3032
|
+
summary: "Report a player, a message, a suggestion or a status line",
|
|
3033
|
+
description: "404 when `refId` is nothing you could have seen from that player.",
|
|
3034
|
+
tags: ["reports"]
|
|
3035
|
+
}
|
|
3036
|
+
} }),
|
|
3037
|
+
...session.group({ listPublicSuggestions: {
|
|
3038
|
+
method: "GET",
|
|
3039
|
+
path: "/suggestions",
|
|
3040
|
+
query: z.object({
|
|
3041
|
+
/** `votes` (default) or `new`. */
|
|
3042
|
+
sort: z.enum(["votes", "new"]).optional().catch(void 0) }),
|
|
3043
|
+
response: z.object({
|
|
3044
|
+
/** The public board (approved, planned, done), at most 200. Ideas from suspended authors drop off. */
|
|
3045
|
+
approved: z.array(Suggestion),
|
|
3046
|
+
/** Your own ideas except withdrawn ones, newest first; empty unless signed in to a claimed account. */
|
|
3047
|
+
mine: z.array(Suggestion)
|
|
3048
|
+
}),
|
|
3049
|
+
errors: { 403: ErrorBody },
|
|
3050
|
+
doc: {
|
|
3051
|
+
summary: "The ideas board, plus your own ideas",
|
|
3052
|
+
tags: ["suggestions"]
|
|
3053
|
+
}
|
|
3054
|
+
} }),
|
|
3055
|
+
...requireClaimed.group({
|
|
3056
|
+
submitSuggestion: {
|
|
3057
|
+
method: "POST",
|
|
3058
|
+
path: "/suggestions",
|
|
3059
|
+
body: z.object({
|
|
3060
|
+
title: SuggestionText,
|
|
3061
|
+
body: SuggestionText
|
|
3062
|
+
}),
|
|
3063
|
+
response: z.object({ suggestion: Suggestion }),
|
|
3064
|
+
errors: {
|
|
3065
|
+
400: ErrorBody,
|
|
3066
|
+
401: ErrorBody,
|
|
3067
|
+
403: ErrorBody,
|
|
3068
|
+
409: ErrorBody,
|
|
3069
|
+
422: ContentRejected,
|
|
3070
|
+
429: ErrorBody
|
|
3071
|
+
},
|
|
3072
|
+
doc: {
|
|
3073
|
+
summary: "Suggest a game idea (filtered; a few a day, a capped number pending)",
|
|
3074
|
+
description: "409 `too_many_pending`; 429 `rate_limited` with `retryAfterMs` past the daily limit.",
|
|
3075
|
+
tags: ["suggestions"]
|
|
3076
|
+
}
|
|
3077
|
+
},
|
|
3078
|
+
voteForSuggestion: {
|
|
3079
|
+
method: "POST",
|
|
3080
|
+
path: "/suggestions/:id/vote",
|
|
3081
|
+
params: SuggestionParams,
|
|
3082
|
+
response: VoteResult,
|
|
3083
|
+
errors: {
|
|
3084
|
+
401: ErrorBody,
|
|
3085
|
+
403: ErrorBody,
|
|
3086
|
+
404: ErrorBody
|
|
3087
|
+
},
|
|
3088
|
+
doc: {
|
|
3089
|
+
summary: "Upvote an idea (idempotent)",
|
|
3090
|
+
tags: ["suggestions"]
|
|
3091
|
+
}
|
|
3092
|
+
},
|
|
3093
|
+
removeSuggestionVote: {
|
|
3094
|
+
method: "DELETE",
|
|
3095
|
+
path: "/suggestions/:id/vote",
|
|
3096
|
+
params: SuggestionParams,
|
|
3097
|
+
response: VoteResult,
|
|
3098
|
+
errors: {
|
|
3099
|
+
401: ErrorBody,
|
|
3100
|
+
403: ErrorBody,
|
|
3101
|
+
404: ErrorBody
|
|
3102
|
+
},
|
|
3103
|
+
doc: {
|
|
3104
|
+
summary: "Take an upvote back (idempotent)",
|
|
3105
|
+
tags: ["suggestions"]
|
|
3106
|
+
}
|
|
3107
|
+
},
|
|
3108
|
+
withdrawSuggestion: {
|
|
3109
|
+
method: "DELETE",
|
|
3110
|
+
path: "/suggestions/:id",
|
|
3111
|
+
params: SuggestionParams,
|
|
3112
|
+
response: Ok,
|
|
3113
|
+
errors: {
|
|
3114
|
+
401: ErrorBody,
|
|
3115
|
+
403: ErrorBody,
|
|
3116
|
+
404: ErrorBody,
|
|
3117
|
+
409: ErrorBody
|
|
3118
|
+
},
|
|
3119
|
+
doc: {
|
|
3120
|
+
summary: "Withdraw your own idea while it is still pending",
|
|
3121
|
+
tags: ["suggestions"]
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
})
|
|
3125
|
+
};
|
|
3126
|
+
//#endregion
|
|
3127
|
+
//#region src/api/social/people.ts
|
|
3128
|
+
/**
|
|
3129
|
+
* People shapes: your own social settings, friends, requests, player cards
|
|
3130
|
+
* and recent players. Several are also part of the socket `hello` snapshot.
|
|
3131
|
+
*
|
|
3132
|
+
* Privacy rules behind them: a block in either direction looks to the
|
|
3133
|
+
* blocked side like an ordinary unfriend (or a missing player), suspended
|
|
3134
|
+
* players are invisible, and guests have no friends.
|
|
3135
|
+
*/
|
|
3136
|
+
/** Who may see my presence, or message me: friends, or nobody. */
|
|
3137
|
+
const Audience = z.enum(["friends", "nobody"]);
|
|
3138
|
+
/**
|
|
3139
|
+
* Your own account's social fields (`GET`/`PUT /social/me`, the `hello`
|
|
3140
|
+
* snapshot's `me`). Never shown to other players.
|
|
3141
|
+
*/
|
|
3142
|
+
const SocialMe = SessionUser.extend({
|
|
3143
|
+
/** Avatar token; never empty (a default is derived from the id). */
|
|
3144
|
+
avatar: PlayerLite.shape.avatar,
|
|
3145
|
+
/** Who sees my presence and last-seen time. */
|
|
3146
|
+
privacy: Audience,
|
|
3147
|
+
/** Who may message me (and invite me). */
|
|
3148
|
+
dmPolicy: Audience
|
|
3149
|
+
});
|
|
3150
|
+
/** A friend, with what you may see of their presence. Online friends sort first. */
|
|
3151
|
+
const FriendEntry = PlayerLite.extend({
|
|
3152
|
+
/** `null` when offline or hiding their presence. */
|
|
3153
|
+
presence: Presence.nullable(),
|
|
3154
|
+
/** "Tell me when they come online" is on for this friend. */
|
|
3155
|
+
notifyOnline: z.boolean(),
|
|
3156
|
+
/** When you became friends (epoch ms). */
|
|
3157
|
+
since: Millis,
|
|
3158
|
+
/** When they were last online (epoch ms); `0` when unknown or they hide their presence. */
|
|
3159
|
+
lastSeenAt: Millis
|
|
3160
|
+
});
|
|
3161
|
+
/** Pending friend requests both ways, newest first (blocked and suspended players left out). */
|
|
3162
|
+
const FriendRequests = z.object({
|
|
3163
|
+
/** Requests waiting for you. */
|
|
3164
|
+
incoming: z.array(PlayerLite),
|
|
3165
|
+
/** Requests you sent. */
|
|
3166
|
+
outgoing: z.array(PlayerLite)
|
|
3167
|
+
});
|
|
3168
|
+
/** `GET /social/friends`: friends, requests and the players you blocked. Guests get empty lists. */
|
|
3169
|
+
const FriendsList = FriendRequests.extend({
|
|
3170
|
+
friends: z.array(FriendEntry),
|
|
3171
|
+
/** Players you blocked, newest first. */
|
|
3172
|
+
blocked: z.array(PlayerLite)
|
|
3173
|
+
});
|
|
3174
|
+
/**
|
|
3175
|
+
* What changed in `ev:friends` (the client refetches `GET /social/friends`).
|
|
3176
|
+
* `restored`: a friend's suspension ended. A player you blocked only ever
|
|
3177
|
+
* sees `removed`.
|
|
3178
|
+
*/
|
|
3179
|
+
const FriendChange = z.enum([
|
|
3180
|
+
"request",
|
|
3181
|
+
"accepted",
|
|
3182
|
+
"removed",
|
|
3183
|
+
"declined",
|
|
3184
|
+
"cancelled",
|
|
3185
|
+
"blocked",
|
|
3186
|
+
"restored"
|
|
3187
|
+
]);
|
|
3188
|
+
/** My relation to another player, as their card shows it. */
|
|
3189
|
+
const Relation = z.enum([
|
|
3190
|
+
"self",
|
|
3191
|
+
"friend",
|
|
3192
|
+
"incoming",
|
|
3193
|
+
"outgoing",
|
|
3194
|
+
"blocked",
|
|
3195
|
+
"none"
|
|
3196
|
+
]);
|
|
3197
|
+
/**
|
|
3198
|
+
* A player card (`GET /social/players/:id`). Someone who blocked you, or who
|
|
3199
|
+
* is suspended, answers 404 exactly like a player who doesn't exist.
|
|
3200
|
+
*/
|
|
3201
|
+
const PlayerCard = PlayerLite.extend({
|
|
3202
|
+
relation: Relation,
|
|
3203
|
+
/** Friends only; `null` otherwise, or when offline or hidden. */
|
|
3204
|
+
presence: Presence.nullable(),
|
|
3205
|
+
/** A friend request can be sent (both claimed, no relation yet or theirs is pending, requests on). */
|
|
3206
|
+
canFriend: z.boolean(),
|
|
3207
|
+
/** Friends who take messages, while DMs are on. */
|
|
3208
|
+
canMessage: z.boolean(),
|
|
3209
|
+
/** Friends who take messages. */
|
|
3210
|
+
canInvite: z.boolean(),
|
|
3211
|
+
/** Anyone but yourself. */
|
|
3212
|
+
canReport: z.boolean()
|
|
3213
|
+
});
|
|
3214
|
+
/** Someone you recently played with (`GET /social/recent`). */
|
|
3215
|
+
const RecentPlayer = PlayerLite.extend({
|
|
3216
|
+
/** The game you played together. */
|
|
3217
|
+
game: Slug,
|
|
3218
|
+
/** When you last played together (epoch ms). */
|
|
3219
|
+
lastAt: Millis
|
|
3220
|
+
});
|
|
3221
|
+
//#endregion
|
|
3222
|
+
//#region src/api/social/endpoints.ts
|
|
3223
|
+
/**
|
|
3224
|
+
* Social core REST (multiplayer-impl.md Β§5.1): config, your own social
|
|
3225
|
+
* settings, friends and requests, blocks, player cards, recent players and
|
|
3226
|
+
* online counts. Every route here is closed to suspended accounts (403
|
|
3227
|
+
* `suspended`) except `GET /social/config` and `GET /social/me`, which a
|
|
3228
|
+
* suspended player's UI needs to explain what happened.
|
|
3229
|
+
*/
|
|
3230
|
+
/** `{ me }`: your social settings after a change. */
|
|
3231
|
+
const SocialMeResult = z.object({ me: SocialMe });
|
|
3232
|
+
/** `:id`, another player's user id. A malformed id is a 400. */
|
|
3233
|
+
const OtherUser = z.object({ id: requiredId("id must be a user id.") });
|
|
3234
|
+
/** An {@link Audience} field, with the old hub's message for anything else. */
|
|
3235
|
+
const audienceField = (field) => z.enum(Audience.options, { error: `${field} must be friends or nobody.` });
|
|
3236
|
+
/**
|
|
3237
|
+
* `POST /social/friends`: who to befriend, by id or by name, and an optional
|
|
3238
|
+
* first message. `userId` wins when both are given; with neither, the player
|
|
3239
|
+
* is not found (404 `no_player`).
|
|
3240
|
+
*/
|
|
3241
|
+
const FriendRequestInput = z.object({
|
|
3242
|
+
userId: requiredId("userId must be a user id.").nullish(),
|
|
3243
|
+
/** A username, case-insensitive. */
|
|
3244
|
+
username: z.string().optional(),
|
|
3245
|
+
message: z.string().optional()
|
|
3246
|
+
});
|
|
3247
|
+
/** The result of `POST /social/friends`. */
|
|
3248
|
+
const FriendRequestStatus = z.enum(["requested", "friends"]);
|
|
3249
|
+
/** The social core endpoints. */
|
|
3250
|
+
const socialEndpoints = {
|
|
3251
|
+
...session.group({
|
|
3252
|
+
getSocialConfig: {
|
|
3253
|
+
method: "GET",
|
|
3254
|
+
path: "/social/config",
|
|
3255
|
+
response: SocialConfig,
|
|
3256
|
+
doc: {
|
|
3257
|
+
summary: "The site-wide social configuration",
|
|
3258
|
+
tags: ["social"]
|
|
3259
|
+
}
|
|
3260
|
+
},
|
|
3261
|
+
getOnlineCounts: {
|
|
3262
|
+
method: "GET",
|
|
3263
|
+
path: "/social/counts",
|
|
3264
|
+
response: Counts,
|
|
3265
|
+
errors: { 403: ErrorBody },
|
|
3266
|
+
doc: {
|
|
3267
|
+
summary: "Anonymous online counts, overall and per game",
|
|
3268
|
+
tags: ["social"]
|
|
3269
|
+
}
|
|
3270
|
+
}
|
|
3271
|
+
}),
|
|
3272
|
+
...requireUser.group({ getMySocialProfile: {
|
|
3273
|
+
method: "GET",
|
|
3274
|
+
path: "/social/me",
|
|
3275
|
+
response: z.object({
|
|
3276
|
+
me: SocialMe,
|
|
3277
|
+
notify: NotifyPrefs,
|
|
3278
|
+
/** How many browsers have a push subscription for this account. */
|
|
3279
|
+
pushSubs: z.number().int().nonnegative()
|
|
3280
|
+
}),
|
|
3281
|
+
errors: { 401: ErrorBody },
|
|
3282
|
+
doc: {
|
|
3283
|
+
summary: "Your social settings, notification preferences and push subscription count",
|
|
3284
|
+
tags: ["social"]
|
|
3285
|
+
}
|
|
3286
|
+
} }),
|
|
3287
|
+
...requireActive.group({
|
|
3288
|
+
listFriendsAndRequests: {
|
|
3289
|
+
method: "GET",
|
|
3290
|
+
path: "/social/friends",
|
|
3291
|
+
response: FriendsList,
|
|
3292
|
+
errors: {
|
|
3293
|
+
401: ErrorBody,
|
|
3294
|
+
403: ErrorBody
|
|
3295
|
+
},
|
|
3296
|
+
doc: {
|
|
3297
|
+
summary: "Friends (online first), requests both ways and blocks",
|
|
3298
|
+
tags: ["social"]
|
|
3299
|
+
}
|
|
3300
|
+
},
|
|
3301
|
+
findPlayerByName: {
|
|
3302
|
+
method: "GET",
|
|
3303
|
+
path: "/social/players/by-name/:name",
|
|
3304
|
+
params: z.object({ name: z.string() }),
|
|
3305
|
+
response: PlayerCard,
|
|
3306
|
+
errors: {
|
|
3307
|
+
401: ErrorBody,
|
|
3308
|
+
403: ErrorBody,
|
|
3309
|
+
404: ErrorBody
|
|
3310
|
+
},
|
|
3311
|
+
doc: {
|
|
3312
|
+
summary: "A player card, by username",
|
|
3313
|
+
tags: ["social"]
|
|
3314
|
+
}
|
|
3315
|
+
},
|
|
3316
|
+
getPlayerCard: {
|
|
3317
|
+
method: "GET",
|
|
3318
|
+
path: "/social/players/:id",
|
|
3319
|
+
params: OtherUser,
|
|
3320
|
+
response: PlayerCard,
|
|
3321
|
+
errors: {
|
|
3322
|
+
400: ErrorBody,
|
|
3323
|
+
401: ErrorBody,
|
|
3324
|
+
403: ErrorBody,
|
|
3325
|
+
404: ErrorBody
|
|
3326
|
+
},
|
|
3327
|
+
doc: {
|
|
3328
|
+
summary: "A player card, by id (404 `no_player` when blocked by them or suspended)",
|
|
3329
|
+
tags: ["social"]
|
|
3330
|
+
}
|
|
3331
|
+
}
|
|
3332
|
+
}),
|
|
3333
|
+
...requireClaimed.group({
|
|
3334
|
+
updateMySocialProfile: {
|
|
3335
|
+
method: "PUT",
|
|
3336
|
+
path: "/social/me",
|
|
3337
|
+
body: z.object({
|
|
3338
|
+
/** One of the config's preset avatar ids (else 400 `bad_avatar`). */
|
|
3339
|
+
avatar: z.string().optional(),
|
|
3340
|
+
privacy: audienceField("privacy").optional(),
|
|
3341
|
+
dmPolicy: audienceField("dmPolicy").optional()
|
|
3342
|
+
}),
|
|
3343
|
+
response: SocialMeResult,
|
|
3344
|
+
errors: {
|
|
3345
|
+
400: ErrorBody,
|
|
3346
|
+
401: ErrorBody,
|
|
3347
|
+
403: ErrorBody
|
|
3348
|
+
},
|
|
3349
|
+
doc: {
|
|
3350
|
+
summary: "Change your avatar, presence privacy or who may message you",
|
|
3351
|
+
tags: ["social"]
|
|
3352
|
+
}
|
|
3353
|
+
},
|
|
3354
|
+
setParentChatLock: {
|
|
3355
|
+
method: "POST",
|
|
3356
|
+
path: "/social/chat-lock",
|
|
3357
|
+
body: z.union([z.object({ on: z.literal(true) }), z.object({
|
|
3358
|
+
on: z.literal(false),
|
|
3359
|
+
/** The account password; required to turn the lock off. */
|
|
3360
|
+
password: z.string().optional()
|
|
3361
|
+
})], { error: "on must be true or false." }),
|
|
3362
|
+
response: SocialMeResult,
|
|
3363
|
+
errors: {
|
|
3364
|
+
400: ErrorBody,
|
|
3365
|
+
401: ErrorBody,
|
|
3366
|
+
403: ErrorBody,
|
|
3367
|
+
429: ErrorBody
|
|
3368
|
+
},
|
|
3369
|
+
doc: {
|
|
3370
|
+
summary: "Parent chat lock: on with one tap, off only with the account password",
|
|
3371
|
+
description: "A wrong password is 403 `bad_password` (logged and rate-limited).",
|
|
3372
|
+
tags: ["social"]
|
|
3373
|
+
}
|
|
3374
|
+
},
|
|
3375
|
+
sendFriendRequest: {
|
|
3376
|
+
method: "POST",
|
|
3377
|
+
path: "/social/friends",
|
|
3378
|
+
body: FriendRequestInput,
|
|
3379
|
+
response: z.object({ status: FriendRequestStatus }),
|
|
3380
|
+
errors: {
|
|
3381
|
+
400: ErrorBody,
|
|
3382
|
+
401: ErrorBody,
|
|
3383
|
+
403: ErrorBody,
|
|
3384
|
+
404: ErrorBody,
|
|
3385
|
+
409: ErrorBody,
|
|
3386
|
+
422: ContentRejected,
|
|
3387
|
+
429: ErrorBody
|
|
3388
|
+
},
|
|
3389
|
+
doc: {
|
|
3390
|
+
summary: "Send a friend request (a reverse request accepts at once)",
|
|
3391
|
+
description: "Answers `requested` without doing anything when the target blocked you or is suspended. `message` is moderated now and delivered as the first DM once you are friends.",
|
|
3392
|
+
tags: ["social"]
|
|
3393
|
+
}
|
|
3394
|
+
},
|
|
3395
|
+
acceptFriendRequest: {
|
|
3396
|
+
method: "POST",
|
|
3397
|
+
path: "/social/friends/:id/accept",
|
|
3398
|
+
params: OtherUser,
|
|
3399
|
+
response: Ok,
|
|
3400
|
+
errors: {
|
|
3401
|
+
400: ErrorBody,
|
|
3402
|
+
401: ErrorBody,
|
|
3403
|
+
403: ErrorBody,
|
|
3404
|
+
404: ErrorBody,
|
|
3405
|
+
409: ErrorBody
|
|
3406
|
+
},
|
|
3407
|
+
doc: {
|
|
3408
|
+
summary: "Accept the request from `:id`",
|
|
3409
|
+
tags: ["social"]
|
|
3410
|
+
}
|
|
3411
|
+
},
|
|
3412
|
+
declineFriendRequest: {
|
|
3413
|
+
method: "POST",
|
|
3414
|
+
path: "/social/friends/:id/decline",
|
|
3415
|
+
params: OtherUser,
|
|
3416
|
+
response: Ok,
|
|
3417
|
+
errors: {
|
|
3418
|
+
400: ErrorBody,
|
|
3419
|
+
401: ErrorBody,
|
|
3420
|
+
403: ErrorBody
|
|
3421
|
+
},
|
|
3422
|
+
doc: {
|
|
3423
|
+
summary: "Quietly drop the request from `:id`",
|
|
3424
|
+
tags: ["social"]
|
|
3425
|
+
}
|
|
3426
|
+
},
|
|
3427
|
+
removeFriend: {
|
|
3428
|
+
method: "DELETE",
|
|
3429
|
+
path: "/social/friends/:id",
|
|
3430
|
+
params: OtherUser,
|
|
3431
|
+
response: Ok,
|
|
3432
|
+
errors: {
|
|
3433
|
+
400: ErrorBody,
|
|
3434
|
+
401: ErrorBody,
|
|
3435
|
+
403: ErrorBody
|
|
3436
|
+
},
|
|
3437
|
+
doc: {
|
|
3438
|
+
summary: "Unfriend `:id`, or cancel your request to them",
|
|
3439
|
+
tags: ["social"]
|
|
3440
|
+
}
|
|
3441
|
+
},
|
|
3442
|
+
setFriendNotifications: {
|
|
3443
|
+
method: "PUT",
|
|
3444
|
+
path: "/social/friends/:id/notify",
|
|
3445
|
+
params: OtherUser,
|
|
3446
|
+
body: z.object({ online: z.boolean({ error: "online must be true or false." }) }),
|
|
3447
|
+
response: Ok,
|
|
3448
|
+
errors: {
|
|
3449
|
+
400: ErrorBody,
|
|
3450
|
+
401: ErrorBody,
|
|
3451
|
+
403: ErrorBody,
|
|
3452
|
+
404: ErrorBody
|
|
3453
|
+
},
|
|
3454
|
+
doc: {
|
|
3455
|
+
summary: "Turn a friend's \"tell me when they're online\" bell on or off",
|
|
3456
|
+
tags: ["social"]
|
|
3457
|
+
}
|
|
3458
|
+
},
|
|
3459
|
+
blockPlayer: {
|
|
3460
|
+
method: "POST",
|
|
3461
|
+
path: "/social/blocks",
|
|
3462
|
+
body: z.object({ userId: requiredId("userId must be a user id.") }),
|
|
3463
|
+
response: Ok,
|
|
3464
|
+
errors: {
|
|
3465
|
+
400: ErrorBody,
|
|
3466
|
+
401: ErrorBody,
|
|
3467
|
+
403: ErrorBody,
|
|
3468
|
+
404: ErrorBody,
|
|
3469
|
+
409: ErrorBody
|
|
3470
|
+
},
|
|
3471
|
+
doc: {
|
|
3472
|
+
summary: "Block a player",
|
|
3473
|
+
description: "Also unfriends, drops requests both ways and splits a shared party.",
|
|
3474
|
+
tags: ["social"]
|
|
3475
|
+
}
|
|
3476
|
+
},
|
|
3477
|
+
unblockPlayer: {
|
|
3478
|
+
method: "DELETE",
|
|
3479
|
+
path: "/social/blocks/:id",
|
|
3480
|
+
params: OtherUser,
|
|
3481
|
+
response: Ok,
|
|
3482
|
+
errors: {
|
|
3483
|
+
400: ErrorBody,
|
|
3484
|
+
401: ErrorBody,
|
|
3485
|
+
403: ErrorBody
|
|
3486
|
+
},
|
|
3487
|
+
doc: {
|
|
3488
|
+
summary: "Unblock a player",
|
|
3489
|
+
tags: ["social"]
|
|
3490
|
+
}
|
|
3491
|
+
},
|
|
3492
|
+
listRecentPlayers: {
|
|
3493
|
+
method: "GET",
|
|
3494
|
+
path: "/social/recent",
|
|
3495
|
+
response: z.object({
|
|
3496
|
+
/** Newest first, at most 50. */
|
|
3497
|
+
players: z.array(RecentPlayer) }),
|
|
3498
|
+
errors: {
|
|
3499
|
+
401: ErrorBody,
|
|
3500
|
+
403: ErrorBody
|
|
3501
|
+
},
|
|
3502
|
+
doc: {
|
|
3503
|
+
summary: "People you recently played with",
|
|
3504
|
+
tags: ["social"]
|
|
3505
|
+
}
|
|
3506
|
+
}
|
|
3507
|
+
})
|
|
3508
|
+
};
|
|
3509
|
+
//#endregion
|
|
3510
|
+
//#region src/api/version.ts
|
|
3511
|
+
/**
|
|
3512
|
+
* `GET /version`: which client versions this hub serves (plan Β§4.3.3). Games'
|
|
3513
|
+
* clients read it once per page, and `hub-doctor` reads it in a game's CI.
|
|
3514
|
+
* The old hub has no such route and answers 404 "No such API endpoint.", which
|
|
3515
|
+
* clients read as "a hub from before versioning".
|
|
3516
|
+
*/
|
|
3517
|
+
/**
|
|
3518
|
+
* A semver range in the npm form: comparators (`>=2.0.0-0`, `<3.0.0`)
|
|
3519
|
+
* separated by spaces are all required; `||` separates alternatives.
|
|
3520
|
+
*/
|
|
3521
|
+
const VersionRange = z.string().min(1);
|
|
3522
|
+
/** What `GET /version` answers. */
|
|
3523
|
+
const HubVersion = z.object({
|
|
3524
|
+
/** The hub release this server was built from: the version of the client it serves at `/_hub/hub.js`. */
|
|
3525
|
+
server: z.string(),
|
|
3526
|
+
/** The version of `@diffenderfer-games/hub-contract` the server implements. */
|
|
3527
|
+
contract: z.string(),
|
|
3528
|
+
/** Client versions the server supports (`x-hub-client`); a client outside it should be updated. */
|
|
3529
|
+
supported: VersionRange,
|
|
3530
|
+
/** Supported client versions that are deprecated: they still work, and `hub-doctor` warns. */
|
|
3531
|
+
deprecated: VersionRange
|
|
3532
|
+
});
|
|
3533
|
+
/** The version endpoints. */
|
|
3534
|
+
const versionEndpoints = { ...session.group({ getHubVersion: {
|
|
3535
|
+
method: "GET",
|
|
3536
|
+
path: "/version",
|
|
3537
|
+
response: HubVersion,
|
|
3538
|
+
doc: {
|
|
3539
|
+
summary: "The hub version and the client versions it supports",
|
|
3540
|
+
tags: ["catalog"]
|
|
3541
|
+
}
|
|
3542
|
+
} }) };
|
|
3543
|
+
//#endregion
|
|
3544
|
+
//#region src/api/index.ts
|
|
3545
|
+
/**
|
|
3546
|
+
* The public REST API: every endpoint a game, the menu or the social runtime
|
|
3547
|
+
* calls, as one ayepi spec. Paths are relative to `/_api`. One file per area.
|
|
3548
|
+
*/
|
|
3549
|
+
/** Every public endpoint, by name. */
|
|
3550
|
+
const publicEndpoints = {
|
|
3551
|
+
...authEndpoints,
|
|
3552
|
+
...catalogEndpoints,
|
|
3553
|
+
...progressEndpoints,
|
|
3554
|
+
...leaderboardsEndpoints,
|
|
3555
|
+
...dailyEndpoints,
|
|
3556
|
+
...economyEndpoints,
|
|
3557
|
+
...analyticsEndpoints,
|
|
3558
|
+
...socialEndpoints,
|
|
3559
|
+
...messagesEndpoints,
|
|
3560
|
+
...notifyEndpoints,
|
|
3561
|
+
...reportsEndpoints,
|
|
3562
|
+
...mpEndpoints,
|
|
3563
|
+
...raceEndpoints,
|
|
3564
|
+
...versionEndpoints
|
|
3565
|
+
};
|
|
3566
|
+
/** The public API spec (paths relative to `/_api`). */
|
|
3567
|
+
const publicApi = spec({ endpoints: publicEndpoints });
|
|
3568
|
+
//#endregion
|
|
3569
|
+
//#region src/realtime/define.ts
|
|
3570
|
+
/** Declare an op (identity at runtime; keeps the literal types). */
|
|
3571
|
+
function op(def) {
|
|
3572
|
+
return def;
|
|
3573
|
+
}
|
|
3574
|
+
/** Declare an event (identity at runtime; keeps the literal types). */
|
|
3575
|
+
function event(def) {
|
|
3576
|
+
return def;
|
|
3577
|
+
}
|
|
3578
|
+
//#endregion
|
|
3579
|
+
//#region src/realtime/events/parties.ts
|
|
3580
|
+
/** Invite and party events (multiplayer-impl.md Β§5.6). */
|
|
3581
|
+
/** The invite and party events, by event name. */
|
|
3582
|
+
const partyEvents = {
|
|
3583
|
+
invite: event({
|
|
3584
|
+
d: Invite,
|
|
3585
|
+
doc: "A friend invited you to a game."
|
|
3586
|
+
}),
|
|
3587
|
+
"invite.update": event({
|
|
3588
|
+
d: z.object({
|
|
3589
|
+
id: OpaqueId,
|
|
3590
|
+
status: InviteStatus,
|
|
3591
|
+
/** Who accepted or declined (absent for cancelled and expired). */
|
|
3592
|
+
by: PlayerLite.optional()
|
|
3593
|
+
}),
|
|
3594
|
+
doc: "An invite ended; sent to the inviter and the invitee (so its toast closes)."
|
|
3595
|
+
}),
|
|
3596
|
+
party: event({
|
|
3597
|
+
d: Party.nullable(),
|
|
3598
|
+
doc: "Your party's current state, or null when you left it or it dissolved."
|
|
3599
|
+
}),
|
|
3600
|
+
"party.chat": event({
|
|
3601
|
+
d: ChatLineEvent,
|
|
3602
|
+
doc: "A party chat line."
|
|
3603
|
+
})
|
|
3604
|
+
};
|
|
3605
|
+
//#endregion
|
|
3606
|
+
//#region src/realtime/events/races.ts
|
|
3607
|
+
/** Race events (docs/plans/race.md Β§4). */
|
|
3608
|
+
/** The race events, by event name. */
|
|
3609
|
+
const raceEvents = {
|
|
3610
|
+
"race.start": event({
|
|
3611
|
+
d: RaceStart,
|
|
3612
|
+
doc: "The countdown began: the seed is revealed now."
|
|
3613
|
+
}),
|
|
3614
|
+
"race.status": event({
|
|
3615
|
+
d: z.object({
|
|
3616
|
+
userId: UserId,
|
|
3617
|
+
stats: RaceStats,
|
|
3618
|
+
progress: RaceProgress.optional(),
|
|
3619
|
+
/** ms since the go (server clock). */
|
|
3620
|
+
at: z.number().int().nonnegative()
|
|
3621
|
+
}),
|
|
3622
|
+
doc: "Another racer's live status, for the race HUD."
|
|
3623
|
+
}),
|
|
3624
|
+
"race.result": event({
|
|
3625
|
+
d: z.object({ result: RaceResult }),
|
|
3626
|
+
doc: "The race was decided."
|
|
3627
|
+
})
|
|
3628
|
+
};
|
|
3629
|
+
//#endregion
|
|
3630
|
+
//#region src/realtime/events/rooms.ts
|
|
3631
|
+
/** Hub relay room events (multiplayer-impl.md Β§6). Race rooms send these too. */
|
|
3632
|
+
/** The room events, by event name. */
|
|
3633
|
+
const roomEvents = {
|
|
3634
|
+
room: event({
|
|
3635
|
+
d: z.object({
|
|
3636
|
+
room: RoomInfo,
|
|
3637
|
+
/** What the host passed to `room.end`; present once, on that update. */
|
|
3638
|
+
results: Json.optional()
|
|
3639
|
+
}),
|
|
3640
|
+
doc: "The room changed: roster, ready flags, phase, host, race state."
|
|
3641
|
+
}),
|
|
3642
|
+
"room.msg": event({
|
|
3643
|
+
d: RoomMessage,
|
|
3644
|
+
doc: "A relayed game message."
|
|
3645
|
+
}),
|
|
3646
|
+
"room.state": event({
|
|
3647
|
+
d: z.object({
|
|
3648
|
+
/** Shallow-merged into the state unless `replace`; a `null` value deletes its key. */
|
|
3649
|
+
patch: RoomState,
|
|
3650
|
+
replace: z.boolean(),
|
|
3651
|
+
seq: z.number().int().positive()
|
|
3652
|
+
}),
|
|
3653
|
+
doc: "The shared state changed."
|
|
3654
|
+
}),
|
|
3655
|
+
"room.chat": event({
|
|
3656
|
+
d: ChatLineEvent,
|
|
3657
|
+
doc: "A room chat line."
|
|
3658
|
+
}),
|
|
3659
|
+
"room.kicked": event({
|
|
3660
|
+
d: z.object({ roomId: OpaqueId }),
|
|
3661
|
+
doc: "The host removed you from the room."
|
|
3662
|
+
}),
|
|
3663
|
+
"room.closed": event({
|
|
3664
|
+
d: z.object({ reason: RoomClosedReason }),
|
|
3665
|
+
doc: "The room is gone for this tab (emptied, ended, host left, moved, another tab took the seat, removed)."
|
|
3666
|
+
}),
|
|
3667
|
+
"room.abandoned": event({
|
|
3668
|
+
d: z.object({
|
|
3669
|
+
userId: UserId,
|
|
3670
|
+
reason: SeatGoneReason
|
|
3671
|
+
}),
|
|
3672
|
+
doc: "A player left mid-game (or their reconnect grace ran out); the game decides what that means."
|
|
3673
|
+
})
|
|
3674
|
+
};
|
|
3675
|
+
//#endregion
|
|
3676
|
+
//#region src/realtime/events/multiplayer.ts
|
|
3677
|
+
/** The multiplayer events, by event name. */
|
|
3678
|
+
const multiplayerEvents = {
|
|
3679
|
+
...partyEvents,
|
|
3680
|
+
...roomEvents,
|
|
3681
|
+
...raceEvents
|
|
3682
|
+
};
|
|
3683
|
+
//#endregion
|
|
3684
|
+
//#region src/realtime/events/social.ts
|
|
3685
|
+
/**
|
|
3686
|
+
* Socket events for presence, counts, friends, typing, config, DMs, notices
|
|
3687
|
+
* and suspension (multiplayer-impl.md Β§4.6). Every event goes to all of the
|
|
3688
|
+
* recipient's tabs.
|
|
3689
|
+
*/
|
|
3690
|
+
/** What kind of thing a notice is about. `renamed`: an admin changed your username. */
|
|
3691
|
+
const NoticeKind = z.enum([
|
|
3692
|
+
"dm",
|
|
3693
|
+
"friend_request",
|
|
3694
|
+
"friend_online",
|
|
3695
|
+
"lobby_open",
|
|
3696
|
+
"invite",
|
|
3697
|
+
"suggestion",
|
|
3698
|
+
"renamed"
|
|
3699
|
+
]);
|
|
3700
|
+
/** A toast (`ev:notice`), sent instead of a push when the player has a visible tab. */
|
|
3701
|
+
const Notice = z.object({
|
|
3702
|
+
kind: NoticeKind,
|
|
3703
|
+
title: z.string(),
|
|
3704
|
+
body: z.string().optional(),
|
|
3705
|
+
/** A button: its label and the page it opens. */
|
|
3706
|
+
action: z.object({
|
|
3707
|
+
label: z.string(),
|
|
3708
|
+
url: z.string()
|
|
3709
|
+
}).optional()
|
|
3710
|
+
});
|
|
3711
|
+
/** The social events, by event name. */
|
|
3712
|
+
const socialEvents = {
|
|
3713
|
+
presence: event({
|
|
3714
|
+
d: z.object({
|
|
3715
|
+
userId: UserId,
|
|
3716
|
+
/** `null`: offline, or no longer visible to you. */
|
|
3717
|
+
p: Presence.nullable()
|
|
3718
|
+
}),
|
|
3719
|
+
doc: "A friend's presence changed."
|
|
3720
|
+
}),
|
|
3721
|
+
counts: event({
|
|
3722
|
+
d: Counts,
|
|
3723
|
+
doc: "The online counts changed (broadcast to every tab, at most every 15 s)."
|
|
3724
|
+
}),
|
|
3725
|
+
friends: event({
|
|
3726
|
+
d: z.object({
|
|
3727
|
+
change: FriendChange,
|
|
3728
|
+
user: PlayerLite
|
|
3729
|
+
}),
|
|
3730
|
+
doc: "Your friends list changed (refetch `GET /social/friends`)."
|
|
3731
|
+
}),
|
|
3732
|
+
typing: event({
|
|
3733
|
+
d: z.object({ from: UserId }),
|
|
3734
|
+
doc: "A friend is typing to you."
|
|
3735
|
+
}),
|
|
3736
|
+
config: event({
|
|
3737
|
+
d: SocialConfig,
|
|
3738
|
+
doc: "The site-wide social configuration changed (broadcast)."
|
|
3739
|
+
}),
|
|
3740
|
+
dm: event({
|
|
3741
|
+
d: z.object({
|
|
3742
|
+
message: Message,
|
|
3743
|
+
/** The other side of the conversation, from your point of view. */
|
|
3744
|
+
conversationWith: UserId
|
|
3745
|
+
}),
|
|
3746
|
+
doc: "A DM was delivered: to the recipient, and to the sender's other tabs."
|
|
3747
|
+
}),
|
|
3748
|
+
"dm.read": event({
|
|
3749
|
+
d: z.object({
|
|
3750
|
+
/** Who read. */
|
|
3751
|
+
by: UserId,
|
|
3752
|
+
/** Their messages up to and including this id are read. */
|
|
3753
|
+
upTo: MessageId
|
|
3754
|
+
}),
|
|
3755
|
+
doc: "Messages were read (to the sender, and to the reader's own tabs to sync badges)."
|
|
3756
|
+
}),
|
|
3757
|
+
"dm.hidden": event({
|
|
3758
|
+
d: z.object({ ids: z.array(MessageId) }),
|
|
3759
|
+
doc: "A moderator hid these messages: drop them from both sides of the conversation."
|
|
3760
|
+
}),
|
|
3761
|
+
notice: event({
|
|
3762
|
+
d: Notice,
|
|
3763
|
+
doc: "A toast to show."
|
|
3764
|
+
}),
|
|
3765
|
+
suspended: event({
|
|
3766
|
+
d: z.object({
|
|
3767
|
+
/** When the suspension ends (epoch ms). */
|
|
3768
|
+
until: Millis,
|
|
3769
|
+
/** The moderator's reason, when given. */
|
|
3770
|
+
reason: z.string().optional()
|
|
3771
|
+
}),
|
|
3772
|
+
doc: "The account is suspended; the socket then closes with 4003."
|
|
3773
|
+
})
|
|
3774
|
+
};
|
|
3775
|
+
/** The `hello` op (allowed before `hello`, obviously). */
|
|
3776
|
+
const helloOps = { hello: op({
|
|
3777
|
+
d: z.object({
|
|
3778
|
+
/** The game this tab is in, or `null` on the home page. Unknown slugs count as `null`. */
|
|
3779
|
+
slug: z.string().nullable().optional(),
|
|
3780
|
+
/** A per-tab id (`[A-Za-z0-9_-]{1,64}`); other values are ignored. */
|
|
3781
|
+
tab: z.string().optional(),
|
|
3782
|
+
/** The document is visible (default `true`). */
|
|
3783
|
+
visible: z.boolean().optional(),
|
|
3784
|
+
/** The client package version that opened the socket (`2.3.0`); v1 vendored copies send none. */
|
|
3785
|
+
client: z.string().optional()
|
|
3786
|
+
}),
|
|
3787
|
+
ack: z.object({
|
|
3788
|
+
me: SocialMe,
|
|
3789
|
+
config: SocialConfig,
|
|
3790
|
+
counts: Counts,
|
|
3791
|
+
friends: z.array(FriendEntry),
|
|
3792
|
+
requests: FriendRequests,
|
|
3793
|
+
unread: UnreadSummary,
|
|
3794
|
+
...MultiplayerSnapshot.shape
|
|
3795
|
+
}),
|
|
3796
|
+
errors: [],
|
|
3797
|
+
doc: "Start the session on this socket; answers with the snapshot."
|
|
3798
|
+
}) };
|
|
3799
|
+
//#endregion
|
|
3800
|
+
//#region src/realtime/ops/parties.ts
|
|
3801
|
+
/** The party socket op: party chat. Invites and party membership are REST (`/mp/*`). */
|
|
3802
|
+
/** The party ops, by op name. */
|
|
3803
|
+
const partyOps = { "party.chat": op({
|
|
3804
|
+
d: ChatSend,
|
|
3805
|
+
ack: ChatLine,
|
|
3806
|
+
errors: ["party_gone", ...CHAT_ERRORS],
|
|
3807
|
+
doc: "Chat to your party: free text only when every member is a mutual friend, quick-chat otherwise."
|
|
3808
|
+
}) };
|
|
3809
|
+
//#endregion
|
|
3810
|
+
//#region src/realtime/ops/empty.ts
|
|
3811
|
+
/** The empty input and ack many socket ops share. */
|
|
3812
|
+
/** An op that takes no input (`d` absent or `{}`). */
|
|
3813
|
+
const NoInput = z.object({});
|
|
3814
|
+
/** An ack with no payload (`{}`). */
|
|
3815
|
+
const EmptyAck = z.object({});
|
|
3816
|
+
//#endregion
|
|
3817
|
+
//#region src/realtime/ops/races.ts
|
|
3818
|
+
/**
|
|
3819
|
+
* Race ops (docs/plans/race.md Β§4). A race is a hub room with a referee: the
|
|
3820
|
+
* countdown starts when every seat is filled, the seed is revealed with it,
|
|
3821
|
+
* and the first valid finish wins. Reports come from browsers, so the hub
|
|
3822
|
+
* measures time itself and enforces what it can (`minMs`, one result each).
|
|
3823
|
+
*/
|
|
3824
|
+
/** A racer's report (status, finish, lose): stats (`null` means none) and optional 0..1 progress. */
|
|
3825
|
+
const RaceReport = z.object({
|
|
3826
|
+
stats: RaceStats.nullish(),
|
|
3827
|
+
progress: RaceProgress.nullish()
|
|
3828
|
+
});
|
|
3829
|
+
/** The refusal a malformed report gets (old wording). */
|
|
3830
|
+
const BAD_STATS = {
|
|
3831
|
+
code: "bad_stats",
|
|
3832
|
+
error: "Race stats must be numbers, true/false or #rrggbb colours."
|
|
3833
|
+
};
|
|
3834
|
+
/** A race room ack. */
|
|
3835
|
+
const RoomAck = z.object({ room: RoomInfo });
|
|
3836
|
+
/** The race ops, by op name. */
|
|
3837
|
+
const raceOps = {
|
|
3838
|
+
"race.create": op({
|
|
3839
|
+
d: z.object({
|
|
3840
|
+
game: Slug,
|
|
3841
|
+
/** The creator's choices; undeclared keys and values fall back to the defaults. */
|
|
3842
|
+
params: RaceParams.optional(),
|
|
3843
|
+
/** Shown in Open races and can alert followers (default true). */
|
|
3844
|
+
public: z.boolean().optional(),
|
|
3845
|
+
/** Seats, within the game's `race.players` (default its min). */
|
|
3846
|
+
maxPlayers: z.number().optional(),
|
|
3847
|
+
/** The caller's party: when they lead it, its join info becomes `{ room: code }`. */
|
|
3848
|
+
partyId: OpaqueId.optional()
|
|
3849
|
+
}),
|
|
3850
|
+
ack: RoomAck,
|
|
3851
|
+
errors: ["not_race", "too_many_rooms"],
|
|
3852
|
+
doc: "Open a race room with the caller seated; the countdown starts once every seat is filled."
|
|
3853
|
+
}),
|
|
3854
|
+
"race.join": op({
|
|
3855
|
+
d: RoomTarget,
|
|
3856
|
+
ack: RoomAck,
|
|
3857
|
+
errors: [
|
|
3858
|
+
"room_not_found",
|
|
3859
|
+
"banned",
|
|
3860
|
+
"in_progress",
|
|
3861
|
+
"room_full",
|
|
3862
|
+
"not_multiplayer",
|
|
3863
|
+
"not_supported"
|
|
3864
|
+
],
|
|
3865
|
+
doc: "Join (or watch) a race room, like `room.join` but only for races."
|
|
3866
|
+
}),
|
|
3867
|
+
"race.list": op({
|
|
3868
|
+
d: z.object({ game: Slug }),
|
|
3869
|
+
ack: z.object({ rooms: z.array(RoomInfo) }),
|
|
3870
|
+
errors: [],
|
|
3871
|
+
doc: "Public races waiting for racers (at most 50), minus rooms with blocked players."
|
|
3872
|
+
}),
|
|
3873
|
+
"race.start": op({
|
|
3874
|
+
d: NoInput,
|
|
3875
|
+
ack: EmptyAck,
|
|
3876
|
+
errors: [
|
|
3877
|
+
"not_in_race",
|
|
3878
|
+
"not_host",
|
|
3879
|
+
"in_progress",
|
|
3880
|
+
"not_ready"
|
|
3881
|
+
],
|
|
3882
|
+
doc: "Creator: start the countdown early once the minimum racers are in."
|
|
3883
|
+
}),
|
|
3884
|
+
"race.status": op({
|
|
3885
|
+
d: RaceReport,
|
|
3886
|
+
ack: EmptyAck,
|
|
3887
|
+
errors: [
|
|
3888
|
+
"not_in_race",
|
|
3889
|
+
"not_started",
|
|
3890
|
+
"race_over",
|
|
3891
|
+
"rate_limited",
|
|
3892
|
+
"bad_stats"
|
|
3893
|
+
],
|
|
3894
|
+
invalid: BAD_STATS,
|
|
3895
|
+
doc: "Live status for the race HUD. Numbers, booleans and #rrggbb only: never text."
|
|
3896
|
+
}),
|
|
3897
|
+
"race.finish": op({
|
|
3898
|
+
d: RaceReport,
|
|
3899
|
+
ack: z.object({ result: RaceResult }),
|
|
3900
|
+
errors: [
|
|
3901
|
+
"not_in_race",
|
|
3902
|
+
"not_started",
|
|
3903
|
+
"race_over",
|
|
3904
|
+
"too_fast",
|
|
3905
|
+
"bad_stats"
|
|
3906
|
+
],
|
|
3907
|
+
invalid: BAD_STATS,
|
|
3908
|
+
doc: "Report a finish with final stats; the first valid finish wins the race."
|
|
3909
|
+
}),
|
|
3910
|
+
"race.lose": op({
|
|
3911
|
+
d: RaceReport,
|
|
3912
|
+
ack: EmptyAck,
|
|
3913
|
+
errors: [
|
|
3914
|
+
"not_in_race",
|
|
3915
|
+
"not_started",
|
|
3916
|
+
"race_over",
|
|
3917
|
+
"bad_stats"
|
|
3918
|
+
],
|
|
3919
|
+
invalid: BAD_STATS,
|
|
3920
|
+
doc: "Game over for this racer (out of lives, stuckβ¦); the last racer left wins."
|
|
3921
|
+
}),
|
|
3922
|
+
"race.forfeit": op({
|
|
3923
|
+
d: NoInput,
|
|
3924
|
+
ack: EmptyAck,
|
|
3925
|
+
errors: ["not_in_race", "race_over"],
|
|
3926
|
+
doc: "Give up (leaving the room mid-race counts the same)."
|
|
3927
|
+
}),
|
|
3928
|
+
"race.rematch": op({
|
|
3929
|
+
d: z.object({
|
|
3930
|
+
/** Creator only: params for the next round. */
|
|
3931
|
+
params: RaceParams.optional() }),
|
|
3932
|
+
ack: EmptyAck,
|
|
3933
|
+
errors: ["not_in_race", "in_progress"],
|
|
3934
|
+
doc: "Ask for another round; it starts when every seated racer has asked."
|
|
3935
|
+
})
|
|
3936
|
+
};
|
|
3937
|
+
//#endregion
|
|
3938
|
+
//#region src/realtime/ops/rooms.ts
|
|
3939
|
+
/**
|
|
3940
|
+
* Hub relay room ops (multiplayer-impl.md Β§6). Rooms hold up to 8 seats;
|
|
3941
|
+
* the hub relays messages and state but never referees, so results are
|
|
3942
|
+
* untrusted. Race rooms refuse `room.start`, `room.state` and `room.end`
|
|
3943
|
+
* (`race_room`): the race ops drive them.
|
|
3944
|
+
*/
|
|
3945
|
+
/** A per-room sequence number ack (`room.send`, `room.state`). */
|
|
3946
|
+
const SeqAck = z.object({ seq: z.number().int().positive() });
|
|
3947
|
+
/** The room ops, by op name. */
|
|
3948
|
+
const roomOps = {
|
|
3949
|
+
"room.create": op({
|
|
3950
|
+
d: z.object({
|
|
3951
|
+
game: Slug,
|
|
3952
|
+
/** One of the game's modes (`race` is refused: use `race.create`). */
|
|
3953
|
+
mode: GameMode.nullish(),
|
|
3954
|
+
/** Listed in `room.list` (default false). */
|
|
3955
|
+
public: z.boolean().optional(),
|
|
3956
|
+
/** Seats; clamped to the game's max (and 8). Defaults to the max. */
|
|
3957
|
+
maxPlayers: z.number().optional(),
|
|
3958
|
+
model: RoomModel.optional(),
|
|
3959
|
+
/** The caller's party: when they lead it, its join info becomes `{ room: code }`. */
|
|
3960
|
+
partyId: OpaqueId.optional()
|
|
3961
|
+
}),
|
|
3962
|
+
ack: z.object({
|
|
3963
|
+
room: RoomInfo,
|
|
3964
|
+
state: RoomState
|
|
3965
|
+
}),
|
|
3966
|
+
errors: [
|
|
3967
|
+
"not_multiplayer",
|
|
3968
|
+
"not_supported",
|
|
3969
|
+
"bad_mode",
|
|
3970
|
+
"too_many_rooms"
|
|
3971
|
+
],
|
|
3972
|
+
doc: "Open a room for a hub-rooms game with the caller seated as host (leaving any other seat)."
|
|
3973
|
+
}),
|
|
3974
|
+
"room.join": op({
|
|
3975
|
+
d: RoomTarget,
|
|
3976
|
+
ack: z.object({
|
|
3977
|
+
room: RoomInfo,
|
|
3978
|
+
state: RoomState,
|
|
3979
|
+
/** Recent messages this member may see, oldest first. */
|
|
3980
|
+
history: z.array(RoomMessage)
|
|
3981
|
+
}),
|
|
3982
|
+
errors: [
|
|
3983
|
+
"room_not_found",
|
|
3984
|
+
"banned",
|
|
3985
|
+
"in_progress",
|
|
3986
|
+
"room_full",
|
|
3987
|
+
"not_multiplayer",
|
|
3988
|
+
"not_supported"
|
|
3989
|
+
],
|
|
3990
|
+
doc: "Take a seat (lobby only), reclaim your own seat (any phase, any tab), or watch."
|
|
3991
|
+
}),
|
|
3992
|
+
"room.leave": op({
|
|
3993
|
+
d: NoInput,
|
|
3994
|
+
ack: EmptyAck,
|
|
3995
|
+
errors: [],
|
|
3996
|
+
doc: "Give up your seat, or stop watching."
|
|
3997
|
+
}),
|
|
3998
|
+
"room.ready": op({
|
|
3999
|
+
d: z.object({ ready: z.boolean() }),
|
|
4000
|
+
ack: EmptyAck,
|
|
4001
|
+
errors: ["not_in_room", "in_progress"],
|
|
4002
|
+
doc: "Mark yourself ready in the lobby."
|
|
4003
|
+
}),
|
|
4004
|
+
"room.seat": op({
|
|
4005
|
+
d: z.object({ data: Json.optional() }),
|
|
4006
|
+
ack: EmptyAck,
|
|
4007
|
+
errors: ["not_in_room", "too_large"],
|
|
4008
|
+
doc: "Set your seat data (team, colourβ¦), at most 2 KB of JSON."
|
|
4009
|
+
}),
|
|
4010
|
+
"room.start": op({
|
|
4011
|
+
d: NoInput,
|
|
4012
|
+
ack: EmptyAck,
|
|
4013
|
+
errors: [
|
|
4014
|
+
"not_in_room",
|
|
4015
|
+
"not_host",
|
|
4016
|
+
"race_room",
|
|
4017
|
+
"in_progress",
|
|
4018
|
+
"not_ready"
|
|
4019
|
+
],
|
|
4020
|
+
doc: "Host: start once the game's minimum players are seated and everyone else is ready."
|
|
4021
|
+
}),
|
|
4022
|
+
"room.send": op({
|
|
4023
|
+
d: z.object({
|
|
4024
|
+
/** The game message, at most 8 KB of JSON. */
|
|
4025
|
+
data: Json.optional(),
|
|
4026
|
+
/** Address one seat; otherwise the room model decides who hears it. */
|
|
4027
|
+
to: UserId.nullish()
|
|
4028
|
+
}),
|
|
4029
|
+
ack: SeqAck,
|
|
4030
|
+
errors: [
|
|
4031
|
+
"not_in_room",
|
|
4032
|
+
"ended",
|
|
4033
|
+
"too_large",
|
|
4034
|
+
"rate_limited",
|
|
4035
|
+
"bad_target"
|
|
4036
|
+
],
|
|
4037
|
+
doc: "Relay a game message (at most 30 a second)."
|
|
4038
|
+
}),
|
|
4039
|
+
"room.state": op({
|
|
4040
|
+
d: z.object({
|
|
4041
|
+
/** Shallow-merged into the state; a `null` value deletes its key. */
|
|
4042
|
+
patch: RoomState,
|
|
4043
|
+
/** Replace the whole state with `patch`. */
|
|
4044
|
+
replace: z.boolean().optional()
|
|
4045
|
+
}),
|
|
4046
|
+
ack: SeqAck,
|
|
4047
|
+
errors: [
|
|
4048
|
+
"not_in_room",
|
|
4049
|
+
"race_room",
|
|
4050
|
+
"not_host",
|
|
4051
|
+
"bad_patch",
|
|
4052
|
+
"too_large"
|
|
4053
|
+
],
|
|
4054
|
+
invalid: {
|
|
4055
|
+
code: "bad_patch",
|
|
4056
|
+
error: "patch must be an object."
|
|
4057
|
+
},
|
|
4058
|
+
doc: "Update the shared state (host only in the host model), at most 32 KB."
|
|
4059
|
+
}),
|
|
4060
|
+
"room.chat": op({
|
|
4061
|
+
d: ChatSend,
|
|
4062
|
+
ack: ChatLine,
|
|
4063
|
+
errors: [
|
|
4064
|
+
"not_in_room",
|
|
4065
|
+
"room_not_found",
|
|
4066
|
+
...CHAT_ERRORS
|
|
4067
|
+
],
|
|
4068
|
+
doc: "Moderated room chat: free text only when every seated player is a mutual friend."
|
|
4069
|
+
}),
|
|
4070
|
+
"room.kick": op({
|
|
4071
|
+
d: z.object({ userId: UserId }),
|
|
4072
|
+
ack: EmptyAck,
|
|
4073
|
+
errors: [
|
|
4074
|
+
"not_in_room",
|
|
4075
|
+
"not_host",
|
|
4076
|
+
"bad_target"
|
|
4077
|
+
],
|
|
4078
|
+
doc: "Host: remove a player; they can't rejoin this room."
|
|
4079
|
+
}),
|
|
4080
|
+
"room.end": op({
|
|
4081
|
+
d: z.object({
|
|
4082
|
+
/** Relayed to everyone in the final `room` event, untrusted; at most 8 KB of JSON. */
|
|
4083
|
+
results: Json.optional() }),
|
|
4084
|
+
ack: EmptyAck,
|
|
4085
|
+
errors: [
|
|
4086
|
+
"not_in_room",
|
|
4087
|
+
"not_host",
|
|
4088
|
+
"race_room",
|
|
4089
|
+
"too_large"
|
|
4090
|
+
],
|
|
4091
|
+
doc: "Host: end the match. Players become recent players; the room closes after a delay."
|
|
4092
|
+
}),
|
|
4093
|
+
"room.list": op({
|
|
4094
|
+
d: z.object({
|
|
4095
|
+
game: Slug,
|
|
4096
|
+
mode: GameMode.optional()
|
|
4097
|
+
}),
|
|
4098
|
+
ack: z.object({ rooms: z.array(RoomInfo) }),
|
|
4099
|
+
errors: [],
|
|
4100
|
+
doc: "Public lobbies with a free seat (at most 50), minus rooms with blocked players."
|
|
4101
|
+
})
|
|
4102
|
+
};
|
|
4103
|
+
//#endregion
|
|
4104
|
+
//#region src/realtime/ops/multiplayer.ts
|
|
4105
|
+
/** The multiplayer ops, by op name. */
|
|
4106
|
+
const multiplayerOps = {
|
|
4107
|
+
...partyOps,
|
|
4108
|
+
...roomOps,
|
|
4109
|
+
...raceOps
|
|
4110
|
+
};
|
|
4111
|
+
//#endregion
|
|
4112
|
+
//#region src/realtime/ops/social.ts
|
|
4113
|
+
/**
|
|
4114
|
+
* Socket ops for presence, visibility, typing and the heartbeat
|
|
4115
|
+
* (multiplayer-impl.md Β§4.3). `hello` is declared with the snapshot.
|
|
4116
|
+
*/
|
|
4117
|
+
/**
|
|
4118
|
+
* `visibility` input: the tab's document visibility and idle state. Input
|
|
4119
|
+
* that isn't an object counts as `{}` (visible, not idle); a field that isn't a
|
|
4120
|
+
* boolean takes its default.
|
|
4121
|
+
*/
|
|
4122
|
+
const VisibilityInput = z.object({
|
|
4123
|
+
/** The document is visible (default `true`). */
|
|
4124
|
+
visible: z.boolean().optional().catch(void 0),
|
|
4125
|
+
/** The player has been idle (default `false`). Hidden or idle tabs count as `away`. */
|
|
4126
|
+
idle: z.boolean().optional().catch(void 0)
|
|
4127
|
+
}).catch({});
|
|
4128
|
+
/** The social ops, by op name. */
|
|
4129
|
+
const socialOps = {
|
|
4130
|
+
presence: op({
|
|
4131
|
+
d: PresenceInput,
|
|
4132
|
+
ack: EmptyAck,
|
|
4133
|
+
errors: ["rate_limited"],
|
|
4134
|
+
doc: "Report this tab's activity. Rate-limited per user; a rejected `detail` is dropped silently."
|
|
4135
|
+
}),
|
|
4136
|
+
visibility: op({
|
|
4137
|
+
d: VisibilityInput,
|
|
4138
|
+
ack: EmptyAck,
|
|
4139
|
+
errors: [],
|
|
4140
|
+
doc: "Report this tab's visibility and idle state (hidden or idle tabs count as `away`)."
|
|
4141
|
+
}),
|
|
4142
|
+
typing: op({
|
|
4143
|
+
d: z.object({
|
|
4144
|
+
/**
|
|
4145
|
+
* The friend you're typing to. Anything that isn't a friend's id is a
|
|
4146
|
+
* silent no-op; a value that isn't a user id at all parses as `0` (no one).
|
|
4147
|
+
*/
|
|
4148
|
+
to: UserId.catch(0) }),
|
|
4149
|
+
ack: EmptyAck,
|
|
4150
|
+
errors: [],
|
|
4151
|
+
doc: "Tell a friend you are typing (`ev:typing`), at most once per 2 s per pair."
|
|
4152
|
+
}),
|
|
4153
|
+
ping: op({
|
|
4154
|
+
d: NoInput,
|
|
4155
|
+
ack: z.object({
|
|
4156
|
+
/** The server clock (epoch ms). */
|
|
4157
|
+
t: Millis }),
|
|
4158
|
+
errors: [],
|
|
4159
|
+
doc: "Heartbeat and clock check; allowed before `hello`."
|
|
4160
|
+
})
|
|
4161
|
+
};
|
|
4162
|
+
//#endregion
|
|
4163
|
+
//#region src/realtime/envelope.ts
|
|
4164
|
+
/**
|
|
4165
|
+
* The legacy `/_ws` envelope (multiplayer-impl.md Β§4.2), unchanged since v1
|
|
4166
|
+
* and kept indefinitely (plan D6):
|
|
4167
|
+
*
|
|
4168
|
+
* ```
|
|
4169
|
+
* client β server {t:'op', id, op, d}
|
|
4170
|
+
* server β client {t:'ack', id, ok:true, d}
|
|
4171
|
+
* {t:'ack', id, ok:false, err: ErrorBody}
|
|
4172
|
+
* {t:'ev', ev, d}
|
|
4173
|
+
* ```
|
|
4174
|
+
*/
|
|
4175
|
+
/** A client's op frame. `id` is the client's own counter, echoed in the ack. */
|
|
4176
|
+
const OpFrame = z.object({
|
|
4177
|
+
t: z.literal("op"),
|
|
4178
|
+
id: z.number(),
|
|
4179
|
+
op: z.string(),
|
|
4180
|
+
d: Json.optional()
|
|
4181
|
+
});
|
|
4182
|
+
/** A successful ack. */
|
|
4183
|
+
const OkAckFrame = z.object({
|
|
4184
|
+
t: z.literal("ack"),
|
|
4185
|
+
id: z.number(),
|
|
4186
|
+
ok: z.literal(true),
|
|
4187
|
+
d: Json
|
|
4188
|
+
});
|
|
4189
|
+
/** A failed ack. */
|
|
4190
|
+
const ErrAckFrame = z.object({
|
|
4191
|
+
t: z.literal("ack"),
|
|
4192
|
+
id: z.number(),
|
|
4193
|
+
ok: z.literal(false),
|
|
4194
|
+
err: ErrorBody
|
|
4195
|
+
});
|
|
4196
|
+
/** An ack frame: the server's answer to one op. */
|
|
4197
|
+
const AckFrame = z.discriminatedUnion("ok", [OkAckFrame, ErrAckFrame]);
|
|
4198
|
+
/** A server-pushed event frame. */
|
|
4199
|
+
const EvFrame = z.object({
|
|
4200
|
+
t: z.literal("ev"),
|
|
4201
|
+
ev: z.string(),
|
|
4202
|
+
d: Json
|
|
4203
|
+
});
|
|
4204
|
+
/** Any frame the server sends. */
|
|
4205
|
+
const ServerFrame = z.union([AckFrame, EvFrame]);
|
|
4206
|
+
/** Close codes the client understands. */
|
|
4207
|
+
const CloseCode = {
|
|
4208
|
+
/** Service restart: reconnect quietly. */
|
|
4209
|
+
restart: 1012,
|
|
4210
|
+
/** The account is suspended. */
|
|
4211
|
+
suspended: 4003,
|
|
4212
|
+
/** Too many sockets for this user or IP. */
|
|
4213
|
+
tooMany: 4029,
|
|
4214
|
+
/** A frame that wasn't a valid op. */
|
|
4215
|
+
badMessage: 4400
|
|
4216
|
+
};
|
|
4217
|
+
/**
|
|
4218
|
+
* Codes any op can fail with, raised by the envelope rather than the op:
|
|
4219
|
+
* the per-socket rate limit, an unknown op name, and ops sent before `hello`.
|
|
4220
|
+
*/
|
|
4221
|
+
const ENVELOPE_ERRORS = [
|
|
4222
|
+
"rate_limited",
|
|
4223
|
+
"unknown_op",
|
|
4224
|
+
"hello_required"
|
|
4225
|
+
];
|
|
4226
|
+
/** Ops the server accepts before `hello`. */
|
|
4227
|
+
const PRE_HELLO_OPS = ["hello", "ping"];
|
|
4228
|
+
//#endregion
|
|
4229
|
+
//#region src/realtime/index.ts
|
|
4230
|
+
/**
|
|
4231
|
+
* The `/_ws` socket contract: the legacy envelope plus every op and event,
|
|
4232
|
+
* by name.
|
|
4233
|
+
*/
|
|
4234
|
+
/** Every socket op, by op name (an intersection type keeps the .d.ts small). */
|
|
4235
|
+
const ops = {
|
|
4236
|
+
...helloOps,
|
|
4237
|
+
...socialOps,
|
|
4238
|
+
...multiplayerOps
|
|
4239
|
+
};
|
|
4240
|
+
/** Every socket event, by event name (an intersection type keeps the .d.ts small). */
|
|
4241
|
+
const events = {
|
|
4242
|
+
...socialEvents,
|
|
4243
|
+
...multiplayerEvents
|
|
4244
|
+
};
|
|
4245
|
+
//#endregion
|
|
4246
|
+
//#region src/manifest/changes.ts
|
|
4247
|
+
/**
|
|
4248
|
+
* `game.changes`: player-facing "What's new" notes (docs/hub.md "What's new").
|
|
4249
|
+
*/
|
|
4250
|
+
/** The text of one note: plain words for a player, at most 280 characters. */
|
|
4251
|
+
const ChangeText = trimmedText(MANIFEST_LIMITS.changeTextMax);
|
|
4252
|
+
/**
|
|
4253
|
+
* One note: a string (identified by its text, so rewording re-announces it)
|
|
4254
|
+
* or `{ id, text }` (identified by `id`, so it can be reworded).
|
|
4255
|
+
*/
|
|
4256
|
+
const ChangeEntry = z.union([ChangeText, z.strictObject({
|
|
4257
|
+
/** A stable id: letters, digits and `_ . : -`, 1 to 64 characters. */
|
|
4258
|
+
id: Key,
|
|
4259
|
+
/** The note. */
|
|
4260
|
+
text: ChangeText
|
|
4261
|
+
})]);
|
|
4262
|
+
/**
|
|
4263
|
+
* The form a string note is identified by: lowercase, whitespace collapsed,
|
|
4264
|
+
* trailing punctuation dropped. Two notes with the same form are duplicates.
|
|
4265
|
+
*/
|
|
4266
|
+
function normalizeChangeText(text) {
|
|
4267
|
+
return text.toLowerCase().replace(/\s+/g, " ").trim().replace(/[\s.!?β¦]+$/u, "");
|
|
4268
|
+
}
|
|
4269
|
+
/** The identity the hub stores a note under. */
|
|
4270
|
+
function noteIdentity(note) {
|
|
4271
|
+
return typeof note === "string" ? `h:${normalizeChangeText(note)}` : `id:${note.id}`;
|
|
4272
|
+
}
|
|
4273
|
+
/** `game.changes`: at most 200 notes, no duplicates. Newest last. */
|
|
4274
|
+
const GameChanges = z.array(ChangeEntry).max(MANIFEST_LIMITS.changesMax).superRefine((notes, ctx) => {
|
|
4275
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4276
|
+
notes.forEach((note, i) => {
|
|
4277
|
+
const id = noteIdentity(note);
|
|
4278
|
+
if (seen.has(id)) ctx.addIssue({
|
|
4279
|
+
code: "custom",
|
|
4280
|
+
message: "duplicate note (same id, or the same text ignoring case and trailing punctuation)",
|
|
4281
|
+
path: [i]
|
|
4282
|
+
});
|
|
4283
|
+
seen.add(id);
|
|
4284
|
+
});
|
|
4285
|
+
});
|
|
4286
|
+
//#endregion
|
|
4287
|
+
//#region src/manifest/controls.ts
|
|
4288
|
+
/**
|
|
4289
|
+
* `game.controls`: the gamepad-to-keyboard map zero-config games use, so a
|
|
4290
|
+
* controller or arcade stick works in a game that never calls
|
|
4291
|
+
* `hub.input.define()` (docs/hub.md "Mapping controls per game").
|
|
4292
|
+
*/
|
|
4293
|
+
/** A `KeyboardEvent.code`, such as `ArrowUp`, `Space`, `KeyZ` or `ShiftLeft`. */
|
|
4294
|
+
const KeyCode = z.string().regex(/^[A-Za-z0-9]{1,32}$/, "must be a KeyboardEvent.code such as ArrowUp, Space or KeyZ");
|
|
4295
|
+
/** A standard-gamepad button index, `"0"` to `"19"`. */
|
|
4296
|
+
const ButtonIndex = z.string().regex(/^(?:1?[0-9])$/, "must be a gamepad button index 0-19");
|
|
4297
|
+
/** One player's map. Unset directions and buttons keep the defaults. */
|
|
4298
|
+
const ControlMap = z.strictObject({
|
|
4299
|
+
/** Key pressed for stick/d-pad up. Default `ArrowUp`. */
|
|
4300
|
+
up: KeyCode.optional(),
|
|
4301
|
+
/** Key pressed for stick/d-pad down. Default `ArrowDown`. */
|
|
4302
|
+
down: KeyCode.optional(),
|
|
4303
|
+
/** Key pressed for stick/d-pad left. Default `ArrowLeft`. */
|
|
4304
|
+
left: KeyCode.optional(),
|
|
4305
|
+
/** Key pressed for stick/d-pad right. Default `ArrowRight`. */
|
|
4306
|
+
right: KeyCode.optional(),
|
|
4307
|
+
/**
|
|
4308
|
+
* Button index β key. Defaults: `0` Space, `1` Enter, `2` KeyZ, `3` KeyX,
|
|
4309
|
+
* `8` Escape, `9` Enter.
|
|
4310
|
+
*/
|
|
4311
|
+
buttons: z.record(ButtonIndex, KeyCode).optional()
|
|
4312
|
+
});
|
|
4313
|
+
/** `game.controls`: one map for every pad, or one per player in `players`. */
|
|
4314
|
+
const GameControls = ControlMap.extend({
|
|
4315
|
+
/** Per-player maps, by gamepad order; pads past the end use the first. Overrides the shared map. */
|
|
4316
|
+
players: z.array(ControlMap).min(1).max(SEAT_LIMITS.maxSeats).optional() });
|
|
4317
|
+
//#endregion
|
|
4318
|
+
//#region src/manifest/leaderboards.ts
|
|
4319
|
+
/**
|
|
4320
|
+
* `game.leaderboards`: boards declared up front, so they show on the catalog
|
|
4321
|
+
* before the first score.
|
|
4322
|
+
*/
|
|
4323
|
+
/** One declared leaderboard. */
|
|
4324
|
+
const LeaderboardDecl = z.strictObject({
|
|
4325
|
+
/** The board key `hub.submitScore(key, score)` uses: letters, digits and `_ . : -`, 1 to 64. */
|
|
4326
|
+
key: Key,
|
|
4327
|
+
/** What players see, at most 80 characters. Defaults to the key. */
|
|
4328
|
+
title: Trimmed.max(MANIFEST_LIMITS.leaderboardTitleMax).optional(),
|
|
4329
|
+
/** Which scores rank first. Default `desc`. */
|
|
4330
|
+
sort: SortDir.default("desc"),
|
|
4331
|
+
/** List players whose best is 0. Default `false`. */
|
|
4332
|
+
showZeros: z.boolean().optional(),
|
|
4333
|
+
/**
|
|
4334
|
+
* `server`: only the game's own server may submit (trusted multiplayer
|
|
4335
|
+
* results); the client endpoint refuses. Default `client`.
|
|
4336
|
+
*/
|
|
4337
|
+
source: z.enum(["client", "server"]).default("client")
|
|
4338
|
+
});
|
|
4339
|
+
/** `game.leaderboards`: unique keys. */
|
|
4340
|
+
const GameLeaderboardDecls = z.array(LeaderboardDecl).refine(hasUniqueKeys, "leaderboard keys must be unique");
|
|
4341
|
+
//#endregion
|
|
4342
|
+
//#region src/manifest/multiplayer.ts
|
|
4343
|
+
/**
|
|
4344
|
+
* `game.multiplayer`: the opt-in that makes a game an online invite/join
|
|
4345
|
+
* target (docs/multiplayer.md Β§7).
|
|
4346
|
+
*/
|
|
4347
|
+
/**
|
|
4348
|
+
* How players connect: `hub-rooms` (the hub relays room messages) or
|
|
4349
|
+
* `own-server` (a process app runs its own game server; process apps only).
|
|
4350
|
+
*/
|
|
4351
|
+
const Transport = z.enum(["hub-rooms", "own-server"]);
|
|
4352
|
+
/** A mode key: letters, digits, `_` or `-`, 1 to 32 characters. */
|
|
4353
|
+
const ModeKey = z.string().regex(/^[A-Za-z0-9_-]{1,32}$/, "must be 1-32 letters, digits, _ or -");
|
|
4354
|
+
/** A game mode players can be invited into. */
|
|
4355
|
+
const MultiplayerMode = z.strictObject({
|
|
4356
|
+
/** Sent with invites and launches as `mode`. */
|
|
4357
|
+
key: ModeKey,
|
|
4358
|
+
/** What players see. Defaults to the key. */
|
|
4359
|
+
title: trimmedText(MANIFEST_LIMITS.modeTitleMax).optional(),
|
|
4360
|
+
/** `[min, max]` players for this mode, within 1..8. Defaults to the game's. */
|
|
4361
|
+
players: playerRange(1).optional()
|
|
4362
|
+
});
|
|
4363
|
+
/**
|
|
4364
|
+
* A soften-list word this game's chat may keep (e.g. `shot` for a pool game):
|
|
4365
|
+
* one lowercase word of 2 to 20 letters. Reject-list words never pass.
|
|
4366
|
+
*/
|
|
4367
|
+
const ChatAllowWord = z.string().trim().toLowerCase().regex(/^[a-z]{2,20}$/, "must be a single word of 2-20 letters");
|
|
4368
|
+
/** The `game.multiplayer` block. */
|
|
4369
|
+
const GameMultiplayer = z.strictObject({
|
|
4370
|
+
/** URL suffix invites and Join open, relative to `/<slug>/`. Default `''` (the game's root). */
|
|
4371
|
+
lobby: LobbySuffix.default(""),
|
|
4372
|
+
/** `[min, max]` players, within 1..8. Default `[2, 8]`. */
|
|
4373
|
+
players: playerRange(1).default([2, 8]),
|
|
4374
|
+
/**
|
|
4375
|
+
* `hub-rooms` or `own-server`. Default: `own-server` for process apps,
|
|
4376
|
+
* `hub-rooms` for static apps (which cannot use `own-server`).
|
|
4377
|
+
*/
|
|
4378
|
+
transport: Transport.optional(),
|
|
4379
|
+
/** Friends can invite each other into this game. Default `true`. */
|
|
4380
|
+
invites: z.boolean().default(true),
|
|
4381
|
+
/** Friends can join a game in progress. Default `true`. */
|
|
4382
|
+
join: z.boolean().default(true),
|
|
4383
|
+
/** Friends can watch. Default `false`. */
|
|
4384
|
+
spectate: z.boolean().default(false),
|
|
4385
|
+
/** Offer quick match. Default `false`. */
|
|
4386
|
+
quickMatch: z.boolean().default(false),
|
|
4387
|
+
/** Modes players can be invited into (at most 16, unique keys). */
|
|
4388
|
+
modes: z.array(MultiplayerMode).max(MANIFEST_LIMITS.modesMax).refine(hasUniqueKeys, "mode keys must be unique").default([]),
|
|
4389
|
+
/**
|
|
4390
|
+
* Extra quick-chat phrases for this game (at most 24, each at most 40
|
|
4391
|
+
* characters). Checked by the content filter at boot; failing ones are dropped.
|
|
4392
|
+
*/
|
|
4393
|
+
quickChat: z.array(trimmedText(MANIFEST_LIMITS.quickChatTextMax)).max(MANIFEST_LIMITS.quickChatMax).default([]),
|
|
4394
|
+
/** Soften-list words this game's chat may keep (at most 20). */
|
|
4395
|
+
chatAllow: z.array(ChatAllowWord).max(MANIFEST_LIMITS.chatAllowMax).default([])
|
|
4396
|
+
});
|
|
4397
|
+
/** `game.multiplayer` for a static app: `own-server` needs a process app. */
|
|
4398
|
+
const StaticGameMultiplayer = GameMultiplayer.refine((multiplayer) => multiplayer.transport !== "own-server", {
|
|
4399
|
+
message: "own-server needs a process app; static apps use hub-rooms",
|
|
4400
|
+
path: ["transport"]
|
|
4401
|
+
});
|
|
4402
|
+
//#endregion
|
|
4403
|
+
//#region src/manifest/game.ts
|
|
4404
|
+
/**
|
|
4405
|
+
* The `game` block of an app's `package.json`: how the host builds, serves
|
|
4406
|
+
* and shows the game (HANDOFF.md "The game block").
|
|
4407
|
+
*
|
|
4408
|
+
* This is the **strict** authoring schema `hub-doctor` runs in a game: a
|
|
4409
|
+
* mistake fails the parse. The host stays lenient at boot (a bad field falls
|
|
4410
|
+
* back to its default with a warning), so one typo never takes a game down.
|
|
4411
|
+
*/
|
|
4412
|
+
/** The corner the injected hub menu button sits in. Default `top-left`. */
|
|
4413
|
+
const MenuPosition = z.enum([
|
|
4414
|
+
"top-left",
|
|
4415
|
+
"top-right",
|
|
4416
|
+
"bottom-left",
|
|
4417
|
+
"bottom-right"
|
|
4418
|
+
]);
|
|
4419
|
+
/** Pixels to push the menu button away from its corner, 0 to 600. */
|
|
4420
|
+
const OffsetPx = z.number().int().min(0).max(MANIFEST_LIMITS.menuOffsetMax);
|
|
4421
|
+
/** The menu offset as the host sends it: both axes, in px. */
|
|
4422
|
+
const MenuOffsetXY = z.strictObject({
|
|
4423
|
+
/** Inward from the left or right edge, px. */
|
|
4424
|
+
x: OffsetPx,
|
|
4425
|
+
/** Down from a top corner or up from a bottom one, px. */
|
|
4426
|
+
y: OffsetPx
|
|
4427
|
+
});
|
|
4428
|
+
/**
|
|
4429
|
+
* `game.menuOffset`: how far to push the menu button away from its corner so
|
|
4430
|
+
* it clears the game's own bars. A number moves it vertically only.
|
|
4431
|
+
*/
|
|
4432
|
+
const MenuOffset = z.union([OffsetPx, MenuOffsetXY.partial()]);
|
|
4433
|
+
/** Fields both app types share. */
|
|
4434
|
+
const commonFields = {
|
|
4435
|
+
/** Shown on the catalog card and in the menu. */
|
|
4436
|
+
title: Trimmed,
|
|
4437
|
+
/** Catalog, search and link-preview text. Falls back to `package.json` `description`. */
|
|
4438
|
+
description: Trimmed.optional(),
|
|
4439
|
+
/**
|
|
4440
|
+
* Credit shown on the tile, e.g. `"Mason & Phil"` (split into authors for the
|
|
4441
|
+
* catalog filter). Falls back to `package.json` `author`.
|
|
4442
|
+
*/
|
|
4443
|
+
author: z.string().trim().min(1).optional(),
|
|
4444
|
+
/** Runs at boot when the source changed. May use `{{PORT}}`, `{{GAME_PATH}}`, `{{GAME_SLUG}}`. */
|
|
4445
|
+
build: Command.optional(),
|
|
4446
|
+
/** Tile and preview image, relative to the project root (not `serveDir`); must stay inside it. */
|
|
4447
|
+
image: AppFilePath.optional(),
|
|
4448
|
+
/** `true` leaves the app out of the catalog entirely. */
|
|
4449
|
+
hidden: z.boolean().optional(),
|
|
4450
|
+
/** `false` opts out of the injected hub menu and SDK. */
|
|
4451
|
+
hub: z.boolean().optional(),
|
|
4452
|
+
/** `false` opts out of the PWA install tags and service worker. */
|
|
4453
|
+
pwa: z.boolean().optional(),
|
|
4454
|
+
/** The corner the hub menu button sits in. Default `top-left`. */
|
|
4455
|
+
menuPosition: MenuPosition.optional(),
|
|
4456
|
+
/** Push the menu button away from its corner, px: a number (vertical) or `{ x, y }`. */
|
|
4457
|
+
menuOffset: MenuOffset.optional(),
|
|
4458
|
+
/** `true` opts into the shared daily challenges (`hub.daily.define(...)`). */
|
|
4459
|
+
dailyChallenges: z.boolean().optional(),
|
|
4460
|
+
/** Boards to create up front, so they show before the first score. */
|
|
4461
|
+
leaderboards: GameLeaderboardDecls.optional(),
|
|
4462
|
+
/** The gamepad-to-keyboard map for games that never call `hub.input.define()`. */
|
|
4463
|
+
controls: GameControls.optional(),
|
|
4464
|
+
/** Turns a single-player game into a race target; `true` for all defaults, `false` for off. */
|
|
4465
|
+
race: GameRace,
|
|
4466
|
+
/** Player-facing "What's new" notes, one per change, newest last. Keep it updated. */
|
|
4467
|
+
changes: GameChanges.optional()
|
|
4468
|
+
};
|
|
4469
|
+
/** A static app: the build emits a folder the host serves. */
|
|
4470
|
+
const StaticGame = z.strictObject({
|
|
4471
|
+
/** `static`: no running process. */
|
|
4472
|
+
type: z.literal("static"),
|
|
4473
|
+
/** The folder served at `/<slug>/`; must exist after `build`. */
|
|
4474
|
+
serveDir: Trimmed,
|
|
4475
|
+
/** Makes the game an online invite/join target (`hub-rooms` only); `false` for off. */
|
|
4476
|
+
multiplayer: orFalse(StaticGameMultiplayer),
|
|
4477
|
+
...commonFields
|
|
4478
|
+
});
|
|
4479
|
+
/** A process app: the host runs a long-lived server and proxies to it. */
|
|
4480
|
+
const ProcessGame = z.strictObject({
|
|
4481
|
+
/** `process`: the host starts `start` and proxies `/<slug>/` to it. */
|
|
4482
|
+
type: z.literal("process"),
|
|
4483
|
+
/** The long-lived server command; must listen on the assigned port (`{{PORT}}` or `PORT`). */
|
|
4484
|
+
start: Command,
|
|
4485
|
+
/** The path the host polls until it answers below 500. Default `/`. */
|
|
4486
|
+
healthPath: Trimmed.startsWith("/").optional(),
|
|
4487
|
+
/** Makes the game an online invite/join target; `false` for off. */
|
|
4488
|
+
multiplayer: orFalse(GameMultiplayer),
|
|
4489
|
+
...commonFields
|
|
4490
|
+
});
|
|
4491
|
+
/** The `game` block of a game's `package.json`. */
|
|
4492
|
+
const GameManifest = z.discriminatedUnion("type", [StaticGame, ProcessGame]);
|
|
4493
|
+
/** A game's `package.json`: the `game` block plus the npm fields the host falls back to. */
|
|
4494
|
+
const GamePackageJson = z.looseObject({
|
|
4495
|
+
/** The npm name; the title fallback. */
|
|
4496
|
+
name: z.string().optional(),
|
|
4497
|
+
/** The description fallback. */
|
|
4498
|
+
description: z.string().optional(),
|
|
4499
|
+
/** The author fallback: npm's string or `{ name }` form. */
|
|
4500
|
+
author: z.union([z.string(), z.looseObject({ name: z.string().optional() })]).optional(),
|
|
4501
|
+
/** How the host treats the app. */
|
|
4502
|
+
game: GameManifest
|
|
4503
|
+
});
|
|
4504
|
+
/**
|
|
4505
|
+
* The host repo's own `game` block: only its "What's new" notes (shown as
|
|
4506
|
+
* "diffenderfer.games"). It is not an app, so it has no `type`.
|
|
4507
|
+
*/
|
|
4508
|
+
const HostManifest = z.strictObject({
|
|
4509
|
+
/** The host's display name. */
|
|
4510
|
+
title: Trimmed.optional(),
|
|
4511
|
+
/** The host's own notes (menu, friends, home page). */
|
|
4512
|
+
changes: GameChanges.optional()
|
|
4513
|
+
});
|
|
4514
|
+
//#endregion
|
|
4515
|
+
//#region src/manifest/inject.ts
|
|
4516
|
+
/**
|
|
4517
|
+
* `window.__HUB__`: the small config object the host writes into every page
|
|
4518
|
+
* it injects (game pages, and the catalog page). The SDK and the menu read it
|
|
4519
|
+
* to find the game's slug, the API base and the menu placement.
|
|
4520
|
+
*
|
|
4521
|
+
* The same inline script may also set `window.__hubDailyDeep` (a
|
|
4522
|
+
* `?daily=YYYY-MM-DD` deep link captured before game code runs) and
|
|
4523
|
+
* `window.__hubInstall` (the PWA install prompt); those are not part of this
|
|
4524
|
+
* object.
|
|
4525
|
+
*/
|
|
4526
|
+
/** The injected `window.__HUB__` config. */
|
|
4527
|
+
const HubInject = z.strictObject({
|
|
4528
|
+
/** The game's slug. Missing on the catalog page. */
|
|
4529
|
+
slug: Slug.optional(),
|
|
4530
|
+
/** Where the REST API lives. Always `/_api` today. */
|
|
4531
|
+
apiBase: z.literal("/_api"),
|
|
4532
|
+
/** The menu button's corner (from `game.menuPosition`). Missing on the catalog page. */
|
|
4533
|
+
menu: MenuPosition.optional(),
|
|
4534
|
+
/** The menu button's offset from its corner (from `game.menuOffset`); only sent when not 0,0. */
|
|
4535
|
+
menuOffset: MenuOffsetXY.optional(),
|
|
4536
|
+
/** The game's `game.controls`, as written; only sent when set. */
|
|
4537
|
+
controls: GameControls.optional()
|
|
4538
|
+
});
|
|
4539
|
+
//#endregion
|
|
4540
|
+
//#region src/logic/daily.ts
|
|
4541
|
+
/**
|
|
4542
|
+
* Daily-challenge day keys and seeds: UTC `YYYY-MM-DD` keys, the playable
|
|
4543
|
+
* window, and the deterministic per-(game, day) seed both the hub and the
|
|
4544
|
+
* game compute. Pure; the signed tokens stay server-only.
|
|
4545
|
+
*/
|
|
4546
|
+
const DAY_MS = 864e5;
|
|
4547
|
+
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
4548
|
+
/** The UTC day key (`YYYY-MM-DD`) of a timestamp. */
|
|
4549
|
+
function dayKey(ts) {
|
|
4550
|
+
return new Date(ts).toISOString().slice(0, 10);
|
|
4551
|
+
}
|
|
4552
|
+
/**
|
|
4553
|
+
* The playable window, newest first: today and the `count - 1` days before it.
|
|
4554
|
+
* @param count - how many days (the back-fill window, 30 by default on the hub)
|
|
4555
|
+
*/
|
|
4556
|
+
function windowDays(now, count) {
|
|
4557
|
+
return Array.from({ length: count }, (_, i) => dayKey(now - i * DAY_MS));
|
|
4558
|
+
}
|
|
4559
|
+
/**
|
|
4560
|
+
* Is `day` playable? A player's local "today" can be UTCΒ±1, so one day into
|
|
4561
|
+
* the future is accepted, plus `backfillDays` into the past. Compared in
|
|
4562
|
+
* whole UTC days, so it is locale-independent.
|
|
4563
|
+
*/
|
|
4564
|
+
function inWindow(day, now, backfillDays) {
|
|
4565
|
+
if (!DAY_RE.test(day)) return false;
|
|
4566
|
+
const dayStart = Date.parse(`${day}T00:00:00Z`);
|
|
4567
|
+
const today = Date.parse(`${dayKey(now)}T00:00:00Z`);
|
|
4568
|
+
if (Number.isNaN(dayStart) || Number.isNaN(today)) return false;
|
|
4569
|
+
const diff = Math.round((dayStart - today) / DAY_MS);
|
|
4570
|
+
return diff <= 1 && diff >= -backfillDays;
|
|
4571
|
+
}
|
|
4572
|
+
/**
|
|
4573
|
+
* The deterministic uint32 seed for a (game, day): FNV-1a over `game#day`.
|
|
4574
|
+
* The game derives its level or difficulty from it, so everyone plays the
|
|
4575
|
+
* same challenge.
|
|
4576
|
+
*/
|
|
4577
|
+
function seedForDay(game, day) {
|
|
4578
|
+
let hash = 2166136261;
|
|
4579
|
+
const seedInput = `${game}#${day}`;
|
|
4580
|
+
for (let i = 0; i < seedInput.length; i++) {
|
|
4581
|
+
hash ^= seedInput.charCodeAt(i);
|
|
4582
|
+
hash = Math.imul(hash, 16777619);
|
|
4583
|
+
}
|
|
4584
|
+
return hash >>> 0;
|
|
4585
|
+
}
|
|
4586
|
+
//#endregion
|
|
4587
|
+
//#region src/logic/time.ts
|
|
4588
|
+
/**
|
|
4589
|
+
* Time and count wording shared by every hub UI (the social drawer, the menu,
|
|
4590
|
+
* the home page, race cards). One copy, so "3h ago" reads the same everywhere
|
|
4591
|
+
* (plan Β§8.4: the menu and the catalog page each had their own).
|
|
4592
|
+
*/
|
|
4593
|
+
const padTwoDigits = (count) => String(count).padStart(2, "0");
|
|
4594
|
+
/**
|
|
4595
|
+
* `m:ss` (or `h:mm:ss`) elapsed since `since`.
|
|
4596
|
+
* @example elapsed(0, 252_000) // "4:12"
|
|
4597
|
+
*/
|
|
4598
|
+
function elapsed(since, now) {
|
|
4599
|
+
const total = Math.max(0, Math.floor((now - since) / 1e3));
|
|
4600
|
+
const hours = Math.floor(total / 3600);
|
|
4601
|
+
const minutes = Math.floor(total % 3600 / 60);
|
|
4602
|
+
const paddedSeconds = padTwoDigits(total % 60);
|
|
4603
|
+
return hours ? `${String(hours)}:${padTwoDigits(minutes)}:${paddedSeconds}` : `${String(minutes)}:${paddedSeconds}`;
|
|
4604
|
+
}
|
|
4605
|
+
/**
|
|
4606
|
+
* "just now", "5m ago", "3h ago", "2d ago", then a short date (in the
|
|
4607
|
+
* viewer's locale) after a week.
|
|
4608
|
+
*/
|
|
4609
|
+
function relativeTime(then, now) {
|
|
4610
|
+
const seconds = Math.max(0, Math.floor((now - then) / 1e3));
|
|
4611
|
+
if (seconds < 60) return "just now";
|
|
4612
|
+
if (seconds < 3600) return `${String(Math.floor(seconds / 60))}m ago`;
|
|
4613
|
+
if (seconds < 86400) return `${String(Math.floor(seconds / 3600))}h ago`;
|
|
4614
|
+
if (seconds < 604800) return `${String(Math.floor(seconds / 86400))}d ago`;
|
|
4615
|
+
return new Date(then).toLocaleDateString(void 0, {
|
|
4616
|
+
month: "short",
|
|
4617
|
+
day: "numeric"
|
|
4618
|
+
});
|
|
4619
|
+
}
|
|
4620
|
+
/**
|
|
4621
|
+
* A seconds countdown for mutes and rate limits: "42s" or "3:05".
|
|
4622
|
+
* @param ms - time left, in ms
|
|
4623
|
+
*/
|
|
4624
|
+
function countdown(ms) {
|
|
4625
|
+
const seconds = Math.max(0, Math.ceil(ms / 1e3));
|
|
4626
|
+
return seconds < 60 ? `${String(seconds)}s` : `${String(Math.floor(seconds / 60))}:${padTwoDigits(seconds % 60)}`;
|
|
4627
|
+
}
|
|
4628
|
+
/** Badge text: `''` for 0, the number up to 9, then `'9+'`. */
|
|
4629
|
+
function badgeText(count) {
|
|
4630
|
+
if (count <= 0) return "";
|
|
4631
|
+
return count > 9 ? "9+" : String(count);
|
|
4632
|
+
}
|
|
4633
|
+
/**
|
|
4634
|
+
* When a suspension or mute ends, for the "can't use online play" notice.
|
|
4635
|
+
* `''` when there is no end (0, or a permanent suspension far in the future).
|
|
4636
|
+
*/
|
|
4637
|
+
function untilText(until) {
|
|
4638
|
+
if (!until || until > 8e15) return "";
|
|
4639
|
+
return new Date(until).toLocaleString(void 0, {
|
|
4640
|
+
month: "short",
|
|
4641
|
+
day: "numeric",
|
|
4642
|
+
hour: "numeric",
|
|
4643
|
+
minute: "2-digit"
|
|
4644
|
+
});
|
|
4645
|
+
}
|
|
4646
|
+
/**
|
|
4647
|
+
* A race clock: `83_400` β `"1:23.4"`; past an hour `"1:02:03"`.
|
|
4648
|
+
* @param ms - elapsed race time, in ms
|
|
4649
|
+
*/
|
|
4650
|
+
function raceClock(ms) {
|
|
4651
|
+
const tenths = Math.max(0, Math.floor(ms / 100));
|
|
4652
|
+
const seconds = Math.floor(tenths / 10);
|
|
4653
|
+
const hours = Math.floor(seconds / 3600);
|
|
4654
|
+
const minutes = Math.floor(seconds % 3600 / 60);
|
|
4655
|
+
const paddedSeconds = padTwoDigits(seconds % 60);
|
|
4656
|
+
return hours ? `${String(hours)}:${padTwoDigits(minutes)}:${paddedSeconds}` : `${String(minutes)}:${paddedSeconds}.${String(tenths % 10)}`;
|
|
4657
|
+
}
|
|
4658
|
+
//#endregion
|
|
4659
|
+
//#region src/logic/presence.ts
|
|
4660
|
+
/** Status line pieces for a friend; `p` is `null` when they are offline. */
|
|
4661
|
+
function statusParts(presence, now, lastSeenAt = 0) {
|
|
4662
|
+
const base = {
|
|
4663
|
+
game: "",
|
|
4664
|
+
detail: "",
|
|
4665
|
+
elapsed: "",
|
|
4666
|
+
online: true,
|
|
4667
|
+
joinable: false,
|
|
4668
|
+
watchable: false
|
|
4669
|
+
};
|
|
4670
|
+
if (!presence) return {
|
|
4671
|
+
...base,
|
|
4672
|
+
online: false,
|
|
4673
|
+
dot: "βͺ",
|
|
4674
|
+
label: lastSeenAt ? `Last seen ${relativeTime(lastSeenAt, now)}` : "Offline"
|
|
4675
|
+
};
|
|
4676
|
+
const game = presence.gameTitle ?? "";
|
|
4677
|
+
const detail = presence.detail ?? "";
|
|
4678
|
+
const hasRoom = presence.room !== void 0 && presence.room !== "";
|
|
4679
|
+
const flags = {
|
|
4680
|
+
joinable: presence.joinable === true && hasRoom,
|
|
4681
|
+
watchable: presence.watchable === true && hasRoom
|
|
4682
|
+
};
|
|
4683
|
+
switch (presence.kind) {
|
|
4684
|
+
case "playing": return {
|
|
4685
|
+
...base,
|
|
4686
|
+
...flags,
|
|
4687
|
+
dot: "π’",
|
|
4688
|
+
label: "Playing",
|
|
4689
|
+
game,
|
|
4690
|
+
detail,
|
|
4691
|
+
elapsed: elapsed(presence.since, now)
|
|
4692
|
+
};
|
|
4693
|
+
case "watching": return {
|
|
4694
|
+
...base,
|
|
4695
|
+
...flags,
|
|
4696
|
+
dot: "π’",
|
|
4697
|
+
label: "Watching",
|
|
4698
|
+
game,
|
|
4699
|
+
detail
|
|
4700
|
+
};
|
|
4701
|
+
case "lobby": return {
|
|
4702
|
+
...base,
|
|
4703
|
+
...flags,
|
|
4704
|
+
dot: "π ",
|
|
4705
|
+
label: "In lobby",
|
|
4706
|
+
game,
|
|
4707
|
+
detail
|
|
4708
|
+
};
|
|
4709
|
+
case "away": return {
|
|
4710
|
+
...base,
|
|
4711
|
+
dot: "π‘",
|
|
4712
|
+
label: "Away",
|
|
4713
|
+
game
|
|
4714
|
+
};
|
|
4715
|
+
case "menu": return {
|
|
4716
|
+
...base,
|
|
4717
|
+
dot: "π’",
|
|
4718
|
+
label: "Online",
|
|
4719
|
+
game
|
|
4720
|
+
};
|
|
4721
|
+
}
|
|
4722
|
+
}
|
|
4723
|
+
/** The status line as one string, e.g. "π’ Playing 8 Ball Pool Β· Table vs Sam Β· 4:12". */
|
|
4724
|
+
function statusText(parts) {
|
|
4725
|
+
const head = parts.label === "Playing" || parts.label === "Watching" ? [parts.game ? `${parts.label} ${parts.game}` : parts.label] : [parts.label, parts.game];
|
|
4726
|
+
return `${parts.dot} ${[
|
|
4727
|
+
...head,
|
|
4728
|
+
parts.detail,
|
|
4729
|
+
parts.elapsed
|
|
4730
|
+
].filter(Boolean).join(" Β· ")}`;
|
|
4731
|
+
}
|
|
4732
|
+
/** The drawer header: "π₯ 3 friends online Β· 41 playing now". */
|
|
4733
|
+
function headerLine(friendsOnline, online) {
|
|
4734
|
+
return `π₯ ${String(friendsOnline)} friend${friendsOnline === 1 ? "" : "s"} online Β· ${String(online)} playing now`;
|
|
4735
|
+
}
|
|
4736
|
+
//#endregion
|
|
4737
|
+
//#region src/logic/race.ts
|
|
4738
|
+
/** A stat value for display, per its declared format. Unknown or absent values show as `'β'`. */
|
|
4739
|
+
function formatStat(value, format) {
|
|
4740
|
+
if (value === void 0) return "β";
|
|
4741
|
+
switch (format) {
|
|
4742
|
+
case "percent": return typeof value === "number" ? `${String(Math.round(Math.max(0, Math.min(1, value)) * 100))}%` : "β";
|
|
4743
|
+
case "time": return typeof value === "number" ? raceClock(value) : "β";
|
|
4744
|
+
case "bool": return typeof value === "boolean" ? value ? "β" : "β" : "β";
|
|
4745
|
+
case "color": return typeof value === "string" ? "β" : "β";
|
|
4746
|
+
case "int":
|
|
4747
|
+
if (typeof value === "number") return String(Math.round(value));
|
|
4748
|
+
return typeof value === "boolean" ? value ? "β" : "β" : "β";
|
|
4749
|
+
}
|
|
4750
|
+
}
|
|
4751
|
+
/** The declared stats with their values (the HUD shows at most `max`). */
|
|
4752
|
+
function shownStats(spec, stats, max = 3) {
|
|
4753
|
+
return spec ? spec.stats.slice(0, max).map((s) => ({
|
|
4754
|
+
spec: s,
|
|
4755
|
+
value: stats[s.key]
|
|
4756
|
+
})) : [];
|
|
4757
|
+
}
|
|
4758
|
+
/** "Board 4x4 Β· Level Hard" from the manifest's option titles; unknown values are skipped. */
|
|
4759
|
+
function paramsText(spec, params) {
|
|
4760
|
+
if (!spec) return "";
|
|
4761
|
+
const parts = [];
|
|
4762
|
+
for (const param of spec.params) {
|
|
4763
|
+
const matchingOption = param.options.find((option) => option.value === params[param.key]);
|
|
4764
|
+
if (matchingOption) parts.push(param.title === matchingOption.title ? matchingOption.title : `${param.title} ${matchingOption.title}`);
|
|
4765
|
+
}
|
|
4766
|
+
return parts.join(" Β· ");
|
|
4767
|
+
}
|
|
4768
|
+
/** A racer's live state as a short tag (`''` while racing and connected). */
|
|
4769
|
+
function racerStateTag(state, isConnected) {
|
|
4770
|
+
switch (state) {
|
|
4771
|
+
case "finished": return "π Finished";
|
|
4772
|
+
case "lost": return "Out";
|
|
4773
|
+
case "forfeit": return "Gave up";
|
|
4774
|
+
case "disconnected": return "Dropped out";
|
|
4775
|
+
case "racing": return isConnected ? "" : "Reconnectingβ¦";
|
|
4776
|
+
}
|
|
4777
|
+
}
|
|
4778
|
+
const RESULT_LABEL = {
|
|
4779
|
+
won: "π Won",
|
|
4780
|
+
beaten: "Didn't finish",
|
|
4781
|
+
lost: "Out",
|
|
4782
|
+
forfeit: "Gave up",
|
|
4783
|
+
disconnected: "Dropped out",
|
|
4784
|
+
timeout: "Time's up"
|
|
4785
|
+
};
|
|
4786
|
+
/** A racer's line in the result card and history. */
|
|
4787
|
+
function resultLabel(racerResult) {
|
|
4788
|
+
return RESULT_LABEL[racerResult];
|
|
4789
|
+
}
|
|
4790
|
+
/** The result card's title from my point of view (`me` is `null` when watching). */
|
|
4791
|
+
function resultTitle(result, me) {
|
|
4792
|
+
const winner = result.players.find((player) => player.userId === result.winnerId);
|
|
4793
|
+
if (!winner) return "Time's up!";
|
|
4794
|
+
return winner.userId === me ? "You won! π" : `${winner.name} won`;
|
|
4795
|
+
}
|
|
4796
|
+
/** Names joined for a sentence: "Sam", "Sam and Ali", "Sam, Ali and Jo". */
|
|
4797
|
+
function nameList(names) {
|
|
4798
|
+
const last = names.at(-1);
|
|
4799
|
+
if (names.length <= 1 || last === void 0) return names[0] ?? "Everyone";
|
|
4800
|
+
return `${names.slice(0, -1).join(", ")} and ${last}`;
|
|
4801
|
+
}
|
|
4802
|
+
/** How the race ended, in one friendly sentence, from my point of view. */
|
|
4803
|
+
function outcomeLine(result, me) {
|
|
4804
|
+
const winner = result.players.find((player) => player.userId === result.winnerId);
|
|
4805
|
+
if (!winner) return "Nobody finished before the time ran out.";
|
|
4806
|
+
const mine = result.players.find((player) => player.userId === me);
|
|
4807
|
+
const others = result.players.filter((player) => player.userId !== result.winnerId);
|
|
4808
|
+
const namesWithOutcome = (outcome) => others.filter((player) => player.result === outcome).map((player) => player.name);
|
|
4809
|
+
const didIWin = winner.userId === me;
|
|
4810
|
+
if (result.outcome === "finished") return `${didIWin ? "You" : winner.name} finished first in ${raceClock(winner.ms)}.`;
|
|
4811
|
+
if (mine && !didIWin) {
|
|
4812
|
+
if (mine.result === "forfeit") return "You gave up.";
|
|
4813
|
+
if (mine.result === "lost") return "You're out β better luck next race!";
|
|
4814
|
+
if (mine.result === "disconnected") return "Your connection dropped.";
|
|
4815
|
+
}
|
|
4816
|
+
const lost = namesWithOutcome("lost");
|
|
4817
|
+
const gaveUp = namesWithOutcome("forfeit");
|
|
4818
|
+
const dropped = namesWithOutcome("disconnected");
|
|
4819
|
+
const bits = [];
|
|
4820
|
+
if (gaveUp.length > 0) bits.push(`${nameList(gaveUp)} gave up`);
|
|
4821
|
+
if (lost.length > 0) bits.push(`${nameList(lost)} ${lost.length === 1 ? "is" : "are"} out`);
|
|
4822
|
+
if (dropped.length > 0) bits.push(`${nameList(dropped)} dropped out`);
|
|
4823
|
+
const why = bits.length > 0 ? bits.join(", ") : "Everyone else is out";
|
|
4824
|
+
return `${why.charAt(0).toUpperCase()}${why.slice(1)} β ${didIWin ? "you win" : `${winner.name} wins`}!`;
|
|
4825
|
+
}
|
|
4826
|
+
const HISTORY_VERB = {
|
|
4827
|
+
won: "Won",
|
|
4828
|
+
beaten: "Lost",
|
|
4829
|
+
lost: "Out",
|
|
4830
|
+
forfeit: "Gave up",
|
|
4831
|
+
disconnected: "Dropped out",
|
|
4832
|
+
timeout: "Time's up"
|
|
4833
|
+
};
|
|
4834
|
+
/** My race's headline verb in the history ("Won vs Sam", "Lost vs Sam"). */
|
|
4835
|
+
function historyVerb(racerResult) {
|
|
4836
|
+
return HISTORY_VERB[racerResult];
|
|
4837
|
+
}
|
|
4838
|
+
/** True when a racer's time means something (they finished or dropped out themselves). */
|
|
4839
|
+
function hasOwnTime(racerResult) {
|
|
4840
|
+
return racerResult !== "beaten" && racerResult !== "timeout";
|
|
4841
|
+
}
|
|
4842
|
+
/** The history badge for my result. */
|
|
4843
|
+
function historyBadge(racerResult) {
|
|
4844
|
+
switch (racerResult) {
|
|
4845
|
+
case "won": return "π";
|
|
4846
|
+
case "forfeit": return "π³οΈ";
|
|
4847
|
+
case "disconnected": return "πΆ";
|
|
4848
|
+
case "timeout": return "β±οΈ";
|
|
4849
|
+
default: return "π";
|
|
4850
|
+
}
|
|
4851
|
+
}
|
|
4852
|
+
//#endregion
|
|
4853
|
+
export { ACCOUNT_LIMITS, AckFrame, AppFilePath, Audience, AuthResult, AuthUser, Board, Bundle, BundleInput, ButtonIndex, CHAT_ERRORS, COMMAND_PLACEHOLDERS, ChangeEntry, ChangeNote, ChangeText, ChatAllowWord, ChatLine, ChatLineEvent, ChatMode, ChatSend, CloseCode, Command, ContentRejected, ControlMap, Conversation, Counts, Credentials, DailyCompletion, DailyCompletions, DayKey, ENVELOPE_ERRORS, ErrAckFrame, ErrorBody, ErrorCode, EvFrame, FriendChange, FriendEntry, FriendRequestInput, FriendRequestStatus, FriendRequests, FriendsList, GameChanges, GameControls, GameInfo, GameLeaderboardDecls, GameLeaderboards, GameManifest, GameMode, GameMultiplayer, GamePackageJson, GameRace, GameRecords, GameSnapshot, GameTicket, HexColor, HostManifest, HubInject, HubVersion, IdParam, Inventory, Invite, InviteCreated, InviteStatus, JoinInfo, Json, Key, KeyCode, Launch, LaunchKind, LaunchTicket, LeaderboardDecl, LeaderboardEntry, LobbySuffix, MANIFEST_LIMITS, MAX_BUNDLE_ITEMS, MAX_ITEM_QTY, MenuOffset, MenuOffsetXY, MenuPosition, Message, MessageId, MessagesPage, Metrics, Millis, ModeKey, MultiplayerMode, MultiplayerSnapshot, MultiplayerSummary, NotifyChange, NotifyPrefKind, NotifyPrefs, Ok, OkAckFrame, OpFrame, OpaqueId, PRE_HELLO_OPS, Party, PlayerCard, PlayerLite, PlayerRange, Presence, PresenceInput, PresenceKind, PresenceMode, PresenceRoom, ProcessGame, Profile, PushSubscriptionJson, QuickId, RACE_STATS_LIMITS, RaceBlock, RaceHistory, RaceHistoryQuery, RaceKey, RaceOption, RaceOutcome, RaceParam, RaceParamValue, RaceParams, RacePhase, RaceProgress, RaceRecord, RaceRecordPlayer, RaceResult, RaceResultPlayer, RaceStart, RaceStat, RaceStatFormat, RaceStatValue, RaceState, RaceStats, RaceSummary, Racer, RacerResult, RacerState, Rank, RecentPlayer, RejectReason, Relation, ReportInput, ReportKind, ReportReason, ReportSnapshot, ReportedMessage, Role, RoomClosedReason, RoomCode, RoomInfo, RoomMessage, RoomModel, RoomPhase, RoomSeat, RoomState, RoomTarget, SEAT_LIMITS, Save, SaveMeta, SeatGoneReason, SendMessageInput, ServerFrame, SessionUser, Slug, SocialConfig, SocialLimits, SocialMe, SocialMeResult, SortDir, StatMode, StaticGame, StaticGameMultiplayer, Stats, Suggestion, SuggestionId, SuggestionStatus, TopQuery, TradeOffer, TradePlayer, TradeStatus, Transport, UI_LIMIT_DEFAULTS, UNSUPPORTED_SUBSCRIPTION, USERNAME_PATTERN, UiLimits, UnreadSummary, UserId, UserIdParam, VersionRange, VoteResult, analyticsEndpoints, authEndpoints, badgeText, bundleInput, catalogEndpoints, countdown, dailyEndpoints, dayKey, economyEndpoints, elapsed, event, events, formatStat, game, hasOwnTime, headerLine, historyBadge, historyVerb, inWindow, leaderboardsEndpoints, messagesEndpoints, mpEndpoints, nameList, normalizeChangeText, notifyEndpoints, op, ops, outcomeLine, paramsText, progressEndpoints, publicApi, publicEndpoints, raceClock, raceEndpoints, racerStateTag, relativeTime, reportsEndpoints, requireActive, requireClaimed, requireTrader, requireUser, resultLabel, resultTitle, seedForDay, session, shownStats, socialEndpoints, statusParts, statusText, untilText, versionEndpoints, windowDays };
|
|
4854
|
+
|
|
4855
|
+
//# sourceMappingURL=index.js.map
|