@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,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `game.controls`: the gamepad-to-keyboard map zero-config games use, so a
|
|
3
|
+
* controller or arcade stick works in a game that never calls
|
|
4
|
+
* `hub.input.define()` (docs/hub.md "Mapping controls per game").
|
|
5
|
+
*/
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { SEAT_LIMITS } from '../limits';
|
|
8
|
+
|
|
9
|
+
/** A `KeyboardEvent.code`, such as `ArrowUp`, `Space`, `KeyZ` or `ShiftLeft`. */
|
|
10
|
+
export const KeyCode = z.string().regex(/^[A-Za-z0-9]{1,32}$/, 'must be a KeyboardEvent.code such as ArrowUp, Space or KeyZ');
|
|
11
|
+
|
|
12
|
+
/** A standard-gamepad button index, `"0"` to `"19"`. */
|
|
13
|
+
export const ButtonIndex = z.string().regex(/^(?:1?[0-9])$/, 'must be a gamepad button index 0-19');
|
|
14
|
+
|
|
15
|
+
/** One player's map. Unset directions and buttons keep the defaults. */
|
|
16
|
+
export const ControlMap = z.strictObject({
|
|
17
|
+
/** Key pressed for stick/d-pad up. Default `ArrowUp`. */
|
|
18
|
+
up: KeyCode.optional(),
|
|
19
|
+
/** Key pressed for stick/d-pad down. Default `ArrowDown`. */
|
|
20
|
+
down: KeyCode.optional(),
|
|
21
|
+
/** Key pressed for stick/d-pad left. Default `ArrowLeft`. */
|
|
22
|
+
left: KeyCode.optional(),
|
|
23
|
+
/** Key pressed for stick/d-pad right. Default `ArrowRight`. */
|
|
24
|
+
right: KeyCode.optional(),
|
|
25
|
+
/**
|
|
26
|
+
* Button index → key. Defaults: `0` Space, `1` Enter, `2` KeyZ, `3` KeyX,
|
|
27
|
+
* `8` Escape, `9` Enter.
|
|
28
|
+
*/
|
|
29
|
+
buttons: z.record(ButtonIndex, KeyCode).optional(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** A gamepad-to-keyboard map. */
|
|
33
|
+
export type ControlMap = z.infer<typeof ControlMap>;
|
|
34
|
+
|
|
35
|
+
/** `game.controls`: one map for every pad, or one per player in `players`. */
|
|
36
|
+
export const GameControls = ControlMap.extend({
|
|
37
|
+
/** Per-player maps, by gamepad order; pads past the end use the first. Overrides the shared map. */
|
|
38
|
+
players: z.array(ControlMap).min(1).max(SEAT_LIMITS.maxSeats).optional(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** `game.controls`. */
|
|
42
|
+
export type GameControls = z.infer<typeof GameControls>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field rules shared by several parts of the `game.*` manifest. Each rule is
|
|
3
|
+
* defined once here.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { MANIFEST_LIMITS, SEAT_LIMITS } from '../limits';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A non-empty string with no leading or trailing whitespace. Used for fields
|
|
10
|
+
* the hub reads verbatim (commands, paths, titles), where stray spaces are a
|
|
11
|
+
* mistake rather than something the hub would trim away.
|
|
12
|
+
*/
|
|
13
|
+
export const Trimmed = z.string().regex(/^\S(?:[\s\S]*\S)?$/, 'must be non-empty, with no leading or trailing spaces');
|
|
14
|
+
|
|
15
|
+
/** A label the hub trims and shows to players, at most `max` characters. */
|
|
16
|
+
export function trimmedText(max: number) {
|
|
17
|
+
return z.string().trim().min(1).max(max);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A URL suffix relative to `/<slug>/` that invites and Join open, such as
|
|
22
|
+
* `?lobby=1` or `play/online`. Not absolute, no `..`, no `//`.
|
|
23
|
+
*/
|
|
24
|
+
export const LobbySuffix = z
|
|
25
|
+
.string()
|
|
26
|
+
.trim()
|
|
27
|
+
.regex(new RegExp(`^[A-Za-z0-9_./?=&#%-]{0,${MANIFEST_LIMITS.lobbyMax}}$`), 'may only use letters, digits and _ . / ? = & # % -')
|
|
28
|
+
.refine((s) => !s.startsWith('/') && !s.includes('..') && !s.includes('//'), 'must be a relative URL suffix (no leading /, no .., no //)');
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A `[min, max]` player range: whole numbers, `lowest <= min <= max <= 8`.
|
|
32
|
+
*
|
|
33
|
+
* @param lowest - the smallest `min` allowed (1 for multiplayer, 2 for races)
|
|
34
|
+
*/
|
|
35
|
+
export function playerRange(lowest: number) {
|
|
36
|
+
const seatCount = z.number().int().min(lowest).max(SEAT_LIMITS.maxSeats);
|
|
37
|
+
return z.tuple([seatCount, seatCount]).refine(([min, max]) => min <= max, `must be [min, max] with ${lowest} <= min <= max <= ${SEAT_LIMITS.maxSeats}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* An optional block that may also be written as `false` ("off"). `false`
|
|
42
|
+
* parses to `undefined`, the same as leaving the field out.
|
|
43
|
+
*
|
|
44
|
+
* (A `z.union` with `z.literal(false)` would work too, but it hides the
|
|
45
|
+
* block's own field errors behind a bare "Invalid input".)
|
|
46
|
+
*/
|
|
47
|
+
export function orFalse<T extends z.ZodType>(block: T) {
|
|
48
|
+
return z.preprocess((v: false | z.input<T>) => (v === false ? undefined : v), block.optional()).optional();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** True when no two items share a `key`. */
|
|
52
|
+
export function hasUniqueKeys(items: readonly { readonly key: string }[]): boolean {
|
|
53
|
+
return new Set(items.map((i) => i.key)).size === items.length;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The placeholders a `build` or `start` command may use. */
|
|
57
|
+
export const COMMAND_PLACEHOLDERS = ['PORT', 'GAME_PATH', 'GAME_SLUG'] as const;
|
|
58
|
+
|
|
59
|
+
const PLACEHOLDER = /\{\{(\w+)\}\}/g;
|
|
60
|
+
const KNOWN_PLACEHOLDERS: ReadonlySet<string> = new Set(COMMAND_PLACEHOLDERS);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A shell command the host runs in the app folder. It may use the
|
|
64
|
+
* placeholders `{{PORT}}`, `{{GAME_PATH}}` and `{{GAME_SLUG}}`; any other
|
|
65
|
+
* `{{NAME}}` fails the build.
|
|
66
|
+
*/
|
|
67
|
+
export const Command = Trimmed.refine(
|
|
68
|
+
(cmd) => [...cmd.matchAll(PLACEHOLDER)].every((m) => KNOWN_PLACEHOLDERS.has(m[1] ?? '')),
|
|
69
|
+
`may only use the placeholders ${COMMAND_PLACEHOLDERS.map((p) => `{{${p}}}`).join(', ')}`,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
/** True when a relative path names something strictly inside its folder. */
|
|
73
|
+
function staysInside(path: string): boolean {
|
|
74
|
+
let depth = 0;
|
|
75
|
+
for (const part of path.split(/[\\/]/)) {
|
|
76
|
+
if (part === '..') {
|
|
77
|
+
depth -= 1;
|
|
78
|
+
if (depth < 0) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
} else if (part !== '' && part !== '.') {
|
|
82
|
+
depth += 1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return depth > 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A file path relative to the app folder that stays inside it, e.g. `cover.png`. */
|
|
89
|
+
export const AppFilePath = Trimmed.refine(staysInside, 'must be a file inside the app folder');
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `game` block of an app's `package.json`: how the host builds, serves
|
|
3
|
+
* and shows the game (HANDOFF.md "The game block").
|
|
4
|
+
*
|
|
5
|
+
* This is the **strict** authoring schema `hub-doctor` runs in a game: a
|
|
6
|
+
* mistake fails the parse. The host stays lenient at boot (a bad field falls
|
|
7
|
+
* back to its default with a warning), so one typo never takes a game down.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { MANIFEST_LIMITS } from '../limits';
|
|
11
|
+
import { GameChanges } from './changes';
|
|
12
|
+
import { GameControls } from './controls';
|
|
13
|
+
import { AppFilePath, Command, Trimmed, orFalse } from './fields';
|
|
14
|
+
import { GameLeaderboardDecls } from './leaderboards';
|
|
15
|
+
import { GameMultiplayer, StaticGameMultiplayer } from './multiplayer';
|
|
16
|
+
import { GameRace } from './race';
|
|
17
|
+
|
|
18
|
+
/** The corner the injected hub menu button sits in. Default `top-left`. */
|
|
19
|
+
export const MenuPosition = z.enum(['top-left', 'top-right', 'bottom-left', 'bottom-right']);
|
|
20
|
+
|
|
21
|
+
/** A menu button corner. */
|
|
22
|
+
export type MenuPosition = z.infer<typeof MenuPosition>;
|
|
23
|
+
|
|
24
|
+
/** Pixels to push the menu button away from its corner, 0 to 600. */
|
|
25
|
+
const OffsetPx = z.number().int().min(0).max(MANIFEST_LIMITS.menuOffsetMax);
|
|
26
|
+
|
|
27
|
+
/** The menu offset as the host sends it: both axes, in px. */
|
|
28
|
+
export const MenuOffsetXY = z.strictObject({
|
|
29
|
+
/** Inward from the left or right edge, px. */
|
|
30
|
+
x: OffsetPx,
|
|
31
|
+
/** Down from a top corner or up from a bottom one, px. */
|
|
32
|
+
y: OffsetPx,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `game.menuOffset`: how far to push the menu button away from its corner so
|
|
37
|
+
* it clears the game's own bars. A number moves it vertically only.
|
|
38
|
+
*/
|
|
39
|
+
export const MenuOffset = z.union([OffsetPx, MenuOffsetXY.partial()]);
|
|
40
|
+
|
|
41
|
+
/** Fields both app types share. */
|
|
42
|
+
const commonFields = {
|
|
43
|
+
/** Shown on the catalog card and in the menu. */
|
|
44
|
+
title: Trimmed,
|
|
45
|
+
/** Catalog, search and link-preview text. Falls back to `package.json` `description`. */
|
|
46
|
+
description: Trimmed.optional(),
|
|
47
|
+
/**
|
|
48
|
+
* Credit shown on the tile, e.g. `"Mason & Phil"` (split into authors for the
|
|
49
|
+
* catalog filter). Falls back to `package.json` `author`.
|
|
50
|
+
*/
|
|
51
|
+
author: z.string().trim().min(1).optional(),
|
|
52
|
+
/** Runs at boot when the source changed. May use `{{PORT}}`, `{{GAME_PATH}}`, `{{GAME_SLUG}}`. */
|
|
53
|
+
build: Command.optional(),
|
|
54
|
+
/** Tile and preview image, relative to the project root (not `serveDir`); must stay inside it. */
|
|
55
|
+
image: AppFilePath.optional(),
|
|
56
|
+
/** `true` leaves the app out of the catalog entirely. */
|
|
57
|
+
hidden: z.boolean().optional(),
|
|
58
|
+
/** `false` opts out of the injected hub menu and SDK. */
|
|
59
|
+
hub: z.boolean().optional(),
|
|
60
|
+
/** `false` opts out of the PWA install tags and service worker. */
|
|
61
|
+
pwa: z.boolean().optional(),
|
|
62
|
+
/** The corner the hub menu button sits in. Default `top-left`. */
|
|
63
|
+
menuPosition: MenuPosition.optional(),
|
|
64
|
+
/** Push the menu button away from its corner, px: a number (vertical) or `{ x, y }`. */
|
|
65
|
+
menuOffset: MenuOffset.optional(),
|
|
66
|
+
/** `true` opts into the shared daily challenges (`hub.daily.define(...)`). */
|
|
67
|
+
dailyChallenges: z.boolean().optional(),
|
|
68
|
+
/** Boards to create up front, so they show before the first score. */
|
|
69
|
+
leaderboards: GameLeaderboardDecls.optional(),
|
|
70
|
+
/** The gamepad-to-keyboard map for games that never call `hub.input.define()`. */
|
|
71
|
+
controls: GameControls.optional(),
|
|
72
|
+
/** Turns a single-player game into a race target; `true` for all defaults, `false` for off. */
|
|
73
|
+
race: GameRace,
|
|
74
|
+
/** Player-facing "What's new" notes, one per change, newest last. Keep it updated. */
|
|
75
|
+
changes: GameChanges.optional(),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** A static app: the build emits a folder the host serves. */
|
|
79
|
+
export const StaticGame = z.strictObject({
|
|
80
|
+
/** `static`: no running process. */
|
|
81
|
+
type: z.literal('static'),
|
|
82
|
+
/** The folder served at `/<slug>/`; must exist after `build`. */
|
|
83
|
+
serveDir: Trimmed,
|
|
84
|
+
/** Makes the game an online invite/join target (`hub-rooms` only); `false` for off. */
|
|
85
|
+
multiplayer: orFalse(StaticGameMultiplayer),
|
|
86
|
+
...commonFields,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** A process app: the host runs a long-lived server and proxies to it. */
|
|
90
|
+
export const ProcessGame = z.strictObject({
|
|
91
|
+
/** `process`: the host starts `start` and proxies `/<slug>/` to it. */
|
|
92
|
+
type: z.literal('process'),
|
|
93
|
+
/** The long-lived server command; must listen on the assigned port (`{{PORT}}` or `PORT`). */
|
|
94
|
+
start: Command,
|
|
95
|
+
/** The path the host polls until it answers below 500. Default `/`. */
|
|
96
|
+
healthPath: Trimmed.startsWith('/').optional(),
|
|
97
|
+
/** Makes the game an online invite/join target; `false` for off. */
|
|
98
|
+
multiplayer: orFalse(GameMultiplayer),
|
|
99
|
+
...commonFields,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/** The `game` block of a game's `package.json`. */
|
|
103
|
+
export const GameManifest = z.discriminatedUnion('type', [StaticGame, ProcessGame]);
|
|
104
|
+
|
|
105
|
+
/** A parsed `game` block (defaults applied). */
|
|
106
|
+
export type GameManifest = z.output<typeof GameManifest>;
|
|
107
|
+
|
|
108
|
+
/** The `game` block as a game writes it. */
|
|
109
|
+
export type GameManifestInput = z.input<typeof GameManifest>;
|
|
110
|
+
|
|
111
|
+
/** A game's `package.json`: the `game` block plus the npm fields the host falls back to. */
|
|
112
|
+
export const GamePackageJson = z.looseObject({
|
|
113
|
+
/** The npm name; the title fallback. */
|
|
114
|
+
name: z.string().optional(),
|
|
115
|
+
/** The description fallback. */
|
|
116
|
+
description: z.string().optional(),
|
|
117
|
+
/** The author fallback: npm's string or `{ name }` form. */
|
|
118
|
+
author: z.union([z.string(), z.looseObject({ name: z.string().optional() })]).optional(),
|
|
119
|
+
/** How the host treats the app. */
|
|
120
|
+
game: GameManifest,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The host repo's own `game` block: only its "What's new" notes (shown as
|
|
125
|
+
* "diffenderfer.games"). It is not an app, so it has no `type`.
|
|
126
|
+
*/
|
|
127
|
+
export const HostManifest = z.strictObject({
|
|
128
|
+
/** The host's display name. */
|
|
129
|
+
title: Trimmed.optional(),
|
|
130
|
+
/** The host's own notes (menu, friends, home page). */
|
|
131
|
+
changes: GameChanges.optional(),
|
|
132
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `package.json` `game.*` manifest and the injected `window.__HUB__`
|
|
3
|
+
* config.
|
|
4
|
+
*/
|
|
5
|
+
export * from './changes';
|
|
6
|
+
export * from './controls';
|
|
7
|
+
export { AppFilePath, COMMAND_PLACEHOLDERS, Command, LobbySuffix } from './fields';
|
|
8
|
+
export * from './game';
|
|
9
|
+
export * from './inject';
|
|
10
|
+
export * from './leaderboards';
|
|
11
|
+
export * from './multiplayer';
|
|
12
|
+
export * from './race';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `window.__HUB__`: the small config object the host writes into every page
|
|
3
|
+
* it injects (game pages, and the catalog page). The SDK and the menu read it
|
|
4
|
+
* to find the game's slug, the API base and the menu placement.
|
|
5
|
+
*
|
|
6
|
+
* The same inline script may also set `window.__hubDailyDeep` (a
|
|
7
|
+
* `?daily=YYYY-MM-DD` deep link captured before game code runs) and
|
|
8
|
+
* `window.__hubInstall` (the PWA install prompt); those are not part of this
|
|
9
|
+
* object.
|
|
10
|
+
*/
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
import { Slug } from '../common';
|
|
13
|
+
import { GameControls } from './controls';
|
|
14
|
+
import { MenuOffsetXY, MenuPosition } from './game';
|
|
15
|
+
|
|
16
|
+
/** The injected `window.__HUB__` config. */
|
|
17
|
+
export const HubInject = z.strictObject({
|
|
18
|
+
/** The game's slug. Missing on the catalog page. */
|
|
19
|
+
slug: Slug.optional(),
|
|
20
|
+
/** Where the REST API lives. Always `/_api` today. */
|
|
21
|
+
apiBase: z.literal('/_api'),
|
|
22
|
+
/** The menu button's corner (from `game.menuPosition`). Missing on the catalog page. */
|
|
23
|
+
menu: MenuPosition.optional(),
|
|
24
|
+
/** The menu button's offset from its corner (from `game.menuOffset`); only sent when not 0,0. */
|
|
25
|
+
menuOffset: MenuOffsetXY.optional(),
|
|
26
|
+
/** The game's `game.controls`, as written; only sent when set. */
|
|
27
|
+
controls: GameControls.optional(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** The injected `window.__HUB__` config. */
|
|
31
|
+
export type HubInject = z.infer<typeof HubInject>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `game.leaderboards`: boards declared up front, so they show on the catalog
|
|
3
|
+
* before the first score.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { SortDir } from '../api/leaderboards';
|
|
7
|
+
import { Key } from '../common';
|
|
8
|
+
import { MANIFEST_LIMITS } from '../limits';
|
|
9
|
+
import { Trimmed, hasUniqueKeys } from './fields';
|
|
10
|
+
|
|
11
|
+
/** One declared leaderboard. */
|
|
12
|
+
export const LeaderboardDecl = z.strictObject({
|
|
13
|
+
/** The board key `hub.submitScore(key, score)` uses: letters, digits and `_ . : -`, 1 to 64. */
|
|
14
|
+
key: Key,
|
|
15
|
+
/** What players see, at most 80 characters. Defaults to the key. */
|
|
16
|
+
title: Trimmed.max(MANIFEST_LIMITS.leaderboardTitleMax).optional(),
|
|
17
|
+
/** Which scores rank first. Default `desc`. */
|
|
18
|
+
sort: SortDir.default('desc'),
|
|
19
|
+
/** List players whose best is 0. Default `false`. */
|
|
20
|
+
showZeros: z.boolean().optional(),
|
|
21
|
+
/**
|
|
22
|
+
* `server`: only the game's own server may submit (trusted multiplayer
|
|
23
|
+
* results); the client endpoint refuses. Default `client`.
|
|
24
|
+
*/
|
|
25
|
+
source: z.enum(['client', 'server']).default('client'),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** `game.leaderboards`: unique keys. */
|
|
29
|
+
export const GameLeaderboardDecls = z.array(LeaderboardDecl).refine(hasUniqueKeys, 'leaderboard keys must be unique');
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `game.multiplayer`: the opt-in that makes a game an online invite/join
|
|
3
|
+
* target (docs/multiplayer.md §7).
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { MANIFEST_LIMITS } from '../limits';
|
|
7
|
+
import { LobbySuffix, hasUniqueKeys, playerRange, trimmedText } from './fields';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* How players connect: `hub-rooms` (the hub relays room messages) or
|
|
11
|
+
* `own-server` (a process app runs its own game server; process apps only).
|
|
12
|
+
*/
|
|
13
|
+
export const Transport = z.enum(['hub-rooms', 'own-server']);
|
|
14
|
+
|
|
15
|
+
/** A mode key: letters, digits, `_` or `-`, 1 to 32 characters. */
|
|
16
|
+
export const ModeKey = z.string().regex(/^[A-Za-z0-9_-]{1,32}$/, 'must be 1-32 letters, digits, _ or -');
|
|
17
|
+
|
|
18
|
+
/** A game mode players can be invited into. */
|
|
19
|
+
export const MultiplayerMode = z.strictObject({
|
|
20
|
+
/** Sent with invites and launches as `mode`. */
|
|
21
|
+
key: ModeKey,
|
|
22
|
+
/** What players see. Defaults to the key. */
|
|
23
|
+
title: trimmedText(MANIFEST_LIMITS.modeTitleMax).optional(),
|
|
24
|
+
/** `[min, max]` players for this mode, within 1..8. Defaults to the game's. */
|
|
25
|
+
players: playerRange(1).optional(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A soften-list word this game's chat may keep (e.g. `shot` for a pool game):
|
|
30
|
+
* one lowercase word of 2 to 20 letters. Reject-list words never pass.
|
|
31
|
+
*/
|
|
32
|
+
export const ChatAllowWord = z.string().trim().toLowerCase().regex(/^[a-z]{2,20}$/, 'must be a single word of 2-20 letters');
|
|
33
|
+
|
|
34
|
+
/** The `game.multiplayer` block. */
|
|
35
|
+
export const GameMultiplayer = z.strictObject({
|
|
36
|
+
/** URL suffix invites and Join open, relative to `/<slug>/`. Default `''` (the game's root). */
|
|
37
|
+
lobby: LobbySuffix.default(''),
|
|
38
|
+
/** `[min, max]` players, within 1..8. Default `[2, 8]`. */
|
|
39
|
+
players: playerRange(1).default([2, 8]),
|
|
40
|
+
/**
|
|
41
|
+
* `hub-rooms` or `own-server`. Default: `own-server` for process apps,
|
|
42
|
+
* `hub-rooms` for static apps (which cannot use `own-server`).
|
|
43
|
+
*/
|
|
44
|
+
transport: Transport.optional(),
|
|
45
|
+
/** Friends can invite each other into this game. Default `true`. */
|
|
46
|
+
invites: z.boolean().default(true),
|
|
47
|
+
/** Friends can join a game in progress. Default `true`. */
|
|
48
|
+
join: z.boolean().default(true),
|
|
49
|
+
/** Friends can watch. Default `false`. */
|
|
50
|
+
spectate: z.boolean().default(false),
|
|
51
|
+
/** Offer quick match. Default `false`. */
|
|
52
|
+
quickMatch: z.boolean().default(false),
|
|
53
|
+
/** Modes players can be invited into (at most 16, unique keys). */
|
|
54
|
+
modes: z.array(MultiplayerMode).max(MANIFEST_LIMITS.modesMax).refine(hasUniqueKeys, 'mode keys must be unique').default([]),
|
|
55
|
+
/**
|
|
56
|
+
* Extra quick-chat phrases for this game (at most 24, each at most 40
|
|
57
|
+
* characters). Checked by the content filter at boot; failing ones are dropped.
|
|
58
|
+
*/
|
|
59
|
+
quickChat: z.array(trimmedText(MANIFEST_LIMITS.quickChatTextMax)).max(MANIFEST_LIMITS.quickChatMax).default([]),
|
|
60
|
+
/** Soften-list words this game's chat may keep (at most 20). */
|
|
61
|
+
chatAllow: z.array(ChatAllowWord).max(MANIFEST_LIMITS.chatAllowMax).default([]),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** A normalised `game.multiplayer` block. */
|
|
65
|
+
export type GameMultiplayer = z.output<typeof GameMultiplayer>;
|
|
66
|
+
|
|
67
|
+
/** `game.multiplayer` for a static app: `own-server` needs a process app. */
|
|
68
|
+
export const StaticGameMultiplayer = GameMultiplayer.refine((multiplayer) => multiplayer.transport !== 'own-server', {
|
|
69
|
+
message: 'own-server needs a process app; static apps use hub-rooms',
|
|
70
|
+
path: ['transport'],
|
|
71
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `game.race`: the opt-in that turns a single-player game into a race target
|
|
3
|
+
* (docs/multiplayer.md §14, docs/plans/race.md §5).
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { RaceKey, RaceParamValue as RaceValue } from '../api/mp/races';
|
|
7
|
+
import { MANIFEST_LIMITS } from '../limits';
|
|
8
|
+
import { LobbySuffix, hasUniqueKeys, playerRange, trimmedText } from './fields';
|
|
9
|
+
|
|
10
|
+
/** A race param value. */
|
|
11
|
+
type RaceValue = z.infer<typeof RaceValue>;
|
|
12
|
+
|
|
13
|
+
const RaceTitle = trimmedText(MANIFEST_LIMITS.raceTitleMax);
|
|
14
|
+
|
|
15
|
+
/** One choice of a race param: a bare value, or a value with a title. */
|
|
16
|
+
export const RaceOption = z.union([
|
|
17
|
+
RaceValue,
|
|
18
|
+
z.strictObject({
|
|
19
|
+
/** The value the game receives in `r.params[key]`. */
|
|
20
|
+
value: RaceValue,
|
|
21
|
+
/** What the creator sees. Defaults to the value. Checked like quick-chat text. */
|
|
22
|
+
title: RaceTitle.optional(),
|
|
23
|
+
}),
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** The value of an option, bare or titled. */
|
|
27
|
+
function optionValue(option: z.output<typeof RaceOption>): RaceValue {
|
|
28
|
+
return typeof option === 'object' ? option.value : option;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A choice the race creator makes. The server only accepts declared values. */
|
|
32
|
+
export const RaceParam = z
|
|
33
|
+
.strictObject({
|
|
34
|
+
/** The key the game reads, `r.params[key]`. */
|
|
35
|
+
key: RaceKey,
|
|
36
|
+
/** What the creator sees. Defaults to the key. Checked like quick-chat text. */
|
|
37
|
+
title: RaceTitle.optional(),
|
|
38
|
+
/** The choices, 1 to 12. */
|
|
39
|
+
options: z.array(RaceOption).min(1).max(MANIFEST_LIMITS.raceOptionsMax),
|
|
40
|
+
/** The preselected value; one of the options' values. Defaults to the first. */
|
|
41
|
+
default: RaceValue.optional(),
|
|
42
|
+
})
|
|
43
|
+
.refine((p) => p.default === undefined || p.options.some((option) => optionValue(option) === p.default), {
|
|
44
|
+
message: 'default must be one of the option values',
|
|
45
|
+
path: ['default'],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** How the live race HUD and the race history show a stat. */
|
|
49
|
+
export const RaceStatFormat = z.enum(['int', 'percent', 'time', 'bool', 'color']);
|
|
50
|
+
|
|
51
|
+
/** A value of {@link RaceStatFormat}. */
|
|
52
|
+
export type RaceStatFormat = z.infer<typeof RaceStatFormat>;
|
|
53
|
+
|
|
54
|
+
/** A stat the live race HUD and the race history show. */
|
|
55
|
+
export const RaceStat = z.strictObject({
|
|
56
|
+
/** The key the game sends in `hub.race.status({ [key]: value })`. */
|
|
57
|
+
key: RaceKey,
|
|
58
|
+
/** The column label. Defaults to the key. Checked like quick-chat text. */
|
|
59
|
+
title: RaceTitle.optional(),
|
|
60
|
+
/** `int` (default), `percent`, `time` (ms), `bool` or `color` (`#rrggbb`). */
|
|
61
|
+
format: RaceStatFormat.default('int'),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** The `game.race` object form. */
|
|
65
|
+
export const RaceBlock = z.strictObject({
|
|
66
|
+
/** URL suffix race invites and Join open, relative to `/<slug>/`. Default `?race=lobby`. */
|
|
67
|
+
lobby: LobbySuffix.default('?race=lobby'),
|
|
68
|
+
/** `[min, max]` racers, within 2..8. Default `[2, 2]`. */
|
|
69
|
+
players: playerRange(2).default([2, 2]),
|
|
70
|
+
/** What the creator picks (at most 6, unique keys). */
|
|
71
|
+
params: z.array(RaceParam).max(MANIFEST_LIMITS.raceParamsMax).refine(hasUniqueKeys, 'param keys must be unique').default([]),
|
|
72
|
+
/** What the live HUD and the history show (at most 8, unique keys; the HUD shows the first 3). */
|
|
73
|
+
stats: z.array(RaceStat).max(MANIFEST_LIMITS.raceStatsMax).refine(hasUniqueKeys, 'stat keys must be unique').default([]),
|
|
74
|
+
/**
|
|
75
|
+
* The earliest a finish may count after the go, in ms (0 to 600000). A
|
|
76
|
+
* sooner finish is held until then. Default: the hub's `raceMinFinishMs`.
|
|
77
|
+
*/
|
|
78
|
+
minMs: z.number().int().min(0).max(MANIFEST_LIMITS.raceMinMsMax).optional(),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/** A normalised `game.race` block. */
|
|
82
|
+
export type RaceBlock = z.output<typeof RaceBlock>;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `game.race`: an object, `true` for all defaults (no params, no stats), or
|
|
86
|
+
* `false` for off. `true` parses to the object form, `false` to `undefined`.
|
|
87
|
+
*/
|
|
88
|
+
export const GameRace = z
|
|
89
|
+
.preprocess((v: boolean | z.input<typeof RaceBlock>) => (v === true ? {} : v === false ? undefined : v), RaceBlock.optional())
|
|
90
|
+
.optional();
|
|
91
|
+
|
|
92
|
+
/** `game.race` as a game writes it. */
|
|
93
|
+
export type GameRaceInput = z.input<typeof GameRace>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middleware **defs** for the public API (plan §6.1). A def is a contract
|
|
3
|
+
* only: a name, the context it provides and its dependencies. It holds no
|
|
4
|
+
* code, so it is safe in a published package; the server binds the real
|
|
5
|
+
* implementation. Each endpoint's guards are part of its declaration, which
|
|
6
|
+
* lets tests check rules such as "every social endpoint requires an active
|
|
7
|
+
* account" (plan §10.3).
|
|
8
|
+
*/
|
|
9
|
+
import { ctx, middleware } from '@ayepi/core';
|
|
10
|
+
import { Slug } from './common';
|
|
11
|
+
import type { SessionUser } from './entities';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolves the `hub_session` cookie (the user, or `null`) and the anonymous
|
|
15
|
+
* `hub_cid` client id. Every endpoint has it.
|
|
16
|
+
*/
|
|
17
|
+
export const session = middleware('session', {
|
|
18
|
+
provides: ctx<{ user: SessionUser | null; clientId: string }>(),
|
|
19
|
+
doc: { security: { cookieAuth: { type: 'apiKey', in: 'cookie', name: 'hub_session' } } },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** Signed in, as a guest or an account. Otherwise 401 `signed_out`. */
|
|
23
|
+
export const requireUser = middleware('requireUser', {
|
|
24
|
+
provides: ctx<{ user: SessionUser }>(),
|
|
25
|
+
requires: [session],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Signed in and not suspended. Otherwise 401 `signed_out` or 403
|
|
30
|
+
* `suspended`. Every social, multiplayer, race and trade endpoint has it.
|
|
31
|
+
*/
|
|
32
|
+
export const requireActive = middleware('requireActive', {
|
|
33
|
+
provides: ctx<{ user: SessionUser }>(),
|
|
34
|
+
requires: [requireUser],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** Active and a claimed account (not a guest). Otherwise 403 `claim_required`. */
|
|
38
|
+
export const requireClaimed = middleware('requireClaimed', {
|
|
39
|
+
provides: ctx<{ user: SessionUser }>(),
|
|
40
|
+
requires: [requireActive],
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Owns the `:slug` path segment and checks it names a known game.
|
|
45
|
+
* Otherwise 404 "No such game".
|
|
46
|
+
*/
|
|
47
|
+
export const game = middleware.loader('slug', Slug, { requires: [session] });
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Definers for socket ops and events (plan §5.2). One declaration per op or
|
|
3
|
+
* event feeds the server (validation), the runtime and test clients (types)
|
|
4
|
+
* and the generated op/event tables.
|
|
5
|
+
*/
|
|
6
|
+
import type { z } from 'zod';
|
|
7
|
+
import type { ErrorCode } from '../errors';
|
|
8
|
+
|
|
9
|
+
/** A client → server op on `/_ws`: its input, its ack and the codes it can fail with. */
|
|
10
|
+
export interface OpDef<D extends z.ZodType = z.ZodType, A extends z.ZodType = z.ZodType> {
|
|
11
|
+
/** The op's input (`d` in `{t:'op', id, op, d}`). */
|
|
12
|
+
readonly d: D;
|
|
13
|
+
/** The success ack's payload (`d` in `{t:'ack', ok:true, d}`). */
|
|
14
|
+
readonly ack: A;
|
|
15
|
+
/** Error codes this op can fail with, beyond the envelope's own (see `ENVELOPE_ERRORS`). */
|
|
16
|
+
readonly errors: readonly ErrorCode[];
|
|
17
|
+
/**
|
|
18
|
+
* The refusal a malformed `d` gets (status 400), when the hub names it
|
|
19
|
+
* (`bad_stats`, `bad_patch`). Without it the refusal is `bad_request`
|
|
20
|
+
* with the first validation message.
|
|
21
|
+
*/
|
|
22
|
+
readonly invalid?: { readonly code: ErrorCode; readonly error: string };
|
|
23
|
+
/** One line for the generated op table. */
|
|
24
|
+
readonly doc: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A server → client event on `/_ws`. */
|
|
28
|
+
export interface EventDef<D extends z.ZodType = z.ZodType> {
|
|
29
|
+
/** The payload (`d` in `{t:'ev', ev, d}`). */
|
|
30
|
+
readonly d: D;
|
|
31
|
+
/** One line for the generated event table. */
|
|
32
|
+
readonly doc: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Declare an op (identity at runtime; keeps the literal types). */
|
|
36
|
+
export function op<const D extends z.ZodType, const A extends z.ZodType>(def: OpDef<D, A>): OpDef<D, A> {
|
|
37
|
+
return def;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Declare an event (identity at runtime; keeps the literal types). */
|
|
41
|
+
export function event<const D extends z.ZodType>(def: EventDef<D>): EventDef<D> {
|
|
42
|
+
return def;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The input type of an op. */
|
|
46
|
+
export type OpInput<O extends OpDef> = z.input<O['d']>;
|
|
47
|
+
|
|
48
|
+
/** The ack payload type of an op. */
|
|
49
|
+
export type OpAck<O extends OpDef> = z.output<O['ack']>;
|
|
50
|
+
|
|
51
|
+
/** The payload type of an event. */
|
|
52
|
+
export type EventData<E extends EventDef> = z.output<E['d']>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The legacy `/_ws` envelope (multiplayer-impl.md §4.2), unchanged since v1
|
|
3
|
+
* and kept indefinitely (plan D6):
|
|
4
|
+
*
|
|
5
|
+
* ```
|
|
6
|
+
* client → server {t:'op', id, op, d}
|
|
7
|
+
* server → client {t:'ack', id, ok:true, d}
|
|
8
|
+
* {t:'ack', id, ok:false, err: ErrorBody}
|
|
9
|
+
* {t:'ev', ev, d}
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import { Json } from '../common';
|
|
14
|
+
import { ErrorBody, type ErrorCode } from '../errors';
|
|
15
|
+
|
|
16
|
+
/** A client's op frame. `id` is the client's own counter, echoed in the ack. */
|
|
17
|
+
export const OpFrame = z.object({
|
|
18
|
+
t: z.literal('op'),
|
|
19
|
+
id: z.number(),
|
|
20
|
+
op: z.string(),
|
|
21
|
+
d: Json.optional(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** An op frame. */
|
|
25
|
+
export type OpFrame = z.infer<typeof OpFrame>;
|
|
26
|
+
|
|
27
|
+
/** A successful ack. */
|
|
28
|
+
export const OkAckFrame = z.object({ t: z.literal('ack'), id: z.number(), ok: z.literal(true), d: Json });
|
|
29
|
+
|
|
30
|
+
/** A failed ack. */
|
|
31
|
+
export const ErrAckFrame = z.object({ t: z.literal('ack'), id: z.number(), ok: z.literal(false), err: ErrorBody });
|
|
32
|
+
|
|
33
|
+
/** An ack frame: the server's answer to one op. */
|
|
34
|
+
export const AckFrame = z.discriminatedUnion('ok', [OkAckFrame, ErrAckFrame]);
|
|
35
|
+
|
|
36
|
+
/** An ack frame. */
|
|
37
|
+
export type AckFrame = z.infer<typeof AckFrame>;
|
|
38
|
+
|
|
39
|
+
/** A server-pushed event frame. */
|
|
40
|
+
export const EvFrame = z.object({ t: z.literal('ev'), ev: z.string(), d: Json });
|
|
41
|
+
|
|
42
|
+
/** An event frame. */
|
|
43
|
+
export type EvFrame = z.infer<typeof EvFrame>;
|
|
44
|
+
|
|
45
|
+
/** Any frame the server sends. */
|
|
46
|
+
export const ServerFrame = z.union([AckFrame, EvFrame]);
|
|
47
|
+
|
|
48
|
+
/** Close codes the client understands. */
|
|
49
|
+
export const CloseCode = {
|
|
50
|
+
/** Service restart: reconnect quietly. */
|
|
51
|
+
restart: 1012,
|
|
52
|
+
/** The account is suspended. */
|
|
53
|
+
suspended: 4003,
|
|
54
|
+
/** Too many sockets for this user or IP. */
|
|
55
|
+
tooMany: 4029,
|
|
56
|
+
/** A frame that wasn't a valid op. */
|
|
57
|
+
badMessage: 4400,
|
|
58
|
+
} as const;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Codes any op can fail with, raised by the envelope rather than the op:
|
|
62
|
+
* the per-socket rate limit, an unknown op name, and ops sent before `hello`.
|
|
63
|
+
*/
|
|
64
|
+
export const ENVELOPE_ERRORS: readonly ErrorCode[] = ['rate_limited', 'unknown_op', 'hello_required'];
|
|
65
|
+
|
|
66
|
+
/** Ops the server accepts before `hello`. */
|
|
67
|
+
export const PRE_HELLO_OPS: readonly string[] = ['hello', 'ping'];
|