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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +26 -3
  4. package/dist/index.d.ts +8454 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +4855 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +53 -6
  9. package/src/api/analytics.ts +81 -0
  10. package/src/api/auth.ts +77 -0
  11. package/src/api/catalog.ts +171 -0
  12. package/src/api/daily.ts +122 -0
  13. package/src/api/economy/bundle.ts +69 -0
  14. package/src/api/economy/inventory.ts +57 -0
  15. package/src/api/economy/trades.ts +154 -0
  16. package/src/api/economy.ts +15 -0
  17. package/src/api/index.ts +85 -0
  18. package/src/api/leaderboards.ts +151 -0
  19. package/src/api/messages.ts +142 -0
  20. package/src/api/mp/chat.ts +36 -0
  21. package/src/api/mp/parties.ts +108 -0
  22. package/src/api/mp/races.ts +211 -0
  23. package/src/api/mp/rooms.ts +90 -0
  24. package/src/api/mp/snapshot.ts +17 -0
  25. package/src/api/mp.ts +110 -0
  26. package/src/api/notify.ts +111 -0
  27. package/src/api/progress/game-data.ts +189 -0
  28. package/src/api/progress.ts +59 -0
  29. package/src/api/race.ts +47 -0
  30. package/src/api/reports.ts +208 -0
  31. package/src/api/social/config.ts +47 -0
  32. package/src/api/social/endpoints.ts +179 -0
  33. package/src/api/social/people.ts +118 -0
  34. package/src/api/social/presence.ts +95 -0
  35. package/src/api/social.ts +9 -0
  36. package/src/api/version.ts +40 -0
  37. package/src/coerce.ts +50 -0
  38. package/src/common.ts +42 -0
  39. package/src/entities.ts +62 -0
  40. package/src/errors/codes/core.ts +10 -0
  41. package/src/errors/codes/multiplayer.ts +43 -0
  42. package/src/errors/codes/play.ts +5 -0
  43. package/src/errors/codes/social.ts +31 -0
  44. package/src/errors/index.ts +88 -0
  45. package/src/index.ts +16 -0
  46. package/src/limits.ts +100 -0
  47. package/src/logic/daily.ts +54 -0
  48. package/src/logic/index.ts +8 -0
  49. package/src/logic/presence.ts +62 -0
  50. package/src/logic/race.ts +169 -0
  51. package/src/logic/time.ts +81 -0
  52. package/src/manifest/changes.ts +55 -0
  53. package/src/manifest/controls.ts +42 -0
  54. package/src/manifest/fields.ts +89 -0
  55. package/src/manifest/game.ts +132 -0
  56. package/src/manifest/index.ts +12 -0
  57. package/src/manifest/inject.ts +31 -0
  58. package/src/manifest/leaderboards.ts +29 -0
  59. package/src/manifest/multiplayer.ts +71 -0
  60. package/src/manifest/race.ts +93 -0
  61. package/src/middleware.ts +47 -0
  62. package/src/realtime/define.ts +52 -0
  63. package/src/realtime/envelope.ts +67 -0
  64. package/src/realtime/events/multiplayer.ts +8 -0
  65. package/src/realtime/events/parties.ts +31 -0
  66. package/src/realtime/events/races.ts +27 -0
  67. package/src/realtime/events/rooms.ts +48 -0
  68. package/src/realtime/events/social.ts +81 -0
  69. package/src/realtime/index.ts +24 -0
  70. package/src/realtime/ops/empty.ts +8 -0
  71. package/src/realtime/ops/hello.ts +40 -0
  72. package/src/realtime/ops/multiplayer.ts +8 -0
  73. package/src/realtime/ops/parties.ts +13 -0
  74. package/src/realtime/ops/races.ts +99 -0
  75. package/src/realtime/ops/rooms.ts +122 -0
  76. package/src/realtime/ops/social.ts +58 -0
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `game.multiplayer`: the opt-in that makes a game an online invite/join
3
+ * target (docs/multiplayer.md §7).
4
+ */
5
+ import { z } from 'zod';
6
+ import { MANIFEST_LIMITS } from '../limits';
7
+ import { LobbySuffix, hasUniqueKeys, playerRange, trimmedText } from './fields';
8
+
9
+ /**
10
+ * How players connect: `hub-rooms` (the hub relays room messages) or
11
+ * `own-server` (a process app runs its own game server; process apps only).
12
+ */
13
+ export const Transport = z.enum(['hub-rooms', 'own-server']);
14
+
15
+ /** A mode key: letters, digits, `_` or `-`, 1 to 32 characters. */
16
+ export const ModeKey = z.string().regex(/^[A-Za-z0-9_-]{1,32}$/, 'must be 1-32 letters, digits, _ or -');
17
+
18
+ /** A game mode players can be invited into. */
19
+ export const MultiplayerMode = z.strictObject({
20
+ /** Sent with invites and launches as `mode`. */
21
+ key: ModeKey,
22
+ /** What players see. Defaults to the key. */
23
+ title: trimmedText(MANIFEST_LIMITS.modeTitleMax).optional(),
24
+ /** `[min, max]` players for this mode, within 1..8. Defaults to the game's. */
25
+ players: playerRange(1).optional(),
26
+ });
27
+
28
+ /**
29
+ * A soften-list word this game's chat may keep (e.g. `shot` for a pool game):
30
+ * one lowercase word of 2 to 20 letters. Reject-list words never pass.
31
+ */
32
+ export const ChatAllowWord = z.string().trim().toLowerCase().regex(/^[a-z]{2,20}$/, 'must be a single word of 2-20 letters');
33
+
34
+ /** The `game.multiplayer` block. */
35
+ export const GameMultiplayer = z.strictObject({
36
+ /** URL suffix invites and Join open, relative to `/<slug>/`. Default `''` (the game's root). */
37
+ lobby: LobbySuffix.default(''),
38
+ /** `[min, max]` players, within 1..8. Default `[2, 8]`. */
39
+ players: playerRange(1).default([2, 8]),
40
+ /**
41
+ * `hub-rooms` or `own-server`. Default: `own-server` for process apps,
42
+ * `hub-rooms` for static apps (which cannot use `own-server`).
43
+ */
44
+ transport: Transport.optional(),
45
+ /** Friends can invite each other into this game. Default `true`. */
46
+ invites: z.boolean().default(true),
47
+ /** Friends can join a game in progress. Default `true`. */
48
+ join: z.boolean().default(true),
49
+ /** Friends can watch. Default `false`. */
50
+ spectate: z.boolean().default(false),
51
+ /** Offer quick match. Default `false`. */
52
+ quickMatch: z.boolean().default(false),
53
+ /** Modes players can be invited into (at most 16, unique keys). */
54
+ modes: z.array(MultiplayerMode).max(MANIFEST_LIMITS.modesMax).refine(hasUniqueKeys, 'mode keys must be unique').default([]),
55
+ /**
56
+ * Extra quick-chat phrases for this game (at most 24, each at most 40
57
+ * characters). Checked by the content filter at boot; failing ones are dropped.
58
+ */
59
+ quickChat: z.array(trimmedText(MANIFEST_LIMITS.quickChatTextMax)).max(MANIFEST_LIMITS.quickChatMax).default([]),
60
+ /** Soften-list words this game's chat may keep (at most 20). */
61
+ chatAllow: z.array(ChatAllowWord).max(MANIFEST_LIMITS.chatAllowMax).default([]),
62
+ });
63
+
64
+ /** A normalised `game.multiplayer` block. */
65
+ export type GameMultiplayer = z.output<typeof GameMultiplayer>;
66
+
67
+ /** `game.multiplayer` for a static app: `own-server` needs a process app. */
68
+ export const StaticGameMultiplayer = GameMultiplayer.refine((multiplayer) => multiplayer.transport !== 'own-server', {
69
+ message: 'own-server needs a process app; static apps use hub-rooms',
70
+ path: ['transport'],
71
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `game.race`: the opt-in that turns a single-player game into a race target
3
+ * (docs/multiplayer.md §14, docs/plans/race.md §5).
4
+ */
5
+ import { z } from 'zod';
6
+ import { RaceKey, RaceParamValue as RaceValue } from '../api/mp/races';
7
+ import { MANIFEST_LIMITS } from '../limits';
8
+ import { LobbySuffix, hasUniqueKeys, playerRange, trimmedText } from './fields';
9
+
10
+ /** A race param value. */
11
+ type RaceValue = z.infer<typeof RaceValue>;
12
+
13
+ const RaceTitle = trimmedText(MANIFEST_LIMITS.raceTitleMax);
14
+
15
+ /** One choice of a race param: a bare value, or a value with a title. */
16
+ export const RaceOption = z.union([
17
+ RaceValue,
18
+ z.strictObject({
19
+ /** The value the game receives in `r.params[key]`. */
20
+ value: RaceValue,
21
+ /** What the creator sees. Defaults to the value. Checked like quick-chat text. */
22
+ title: RaceTitle.optional(),
23
+ }),
24
+ ]);
25
+
26
+ /** The value of an option, bare or titled. */
27
+ function optionValue(option: z.output<typeof RaceOption>): RaceValue {
28
+ return typeof option === 'object' ? option.value : option;
29
+ }
30
+
31
+ /** A choice the race creator makes. The server only accepts declared values. */
32
+ export const RaceParam = z
33
+ .strictObject({
34
+ /** The key the game reads, `r.params[key]`. */
35
+ key: RaceKey,
36
+ /** What the creator sees. Defaults to the key. Checked like quick-chat text. */
37
+ title: RaceTitle.optional(),
38
+ /** The choices, 1 to 12. */
39
+ options: z.array(RaceOption).min(1).max(MANIFEST_LIMITS.raceOptionsMax),
40
+ /** The preselected value; one of the options' values. Defaults to the first. */
41
+ default: RaceValue.optional(),
42
+ })
43
+ .refine((p) => p.default === undefined || p.options.some((option) => optionValue(option) === p.default), {
44
+ message: 'default must be one of the option values',
45
+ path: ['default'],
46
+ });
47
+
48
+ /** How the live race HUD and the race history show a stat. */
49
+ export const RaceStatFormat = z.enum(['int', 'percent', 'time', 'bool', 'color']);
50
+
51
+ /** A value of {@link RaceStatFormat}. */
52
+ export type RaceStatFormat = z.infer<typeof RaceStatFormat>;
53
+
54
+ /** A stat the live race HUD and the race history show. */
55
+ export const RaceStat = z.strictObject({
56
+ /** The key the game sends in `hub.race.status({ [key]: value })`. */
57
+ key: RaceKey,
58
+ /** The column label. Defaults to the key. Checked like quick-chat text. */
59
+ title: RaceTitle.optional(),
60
+ /** `int` (default), `percent`, `time` (ms), `bool` or `color` (`#rrggbb`). */
61
+ format: RaceStatFormat.default('int'),
62
+ });
63
+
64
+ /** The `game.race` object form. */
65
+ export const RaceBlock = z.strictObject({
66
+ /** URL suffix race invites and Join open, relative to `/<slug>/`. Default `?race=lobby`. */
67
+ lobby: LobbySuffix.default('?race=lobby'),
68
+ /** `[min, max]` racers, within 2..8. Default `[2, 2]`. */
69
+ players: playerRange(2).default([2, 2]),
70
+ /** What the creator picks (at most 6, unique keys). */
71
+ params: z.array(RaceParam).max(MANIFEST_LIMITS.raceParamsMax).refine(hasUniqueKeys, 'param keys must be unique').default([]),
72
+ /** What the live HUD and the history show (at most 8, unique keys; the HUD shows the first 3). */
73
+ stats: z.array(RaceStat).max(MANIFEST_LIMITS.raceStatsMax).refine(hasUniqueKeys, 'stat keys must be unique').default([]),
74
+ /**
75
+ * The earliest a finish may count after the go, in ms (0 to 600000). A
76
+ * sooner finish is held until then. Default: the hub's `raceMinFinishMs`.
77
+ */
78
+ minMs: z.number().int().min(0).max(MANIFEST_LIMITS.raceMinMsMax).optional(),
79
+ });
80
+
81
+ /** A normalised `game.race` block. */
82
+ export type RaceBlock = z.output<typeof RaceBlock>;
83
+
84
+ /**
85
+ * `game.race`: an object, `true` for all defaults (no params, no stats), or
86
+ * `false` for off. `true` parses to the object form, `false` to `undefined`.
87
+ */
88
+ export const GameRace = z
89
+ .preprocess((v: boolean | z.input<typeof RaceBlock>) => (v === true ? {} : v === false ? undefined : v), RaceBlock.optional())
90
+ .optional();
91
+
92
+ /** `game.race` as a game writes it. */
93
+ export type GameRaceInput = z.input<typeof GameRace>;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Middleware **defs** for the public API (plan §6.1). A def is a contract
3
+ * only: a name, the context it provides and its dependencies. It holds no
4
+ * code, so it is safe in a published package; the server binds the real
5
+ * implementation. Each endpoint's guards are part of its declaration, which
6
+ * lets tests check rules such as "every social endpoint requires an active
7
+ * account" (plan §10.3).
8
+ */
9
+ import { ctx, middleware } from '@ayepi/core';
10
+ import { Slug } from './common';
11
+ import type { SessionUser } from './entities';
12
+
13
+ /**
14
+ * Resolves the `hub_session` cookie (the user, or `null`) and the anonymous
15
+ * `hub_cid` client id. Every endpoint has it.
16
+ */
17
+ export const session = middleware('session', {
18
+ provides: ctx<{ user: SessionUser | null; clientId: string }>(),
19
+ doc: { security: { cookieAuth: { type: 'apiKey', in: 'cookie', name: 'hub_session' } } },
20
+ });
21
+
22
+ /** Signed in, as a guest or an account. Otherwise 401 `signed_out`. */
23
+ export const requireUser = middleware('requireUser', {
24
+ provides: ctx<{ user: SessionUser }>(),
25
+ requires: [session],
26
+ });
27
+
28
+ /**
29
+ * Signed in and not suspended. Otherwise 401 `signed_out` or 403
30
+ * `suspended`. Every social, multiplayer, race and trade endpoint has it.
31
+ */
32
+ export const requireActive = middleware('requireActive', {
33
+ provides: ctx<{ user: SessionUser }>(),
34
+ requires: [requireUser],
35
+ });
36
+
37
+ /** Active and a claimed account (not a guest). Otherwise 403 `claim_required`. */
38
+ export const requireClaimed = middleware('requireClaimed', {
39
+ provides: ctx<{ user: SessionUser }>(),
40
+ requires: [requireActive],
41
+ });
42
+
43
+ /**
44
+ * Owns the `:slug` path segment and checks it names a known game.
45
+ * Otherwise 404 "No such game".
46
+ */
47
+ export const game = middleware.loader('slug', Slug, { requires: [session] });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Definers for socket ops and events (plan §5.2). One declaration per op or
3
+ * event feeds the server (validation), the runtime and test clients (types)
4
+ * and the generated op/event tables.
5
+ */
6
+ import type { z } from 'zod';
7
+ import type { ErrorCode } from '../errors';
8
+
9
+ /** A client → server op on `/_ws`: its input, its ack and the codes it can fail with. */
10
+ export interface OpDef<D extends z.ZodType = z.ZodType, A extends z.ZodType = z.ZodType> {
11
+ /** The op's input (`d` in `{t:'op', id, op, d}`). */
12
+ readonly d: D;
13
+ /** The success ack's payload (`d` in `{t:'ack', ok:true, d}`). */
14
+ readonly ack: A;
15
+ /** Error codes this op can fail with, beyond the envelope's own (see `ENVELOPE_ERRORS`). */
16
+ readonly errors: readonly ErrorCode[];
17
+ /**
18
+ * The refusal a malformed `d` gets (status 400), when the hub names it
19
+ * (`bad_stats`, `bad_patch`). Without it the refusal is `bad_request`
20
+ * with the first validation message.
21
+ */
22
+ readonly invalid?: { readonly code: ErrorCode; readonly error: string };
23
+ /** One line for the generated op table. */
24
+ readonly doc: string;
25
+ }
26
+
27
+ /** A server → client event on `/_ws`. */
28
+ export interface EventDef<D extends z.ZodType = z.ZodType> {
29
+ /** The payload (`d` in `{t:'ev', ev, d}`). */
30
+ readonly d: D;
31
+ /** One line for the generated event table. */
32
+ readonly doc: string;
33
+ }
34
+
35
+ /** Declare an op (identity at runtime; keeps the literal types). */
36
+ export function op<const D extends z.ZodType, const A extends z.ZodType>(def: OpDef<D, A>): OpDef<D, A> {
37
+ return def;
38
+ }
39
+
40
+ /** Declare an event (identity at runtime; keeps the literal types). */
41
+ export function event<const D extends z.ZodType>(def: EventDef<D>): EventDef<D> {
42
+ return def;
43
+ }
44
+
45
+ /** The input type of an op. */
46
+ export type OpInput<O extends OpDef> = z.input<O['d']>;
47
+
48
+ /** The ack payload type of an op. */
49
+ export type OpAck<O extends OpDef> = z.output<O['ack']>;
50
+
51
+ /** The payload type of an event. */
52
+ export type EventData<E extends EventDef> = z.output<E['d']>;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The legacy `/_ws` envelope (multiplayer-impl.md §4.2), unchanged since v1
3
+ * and kept indefinitely (plan D6):
4
+ *
5
+ * ```
6
+ * client → server {t:'op', id, op, d}
7
+ * server → client {t:'ack', id, ok:true, d}
8
+ * {t:'ack', id, ok:false, err: ErrorBody}
9
+ * {t:'ev', ev, d}
10
+ * ```
11
+ */
12
+ import { z } from 'zod';
13
+ import { Json } from '../common';
14
+ import { ErrorBody, type ErrorCode } from '../errors';
15
+
16
+ /** A client's op frame. `id` is the client's own counter, echoed in the ack. */
17
+ export const OpFrame = z.object({
18
+ t: z.literal('op'),
19
+ id: z.number(),
20
+ op: z.string(),
21
+ d: Json.optional(),
22
+ });
23
+
24
+ /** An op frame. */
25
+ export type OpFrame = z.infer<typeof OpFrame>;
26
+
27
+ /** A successful ack. */
28
+ export const OkAckFrame = z.object({ t: z.literal('ack'), id: z.number(), ok: z.literal(true), d: Json });
29
+
30
+ /** A failed ack. */
31
+ export const ErrAckFrame = z.object({ t: z.literal('ack'), id: z.number(), ok: z.literal(false), err: ErrorBody });
32
+
33
+ /** An ack frame: the server's answer to one op. */
34
+ export const AckFrame = z.discriminatedUnion('ok', [OkAckFrame, ErrAckFrame]);
35
+
36
+ /** An ack frame. */
37
+ export type AckFrame = z.infer<typeof AckFrame>;
38
+
39
+ /** A server-pushed event frame. */
40
+ export const EvFrame = z.object({ t: z.literal('ev'), ev: z.string(), d: Json });
41
+
42
+ /** An event frame. */
43
+ export type EvFrame = z.infer<typeof EvFrame>;
44
+
45
+ /** Any frame the server sends. */
46
+ export const ServerFrame = z.union([AckFrame, EvFrame]);
47
+
48
+ /** Close codes the client understands. */
49
+ export const CloseCode = {
50
+ /** Service restart: reconnect quietly. */
51
+ restart: 1012,
52
+ /** The account is suspended. */
53
+ suspended: 4003,
54
+ /** Too many sockets for this user or IP. */
55
+ tooMany: 4029,
56
+ /** A frame that wasn't a valid op. */
57
+ badMessage: 4400,
58
+ } as const;
59
+
60
+ /**
61
+ * Codes any op can fail with, raised by the envelope rather than the op:
62
+ * the per-socket rate limit, an unknown op name, and ops sent before `hello`.
63
+ */
64
+ export const ENVELOPE_ERRORS: readonly ErrorCode[] = ['rate_limited', 'unknown_op', 'hello_required'];
65
+
66
+ /** Ops the server accepts before `hello`. */
67
+ export const PRE_HELLO_OPS: readonly string[] = ['hello', 'ping'];
@@ -0,0 +1,8 @@
1
+ /** Socket events for invites, parties, hub rooms and races. */
2
+ import type { EventDef } from '../define';
3
+ import { partyEvents } from './parties';
4
+ import { raceEvents } from './races';
5
+ import { roomEvents } from './rooms';
6
+
7
+ /** The multiplayer events, by event name. */
8
+ export const multiplayerEvents = { ...partyEvents, ...roomEvents, ...raceEvents } satisfies Record<string, EventDef>;
@@ -0,0 +1,31 @@
1
+ /** Invite and party events (multiplayer-impl.md §5.6). */
2
+ import { z } from 'zod';
3
+ import { PlayerLite } from '../../entities';
4
+ import { ChatLineEvent } from '../../api/mp/chat';
5
+ import { Invite, InviteStatus, OpaqueId, Party } from '../../api/mp/parties';
6
+ import { event, type EventDef } from '../define';
7
+
8
+ /** The invite and party events, by event name. */
9
+ export const partyEvents = {
10
+ invite: event({
11
+ d: Invite,
12
+ doc: 'A friend invited you to a game.',
13
+ }),
14
+ 'invite.update': event({
15
+ d: z.object({
16
+ id: OpaqueId,
17
+ status: InviteStatus,
18
+ /** Who accepted or declined (absent for cancelled and expired). */
19
+ by: PlayerLite.optional(),
20
+ }),
21
+ doc: 'An invite ended; sent to the inviter and the invitee (so its toast closes).',
22
+ }),
23
+ party: event({
24
+ d: Party.nullable(),
25
+ doc: "Your party's current state, or null when you left it or it dissolved.",
26
+ }),
27
+ 'party.chat': event({
28
+ d: ChatLineEvent,
29
+ doc: 'A party chat line.',
30
+ }),
31
+ } satisfies Record<string, EventDef>;
@@ -0,0 +1,27 @@
1
+ /** Race events (docs/plans/race.md §4). */
2
+ import { z } from 'zod';
3
+ import { UserId } from '../../common';
4
+ import { RaceProgress, RaceResult, RaceStart, RaceStats } from '../../api/mp/races';
5
+ import { event, type EventDef } from '../define';
6
+
7
+ /** The race events, by event name. */
8
+ export const raceEvents = {
9
+ 'race.start': event({
10
+ d: RaceStart,
11
+ doc: 'The countdown began: the seed is revealed now.',
12
+ }),
13
+ 'race.status': event({
14
+ d: z.object({
15
+ userId: UserId,
16
+ stats: RaceStats,
17
+ progress: RaceProgress.optional(),
18
+ /** ms since the go (server clock). */
19
+ at: z.number().int().nonnegative(),
20
+ }),
21
+ doc: "Another racer's live status, for the race HUD.",
22
+ }),
23
+ 'race.result': event({
24
+ d: z.object({ result: RaceResult }),
25
+ doc: 'The race was decided.',
26
+ }),
27
+ } satisfies Record<string, EventDef>;
@@ -0,0 +1,48 @@
1
+ /** Hub relay room events (multiplayer-impl.md §6). Race rooms send these too. */
2
+ import { z } from 'zod';
3
+ import { Json, UserId } from '../../common';
4
+ import { ChatLineEvent } from '../../api/mp/chat';
5
+ import { OpaqueId } from '../../api/mp/parties';
6
+ import { RoomClosedReason, RoomInfo, RoomMessage, RoomState, SeatGoneReason } from '../../api/mp/rooms';
7
+ import { event, type EventDef } from '../define';
8
+
9
+ /** The room events, by event name. */
10
+ export const roomEvents = {
11
+ room: event({
12
+ d: z.object({
13
+ room: RoomInfo,
14
+ /** What the host passed to `room.end`; present once, on that update. */
15
+ results: Json.optional(),
16
+ }),
17
+ doc: 'The room changed: roster, ready flags, phase, host, race state.',
18
+ }),
19
+ 'room.msg': event({
20
+ d: RoomMessage,
21
+ doc: 'A relayed game message.',
22
+ }),
23
+ 'room.state': event({
24
+ d: z.object({
25
+ /** Shallow-merged into the state unless `replace`; a `null` value deletes its key. */
26
+ patch: RoomState,
27
+ replace: z.boolean(),
28
+ seq: z.number().int().positive(),
29
+ }),
30
+ doc: 'The shared state changed.',
31
+ }),
32
+ 'room.chat': event({
33
+ d: ChatLineEvent,
34
+ doc: 'A room chat line.',
35
+ }),
36
+ 'room.kicked': event({
37
+ d: z.object({ roomId: OpaqueId }),
38
+ doc: 'The host removed you from the room.',
39
+ }),
40
+ 'room.closed': event({
41
+ d: z.object({ reason: RoomClosedReason }),
42
+ doc: 'The room is gone for this tab (emptied, ended, host left, moved, another tab took the seat, removed).',
43
+ }),
44
+ 'room.abandoned': event({
45
+ d: z.object({ userId: UserId, reason: SeatGoneReason }),
46
+ doc: 'A player left mid-game (or their reconnect grace ran out); the game decides what that means.',
47
+ }),
48
+ } satisfies Record<string, EventDef>;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Socket events for presence, counts, friends, typing, config, DMs, notices
3
+ * and suspension (multiplayer-impl.md §4.6). Every event goes to all of the
4
+ * recipient's tabs.
5
+ */
6
+ import { z } from 'zod';
7
+ import { Millis, UserId } from '../../common';
8
+ import { PlayerLite } from '../../entities';
9
+ import { Message, MessageId } from '../../api/messages';
10
+ import { SocialConfig } from '../../api/social/config';
11
+ import { FriendChange } from '../../api/social/people';
12
+ import { Counts, Presence } from '../../api/social/presence';
13
+ import { event, type EventDef } from '../define';
14
+
15
+ /** What kind of thing a notice is about. `renamed`: an admin changed your username. */
16
+ export const NoticeKind = z.enum(['dm', 'friend_request', 'friend_online', 'lobby_open', 'invite', 'suggestion', 'renamed']);
17
+
18
+ /** A notice kind. */
19
+ export type NoticeKind = z.infer<typeof NoticeKind>;
20
+
21
+ /** A toast (`ev:notice`), sent instead of a push when the player has a visible tab. */
22
+ export const Notice = z.object({
23
+ kind: NoticeKind,
24
+ title: z.string(),
25
+ body: z.string().optional(),
26
+ /** A button: its label and the page it opens. */
27
+ action: z.object({ label: z.string(), url: z.string() }).optional(),
28
+ });
29
+
30
+ /** A toast. */
31
+ export type Notice = z.infer<typeof Notice>;
32
+
33
+ /** The social events, by event name. */
34
+ export const socialEvents = {
35
+ presence: event({
36
+ d: z.object({
37
+ userId: UserId,
38
+ /** `null`: offline, or no longer visible to you. */
39
+ p: Presence.nullable(),
40
+ }),
41
+ doc: "A friend's presence changed.",
42
+ }),
43
+ counts: event({ d: Counts, doc: 'The online counts changed (broadcast to every tab, at most every 15 s).' }),
44
+ friends: event({
45
+ d: z.object({ change: FriendChange, user: PlayerLite }),
46
+ doc: 'Your friends list changed (refetch `GET /social/friends`).',
47
+ }),
48
+ typing: event({ d: z.object({ from: UserId }), doc: 'A friend is typing to you.' }),
49
+ config: event({ d: SocialConfig, doc: 'The site-wide social configuration changed (broadcast).' }),
50
+ dm: event({
51
+ d: z.object({
52
+ message: Message,
53
+ /** The other side of the conversation, from your point of view. */
54
+ conversationWith: UserId,
55
+ }),
56
+ doc: 'A DM was delivered: to the recipient, and to the sender\'s other tabs.',
57
+ }),
58
+ 'dm.read': event({
59
+ d: z.object({
60
+ /** Who read. */
61
+ by: UserId,
62
+ /** Their messages up to and including this id are read. */
63
+ upTo: MessageId,
64
+ }),
65
+ doc: 'Messages were read (to the sender, and to the reader\'s own tabs to sync badges).',
66
+ }),
67
+ 'dm.hidden': event({
68
+ d: z.object({ ids: z.array(MessageId) }),
69
+ doc: 'A moderator hid these messages: drop them from both sides of the conversation.',
70
+ }),
71
+ notice: event({ d: Notice, doc: 'A toast to show.' }),
72
+ suspended: event({
73
+ d: z.object({
74
+ /** When the suspension ends (epoch ms). */
75
+ until: Millis,
76
+ /** The moderator's reason, when given. */
77
+ reason: z.string().optional(),
78
+ }),
79
+ doc: 'The account is suspended; the socket then closes with 4003.',
80
+ }),
81
+ } satisfies Record<string, EventDef>;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The `/_ws` socket contract: the legacy envelope plus every op and event,
3
+ * by name.
4
+ */
5
+ import { multiplayerEvents } from './events/multiplayer';
6
+ import { socialEvents } from './events/social';
7
+ import { helloOps } from './ops/hello';
8
+ import { multiplayerOps } from './ops/multiplayer';
9
+ import { socialOps } from './ops/social';
10
+
11
+ export * from './define';
12
+ export * from './envelope';
13
+
14
+ /** Every socket op, by op name (an intersection type keeps the .d.ts small). */
15
+ export const ops: typeof helloOps & typeof socialOps & typeof multiplayerOps = { ...helloOps, ...socialOps, ...multiplayerOps };
16
+
17
+ /** Every socket event, by event name (an intersection type keeps the .d.ts small). */
18
+ export const events: typeof socialEvents & typeof multiplayerEvents = { ...socialEvents, ...multiplayerEvents };
19
+
20
+ /** A socket op name. */
21
+ export type OpName = keyof typeof ops;
22
+
23
+ /** A socket event name. */
24
+ export type EventName = keyof typeof events;
@@ -0,0 +1,8 @@
1
+ /** The empty input and ack many socket ops share. */
2
+ import { z } from 'zod';
3
+
4
+ /** An op that takes no input (`d` absent or `{}`). */
5
+ export const NoInput = z.object({});
6
+
7
+ /** An ack with no payload (`{}`). */
8
+ export const EmptyAck = z.object({});
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `hello`: the first op on every socket (multiplayer-impl.md §4.5). It marks
3
+ * the tab live for events and presence and answers with the snapshot a
4
+ * client needs to draw the Friends drawer, invites and any room it still holds.
5
+ */
6
+ import { z } from 'zod';
7
+ import { UnreadSummary } from '../../api/messages';
8
+ import { MultiplayerSnapshot } from '../../api/mp/snapshot';
9
+ import { SocialConfig } from '../../api/social/config';
10
+ import { FriendEntry, FriendRequests, SocialMe } from '../../api/social/people';
11
+ import { Counts } from '../../api/social/presence';
12
+ import { op } from '../define';
13
+
14
+ /** `hello` input. Anything unrecognised is ignored rather than rejected. */
15
+ export const HelloInput = z.object({
16
+ /** The game this tab is in, or `null` on the home page. Unknown slugs count as `null`. */
17
+ slug: z.string().nullable().optional(),
18
+ /** A per-tab id (`[A-Za-z0-9_-]{1,64}`); other values are ignored. */
19
+ tab: z.string().optional(),
20
+ /** The document is visible (default `true`). */
21
+ visible: z.boolean().optional(),
22
+ /** The client package version that opened the socket (`2.3.0`); v1 vendored copies send none. */
23
+ client: z.string().optional(),
24
+ });
25
+
26
+ /** The `hello` snapshot. Guests get no friends and empty request lists. */
27
+ export const HelloSnapshot = z.object({
28
+ me: SocialMe,
29
+ config: SocialConfig,
30
+ counts: Counts,
31
+ friends: z.array(FriendEntry),
32
+ requests: FriendRequests,
33
+ unread: UnreadSummary,
34
+ ...MultiplayerSnapshot.shape,
35
+ });
36
+
37
+ /** The `hello` op (allowed before `hello`, obviously). */
38
+ export const helloOps = {
39
+ hello: op({ d: HelloInput, ack: HelloSnapshot, errors: [], doc: 'Start the session on this socket; answers with the snapshot.' }),
40
+ };
@@ -0,0 +1,8 @@
1
+ /** Socket ops for invites, parties, hub rooms and races. */
2
+ import type { OpDef } from '../define';
3
+ import { partyOps } from './parties';
4
+ import { raceOps } from './races';
5
+ import { roomOps } from './rooms';
6
+
7
+ /** The multiplayer ops, by op name. */
8
+ export const multiplayerOps = { ...partyOps, ...roomOps, ...raceOps } satisfies Record<string, OpDef>;
@@ -0,0 +1,13 @@
1
+ /** The party socket op: party chat. Invites and party membership are REST (`/mp/*`). */
2
+ import { CHAT_ERRORS, ChatLine, ChatSend } from '../../api/mp/chat';
3
+ import { op, type OpDef } from '../define';
4
+
5
+ /** The party ops, by op name. */
6
+ export const partyOps = {
7
+ 'party.chat': op({
8
+ d: ChatSend,
9
+ ack: ChatLine,
10
+ errors: ['party_gone', ...CHAT_ERRORS],
11
+ doc: "Chat to your party: free text only when every member is a mutual friend, quick-chat otherwise.",
12
+ }),
13
+ } satisfies Record<string, OpDef>;