@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.
Files changed (76) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +26 -3
  4. package/dist/index.d.ts +8454 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +4855 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +53 -6
  9. package/src/api/analytics.ts +81 -0
  10. package/src/api/auth.ts +77 -0
  11. package/src/api/catalog.ts +171 -0
  12. package/src/api/daily.ts +122 -0
  13. package/src/api/economy/bundle.ts +69 -0
  14. package/src/api/economy/inventory.ts +57 -0
  15. package/src/api/economy/trades.ts +154 -0
  16. package/src/api/economy.ts +15 -0
  17. package/src/api/index.ts +85 -0
  18. package/src/api/leaderboards.ts +151 -0
  19. package/src/api/messages.ts +142 -0
  20. package/src/api/mp/chat.ts +36 -0
  21. package/src/api/mp/parties.ts +108 -0
  22. package/src/api/mp/races.ts +211 -0
  23. package/src/api/mp/rooms.ts +90 -0
  24. package/src/api/mp/snapshot.ts +17 -0
  25. package/src/api/mp.ts +110 -0
  26. package/src/api/notify.ts +111 -0
  27. package/src/api/progress/game-data.ts +189 -0
  28. package/src/api/progress.ts +59 -0
  29. package/src/api/race.ts +47 -0
  30. package/src/api/reports.ts +208 -0
  31. package/src/api/social/config.ts +47 -0
  32. package/src/api/social/endpoints.ts +179 -0
  33. package/src/api/social/people.ts +118 -0
  34. package/src/api/social/presence.ts +95 -0
  35. package/src/api/social.ts +9 -0
  36. package/src/api/version.ts +40 -0
  37. package/src/coerce.ts +50 -0
  38. package/src/common.ts +42 -0
  39. package/src/entities.ts +62 -0
  40. package/src/errors/codes/core.ts +10 -0
  41. package/src/errors/codes/multiplayer.ts +43 -0
  42. package/src/errors/codes/play.ts +5 -0
  43. package/src/errors/codes/social.ts +31 -0
  44. package/src/errors/index.ts +88 -0
  45. package/src/index.ts +16 -0
  46. package/src/limits.ts +100 -0
  47. package/src/logic/daily.ts +54 -0
  48. package/src/logic/index.ts +8 -0
  49. package/src/logic/presence.ts +62 -0
  50. package/src/logic/race.ts +169 -0
  51. package/src/logic/time.ts +81 -0
  52. package/src/manifest/changes.ts +55 -0
  53. package/src/manifest/controls.ts +42 -0
  54. package/src/manifest/fields.ts +89 -0
  55. package/src/manifest/game.ts +132 -0
  56. package/src/manifest/index.ts +12 -0
  57. package/src/manifest/inject.ts +31 -0
  58. package/src/manifest/leaderboards.ts +29 -0
  59. package/src/manifest/multiplayer.ts +71 -0
  60. package/src/manifest/race.ts +93 -0
  61. package/src/middleware.ts +47 -0
  62. package/src/realtime/define.ts +52 -0
  63. package/src/realtime/envelope.ts +67 -0
  64. package/src/realtime/events/multiplayer.ts +8 -0
  65. package/src/realtime/events/parties.ts +31 -0
  66. package/src/realtime/events/races.ts +27 -0
  67. package/src/realtime/events/rooms.ts +48 -0
  68. package/src/realtime/events/social.ts +81 -0
  69. package/src/realtime/index.ts +24 -0
  70. package/src/realtime/ops/empty.ts +8 -0
  71. package/src/realtime/ops/hello.ts +40 -0
  72. package/src/realtime/ops/multiplayer.ts +8 -0
  73. package/src/realtime/ops/parties.ts +13 -0
  74. package/src/realtime/ops/races.ts +99 -0
  75. package/src/realtime/ops/rooms.ts +122 -0
  76. package/src/realtime/ops/social.ts +58 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Daily challenges: one deterministic challenge per UTC day for games with
