@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
package/src/api/auth.ts
CHANGED
|
@@ -1,77 +1,77 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Accounts and sessions: guests, claiming, sign-up, log-in, log-out, and the
|
|
3
|
-
* current user (`/me`). Sessions ride the `hub_session` cookie.
|
|
4
|
-
*/
|
|
5
|
-
import { z } from 'zod';
|
|
6
|
-
import { Ok, Slug } from '../common';
|
|
7
|
-
import { AuthUser, SessionUser } from '../entities';
|
|
8
|
-
import { ContentRejected, ErrorBody } from '../errors';
|
|
9
|
-
import { requireUser, session } from '../middleware';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* A credential field, read like the hub's `String(x ?? '')`: a missing field
|
|
13
|
-
* is empty text (the hub then answers with its own rule, or a failed log-in)
|
|
14
|
-
* and a number becomes text. Clients should send a string.
|
|
15
|
-
*/
|
|
16
|
-
function credentialText(message: string) {
|
|
17
|
-
return z.union([z.string(), z.number()], { error: message }).nullish().transform((value) => String(value ?? ''));
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Username + password, as the sign-up, claim and log-in forms send them. */
|
|
21
|
-
export const Credentials = z.object({
|
|
22
|
-
/** 2 to 24 characters: letters, digits, `_`, `-` and spaces. */
|
|
23
|
-
username: credentialText('Username can use letters, numbers, _, -, and spaces.'),
|
|
24
|
-
/** At least 6 characters. */
|
|
25
|
-
password: credentialText('Password must be at least 6 characters.'),
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
/** `{ user }`: the session's account after an auth call. */
|
|
29
|
-
export const AuthResult = z.object({ user: AuthUser });
|
|
30
|
-
|
|
31
|
-
/** A 400 from sign-up or claim: a bad name or password, or a name the filter rejects. */
|
|
32
|
-
const CredentialsError = z.union([ContentRejected, ErrorBody]);
|
|
33
|
-
|
|
34
|
-
/** The auth endpoints. */
|
|
35
|
-
export const authEndpoints = {
|
|
36
|
-
...session.group({
|
|
37
|
-
/**
|
|
38
|
-
* Make sure a session exists. Idempotent: returns the current user if
|
|
39
|
-
* signed in (the full {@link SessionUser}), else creates a guest.
|
|
40
|
-
*/
|
|
41
|
-
ensureGuestSession: {
|
|
42
|
-
method: 'POST', path: '/auth/guest',
|
|
43
|
-
response: z.object({ user: z.union([SessionUser, AuthUser]) }),
|
|
44
|
-
doc: { summary: 'Ensure a session (creates a guest if needed)', tags: ['auth'] },
|
|
45
|
-
},
|
|
46
|
-
signUpWithPassword: {
|
|
47
|
-
method: 'POST', path: '/auth/signup', body: Credentials, response: AuthResult,
|
|
48
|
-
errors: { 400: CredentialsError, 409: ErrorBody, 429: ErrorBody },
|
|
49
|
-
doc: { summary: 'Create an account and sign in', tags: ['auth'] },
|
|
50
|
-
},
|
|
51
|
-
logInWithPassword: {
|
|
52
|
-
method: 'POST', path: '/auth/login', body: Credentials, response: AuthResult,
|
|
53
|
-
errors: { 401: ErrorBody, 429: ErrorBody },
|
|
54
|
-
doc: { summary: 'Sign in; a guest session on this device is merged into the account', tags: ['auth'] },
|
|
55
|
-
},
|
|
56
|
-
logOut: {
|
|
57
|
-
method: 'POST', path: '/auth/logout', response: Ok,
|
|
58
|
-
doc: { summary: 'Sign out and clear the session cookie', tags: ['auth'] },
|
|
59
|
-
},
|
|
60
|
-
getCurrentUser: {
|
|
61
|
-
method: 'GET', path: '/me',
|
|
62
|
-
response: z.object({
|
|
63
|
-
user: SessionUser.nullable(),
|
|
64
|
-
/** Games the user has a profile in. */
|
|
65
|
-
profiles: z.array(z.object({ game: Slug, displayName: z.string().nullable() })),
|
|
66
|
-
}),
|
|
67
|
-
doc: { summary: 'The current user (or null) and their game profiles', tags: ['auth'] },
|
|
68
|
-
},
|
|
69
|
-
}),
|
|
70
|
-
...requireUser.group({
|
|
71
|
-
claimGuestAccount: {
|
|
72
|
-
method: 'POST', path: '/auth/claim', body: Credentials, response: AuthResult,
|
|
73
|
-
errors: { 400: CredentialsError, 401: ErrorBody, 409: ErrorBody, 429: ErrorBody },
|
|
74
|
-
doc: { summary: 'Turn the current guest into an account, keeping its progress', tags: ['auth'] },
|
|
75
|
-
},
|
|
76
|
-
}),
|
|
77
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* Accounts and sessions: guests, claiming, sign-up, log-in, log-out, and the
|
|
3
|
+
* current user (`/me`). Sessions ride the `hub_session` cookie.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { Ok, Slug } from '../common';
|
|
7
|
+
import { AuthUser, SessionUser } from '../entities';
|
|
8
|
+
import { ContentRejected, ErrorBody } from '../errors';
|
|
9
|
+
import { requireUser, session } from '../middleware';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A credential field, read like the hub's `String(x ?? '')`: a missing field
|
|
13
|
+
* is empty text (the hub then answers with its own rule, or a failed log-in)
|
|
14
|
+
* and a number becomes text. Clients should send a string.
|
|
15
|
+
*/
|
|
16
|
+
function credentialText(message: string) {
|
|
17
|
+
return z.union([z.string(), z.number()], { error: message }).nullish().transform((value) => String(value ?? ''));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Username + password, as the sign-up, claim and log-in forms send them. */
|
|
21
|
+
export const Credentials = z.object({
|
|
22
|
+
/** 2 to 24 characters: letters, digits, `_`, `-` and spaces. */
|
|
23
|
+
username: credentialText('Username can use letters, numbers, _, -, and spaces.'),
|
|
24
|
+
/** At least 6 characters. */
|
|
25
|
+
password: credentialText('Password must be at least 6 characters.'),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** `{ user }`: the session's account after an auth call. */
|
|
29
|
+
export const AuthResult = z.object({ user: AuthUser });
|
|
30
|
+
|
|
31
|
+
/** A 400 from sign-up or claim: a bad name or password, or a name the filter rejects. */
|
|
32
|
+
const CredentialsError = z.union([ContentRejected, ErrorBody]);
|
|
33
|
+
|
|
34
|
+
/** The auth endpoints. */
|
|
35
|
+
export const authEndpoints = {
|
|
36
|
+
...session.group({
|
|
37
|
+
/**
|
|
38
|
+
* Make sure a session exists. Idempotent: returns the current user if
|
|
39
|
+
* signed in (the full {@link SessionUser}), else creates a guest.
|
|
40
|
+
*/
|
|
41
|
+
ensureGuestSession: {
|
|
42
|
+
method: 'POST', path: '/auth/guest',
|
|
43
|
+
response: z.object({ user: z.union([SessionUser, AuthUser]) }),
|
|
44
|
+
doc: { summary: 'Ensure a session (creates a guest if needed)', tags: ['auth'] },
|
|
45
|
+
},
|
|
46
|
+
signUpWithPassword: {
|
|
47
|
+
method: 'POST', path: '/auth/signup', body: Credentials, response: AuthResult,
|
|
48
|
+
errors: { 400: CredentialsError, 409: ErrorBody, 429: ErrorBody },
|
|
49
|
+
doc: { summary: 'Create an account and sign in', tags: ['auth'] },
|
|
50
|
+
},
|
|
51
|
+
logInWithPassword: {
|
|
52
|
+
method: 'POST', path: '/auth/login', body: Credentials, response: AuthResult,
|
|
53
|
+
errors: { 401: ErrorBody, 429: ErrorBody },
|
|
54
|
+
doc: { summary: 'Sign in; a guest session on this device is merged into the account', tags: ['auth'] },
|
|
55
|
+
},
|
|
56
|
+
logOut: {
|
|
57
|
+
method: 'POST', path: '/auth/logout', response: Ok,
|
|
58
|
+
doc: { summary: 'Sign out and clear the session cookie', tags: ['auth'] },
|
|
59
|
+
},
|
|
60
|
+
getCurrentUser: {
|
|
61
|
+
method: 'GET', path: '/me',
|
|
62
|
+
response: z.object({
|
|
63
|
+
user: SessionUser.nullable(),
|
|
64
|
+
/** Games the user has a profile in. */
|
|
65
|
+
profiles: z.array(z.object({ game: Slug, displayName: z.string().nullable() })),
|
|
66
|
+
}),
|
|
67
|
+
doc: { summary: 'The current user (or null) and their game profiles', tags: ['auth'] },
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
...requireUser.group({
|
|
71
|
+
claimGuestAccount: {
|
|
72
|
+
method: 'POST', path: '/auth/claim', body: Credentials, response: AuthResult,
|
|
73
|
+
errors: { 400: CredentialsError, 401: ErrorBody, 409: ErrorBody, 429: ErrorBody },
|
|
74
|
+
doc: { summary: 'Turn the current guest into an account, keeping its progress', tags: ['auth'] },
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who is in each game right now (aggregate head counts only: never who), and
|
|
3
|
+
* the signed-in player's own activity in one game. The catalog's cards, the
|
|
4
|
+
* menu's game list and the game sheet show them.
|
|
5
|
+
*/
|
|
6
|
+
import { use } from '@ayepi/core';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { Millis, Slug } from '../common';
|
|
9
|
+
import { ErrorBody } from '../errors';
|
|
10
|
+
import { game, requireUser, session } from '../middleware';
|
|
11
|
+
|
|
12
|
+
/** A non-negative head count. */
|
|
13
|
+
const HeadCount = z.number().int().nonnegative();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* How many people have a game open right now: claimed accounts and guests.
|
|
17
|
+
* Counts only, so no player is ever identified.
|
|
18
|
+
*/
|
|
19
|
+
export const GamePlayerCount = z.object({
|
|
20
|
+
accounts: HeadCount,
|
|
21
|
+
guests: HeadCount,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** How many people have a game open right now. */
|
|
25
|
+
export type GamePlayerCount = z.infer<typeof GamePlayerCount>;
|
|
26
|
+
|
|
27
|
+
/** Head counts per game slug; games nobody has open are absent. */
|
|
28
|
+
export const GamePlayerCounts = z.record(Slug, GamePlayerCount);
|
|
29
|
+
|
|
30
|
+
/** Head counts per game slug. */
|
|
31
|
+
export type GamePlayerCounts = z.infer<typeof GamePlayerCounts>;
|
|
32
|
+
|
|
33
|
+
/** The caller's activity in one game, within the analytics retention window. */
|
|
34
|
+
export const MyGameActivity = z.object({
|
|
35
|
+
/** Times the game was opened. */
|
|
36
|
+
plays: HeadCount,
|
|
37
|
+
/** Minutes with the game open and visible (one heartbeat a minute). */
|
|
38
|
+
minutesPlayed: HeadCount,
|
|
39
|
+
/** The first and latest event in the window, epoch ms; `null` when there are none. */
|
|
40
|
+
firstPlayedAt: Millis.nullable(),
|
|
41
|
+
lastPlayedAt: Millis.nullable(),
|
|
42
|
+
/** How many days back the counts reach. */
|
|
43
|
+
windowDays: z.number().int().positive(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** The caller's activity in one game. */
|
|
47
|
+
export type MyGameActivity = z.infer<typeof MyGameActivity>;
|
|
48
|
+
|
|
49
|
+
/** The activity endpoints (part of the catalog area). */
|
|
50
|
+
export const catalogActivityEndpoints = {
|
|
51
|
+
...session.group({
|
|
52
|
+
getGamePlayerCounts: {
|
|
53
|
+
method: 'GET', path: '/catalog/players',
|
|
54
|
+
response: z.object({ games: GamePlayerCounts }),
|
|
55
|
+
doc: { summary: 'How many accounts and guests have each game open right now (counts only)', tags: ['catalog'] },
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
...use(requireUser, game).path('/games/:slug').group({
|
|
59
|
+
getMyGameActivity: {
|
|
60
|
+
method: 'GET', path: '/me/activity',
|
|
61
|
+
response: MyGameActivity,
|
|
62
|
+
errors: { 401: ErrorBody, 404: ErrorBody },
|
|
63
|
+
doc: { summary: "The caller's plays and time played in this game", tags: ['catalog'] },
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
};
|
package/src/api/catalog.ts
CHANGED
|
@@ -1,171 +1,177 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The game catalog, favorites
|
|
3
|
-
*/
|
|
4
|
-
import { z } from 'zod';
|
|
5
|
-
import { Millis, Slug } from '../common';
|
|
6
|
-
import { ErrorBody } from '../errors';
|
|
7
|
-
import { requireUser, session } from '../middleware';
|
|
8
|
-
import { RaceStatFormat } from '../manifest/race';
|
|
9
|
-
import { RaceParamValue } from './mp/races';
|
|
10
|
-
import { LooseNumber, LooseText, Truthy } from '../coerce';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
export
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
export
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
/** A
|
|
59
|
-
export
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
/** `
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}),
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The game catalog, favorites, What's new notes, and who is playing what (counts only).
|
|
3
|
+
*/
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { Millis, Slug } from '../common';
|
|
6
|
+
import { ErrorBody } from '../errors';
|
|
7
|
+
import { requireUser, session } from '../middleware';
|
|
8
|
+
import { RaceStatFormat } from '../manifest/race';
|
|
9
|
+
import { RaceParamValue } from './mp/races';
|
|
10
|
+
import { LooseNumber, LooseText, Truthy } from '../coerce';
|
|
11
|
+
import { catalogActivityEndpoints } from './catalog-activity';
|
|
12
|
+
|
|
13
|
+
export * from './catalog-activity';
|
|
14
|
+
|
|
15
|
+
/** A `[min, max]` player count, each 1 to 8. */
|
|
16
|
+
export const PlayerRange = z.tuple([z.number().int().min(1).max(8), z.number().int().min(1).max(8)]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What the menu needs to know about an online game (invite picker, Join and
|
|
20
|
+
* Watch buttons, the catalog's Online tag), not the full manifest.
|
|
21
|
+
*/
|
|
22
|
+
export const MultiplayerSummary = z.object({
|
|
23
|
+
/** `own-server` only for process apps that run their own game server. */
|
|
24
|
+
transport: z.enum(['hub-rooms', 'own-server']),
|
|
25
|
+
players: PlayerRange,
|
|
26
|
+
invites: z.boolean(),
|
|
27
|
+
join: z.boolean(),
|
|
28
|
+
spectate: z.boolean(),
|
|
29
|
+
modes: z.array(z.object({ key: z.string(), title: z.string() })),
|
|
30
|
+
/** Present (and `true`) when the block was made for a race-only game. */
|
|
31
|
+
raceOnly: z.literal(true).optional(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** An online game's capabilities. */
|
|
35
|
+
export type MultiplayerSummary = z.infer<typeof MultiplayerSummary>;
|
|
36
|
+
|
|
37
|
+
/** A race game's manifest summary (players, the creator's param choices, the stats the HUD shows). */
|
|
38
|
+
export const RaceSummary = z.object({
|
|
39
|
+
/** Each 2 to 8. */
|
|
40
|
+
players: PlayerRange,
|
|
41
|
+
/** Up to 6 choices the race creator picks. */
|
|
42
|
+
params: z.array(z.object({
|
|
43
|
+
key: z.string(),
|
|
44
|
+
title: z.string(),
|
|
45
|
+
/** Up to 12. */
|
|
46
|
+
options: z.array(z.object({ value: RaceParamValue, title: z.string() })),
|
|
47
|
+
/** One of the options' values. */
|
|
48
|
+
default: RaceParamValue,
|
|
49
|
+
})),
|
|
50
|
+
/** Up to 8 stats the race HUD and history show. */
|
|
51
|
+
stats: z.array(z.object({
|
|
52
|
+
key: z.string(),
|
|
53
|
+
title: z.string(),
|
|
54
|
+
format: RaceStatFormat,
|
|
55
|
+
})),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** A race game's manifest summary. */
|
|
59
|
+
export type RaceSummary = z.infer<typeof RaceSummary>;
|
|
60
|
+
|
|
61
|
+
/** A listed game, as the menu's game list and the catalog show it. Broken apps are left out. */
|
|
62
|
+
export const GameInfo = z.object({
|
|
63
|
+
slug: Slug,
|
|
64
|
+
title: z.string(),
|
|
65
|
+
/** Its URL path, e.g. `/duel`. */
|
|
66
|
+
path: z.string(),
|
|
67
|
+
/** `process` apps run their own server behind the host. */
|
|
68
|
+
kind: z.enum(['static', 'process']),
|
|
69
|
+
hasImage: z.boolean(),
|
|
70
|
+
/** `false` when the game opted out of the injected hub menu. */
|
|
71
|
+
hubEnabled: z.boolean(),
|
|
72
|
+
/** `null` for single-player games. */
|
|
73
|
+
multiplayer: MultiplayerSummary.nullable(),
|
|
74
|
+
/** `null` unless the game supports races. */
|
|
75
|
+
race: RaceSummary.nullable(),
|
|
76
|
+
/** Present (and `true`) when tournaments can be played in this game: it races, or declares `multiplayer.tournaments`. */
|
|
77
|
+
tournaments: z.literal(true).optional(),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/** A listed game. */
|
|
81
|
+
export type GameInfo = z.infer<typeof GameInfo>;
|
|
82
|
+
|
|
83
|
+
/** `{ games }`: the caller's starred slugs, oldest star first. */
|
|
84
|
+
const Favorites = z.object({ games: z.array(Slug) });
|
|
85
|
+
|
|
86
|
+
/** One What's new note. */
|
|
87
|
+
export const ChangeNote = z.object({
|
|
88
|
+
id: z.number().int().positive(),
|
|
89
|
+
/** The game's slug, or `_hub` for the site's own notes. */
|
|
90
|
+
game: Slug,
|
|
91
|
+
/** The game's title, or `diffenderfer.games` for the site's own notes. */
|
|
92
|
+
title: z.string(),
|
|
93
|
+
text: z.string(),
|
|
94
|
+
/** When the note went live (the deploy that first saw it), epoch ms. */
|
|
95
|
+
at: Millis,
|
|
96
|
+
/** Not yet seen by this player (or since `?since=` when signed out). */
|
|
97
|
+
new: z.boolean(),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/** A What's new note. */
|
|
101
|
+
export type ChangeNote = z.infer<typeof ChangeNote>;
|
|
102
|
+
|
|
103
|
+
/** The catalog endpoints. */
|
|
104
|
+
export const catalogEndpoints = {
|
|
105
|
+
...catalogActivityEndpoints,
|
|
106
|
+
...session.group({
|
|
107
|
+
listGames: {
|
|
108
|
+
method: 'GET', path: '/games',
|
|
109
|
+
response: z.object({ games: z.array(GameInfo) }),
|
|
110
|
+
doc: { summary: 'The game catalog, sorted by title', tags: ['catalog'] },
|
|
111
|
+
},
|
|
112
|
+
/**
|
|
113
|
+
* Notes from the last 30 days, newest first (at most 6 per game and 30
|
|
114
|
+
* in all). In a game, `?game=` limits them to that game's and the site's.
|
|
115
|
+
*/
|
|
116
|
+
listWhatsNewNotes: {
|
|
117
|
+
method: 'GET', path: '/changes',
|
|
118
|
+
query: z.object({
|
|
119
|
+
/** A game's slug. Empty means everything; an unknown one lists only the site's notes. */
|
|
120
|
+
game: z.string().optional(),
|
|
121
|
+
/**
|
|
122
|
+
* Signed out only: the device's last-cleared time (epoch ms); without
|
|
123
|
+
* it (or when it isn't a number) nothing is new.
|
|
124
|
+
*/
|
|
125
|
+
since: z.preprocess((value: number | string | undefined) => (value === '' ? undefined : value), LooseNumber.optional()).catch(undefined),
|
|
126
|
+
}),
|
|
127
|
+
response: z.object({
|
|
128
|
+
items: z.array(ChangeNote),
|
|
129
|
+
/** How many of `items` are new. */
|
|
130
|
+
unseen: z.number().int().nonnegative(),
|
|
131
|
+
signedIn: z.boolean(),
|
|
132
|
+
}),
|
|
133
|
+
doc: { summary: "What's new: recent change notes", tags: ['catalog'] },
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
...requireUser.group({
|
|
137
|
+
listFavoriteGames: {
|
|
138
|
+
method: 'GET', path: '/favorites', response: Favorites,
|
|
139
|
+
errors: { 401: ErrorBody },
|
|
140
|
+
doc: { summary: "The caller's starred games", tags: ['catalog'] },
|
|
141
|
+
},
|
|
142
|
+
/**
|
|
143
|
+
* Star or unstar a game (idempotent). Starring needs a known game (404);
|
|
144
|
+
* unstarring any slug succeeds.
|
|
145
|
+
*/
|
|
146
|
+
setGameFavorite: {
|
|
147
|
+
method: 'PUT', path: '/favorites/:slug',
|
|
148
|
+
params: z.object({ slug: Slug }),
|
|
149
|
+
body: z.object({
|
|
150
|
+
/** `true` stars, `false` (or missing) unstars. */
|
|
151
|
+
on: Truthy.optional(),
|
|
152
|
+
}),
|
|
153
|
+
response: Favorites,
|
|
154
|
+
errors: { 401: ErrorBody, 404: ErrorBody },
|
|
155
|
+
doc: { summary: 'Star or unstar a game', tags: ['catalog'] },
|
|
156
|
+
},
|
|
157
|
+
/**
|
|
158
|
+
* Mark notes seen now. With `game`: that game's and the site's. With
|
|
159
|
+
* `all` (or neither): everything.
|
|
160
|
+
*/
|
|
161
|
+
markWhatsNewSeen: {
|
|
162
|
+
method: 'POST', path: '/changes/seen',
|
|
163
|
+
body: z.object({
|
|
164
|
+
/** A game's slug, read as text; an unknown one is a 404. Ignored with `all`. */
|
|
165
|
+
game: LooseText.nullish(),
|
|
166
|
+
all: Truthy.optional(),
|
|
167
|
+
}),
|
|
168
|
+
response: z.object({
|
|
169
|
+
ok: z.literal(true),
|
|
170
|
+
/** The seen mark stored, epoch ms. */
|
|
171
|
+
seenAt: Millis,
|
|
172
|
+
}),
|
|
173
|
+
errors: { 401: ErrorBody, 404: ErrorBody },
|
|
174
|
+
doc: { summary: "Mark What's new notes seen", tags: ['catalog'] },
|
|
175
|
+
},
|
|
176
|
+
}),
|
|
177
|
+
};
|