@diffenderfer-games/hub-contract 0.0.0-stage → 2.0.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +26 -3
- package/dist/index.d.ts +8454 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4855 -0
- package/dist/index.js.map +1 -0
- package/package.json +53 -6
- package/src/api/analytics.ts +81 -0
- package/src/api/auth.ts +77 -0
- package/src/api/catalog.ts +171 -0
- package/src/api/daily.ts +122 -0
- package/src/api/economy/bundle.ts +69 -0
- package/src/api/economy/inventory.ts +57 -0
- package/src/api/economy/trades.ts +154 -0
- package/src/api/economy.ts +15 -0
- package/src/api/index.ts +85 -0
- package/src/api/leaderboards.ts +151 -0
- package/src/api/messages.ts +142 -0
- package/src/api/mp/chat.ts +36 -0
- package/src/api/mp/parties.ts +108 -0
- package/src/api/mp/races.ts +211 -0
- package/src/api/mp/rooms.ts +90 -0
- package/src/api/mp/snapshot.ts +17 -0
- package/src/api/mp.ts +110 -0
- package/src/api/notify.ts +111 -0
- package/src/api/progress/game-data.ts +189 -0
- package/src/api/progress.ts +59 -0
- package/src/api/race.ts +47 -0
- package/src/api/reports.ts +208 -0
- package/src/api/social/config.ts +47 -0
- package/src/api/social/endpoints.ts +179 -0
- package/src/api/social/people.ts +118 -0
- package/src/api/social/presence.ts +95 -0
- package/src/api/social.ts +9 -0
- package/src/api/version.ts +40 -0
- package/src/coerce.ts +50 -0
- package/src/common.ts +42 -0
- package/src/entities.ts +62 -0
- package/src/errors/codes/core.ts +10 -0
- package/src/errors/codes/multiplayer.ts +43 -0
- package/src/errors/codes/play.ts +5 -0
- package/src/errors/codes/social.ts +31 -0
- package/src/errors/index.ts +88 -0
- package/src/index.ts +16 -0
- package/src/limits.ts +100 -0
- package/src/logic/daily.ts +54 -0
- package/src/logic/index.ts +8 -0
- package/src/logic/presence.ts +62 -0
- package/src/logic/race.ts +169 -0
- package/src/logic/time.ts +81 -0
- package/src/manifest/changes.ts +55 -0
- package/src/manifest/controls.ts +42 -0
- package/src/manifest/fields.ts +89 -0
- package/src/manifest/game.ts +132 -0
- package/src/manifest/index.ts +12 -0
- package/src/manifest/inject.ts +31 -0
- package/src/manifest/leaderboards.ts +29 -0
- package/src/manifest/multiplayer.ts +71 -0
- package/src/manifest/race.ts +93 -0
- package/src/middleware.ts +47 -0
- package/src/realtime/define.ts +52 -0
- package/src/realtime/envelope.ts +67 -0
- package/src/realtime/events/multiplayer.ts +8 -0
- package/src/realtime/events/parties.ts +31 -0
- package/src/realtime/events/races.ts +27 -0
- package/src/realtime/events/rooms.ts +48 -0
- package/src/realtime/events/social.ts +81 -0
- package/src/realtime/index.ts +24 -0
- package/src/realtime/ops/empty.ts +8 -0
- package/src/realtime/ops/hello.ts +40 -0
- package/src/realtime/ops/multiplayer.ts +8 -0
- package/src/realtime/ops/parties.ts +13 -0
- package/src/realtime/ops/races.ts +99 -0
- package/src/realtime/ops/rooms.ts +122 -0
- package/src/realtime/ops/social.ts +58 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Race ops (docs/plans/race.md §4). A race is a hub room with a referee: the
|
|
3
|
+
* countdown starts when every seat is filled, the seed is revealed with it,
|
|
4
|
+
* and the first valid finish wins. Reports come from browsers, so the hub
|
|
5
|
+
* measures time itself and enforces what it can (`minMs`, one result each).
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { Slug } from '../../common';
|
|
9
|
+
import { OpaqueId } from '../../api/mp/parties';
|
|
10
|
+
import { RaceParams, RaceProgress, RaceResult, RaceStats } from '../../api/mp/races';
|
|
11
|
+
import { RoomInfo, RoomTarget } from '../../api/mp/rooms';
|
|
12
|
+
import { op, type OpDef } from '../define';
|
|
13
|
+
import { EmptyAck, NoInput } from './empty';
|
|
14
|
+
|
|
15
|
+
/** A racer's report (status, finish, lose): stats (`null` means none) and optional 0..1 progress. */
|
|
16
|
+
const RaceReport = z.object({
|
|
17
|
+
stats: RaceStats.nullish(),
|
|
18
|
+
progress: RaceProgress.nullish(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/** The refusal a malformed report gets (old wording). */
|
|
22
|
+
const BAD_STATS = { code: 'bad_stats', error: 'Race stats must be numbers, true/false or #rrggbb colours.' } as const;
|
|
23
|
+
|
|
24
|
+
/** A race room ack. */
|
|
25
|
+
const RoomAck = z.object({ room: RoomInfo });
|
|
26
|
+
|
|
27
|
+
/** The race ops, by op name. */
|
|
28
|
+
export const raceOps = {
|
|
29
|
+
'race.create': op({
|
|
30
|
+
d: z.object({
|
|
31
|
+
game: Slug,
|
|
32
|
+
/** The creator's choices; undeclared keys and values fall back to the defaults. */
|
|
33
|
+
params: RaceParams.optional(),
|
|
34
|
+
/** Shown in Open races and can alert followers (default true). */
|
|
35
|
+
public: z.boolean().optional(),
|
|
36
|
+
/** Seats, within the game's `race.players` (default its min). */
|
|
37
|
+
maxPlayers: z.number().optional(),
|
|
38
|
+
/** The caller's party: when they lead it, its join info becomes `{ room: code }`. */
|
|
39
|
+
partyId: OpaqueId.optional(),
|
|
40
|
+
}),
|
|
41
|
+
ack: RoomAck,
|
|
42
|
+
errors: ['not_race', 'too_many_rooms'],
|
|
43
|
+
doc: 'Open a race room with the caller seated; the countdown starts once every seat is filled.',
|
|
44
|
+
}),
|
|
45
|
+
'race.join': op({
|
|
46
|
+
d: RoomTarget,
|
|
47
|
+
ack: RoomAck,
|
|
48
|
+
errors: ['room_not_found', 'banned', 'in_progress', 'room_full', 'not_multiplayer', 'not_supported'],
|
|
49
|
+
doc: 'Join (or watch) a race room, like `room.join` but only for races.',
|
|
50
|
+
}),
|
|
51
|
+
'race.list': op({
|
|
52
|
+
d: z.object({ game: Slug }),
|
|
53
|
+
ack: z.object({ rooms: z.array(RoomInfo) }),
|
|
54
|
+
errors: [],
|
|
55
|
+
doc: 'Public races waiting for racers (at most 50), minus rooms with blocked players.',
|
|
56
|
+
}),
|
|
57
|
+
'race.start': op({
|
|
58
|
+
d: NoInput,
|
|
59
|
+
ack: EmptyAck,
|
|
60
|
+
errors: ['not_in_race', 'not_host', 'in_progress', 'not_ready'],
|
|
61
|
+
doc: 'Creator: start the countdown early once the minimum racers are in.',
|
|
62
|
+
}),
|
|
63
|
+
'race.status': op({
|
|
64
|
+
d: RaceReport,
|
|
65
|
+
ack: EmptyAck,
|
|
66
|
+
errors: ['not_in_race', 'not_started', 'race_over', 'rate_limited', 'bad_stats'],
|
|
67
|
+
invalid: BAD_STATS,
|
|
68
|
+
doc: 'Live status for the race HUD. Numbers, booleans and #rrggbb only: never text.',
|
|
69
|
+
}),
|
|
70
|
+
'race.finish': op({
|
|
71
|
+
d: RaceReport,
|
|
72
|
+
ack: z.object({ result: RaceResult }),
|
|
73
|
+
errors: ['not_in_race', 'not_started', 'race_over', 'too_fast', 'bad_stats'],
|
|
74
|
+
invalid: BAD_STATS,
|
|
75
|
+
doc: 'Report a finish with final stats; the first valid finish wins the race.',
|
|
76
|
+
}),
|
|
77
|
+
'race.lose': op({
|
|
78
|
+
d: RaceReport,
|
|
79
|
+
ack: EmptyAck,
|
|
80
|
+
errors: ['not_in_race', 'not_started', 'race_over', 'bad_stats'],
|
|
81
|
+
invalid: BAD_STATS,
|
|
82
|
+
doc: 'Game over for this racer (out of lives, stuck…); the last racer left wins.',
|
|
83
|
+
}),
|
|
84
|
+
'race.forfeit': op({
|
|
85
|
+
d: NoInput,
|
|
86
|
+
ack: EmptyAck,
|
|
87
|
+
errors: ['not_in_race', 'race_over'],
|
|
88
|
+
doc: 'Give up (leaving the room mid-race counts the same).',
|
|
89
|
+
}),
|
|
90
|
+
'race.rematch': op({
|
|
91
|
+
d: z.object({
|
|
92
|
+
/** Creator only: params for the next round. */
|
|
93
|
+
params: RaceParams.optional(),
|
|
94
|
+
}),
|
|
95
|
+
ack: EmptyAck,
|
|
96
|
+
errors: ['not_in_race', 'in_progress'],
|
|
97
|
+
doc: 'Ask for another round; it starts when every seated racer has asked.',
|
|
98
|
+
}),
|
|
99
|
+
} satisfies Record<string, OpDef>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub relay room ops (multiplayer-impl.md §6). Rooms hold up to 8 seats;
|
|
3
|
+
* the hub relays messages and state but never referees, so results are
|
|
4
|
+
* untrusted. Race rooms refuse `room.start`, `room.state` and `room.end`
|
|
5
|
+
* (`race_room`): the race ops drive them.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { Json, Slug, UserId } from '../../common';
|
|
9
|
+
import { CHAT_ERRORS, ChatLine, ChatSend } from '../../api/mp/chat';
|
|
10
|
+
import { GameMode, OpaqueId } from '../../api/mp/parties';
|
|
11
|
+
import { RoomInfo, RoomMessage, RoomModel, RoomState, RoomTarget } from '../../api/mp/rooms';
|
|
12
|
+
import { op, type OpDef } from '../define';
|
|
13
|
+
import { EmptyAck, NoInput } from './empty';
|
|
14
|
+
|
|
15
|
+
/** A per-room sequence number ack (`room.send`, `room.state`). */
|
|
16
|
+
const SeqAck = z.object({ seq: z.number().int().positive() });
|
|
17
|
+
|
|
18
|
+
/** The room ops, by op name. */
|
|
19
|
+
export const roomOps = {
|
|
20
|
+
'room.create': op({
|
|
21
|
+
d: z.object({
|
|
22
|
+
game: Slug,
|
|
23
|
+
/** One of the game's modes (`race` is refused: use `race.create`). */
|
|
24
|
+
mode: GameMode.nullish(),
|
|
25
|
+
/** Listed in `room.list` (default false). */
|
|
26
|
+
public: z.boolean().optional(),
|
|
27
|
+
/** Seats; clamped to the game's max (and 8). Defaults to the max. */
|
|
28
|
+
maxPlayers: z.number().optional(),
|
|
29
|
+
model: RoomModel.optional(),
|
|
30
|
+
/** The caller's party: when they lead it, its join info becomes `{ room: code }`. */
|
|
31
|
+
partyId: OpaqueId.optional(),
|
|
32
|
+
}),
|
|
33
|
+
ack: z.object({ room: RoomInfo, state: RoomState }),
|
|
34
|
+
errors: ['not_multiplayer', 'not_supported', 'bad_mode', 'too_many_rooms'],
|
|
35
|
+
doc: 'Open a room for a hub-rooms game with the caller seated as host (leaving any other seat).',
|
|
36
|
+
}),
|
|
37
|
+
'room.join': op({
|
|
38
|
+
d: RoomTarget,
|
|
39
|
+
ack: z.object({
|
|
40
|
+
room: RoomInfo,
|
|
41
|
+
state: RoomState,
|
|
42
|
+
/** Recent messages this member may see, oldest first. */
|
|
43
|
+
history: z.array(RoomMessage),
|
|
44
|
+
}),
|
|
45
|
+
errors: ['room_not_found', 'banned', 'in_progress', 'room_full', 'not_multiplayer', 'not_supported'],
|
|
46
|
+
doc: 'Take a seat (lobby only), reclaim your own seat (any phase, any tab), or watch.',
|
|
47
|
+
}),
|
|
48
|
+
'room.leave': op({
|
|
49
|
+
d: NoInput,
|
|
50
|
+
ack: EmptyAck,
|
|
51
|
+
errors: [],
|
|
52
|
+
doc: 'Give up your seat, or stop watching.',
|
|
53
|
+
}),
|
|
54
|
+
'room.ready': op({
|
|
55
|
+
d: z.object({ ready: z.boolean() }),
|
|
56
|
+
ack: EmptyAck,
|
|
57
|
+
errors: ['not_in_room', 'in_progress'],
|
|
58
|
+
doc: 'Mark yourself ready in the lobby.',
|
|
59
|
+
}),
|
|
60
|
+
'room.seat': op({
|
|
61
|
+
d: z.object({ data: Json.optional() }),
|
|
62
|
+
ack: EmptyAck,
|
|
63
|
+
errors: ['not_in_room', 'too_large'],
|
|
64
|
+
doc: 'Set your seat data (team, colour…), at most 2 KB of JSON.',
|
|
65
|
+
}),
|
|
66
|
+
'room.start': op({
|
|
67
|
+
d: NoInput,
|
|
68
|
+
ack: EmptyAck,
|
|
69
|
+
errors: ['not_in_room', 'not_host', 'race_room', 'in_progress', 'not_ready'],
|
|
70
|
+
doc: "Host: start once the game's minimum players are seated and everyone else is ready.",
|
|
71
|
+
}),
|
|
72
|
+
'room.send': op({
|
|
73
|
+
d: z.object({
|
|
74
|
+
/** The game message, at most 8 KB of JSON. */
|
|
75
|
+
data: Json.optional(),
|
|
76
|
+
/** Address one seat; otherwise the room model decides who hears it. */
|
|
77
|
+
to: UserId.nullish(),
|
|
78
|
+
}),
|
|
79
|
+
ack: SeqAck,
|
|
80
|
+
errors: ['not_in_room', 'ended', 'too_large', 'rate_limited', 'bad_target'],
|
|
81
|
+
doc: 'Relay a game message (at most 30 a second).',
|
|
82
|
+
}),
|
|
83
|
+
'room.state': op({
|
|
84
|
+
d: z.object({
|
|
85
|
+
/** Shallow-merged into the state; a `null` value deletes its key. */
|
|
86
|
+
patch: RoomState,
|
|
87
|
+
/** Replace the whole state with `patch`. */
|
|
88
|
+
replace: z.boolean().optional(),
|
|
89
|
+
}),
|
|
90
|
+
ack: SeqAck,
|
|
91
|
+
errors: ['not_in_room', 'race_room', 'not_host', 'bad_patch', 'too_large'],
|
|
92
|
+
invalid: { code: 'bad_patch', error: 'patch must be an object.' },
|
|
93
|
+
doc: 'Update the shared state (host only in the host model), at most 32 KB.',
|
|
94
|
+
}),
|
|
95
|
+
'room.chat': op({
|
|
96
|
+
d: ChatSend,
|
|
97
|
+
ack: ChatLine,
|
|
98
|
+
errors: ['not_in_room', 'room_not_found', ...CHAT_ERRORS],
|
|
99
|
+
doc: 'Moderated room chat: free text only when every seated player is a mutual friend.',
|
|
100
|
+
}),
|
|
101
|
+
'room.kick': op({
|
|
102
|
+
d: z.object({ userId: UserId }),
|
|
103
|
+
ack: EmptyAck,
|
|
104
|
+
errors: ['not_in_room', 'not_host', 'bad_target'],
|
|
105
|
+
doc: "Host: remove a player; they can't rejoin this room.",
|
|
106
|
+
}),
|
|
107
|
+
'room.end': op({
|
|
108
|
+
d: z.object({
|
|
109
|
+
/** Relayed to everyone in the final `room` event, untrusted; at most 8 KB of JSON. */
|
|
110
|
+
results: Json.optional(),
|
|
111
|
+
}),
|
|
112
|
+
ack: EmptyAck,
|
|
113
|
+
errors: ['not_in_room', 'not_host', 'race_room', 'too_large'],
|
|
114
|
+
doc: 'Host: end the match. Players become recent players; the room closes after a delay.',
|
|
115
|
+
}),
|
|
116
|
+
'room.list': op({
|
|
117
|
+
d: z.object({ game: Slug, mode: GameMode.optional() }),
|
|
118
|
+
ack: z.object({ rooms: z.array(RoomInfo) }),
|
|
119
|
+
errors: [],
|
|
120
|
+
doc: 'Public lobbies with a free seat (at most 50), minus rooms with blocked players.',
|
|
121
|
+
}),
|
|
122
|
+
} satisfies Record<string, OpDef>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Socket ops for presence, visibility, typing and the heartbeat
|
|
3
|
+
* (multiplayer-impl.md §4.3). `hello` is declared with the snapshot.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { Millis, UserId } from '../../common';
|
|
7
|
+
import { PresenceInput } from '../../api/social/presence';
|
|
8
|
+
import { op, type OpDef } from '../define';
|
|
9
|
+
import { EmptyAck, NoInput } from './empty';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `visibility` input: the tab's document visibility and idle state. Input
|
|
13
|
+
* that isn't an object counts as `{}` (visible, not idle); a field that isn't a
|
|
14
|
+
* boolean takes its default.
|
|
15
|
+
*/
|
|
16
|
+
export const VisibilityInput = z.object({
|
|
17
|
+
/** The document is visible (default `true`). */
|
|
18
|
+
visible: z.boolean().optional().catch(undefined),
|
|
19
|
+
/** The player has been idle (default `false`). Hidden or idle tabs count as `away`. */
|
|
20
|
+
idle: z.boolean().optional().catch(undefined),
|
|
21
|
+
}).catch({});
|
|
22
|
+
|
|
23
|
+
/** The social ops, by op name. */
|
|
24
|
+
export const socialOps = {
|
|
25
|
+
presence: op({
|
|
26
|
+
d: PresenceInput,
|
|
27
|
+
ack: EmptyAck,
|
|
28
|
+
errors: ['rate_limited'],
|
|
29
|
+
doc: "Report this tab's activity. Rate-limited per user; a rejected `detail` is dropped silently.",
|
|
30
|
+
}),
|
|
31
|
+
visibility: op({
|
|
32
|
+
d: VisibilityInput,
|
|
33
|
+
ack: EmptyAck,
|
|
34
|
+
errors: [],
|
|
35
|
+
doc: "Report this tab's visibility and idle state (hidden or idle tabs count as `away`).",
|
|
36
|
+
}),
|
|
37
|
+
typing: op({
|
|
38
|
+
d: z.object({
|
|
39
|
+
/**
|
|
40
|
+
* The friend you're typing to. Anything that isn't a friend's id is a
|
|
41
|
+
* silent no-op; a value that isn't a user id at all parses as `0` (no one).
|
|
42
|
+
*/
|
|
43
|
+
to: UserId.catch(0),
|
|
44
|
+
}),
|
|
45
|
+
ack: EmptyAck,
|
|
46
|
+
errors: [],
|
|
47
|
+
doc: 'Tell a friend you are typing (`ev:typing`), at most once per 2 s per pair.',
|
|
48
|
+
}),
|
|
49
|
+
ping: op({
|
|
50
|
+
d: NoInput,
|
|
51
|
+
ack: z.object({
|
|
52
|
+
/** The server clock (epoch ms). */
|
|
53
|
+
t: Millis,
|
|
54
|
+
}),
|
|
55
|
+
errors: [],
|
|
56
|
+
doc: 'Heartbeat and clock check; allowed before `hello`.',
|
|
57
|
+
}),
|
|
58
|
+
} satisfies Record<string, OpDef>;
|