@diffenderfer-games/hub-contract 2.0.0-next.1 → 2.0.0-next.2

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.
@@ -0,0 +1,99 @@
1
+ /**
2
+ * `game.labels`: what kind of game it is, from one fixed vocabulary, so the
3
+ * catalog can filter by them. Labels are grouped (genre, look, where it
4
+ * plays, how many play); a game lists every label that fits.
5
+ *
6
+ * Authoring is lenient: an unknown label is a warning (`hub-doctor`, the
7
+ * host's boot log), never an error, and the catalog ignores it.
8
+ */
9
+ import { z } from 'zod';
10
+
11
+ /** The vocabulary, by group. */
12
+ export const GAME_LABEL_GROUPS = {
13
+ /** What kind of game. */
14
+ genre: ['cards', 'board', 'puzzle', 'arcade', 'platformer', 'real-time-strategy', 'physics', 'typing', 'word', 'sports', 'racing'],
15
+ /** How it looks. */
16
+ form: ['2d', '3d'],
17
+ /** Where it plays: `phone` (touch controls), `controller` (gamepad support), `desktop` (keyboard or mouse only). */
18
+ platform: ['phone', 'controller', 'desktop'],
19
+ /** How many play. */
20
+ play: ['multiplayer', 'single-player'],
21
+ } as const;
22
+
23
+ /** A label group's key. */
24
+ export type GameLabelGroup = keyof typeof GAME_LABEL_GROUPS;
25
+
26
+ /** The groups in display order. */
27
+ export const GAME_LABEL_GROUP_ORDER: readonly GameLabelGroup[] = ['genre', 'form', 'platform', 'play'];
28
+
29
+ /** Every label, in display order (group by group). */
30
+ export const GAME_LABELS = [
31
+ ...GAME_LABEL_GROUPS.genre, ...GAME_LABEL_GROUPS.form, ...GAME_LABEL_GROUPS.platform, ...GAME_LABEL_GROUPS.play,
32
+ ] as const;
33
+
34
+ /** One known label. */
35
+ export const GameLabel = z.enum(GAME_LABELS);
36
+
37
+ /** One known label. */
38
+ export type GameLabel = z.infer<typeof GameLabel>;
39
+
40
+ /** Each label's display name. */
41
+ export const GAME_LABEL_TITLES: Readonly<Record<GameLabel, string>> = {
42
+ cards: 'Cards',
43
+ board: 'Board',
44
+ puzzle: 'Puzzle',
45
+ arcade: 'Arcade',
46
+ platformer: 'Platformer',
47
+ 'real-time-strategy': 'Real-time strategy',
48
+ physics: 'Physics',
49
+ typing: 'Typing',
50
+ word: 'Word',
51
+ sports: 'Sports',
52
+ racing: 'Racing',
53
+ '2d': '2D',
54
+ '3d': '3D',
55
+ phone: 'Phone',
56
+ controller: 'Controller',
57
+ desktop: 'Desktop only',
58
+ multiplayer: 'Multiplayer',
59
+ 'single-player': 'Single player',
60
+ };
61
+
62
+ /** Each group's display name. */
63
+ export const GAME_LABEL_GROUP_TITLES: Readonly<Record<GameLabelGroup, string>> = {
64
+ genre: 'Genre',
65
+ form: 'Look',
66
+ platform: 'Plays on',
67
+ play: 'Players',
68
+ };
69
+
70
+ /** Whether `text` is a known label. */
71
+ export function isGameLabel(text: string): text is GameLabel {
72
+ return GameLabel.safeParse(text).success;
73
+ }
74
+
75
+ /** The group a label belongs to. */
76
+ export function groupOfLabel(label: GameLabel): GameLabelGroup {
77
+ return GAME_LABEL_GROUP_ORDER.find((group) => GAME_LABEL_GROUPS[group].some((member) => member === label)) ?? 'genre';
78
+ }
79
+
80
+ /**
81
+ * `game.labels` as written: short strings. Unknown ones parse (they are a
82
+ * warning, see {@link splitGameLabels}).
83
+ */
84
+ export const GameLabels = z.array(z.string().trim().min(1).max(40)).max(24);
85
+
86
+ /** Known labels (trimmed, lower-cased, de-duplicated, in vocabulary order) and the unknown ones as written. */
87
+ export function splitGameLabels(written: readonly string[]): { readonly labels: GameLabel[]; readonly unknown: string[] } {
88
+ const known = new Set<GameLabel>();
89
+ const unknown: string[] = [];
90
+ for (const text of written) {
91
+ const candidate = text.trim().toLowerCase();
92
+ if (isGameLabel(candidate)) {
93
+ known.add(candidate);
94
+ } else {
95
+ unknown.push(text);
96
+ }
97
+ }
98
+ return { labels: GAME_LABELS.filter((label) => known.has(label)), unknown };
99
+ }
@@ -50,6 +50,14 @@ export const GameMultiplayer = z.strictObject({
50
50
  spectate: z.boolean().default(false),
51
51
  /** Offer quick match. Default `false`. */
52
52
  quickMatch: z.boolean().default(false),
53
+ /**
54
+ * Tournaments can be played in this game (docs/multiplayer.md §15). Default
55
+ * `false`. The game must play a 1v1 match from a `host`/`guest` launch that
56
+ * carries `launch.tournament`, and report its winner: hub rooms end with
57
+ * `room.end({ winnerId })`, own-server games post results with
58
+ * `tournament`. Race games need no flag: their races are the matches.
59
+ */
60
+ tournaments: z.boolean().default(false),
53
61
  /** Modes players can be invited into (at most 16, unique keys). */
54
62
  modes: z.array(MultiplayerMode).max(MANIFEST_LIMITS.modesMax).refine(hasUniqueKeys, 'mode keys must be unique').default([]),
55
63
  /**
@@ -1,8 +1,9 @@
1
- /** Socket events for invites, parties, hub rooms and races. */
1
+ /** Socket events for invites, parties, hub rooms, races and tournaments. */
2
2
  import type { EventDef } from '../define';
3
3
  import { partyEvents } from './parties';
4
4
  import { raceEvents } from './races';
5
5
  import { roomEvents } from './rooms';
6
+ import { tournamentEvents } from './tournaments';
6
7
 
7
8
  /** The multiplayer events, by event name. */
8
- export const multiplayerEvents = { ...partyEvents, ...roomEvents, ...raceEvents } satisfies Record<string, EventDef>;
9
+ export const multiplayerEvents = { ...partyEvents, ...roomEvents, ...raceEvents, ...tournamentEvents } satisfies Record<string, EventDef>;
@@ -3,7 +3,7 @@ import { z } from 'zod';
3
3
  import { Json, UserId } from '../../common';
4
4
  import { ChatLineEvent } from '../../api/mp/chat';
5
5
  import { OpaqueId } from '../../api/mp/parties';
6
- import { RoomClosedReason, RoomInfo, RoomMessage, RoomState, SeatGoneReason } from '../../api/mp/rooms';
6
+ import { RoomClosedReason, RoomInfo, RoomMessage, RoomState, SeatChange, SeatGoneReason } from '../../api/mp/rooms';
7
7
  import { event, type EventDef } from '../define';
8
8
 
9
9
  /** The room events, by event name. */
@@ -39,10 +39,14 @@ export const roomEvents = {
39
39
  }),
40
40
  'room.closed': event({
41
41
  d: z.object({ reason: RoomClosedReason }),
42
- doc: 'The room is gone for this tab (emptied, ended, host left, moved, another tab took the seat, removed).',
42
+ doc: 'The room is gone for this tab (emptied, ended, moved, another tab took the seat, removed). `host_left` is no longer sent: the host role passes on.',
43
43
  }),
44
44
  'room.abandoned': event({
45
45
  d: z.object({ userId: UserId, reason: SeatGoneReason }),
46
46
  doc: 'A player left mid-game (or their reconnect grace ran out); the game decides what that means.',
47
47
  }),
48
+ 'room.seat': event({
49
+ d: SeatChange,
50
+ doc: 'A seat went away (held for the grace period), came back (reclaimed) or is gone. The game hands an away seat to its computer player and gives it back on `back`.',
51
+ }),
48
52
  } satisfies Record<string, EventDef>;
