@diffenderfer-games/hub-contract 0.0.0-stage → 2.0.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +26 -3
- package/dist/index.d.ts +8454 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4855 -0
- package/dist/index.js.map +1 -0
- package/package.json +53 -6
- package/src/api/analytics.ts +81 -0
- package/src/api/auth.ts +77 -0
- package/src/api/catalog.ts +171 -0
- package/src/api/daily.ts +122 -0
- package/src/api/economy/bundle.ts +69 -0
- package/src/api/economy/inventory.ts +57 -0
- package/src/api/economy/trades.ts +154 -0
- package/src/api/economy.ts +15 -0
- package/src/api/index.ts +85 -0
- package/src/api/leaderboards.ts +151 -0
- package/src/api/messages.ts +142 -0
- package/src/api/mp/chat.ts +36 -0
- package/src/api/mp/parties.ts +108 -0
- package/src/api/mp/races.ts +211 -0
- package/src/api/mp/rooms.ts +90 -0
- package/src/api/mp/snapshot.ts +17 -0
- package/src/api/mp.ts +110 -0
- package/src/api/notify.ts +111 -0
- package/src/api/progress/game-data.ts +189 -0
- package/src/api/progress.ts +59 -0
- package/src/api/race.ts +47 -0
- package/src/api/reports.ts +208 -0
- package/src/api/social/config.ts +47 -0
- package/src/api/social/endpoints.ts +179 -0
- package/src/api/social/people.ts +118 -0
- package/src/api/social/presence.ts +95 -0
- package/src/api/social.ts +9 -0
- package/src/api/version.ts +40 -0
- package/src/coerce.ts +50 -0
- package/src/common.ts +42 -0
- package/src/entities.ts +62 -0
- package/src/errors/codes/core.ts +10 -0
- package/src/errors/codes/multiplayer.ts +43 -0
- package/src/errors/codes/play.ts +5 -0
- package/src/errors/codes/social.ts +31 -0
- package/src/errors/index.ts +88 -0
- package/src/index.ts +16 -0
- package/src/limits.ts +100 -0
- package/src/logic/daily.ts +54 -0
- package/src/logic/index.ts +8 -0
- package/src/logic/presence.ts +62 -0
- package/src/logic/race.ts +169 -0
- package/src/logic/time.ts +81 -0
- package/src/manifest/changes.ts +55 -0
- package/src/manifest/controls.ts +42 -0
- package/src/manifest/fields.ts +89 -0
- package/src/manifest/game.ts +132 -0
- package/src/manifest/index.ts +12 -0
- package/src/manifest/inject.ts +31 -0
- package/src/manifest/leaderboards.ts +29 -0
- package/src/manifest/multiplayer.ts +71 -0
- package/src/manifest/race.ts +93 -0
- package/src/middleware.ts +47 -0
- package/src/realtime/define.ts +52 -0
- package/src/realtime/envelope.ts +67 -0
- package/src/realtime/events/multiplayer.ts +8 -0
- package/src/realtime/events/parties.ts +31 -0
- package/src/realtime/events/races.ts +27 -0
- package/src/realtime/events/rooms.ts +48 -0
- package/src/realtime/events/social.ts +81 -0
- package/src/realtime/index.ts +24 -0
- package/src/realtime/ops/empty.ts +8 -0
- package/src/realtime/ops/hello.ts +40 -0
- package/src/realtime/ops/multiplayer.ts +8 -0
- package/src/realtime/ops/parties.ts +13 -0
- package/src/realtime/ops/races.ts +99 -0
- package/src/realtime/ops/rooms.ts +122 -0
- package/src/realtime/ops/social.ts +58 -0
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
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The caller's own per-game data: profile, save slots, numeric stats, and
|
|
3
|
+
* resetting a game. Guests count: a guest is a real account row.
|
|
4
|
+
*/
|
|
5
|
+
import { use } from '@ayepi/core';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { Json, Key, Millis, Ok, Slug } from '../../common';
|
|
8
|
+
import { ContentRejected, ErrorBody } from '../../errors';
|
|
9
|
+
import { game, requireUser, session } from '../../middleware';
|
|
10
|
+
import { LooseText, requiredText } from '../../coerce';
|
|
11
|
+
|
|
12
|
+
/** A player's profile in one game. */
|
|
13
|
+
export const Profile = z.object({
|
|
14
|
+
game: Slug,
|
|
15
|
+
/** Shown to other players; `null` when never set. */
|
|
16
|
+
displayName: z.string().nullable(),
|
|
17
|
+
/** Any JSON the game stores (`{}` by default). */
|
|
18
|
+
data: Json,
|
|
19
|
+
/** Epoch ms. */
|
|
20
|
+
updatedAt: Millis,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** A game profile. */
|
|
24
|
+
export type Profile = z.infer<typeof Profile>;
|
|
25
|
+
|
|
26
|
+
/** A save slot's metadata (no payload). */
|
|
27
|
+
export const SaveMeta = z.object({
|
|
28
|
+
slot: Key,
|
|
29
|
+
label: z.string().nullable(),
|
|
30
|
+
/** Epoch ms. */
|
|
31
|
+
updatedAt: Millis,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** A save slot's metadata. */
|
|
35
|
+
export type SaveMeta = z.infer<typeof SaveMeta>;
|
|
36
|
+
|
|
37
|
+
/** A save slot with its payload. */
|
|
38
|
+
export const Save = SaveMeta.extend({
|
|
39
|
+
/** The stored JSON (`null` if it could not be read back). */
|
|
40
|
+
data: Json,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** A save slot. */
|
|
44
|
+
export type Save = z.infer<typeof Save>;
|
|
45
|
+
|
|
46
|
+
/** A player's numeric stats in one game: stat {@link Key} to value. */
|
|
47
|
+
export const Stats = z.record(Key, z.number());
|
|
48
|
+
|
|
49
|
+
/** A player's stats. */
|
|
50
|
+
export type Stats = z.infer<typeof Stats>;
|
|
51
|
+
|
|
52
|
+
/** How a stat update combines with the stored value. */
|
|
53
|
+
export const StatMode = z.enum(['set', 'incr', 'max']);
|
|
54
|
+
|
|
55
|
+
/** A stat update mode. */
|
|
56
|
+
export type StatMode = z.infer<typeof StatMode>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A {@link Key} read like the hub's `validKey` (`String(x || '')`), failing
|
|
60
|
+
* with its message: "<label> must match /^[A-Za-z0-9_.:-]{1,64}$/ (1–64 chars)."
|
|
61
|
+
*/
|
|
62
|
+
function keyField(label: string) {
|
|
63
|
+
const message = `${label} must match /^[A-Za-z0-9_.:-]{1,64}$/ (1–64 chars).`;
|
|
64
|
+
return requiredText(message).refine((text) => Key.safeParse(text).success, message);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A number read like the hub's `finiteNumber` (`Number(x)`), failing with "<label> must be a finite number.". */
|
|
68
|
+
function finiteNumberField(label: string) {
|
|
69
|
+
return z.coerce.number<number>({ error: `${label} must be a finite number.` });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A stat update's mode. The hub reads `body.mode || 'set'`, so a falsy value
|
|
74
|
+
* (`null`, `''`, `0`, `false`) also means `set`. Clients should send a
|
|
75
|
+
* {@link StatMode} or nothing.
|
|
76
|
+
*/
|
|
77
|
+
const StatModeField = z
|
|
78
|
+
.union([StatMode, z.literal(['', 0, false]), z.null()], { error: 'mode must be set, incr, or max.' })
|
|
79
|
+
.optional()
|
|
80
|
+
.transform((mode) => mode || 'set');
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A save's payload: any JSON, but present. (The union with `never` exists only
|
|
84
|
+
* so a missing value fails with the hub's message rather than zod's.)
|
|
85
|
+
*/
|
|
86
|
+
const SaveData = z.union([Json, z.never()], { error: 'Missing "data".' });
|
|
87
|
+
|
|
88
|
+
/** `:slot` of a save. */
|
|
89
|
+
const SlotParams = z.object({ slot: keyField('slot') });
|
|
90
|
+
|
|
91
|
+
/** The per-game data endpoints. */
|
|
92
|
+
export const gameDataEndpoints = {
|
|
93
|
+
...use(session, game).path('/games/:slug').group({
|
|
94
|
+
/**
|
|
95
|
+
* A player's stats. Defaults to the caller's own (401 when signed out);
|
|
96
|
+
* `?user=<id>` reads anyone's, since stats are public like a profile.
|
|
97
|
+
*/
|
|
98
|
+
getGameStats: {
|
|
99
|
+
method: 'GET', path: '/stats',
|
|
100
|
+
query: z.object({
|
|
101
|
+
/** Whose stats to read (a user id); omit for the caller's. */
|
|
102
|
+
user: finiteNumberField('user').optional(),
|
|
103
|
+
}),
|
|
104
|
+
response: z.object({ stats: Stats }),
|
|
105
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 404: ErrorBody },
|
|
106
|
+
doc: { summary: "A player's stats in this game", tags: ['progress'] },
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
...use(requireUser, game).path('/games/:slug').group({
|
|
110
|
+
getGameProfile: {
|
|
111
|
+
method: 'GET', path: '/profile',
|
|
112
|
+
response: z.object({ profile: Profile.nullable() }),
|
|
113
|
+
errors: { 401: ErrorBody, 404: ErrorBody },
|
|
114
|
+
doc: { summary: "The caller's profile in this game, or null", tags: ['progress'] },
|
|
115
|
+
},
|
|
116
|
+
/**
|
|
117
|
+
* Create or replace the caller's profile. A new or changed display name
|
|
118
|
+
* is moderated (422) because other players see it.
|
|
119
|
+
*/
|
|
120
|
+
setGameProfile: {
|
|
121
|
+
method: 'PUT', path: '/profile',
|
|
122
|
+
body: z.object({
|
|
123
|
+
/** Up to 60 characters (longer is clipped); `null` or missing clears it. */
|
|
124
|
+
displayName: LooseText.nullish(),
|
|
125
|
+
/** Replaces the stored data; missing stores `{}`. */
|
|
126
|
+
data: Json.optional(),
|
|
127
|
+
}),
|
|
128
|
+
response: z.object({ profile: Profile }),
|
|
129
|
+
errors: { 401: ErrorBody, 404: ErrorBody, 422: ContentRejected },
|
|
130
|
+
doc: { summary: "Create or replace the caller's profile in this game", tags: ['progress'] },
|
|
131
|
+
},
|
|
132
|
+
listSaves: {
|
|
133
|
+
method: 'GET', path: '/saves',
|
|
134
|
+
response: z.object({ saves: z.array(SaveMeta) }),
|
|
135
|
+
errors: { 401: ErrorBody, 404: ErrorBody },
|
|
136
|
+
doc: { summary: "The caller's save slots, newest first (no payloads)", tags: ['progress'] },
|
|
137
|
+
},
|
|
138
|
+
getSave: {
|
|
139
|
+
method: 'GET', path: '/saves/:slot', params: SlotParams,
|
|
140
|
+
response: z.object({ save: Save.nullable() }),
|
|
141
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 404: ErrorBody },
|
|
142
|
+
doc: { summary: 'One save slot, or null when empty', tags: ['progress'] },
|
|
143
|
+
},
|
|
144
|
+
writeSave: {
|
|
145
|
+
method: 'PUT', path: '/saves/:slot', params: SlotParams,
|
|
146
|
+
body: z.object({
|
|
147
|
+
/** Any JSON up to 64 KB serialized (`HUB_MAX_SAVE_BYTES`); 413 over. */
|
|
148
|
+
data: SaveData,
|
|
149
|
+
/** Up to 80 characters (longer is clipped). */
|
|
150
|
+
label: LooseText.nullish(),
|
|
151
|
+
}),
|
|
152
|
+
response: z.object({
|
|
153
|
+
ok: z.literal(true),
|
|
154
|
+
/** Epoch ms. */
|
|
155
|
+
updatedAt: Millis,
|
|
156
|
+
}),
|
|
157
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 404: ErrorBody, 413: ErrorBody },
|
|
158
|
+
doc: { summary: 'Create or overwrite a save slot', tags: ['progress'] },
|
|
159
|
+
},
|
|
160
|
+
deleteSave: {
|
|
161
|
+
method: 'DELETE', path: '/saves/:slot', params: SlotParams, response: Ok,
|
|
162
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 404: ErrorBody },
|
|
163
|
+
doc: { summary: 'Delete a save slot (idempotent)', tags: ['progress'] },
|
|
164
|
+
},
|
|
165
|
+
/** Update one stat. Backs the client's `setStat`, `incrStat` and `maxStat`. */
|
|
166
|
+
updateGameStat: {
|
|
167
|
+
method: 'POST', path: '/stats',
|
|
168
|
+
body: z.object({
|
|
169
|
+
key: keyField('key'),
|
|
170
|
+
value: finiteNumberField('value'),
|
|
171
|
+
/** Default `set`. */
|
|
172
|
+
mode: StatModeField,
|
|
173
|
+
}),
|
|
174
|
+
response: z.object({ key: Key, value: z.number() }),
|
|
175
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 404: ErrorBody },
|
|
176
|
+
doc: { summary: 'Set, add to, or raise one stat; returns its new value', tags: ['progress'] },
|
|
177
|
+
},
|
|
178
|
+
/**
|
|
179
|
+
* Wipe the caller's saves, stats, inventory and coins in this game.
|
|
180
|
+
* Pending trades are closed first, refunding other players' escrow.
|
|
181
|
+
* Leaderboard entries and the profile are kept.
|
|
182
|
+
*/
|
|
183
|
+
resetMyGameProgress: {
|
|
184
|
+
method: 'POST', path: '/reset', response: Ok,
|
|
185
|
+
errors: { 401: ErrorBody, 404: ErrorBody },
|
|
186
|
+
doc: { summary: "Reset the caller's progress in this game", tags: ['progress'] },
|
|
187
|
+
},
|
|
188
|
+
}),
|
|
189
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-game player data: profiles, saves, stats, game reset, and the player's
|
|
3
|
+
* records and snapshot. The per-game endpoints live in `progress/game-data.ts`.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { Slug } from '../common';
|
|
7
|
+
import { SessionUser } from '../entities';
|
|
8
|
+
import { session } from '../middleware';
|
|
9
|
+
import { Inventory } from './economy/bundle';
|
|
10
|
+
import { GameLeaderboards, GameRecords, TopQuery } from './leaderboards';
|
|
11
|
+
import { gameDataEndpoints, Profile, Save, Stats } from './progress/game-data';
|
|
12
|
+
|
|
13
|
+
export { Profile, Save, SaveMeta, StatMode, Stats } from './progress/game-data';
|
|
14
|
+
|
|
15
|
+
/** Everything the caller has in one game, as the snapshot carries it. */
|
|
16
|
+
export const GameSnapshot = z.object({
|
|
17
|
+
profile: Profile.nullable(),
|
|
18
|
+
/** Newest first, with payloads. */
|
|
19
|
+
saves: z.array(Save),
|
|
20
|
+
stats: Stats,
|
|
21
|
+
inventory: Inventory,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** One game's slice of the snapshot. */
|
|
25
|
+
export type GameSnapshot = z.infer<typeof GameSnapshot>;
|
|
26
|
+
|
|
27
|
+
/** The progress endpoints. */
|
|
28
|
+
export const progressEndpoints = {
|
|
29
|
+
...gameDataEndpoints,
|
|
30
|
+
...session.group({
|
|
31
|
+
/**
|
|
32
|
+
* The caller's leaderboard standings, grouped by game (empty when
|
|
33
|
+
* signed out). `?game=` limits it to one game; empty means every game,
|
|
34
|
+
* and text that is no game's slug matches nothing, as in the old hub.
|
|
35
|
+
*/
|
|
36
|
+
listMyLeaderboardRecords: {
|
|
37
|
+
method: 'GET', path: '/me/records',
|
|
38
|
+
query: z.object({ game: z.string().optional() }),
|
|
39
|
+
response: z.object({ games: z.array(GameRecords) }),
|
|
40
|
+
doc: { summary: "The caller's leaderboard standings", tags: ['progress'] },
|
|
41
|
+
},
|
|
42
|
+
/**
|
|
43
|
+
* Everything the caller has, in one request, to warm the offline cache
|
|
44
|
+
* for every game (the client's `prefetchAll`). Signed out, only the
|
|
45
|
+
* global boards are filled in.
|
|
46
|
+
*/
|
|
47
|
+
getMyOfflineSnapshot: {
|
|
48
|
+
method: 'GET', path: '/me/snapshot', query: TopQuery,
|
|
49
|
+
response: z.object({
|
|
50
|
+
user: SessionUser.nullable(),
|
|
51
|
+
/** By slug: every game the caller has any profile, save, stat or holding in. */
|
|
52
|
+
games: z.record(Slug, GameSnapshot),
|
|
53
|
+
records: z.array(GameRecords),
|
|
54
|
+
leaderboards: z.array(GameLeaderboards),
|
|
55
|
+
}),
|
|
56
|
+
doc: { summary: "The caller's data across every game, plus the global boards", tags: ['progress'] },
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
};
|
package/src/api/race.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Races: the signed-in player's own race history (docs/plans/race.md §7).
|
|
3
|
+
* The live race runs over the socket (`race.*` ops and events).
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { ErrorBody } from '../errors';
|
|
7
|
+
import { requireActive } from '../middleware';
|
|
8
|
+
import { RaceRecord } from './mp/races';
|
|
9
|
+
|
|
10
|
+
/** An optional positive-integer query param; empty means "not set". */
|
|
11
|
+
const OptionalCount = z.union([z.literal(''), z.coerce.number().int().positive()]).optional();
|
|
12
|
+
|
|
13
|
+
/** `GET /race/history` filters. */
|
|
14
|
+
export const RaceHistoryQuery = z.object({
|
|
15
|
+
/** Only this game (default: every game). */
|
|
16
|
+
game: z.union([z.literal(''), z.string().regex(/^[A-Za-z0-9_-]{1,64}$/)]).optional(),
|
|
17
|
+
/** Only races with this player (a user id). */
|
|
18
|
+
with: OptionalCount,
|
|
19
|
+
/** Only races older than this race id (paging). */
|
|
20
|
+
before: OptionalCount,
|
|
21
|
+
/** Page size, 1 to 50 (default 20; larger values are capped). */
|
|
22
|
+
limit: OptionalCount,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** A page of the player's races, newest first. */
|
|
26
|
+
export const RaceHistory = z.object({
|
|
27
|
+
races: z.array(RaceRecord),
|
|
28
|
+
/** More races exist before the last one. */
|
|
29
|
+
more: z.boolean(),
|
|
30
|
+
/** Totals over every race matching the filters (not just this page). */
|
|
31
|
+
summary: z.object({
|
|
32
|
+
races: z.number().int().nonnegative(),
|
|
33
|
+
wins: z.number().int().nonnegative(),
|
|
34
|
+
losses: z.number().int().nonnegative(),
|
|
35
|
+
}),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** The race endpoints. */
|
|
39
|
+
export const raceEndpoints = {
|
|
40
|
+
...requireActive.group({
|
|
41
|
+
listMyRaceHistory: {
|
|
42
|
+
method: 'GET', path: '/race/history', query: RaceHistoryQuery, response: RaceHistory,
|
|
43
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody },
|
|
44
|
+
doc: { summary: "The signed-in player's decided races, newest first", tags: ['race'] },
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
};
|