@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.
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +26 -3
- 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 +53 -6
- 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,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct messages between mutual friends (multiplayer-impl.md §5.2).
|
|
3
|
+
*
|
|
4
|
+
* "Checked before delivered": text is moderated before it is stored or
|
|
5
|
+
* pushed. Free text needs the conversation's chat mode to be `on` (both
|
|
6
|
+
* claimed, friends, no parent lock, site switch on); otherwise only
|
|
7
|
+
* quick-chat ids go through. Hidden messages and messages from currently
|
|
8
|
+
* suspended senders are left out of every read.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { Millis, Ok, UserId } from '../common';
|
|
12
|
+
import { LooseNumber, requiredId } from '../coerce';
|
|
13
|
+
import { PlayerLite } from '../entities';
|
|
14
|
+
import { ContentRejected, ErrorBody } from '../errors';
|
|
15
|
+
import { requireClaimed } from '../middleware';
|
|
16
|
+
import { ChatMode } from '../entities';
|
|
17
|
+
import { QuickId } from './social/config';
|
|
18
|
+
|
|
19
|
+
/** A message id: a positive integer, increasing over time. */
|
|
20
|
+
export const MessageId = z.number().int().positive();
|
|
21
|
+
|
|
22
|
+
/** A direct message. */
|
|
23
|
+
export const Message = z.object({
|
|
24
|
+
id: MessageId,
|
|
25
|
+
/** The sender. */
|
|
26
|
+
from: UserId,
|
|
27
|
+
/** The recipient. */
|
|
28
|
+
to: UserId,
|
|
29
|
+
/** The delivered (possibly softened) text; a quick message carries its phrase's text. */
|
|
30
|
+
text: z.string(),
|
|
31
|
+
/** The quick-chat phrase id, for quick messages only. */
|
|
32
|
+
quick: QuickId.optional(),
|
|
33
|
+
/** When it was sent (epoch ms). */
|
|
34
|
+
at: Millis,
|
|
35
|
+
/** The recipient has read it. */
|
|
36
|
+
read: z.boolean(),
|
|
37
|
+
/** Words were swapped by the filter. Only on the sender's own copy. */
|
|
38
|
+
softened: z.literal(true).optional(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** A direct message. */
|
|
42
|
+
export type Message = z.infer<typeof Message>;
|
|
43
|
+
|
|
44
|
+
/** A conversation summary: the other player, the last message and how many of theirs you haven't read. */
|
|
45
|
+
export const Conversation = z.object({
|
|
46
|
+
user: PlayerLite,
|
|
47
|
+
last: Message,
|
|
48
|
+
unread: z.number().int().nonnegative(),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** A conversation summary. */
|
|
52
|
+
export type Conversation = z.infer<typeof Conversation>;
|
|
53
|
+
|
|
54
|
+
/** One page of a conversation's history. */
|
|
55
|
+
export const MessagesPage = z.object({
|
|
56
|
+
/** Oldest to newest, at most 50. */
|
|
57
|
+
messages: z.array(Message),
|
|
58
|
+
/** Older messages exist (page with `?before=` the first id). */
|
|
59
|
+
more: z.boolean(),
|
|
60
|
+
/** The mode the chat UI should render: `off` when DMs are off, `quick` while you are muted. */
|
|
61
|
+
mode: ChatMode,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** A page of history. */
|
|
65
|
+
export type MessagesPage = z.infer<typeof MessagesPage>;
|
|
66
|
+
|
|
67
|
+
/** A DM to send: free text, or a quick-chat phrase id (exactly one). */
|
|
68
|
+
export const SendMessageInput = z.object({
|
|
69
|
+
/** Free text (moderated; only when the conversation's mode is `on`). */
|
|
70
|
+
text: z.string().optional(),
|
|
71
|
+
/** A quick-chat phrase id (any mode but `off`). */
|
|
72
|
+
quick: QuickId.optional(),
|
|
73
|
+
}).refine((b) => (b.text === undefined) !== (b.quick === undefined), 'Send either text or a quick-chat id.');
|
|
74
|
+
|
|
75
|
+
/** A DM to send. */
|
|
76
|
+
export type SendMessageInput = z.infer<typeof SendMessageInput>;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Unread DMs (the `hello` snapshot's `unread`), ignoring hidden messages and
|
|
80
|
+
* suspended senders.
|
|
81
|
+
*/
|
|
82
|
+
export const UnreadSummary = z.object({
|
|
83
|
+
/** Total unread. */
|
|
84
|
+
dms: z.number().int().nonnegative(),
|
|
85
|
+
/** Unread per sender, keyed by the sender's user id (as a decimal string). */
|
|
86
|
+
byUser: z.record(z.string().regex(/^\d+$/), z.number().int().positive()),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** Unread DM counts. */
|
|
90
|
+
export type UnreadSummary = z.infer<typeof UnreadSummary>;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* `:userId`, the other side of the conversation: a claimed, non-suspended
|
|
94
|
+
* player other than you, else 404 "No such player." Read like `Number(x)`:
|
|
95
|
+
* anything that is not a player's id is the same 404.
|
|
96
|
+
*/
|
|
97
|
+
const Partner = z.object({ userId: LooseNumber.catch(Number.NaN) });
|
|
98
|
+
|
|
99
|
+
/** The direct-message endpoints. */
|
|
100
|
+
export const messagesEndpoints = {
|
|
101
|
+
...requireClaimed.group({
|
|
102
|
+
listConversations: {
|
|
103
|
+
method: 'GET', path: '/social/conversations',
|
|
104
|
+
response: z.object({
|
|
105
|
+
/** Newest first, one per partner, at most 200. */
|
|
106
|
+
conversations: z.array(Conversation),
|
|
107
|
+
}),
|
|
108
|
+
errors: { 401: ErrorBody, 403: ErrorBody },
|
|
109
|
+
doc: { summary: 'Your conversations, newest first', tags: ['messages'] },
|
|
110
|
+
},
|
|
111
|
+
listDirectMessages: {
|
|
112
|
+
method: 'GET', path: '/social/messages/:userId', params: Partner,
|
|
113
|
+
query: z.object({
|
|
114
|
+
/** Only messages older than this message id; `0` or anything not a number means from the newest. */
|
|
115
|
+
before: LooseNumber.optional().catch(undefined),
|
|
116
|
+
}),
|
|
117
|
+
response: MessagesPage,
|
|
118
|
+
errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
|
|
119
|
+
doc: { summary: 'One page of history with a player', tags: ['messages'] },
|
|
120
|
+
},
|
|
121
|
+
sendDirectMessage: {
|
|
122
|
+
method: 'POST', path: '/social/messages/:userId', params: Partner, body: SendMessageInput,
|
|
123
|
+
response: z.object({ message: Message }),
|
|
124
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 422: ContentRejected },
|
|
125
|
+
doc: {
|
|
126
|
+
summary: 'Send a DM to a friend',
|
|
127
|
+
description: '403 `not_friends` or `dms_closed`; 422 when the text is rejected, the chat mode forbids it, or you are sending too fast.',
|
|
128
|
+
tags: ['messages'],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
markDirectMessagesRead: {
|
|
132
|
+
method: 'POST', path: '/social/messages/:userId/read', params: Partner,
|
|
133
|
+
body: z.object({
|
|
134
|
+
/** Mark their messages to you up to and including this id. */
|
|
135
|
+
upTo: requiredId('upTo must be a message id.'),
|
|
136
|
+
}),
|
|
137
|
+
response: Ok,
|
|
138
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
|
|
139
|
+
doc: { summary: "Mark a friend's messages to you as read", tags: ['messages'] },
|
|
140
|
+
},
|
|
141
|
+
}),
|
|
142
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group chat in hub rooms and parties (multiplayer.md §4.3, §4.8): what a
|
|
3
|
+
* player sends, and the moderated line everyone receives.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { UserId } from '../../common';
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A chat send: a quick-chat phrase id, or free text. When `quick` is set it
|
|
11
|
+
* wins and `text` is ignored.
|
|
12
|
+
*/
|
|
13
|
+
export const ChatSend = z.object({
|
|
14
|
+
/** Free text; only allowed in mode `on`, then moderated. */
|
|
15
|
+
text: z.string().optional(),
|
|
16
|
+
/** A quick-chat phrase id (hub phrase, `e:<emoji>` emote or game phrase). */
|
|
17
|
+
quick: z.string().optional(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** The line as delivered: the (possibly softened) text, and the phrase id for quick-chat. */
|
|
21
|
+
export const ChatLine = z.object({
|
|
22
|
+
text: z.string(),
|
|
23
|
+
/** Set when the line is a quick-chat phrase. */
|
|
24
|
+
quick: z.string().optional(),
|
|
25
|
+
/** Moderation reworded the text. */
|
|
26
|
+
softened: z.literal(true).optional(),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/** A delivered chat line. */
|
|
30
|
+
export type ChatLine = z.infer<typeof ChatLine>;
|
|
31
|
+
|
|
32
|
+
/** A chat line pushed to a room or party, with its sender. */
|
|
33
|
+
export const ChatLineEvent = ChatLine.extend({ from: UserId });
|
|
34
|
+
|
|
35
|
+
/** The error codes a chat send can fail with (besides its surface's own). */
|
|
36
|
+
export const CHAT_ERRORS = ['rate_limited', 'content_rejected', 'bad_quick'] as const;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invites, parties and launches (multiplayer-impl.md §5.6): the shapes the
|
|
3
|
+
* mp endpoints, the `invite` / `invite.update` / `party` socket events and
|
|
4
|
+
* the `hello` snapshot share. All of it lives in the hub's memory; a restart
|
|
5
|
+
* drops it.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { Millis, Slug, UserId } from '../../common';
|
|
9
|
+
import { PlayerLite } from '../../entities';
|
|
10
|
+
|
|
11
|
+
/** A game mode key, as the game's `multiplayer.modes[].key` declares it: `[A-Za-z0-9_-]{1,32}`. */
|
|
12
|
+
export const GameMode = z.string().regex(/^[A-Za-z0-9_-]{1,32}$/);
|
|
13
|
+
|
|
14
|
+
/** An opaque hub-minted id (party, invite, room): base64url text. */
|
|
15
|
+
export const OpaqueId = z.string().min(1);
|
|
16
|
+
|
|
17
|
+
/** A `joinInfo` key: `[A-Za-z0-9_]{1,32}`. */
|
|
18
|
+
const JoinInfoKey = z.string().regex(/^[A-Za-z0-9_]{1,32}$/);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where a party should connect, as its leader publishes it (e.g.
|
|
22
|
+
* `{ room: 'ABCDE' }`): at most 8 short keys, string values of at most 64
|
|
23
|
+
* characters. The hub accepts finite numbers and stores them as strings.
|
|
24
|
+
*/
|
|
25
|
+
export const JoinInfo = z
|
|
26
|
+
.record(JoinInfoKey, z.union([z.string().max(64), z.number().transform(String)]))
|
|
27
|
+
.refine((joinInfo) => Object.keys(joinInfo).length <= 8, 'info may have at most 8 keys');
|
|
28
|
+
|
|
29
|
+
/** A party's join info. */
|
|
30
|
+
export type JoinInfo = z.infer<typeof JoinInfo>;
|
|
31
|
+
|
|
32
|
+
/** A group of 2 to 8 players moving between games together. A user is in at most one. */
|
|
33
|
+
export const Party = z.object({
|
|
34
|
+
id: OpaqueId,
|
|
35
|
+
game: Slug,
|
|
36
|
+
/** Present when the party formed for a specific mode (`race` for race invites). */
|
|
37
|
+
mode: GameMode.optional(),
|
|
38
|
+
leaderId: UserId,
|
|
39
|
+
members: z.array(PlayerLite),
|
|
40
|
+
/** `null` until the leader publishes where to connect. */
|
|
41
|
+
joinInfo: JoinInfo.nullable(),
|
|
42
|
+
/** Epoch ms. */
|
|
43
|
+
createdAt: Millis,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** A party. */
|
|
47
|
+
export type Party = z.infer<typeof Party>;
|
|
48
|
+
|
|
49
|
+
/** "Come play <game> with me": pending until accepted, declined, cancelled or expired. */
|
|
50
|
+
export const Invite = z.object({
|
|
51
|
+
id: OpaqueId,
|
|
52
|
+
from: PlayerLite,
|
|
53
|
+
to: PlayerLite,
|
|
54
|
+
game: Slug,
|
|
55
|
+
gameTitle: z.string(),
|
|
56
|
+
mode: GameMode.optional(),
|
|
57
|
+
/** The inviter's party the invitee joins on accept. */
|
|
58
|
+
partyId: OpaqueId,
|
|
59
|
+
/** Epoch ms. */
|
|
60
|
+
expiresAt: Millis,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** An invite. */
|
|
64
|
+
export type Invite = z.infer<typeof Invite>;
|
|
65
|
+
|
|
66
|
+
/** How an invite ended. */
|
|
67
|
+
export const InviteStatus = z.enum(['accepted', 'declined', 'expired', 'cancelled']);
|
|
68
|
+
|
|
69
|
+
/** How an invite ended. */
|
|
70
|
+
export type InviteStatus = z.infer<typeof InviteStatus>;
|
|
71
|
+
|
|
72
|
+
/** A single-use launch token plus the game URL that redeems it (`?hub_launch=<token>`). */
|
|
73
|
+
export const LaunchTicket = z.object({
|
|
74
|
+
launchToken: z.string(),
|
|
75
|
+
/** `/<slug>/<lobby>` with `hub_launch` appended. */
|
|
76
|
+
url: z.string(),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/** A launch ticket. */
|
|
80
|
+
export type LaunchTicket = z.infer<typeof LaunchTicket>;
|
|
81
|
+
|
|
82
|
+
/** Why the hub is bringing a player into a game. */
|
|
83
|
+
export const LaunchKind = z.enum(['host', 'guest', 'join', 'watch']);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What a game's `hub.mp.onLaunch` receives after redeeming a launch token.
|
|
87
|
+
* Party fields reflect the party as it is at redemption time.
|
|
88
|
+
*/
|
|
89
|
+
export const Launch = z.object({
|
|
90
|
+
kind: LaunchKind,
|
|
91
|
+
game: Slug,
|
|
92
|
+
mode: GameMode.optional(),
|
|
93
|
+
/** Host and guest launches: the party they belong to. */
|
|
94
|
+
partyId: OpaqueId.optional(),
|
|
95
|
+
/** The party now; `null` when it has dissolved since the token was minted. */
|
|
96
|
+
party: Party.nullable().optional(),
|
|
97
|
+
/** The party's join info now; `null` when unset or the party is gone. */
|
|
98
|
+
joinInfo: JoinInfo.nullable().optional(),
|
|
99
|
+
/** Join and watch launches: the room code the friend's presence advertised. */
|
|
100
|
+
room: z.string().optional(),
|
|
101
|
+
/** Guest launches: the party leader (`null` if the account is gone). */
|
|
102
|
+
host: PlayerLite.nullable().optional(),
|
|
103
|
+
/** Join and watch launches: the friend being joined (`null` if the account is gone). */
|
|
104
|
+
target: PlayerLite.nullable().optional(),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/** A launch. */
|
|
108
|
+
export type Launch = z.infer<typeof Launch>;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Race shapes (docs/plans/race.md): params, live stats, racers and decided
|
|
3
|
+
* results. Shared by the `race.*` socket ops and events, the race block of
|
|
4
|
+
* a room, and `GET /race/history`. Each rule here is defined once.
|
|
5
|
+
*/
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { HexColor, Millis, Slug, UserId } from '../../common';
|
|
8
|
+
import { RACE_STATS_LIMITS } from '../../limits';
|
|
9
|
+
|
|
10
|
+
/** A race param or stat key: a letter, then up to 23 letters, digits or `_`. */
|
|
11
|
+
export const RaceKey = z.string().regex(/^[A-Za-z][A-Za-z0-9_]{0,23}$/);
|
|
12
|
+
|
|
13
|
+
/** A race param value, one of the game's declared options: a number, a boolean or a short token. */
|
|
14
|
+
export const RaceParamValue = z.union([
|
|
15
|
+
z.number(),
|
|
16
|
+
z.boolean(),
|
|
17
|
+
z.string().regex(/^[A-Za-z0-9_-]{1,24}$/),
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The creator's choices, keyed by the manifest's `race.params[].key`. The
|
|
22
|
+
* hub keeps only declared keys and option values (anything else becomes the
|
|
23
|
+
* param's default), so everyone gets the same puzzle.
|
|
24
|
+
*/
|
|
25
|
+
export const RaceParams = z.record(RaceKey, RaceParamValue);
|
|
26
|
+
|
|
27
|
+
/** A race's params. */
|
|
28
|
+
export type RaceParams = z.infer<typeof RaceParams>;
|
|
29
|
+
|
|
30
|
+
/** Live stat values are clamped to ±1e9. */
|
|
31
|
+
const STAT_LIMIT = 1e9;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A live stat value: a finite number (clamped to ±1e9), a boolean or a
|
|
35
|
+
* `#rrggbb` colour (lower-cased). Never text.
|
|
36
|
+
*/
|
|
37
|
+
export const RaceStatValue = z.union([
|
|
38
|
+
z.number().transform((n) => Math.max(-STAT_LIMIT, Math.min(STAT_LIMIT, n))),
|
|
39
|
+
z.boolean(),
|
|
40
|
+
HexColor.transform((c) => c.toLowerCase()),
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
/** A live stat value, as cleaned by the hub. */
|
|
44
|
+
export type RaceStatValue = z.output<typeof RaceStatValue>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A racer's live stats, keyed like the manifest's `race.stats[].key`: at
|
|
48
|
+
* most 12 keys and 600 bytes of JSON once cleaned.
|
|
49
|
+
*/
|
|
50
|
+
export const RaceStats = z
|
|
51
|
+
.record(RaceKey, RaceStatValue)
|
|
52
|
+
.refine((s) => Object.keys(s).length <= RACE_STATS_LIMITS.maxKeys, `at most ${String(RACE_STATS_LIMITS.maxKeys)} stats`)
|
|
53
|
+
.refine((s) => JSON.stringify(s).length <= RACE_STATS_LIMITS.maxBytes, 'stats too large');
|
|
54
|
+
|
|
55
|
+
/** A racer's stats. */
|
|
56
|
+
export type RaceStats = z.infer<typeof RaceStats>;
|
|
57
|
+
|
|
58
|
+
/** How far along a racer is, 0 to 1. The hub clamps values outside that range. */
|
|
59
|
+
export const RaceProgress = z.number().transform((n) => Math.max(0, Math.min(1, n)));
|
|
60
|
+
|
|
61
|
+
/** A race room's phase: waiting for racers → countdown → racing → done (→ countdown on a rematch). */
|
|
62
|
+
export const RacePhase = z.enum(['waiting', 'countdown', 'racing', 'done']);
|
|
63
|
+
|
|
64
|
+
/** A racer's state while the race runs. */
|
|
65
|
+
export const RacerState = z.enum(['racing', 'finished', 'lost', 'forfeit', 'disconnected']);
|
|
66
|
+
|
|
67
|
+
/** A value of {@link RacerState}. */
|
|
68
|
+
export type RacerState = z.infer<typeof RacerState>;
|
|
69
|
+
|
|
70
|
+
/** How a race ended: the winner finished, everyone else lost / gave up / dropped, or time ran out. */
|
|
71
|
+
export const RaceOutcome = z.enum(['finished', 'lost', 'forfeit', 'disconnected', 'timeout']);
|
|
72
|
+
|
|
73
|
+
/** A value of {@link RaceOutcome}. */
|
|
74
|
+
export type RaceOutcome = z.infer<typeof RaceOutcome>;
|
|
75
|
+
|
|
76
|
+
/** One racer's result. `beaten`: still racing when someone else won. */
|
|
77
|
+
export const RacerResult = z.enum(['won', 'beaten', 'lost', 'forfeit', 'disconnected', 'timeout']);
|
|
78
|
+
|
|
79
|
+
/** A value of {@link RacerResult}. */
|
|
80
|
+
export type RacerResult = z.infer<typeof RacerResult>;
|
|
81
|
+
|
|
82
|
+
/** Fields every live racer line has (room roster and result card). */
|
|
83
|
+
const RacerLine = z.object({
|
|
84
|
+
userId: UserId,
|
|
85
|
+
/** The racer's username when the race began. */
|
|
86
|
+
name: z.string(),
|
|
87
|
+
/** Avatar token; never empty. */
|
|
88
|
+
avatar: z.string(),
|
|
89
|
+
stats: RaceStats,
|
|
90
|
+
/** 0..1, when the game reports it. */
|
|
91
|
+
progress: RaceProgress.optional(),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/** A racer in a race room's roster (RoomInfo.race.players). */
|
|
95
|
+
export const Racer = RacerLine.extend({
|
|
96
|
+
state: RacerState,
|
|
97
|
+
/** Server-measured ms from the go to finishing or dropping out. */
|
|
98
|
+
ms: z.number().int().nonnegative().optional(),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/** A racer in a race room. */
|
|
102
|
+
export type Racer = z.infer<typeof Racer>;
|
|
103
|
+
|
|
104
|
+
/** One racer's line in a {@link RaceResult}. */
|
|
105
|
+
export const RaceResultPlayer = RacerLine.extend({
|
|
106
|
+
/** 1 for the winner. */
|
|
107
|
+
place: z.number().int().positive(),
|
|
108
|
+
result: RacerResult,
|
|
109
|
+
/** Server-measured ms from the go to finishing or dropping out (the race's end for those still in). */
|
|
110
|
+
ms: z.number().int().nonnegative(),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/** Fields a decided race has both live and in the history. */
|
|
114
|
+
const DecidedRace = z.object({
|
|
115
|
+
game: Slug,
|
|
116
|
+
params: RaceParams,
|
|
117
|
+
outcome: RaceOutcome,
|
|
118
|
+
/** Epoch ms of the go. */
|
|
119
|
+
startedAt: Millis,
|
|
120
|
+
/** Epoch ms. */
|
|
121
|
+
endedAt: Millis,
|
|
122
|
+
/** ms from the go to the decision. */
|
|
123
|
+
durationMs: z.number().int().nonnegative(),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/** A decided race: the `race.result` event, the `race.finish` ack and RoomInfo.race.result. */
|
|
127
|
+
export const RaceResult = DecidedRace.extend({
|
|
128
|
+
/** The race's id in the history; `null` if recording it failed. */
|
|
129
|
+
id: z.number().int().positive().nullable(),
|
|
130
|
+
/** The room code. */
|
|
131
|
+
room: z.string(),
|
|
132
|
+
/** 1 for the room's first race, +1 per rematch. */
|
|
133
|
+
round: z.number().int().positive(),
|
|
134
|
+
seed: z.number().int().positive(),
|
|
135
|
+
/** `null` when nobody won (time ran out, or everyone dropped out). */
|
|
136
|
+
winnerId: UserId.nullable(),
|
|
137
|
+
/** In finishing order (place 1 first). */
|
|
138
|
+
players: z.array(RaceResultPlayer),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/** A decided race. */
|
|
142
|
+
export type RaceResult = z.infer<typeof RaceResult>;
|
|
143
|
+
|
|
144
|
+
/** The referee's view of a race room (RoomInfo.race). */
|
|
145
|
+
export const RaceState = z.object({
|
|
146
|
+
phase: RacePhase,
|
|
147
|
+
/** 1 for the first race in the room, +1 per rematch; 0 while waiting for the first. */
|
|
148
|
+
round: z.number().int().nonnegative(),
|
|
149
|
+
params: RaceParams,
|
|
150
|
+
/** The countdown can start once this many racers are seated. */
|
|
151
|
+
minPlayers: z.number().int().positive(),
|
|
152
|
+
/** Everyone racing this round (empty while waiting for the first). */
|
|
153
|
+
players: z.array(Racer),
|
|
154
|
+
/** Who asked for a rematch since the last race. */
|
|
155
|
+
rematch: z.array(UserId),
|
|
156
|
+
/** Present once the countdown starts (never before, so nobody can pre-solve). */
|
|
157
|
+
seed: z.number().int().positive().optional(),
|
|
158
|
+
/** Epoch ms of the go (server clock); present with `seed`. */
|
|
159
|
+
startAt: Millis.optional(),
|
|
160
|
+
/** Present once the round is decided. */
|
|
161
|
+
result: RaceResult.optional(),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
/** A race room's referee state. */
|
|
165
|
+
export type RaceState = z.infer<typeof RaceState>;
|
|
166
|
+
|
|
167
|
+
/** The countdown began (`race.start`): build the puzzle from `seed` + `params`. */
|
|
168
|
+
export const RaceStart = z.object({
|
|
169
|
+
roomId: z.string(),
|
|
170
|
+
round: z.number().int().positive(),
|
|
171
|
+
seed: z.number().int().positive(),
|
|
172
|
+
params: RaceParams,
|
|
173
|
+
/** Epoch ms of the go (server clock). */
|
|
174
|
+
startAt: Millis,
|
|
175
|
+
/** ms until the go. */
|
|
176
|
+
startsIn: z.number().int().nonnegative(),
|
|
177
|
+
/** The racers' user ids. */
|
|
178
|
+
players: z.array(UserId),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
/** A racer in a history record. Suspended opponents are `hidden`: id 0, name "A player", no avatar. */
|
|
182
|
+
export const RaceRecordPlayer = z.object({
|
|
183
|
+
/** 0 when hidden. */
|
|
184
|
+
userId: z.number().int().nonnegative(),
|
|
185
|
+
username: z.string(),
|
|
186
|
+
/** Avatar token; `''` when hidden. */
|
|
187
|
+
avatar: z.string(),
|
|
188
|
+
guest: z.boolean(),
|
|
189
|
+
place: z.number().int().positive(),
|
|
190
|
+
result: RacerResult,
|
|
191
|
+
/** Server-measured ms from the go to finishing or dropping out. */
|
|
192
|
+
ms: z.number().int().nonnegative().optional(),
|
|
193
|
+
progress: RaceProgress.optional(),
|
|
194
|
+
stats: RaceStats,
|
|
195
|
+
hidden: z.literal(true).optional(),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
/** One past race the player was in (`GET /race/history`). */
|
|
199
|
+
export const RaceRecord = DecidedRace.extend({
|
|
200
|
+
id: z.number().int().positive(),
|
|
201
|
+
gameTitle: z.string(),
|
|
202
|
+
/** `null` when nobody won; 0 when the winner is hidden (suspended). */
|
|
203
|
+
winnerId: z.number().int().nonnegative().nullable(),
|
|
204
|
+
/** The signed-in player's own line (`null` only if their row is missing). */
|
|
205
|
+
me: RaceRecordPlayer.nullable(),
|
|
206
|
+
/** In place order. */
|
|
207
|
+
players: z.array(RaceRecordPlayer),
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
/** A past race. */
|
|
211
|
+
export type RaceRecord = z.infer<typeof RaceRecord>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub relay rooms (multiplayer-impl.md §6): realtime multiplayer for static
|
|
3
|
+
* games, relayed (never refereed) by the hub. A race is a room with a
|
|
4
|
+
* `race` block on top. Shared by the `room.*` / `race.*` ops and events and
|
|
5
|
+
* the `hello` snapshot.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { Json, Slug, UserId } from '../../common';
|
|
9
|
+
import { ChatMode } from '../../entities';
|
|
10
|
+
import { GameMode, OpaqueId } from './parties';
|
|
11
|
+
import { RaceState } from './races';
|
|
12
|
+
|
|
13
|
+
/** A room's join code: 5 characters from `A-Z` and `2-9` (no `I`, `O`, `0`, `1`). */
|
|
14
|
+
export const RoomCode = z.string().regex(/^[A-HJ-NP-Z2-9]{5}$/);
|
|
15
|
+
|
|
16
|
+
/** A room's lifecycle: new players join only in the lobby. */
|
|
17
|
+
export const RoomPhase = z.enum(['lobby', 'playing', 'ended']);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Who hears a send. `host` (default): guests' unaddressed sends reach the
|
|
21
|
+
* host only. `relay`: every unaddressed send reaches everyone, sender
|
|
22
|
+
* included.
|
|
23
|
+
*/
|
|
24
|
+
export const RoomModel = z.enum(['host', 'relay']);
|
|
25
|
+
|
|
26
|
+
/** A seat in a room, keyed by user (any tab of that user reclaims it). */
|
|
27
|
+
export const RoomSeat = z.object({
|
|
28
|
+
userId: UserId,
|
|
29
|
+
/** The player's username when they sat down. */
|
|
30
|
+
name: z.string(),
|
|
31
|
+
/** Avatar token; never empty. */
|
|
32
|
+
avatar: z.string(),
|
|
33
|
+
ready: z.boolean(),
|
|
34
|
+
/** `false` while the seat is held for a dropped player's reconnect grace. */
|
|
35
|
+
connected: z.boolean(),
|
|
36
|
+
/** Small per-seat game data (team, colour…), at most 2 KB of JSON. */
|
|
37
|
+
data: Json.optional(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** The public description of a room (`room` event, room and race acks, the `hello` snapshot). */
|
|
41
|
+
export const RoomInfo = z.object({
|
|
42
|
+
id: OpaqueId,
|
|
43
|
+
code: RoomCode,
|
|
44
|
+
game: Slug,
|
|
45
|
+
/** `race` for race rooms. */
|
|
46
|
+
mode: GameMode.optional(),
|
|
47
|
+
hostId: UserId,
|
|
48
|
+
/** Listed in `room.list` / `race.list`. */
|
|
49
|
+
public: z.boolean(),
|
|
50
|
+
phase: RoomPhase,
|
|
51
|
+
maxPlayers: z.number().int().positive(),
|
|
52
|
+
model: RoomModel,
|
|
53
|
+
seats: z.array(RoomSeat),
|
|
54
|
+
/** How many are watching. */
|
|
55
|
+
spectators: z.number().int().nonnegative(),
|
|
56
|
+
/** The chat mode for the seated players. */
|
|
57
|
+
chatMode: ChatMode,
|
|
58
|
+
/** Present on race rooms. */
|
|
59
|
+
race: RaceState.optional(),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** A room. */
|
|
63
|
+
export type RoomInfo = z.infer<typeof RoomInfo>;
|
|
64
|
+
|
|
65
|
+
/** A room's shared state for late joiners: a JSON object, at most 32 KB. */
|
|
66
|
+
export const RoomState = z.record(z.string(), Json);
|
|
67
|
+
|
|
68
|
+
/** A relayed game message (`room.msg`, and a join's replay history). */
|
|
69
|
+
export const RoomMessage = z.object({
|
|
70
|
+
from: UserId,
|
|
71
|
+
data: Json,
|
|
72
|
+
/** Per-room sequence number, shared with state patches. */
|
|
73
|
+
seq: z.number().int().positive(),
|
|
74
|
+
/** Set when the message went to one seat only. */
|
|
75
|
+
to: UserId.optional(),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/** How to find a room: by its code (case-insensitive) or its id. */
|
|
79
|
+
export const RoomTarget = z.object({
|
|
80
|
+
code: z.string().optional(),
|
|
81
|
+
id: z.string().optional(),
|
|
82
|
+
/** Watch instead of taking a seat (if the game allows spectators). */
|
|
83
|
+
spectate: z.boolean().optional(),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/** Why a room went away for this tab (`room.closed`). */
|
|
87
|
+
export const RoomClosedReason = z.enum(['empty', 'ended', 'host_left', 'moved', 'other_tab', 'removed']);
|
|
88
|
+
|
|
89
|
+
/** Why a seat was freed (`room.abandoned`). */
|
|
90
|
+
export const SeatGoneReason = z.enum(['left', 'abandoned', 'kicked']);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** The multiplayer part of the socket `hello` snapshot (multiplayer-impl.md §4.5). */
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { Invite, Party } from './parties';
|
|
4
|
+
import { RoomInfo } from './rooms';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The multiplayer fields of the `hello` ack, to spread into the snapshot
|
|
8
|
+
* schema (`z.object({ ...other, ...MultiplayerSnapshot.shape })`).
|
|
9
|
+
*/
|
|
10
|
+
export const MultiplayerSnapshot = z.object({
|
|
11
|
+
/** Pending invites addressed to me. */
|
|
12
|
+
invites: z.array(Invite),
|
|
13
|
+
/** The party I'm in, or `null`. */
|
|
14
|
+
party: Party.nullable(),
|
|
15
|
+
/** The room seat I still hold (not ended), or `null`; `room.join` it again to reconnect. */
|
|
16
|
+
room: RoomInfo.nullable(),
|
|
17
|
+
});
|