@@ -12,8 +12,8 @@ import { FriendChange } from '../../api/social/people';
12
12
  import { Counts, Presence } from '../../api/social/presence';
13
13
  import { event, type EventDef } from '../define';
14
14
 
15
- /** What kind of thing a notice is about. `renamed`: an admin changed your username. */
16
- export const NoticeKind = z.enum(['dm', 'friend_request', 'friend_online', 'lobby_open', 'invite', 'suggestion', 'renamed']);
15
+ /** What kind of thing a notice is about. `renamed`: an admin changed your username; `tournament`: a match is ready, a start is near, a forfeit warning. */
16
+ export const NoticeKind = z.enum(['dm', 'friend_request', 'friend_online', 'lobby_open', 'invite', 'suggestion', 'renamed', 'tournament']);
17
17
 
18
18
  /** A notice kind. */
19
19
  export type NoticeKind = z.infer<typeof NoticeKind>;
@@ -0,0 +1,12 @@
1
+ /** Tournament events (batch-2026-10 R15). */
2
+ import { z } from 'zod';
3
+ import { TournamentSummary } from '../../api/tournaments/shapes';
4
+ import { event, type EventDef } from '../define';
5
+
6
+ /** The tournament events, by event name. */
7
+ export const tournamentEvents = {
8
+ tournament: event({
9
+ d: z.object({ tournament: TournamentSummary }),
10
+ doc: 'A tournament you are in, follow, created or were invited to changed (sign-ups, the start, a match ready or decided, the end).',
11
+ }),
12
+ } satisfies Record<string, EventDef>;
@@ -8,13 +8,21 @@ import { z } from 'zod';
8
8
  import { Json, Slug, UserId } from '../../common';
