@diffenderfer-games/hub-contract 2.0.0-next.0 → 2.0.0-next.2
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 +24 -0
- package/LICENSE +21 -21
- package/README.md +26 -26
- package/dist/index.d.ts +2683 -39
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +862 -18
- package/dist/index.js.map +1 -1
- package/package.json +53 -53
- package/src/api/analytics.ts +81 -81
- package/src/api/auth.ts +77 -77
- package/src/api/catalog-activity.ts +66 -0
- package/src/api/catalog.ts +177 -171
- package/src/api/daily.ts +122 -122
- package/src/api/economy/bundle.ts +69 -69
- package/src/api/index.ts +92 -85
- package/src/api/leaderboards.ts +151 -151
- package/src/api/messages.ts +142 -142
- package/src/api/mp/chat.ts +36 -36
- package/src/api/mp/invite-links.ts +42 -0
- package/src/api/mp/parties.ts +5 -0
- package/src/api/mp/races.ts +211 -211
- package/src/api/mp/rooms.ts +122 -90
- package/src/api/mp/snapshot.ts +17 -17
- package/src/api/mp.ts +135 -110
- package/src/api/notify.ts +114 -111
- package/src/api/offline.ts +45 -0
- package/src/api/progress/game-data.ts +189 -189
- package/src/api/progress.ts +59 -59
- package/src/api/race.ts +47 -47
- package/src/api/reports.ts +208 -208
- package/src/api/social/config.ts +47 -47
- package/src/api/social/endpoints.ts +179 -179
- package/src/api/social/people.ts +118 -118
- package/src/api/social/presence.ts +95 -95
- package/src/api/social.ts +9 -9
- package/src/api/tournaments/endpoints.ts +102 -0
- package/src/api/tournaments/play-online.ts +71 -0
- package/src/api/tournaments/shapes.ts +202 -0
- package/src/api/tournaments.ts +7 -0
- package/src/api/version.ts +40 -40
- package/src/coerce.ts +50 -50
- package/src/common.ts +42 -42
- package/src/entities.ts +62 -62
- package/src/errors/codes/core.ts +10 -10
- package/src/errors/codes/multiplayer.ts +56 -43
- package/src/errors/codes/play.ts +5 -5
- package/src/errors/codes/social.ts +31 -31
- package/src/errors/index.ts +88 -88
- package/src/index.ts +16 -16
- package/src/limits.ts +125 -100
- package/src/logic/daily.ts +54 -54
- package/src/logic/index.ts +8 -8
- package/src/logic/presence.ts +62 -62
- package/src/logic/time.ts +81 -81
- package/src/manifest/changes.ts +55 -55
- package/src/manifest/controls.ts +42 -42
- package/src/manifest/fields.ts +89 -89
- package/src/manifest/game.ts +139 -132
- package/src/manifest/index.ts +13 -12
- package/src/manifest/inject.ts +31 -31
- package/src/manifest/labels.ts +99 -0
- package/src/manifest/leaderboards.ts +29 -29
- package/src/manifest/multiplayer.ts +79 -71
- package/src/manifest/race.ts +93 -93
- package/src/middleware.ts +47 -47
- package/src/realtime/define.ts +52 -52
- package/src/realtime/envelope.ts +67 -67
- package/src/realtime/events/multiplayer.ts +9 -8
- package/src/realtime/events/parties.ts +31 -31
- package/src/realtime/events/races.ts +27 -27
- package/src/realtime/events/rooms.ts +52 -48
- package/src/realtime/events/social.ts +81 -81
- package/src/realtime/events/tournaments.ts +12 -0
- package/src/realtime/index.ts +24 -24
- package/src/realtime/ops/empty.ts +8 -8
- package/src/realtime/ops/hello.ts +40 -40
- package/src/realtime/ops/multiplayer.ts +8 -8
- package/src/realtime/ops/parties.ts +13 -13
- package/src/realtime/ops/races.ts +99 -99
- package/src/realtime/ops/rooms.ts +22 -8
- package/src/realtime/ops/social.ts +58 -58
|
@@ -1,95 +1,95 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Presence: what a player is doing right now, as their friends see it, and
|
|
3
|
-
* the anonymous online counts (multiplayer-impl.md §4.4). Games report a
|
|
4
|
-
* {@link PresenceInput} per tab (socket op `presence`); the hub folds a
|
|
5
|
-
* user's tabs into one {@link Presence} and pushes it to friends
|
|
6
|
-
* (`ev:presence`).
|
|
7
|
-
*/
|
|
8
|
-
import { z } from 'zod';
|
|
9
|
-
import { Millis, Slug } from '../../common';
|
|
10
|
-
|
|
11
|
-
/** Activity kinds, least engaged first. The most engaged tab wins; hidden or idle tabs count as `away`. */
|
|
12
|
-
export const PresenceKind = z.enum(['away', 'menu', 'lobby', 'watching', 'playing']);
|
|
13
|
-
|
|
14
|
-
/** An activity kind. */
|
|
15
|
-
export type PresenceKind = z.infer<typeof PresenceKind>;
|
|
16
|
-
|
|
17
|
-
/** A room reference friends can join with: 1 to 64 of `A-Z a-z 0-9 _ : -`. */
|
|
18
|
-
export const PresenceRoom = z.string().regex(/^[A-Za-z0-9_:-]{1,64}$/);
|
|
19
|
-
|
|
20
|
-
/** A game mode key: 1 to 32 of `A-Z a-z 0-9 _ -`. */
|
|
21
|
-
export const PresenceMode = z.string().regex(/^[A-Za-z0-9_-]{1,32}$/);
|
|
22
|
-
|
|
23
|
-
/** The fields a tab reports and friends see back, with the limits the hub enforces. */
|
|
24
|
-
const PresenceFields = z.object({
|
|
25
|
-
kind: PresenceKind,
|
|
26
|
-
/** A short status line (at most 40 characters), filtered; a rejected one is dropped. */
|
|
27
|
-
detail: z.string().max(40).optional(),
|
|
28
|
-
/** Friends may join. Only ever `true` when present. */
|
|
29
|
-
joinable: z.boolean().optional(),
|
|
30
|
-
/** Friends may watch. Only ever `true` when present. */
|
|
31
|
-
watchable: z.boolean().optional(),
|
|
32
|
-
/** Join info for friends: the game's own room reference, or the hub room's code. */
|
|
33
|
-
room: PresenceRoom.optional(),
|
|
34
|
-
/** A public lobby: friends who follow the game may get a `lobby_open` alert. */
|
|
35
|
-
public: z.boolean().optional(),
|
|
36
|
-
/** Free seats, 0 to 64. */
|
|
37
|
-
openSeats: z.number().int().min(0).max(64).optional(),
|
|
38
|
-
mode: PresenceMode.optional(),
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* What a game reports for its tab (socket op `presence`). The hub is
|
|
43
|
-
* lenient: an unknown `kind`, or input that isn't an object at all (`null`),
|
|
44
|
-
* counts as `{kind:'menu'}`; unknown fields, and fields that break a rule (a
|
|
45
|
-
* bad `room`, a fractional `openSeats`, a field of the wrong type), are
|
|
46
|
-
* ignored one by one, so friends only ever
|
|
47
|
-
* see {@link Presence}'s fields; `detail` is trimmed to 40 characters before
|
|
48
|
-
* filtering.
|
|
49
|
-
*/
|
|
50
|
-
export const PresenceInput = z.looseObject({
|
|
51
|
-
kind: PresenceKind.catch('menu'),
|
|
52
|
-
detail: z.string().optional().catch(undefined),
|
|
53
|
-
joinable: z.boolean().optional().catch(undefined),
|
|
54
|
-
watchable: z.boolean().optional().catch(undefined),
|
|
55
|
-
room: z.string().optional().catch(undefined),
|
|
56
|
-
public: z.boolean().optional().catch(undefined),
|
|
57
|
-
openSeats: z.number().optional().catch(undefined),
|
|
58
|
-
mode: z.string().optional().catch(undefined),
|
|
59
|
-
}).catch({ kind: 'menu' });
|
|
60
|
-
|
|
61
|
-
/** A presence report. */
|
|
62
|
-
export type PresenceInput = z.input<typeof PresenceInput>;
|
|
63
|
-
|
|
64
|
-
/** A user's aggregated presence: their most engaged tab, decorated by the hub. */
|
|
65
|
-
export const Presence = PresenceFields.extend({
|
|
66
|
-
/** The game the tab is on, `null` on the catalog and other hub pages. */
|
|
67
|
-
game: Slug.nullable(),
|
|
68
|
-
/** That game's title, `null` with `game`. */
|
|
69
|
-
gameTitle: z.string().nullable(),
|
|
70
|
-
/** When this activity (kind + game) started (epoch ms). */
|
|
71
|
-
since: Millis,
|
|
72
|
-
/** Party size, present when the user is in a party of two or more. */
|
|
73
|
-
partySize: z.number().int().min(2).optional(),
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
/** A user's aggregated presence. */
|
|
77
|
-
export type Presence = z.infer<typeof Presence>;
|
|
78
|
-
|
|
79
|
-
/** A non-negative head count. */
|
|
80
|
-
const HeadCount = z.number().int().nonnegative();
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Anonymous online counts (`GET /social/counts`, `ev:counts`, the `hello`
|
|
84
|
-
* snapshot): everyone with a live presence, guests and hidden players
|
|
85
|
-
* included.
|
|
86
|
-
*/
|
|
87
|
-
export const Counts = z.object({
|
|
88
|
-
/** Everyone online. */
|
|
89
|
-
online: HeadCount,
|
|
90
|
-
/** Players per game slug (games with nobody are absent). */
|
|
91
|
-
byGame: z.record(Slug, HeadCount),
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
/** Online counts. */
|
|
95
|
-
export type Counts = z.infer<typeof Counts>;
|
|
1
|
+
/**
|
|
2
|
+
* Presence: what a player is doing right now, as their friends see it, and
|
|
3
|
+
* the anonymous online counts (multiplayer-impl.md §4.4). Games report a
|
|
4
|
+
* {@link PresenceInput} per tab (socket op `presence`); the hub folds a
|
|
5
|
+
* user's tabs into one {@link Presence} and pushes it to friends
|
|
6
|
+
* (`ev:presence`).
|
|
7
|
+
*/
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { Millis, Slug } from '../../common';
|
|
10
|
+
|
|
11
|
+
/** Activity kinds, least engaged first. The most engaged tab wins; hidden or idle tabs count as `away`. */
|
|
12
|
+
export const PresenceKind = z.enum(['away', 'menu', 'lobby', 'watching', 'playing']);
|
|
13
|
+
|
|
14
|
+
/** An activity kind. */
|
|
15
|
+
export type PresenceKind = z.infer<typeof PresenceKind>;
|
|
16
|
+
|
|
17
|
+
/** A room reference friends can join with: 1 to 64 of `A-Z a-z 0-9 _ : -`. */
|
|
18
|
+
export const PresenceRoom = z.string().regex(/^[A-Za-z0-9_:-]{1,64}$/);
|
|
19
|
+
|
|
20
|
+
/** A game mode key: 1 to 32 of `A-Z a-z 0-9 _ -`. */
|
|
21
|
+
export const PresenceMode = z.string().regex(/^[A-Za-z0-9_-]{1,32}$/);
|
|
22
|
+
|
|
23
|
+
/** The fields a tab reports and friends see back, with the limits the hub enforces. */
|
|
24
|
+
const PresenceFields = z.object({
|
|
25
|
+
kind: PresenceKind,
|
|
26
|
+
/** A short status line (at most 40 characters), filtered; a rejected one is dropped. */
|
|
27
|
+
detail: z.string().max(40).optional(),
|
|
28
|
+
/** Friends may join. Only ever `true` when present. */
|
|
29
|
+
joinable: z.boolean().optional(),
|
|
30
|
+
/** Friends may watch. Only ever `true` when present. */
|
|
31
|
+
watchable: z.boolean().optional(),
|
|
32
|
+
/** Join info for friends: the game's own room reference, or the hub room's code. */
|
|
33
|
+
room: PresenceRoom.optional(),
|
|
34
|
+
/** A public lobby: friends who follow the game may get a `lobby_open` alert. */
|
|
35
|
+
public: z.boolean().optional(),
|
|
36
|
+
/** Free seats, 0 to 64. */
|
|
37
|
+
openSeats: z.number().int().min(0).max(64).optional(),
|
|
38
|
+
mode: PresenceMode.optional(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What a game reports for its tab (socket op `presence`). The hub is
|
|
43
|
+
* lenient: an unknown `kind`, or input that isn't an object at all (`null`),
|
|
44
|
+
* counts as `{kind:'menu'}`; unknown fields, and fields that break a rule (a
|
|
45
|
+
* bad `room`, a fractional `openSeats`, a field of the wrong type), are
|
|
46
|
+
* ignored one by one, so friends only ever
|
|
47
|
+
* see {@link Presence}'s fields; `detail` is trimmed to 40 characters before
|
|
48
|
+
* filtering.
|
|
49
|
+
*/
|
|
50
|
+
export const PresenceInput = z.looseObject({
|
|
51
|
+
kind: PresenceKind.catch('menu'),
|
|
52
|
+
detail: z.string().optional().catch(undefined),
|
|
53
|
+
joinable: z.boolean().optional().catch(undefined),
|
|
54
|
+
watchable: z.boolean().optional().catch(undefined),
|
|
55
|
+
room: z.string().optional().catch(undefined),
|
|
56
|
+
public: z.boolean().optional().catch(undefined),
|
|
57
|
+
openSeats: z.number().optional().catch(undefined),
|
|
58
|
+
mode: z.string().optional().catch(undefined),
|
|
59
|
+
}).catch({ kind: 'menu' });
|
|
60
|
+
|
|
61
|
+
/** A presence report. */
|
|
62
|
+
export type PresenceInput = z.input<typeof PresenceInput>;
|
|
63
|
+
|
|
64
|
+
/** A user's aggregated presence: their most engaged tab, decorated by the hub. */
|
|
65
|
+
export const Presence = PresenceFields.extend({
|
|
66
|
+
/** The game the tab is on, `null` on the catalog and other hub pages. */
|
|
67
|
+
game: Slug.nullable(),
|
|
68
|
+
/** That game's title, `null` with `game`. */
|
|
69
|
+
gameTitle: z.string().nullable(),
|
|
70
|
+
/** When this activity (kind + game) started (epoch ms). */
|
|
71
|
+
since: Millis,
|
|
72
|
+
/** Party size, present when the user is in a party of two or more. */
|
|
73
|
+
partySize: z.number().int().min(2).optional(),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/** A user's aggregated presence. */
|
|
77
|
+
export type Presence = z.infer<typeof Presence>;
|
|
78
|
+
|
|
79
|
+
/** A non-negative head count. */
|
|
80
|
+
const HeadCount = z.number().int().nonnegative();
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Anonymous online counts (`GET /social/counts`, `ev:counts`, the `hello`
|
|
84
|
+
* snapshot): everyone with a live presence, guests and hidden players
|
|
85
|
+
* included.
|
|
86
|
+
*/
|
|
87
|
+
export const Counts = z.object({
|
|
88
|
+
/** Everyone online. */
|
|
89
|
+
online: HeadCount,
|
|
90
|
+
/** Players per game slug (games with nobody are absent). */
|
|
91
|
+
byGame: z.record(Slug, HeadCount),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/** Online counts. */
|
|
95
|
+
export type Counts = z.infer<typeof Counts>;
|
package/src/api/social.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Friends, blocks, the social profile and settings, player cards and counts,
|
|
3
|
-
* plus the shared social shapes (config, presence, people) the socket
|
|
4
|
-
* `hello` snapshot and events reuse. Split by topic under `social/`.
|
|
5
|
-
*/
|
|
6
|
-
export * from './social/config';
|
|
7
|
-
export * from './social/presence';
|
|
8
|
-
export * from './social/people';
|
|
9
|
-
export * from './social/endpoints';
|
|
1
|
+
/**
|
|
2
|
+
* Friends, blocks, the social profile and settings, player cards and counts,
|
|
3
|
+
* plus the shared social shapes (config, presence, people) the socket
|
|
4
|
+
* `hello` snapshot and events reuse. Split by topic under `social/`.
|
|
5
|
+
*/
|
|
6
|
+
export * from './social/config';
|
|
7
|
+
export * from './social/presence';
|
|
8
|
+
export * from './social/people';
|
|
9
|
+
export * from './social/endpoints';
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tournament endpoints (batch-2026-10 R15). Anyone may look at open
|
|
3
|
+
* tournaments; a closed one answers 404 to everyone it wasn't shared with.
|
|
4
|
+
* Creating, inviting and following need a claimed account; guests may sign
|
|
5
|
+
* up for open tournaments (and for closed ones they were invited to) and
|
|
6
|
+
* play their matches.
|
|
7
|
+
*/
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { IdParam, Ok, Slug, UserId } from '../../common';
|
|
10
|
+
import { ErrorBody } from '../../errors';
|
|
11
|
+
import { requireActive, requireClaimed, session } from '../../middleware';
|
|
12
|
+
import { TOURNAMENT_LIMITS } from '../../limits';
|
|
13
|
+
import { LaunchTicket } from '../mp/parties';
|
|
14
|
+
import { TournamentCreate, TournamentDetail, TournamentSummary } from './shapes';
|
|
15
|
+
|
|
16
|
+
/** `:id` of a tournament. */
|
|
17
|
+
const TournamentParams = z.object({ id: IdParam });
|
|
18
|
+
|
|
19
|
+
/** `:id` and `:match` of a tournament match. */
|
|
20
|
+
const MatchParams = z.object({ id: IdParam, match: z.string().regex(/^m\d{1,4}$/) });
|
|
21
|
+
|
|
22
|
+
/** The invite key from a shared link (`?hub_key=`), letting its opener into a closed tournament. */
|
|
23
|
+
const InviteKey = z.string().regex(/^[A-Za-z0-9_-]{8,64}$/);
|
|
24
|
+
|
|
25
|
+
/** `GET /tournaments` filters. */
|
|
26
|
+
export const TournamentListQuery = z.object({
|
|
27
|
+
/** Only tournaments in this game. */
|
|
28
|
+
game: z.union([z.literal(''), Slug]).optional(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** The tournament endpoints. */
|
|
32
|
+
export const tournamentEndpoints = {
|
|
33
|
+
...session.group({
|
|
34
|
+
listTournaments: {
|
|
35
|
+
method: 'GET', path: '/tournaments', query: TournamentListQuery,
|
|
36
|
+
response: z.object({ tournaments: z.array(TournamentSummary) }),
|
|
37
|
+
doc: { summary: 'Open tournaments plus the ones I am in, invited to or follow, soonest first', tags: ['tournaments'] },
|
|
38
|
+
},
|
|
39
|
+
getTournament: {
|
|
40
|
+
method: 'GET', path: '/tournaments/:id', params: TournamentParams,
|
|
41
|
+
query: z.object({ key: InviteKey.optional() }),
|
|
42
|
+
response: TournamentDetail,
|
|
43
|
+
errors: { 404: ErrorBody },
|
|
44
|
+
doc: { summary: 'A tournament with its players and bracket (404 for a closed one not shared with me)', tags: ['tournaments'] },
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
...requireClaimed.group({
|
|
48
|
+
createTournament: {
|
|
49
|
+
method: 'POST', path: '/tournaments', body: TournamentCreate, response: TournamentDetail,
|
|
50
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 422: ErrorBody, 429: ErrorBody },
|
|
51
|
+
doc: { summary: 'Create a tournament (I am signed up to it); the title goes through the content filter', tags: ['tournaments'] },
|
|
52
|
+
},
|
|
53
|
+
inviteToTournament: {
|
|
54
|
+
method: 'POST', path: '/tournaments/:id/invites', params: TournamentParams,
|
|
55
|
+
body: z.object({ userIds: z.array(UserId).min(1).max(TOURNAMENT_LIMITS.invitesMax) }),
|
|
56
|
+
response: z.object({ invited: z.number().int().nonnegative() }),
|
|
57
|
+
errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody, 429: ErrorBody },
|
|
58
|
+
doc: { summary: 'Creator: invite friends (others are skipped without saying why)', tags: ['tournaments'] },
|
|
59
|
+
},
|
|
60
|
+
followTournament: {
|
|
61
|
+
method: 'PUT', path: '/tournaments/:id/follow', params: TournamentParams,
|
|
62
|
+
body: z.object({ leadMinutes: z.number().int().min(0).max(TOURNAMENT_LIMITS.leadMinutesMax).nullish() }),
|
|
63
|
+
response: TournamentSummary,
|
|
64
|
+
errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
|
|
65
|
+
doc: { summary: 'Follow a tournament (hear when it opens, starts and ends); leadMinutes: remind me that long before a scheduled start (absent: no reminder)', tags: ['tournaments'] },
|
|
66
|
+
},
|
|
67
|
+
unfollowTournament: {
|
|
68
|
+
method: 'DELETE', path: '/tournaments/:id/follow', params: TournamentParams, response: Ok,
|
|
69
|
+
errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
|
|
70
|
+
doc: { summary: 'Stop following a tournament', tags: ['tournaments'] },
|
|
71
|
+
},
|
|
72
|
+
startTournament: {
|
|
73
|
+
method: 'POST', path: '/tournaments/:id/start', params: TournamentParams, response: TournamentDetail,
|
|
74
|
+
errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody },
|
|
75
|
+
doc: { summary: 'Creator: start now with whoever signed up (at least two)', tags: ['tournaments'] },
|
|
76
|
+
},
|
|
77
|
+
cancelTournament: {
|
|
78
|
+
method: 'DELETE', path: '/tournaments/:id', params: TournamentParams, response: Ok,
|
|
79
|
+
errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody },
|
|
80
|
+
doc: { summary: 'Creator (or an admin): cancel a tournament that has not finished', tags: ['tournaments'] },
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
...requireActive.group({
|
|
84
|
+
joinTournament: {
|
|
85
|
+
method: 'POST', path: '/tournaments/:id/join', params: TournamentParams,
|
|
86
|
+
body: z.object({ key: InviteKey.optional() }),
|
|
87
|
+
response: TournamentDetail,
|
|
88
|
+
errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody, 429: ErrorBody },
|
|
89
|
+
doc: { summary: 'Sign up (open tournaments, or closed ones I was invited to or have the link for)', tags: ['tournaments'] },
|
|
90
|
+
},
|
|
91
|
+
leaveTournament: {
|
|
92
|
+
method: 'POST', path: '/tournaments/:id/leave', params: TournamentParams, response: TournamentDetail,
|
|
93
|
+
errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody },
|
|
94
|
+
doc: { summary: 'Drop out: before the start my place is freed; after it I forfeit what is left', tags: ['tournaments'] },
|
|
95
|
+
},
|
|
96
|
+
launchTournamentMatch: {
|
|
97
|
+
method: 'POST', path: '/tournaments/:id/matches/:match/launch', params: MatchParams, response: LaunchTicket,
|
|
98
|
+
errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody },
|
|
99
|
+
doc: { summary: 'A launch into my ready match: host for the better seed, guest for the other', tags: ['tournaments'] },
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
102
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Play online" (batch-2026-10 R15): everything the viewer could play with
|
|
3
|
+
* someone, now and later, for the home page card and its modal. Public
|
|
4
|
+
* lobbies and races waiting for a player (joined through
|
|
5
|
+
* `POST /mp/join { userId, fromLobbyOpen: true }`), invites to the viewer,
|
|
6
|
+
* the tournaments they are in or follow (with a ready match first), and open
|
|
7
|
+
* tournaments still taking sign-ups.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { Millis, Slug } from '../../common';
|
|
11
|
+
import { PlayerLite } from '../../entities';
|
|
12
|
+
import { session } from '../../middleware';
|
|
13
|
+
import { GameMode, Invite } from '../mp/parties';
|
|
14
|
+
import { TournamentSummary } from './shapes';
|
|
15
|
+
|
|
16
|
+
/** A public lobby or race waiting for players right now. */
|
|
17
|
+
export const OpenLobbyEntry = z.object({
|
|
18
|
+
/** Who is waiting (join them with `POST /mp/join`). */
|
|
19
|
+
host: PlayerLite,
|
|
20
|
+
game: Slug,
|
|
21
|
+
gameTitle: z.string(),
|
|
22
|
+
/** `race` for a race waiting for racers, otherwise the lobby's mode when it has one. */
|
|
23
|
+
mode: GameMode.optional(),
|
|
24
|
+
openSeats: z.number().int().positive(),
|
|
25
|
+
/** When they started waiting, epoch ms. */
|
|
26
|
+
since: Millis,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/** A public lobby or race waiting for players. */
|
|
30
|
+
export type OpenLobbyEntry = z.infer<typeof OpenLobbyEntry>;
|
|
31
|
+
|
|
32
|
+
/** The counts on the home card. */
|
|
33
|
+
export const PlayOnlineCounts = z.object({
|
|
34
|
+
/** Lobbies to join now, plus my matches that are ready. */
|
|
35
|
+
now: z.number().int().nonnegative(),
|
|
36
|
+
/** Game invites and tournament invites waiting for me. */
|
|
37
|
+
invites: z.number().int().nonnegative(),
|
|
38
|
+
/** Tournaments I signed up for or follow, not over. */
|
|
39
|
+
mine: z.number().int().nonnegative(),
|
|
40
|
+
/** Open tournaments still taking sign-ups. */
|
|
41
|
+
upcoming: z.number().int().nonnegative(),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** The counts on the home card. */
|
|
45
|
+
export type PlayOnlineCounts = z.infer<typeof PlayOnlineCounts>;
|
|
46
|
+
|
|
47
|
+
/** Everything playable online for the viewer. */
|
|
48
|
+
export const PlayOnline = z.object({
|
|
49
|
+
counts: PlayOnlineCounts,
|
|
50
|
+
lobbies: z.array(OpenLobbyEntry),
|
|
51
|
+
gameInvites: z.array(Invite),
|
|
52
|
+
/** Closed tournaments I'm invited to and haven't joined. */
|
|
53
|
+
tournamentInvites: z.array(TournamentSummary),
|
|
54
|
+
/** Mine: a ready match first, then the soonest. */
|
|
55
|
+
mine: z.array(TournamentSummary),
|
|
56
|
+
/** Open, taking sign-ups, not mine. */
|
|
57
|
+
upcoming: z.array(TournamentSummary),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** Everything playable online for the viewer. */
|
|
61
|
+
export type PlayOnline = z.infer<typeof PlayOnline>;
|
|
62
|
+
|
|
63
|
+
/** The play-online endpoint. Signed-out visitors get the open tournaments only. */
|
|
64
|
+
export const playOnlineEndpoints = {
|
|
65
|
+
...session.group({
|
|
66
|
+
getPlayOnline: {
|
|
67
|
+
method: 'GET', path: '/play-online', response: PlayOnline,
|
|
68
|
+
doc: { summary: 'Lobbies to join now, invites, my tournaments and upcoming open ones (the home page card and modal)', tags: ['tournaments'] },
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tournaments (batch-2026-10 R15): brackets of 1v1 matches in one game or a
|
|
3
|
+
* set of games. Single elimination, or double/triple elimination with
|
|
4
|
+
* redemption brackets; seeded by rank in the game or at random; started at a
|
|
5
|
+
* scheduled time or once enough players sign up. Open tournaments are
|
|
6
|
+
* listed for anyone; closed ones only for the players invited.
|
|
7
|
+
*
|
|
8
|
+
* A match is an ordinary launch into the game (`host` for the better seed,
|
|
9
|
+
* `guest` for the other, sharing a two-player party), carrying
|
|
10
|
+
* {@link TournamentLaunch}; the game reports the winner the way it already
|
|
11
|
+
* ends a match.
|
|
12
|
+
*/
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
import { Millis, Slug, UserId } from '../../common';
|
|
15
|
+
import { PlayerLite } from '../../entities';
|
|
16
|
+
import { TOURNAMENT_LIMITS } from '../../limits';
|
|
17
|
+
|
|
18
|
+
/** A tournament's id. */
|
|
19
|
+
export const TournamentId = z.number().int().positive();
|
|
20
|
+
|
|
21
|
+
/** `open`: listed, anyone can sign up or follow. `closed`: only invited players see it. */
|
|
22
|
+
export const TournamentVisibility = z.enum(['open', 'closed']);
|
|
23
|
+
|
|
24
|
+
/** How the bracket is seeded: by each player's wins in the tournament's games, or at random. */
|
|
25
|
+
export const TournamentSeeding = z.enum(['rank', 'random']);
|
|
26
|
+
|
|
27
|
+
/** Which game each match is: the one game, one random pick from the set for the whole tournament, or a random pick per match. */
|
|
28
|
+
export const TournamentGamePick = z.enum(['fixed', 'random-once', 'random-each']);
|
|
29
|
+
|
|
30
|
+
/** When a tournament starts: at a set time (with whoever signed up), or as soon as `minPlayers` sign up. */
|
|
31
|
+
export const TournamentStart = z.discriminatedUnion('kind', [
|
|
32
|
+
z.object({ kind: z.literal('scheduled'), at: Millis }),
|
|
33
|
+
z.object({ kind: z.literal('filled') }),
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/** When a tournament starts. */
|
|
37
|
+
export type TournamentStart = z.infer<typeof TournamentStart>;
|
|
38
|
+
|
|
39
|
+
/** `signup` → `running` → `done`; or `cancelled` (by its creator, or too few players at the start). */
|
|
40
|
+
export const TournamentPhase = z.enum(['signup', 'running', 'done', 'cancelled']);
|
|
41
|
+
|
|
42
|
+
/** A tournament's phase. */
|
|
43
|
+
export type TournamentPhase = z.infer<typeof TournamentPhase>;
|
|
44
|
+
|
|
45
|
+
/** Where a match sits: the winners bracket, a redemption bracket (players with losses), or the final ladder. */
|
|
46
|
+
export const TournamentStage = z.enum(['winners', 'redemption', 'final']);
|
|
47
|
+
|
|
48
|
+
/** How a match was decided. */
|
|
49
|
+
export const TournamentDecision = z.enum(['played', 'forfeit', 'bye', 'timeout', 'withdrawn']);
|
|
50
|
+
|
|
51
|
+
/** How a match was decided. */
|
|
52
|
+
export type TournamentDecision = z.infer<typeof TournamentDecision>;
|
|
53
|
+
|
|
54
|
+
/** A player count within the tournament limits. */
|
|
55
|
+
const PlayerCount = z.number().int().min(TOURNAMENT_LIMITS.playersMin).max(TOURNAMENT_LIMITS.playersMax);
|
|
56
|
+
|
|
57
|
+
/** `POST /tournaments`. Everything but `games` has a default: the quick "public 1v1, single elimination". */
|
|
58
|
+
export const TournamentCreate = z.object({
|
|
59
|
+
/** Shown to everyone who can see the tournament; checked by the content filter. Default: made from the game's title. */
|
|
60
|
+
title: z.string().trim().min(1).max(TOURNAMENT_LIMITS.titleMax).optional(),
|
|
61
|
+
/** The game, or the set to pick from; each must support tournaments. */
|
|
62
|
+
games: z.array(Slug).min(1).max(TOURNAMENT_LIMITS.gamesMax),
|
|
63
|
+
pick: TournamentGamePick.default('fixed'),
|
|
64
|
+
/** Lives per player: 1 single (default), 2 double, 3 triple elimination. */
|
|
65
|
+
lives: z.number().int().min(1).max(TOURNAMENT_LIMITS.livesMax).default(1),
|
|
66
|
+
seeding: TournamentSeeding.default('random'),
|
|
67
|
+
visibility: TournamentVisibility.default('open'),
|
|
68
|
+
start: TournamentStart.default({ kind: 'filled' }),
|
|
69
|
+
/** Players needed to start (a `filled` start), or to go ahead at the scheduled time. Default 2. */
|
|
70
|
+
minPlayers: PlayerCount.default(2),
|
|
71
|
+
/** Most players who can sign up. Default 2. */
|
|
72
|
+
maxPlayers: PlayerCount.default(2),
|
|
73
|
+
/** Minutes a ready match waits for its players before the absent side forfeits. */
|
|
74
|
+
readyMinutes: z.number().int().min(1).max(TOURNAMENT_LIMITS.readyMinutesMax).default(TOURNAMENT_LIMITS.readyMinutesDefault),
|
|
75
|
+
/** Friends to invite now (closed tournaments, or a nudge for open ones). */
|
|
76
|
+
invite: z.array(UserId).max(TOURNAMENT_LIMITS.invitesMax).optional(),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/** What creating a tournament takes. */
|
|
80
|
+
export type TournamentCreate = z.output<typeof TournamentCreate>;
|
|
81
|
+
|
|
82
|
+
/** One entrant, as the bracket shows them. */
|
|
83
|
+
export const TournamentPlayer = z.object({
|
|
84
|
+
user: PlayerLite,
|
|
85
|
+
/** 1 is the top seed; `null` until the bracket is drawn. */
|
|
86
|
+
seed: z.number().int().positive().nullable(),
|
|
87
|
+
wins: z.number().int().nonnegative(),
|
|
88
|
+
losses: z.number().int().nonnegative(),
|
|
89
|
+
/** Knocked out (lost every life, or withdrew). */
|
|
90
|
+
out: z.boolean(),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/** One entrant. */
|
|
94
|
+
export type TournamentPlayer = z.infer<typeof TournamentPlayer>;
|
|
95
|
+
|
|
96
|
+
/** One match in the bracket. Sides are user ids (see the detail's `players`). */
|
|
97
|
+
export const TournamentMatch = z.object({
|
|
98
|
+
id: z.string(),
|
|
99
|
+
stage: TournamentStage,
|
|
100
|
+
/** Redemption bracket number (losses), 0 elsewhere. */
|
|
101
|
+
level: z.number().int().nonnegative(),
|
|
102
|
+
round: z.number().int().positive(),
|
|
103
|
+
slot: z.number().int().nonnegative(),
|
|
104
|
+
/** `null` while unknown, or the empty side of a bye. */
|
|
105
|
+
a: UserId.nullable(),
|
|
106
|
+
b: UserId.nullable(),
|
|
107
|
+
status: z.enum(['waiting', 'ready', 'done']),
|
|
108
|
+
winnerId: UserId.nullable(),
|
|
109
|
+
decision: TournamentDecision.nullable(),
|
|
110
|
+
/** The game this match is played in, once known. */
|
|
111
|
+
game: Slug.nullable(),
|
|
112
|
+
/** Ready matches: when the absent side forfeits, epoch ms. */
|
|
113
|
+
deadlineAt: Millis.nullable(),
|
|
114
|
+
/** Whether the match has started in the game. */
|
|
115
|
+
started: z.boolean(),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/** One match. */
|
|
119
|
+
export type TournamentMatch = z.infer<typeof TournamentMatch>;
|
|
120
|
+
|
|
121
|
+
/** A ready match of mine: who against, in which game, and until when. */
|
|
122
|
+
export const MyTournamentMatch = z.object({
|
|
123
|
+
id: z.string(),
|
|
124
|
+
opponent: PlayerLite.nullable(),
|
|
125
|
+
game: Slug,
|
|
126
|
+
gameTitle: z.string(),
|
|
127
|
+
deadlineAt: Millis,
|
|
128
|
+
started: z.boolean(),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/** A ready match of mine. */
|
|
132
|
+
export type MyTournamentMatch = z.infer<typeof MyTournamentMatch>;
|
|
133
|
+
|
|
134
|
+
/** The viewer's own relation to a tournament. */
|
|
135
|
+
export const TournamentMe = z.object({
|
|
136
|
+
joined: z.boolean(),
|
|
137
|
+
/** Invited and not yet signed up. */
|
|
138
|
+
invited: z.boolean(),
|
|
139
|
+
following: z.boolean(),
|
|
140
|
+
/** Minutes before a scheduled start to remind me; `null` for no reminder. */
|
|
141
|
+
leadMinutes: z.number().int().nonnegative().nullable(),
|
|
142
|
+
isCreator: z.boolean(),
|
|
143
|
+
/** Still in (signed up and not knocked out). */
|
|
144
|
+
stillIn: z.boolean(),
|
|
145
|
+
nextMatch: MyTournamentMatch.nullable(),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
/** A tournament in lists. */
|
|
149
|
+
export const TournamentSummary = z.object({
|
|
150
|
+
id: TournamentId,
|
|
151
|
+
title: z.string(),
|
|
152
|
+
creator: PlayerLite.nullable(),
|
|
153
|
+
visibility: TournamentVisibility,
|
|
154
|
+
phase: TournamentPhase,
|
|
155
|
+
/** The game set (one game for `fixed`). */
|
|
156
|
+
games: z.array(Slug),
|
|
157
|
+
pick: TournamentGamePick,
|
|
158
|
+
/** The game every match is in, once known (`fixed`, or `random-once` after the start); `null` for `random-each`. */
|
|
159
|
+
game: Slug.nullable(),
|
|
160
|
+
/** A title per game slug. */
|
|
161
|
+
gameTitles: z.record(Slug, z.string()),
|
|
162
|
+
lives: z.number().int().min(1),
|
|
163
|
+
seeding: TournamentSeeding,
|
|
164
|
+
start: TournamentStart,
|
|
165
|
+
minPlayers: z.number().int(),
|
|
166
|
+
maxPlayers: z.number().int(),
|
|
167
|
+
readyMinutes: z.number().int(),
|
|
168
|
+
/** How many have signed up. */
|
|
169
|
+
entrants: z.number().int().nonnegative(),
|
|
170
|
+
createdAt: Millis,
|
|
171
|
+
startedAt: Millis.nullable(),
|
|
172
|
+
endedAt: Millis.nullable(),
|
|
173
|
+
champion: PlayerLite.nullable(),
|
|
174
|
+
me: TournamentMe,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
/** A tournament in lists. */
|
|
178
|
+
export type TournamentSummary = z.infer<typeof TournamentSummary>;
|
|
179
|
+
|
|
180
|
+
/** A tournament with its players and bracket. */
|
|
181
|
+
export const TournamentDetail = TournamentSummary.extend({
|
|
182
|
+
players: z.array(TournamentPlayer),
|
|
183
|
+
matches: z.array(TournamentMatch),
|
|
184
|
+
/** The creator's link that lets friends of friends in (closed tournaments, creator only). */
|
|
185
|
+
inviteUrl: z.string().nullable(),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
/** A tournament with its bracket. */
|
|
189
|
+
export type TournamentDetail = z.infer<typeof TournamentDetail>;
|
|
190
|
+
|
|
191
|
+
/** What a launch into a tournament match carries (`launch.tournament`). */
|
|
192
|
+
export const TournamentLaunch = z.object({
|
|
193
|
+
id: TournamentId,
|
|
194
|
+
title: z.string(),
|
|
195
|
+
/** The match id within the tournament; own-server games send it back with the result. */
|
|
196
|
+
match: z.string(),
|
|
197
|
+
/** Who to play. */
|
|
198
|
+
opponent: PlayerLite.nullable(),
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
/** What a launch into a tournament match carries. */
|
|
202
|
+
export type TournamentLaunch = z.infer<typeof TournamentLaunch>;
|