@diffenderfer-games/hub-contract 0.0.0-stage → 2.0.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +25 -2
- package/dist/index.d.ts +8454 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4855 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -4
- package/src/api/analytics.ts +81 -0
- package/src/api/auth.ts +77 -0
- package/src/api/catalog.ts +171 -0
- package/src/api/daily.ts +122 -0
- package/src/api/economy/bundle.ts +69 -0
- package/src/api/economy/inventory.ts +57 -0
- package/src/api/economy/trades.ts +154 -0
- package/src/api/economy.ts +15 -0
- package/src/api/index.ts +85 -0
- package/src/api/leaderboards.ts +151 -0
- package/src/api/messages.ts +142 -0
- package/src/api/mp/chat.ts +36 -0
- package/src/api/mp/parties.ts +108 -0
- package/src/api/mp/races.ts +211 -0
- package/src/api/mp/rooms.ts +90 -0
- package/src/api/mp/snapshot.ts +17 -0
- package/src/api/mp.ts +110 -0
- package/src/api/notify.ts +111 -0
- package/src/api/progress/game-data.ts +189 -0
- package/src/api/progress.ts +59 -0
- package/src/api/race.ts +47 -0
- package/src/api/reports.ts +208 -0
- package/src/api/social/config.ts +47 -0
- package/src/api/social/endpoints.ts +179 -0
- package/src/api/social/people.ts +118 -0
- package/src/api/social/presence.ts +95 -0
- package/src/api/social.ts +9 -0
- package/src/api/version.ts +40 -0
- package/src/coerce.ts +50 -0
- package/src/common.ts +42 -0
- package/src/entities.ts +62 -0
- package/src/errors/codes/core.ts +10 -0
- package/src/errors/codes/multiplayer.ts +43 -0
- package/src/errors/codes/play.ts +5 -0
- package/src/errors/codes/social.ts +31 -0
- package/src/errors/index.ts +88 -0
- package/src/index.ts +16 -0
- package/src/limits.ts +100 -0
- package/src/logic/daily.ts +54 -0
- package/src/logic/index.ts +8 -0
- package/src/logic/presence.ts +62 -0
- package/src/logic/race.ts +169 -0
- package/src/logic/time.ts +81 -0
- package/src/manifest/changes.ts +55 -0
- package/src/manifest/controls.ts +42 -0
- package/src/manifest/fields.ts +89 -0
- package/src/manifest/game.ts +132 -0
- package/src/manifest/index.ts +12 -0
- package/src/manifest/inject.ts +31 -0
- package/src/manifest/leaderboards.ts +29 -0
- package/src/manifest/multiplayer.ts +71 -0
- package/src/manifest/race.ts +93 -0
- package/src/middleware.ts +47 -0
- package/src/realtime/define.ts +52 -0
- package/src/realtime/envelope.ts +67 -0
- package/src/realtime/events/multiplayer.ts +8 -0
- package/src/realtime/events/parties.ts +31 -0
- package/src/realtime/events/races.ts +27 -0
- package/src/realtime/events/rooms.ts +48 -0
- package/src/realtime/events/social.ts +81 -0
- package/src/realtime/index.ts +24 -0
- package/src/realtime/ops/empty.ts +8 -0
- package/src/realtime/ops/hello.ts +40 -0
- package/src/realtime/ops/multiplayer.ts +8 -0
- package/src/realtime/ops/parties.ts +13 -0
- package/src/realtime/ops/races.ts +99 -0
- package/src/realtime/ops/rooms.ts +122 -0
- package/src/realtime/ops/social.ts +58 -0
|
@@ -0,0 +1,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,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>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Race ops (docs/plans/race.md §4). A race is a hub room with a referee: the
|
|
3
|
+
* countdown starts when every seat is filled, the seed is revealed with it,
|
|
4
|
+
* and the first valid finish wins. Reports come from browsers, so the hub
|
|
5
|
+
* measures time itself and enforces what it can (`minMs`, one result each).
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { Slug } from '../../common';
|
|
9
|
+
import { OpaqueId } from '../../api/mp/parties';
|
|
10
|
+
import { RaceParams, RaceProgress, RaceResult, RaceStats } from '../../api/mp/races';
|
|
11
|
+
import { RoomInfo, RoomTarget } from '../../api/mp/rooms';
|
|
12
|
+
import { op, type OpDef } from '../define';
|
|
13
|
+
import { EmptyAck, NoInput } from './empty';
|
|
14
|
+
|
|
15
|
+
/** A racer's report (status, finish, lose): stats (`null` means none) and optional 0..1 progress. */
|
|
16
|
+
const RaceReport = z.object({
|
|
17
|
+
stats: RaceStats.nullish(),
|
|
18
|
+
progress: RaceProgress.nullish(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/** The refusal a malformed report gets (old wording). */
|
|
22
|
+
const BAD_STATS = { code: 'bad_stats', error: 'Race stats must be numbers, true/false or #rrggbb colours.' } as const;
|
|
23
|
+
|
|
24
|
+
/** A race room ack. */
|
|
25
|
+
const RoomAck = z.object({ room: RoomInfo });
|
|
26
|
+
|
|
27
|
+
/** The race ops, by op name. */
|
|
28
|
+
export const raceOps = {
|
|
29
|
+
'race.create': op({
|
|
30
|
+
d: z.object({
|
|
31
|
+
game: Slug,
|
|
32
|
+
/** The creator's choices; undeclared keys and values fall back to the defaults. */
|
|
33
|
+
params: RaceParams.optional(),
|
|
34
|
+
/** Shown in Open races and can alert followers (default true). */
|
|
35
|
+
public: z.boolean().optional(),
|
|
36
|
+
/** Seats, within the game's `race.players` (default its min). */
|
|
37
|
+
maxPlayers: z.number().optional(),
|
|
38
|
+
/** The caller's party: when they lead it, its join info becomes `{ room: code }`. */
|
|
39
|
+
partyId: OpaqueId.optional(),
|
|
40
|
+
}),
|
|
41
|
+
ack: RoomAck,
|
|
42
|
+
errors: ['not_race', 'too_many_rooms'],
|
|
43
|
+
doc: 'Open a race room with the caller seated; the countdown starts once every seat is filled.',
|
|
44
|
+
}),
|
|
45
|
+
'race.join': op({
|
|
46
|
+
d: RoomTarget,
|
|
47
|
+
ack: RoomAck,
|
|
48
|
+
errors: ['room_not_found', 'banned', 'in_progress', 'room_full', 'not_multiplayer', 'not_supported'],
|
|
49
|
+
doc: 'Join (or watch) a race room, like `room.join` but only for races.',
|
|
50
|
+
}),
|
|
51
|
+
'race.list': op({
|
|
52
|
+
d: z.object({ game: Slug }),
|
|
53
|
+
ack: z.object({ rooms: z.array(RoomInfo) }),
|
|
54
|
+
errors: [],
|
|
55
|
+
doc: 'Public races waiting for racers (at most 50), minus rooms with blocked players.',
|
|
56
|
+
}),
|
|
57
|
+
'race.start': op({
|
|
58
|
+
d: NoInput,
|
|
59
|
+
ack: EmptyAck,
|
|
60
|
+
errors: ['not_in_race', 'not_host', 'in_progress', 'not_ready'],
|
|
61
|
+
doc: 'Creator: start the countdown early once the minimum racers are in.',
|
|
62
|
+
}),
|
|
63
|
+
'race.status': op({
|
|
64
|
+
d: RaceReport,
|
|
65
|
+
ack: EmptyAck,
|
|
66
|
+
errors: ['not_in_race', 'not_started', 'race_over', 'rate_limited', 'bad_stats'],
|
|
67
|
+
invalid: BAD_STATS,
|
|
68
|
+
doc: 'Live status for the race HUD. Numbers, booleans and #rrggbb only: never text.',
|
|
69
|
+
}),
|
|
70
|
+
'race.finish': op({
|
|
71
|
+
d: RaceReport,
|
|
72
|
+
ack: z.object({ result: RaceResult }),
|
|
73
|
+
errors: ['not_in_race', 'not_started', 'race_over', 'too_fast', 'bad_stats'],
|
|
74
|
+
invalid: BAD_STATS,
|
|
75
|
+
doc: 'Report a finish with final stats; the first valid finish wins the race.',
|
|
76
|
+
}),
|
|
77
|
+
'race.lose': op({
|
|
78
|
+
d: RaceReport,
|
|
79
|
+
ack: EmptyAck,
|
|
80
|
+
errors: ['not_in_race', 'not_started', 'race_over', 'bad_stats'],
|
|
81
|
+
invalid: BAD_STATS,
|
|
82
|
+
doc: 'Game over for this racer (out of lives, stuck…); the last racer left wins.',
|
|
83
|
+
}),
|
|
84
|
+
'race.forfeit': op({
|
|
85
|
+
d: NoInput,
|
|
86
|
+
ack: EmptyAck,
|
|
87
|
+
errors: ['not_in_race', 'race_over'],
|
|
88
|
+
doc: 'Give up (leaving the room mid-race counts the same).',
|
|
89
|
+
}),
|
|
90
|
+
'race.rematch': op({
|
|
91
|
+
d: z.object({
|
|
92
|
+
/** Creator only: params for the next round. */
|
|
93
|
+
params: RaceParams.optional(),
|
|
94
|
+
}),
|
|
95
|
+
ack: EmptyAck,
|
|
96
|
+
errors: ['not_in_race', 'in_progress'],
|
|
97
|
+
doc: 'Ask for another round; it starts when every seated racer has asked.',
|
|
98
|
+
}),
|
|
99
|
+
} satisfies Record<string, OpDef>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub relay room ops (multiplayer-impl.md §6). Rooms hold up to 8 seats;
|
|
3
|
+
* the hub relays messages and state but never referees, so results are
|
|
4
|
+
* untrusted. Race rooms refuse `room.start`, `room.state` and `room.end`
|
|
5
|
+
* (`race_room`): the race ops drive them.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { Json, Slug, UserId } from '../../common';
|
|
9
|
+
import { CHAT_ERRORS, ChatLine, ChatSend } from '../../api/mp/chat';
|
|
10
|
+
import { GameMode, OpaqueId } from '../../api/mp/parties';
|
|
11
|
+
import { RoomInfo, RoomMessage, RoomModel, RoomState, RoomTarget } from '../../api/mp/rooms';
|
|
12
|
+
import { op, type OpDef } from '../define';
|
|
13
|
+
import { EmptyAck, NoInput } from './empty';
|
|
14
|
+
|
|
15
|
+
/** A per-room sequence number ack (`room.send`, `room.state`). */
|
|
16
|
+
const SeqAck = z.object({ seq: z.number().int().positive() });
|
|
17
|
+
|
|
18
|
+
/** The room ops, by op name. */
|
|
19
|
+
export const roomOps = {
|
|
20
|
+
'room.create': op({
|
|
21
|
+
d: z.object({
|
|
22
|
+
game: Slug,
|
|
23
|
+
/** One of the game's modes (`race` is refused: use `race.create`). */
|
|
24
|
+
mode: GameMode.nullish(),
|
|
25
|
+
/** Listed in `room.list` (default false). */
|
|
26
|
+
public: z.boolean().optional(),
|
|
27
|
+
/** Seats; clamped to the game's max (and 8). Defaults to the max. */
|
|
28
|
+
maxPlayers: z.number().optional(),
|
|
29
|
+
model: RoomModel.optional(),
|
|
30
|
+
/** The caller's party: when they lead it, its join info becomes `{ room: code }`. */
|
|
31
|
+
partyId: OpaqueId.optional(),
|
|
32
|
+
}),
|
|
33
|
+
ack: z.object({ room: RoomInfo, state: RoomState }),
|
|
34
|
+
errors: ['not_multiplayer', 'not_supported', 'bad_mode', 'too_many_rooms'],
|
|
35
|
+
doc: 'Open a room for a hub-rooms game with the caller seated as host (leaving any other seat).',
|
|
36
|
+
}),
|
|
37
|
+
'room.join': op({
|
|
38
|
+
d: RoomTarget,
|
|
39
|
+
ack: z.object({
|
|
40
|
+
room: RoomInfo,
|
|
41
|
+
state: RoomState,
|
|
42
|
+
/** Recent messages this member may see, oldest first. */
|
|
43
|
+
history: z.array(RoomMessage),
|
|
44
|
+
}),
|
|
45
|
+
errors: ['room_not_found', 'banned', 'in_progress', 'room_full', 'not_multiplayer', 'not_supported'],
|
|
46
|
+
doc: 'Take a seat (lobby only), reclaim your own seat (any phase, any tab), or watch.',
|
|
47
|
+
}),
|
|
48
|
+
'room.leave': op({
|
|
49
|
+
d: NoInput,
|
|
50
|
+
ack: EmptyAck,
|
|
51
|
+
errors: [],
|
|
52
|
+
doc: 'Give up your seat, or stop watching.',
|
|
53
|
+
}),
|
|
54
|
+
'room.ready': op({
|
|
55
|
+
d: z.object({ ready: z.boolean() }),
|
|
56
|
+
ack: EmptyAck,
|
|
57
|
+
errors: ['not_in_room', 'in_progress'],
|
|
58
|
+
doc: 'Mark yourself ready in the lobby.',
|
|
59
|
+
}),
|
|
60
|
+
'room.seat': op({
|
|
61
|
+
d: z.object({ data: Json.optional() }),
|
|
62
|
+
ack: EmptyAck,
|
|
63
|
+
errors: ['not_in_room', 'too_large'],
|
|
64
|
+
doc: 'Set your seat data (team, colour…), at most 2 KB of JSON.',
|
|
65
|
+
}),
|
|
66
|
+
'room.start': op({
|
|
67
|
+
d: NoInput,
|
|
68
|
+
ack: EmptyAck,
|
|
69
|
+
errors: ['not_in_room', 'not_host', 'race_room', 'in_progress', 'not_ready'],
|
|
70
|
+
doc: "Host: start once the game's minimum players are seated and everyone else is ready.",
|
|
71
|
+
}),
|
|
72
|
+
'room.send': op({
|
|
73
|
+
d: z.object({
|
|
74
|
+
/** The game message, at most 8 KB of JSON. */
|
|
75
|
+
data: Json.optional(),
|
|
76
|
+
/** Address one seat; otherwise the room model decides who hears it. */
|
|
77
|
+
to: UserId.nullish(),
|
|
78
|
+
}),
|
|
79
|
+
ack: SeqAck,
|
|
80
|
+
errors: ['not_in_room', 'ended', 'too_large', 'rate_limited', 'bad_target'],
|
|
81
|
+
doc: 'Relay a game message (at most 30 a second).',
|
|
82
|
+
}),
|
|
83
|
+
'room.state': op({
|
|
84
|
+
d: z.object({
|
|
85
|
+
/** Shallow-merged into the state; a `null` value deletes its key. */
|
|
86
|
+
patch: RoomState,
|
|
87
|
+
/** Replace the whole state with `patch`. */
|
|
88
|
+
replace: z.boolean().optional(),
|
|
89
|
+
}),
|
|
90
|
+
ack: SeqAck,
|
|
91
|
+
errors: ['not_in_room', 'race_room', 'not_host', 'bad_patch', 'too_large'],
|
|
92
|
+
invalid: { code: 'bad_patch', error: 'patch must be an object.' },
|
|
93
|
+
doc: 'Update the shared state (host only in the host model), at most 32 KB.',
|
|
94
|
+
}),
|
|
95
|
+
'room.chat': op({
|
|
96
|
+
d: ChatSend,
|
|
97
|
+
ack: ChatLine,
|
|
98
|
+
errors: ['not_in_room', 'room_not_found', ...CHAT_ERRORS],
|
|
99
|
+
doc: 'Moderated room chat: free text only when every seated player is a mutual friend.',
|
|
100
|
+
}),
|
|
101
|
+
'room.kick': op({
|
|
102
|
+
d: z.object({ userId: UserId }),
|
|
103
|
+
ack: EmptyAck,
|
|
104
|
+
errors: ['not_in_room', 'not_host', 'bad_target'],
|
|
105
|
+
doc: "Host: remove a player; they can't rejoin this room.",
|
|
106
|
+
}),
|
|
107
|
+
'room.end': op({
|
|
108
|
+
d: z.object({
|
|
109
|
+
/** Relayed to everyone in the final `room` event, untrusted; at most 8 KB of JSON. */
|
|
110
|
+
results: Json.optional(),
|
|
111
|
+
}),
|
|
112
|
+
ack: EmptyAck,
|
|
113
|
+
errors: ['not_in_room', 'not_host', 'race_room', 'too_large'],
|
|
114
|
+
doc: 'Host: end the match. Players become recent players; the room closes after a delay.',
|
|
115
|
+
}),
|
|
116
|
+
'room.list': op({
|
|
117
|
+
d: z.object({ game: Slug, mode: GameMode.optional() }),
|
|
118
|
+
ack: z.object({ rooms: z.array(RoomInfo) }),
|
|
119
|
+
errors: [],
|
|
120
|
+
doc: 'Public lobbies with a free seat (at most 50), minus rooms with blocked players.',
|
|
121
|
+
}),
|
|
122
|
+
} satisfies Record<string, OpDef>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Socket ops for presence, visibility, typing and the heartbeat
|
|
3
|
+
* (multiplayer-impl.md §4.3). `hello` is declared with the snapshot.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { Millis, UserId } from '../../common';
|
|
7
|
+
import { PresenceInput } from '../../api/social/presence';
|
|
8
|
+
import { op, type OpDef } from '../define';
|
|
9
|
+
import { EmptyAck, NoInput } from './empty';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `visibility` input: the tab's document visibility and idle state. Input
|
|
13
|
+
* that isn't an object counts as `{}` (visible, not idle); a field that isn't a
|
|
14
|
+
* boolean takes its default.
|
|
15
|
+
*/
|
|
16
|
+
export const VisibilityInput = z.object({
|
|
17
|
+
/** The document is visible (default `true`). */
|
|
18
|
+
visible: z.boolean().optional().catch(undefined),
|
|
19
|
+
/** The player has been idle (default `false`). Hidden or idle tabs count as `away`. */
|
|
20
|
+
idle: z.boolean().optional().catch(undefined),
|
|
21
|
+
}).catch({});
|
|
22
|
+
|
|
23
|
+
/** The social ops, by op name. */
|
|
24
|
+
export const socialOps = {
|
|
25
|
+
presence: op({
|
|
26
|
+
d: PresenceInput,
|
|
27
|
+
ack: EmptyAck,
|
|
28
|
+
errors: ['rate_limited'],
|
|
29
|
+
doc: "Report this tab's activity. Rate-limited per user; a rejected `detail` is dropped silently.",
|
|
30
|
+
}),
|
|
31
|
+
visibility: op({
|
|
32
|
+
d: VisibilityInput,
|
|
33
|
+
ack: EmptyAck,
|
|
34
|
+
errors: [],
|
|
35
|
+
doc: "Report this tab's visibility and idle state (hidden or idle tabs count as `away`).",
|
|
36
|
+
}),
|
|
37
|
+
typing: op({
|
|
38
|
+
d: z.object({
|
|
39
|
+
/**
|
|
40
|
+
* The friend you're typing to. Anything that isn't a friend's id is a
|
|
41
|
+
* silent no-op; a value that isn't a user id at all parses as `0` (no one).
|
|
42
|
+
*/
|
|
43
|
+
to: UserId.catch(0),
|
|
44
|
+
}),
|
|
45
|
+
ack: EmptyAck,
|
|
46
|
+
errors: [],
|
|
47
|
+
doc: 'Tell a friend you are typing (`ev:typing`), at most once per 2 s per pair.',
|
|
48
|
+
}),
|
|
49
|
+
ping: op({
|
|
50
|
+
d: NoInput,
|
|
51
|
+
ack: z.object({
|
|
52
|
+
/** The server clock (epoch ms). */
|
|
53
|
+
t: Millis,
|
|
54
|
+
}),
|
|
55
|
+
errors: [],
|
|
56
|
+
doc: 'Heartbeat and clock check; allowed before `hello`.',
|
|
57
|
+
}),
|
|
58
|
+
} satisfies Record<string, OpDef>;
|