9
9
  import { CHAT_ERRORS, ChatLine, ChatSend } from '../../api/mp/chat';
10
10
  import { GameMode, OpaqueId } from '../../api/mp/parties';
11
- import { RoomInfo, RoomMessage, RoomModel, RoomState, RoomTarget } from '../../api/mp/rooms';
11
+ import { HostMigration, RoomInfo, RoomMessage, RoomModel, RoomState, RoomTarget } from '../../api/mp/rooms';
12
12
  import { op, type OpDef } from '../define';
13
13
  import { EmptyAck, NoInput } from './empty';
14
14
 
15
15
  /** A per-room sequence number ack (`room.send`, `room.state`). */
16
16
  const SeqAck = z.object({ seq: z.number().int().positive() });
17
17
 
18
+ /** A join's ack: the room, its state and the history this member may replay. */
19
+ const RoomJoinAck = z.object({
20
+ room: RoomInfo,
21
+ state: RoomState,
22
+ /** Recent messages this member may see, oldest first. */
23
+ history: z.array(RoomMessage),
24
+ });
25
+
18
26
  /** The room ops, by op name. */
19
27
  export const roomOps = {
20
28
  'room.create': op({
@@ -27,6 +35,8 @@ export const roomOps = {
27
35
  /** Seats; clamped to the game's max (and 8). Defaults to the max. */
28
36
  maxPlayers: z.number().optional(),
29
37
  model: RoomModel.optional(),
38
+ /** When the host role moves on mid-game (default `grace`). */
39
+ hostMigration: HostMigration.optional(),
30
40
  /** The caller's party: when they lead it, its join info becomes `{ room: code }`. */
31
41
  partyId: OpaqueId.optional(),
32
42
  }),
@@ -36,15 +46,19 @@ export const roomOps = {
36
46
  }),
37
47
  'room.join': op({
38
48
  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
- }),
49
+ ack: RoomJoinAck,
45
50
  errors: ['room_not_found', 'banned', 'in_progress', 'room_full', 'not_multiplayer', 'not_supported'],
46
51
  doc: 'Take a seat (lobby only), reclaim your own seat (any phase, any tab), or watch.',
47
52
  }),
53
+ 'room.reclaim': op({
54
+ d: z.object({ game: Slug }),
55
+ ack: z.object({
56
+ /** The seat I still hold in this game, now reclaimed by this tab; `null` when there is none. */
57
+ held: RoomJoinAck.nullable(),
58
+ }),
59
+ errors: [],
60
+ doc: 'Take back the seat I still hold in this game (after a reload or from another tab); the room hears `room.seat` `back`.',
61
+ }),
48
62
  'room.leave': op({
49
63
  d: NoInput,
50
64
  ack: EmptyAck,