@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,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
|
+
});
|
package/src/api/mp.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multiplayer REST (multiplayer-impl.md §5.6): invites, launch tokens,
|
|
3
|
+
* parties, joining a friend's game, and game-server tickets. Every route
|
|
4
|
+
* needs an active account; invites need a claimed one. The live state is in
|
|
5
|
+
* the hub's memory and changes are pushed over `/_ws` (`invite`,
|
|
6
|
+
* `invite.update`, `party`).
|
|
7
|
+
*/
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { Millis, Ok, Slug, UserIdParam } from '../common';
|
|
10
|
+
import { ErrorBody } from '../errors';
|
|
11
|
+
import { requireActive, requireClaimed } from '../middleware';
|
|
12
|
+
import { GameMode, Invite, JoinInfo, Launch, LaunchTicket, OpaqueId } from './mp/parties';
|
|
13
|
+
|
|
14
|
+
export * from './mp/chat';
|
|
15
|
+
export * from './mp/parties';
|
|
16
|
+
export * from './mp/races';
|
|
17
|
+
export * from './mp/rooms';
|
|
18
|
+
export * from './mp/snapshot';
|
|
19
|
+
|
|
20
|
+
/** `:id` of an invite or party. */
|
|
21
|
+
const IdParams = z.object({ id: OpaqueId });
|
|
22
|
+
|
|
23
|
+
/** The answer to `POST /mp/invites`: the invite, plus the inviter's own `host` launch. */
|
|
24
|
+
export const InviteCreated = LaunchTicket.extend({ invite: Invite });
|
|
25
|
+
|
|
26
|
+
/** A game-server identity ticket. */
|
|
27
|
+
export const GameTicket = z.object({
|
|
28
|
+
/** `base64url(payload).base64url(hmac)`, verified offline by the game's server with `HUB_APP_KEY`. */
|
|
29
|
+
ticket: z.string(),
|
|
30
|
+
/** Epoch ms; tickets live 60 s. */
|
|
31
|
+
expiresAt: Millis,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** The mp endpoints. */
|
|
35
|
+
export const mpEndpoints = {
|
|
36
|
+
...requireClaimed.group({
|
|
37
|
+
sendGameInvite: {
|
|
38
|
+
method: 'POST', path: '/mp/invites',
|
|
39
|
+
body: z.object({
|
|
40
|
+
/** The friend to invite. */
|
|
41
|
+
to: UserIdParam,
|
|
42
|
+
game: Slug,
|
|
43
|
+
/** One of the game's modes; race-only games default to `race`. */
|
|
44
|
+
mode: GameMode.nullish(),
|
|
45
|
+
}),
|
|
46
|
+
response: InviteCreated,
|
|
47
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody, 429: ErrorBody },
|
|
48
|
+
doc: {
|
|
49
|
+
summary: 'Invite a friend to a game; creates or reuses your party and returns your host launch',
|
|
50
|
+
tags: ['mp'],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
acceptGameInvite: {
|
|
54
|
+
method: 'POST', path: '/mp/invites/:id/accept', params: IdParams, response: LaunchTicket,
|
|
55
|
+
errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody },
|
|
56
|
+
doc: { summary: "Accept an invite: join the inviter's party and get a guest launch", tags: ['mp'] },
|
|
57
|
+
},
|
|
58
|
+
declineGameInvite: {
|
|
59
|
+
method: 'POST', path: '/mp/invites/:id/decline', params: IdParams, response: Ok,
|
|
60
|
+
errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
|
|
61
|
+
doc: { summary: 'Decline an invite addressed to you', tags: ['mp'] },
|
|
62
|
+
},
|
|
63
|
+
cancelGameInvite: {
|
|
64
|
+
method: 'DELETE', path: '/mp/invites/:id', params: IdParams, response: Ok,
|
|
65
|
+
errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
|
|
66
|
+
doc: { summary: 'Cancel an invite you sent', tags: ['mp'] },
|
|
67
|
+
},
|
|
68
|
+
}),
|
|
69
|
+
...requireActive.group({
|
|
70
|
+
redeemLaunchToken: {
|
|
71
|
+
method: 'GET', path: '/mp/launch/:token', params: z.object({ token: z.string() }),
|
|
72
|
+
response: z.object({ launch: Launch }),
|
|
73
|
+
errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
|
|
74
|
+
doc: { summary: 'Redeem a launch token (single use, bound to the signed-in user)', tags: ['mp'] },
|
|
75
|
+
},
|
|
76
|
+
setPartyJoinInfo: {
|
|
77
|
+
method: 'POST', path: '/mp/parties/:id/join-info', params: IdParams,
|
|
78
|
+
body: z.object({ info: JoinInfo }),
|
|
79
|
+
response: Ok,
|
|
80
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
|
|
81
|
+
doc: { summary: 'Party leader: publish where the party should connect', tags: ['mp'] },
|
|
82
|
+
},
|
|
83
|
+
leaveParty: {
|
|
84
|
+
method: 'POST', path: '/mp/parties/:id/leave', params: IdParams, response: Ok,
|
|
85
|
+
errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
|
|
86
|
+
doc: { summary: 'Leave a party (the leader role passes on; an empty party dissolves)', tags: ['mp'] },
|
|
87
|
+
},
|
|
88
|
+
launchIntoFriendsRoom: {
|
|
89
|
+
method: 'POST', path: '/mp/join',
|
|
90
|
+
body: z.object({
|
|
91
|
+
/** The player whose game to join or watch. */
|
|
92
|
+
userId: UserIdParam,
|
|
93
|
+
/** Watch instead of play. */
|
|
94
|
+
watch: z.boolean().optional(),
|
|
95
|
+
/** Joining a stranger's public lobby from a `lobby_open` alert (not a friend's game). */
|
|
96
|
+
fromLobbyOpen: z.boolean().optional(),
|
|
97
|
+
}),
|
|
98
|
+
response: LaunchTicket,
|
|
99
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody, 429: ErrorBody },
|
|
100
|
+
doc: { summary: "A Join or Watch launch into the room a friend's presence advertises", tags: ['mp'] },
|
|
101
|
+
},
|
|
102
|
+
getGameServerTicket: {
|
|
103
|
+
method: 'POST', path: '/mp/ticket',
|
|
104
|
+
body: z.object({ slug: Slug }),
|
|
105
|
+
response: GameTicket,
|
|
106
|
+
errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 429: ErrorBody },
|
|
107
|
+
doc: { summary: "A 60 s identity ticket for a game's own server", tags: ['mp'] },
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification preferences and Web Push subscriptions
|
|
3
|
+
* (multiplayer-impl.md §5.4). Everything is off by default and claimed-only.
|
|
4
|
+
* Which channel an alert takes (a live `ev:notice` toast or a push) is the
|
|
5
|
+
* hub's decision; see `ev:notice`.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { Ok, Slug, UserId } from '../common';
|
|
9
|
+
import { ErrorBody } from '../errors';
|
|
10
|
+
import { requireActive, requireClaimed } from '../middleware';
|
|
11
|
+
|
|
12
|
+
/** Notification preferences (`GET`/`PUT /social/notify`, `GET /social/me`). */
|
|
13
|
+
export const NotifyPrefs = z.object({
|
|
14
|
+
/** Push a new DM when no tab is open. */
|
|
15
|
+
dm: z.boolean(),
|
|
16
|
+
/** Push a friend request when no tab is open. */
|
|
17
|
+
friend_request: z.boolean(),
|
|
18
|
+
/** "Tell me when any friend comes online", on top of the per-friend bells. */
|
|
19
|
+
friendOnlineAll: z.boolean(),
|
|
20
|
+
/** Friends whose bell is on, ascending. */
|
|
21
|
+
friendOnline: z.array(UserId),
|
|
22
|
+
/** Games followed for "someone's waiting in a lobby", sorted. */
|
|
23
|
+
lobbyOpen: z.array(Slug),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** Notification preferences. */
|
|
27
|
+
export type NotifyPrefs = z.infer<typeof NotifyPrefs>;
|
|
28
|
+
|
|
29
|
+
/** The preference kinds `PUT /social/notify` can flip. */
|
|
30
|
+
export const NotifyPrefKind = z.enum(['dm', 'friend_request', 'friend_online', 'lobby_open'], {
|
|
31
|
+
error: 'kind must be dm, friend_request, friend_online or lobby_open.',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** A preference kind. */
|
|
35
|
+
export type NotifyPrefKind = z.infer<typeof NotifyPrefKind>;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One `PUT /social/notify` change. `target`: for `friend_online`, a friend's
|
|
39
|
+
* id (a number, or its decimal string) for that friend's bell, or absent
|
|
40
|
+
* (`''`, `null`) for the "any friend" switch; for `lobby_open`, the game slug.
|
|
41
|
+
* Ignored for `dm` and `friend_request`. Any other number or text is a 404
|
|
42
|
+
* (not a friend, no such game), as on the old hub. `enabled` is checked
|
|
43
|
+
* first, so its message wins when both fields are wrong.
|
|
44
|
+
*/
|
|
45
|
+
export const NotifyChange = z.object({
|
|
46
|
+
enabled: z.boolean({ error: 'enabled must be true or false.' }),
|
|
47
|
+
kind: NotifyPrefKind,
|
|
48
|
+
target: z.union([z.string(), z.number()]).nullable().optional(),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** A notification preference change. */
|
|
52
|
+
export type NotifyChange = z.infer<typeof NotifyChange>;
|
|
53
|
+
|
|
54
|
+
/** The 400 for a subscription the hub can't push to (malformed, or not on a known push service). */
|
|
55
|
+
export const UNSUPPORTED_SUBSCRIPTION = "That browser subscription isn't supported.";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A browser push subscription, as `PushSubscription.toJSON()` gives it. The
|
|
59
|
+
* endpoint must be `https` on a known push service; `p256dh` is a 65-byte
|
|
60
|
+
* and `auth` an at-least-16-byte base64url key.
|
|
61
|
+
*/
|
|
62
|
+
export const PushSubscriptionJson = z.object({
|
|
63
|
+
endpoint: z.string().max(1000, { error: UNSUPPORTED_SUBSCRIPTION }),
|
|
64
|
+
/** When the subscription expires (epoch ms), `null` when it doesn't. */
|
|
65
|
+
expirationTime: z.number().nullable().optional(),
|
|
66
|
+
keys: z.object({
|
|
67
|
+
p256dh: z.string().max(200, { error: UNSUPPORTED_SUBSCRIPTION }),
|
|
68
|
+
auth: z.string().max(100, { error: UNSUPPORTED_SUBSCRIPTION }),
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/** A browser push subscription. */
|
|
73
|
+
export type PushSubscriptionJson = z.infer<typeof PushSubscriptionJson>;
|
|
74
|
+
|
|
75
|
+
/** The notification endpoints. */
|
|
76
|
+
export const notifyEndpoints = {
|
|
77
|
+
...requireActive.group({
|
|
78
|
+
getNotificationPreferences: {
|
|
79
|
+
method: 'GET', path: '/social/notify', response: NotifyPrefs,
|
|
80
|
+
errors: { 401: ErrorBody, 403: ErrorBody },
|
|
81
|
+
doc: { summary: 'Your notification preferences (all off for guests)', tags: ['notify'] },
|
|
82
|
+
},
|
|
83
|
+
unsubscribeFromPush: {
|
|
84
|
+
method: 'DELETE', path: '/social/push',
|
|
85
|
+
body: z.object({ endpoint: z.string({ error: 'endpoint is required.' }).min(1, { error: 'endpoint is required.' }) }), response: Ok,
|
|
86
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody },
|
|
87
|
+
doc: { summary: "Forget one of this account's push subscriptions", tags: ['notify'] },
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
...requireClaimed.group({
|
|
91
|
+
setNotificationPreferences: {
|
|
92
|
+
method: 'PUT', path: '/social/notify', body: NotifyChange, response: NotifyPrefs,
|
|
93
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody },
|
|
94
|
+
doc: {
|
|
95
|
+
summary: 'Flip one notification preference',
|
|
96
|
+
description: '404 for a friend bell on a non-friend or an unknown game; 409 past 50 followed games.',
|
|
97
|
+
tags: ['notify'],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
subscribeToPush: {
|
|
101
|
+
method: 'POST', path: '/social/push',
|
|
102
|
+
body: z.object({ subscription: z.union([PushSubscriptionJson, z.never()], { error: UNSUPPORTED_SUBSCRIPTION }) }), response: Ok,
|
|
103
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody },
|
|
104
|
+
doc: {
|
|
105
|
+
summary: "Store this browser's push subscription",
|
|
106
|
+
description: 'One browser is one endpoint: subscribing from another account moves it. Past the per-user cap the oldest is dropped.',
|
|
107
|
+
tags: ['notify'],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
};
|