@diffenderfer-games/hub-contract 0.0.0-stage → 2.0.0-next.0
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/LICENSE +21 -0
- package/README.md +25 -2
- 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 +51 -4
- 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
|
@@ -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
|
+
};
|
package/src/api/index.ts
ADDED
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct messages between mutual friends (multiplayer-impl.md §5.2).
|
|
3
|
+
*
|
|
4
|
+
* "Checked before delivered": text is moderated before it is stored or
|
|
5
|
+
* pushed. Free text needs the conversation's chat mode to be `on` (both
|
|
6
|
+
* claimed, friends, no parent lock, site switch on); otherwise only
|
|
7
|
+
* quick-chat ids go through. Hidden messages and messages from currently
|
|
8
|
+
* suspended senders are left out of every read.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { Millis, Ok, UserId } from '../common';
|
|
12
|
+
import { LooseNumber, requiredId } from '../coerce';
|
|
13
|
+
import { PlayerLite } from '../entities';
|
|
14
|
+
import { ContentRejected, ErrorBody } from '../errors';
|
|
15
|
+
import { requireClaimed } from '../middleware';
|
|
16
|
+
import { ChatMode } from '../entities';
|
|
17
|
+
import { QuickId } from './social/config';
|
|
18
|
+
|
|
19
|
+
/** A message id: a positive integer, increasing over time. */
|
|
20
|
+
export const MessageId = z.number().int().positive();
|
|
21
|
+
|
|
22
|
+
/** A direct message. */
|
|
23
|
+
export const Message = z.object({
|
|
24
|
+
id: MessageId,
|
|
25
|
+
/** The sender. */
|
|
26
|
+
from: UserId,
|
|
27
|
+
/** The recipient. */
|
|
28
|
+
to: UserId,
|
|
29
|
+
/** The delivered (possibly softened) text; a quick message carries its phrase's text. */
|
|
30
|
+
text: z.string(),
|
|
31
|
+
/** The quick-chat phrase id, for quick messages only. */
|
|
32
|
+
quick: QuickId.optional(),
|
|
33
|
+
/** When it was sent (epoch ms). */
|
|
34
|
+
at: Millis,
|
|
35
|
+
/** The recipient has read it. */
|
|
36
|
+
read: z.boolean(),
|
|
37
|
+
/** Words were swapped by the filter. Only on the sender's own copy. */
|
|
38
|
+
softened: z.literal(true).optional(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** A direct message. */
|
|
42
|
+
export type Message = z.infer<typeof Message>;
|
|
43
|
+
|
|
44
|
+
/** A conversation summary: the other player, the last message and how many of theirs you haven't read. */
|
|
45
|
+
export const Conversation = z.object({
|
|
46
|
+
user: PlayerLite,
|
|
47
|
+
last: Message,
|
|
48
|
+
unread: z.number().int().nonnegative(),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** A conversation summary. */
|
|
52
|
+
export type Conversation = z.infer<typeof Conversation>;
|
|
53
|
+
|
|
54
|
+
/** One page of a conversation's history. */
|
|
55
|
+
export const MessagesPage = z.object({
|
|
56
|
+
/** Oldest to newest, at most 50. */
|
|
57
|
+
messages: z.array(Message),
|
|
58
|
+
/** Older messages exist (page with `?before=` the first id). */
|
|
59
|
+
more: z.boolean(),
|
|
60
|
+
/** The mode the chat UI should render: `off` when DMs are off, `quick` while you are muted. */
|
|
61
|
+
mode: ChatMode,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** A page of history. */
|
|
65
|
+
export type MessagesPage = z.infer<typeof MessagesPage>;
|
|
66
|
+
|
|
67
|
+
/** A DM to send: free text, or a quick-chat phrase id (exactly one). */
|
|
68
|
+
export const SendMessageInput = z.object({
|
|
69
|
+
/** Free text (moderated; only when the conversation's mode is `on`). */
|
|
70
|
+
text: z.string().optional(),
|
|
71
|
+
/** A quick-chat phrase id (any mode but `off`). */
|
|
72
|
+
quick: QuickId.optional(),
|
|
73
|
+
}).refine((b) => (b.text === undefined) !== (b.quick === undefined), 'Send either text or a quick-chat id.');
|
|
74
|
+
|
|
75
|
+
/** A DM to send. */
|
|
76
|
+
export type SendMessageInput = z.infer<typeof SendMessageInput>;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Unread DMs (the `hello` snapshot's `unread`), ignoring hidden messages and
|
|
80
|
+
* suspended senders.
|
|
81
|
+
*/
|
|
82
|
+
export const UnreadSummary = z.object({
|
|
83
|
+
/** Total unread. */
|
|
84
|
+
dms: z.number().int().nonnegative(),
|
|
85
|
+
/** Unread per sender, keyed by the sender's user id (as a decimal string). */
|
|
86
|
+
byUser: z.record(z.string().regex(/^\d+$/), z.number().int().positive()),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** Unread DM counts. */
|
|
90
|
+
export type UnreadSummary = z.infer<typeof UnreadSummary>;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* `:userId`, the other side of the conversation: a claimed, non-suspended
|
|
94
|
+
* player other than you, else 404 "No such player." Read like `Number(x)`:
|
|
95
|
+
* anything that is not a player's id is the same 404.
|
|
96
|
+
*/
|
|
97
|
+
const Partner = z.object({ userId: LooseNumber.catch(Number.NaN) });
|
|
98
|
+
|
|
99
|
+
/** The direct-message endpoints. */
|
|
100
|
+
export const messagesEndpoints = {
|
|
101
|
+
...requireClaimed.group({
|
|
102
|
+
listConversations: {
|
|
103
|
+
method: 'GET', path: '/social/conversations',
|
|
104
|
+
response: z.object({
|
|
105
|
+
/** Newest first, one per partner, at most 200. */
|
|
106
|
+
conversations: z.array(Conversation),
|
|
107
|
+
}),
|
|
108
|
+
errors: { 401: ErrorBody, 403: ErrorBody },
|
|
109
|
+
doc: { summary: 'Your conversations, newest first', tags: ['messages'] },
|
|
110
|
+
},
|
|
111
|
+
listDirectMessages: {
|
|
112
|
+
method: 'GET', path: '/social/messages/:userId', params: Partner,
|
|
113
|
+
query: z.object({
|
|
114
|
+
/** Only messages older than this message id; `0` or anything not a number means from the newest. */
|
|
115
|
+
before: LooseNumber.optional().catch(undefined),
|
|
116
|
+
}),
|
|
117
|
+
response: MessagesPage,
|
|
118
|
+
errors: { 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
|
|
119
|
+
doc: { summary: 'One page of history with a player', tags: ['messages'] },
|
|
120
|
+
},
|
|
121
|
+
sendDirectMessage: {
|
|
122
|
+
method: 'POST', path: '/social/messages/:userId', params: Partner, body: SendMessageInput,
|
|
123
|
+
response: z.object({ message: Message }),
|
|
124
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody, 422: ContentRejected },
|
|
125
|
+
doc: {
|
|
126
|
+
summary: 'Send a DM to a friend',
|
|
127
|
+
description: '403 `not_friends` or `dms_closed`; 422 when the text is rejected, the chat mode forbids it, or you are sending too fast.',
|
|
128
|
+
tags: ['messages'],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
markDirectMessagesRead: {
|
|
132
|
+
method: 'POST', path: '/social/messages/:userId/read', params: Partner,
|
|
133
|
+
body: z.object({
|
|
134
|
+
/** Mark their messages to you up to and including this id. */
|
|
135
|
+
upTo: requiredId('upTo must be a message id.'),
|
|
136
|
+
}),
|
|
137
|
+
response: Ok,
|
|
138
|
+
errors: { 400: ErrorBody, 401: ErrorBody, 403: ErrorBody, 404: ErrorBody },
|
|
139
|
+
doc: { summary: "Mark a friend's messages to you as read", tags: ['messages'] },
|
|
140
|
+
},
|
|
141
|
+
}),
|
|
142
|
+
};
|