@diffenderfer-games/hub-contract 2.0.0-next.0 → 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 -21
- package/README.md +26 -26
- package/dist/index.d.ts +95 -95
- package/package.json +53 -53
- package/src/api/analytics.ts +81 -81
- package/src/api/auth.ts +77 -77
- package/src/api/catalog.ts +171 -171
- package/src/api/daily.ts +122 -122
- package/src/api/economy/bundle.ts +69 -69
- package/src/api/index.ts +85 -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/races.ts +211 -211
- package/src/api/mp/rooms.ts +90 -90
- package/src/api/mp/snapshot.ts +17 -17
- package/src/api/mp.ts +110 -110
- package/src/api/notify.ts +111 -111
- 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/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 +43 -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 +100 -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 +132 -132
- package/src/manifest/index.ts +12 -12
- package/src/manifest/inject.ts +31 -31
- package/src/manifest/leaderboards.ts +29 -29
- package/src/manifest/multiplayer.ts +71 -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 +8 -8
- package/src/realtime/events/parties.ts +31 -31
- package/src/realtime/events/races.ts +27 -27
- package/src/realtime/events/rooms.ts +48 -48
- package/src/realtime/events/social.ts +81 -81
- 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 +1 -1
- package/src/realtime/ops/social.ts +58 -58
|
@@ -1,69 +1,69 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Economy shapes: a player's per-game holdings and the bundles that move
|
|
3
|
-
* goods. Items are opaque, app-defined {@link Key}s; the hub only counts them.
|
|
4
|
-
*/
|
|
5
|
-
import { z } from 'zod';
|
|
6
|
-
import { Key } from '../../common';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* The default largest quantity (or coin amount) one bundle entry may carry.
|
|
10
|
-
* The hub's limit is `HUB_MAX_ITEM_QTY`, which defaults to this.
|
|
11
|
-
*/
|
|
12
|
-
export const MAX_ITEM_QTY = 1_000_000;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* The default most distinct item keys one bundle may name. The hub's limit
|
|
16
|
-
* is `HUB_MAX_BUNDLE_ITEMS`, which defaults to this.
|
|
17
|
-
*/
|
|
18
|
-
export const MAX_BUNDLE_ITEMS = 50;
|
|
19
|
-
|
|
20
|
-
/** A player's holdings in one game: items with a positive count, and coins. */
|
|
21
|
-
export const Inventory = z.object({
|
|
22
|
-
/** Sorted by key. */
|
|
23
|
-
items: z.array(z.object({ key: Key, qty: z.number().int().positive() })),
|
|
24
|
-
coins: z.number().int().nonnegative(),
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
/** A player's holdings in one game. */
|
|
28
|
-
export type Inventory = z.infer<typeof Inventory>;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* A bundle quantity, read like the hub's `Number(x)` (numeric strings pass).
|
|
32
|
-
* A value that is not a number reads as `NaN` here rather than failing, so
|
|
33
|
-
* the hub's own range check, which knows its configured limits, answers with
|
|
34
|
-
* its message (e.g. `grant.coins must be an integer in [0, 1000000].`).
|
|
35
|
-
* Clients should send a JSON integer.
|
|
36
|
-
*/
|
|
37
|
-
const BundleQuantity = z.coerce.number<number>().catch(Number.NaN);
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Goods to grant, spend or trade, as a request sends them, for the request
|
|
41
|
-
* field named `label` (`grant`, `take`, `give`, `want`), which the error
|
|
42
|
-
* messages name. Both parts are optional (`null` or missing means none).
|
|
43
|
-
* Item keys must be {@link Key}s, at most `HUB_MAX_BUNDLE_ITEMS` of them,
|
|
44
|
-
* each with a quantity from 1 to `HUB_MAX_ITEM_QTY`; coins run from 0 to
|
|
45
|
-
* `HUB_MAX_ITEM_QTY`. The hub checks those limits and the keys itself.
|
|
46
|
-
*/
|
|
47
|
-
export function bundleInput(label: string) {
|
|
48
|
-
return z.object({
|
|
49
|
-
/** 0 to `HUB_MAX_ITEM_QTY`. */
|
|
50
|
-
coins: BundleQuantity.nullish(),
|
|
51
|
-
/** Item key to quantity (1 to `HUB_MAX_ITEM_QTY`). */
|
|
52
|
-
items: z.record(z.string(), BundleQuantity, { error: `${label}.items must be an object of key→quantity.` }).nullish(),
|
|
53
|
-
}, { error: `${label} must be an object.` });
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** A request bundle, for documentation and clients. */
|
|
57
|
-
export const BundleInput = bundleInput('bundle');
|
|
58
|
-
|
|
59
|
-
/** A request bundle. */
|
|
60
|
-
export type BundleInput = z.infer<typeof BundleInput>;
|
|
61
|
-
|
|
62
|
-
/** A bundle as the hub stores and returns it on a trade offer: both parts always present. */
|
|
63
|
-
export const Bundle = z.object({
|
|
64
|
-
items: z.record(Key, z.number().int().positive()),
|
|
65
|
-
coins: z.number().int().nonnegative(),
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
/** A stored bundle. */
|
|
69
|
-
export type Bundle = z.infer<typeof Bundle>;
|
|
1
|
+
/**
|
|
2
|
+
* Economy shapes: a player's per-game holdings and the bundles that move
|
|
3
|
+
* goods. Items are opaque, app-defined {@link Key}s; the hub only counts them.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { Key } from '../../common';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The default largest quantity (or coin amount) one bundle entry may carry.
|
|
10
|
+
* The hub's limit is `HUB_MAX_ITEM_QTY`, which defaults to this.
|
|
11
|
+
*/
|
|
12
|
+
export const MAX_ITEM_QTY = 1_000_000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The default most distinct item keys one bundle may name. The hub's limit
|
|
16
|
+
* is `HUB_MAX_BUNDLE_ITEMS`, which defaults to this.
|
|
17
|
+
*/
|
|
18
|
+
export const MAX_BUNDLE_ITEMS = 50;
|
|
19
|
+
|
|
20
|
+
/** A player's holdings in one game: items with a positive count, and coins. */
|
|
21
|
+
export const Inventory = z.object({
|
|
22
|
+
/** Sorted by key. */
|
|
23
|
+
items: z.array(z.object({ key: Key, qty: z.number().int().positive() })),
|
|
24
|
+
coins: z.number().int().nonnegative(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** A player's holdings in one game. */
|
|
28
|
+
export type Inventory = z.infer<typeof Inventory>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A bundle quantity, read like the hub's `Number(x)` (numeric strings pass).
|
|
32
|
+
* A value that is not a number reads as `NaN` here rather than failing, so
|
|
33
|
+
* the hub's own range check, which knows its configured limits, answers with
|
|
34
|
+
* its message (e.g. `grant.coins must be an integer in [0, 1000000].`).
|
|
35
|
+
* Clients should send a JSON integer.
|
|
36
|
+
*/
|
|
37
|
+
const BundleQuantity = z.coerce.number<number>().catch(Number.NaN);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Goods to grant, spend or trade, as a request sends them, for the request
|
|
41
|
+
* field named `label` (`grant`, `take`, `give`, `want`), which the error
|
|
42
|
+
* messages name. Both parts are optional (`null` or missing means none).
|
|
43
|
+
* Item keys must be {@link Key}s, at most `HUB_MAX_BUNDLE_ITEMS` of them,
|
|
44
|
+
* each with a quantity from 1 to `HUB_MAX_ITEM_QTY`; coins run from 0 to
|
|
45
|
+
* `HUB_MAX_ITEM_QTY`. The hub checks those limits and the keys itself.
|
|
46
|
+
*/
|
|
47
|
+
export function bundleInput(label: string) {
|
|
48
|
+
return z.object({
|
|
49
|
+
/** 0 to `HUB_MAX_ITEM_QTY`. */
|
|
50
|
+
coins: BundleQuantity.nullish(),
|
|
51
|
+
/** Item key to quantity (1 to `HUB_MAX_ITEM_QTY`). */
|
|
52
|
+
items: z.record(z.string(), BundleQuantity, { error: `${label}.items must be an object of key→quantity.` }).nullish(),
|
|
53
|
+
}, { error: `${label} must be an object.` });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A request bundle, for documentation and clients. */
|
|
57
|
+
export const BundleInput = bundleInput('bundle');
|
|
58
|
+
|
|
59
|
+
/** A request bundle. */
|
|
60
|
+
export type BundleInput = z.infer<typeof BundleInput>;
|
|
61
|
+
|
|
62
|
+
/** A bundle as the hub stores and returns it on a trade offer: both parts always present. */
|
|
63
|
+
export const Bundle = z.object({
|
|
64
|
+
items: z.record(Key, z.number().int().positive()),
|
|
65
|
+
coins: z.number().int().nonnegative(),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** A stored bundle. */
|
|
69
|
+
export type Bundle = z.infer<typeof Bundle>;
|
package/src/api/index.ts
CHANGED
|
@@ -1,85 +1,85 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The public REST API: every endpoint a game, the menu or the social runtime
|
|
3
|
-
* calls, as one ayepi spec. Paths are relative to `/_api`. One file per area.
|
|
4
|
-
*/
|
|
5
|
-
import { spec } from '@ayepi/core';
|
|
6
|
-
import { analyticsEndpoints } from './analytics';
|
|
7
|
-
import { authEndpoints } from './auth';
|
|
8
|
-
import { catalogEndpoints } from './catalog';
|
|
9
|
-
import { dailyEndpoints } from './daily';
|
|
10
|
-
import { economyEndpoints } from './economy';
|
|
11
|
-
import { leaderboardsEndpoints } from './leaderboards';
|
|
12
|
-
import { messagesEndpoints } from './messages';
|
|
13
|
-
import { mpEndpoints } from './mp';
|
|
14
|
-
import { notifyEndpoints } from './notify';
|
|
15
|
-
import { progressEndpoints } from './progress';
|
|
16
|
-
import { raceEndpoints } from './race';
|
|
17
|
-
import { reportsEndpoints } from './reports';
|
|
18
|
-
import { socialEndpoints } from './social';
|
|
19
|
-
import { versionEndpoints } from './version';
|
|
20
|
-
|
|
21
|
-
// Every area's shapes (request, response and entity schemas), for clients and tests.
|
|
22
|
-
export * from './analytics';
|
|
23
|
-
export * from './auth';
|
|
24
|
-
export * from './catalog';
|
|
25
|
-
export * from './daily';
|
|
26
|
-
export * from './economy';
|
|
27
|
-
export * from './leaderboards';
|
|
28
|
-
export * from './messages';
|
|
29
|
-
export * from './mp';
|
|
30
|
-
export * from './notify';
|
|
31
|
-
export * from './progress';
|
|
32
|
-
export * from './race';
|
|
33
|
-
export * from './reports';
|
|
34
|
-
export * from './social';
|
|
35
|
-
export * from './version';
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Every public endpoint, by name. Typed as an intersection of the areas so the
|
|
39
|
-
* published .d.ts refers to each area instead of repeating every endpoint.
|
|
40
|
-
*/
|
|
41
|
-
export type PublicEndpoints =
|
|
42
|
-
typeof authEndpoints
|
|
43
|
-
& typeof catalogEndpoints
|
|
44
|
-
& typeof progressEndpoints
|
|
45
|
-
& typeof leaderboardsEndpoints
|
|
46
|
-
& typeof dailyEndpoints
|
|
47
|
-
& typeof economyEndpoints
|
|
48
|
-
& typeof analyticsEndpoints
|
|
49
|
-
& typeof socialEndpoints
|
|
50
|
-
& typeof messagesEndpoints
|
|
51
|
-
& typeof notifyEndpoints
|
|
52
|
-
& typeof reportsEndpoints
|
|
53
|
-
& typeof mpEndpoints
|
|
54
|
-
& typeof raceEndpoints
|
|
55
|
-
& typeof versionEndpoints;
|
|
56
|
-
|
|
57
|
-
/** Every public endpoint, by name. */
|
|
58
|
-
export const publicEndpoints: PublicEndpoints = {
|
|
59
|
-
...authEndpoints,
|
|
60
|
-
...catalogEndpoints,
|
|
61
|
-
...progressEndpoints,
|
|
62
|
-
...leaderboardsEndpoints,
|
|
63
|
-
...dailyEndpoints,
|
|
64
|
-
...economyEndpoints,
|
|
65
|
-
...analyticsEndpoints,
|
|
66
|
-
...socialEndpoints,
|
|
67
|
-
...messagesEndpoints,
|
|
68
|
-
...notifyEndpoints,
|
|
69
|
-
...reportsEndpoints,
|
|
70
|
-
...mpEndpoints,
|
|
71
|
-
...raceEndpoints,
|
|
72
|
-
...versionEndpoints,
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* The public API spec's type. Declared (not inferred) so the published
|
|
77
|
-
* `.d.ts` refers to `publicEndpoints` instead of repeating every endpoint.
|
|
78
|
-
*/
|
|
79
|
-
export interface PublicApi {
|
|
80
|
-
/** Every public endpoint, by name. */
|
|
81
|
-
readonly endpoints: PublicEndpoints;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** The public API spec (paths relative to `/_api`). */
|
|
85
|
-
export const publicApi: PublicApi = spec({ endpoints: publicEndpoints });
|
|
1
|
+
/**
|
|
2
|
+
* The public REST API: every endpoint a game, the menu or the social runtime
|
|
3
|
+
* calls, as one ayepi spec. Paths are relative to `/_api`. One file per area.
|
|
4
|
+
*/
|
|
5
|
+
import { spec } from '@ayepi/core';
|
|
6
|
+
import { analyticsEndpoints } from './analytics';
|
|
7
|
+
import { authEndpoints } from './auth';
|
|
8
|
+
import { catalogEndpoints } from './catalog';
|
|
9
|
+
import { dailyEndpoints } from './daily';
|
|
10
|
+
import { economyEndpoints } from './economy';
|
|
11
|
+
import { leaderboardsEndpoints } from './leaderboards';
|
|
12
|
+
import { messagesEndpoints } from './messages';
|
|
13
|
+
import { mpEndpoints } from './mp';
|
|
14
|
+
import { notifyEndpoints } from './notify';
|
|
15
|
+
import { progressEndpoints } from './progress';
|
|
16
|
+
import { raceEndpoints } from './race';
|
|
17
|
+
import { reportsEndpoints } from './reports';
|
|
18
|
+
import { socialEndpoints } from './social';
|
|
19
|
+
import { versionEndpoints } from './version';
|
|
20
|
+
|
|
21
|
+
// Every area's shapes (request, response and entity schemas), for clients and tests.
|
|
22
|
+
export * from './analytics';
|
|
23
|
+
export * from './auth';
|
|
24
|
+
export * from './catalog';
|
|
25
|
+
export * from './daily';
|
|
26
|
+
export * from './economy';
|
|
27
|
+
export * from './leaderboards';
|
|
28
|
+
export * from './messages';
|
|
29
|
+
export * from './mp';
|
|
30
|
+
export * from './notify';
|
|
31
|
+
export * from './progress';
|
|
32
|
+
export * from './race';
|
|
33
|
+
export * from './reports';
|
|
34
|
+
export * from './social';
|
|
35
|
+
export * from './version';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Every public endpoint, by name. Typed as an intersection of the areas so the
|
|
39
|
+
* published .d.ts refers to each area instead of repeating every endpoint.
|
|
40
|
+
*/
|
|
41
|
+
export type PublicEndpoints =
|
|
42
|
+
typeof authEndpoints
|
|
43
|
+
& typeof catalogEndpoints
|
|
44
|
+
& typeof progressEndpoints
|
|
45
|
+
& typeof leaderboardsEndpoints
|
|
46
|
+
& typeof dailyEndpoints
|
|
47
|
+
& typeof economyEndpoints
|
|
48
|
+
& typeof analyticsEndpoints
|
|
49
|
+
& typeof socialEndpoints
|
|
50
|
+
& typeof messagesEndpoints
|
|
51
|
+
& typeof notifyEndpoints
|
|
52
|
+
& typeof reportsEndpoints
|
|
53
|
+
& typeof mpEndpoints
|
|
54
|
+
& typeof raceEndpoints
|
|
55
|
+
& typeof versionEndpoints;
|
|
56
|
+
|
|
57
|
+
/** Every public endpoint, by name. */
|
|
58
|
+
export const publicEndpoints: PublicEndpoints = {
|
|
59
|
+
...authEndpoints,
|
|
60
|
+
...catalogEndpoints,
|
|
61
|
+
...progressEndpoints,
|
|
62
|
+
...leaderboardsEndpoints,
|
|
63
|
+
...dailyEndpoints,
|
|
64
|
+
...economyEndpoints,
|
|
65
|
+
...analyticsEndpoints,
|
|
66
|
+
...socialEndpoints,
|
|
67
|
+
...messagesEndpoints,
|
|
68
|
+
...notifyEndpoints,
|
|
69
|
+
...reportsEndpoints,
|
|
70
|
+
...mpEndpoints,
|
|
71
|
+
...raceEndpoints,
|
|
72
|
+
...versionEndpoints,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The public API spec's type. Declared (not inferred) so the published
|
|
77
|
+
* `.d.ts` refers to `publicEndpoints` instead of repeating every endpoint.
|
|
78
|
+
*/
|
|
79
|
+
export interface PublicApi {
|
|
80
|
+
/** Every public endpoint, by name. */
|
|
81
|
+
readonly endpoints: PublicEndpoints;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The public API spec (paths relative to `/_api`). */
|
|
85
|
+
export const publicApi: PublicApi = spec({ endpoints: publicEndpoints });
|
package/src/api/leaderboards.ts
CHANGED
|
@@ -1,151 +1,151 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Leaderboards: score submission, boards and standings. A board belongs to a
|
|
3
|
-
* game and is keyed by an app-chosen {@link Key}; it keeps each player's best
|
|
4
|
-
* score only (highest for `desc` boards, lowest for `asc`).
|
|
5
|
-
*/
|
|
6
|
-
import { use } from '@ayepi/core';
|
|
7
|
-
import { z } from 'zod';
|
|
8
|
-
import { Json, Key, Slug, UserId } from '../common';
|
|
9
|
-
import { ErrorBody } from '../errors';
|
|
10
|
-
import { game, requireUser, session } from '../middleware';
|
|
11
|
-
import { RowCount, Truthy } from '../coerce';
|
|
12
|
-
|
|
13
|
-
/** A board key in a path, with the old hub's message when it is malformed. */
|
|
14
|
-
const BoardKeyParams = z.object({
|
|
15
|
-
key: z.string().refine((key) => Key.safeParse(key).success, { error: 'board key must match /^[A-Za-z0-9_.:-]{1,64}$/ (1–64 chars).' }),
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* A row count the hub reads like `Number(x) || default`: anything that is not
|
|
20
|
-
* an integer falls back to the default instead of failing.
|
|
21
|
-
*/
|
|
22
|
-
const LenientRowCount = RowCount.optional().catch(undefined);
|
|
23
|
-
|
|
24
|
-
/** A board's sort direction: `desc` ranks high scores first, `asc` low scores (times) first. */
|
|
25
|
-
export const SortDir = z.enum(['asc', 'desc']);
|
|
26
|
-
|
|
27
|
-
/** A board's sort direction. */
|
|
28
|
-
export type SortDir = z.infer<typeof SortDir>;
|
|
29
|
-
|
|
30
|
-
/** A 1-based rank on a board. */
|
|
31
|
-
export const Rank = z.number().int().positive();
|
|
32
|
-
|
|
33
|
-
/** A leaderboard's identity, without entries. */
|
|
34
|
-
export const Board = z.object({
|
|
35
|
-
key: Key,
|
|
36
|
-
/** Set by the first submitter or the game's `package.json`; defaults to the key. */
|
|
37
|
-
title: z.string(),
|
|
38
|
-
sortDir: SortDir,
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
/** A leaderboard's identity. */
|
|
42
|
-
export type Board = z.infer<typeof Board>;
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* One ranked row of a board. Suspended players and (unless the board opts
|
|
46
|
-
* in) zero scores are left out.
|
|
47
|
-
*/
|
|
48
|
-
export const LeaderboardEntry = z.object({
|
|
49
|
-
rank: Rank,
|
|
50
|
-
userId: UserId,
|
|
51
|
-
username: z.string(),
|
|
52
|
-
score: z.number(),
|
|
53
|
-
/** The JSON the game stored with the score, or `null`. */
|
|
54
|
-
meta: Json,
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
/** A ranked board row. */
|
|
58
|
-
export type LeaderboardEntry = z.infer<typeof LeaderboardEntry>;
|
|
59
|
-
|
|
60
|
-
/** Every board of one game, each with its top entries (`GET /leaderboards`, the snapshot). */
|
|
61
|
-
export const GameLeaderboards = z.object({
|
|
62
|
-
slug: Slug,
|
|
63
|
-
/** The game's title (the slug when the game is no longer listed). */
|
|
64
|
-
title: z.string(),
|
|
65
|
-
boards: z.array(Board.extend({ top: z.array(LeaderboardEntry) })),
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
/** One game's boards with their top entries. */
|
|
69
|
-
export type GameLeaderboards = z.infer<typeof GameLeaderboards>;
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* The player's own standings in one game (`GET /me/records`, the snapshot):
|
|
73
|
-
* their score and rank on each board they have an entry on.
|
|
74
|
-
*/
|
|
75
|
-
export const GameRecords = z.object({
|
|
76
|
-
slug: Slug,
|
|
77
|
-
/** The game's title (the slug when the game is no longer listed). */
|
|
78
|
-
title: z.string(),
|
|
79
|
-
records: z.array(Board.extend({ score: z.number(), rank: Rank })),
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
/** The player's standings in one game. */
|
|
83
|
-
export type GameRecords = z.infer<typeof GameRecords>;
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* `?top=` for the cross-game views (`GET /leaderboards`, `GET /me/snapshot`):
|
|
87
|
-
* entries per board, 1 to 50 (clamped), default 5.
|
|
88
|
-
*/
|
|
89
|
-
export const TopQuery = z.object({ top: LenientRowCount });
|
|
90
|
-
|
|
91
|
-
/** The leaderboards endpoints. */
|
|
92
|
-
export const leaderboardsEndpoints = {
|
|
93
|
-
...session.group({
|
|
94
|
-
/** Every board of every game, grouped by game, each with its top entries. */
|
|
95
|
-
listTopOfEveryLeaderboard: {
|
|
96
|
-
method: 'GET', path: '/leaderboards', query: TopQuery,
|
|
97
|
-
response: z.object({ games: z.array(GameLeaderboards) }),
|
|
98
|
-
doc: { summary: 'Every board across every game, with top entries', tags: ['leaderboards'] },
|
|
99
|
-
},
|
|
100
|
-
}),
|
|
101
|
-
...use(session, game).path('/games/:slug').group({
|
|
102
|
-
listGameLeaderboards: {
|
|
103
|
-
method: 'GET', path: '/leaderboards',
|
|
104
|
-
response: z.object({ boards: z.array(Board) }),
|
|
105
|
-
errors: { 404: ErrorBody },
|
|
106
|
-
doc: { summary: "The game's boards (no entries)", tags: ['leaderboards'] },
|
|
107
|
-
},
|
|
108
|
-
getLeaderboard: {
|
|
109
|
-
method: 'GET', path: '/leaderboards/:key',
|
|
110
|
-
params: BoardKeyParams,
|
|
111
|
-
/** `limit`: rows to return, 1 to 500 (clamped), default 50. */
|
|
112
|
-
query: z.object({ limit: LenientRowCount }),
|
|
113
|
-
response: z.object({ board: Board, entries: z.array(LeaderboardEntry) }),
|
|
114
|
-
errors: { 400: ErrorBody, 404: ErrorBody },
|
|
115
|
-
doc: { summary: "One board's ranked entries (404 if the board doesn't exist)", tags: ['leaderboards'] },
|
|
116
|
-
},
|
|
117
|
-
}),
|
|
118
|
-
...use(requireUser, game).path('/games/:slug').group({
|
|
119
|
-
/**
|
|
120
|
-
* Submit a score. The first submission creates the board (with `title`,
|
|
121
|
-
* `sort` and `showZeros`; later values are ignored). Only an improvement
|
|
122
|
-
* replaces the player's entry. Boards a game declares with
|
|
123
|
-
* `source: "server"` refuse client scores (403 `server_board`).
|
|
124
|
-
*/
|
|
125
|
-
submitScore: {
|
|
126
|
-
method: 'POST', path: '/leaderboards/:key/scores',
|
|
127
|
-
params: BoardKeyParams,
|
|
128
|
-
body: z.object({
|
|
129
|
-
/** A finite number (numeric strings pass, as for the old hub's `Number(x)`). */
|
|
130
|
-
score: z.coerce.number<number>({ error: 'score must be a finite number.' }),
|
|
131
|
-
/** Board title, used only when this call creates the board (non-text is ignored). */
|
|
132
|
-
title: z.string().optional().catch(undefined),
|
|
133
|
-
/** Sort direction, used only on creation. Anything but `asc` means `desc`. */
|
|
134
|
-
sort: SortDir.optional().catch(undefined),
|
|
135
|
-
/** Keep zero scores visible, used only on creation. */
|
|
136
|
-
showZeros: Truthy.optional(),
|
|
137
|
-
/** Any JSON stored with the entry (a character, a run seed). */
|
|
138
|
-
meta: Json.optional(),
|
|
139
|
-
}),
|
|
140
|
-
response: z.object({
|
|
141
|
-
board: Board,
|
|
142
|
-
/** The player's stored best after this submission. */
|
|
143
|
-
best: z.number().nullable(),
|
|
144
|
-
/** The player's rank on the board (`null` if they have no entry). */
|
|
145
|
-
rank: Rank.nullable(),
|
|
146
|
-
}),
|
|
147
|
-
errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
|
|
148
|
-
doc: { summary: 'Submit a score (best-only; creates the board on first use)', tags: ['leaderboards'] },
|
|
149
|
-
},
|
|
150
|
-
}),
|
|
151
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* Leaderboards: score submission, boards and standings. A board belongs to a
|
|
3
|
+
* game and is keyed by an app-chosen {@link Key}; it keeps each player's best
|
|
4
|
+
* score only (highest for `desc` boards, lowest for `asc`).
|
|
5
|
+
*/
|
|
6
|
+
import { use } from '@ayepi/core';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { Json, Key, Slug, UserId } from '../common';
|
|
9
|
+
import { ErrorBody } from '../errors';
|
|
10
|
+
import { game, requireUser, session } from '../middleware';
|
|
11
|
+
import { RowCount, Truthy } from '../coerce';
|
|
12
|
+
|
|
13
|
+
/** A board key in a path, with the old hub's message when it is malformed. */
|
|
14
|
+
const BoardKeyParams = z.object({
|
|
15
|
+
key: z.string().refine((key) => Key.safeParse(key).success, { error: 'board key must match /^[A-Za-z0-9_.:-]{1,64}$/ (1–64 chars).' }),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A row count the hub reads like `Number(x) || default`: anything that is not
|
|
20
|
+
* an integer falls back to the default instead of failing.
|
|
21
|
+
*/
|
|
22
|
+
const LenientRowCount = RowCount.optional().catch(undefined);
|
|
23
|
+
|
|
24
|
+
/** A board's sort direction: `desc` ranks high scores first, `asc` low scores (times) first. */
|
|
25
|
+
export const SortDir = z.enum(['asc', 'desc']);
|
|
26
|
+
|
|
27
|
+
/** A board's sort direction. */
|
|
28
|
+
export type SortDir = z.infer<typeof SortDir>;
|
|
29
|
+
|
|
30
|
+
/** A 1-based rank on a board. */
|
|
31
|
+
export const Rank = z.number().int().positive();
|
|
32
|
+
|
|
33
|
+
/** A leaderboard's identity, without entries. */
|
|
34
|
+
export const Board = z.object({
|
|
35
|
+
key: Key,
|
|
36
|
+
/** Set by the first submitter or the game's `package.json`; defaults to the key. */
|
|
37
|
+
title: z.string(),
|
|
38
|
+
sortDir: SortDir,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** A leaderboard's identity. */
|
|
42
|
+
export type Board = z.infer<typeof Board>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One ranked row of a board. Suspended players and (unless the board opts
|
|
46
|
+
* in) zero scores are left out.
|
|
47
|
+
*/
|
|
48
|
+
export const LeaderboardEntry = z.object({
|
|
49
|
+
rank: Rank,
|
|
50
|
+
userId: UserId,
|
|
51
|
+
username: z.string(),
|
|
52
|
+
score: z.number(),
|
|
53
|
+
/** The JSON the game stored with the score, or `null`. */
|
|
54
|
+
meta: Json,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** A ranked board row. */
|
|
58
|
+
export type LeaderboardEntry = z.infer<typeof LeaderboardEntry>;
|
|
59
|
+
|
|
60
|
+
/** Every board of one game, each with its top entries (`GET /leaderboards`, the snapshot). */
|
|
61
|
+
export const GameLeaderboards = z.object({
|
|
62
|
+
slug: Slug,
|
|
63
|
+
/** The game's title (the slug when the game is no longer listed). */
|
|
64
|
+
title: z.string(),
|
|
65
|
+
boards: z.array(Board.extend({ top: z.array(LeaderboardEntry) })),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** One game's boards with their top entries. */
|
|
69
|
+
export type GameLeaderboards = z.infer<typeof GameLeaderboards>;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The player's own standings in one game (`GET /me/records`, the snapshot):
|
|
73
|
+
* their score and rank on each board they have an entry on.
|
|
74
|
+
*/
|
|
75
|
+
export const GameRecords = z.object({
|
|
76
|
+
slug: Slug,
|
|
77
|
+
/** The game's title (the slug when the game is no longer listed). */
|
|
78
|
+
title: z.string(),
|
|
79
|
+
records: z.array(Board.extend({ score: z.number(), rank: Rank })),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** The player's standings in one game. */
|
|
83
|
+
export type GameRecords = z.infer<typeof GameRecords>;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `?top=` for the cross-game views (`GET /leaderboards`, `GET /me/snapshot`):
|
|
87
|
+
* entries per board, 1 to 50 (clamped), default 5.
|
|
88
|
+
*/
|
|
89
|
+
export const TopQuery = z.object({ top: LenientRowCount });
|
|
90
|
+
|
|
91
|
+
/** The leaderboards endpoints. */
|
|
92
|
+
export const leaderboardsEndpoints = {
|
|
93
|
+
...session.group({
|
|
94
|
+
/** Every board of every game, grouped by game, each with its top entries. */
|
|
95
|
+
listTopOfEveryLeaderboard: {
|
|
96
|
+
method: 'GET', path: '/leaderboards', query: TopQuery,
|
|
97
|
+
response: z.object({ games: z.array(GameLeaderboards) }),
|
|
98
|
+
doc: { summary: 'Every board across every game, with top entries', tags: ['leaderboards'] },
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
...use(session, game).path('/games/:slug').group({
|
|
102
|
+
listGameLeaderboards: {
|
|
103
|
+
method: 'GET', path: '/leaderboards',
|
|
104
|
+
response: z.object({ boards: z.array(Board) }),
|
|
105
|
+
errors: { 404: ErrorBody },
|
|
106
|
+
doc: { summary: "The game's boards (no entries)", tags: ['leaderboards'] },
|
|
107
|
+
},
|
|
108
|
+
getLeaderboard: {
|
|
109
|
+
method: 'GET', path: '/leaderboards/:key',
|
|
110
|
+
params: BoardKeyParams,
|
|
111
|
+
/** `limit`: rows to return, 1 to 500 (clamped), default 50. */
|
|
112
|
+
query: z.object({ limit: LenientRowCount }),
|
|
113
|
+
response: z.object({ board: Board, entries: z.array(LeaderboardEntry) }),
|
|
114
|
+
errors: { 400: ErrorBody, 404: ErrorBody },
|
|
115
|
+
doc: { summary: "One board's ranked entries (404 if the board doesn't exist)", tags: ['leaderboards'] },
|
|
116
|
+
},
|
|
117
|
+
}),
|
|
118
|
+
...use(requireUser, game).path('/games/:slug').group({
|
|
119
|
+
/**
|
|
120
|
+
* Submit a score. The first submission creates the board (with `title`,
|
|
121
|
+
* `sort` and `showZeros`; later values are ignored). Only an improvement
|
|
122
|
+
* replaces the player's entry. Boards a game declares with
|
|
123
|
+
* `source: "server"` refuse client scores (403 `server_board`).
|
|
124
|
+
*/
|
|
125
|
+
submitScore: {
|
|
126
|
+
method: 'POST', path: '/leaderboards/:key/scores',
|
|
127
|
+
params: BoardKeyParams,
|
|
128
|
+
body: z.object({
|
|
129
|
+
/** A finite number (numeric strings pass, as for the old hub's `Number(x)`). */
|
|
130
|
+
score: z.coerce.number<number>({ error: 'score must be a finite number.' }),
|
|
131
|
+
/** Board title, used only when this call creates the board (non-text is ignored). */
|
|
132
|
+
title: z.string().optional().catch(undefined),
|
|
133
|
+
/** Sort direction, used only on creation. Anything but `asc` means `desc`. */
|
|
134
|
+
sort: SortDir.optional().catch(undefined),
|
|
135
|
+
/** Keep zero scores visible, used only on creation. */
|
|
136
|
+
showZeros: Truthy.optional(),
|
|
137
|
+
/** Any JSON stored with the entry (a character, a run seed). */
|
|
138
|
+
meta: Json.optional(),
|
|
139
|
+
}),
|
|
140
|
+
response: z.object({
|
|
141
|
+
board: Board,
|
|
142
|
+
/** The player's stored best after this submission. */
|
|
143
|
+
best: z.number().nullable(),
|
|
144
|
+
/** The player's rank on the board (`null` if they have no entry). */
|
|
145
|
+
rank: Rank.nullable(),
|
|
146
|
+
}),
|
|
147
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
|
|
148
|
+
doc: { summary: 'Submit a score (best-only; creates the board on first use)', tags: ['leaderboards'] },
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
};
|