3
+ * `game.dailyChallenges: true`. Starting a day mints a signed token; the game
4
+ * presents it on completion. Completions are counted on the game's `daily`
5
+ * leaderboard. The client owns the calendar (its local date); the hub only
6
+ * reports which days are done.
7
+ */
8
+ import { use } from '@ayepi/core';
9
+ import { z } from 'zod';
10
+ import { DayKey, Json, Millis, Slug, UserId } from '../common';
11
+ import { ErrorBody } from '../errors';
12
+ import { game, requireUser, session } from '../middleware';
13
+ import { Rank } from './leaderboards';
14
+
15
+ /** One completed day. */
16
+ export const DailyCompletion = z.object({
17
+ /** Epoch ms (server clock). */
18
+ completedAt: Millis,
19
+ /** The JSON the game sent on completion, or `null`. */
20
+ meta: Json,
21
+ });
22
+
23
+ /** One completed day. */
24
+ export type DailyCompletion = z.infer<typeof DailyCompletion>;
25
+
26
+ /** A player's completions in one game, keyed by `YYYY-MM-DD`. */
27
+ export const DailyCompletions = z.record(DayKey, DailyCompletion);
28
+
29
+ /** A player's completions in one game. */
30
+ export type DailyCompletions = z.infer<typeof DailyCompletions>;
31
+
32
+ /** `:day` of a challenge: within 30 days back and 1 day ahead of UTC today. */
33
+ const DayParams = z.object({ day: z.string().refine((day) => DayKey.safeParse(day).success, { error: 'Invalid day (expected YYYY-MM-DD).' }) });
34
+
35
+ /** The daily endpoints. */
36
+ export const dailyEndpoints = {
37
+ ...session.group({
38
+ /**
39
+ * Every daily game with the caller's completions (empty maps when signed
40
+ * out), plus the top 10 players by completions across all games.
41
+ */
42
+ listDailyChallengesForAllGames: {
43
+ method: 'GET', path: '/daily',
44
+ response: z.object({
45
+ /** UTC today, a hint: clients use their local date. */
46
+ today: DayKey,
47
+ games: z.array(z.object({ slug: Slug, title: z.string(), completions: DailyCompletions })),
48
+ global: z.array(z.object({
49
+ rank: Rank,
50
+ userId: UserId,
51
+ username: z.string(),
52
+ /** Daily challenges completed, across all games. */
53
+ score: z.number().int().nonnegative(),
54
+ })),
55
+ }),
56
+ doc: { summary: 'Daily challenges across every game, plus global standings', tags: ['daily'] },
57
+ },
58
+ }),
59
+ ...use(session, game).path('/games/:slug').group({
60
+ getGameDailyChallenges: {
61
+ method: 'GET', path: '/daily',
62
+ response: z.object({
63
+ enabled: z.literal(true),
64
+ /** UTC today, a hint: clients use their local date. */
65
+ today: DayKey,
66
+ /** Empty when signed out. */
67
+ completions: DailyCompletions,
68
+ }),
69
+ errors: { 404: ErrorBody },
70
+ doc: { summary: "The caller's completions in this game (404 if it has no dailies)", tags: ['daily'] },
71
+ },
72
+ }),
73
+ ...use(requireUser, game).path('/games/:slug').group({
74
+ /**
75
+ * Start (or replay) a day: returns its seed and a signed token to present
76
+ * on completion. 400 when the day is outside the window.
77
+ */
78
+ startDailyChallenge: {
79
+ method: 'POST', path: '/daily/:day/start', params: DayParams,
80
+ response: z.object({
81
+ /** Present it to `dailyComplete` for the same day. */
82
+ token: z.string(),
83
+ day: DayKey,
84
+ /** Deterministic per (game, day), an unsigned 32-bit integer. */
85
+ seed: z.number().int().min(0).max(0xffff_ffff),
86
+ /** When the token stops working (epoch ms), about 26 hours out. */
87
+ expiresAt: Millis,
88
+ alreadyCompleted: z.boolean(),
89
+ /** Epoch ms, or `null` when not completed yet. */
90
+ completedAt: Millis.nullable(),
91
+ /** The earlier completion's JSON, or `null`. */
92
+ meta: Json,
93
+ }),
94
+ errors: { 400: ErrorBody, 401: ErrorBody, 404: ErrorBody },
95
+ doc: { summary: "Start a day's challenge and get its token", tags: ['daily'] },
96
+ },
97
+ /**
98
+ * Record a completion with the token from `dailyStart`. Idempotent: a
99
+ * replay of a done day changes nothing. 401 on a bad, expired or
100
+ * mismatched token.
101
+ */
102
+ completeDailyChallenge: {
103
+ method: 'POST', path: '/daily/:day/complete', params: DayParams,
104
+ body: z.object({
105
+ /** The token from `dailyStart`; a missing or non-text token is refused like a bad one (401). */
106
+ token: z.string().optional().catch(undefined),
107
+ /** Any JSON; a numeric `meta.score` is kept with the completion. */
108
+ meta: Json.optional(),
109
+ }),
110
+ response: z.object({
111
+ ok: z.literal(true),
112
+ day: DayKey,
113
+ /** The caller's completions in this game. */
114
+ count: z.number().int().positive(),
115
+ /** The caller's rank on the game's `daily` board. */
116
+ rank: Rank.nullable(),
117
+ }),
118
+ errors: { 400: ErrorBody, 401: ErrorBody, 404: ErrorBody },
119
+ doc: { summary: 'Complete a day with its token', tags: ['daily'] },
120
+ },
121
+ }),
122
+ };
@@ -0,0 +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>;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Per-game inventory: the hub is the ledger (no negative balances, goods are
3
+ * conserved in trades) but does not judge game rules, so granting is trusted
4
+ * like scores are.
5
+ */
6
+ import { use } from '@ayepi/core';
7
+ import { z } from 'zod';
8
+ import { ErrorBody } from '../../errors';
9
+ import { game, requireActive, requireUser } from '../../middleware';
10
+ import { bundleInput, Inventory } from './bundle';
11
+
12
+ const BAD_USER_ID = 'Bad user id.';
13
+
14
+ /** `:userId`: a positive integer, else 400 "Bad user id.". */
15
+ const PlayerIdParam = z.coerce.number<number>({ error: BAD_USER_ID }).int({ error: BAD_USER_ID }).positive({ error: BAD_USER_ID });
16
+
17
+ /** The inventory endpoints. */
18
+ export const inventoryEndpoints = {
19
+ ...use(requireUser, game).path('/games/:slug').group({
20
+ getMyInventory: {
21
+ method: 'GET', path: '/inventory', response: Inventory,
22
+ errors: { 401: ErrorBody, 404: ErrorBody },
23
+ doc: { summary: "The caller's items and coins in this game", tags: ['economy'] },
24
+ },
25
+ /** Add goods to the caller (gameplay rewards, opening a box). The body is the bundle. */
26
+ grantInventoryItems: {
27
+ method: 'POST', path: '/inventory/grant', body: bundleInput('grant'), response: Inventory,
28
+ errors: { 400: ErrorBody, 401: ErrorBody, 404: ErrorBody },
29
+ doc: { summary: 'Grant goods to the caller; returns the new holdings', tags: ['economy'] },
30
+ },
31
+ /**
32
+ * Atomically remove `take` from the caller and add `give`: selling (take
33
+ * items, give coins) and buying from the house (take coins, give items).
34
+ * 409 when the caller can't cover `take`.
35
+ */
36
+ exchangeInventoryItems: {
37
+ method: 'POST', path: '/inventory/exchange',
38
+ body: z.object({ take: bundleInput('take').nullish(), give: bundleInput('give').nullish() }),
39
+ response: Inventory,
40
+ errors: { 400: ErrorBody, 401: ErrorBody, 404: ErrorBody, 409: ErrorBody },
41
+ doc: { summary: 'Swap goods with the house; returns the new holdings', tags: ['economy'] },
42
+ },
43
+ }),
44
+ ...use(requireActive, game).path('/games/:slug').group({
45
+ /**
46
+ * Another claimed player's holdings in this game, for composing a trade
47
+ * offer. 404 for a guest or unknown id.
48
+ */
49
+ getPlayerInventory: {
50
+ method: 'GET', path: '/players/:userId/inventory',
51
+ params: z.object({ userId: PlayerIdParam }),
52
+ response: Inventory,
53
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
54
+ doc: { summary: "Another player's holdings (to compose a trade)", tags: ['economy'] },
55
+ },
56
+ }),
57
+ };
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Async player-to-player trades on the economy ledger. Making an offer
3
+ * escrows its `give` side; the recipient accepts (atomic swap), declines or
4
+ * counters, and the sender may cancel. Pending offers expire after 14 days
5
+ * (`HUB_TRADE_TTL_MS`), refunding the escrow.
6
+ */
7
+ import { ctx, middleware, use } from '@ayepi/core';
8
+ import { z } from 'zod';
9
+ import { Millis, Slug } from '../../common';
10
+ import { PlayerLite, type SessionUser } from '../../entities';
11
+ import { ContentRejected, ErrorBody } from '../../errors';
12
+ import { game, requireActive } from '../../middleware';
13
+ import { LooseNumber, LooseText } from '../../coerce';
14
+ import { Bundle, bundleInput } from './bundle';
15
+
16
+ /** Where an offer is in its life. Only `pending` offers can change. */
17
+ export const TradeStatus = z.enum(['pending', 'accepted', 'declined', 'cancelled', 'countered', 'expired']);
18
+
19
+ /** A trade status. */
20
+ export type TradeStatus = z.infer<typeof TradeStatus>;
21
+
22
+ /** A party to a trade, or a player the roster offers to trade with. */
23
+ export const TradePlayer = PlayerLite.pick({ id: true, username: true });
24
+
25
+ /** A trade party. */
26
+ export type TradePlayer = z.infer<typeof TradePlayer>;
27
+
28
+ /** A trade offer, seen from the caller's side. */
29
+ export const TradeOffer = z.object({
30
+ id: z.number().int().positive(),
31
+ game: Slug,
32
+ status: TradeStatus,
33
+ /** `outgoing` when the caller sent it. */
34
+ direction: z.enum(['incoming', 'outgoing']),
35
+ from: TradePlayer,
36
+ to: TradePlayer,
37
+ /** What the sender offers (escrowed while pending). */
38
+ give: Bundle,
39
+ /** What the sender asks for in return. */
40
+ want: Bundle,
41
+ /** The sender's note, `''` when none. */
42
+ note: z.string(),
43
+ /** The offer this one counters, or `null`. */
44
+ parentId: z.number().int().positive().nullable(),
45
+ /** Epoch ms. */
46
+ createdAt: Millis,
47
+ /** When it stopped being pending (epoch ms), or `null`. */
48
+ resolvedAt: Millis.nullable(),
49
+ /** When a pending offer expires (epoch ms). */
50
+ expiresAt: Millis,
51
+ });
52
+
53
+ /** A trade offer. */
54
+ export type TradeOffer = z.infer<typeof TradeOffer>;
55
+
56
+ /**
57
+ * Active and a claimed account, for making, accepting and countering trades
58
+ * (so escrow is never stranded on a guest). A guest gets 403 "Create a free
59
+ * account (via the menu) to trade." with no `code`, before the game or the
60
+ * body is checked; unlike the shared `requireClaimed`, which says
61
+ * `claim_required`.
62
+ */
63
+ export const requireTrader = middleware('requireTrader', {
64
+ provides: ctx<{ user: SessionUser }>(),
65
+ requires: [requireActive],
66
+ });
67
+
68
+ /** `{ offer }`: the offer after a trade action. */
69
+ const OfferResult = z.object({ offer: TradeOffer });
70
+
71
+ /** The two sides of a new offer or counter, plus an optional note (moderated, up to 200 characters). */
72
+ const OfferTerms = z.object({
73
+ give: bundleInput('give').nullish(),
74
+ want: bundleInput('want').nullish(),
75
+ /** Shown to the other player, so it is moderated. */
76
+ note: LooseText.nullish(),
77
+ });
78
+
79
+ /**
80
+ * `:id` of a trade offer, read with `Number()`: an id that is not a number
81
+ * names no offer, so it is a 404 "No such trade offer." like an unknown one.
82
+ */
83
+ const OfferParams = z.object({ id: LooseNumber.catch(Number.NaN) });
84
+
85
+ /** Errors from resolving an offer: not yours (403), unknown (404), no longer pending (409). */
86
+ const ResolveErrors = { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody };
87
+
88
+ /** The trade endpoints. */
89
+ export const tradeEndpoints = {
90
+ ...use(requireActive, game).path('/games/:slug').group({
91
+ /** Claimed players with a footprint in this game, excluding the caller (up to 500, by name). */
92
+ listTradePartners: {
93
+ method: 'GET', path: '/trades/players',
94
+ response: z.object({ players: z.array(TradePlayer) }),
95
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
96
+ doc: { summary: 'Players the caller can trade with in this game', tags: ['trades'] },
97
+ },
98
+ /** The caller's offers in this game, newest first (up to 100). Expired offers are settled first. */
99
+ listMyTrades: {
100
+ method: 'GET', path: '/trades',
101
+ query: z.object({
102
+ /** Only offers sent to (`incoming`) or by (`outgoing`) the caller; omit (or anything else) for both. */
103
+ box: z.enum(['incoming', 'outgoing']).optional().catch(undefined),
104
+ /** Only offers with this {@link TradeStatus}; an unknown status matches nothing, `''` means any. */
105
+ status: z.union([TradeStatus, z.string()]).optional(),
106
+ }),
107
+ response: z.object({ offers: z.array(TradeOffer) }),
108
+ errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
109
+ doc: { summary: "The caller's trade offers", tags: ['trades'] },
110
+ },
111
+ declineTrade: {
112
+ method: 'POST', path: '/trades/:id/decline', params: OfferParams, response: OfferResult,
113
+ errors: ResolveErrors,
114
+ doc: { summary: 'Decline an offer sent to the caller (refunds the escrow)', tags: ['trades'] },
115
+ },
116
+ cancelTrade: {
117
+ method: 'POST', path: '/trades/:id/cancel', params: OfferParams, response: OfferResult,
118
+ errors: ResolveErrors,
119
+ doc: { summary: 'Cancel an offer the caller sent (refunds the escrow)', tags: ['trades'] },
120
+ },
121
+ }),
122
+ ...use(requireTrader, game).path('/games/:slug').group({
123
+ /**
124
+ * Offer a trade to the player named `toUser`. Escrows `give` (409 if the
125
+ * caller can't cover it); at least one side must be non-empty.
126
+ */
127
+ createTrade: {
128
+ method: 'POST', path: '/trades',
129
+ body: OfferTerms.extend({
130
+ /** The recipient's username (case-insensitive). Missing or blank is a 400. */
131
+ toUser: LooseText.nullish(),
132
+ }),
133
+ responses: { 201: OfferResult },
134
+ errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 409: ErrorBody, 422: ContentRejected },
135
+ doc: { summary: 'Make a trade offer', tags: ['trades'] },
136
+ },
137
+ /** Accept an offer sent to the caller: they must own its `want` side (409 otherwise). */
138
+ acceptTrade: {
139
+ method: 'POST', path: '/trades/:id/accept', params: OfferParams, response: OfferResult,
140
+ errors: ResolveErrors,
141
+ doc: { summary: 'Accept an offer (atomic swap)', tags: ['trades'] },
142
+ },
143
+ /**
144
+ * Counter an offer sent to the caller: the original is refunded and
145
+ * closed as `countered`, and a linked offer goes back to its sender.
146
+ */
147
+ counterTradeOffer: {
148
+ method: 'POST', path: '/trades/:id/counter', params: OfferParams, body: OfferTerms,
149
+ responses: { 201: OfferResult },
150
+ errors: { ...ResolveErrors, 400: ErrorBody, 422: ContentRejected },
151
+ doc: { summary: 'Counter an offer with a new one', tags: ['trades'] },
152
+ },
153
+ }),
154
+ };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Per-game economy: inventory, grants, exchanges and trades. The endpoints
3
+ * and shapes live in `economy/`.
4
+ */
5
+ import { inventoryEndpoints } from './economy/inventory';
6
+ import { tradeEndpoints } from './economy/trades';
7
+
8
+ export * from './economy/bundle';
9
+ export { requireTrader, TradeOffer, TradePlayer, TradeStatus } from './economy/trades';
10
+
11
+ /** The economy endpoints. */
12
+ export const economyEndpoints = {
13
+ ...inventoryEndpoints,
14
+ ...tradeEndpoints,
15
+ };
@@ -0,0 +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 });
@@ -0,0 +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
+ };