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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +26 -3
  4. package/dist/index.d.ts +8454 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +4855 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +53 -6
  9. package/src/api/analytics.ts +81 -0
  10. package/src/api/auth.ts +77 -0
  11. package/src/api/catalog.ts +171 -0
  12. package/src/api/daily.ts +122 -0
  13. package/src/api/economy/bundle.ts +69 -0
  14. package/src/api/economy/inventory.ts +57 -0
  15. package/src/api/economy/trades.ts +154 -0
  16. package/src/api/economy.ts +15 -0
  17. package/src/api/index.ts +85 -0
  18. package/src/api/leaderboards.ts +151 -0
  19. package/src/api/messages.ts +142 -0
  20. package/src/api/mp/chat.ts +36 -0
  21. package/src/api/mp/parties.ts +108 -0
  22. package/src/api/mp/races.ts +211 -0
  23. package/src/api/mp/rooms.ts +90 -0
  24. package/src/api/mp/snapshot.ts +17 -0
  25. package/src/api/mp.ts +110 -0
  26. package/src/api/notify.ts +111 -0
  27. package/src/api/progress/game-data.ts +189 -0
  28. package/src/api/progress.ts +59 -0
  29. package/src/api/race.ts +47 -0
  30. package/src/api/reports.ts +208 -0
  31. package/src/api/social/config.ts +47 -0
  32. package/src/api/social/endpoints.ts +179 -0
  33. package/src/api/social/people.ts +118 -0
  34. package/src/api/social/presence.ts +95 -0
  35. package/src/api/social.ts +9 -0
  36. package/src/api/version.ts +40 -0
  37. package/src/coerce.ts +50 -0
  38. package/src/common.ts +42 -0
  39. package/src/entities.ts +62 -0
  40. package/src/errors/codes/core.ts +10 -0
  41. package/src/errors/codes/multiplayer.ts +43 -0
  42. package/src/errors/codes/play.ts +5 -0
  43. package/src/errors/codes/social.ts +31 -0
  44. package/src/errors/index.ts +88 -0
  45. package/src/index.ts +16 -0
  46. package/src/limits.ts +100 -0
  47. package/src/logic/daily.ts +54 -0
  48. package/src/logic/index.ts +8 -0
  49. package/src/logic/presence.ts +62 -0
  50. package/src/logic/race.ts +169 -0
  51. package/src/logic/time.ts +81 -0
  52. package/src/manifest/changes.ts +55 -0
  53. package/src/manifest/controls.ts +42 -0
  54. package/src/manifest/fields.ts +89 -0
  55. package/src/manifest/game.ts +132 -0
  56. package/src/manifest/index.ts +12 -0
  57. package/src/manifest/inject.ts +31 -0
  58. package/src/manifest/leaderboards.ts +29 -0
  59. package/src/manifest/multiplayer.ts +71 -0
  60. package/src/manifest/race.ts +93 -0
  61. package/src/middleware.ts +47 -0
  62. package/src/realtime/define.ts +52 -0
  63. package/src/realtime/envelope.ts +67 -0
  64. package/src/realtime/events/multiplayer.ts +8 -0
  65. package/src/realtime/events/parties.ts +31 -0
  66. package/src/realtime/events/races.ts +27 -0
  67. package/src/realtime/events/rooms.ts +48 -0
  68. package/src/realtime/events/social.ts +81 -0
  69. package/src/realtime/index.ts +24 -0
  70. package/src/realtime/ops/empty.ts +8 -0
  71. package/src/realtime/ops/hello.ts +40 -0
  72. package/src/realtime/ops/multiplayer.ts +8 -0
  73. package/src/realtime/ops/parties.ts +13 -0
  74. package/src/realtime/ops/races.ts +99 -0
  75. package/src/realtime/ops/rooms.ts +122 -0
  76. package/src/realtime/ops/social.ts +58 -0
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `GET /version`: which client versions this hub serves (plan §4.3.3). Games'
3
+ * clients read it once per page, and `hub-doctor` reads it in a game's CI.
4
+ * The old hub has no such route and answers 404 "No such API endpoint.", which
5
+ * clients read as "a hub from before versioning".
6
+ */
7
+ import { z } from 'zod';
8
+ import { session } from '../middleware';
9
+
10
+ /**
11
+ * A semver range in the npm form: comparators (`>=2.0.0-0`, `<3.0.0`)
12
+ * separated by spaces are all required; `||` separates alternatives.
13
+ */
14
+ export const VersionRange = z.string().min(1);
15
+
16
+ /** What `GET /version` answers. */
17
+ export const HubVersion = z.object({
18
+ /** The hub release this server was built from: the version of the client it serves at `/_hub/hub.js`. */
19
+ server: z.string(),
20
+ /** The version of `@diffenderfer-games/hub-contract` the server implements. */
21
+ contract: z.string(),
22
+ /** Client versions the server supports (`x-hub-client`); a client outside it should be updated. */
23
+ supported: VersionRange,
24
+ /** Supported client versions that are deprecated: they still work, and `hub-doctor` warns. */
25
+ deprecated: VersionRange,
26
+ });
27
+
28
+ /** The hub's version and the client versions it supports. */
29
+ export type HubVersion = z.infer<typeof HubVersion>;
30
+
31
+ /** The version endpoints. */
32
+ export const versionEndpoints = {
33
+ ...session.group({
34
+ getHubVersion: {
35
+ method: 'GET', path: '/version',
36
+ response: HubVersion,
37
+ doc: { summary: 'The hub version and the client versions it supports', tags: ['catalog'] },
38
+ },
39
+ }),
40
+ };
package/src/coerce.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Request coercions the single-player handlers apply today. The old hub reads
3
+ * many request fields loosely (`Number(x)`, `String(x)`, truthiness), and the
4
+ * contract must accept every request the hub accepts, so these schemas
5
+ * coerce the same way while keeping the input type clients should send.
6
+ */
7
+ import { z } from 'zod';
8
+
9
+ /**
10
+ * A finite number, read like the hub's `Number(x)`: numeric strings (and
11
+ * query-string values) pass. Clients should send a JSON number.
12
+ */
13
+ export const LooseNumber = z.coerce.number<number>();
14
+
15
+ /**
16
+ * A flag read by truthiness, like the hub's `if (body.on)`: any value is
17
+ * accepted and becomes `true` or `false`. Clients should send a boolean.
18
+ */
19
+ export const Truthy = z.coerce.boolean<boolean>();
20
+
21
+ /**
22
+ * Free text read with `String(x)`; the hub trims or clips it to its own limit
23
+ * rather than rejecting it. Clients should send a string.
24
+ */
25
+ export const LooseText = z.coerce.string<string>();
26
+
27
+ /**
28
+ * A row count from the query string (`?limit=`, `?top=`). The hub clamps it to
29
+ * its own range and falls back to a default for `0` or a missing value.
30
+ */
31
+ export const RowCount = LooseNumber.int();
32
+
33
+ /**
34
+ * Required text read like the hub's `String(x || '')`, with the old hub's
35
+ * message for a missing or invalid value. Numbers are accepted and become
36
+ * strings; anything else fails with `message`. Refine the result with
37
+ * `.pipe(...)` (e.g. a pattern carrying the same message).
38
+ */
39
+ export function requiredText(message: string) {
40
+ return z.union([z.string(), z.number()], { error: message }).transform(String);
41
+ }
42
+
43
+ /**
44
+ * A positive integer id read like the hub's `Number(x)` (numeric strings
45
+ * pass), with the old hub's message for any bad value (`"id must be a user
46
+ * id."`). Clients should send a JSON number.
47
+ */
48
+ export function requiredId(message: string) {
49
+ return z.coerce.number<number>({ error: message }).int({ error: message }).positive({ error: message });
50
+ }
package/src/common.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Primitives shared by every part of the contract. Each one is the single
3
+ * definition of its rule: the server validates with it, the client's types
4
+ * come from it, and the docs are generated from it.
5
+ */
6
+ import { z } from 'zod';
7
+
8
+ /** A user id: a positive integer. In paths it arrives as a string and is coerced. */
9
+ export const UserId = z.number().int().positive();
10
+
11
+ /** A user id taken from a URL path segment (`/players/:userId`). */
12
+ export const UserIdParam = z.coerce.number().int().positive();
13
+
14
+ /** A numeric row id taken from a URL path segment (`/trades/:id`, `/reports/:id`). */
15
+ export const IdParam = z.coerce.number().int().positive();
16
+
17
+ /** Milliseconds since the Unix epoch. */
18
+ export const Millis = z.number().int().nonnegative();
19
+
20
+ /** A game's slug: its folder name under `apps/`, as used in `/<slug>/` URLs. */
21
+ export const Slug = z.string().min(1).max(64);
22
+
23
+ /**
24
+ * An app-chosen identifier: a save slot, leaderboard key or stat name.
25
+ * Letters, digits and `_ . : -`, 1 to 64 characters.
26
+ */
27
+ export const Key = z.string().regex(/^[A-Za-z0-9_.:-]{1,64}$/);
28
+
29
+ /** Any JSON value (saves, metadata, room state). */
30
+ export const Json = z.json();
31
+
32
+ /** A JSON value as stored and returned by the hub: never `undefined`. */
33
+ export type Json = z.infer<typeof Json>;
34
+
35
+ /** The `{ ok: true }` body many write endpoints answer with. */
36
+ export const Ok = z.object({ ok: z.literal(true) });
37
+
38
+ /** A UTC day key, `YYYY-MM-DD`. */
39
+ export const DayKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
40
+
41
+ /** A `#rrggbb` colour. */
42
+ export const HexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/);
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Entity shapes that several areas of the API share. Shapes used by only one
3
+ * area live in that area's `api/` file.
4
+ */
5
+ import { z } from 'zod';
6
+ import { Millis, UserId } from './common';
7
+
8
+ /** Account roles. An empty string is a normal player. */
9
+ export const Role = z.enum(['', 'admin']);
10
+
11
+ /**
12
+ * The signed-in user as the hub sees it (`GET /me`, socket `hello`).
13
+ * `id`, `username` and `guest` are the long-standing fields; the rest drive
14
+ * social permissions and are only ever sent to the user themself.
15
+ */
16
+ export const SessionUser = z.object({
17
+ id: UserId,
18
+ username: z.string(),
19
+ /** A temporary account with no password yet; it can be claimed. */
20
+ guest: z.boolean(),
21
+ role: Role,
22
+ /** Avatar token, `''` for the default. */
23
+ avatar: z.string(),
24
+ /** A parent locked chat for this account. */
25
+ chatLock: z.boolean(),
26
+ /** Muted until this time (epoch ms), `0` when not muted. */
27
+ mutedUntil: Millis,
28
+ /** Suspended until this time (epoch ms), `0` when not suspended. */
29
+ suspendedUntil: Millis,
30
+ });
31
+
32
+ /** The signed-in user. */
33
+ export type SessionUser = z.infer<typeof SessionUser>;
34
+
35
+ /** The short account shape the auth endpoints return for a new session. */
36
+ export const AuthUser = SessionUser.pick({ id: true, username: true, guest: true });
37
+
38
+ /** The short account shape. */
39
+ export type AuthUser = z.infer<typeof AuthUser>;
40
+
41
+ /**
42
+ * The effective chat mode of a conversation, room or the whole site: free
43
+ * text (still filtered), quick-chat phrases only, or no messaging at all.
44
+ * The strictest of the operator setting, the admin switch and every
45
+ * participant (guests and parent-locked accounts are `quick`) applies.
46
+ */
47
+ export const ChatMode = z.enum(['on', 'quick', 'off']);
48
+
49
+ /** A chat mode. */
50
+ export type ChatMode = z.infer<typeof ChatMode>;
51
+
52
+ /** Another player as lists and cards show them. */
53
+ export const PlayerLite = z.object({
54
+ id: UserId,
55
+ username: z.string(),
56
+ /** Avatar token; never empty (a default is derived from the id). */
57
+ avatar: z.string(),
58
+ guest: z.boolean(),
59
+ });
60
+
61
+ /** Another player. */
62
+ export type PlayerLite = z.infer<typeof PlayerLite>;
@@ -0,0 +1,10 @@
1
+ /** Error codes any endpoint or socket op can return. */
2
+ export const CORE_CODES = [
3
+ 'bad_request',
4
+ 'signed_out',
5
+ 'suspended',
6
+ 'claim_required',
7
+ 'rate_limited',
8
+ 'content_rejected',
9
+ 'too_large',
10
+ ] as const;
@@ -0,0 +1,43 @@
1
+ /** Error codes from invites, parties, hub rooms, races and the socket envelope. */
2
+ export const MULTIPLAYER_CODES = [
3
+ // Socket envelope.
4
+ 'unknown_op',
5
+ 'hello_required',
6
+ // Games and modes.
7
+ 'not_multiplayer',
8
+ 'not_supported',
9
+ 'bad_mode',
10
+ // Invites, parties, launches, joins.
11
+ 'bad_target',
12
+ 'cannot_invite',
13
+ 'already_member',
14
+ 'party_full',
15
+ 'invite_gone',
16
+ 'party_gone',
17
+ 'not_leader',
18
+ 'bad_info',
19
+ 'launch_invalid',
20
+ 'not_joinable',
21
+ // Group chat.
22
+ 'bad_quick',
23
+ // Hub rooms.
24
+ 'room_not_found',
25
+ 'not_in_room',
26
+ 'not_host',
27
+ 'too_many_rooms',
28
+ 'banned',
29
+ 'in_progress',
30
+ 'room_full',
31
+ 'not_ready',
32
+ 'ended',
33
+ 'bad_patch',
34
+ 'race_room',
35
+ // Races.
36
+ 'not_race',
37
+ 'not_in_race',
38
+ 'not_started',
39
+ 'race_over',
40
+ 'too_fast',
41
+ 'bad_stats',
42
+ 'bad_param',
43
+ ] as const;
@@ -0,0 +1,5 @@
1
+ /** Error codes from accounts and the single-player API (saves, stats, boards, daily, economy, trades). */
2
+ export const PLAY_CODES = [
3
+ /** A score was sent to a board only the game's own server may update (`source: "server"`). */
4
+ 'server_board',
5
+ ] as const;
@@ -0,0 +1,31 @@
1
+ /** Error codes from friends, messages, notifications, reports and suggestions. */
2
+ export const SOCIAL_CODES = [
3
+ /** No such player, or one who blocked you or is suspended (they look the same). */
4
+ 'no_player',
5
+ /** Not one of the preset avatars. */
6
+ 'bad_avatar',
7
+ /** The chat-lock password was wrong. */
8
+ 'bad_password',
9
+ /** An admin turned new friend requests off. */
10
+ 'friend_requests_off',
11
+ /** You can't do that to yourself (friend, block; also invites, joins and room targets). */
12
+ 'bad_target',
13
+ /** The other player is a guest and must create an account first. */
14
+ 'guest_target',
15
+ /** You blocked them: unblock first. */
16
+ 'blocked',
17
+ /** You or they have the most friends allowed. */
18
+ 'friends_limit',
19
+ /** Too many outgoing friend requests waiting. */
20
+ 'pending_limit',
21
+ /** The friend request is gone. */
22
+ 'no_request',
23
+ /** You're not friends (messages, friend bells). */
24
+ 'not_friends',
25
+ /** You've blocked the most players allowed. */
26
+ 'blocks_limit',
27
+ /** The player isn't taking messages. */
28
+ 'dms_closed',
29
+ /** Too many of your suggestions are waiting for review. */
30
+ 'too_many_pending',
31
+ ] as const;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The error model (plan §5.3). Every failed REST call and every failed socket
3
+ * ack carries an {@link ErrorBody}. `error` is always safe to show a player.
4
+ * Public codes are listed per area in `codes/`; admin-only codes live in the
5
+ * server-only internal contract.
6
+ *
7
+ * Each schema here also has a named `…Schema` interface. Endpoints reference
8
+ * these schemas hundreds of times; naming their types lets the published
9
+ * `.d.ts` refer to them instead of printing every code at every use.
10
+ */
11
+ import { z } from 'zod';
12
+ import { Millis } from '../common';
13
+ import { CORE_CODES } from './codes/core';
14
+ import { MULTIPLAYER_CODES } from './codes/multiplayer';
15
+ import { PLAY_CODES } from './codes/play';
16
+ import { SOCIAL_CODES } from './codes/social';
17
+
18
+ const errorCode = z.enum([...CORE_CODES, ...PLAY_CODES, ...SOCIAL_CODES, ...MULTIPLAYER_CODES]);
19
+
20
+ /** The type of the {@link ErrorCode} schema. */
21
+ export interface ErrorCodeSchema extends Readonly<typeof errorCode> {}
22
+
23
+ /** Every public error code. */
24
+ export const ErrorCode: ErrorCodeSchema = errorCode;
25
+
26
+ /** A public error code. */
27
+ export type ErrorCode = z.infer<ErrorCodeSchema>;
28
+
29
+ const rejectReason = z.enum([
30
+ 'link', 'personal_info', 'contact', 'language', 'slur', 'sexual', 'grown_up', 'unkind',
31
+ 'threat', 'drugs', 'spam', 'shouting', 'too_long', 'empty', 'chars', 'reserved',
32
+ 'rate_limited', 'muted', 'check_unavailable', 'chat_off', 'not_friends', 'suspended',
33
+ 'quick_only', 'dms_closed', 'other',
34
+ ]);
35
+
36
+ /** The type of the {@link RejectReason} schema. */
37
+ export interface RejectReasonSchema extends Readonly<typeof rejectReason> {}
38
+
39
+ /**
40
+ * Why text was rejected or a chat action was refused. Coarse on purpose: a
41
+ * reason names a category, never the word or list that matched.
42
+ */
43
+ export const RejectReason: RejectReasonSchema = rejectReason;
44
+
45
+ /** A content-reject reason. */
46
+ export type RejectReason = z.infer<RejectReasonSchema>;
47
+
48
+ const errorBody = z.object({
49
+ /** A sentence that is always safe to show the player. */
50
+ error: z.string(),
51
+ /** What went wrong, for code to branch on. */
52
+ code: ErrorCode.optional(),
53
+ /** Content rejects and suspensions: the category. */
54
+ reason: RejectReason.optional(),
55
+ /** A kid-friendly suggestion, e.g. "try saying it another way". */
56
+ hint: z.string().optional(),
57
+ /** Rate limits: how long to wait, in ms (0 when not limited). */
58
+ retryAfterMs: z.number().int().nonnegative().optional(),
59
+ /** Suspensions and mutes: when they end (epoch ms). */
60
+ until: Millis.optional(),
61
+ });
62
+
63
+ /** The type of the {@link ErrorBody} schema. */
64
+ export interface ErrorBodySchema extends Readonly<typeof errorBody> {}
65
+
66
+ /** The body of every error response and failed socket ack. */
67
+ export const ErrorBody: ErrorBodySchema = errorBody;
68
+
69
+ /** An error body. */
70
+ export type ErrorBody = z.infer<ErrorBodySchema>;
71
+
72
+ const contentRejected = errorBody.extend({
73
+ code: z.literal('content_rejected'),
74
+ reason: RejectReason,
75
+ hint: z.string(),
76
+ });
77
+
78
+ /** The type of the {@link ContentRejected} schema. */
79
+ export interface ContentRejectedSchema extends Readonly<typeof contentRejected> {}
80
+
81
+ /**
82
+ * The 422 content-rejected body (multiplayer.md §4.6), shared by DMs, room
83
+ * chat, suggestions, names and trade notes.
84
+ */
85
+ export const ContentRejected: ContentRejectedSchema = contentRejected;
86
+
87
+ /** A content-rejected body. */
88
+ export type ContentRejected = z.infer<ContentRejectedSchema>;
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The diffenderfer.games hub wire contract: zod schemas and types for the
3
+ * public REST API, the `/_ws` socket envelope, ops and events, error bodies
4
+ * and the `game.*` manifest.
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+ export * from './common';
9
+ export * from './entities';
10
+ export * from './errors';
11
+ export * from './middleware';
12
+ export * from './api';
13
+ export * from './realtime';
14
+ export * from './manifest';
15
+ export * from './limits';
16
+ export * from './logic';
package/src/limits.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Limits a UI has to know about: text lengths, name rules, seat counts and
3
+ * the rules for a game's `package.json` `game.*` block. Each number is
4
+ * defined once here; the schemas and the server import it.
5
+ *
6
+ * Rate thresholds, caps and timers are server-only and not part of this package.
7
+ */
8
+ import { z } from 'zod';
9
+
10
+ /** Account name and password rules (sign-up, claim, rename). */
11
+ export const ACCOUNT_LIMITS = {
12
+ /** Shortest username, after trimming. */
13
+ usernameMin: 2,
14
+ /** Longest username, after trimming. */
15
+ usernameMax: 24,
16
+ /** Shortest password. */
17
+ passwordMin: 6,
18
+ /** How many built-in avatars there are (`a0` … `a23`). */
19
+ avatarCount: 24,
20
+ } as const;
21
+
22
+ /** The characters a username may use: letters, digits, `_`, `-` and spaces. */
23
+ export const USERNAME_PATTERN = /^[A-Za-z0-9_\- ]+$/;
24
+
25
+ /**
26
+ * The text limits every social UI renders from. The hub sends the live values
27
+ * in `GET /social/config` and the socket `hello` (`limits`); the server can
28
+ * change them (`HUB_LIMITS_JSON`), so a UI should prefer the sent values.
29
+ */
30
+ export const UiLimits = z.object({
31
+ /** Longest direct message, in characters. */
32
+ dmMaxChars: z.number().int().positive(),
33
+ /** Longest room or party chat line, in characters. */
34
+ roomChatMaxChars: z.number().int().positive(),
35
+ /** Longest suggestion title, in characters. */
36
+ suggestionTitleMax: z.number().int().positive(),
37
+ /** Longest suggestion body, in characters. */
38
+ suggestionBodyMax: z.number().int().positive(),
39
+ });
40
+
41
+ /** The text limits every social UI renders from. */
42
+ export type UiLimits = z.infer<typeof UiLimits>;
43
+
44
+ /** Today's default {@link UiLimits}, used until the hub sends its own. */
45
+ export const UI_LIMIT_DEFAULTS = {
46
+ dmMaxChars: 200,
47
+ roomChatMaxChars: 120,
48
+ suggestionTitleMax: 60,
49
+ suggestionBodyMax: 500,
50
+ } as const satisfies UiLimits;
51
+
52
+ /** Seats in hub rooms and parties. */
53
+ export const SEAT_LIMITS = {
54
+ /** Most players in one hub room, and the top of any `players` range. */
55
+ maxSeats: 8,
56
+ /** Most members in one party. */
57
+ partyMax: 8,
58
+ } as const;
59
+
60
+ /** What a racer's live stats (`hub.race.status(stats)`) may hold. */
61
+ export const RACE_STATS_LIMITS = {
62
+ /** Most stat keys in one update. */
63
+ maxKeys: 12,
64
+ /** Largest update, in bytes of JSON. */
65
+ maxBytes: 600,
66
+ } as const;
67
+
68
+ /** Limits on the `package.json` `game.*` block (see the manifest schemas). */
69
+ export const MANIFEST_LIMITS = {
70
+ /** Most `game.changes` entries the hub reads. */
71
+ changesMax: 200,
72
+ /** Longest `game.changes` entry, in characters. */
73
+ changeTextMax: 280,
74
+ /** Longest leaderboard title, in characters. */
75
+ leaderboardTitleMax: 80,
76
+ /** Furthest the menu button may be pushed from its corner, in px. */
77
+ menuOffsetMax: 600,
78
+ /** Longest lobby URL suffix, in characters. */
79
+ lobbyMax: 200,
80
+ /** Most `game.multiplayer.modes`. */
81
+ modesMax: 16,
82
+ /** Longest mode title, in characters. */
83
+ modeTitleMax: 40,
84
+ /** Most `game.multiplayer.quickChat` phrases. */
85
+ quickChatMax: 24,
86
+ /** Longest quick-chat phrase, in characters. */
87
+ quickChatTextMax: 40,
88
+ /** Most `game.multiplayer.chatAllow` words. */
89
+ chatAllowMax: 20,
90
+ /** Most `game.race.params`. */
91
+ raceParamsMax: 6,
92
+ /** Most options in one race param. */
93
+ raceOptionsMax: 12,
94
+ /** Most `game.race.stats` (the live HUD shows the first 3). */
95
+ raceStatsMax: 8,
96
+ /** Longest race param, option or stat title, in characters. */
97
+ raceTitleMax: 24,
98
+ /** Largest `game.race.minMs`, in ms (10 minutes). */
99
+ raceMinMsMax: 600_000,
100
+ } as const;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Daily-challenge day keys and seeds: UTC `YYYY-MM-DD` keys, the playable
3
+ * window, and the deterministic per-(game, day) seed both the hub and the
4
+ * game compute. Pure; the signed tokens stay server-only.
5
+ */
6
+
7
+ const DAY_MS = 24 * 60 * 60 * 1000;
8
+ const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
9
+
10
+ /** The UTC day key (`YYYY-MM-DD`) of a timestamp. */
11
+ export function dayKey(ts: number): string {
12
+ return new Date(ts).toISOString().slice(0, 10);
13
+ }
14
+
15
+ /**
16
+ * The playable window, newest first: today and the `count - 1` days before it.
17
+ * @param count - how many days (the back-fill window, 30 by default on the hub)
18
+ */
19
+ export function windowDays(now: number, count: number): string[] {
20
+ return Array.from({ length: count }, (_, i) => dayKey(now - i * DAY_MS));
21
+ }
22
+
23
+ /**
24
+ * Is `day` playable? A player's local "today" can be UTC±1, so one day into
25
+ * the future is accepted, plus `backfillDays` into the past. Compared in
26
+ * whole UTC days, so it is locale-independent.
27
+ */
28
+ export function inWindow(day: string, now: number, backfillDays: number): boolean {
29
+ if (!DAY_RE.test(day)) {
30
+ return false;
31
+ }
32
+ const dayStart = Date.parse(`${day}T00:00:00Z`);
33
+ const today = Date.parse(`${dayKey(now)}T00:00:00Z`);
34
+ if (Number.isNaN(dayStart) || Number.isNaN(today)) {
35
+ return false;
36
+ }
37
+ const diff = Math.round((dayStart - today) / DAY_MS);
38
+ return diff <= 1 && diff >= -backfillDays;
39
+ }
40
+
41
+ /**
42
+ * The deterministic uint32 seed for a (game, day): FNV-1a over `game#day`.
43
+ * The game derives its level or difficulty from it, so everyone plays the
44
+ * same challenge.
45
+ */
46
+ export function seedForDay(game: string, day: string): number {
47
+ let hash = 2166136261 >>> 0;
48
+ const seedInput = `${game}#${day}`;
49
+ for (let i = 0; i < seedInput.length; i++) {
50
+ hash ^= seedInput.charCodeAt(i);
51
+ hash = Math.imul(hash, 16777619);
52
+ }
53
+ return hash >>> 0;
54
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Pure, browser-safe helpers every hub UI shares: time and count wording,
3
+ * presence status lines, race wording, and daily day keys and seeds.
4
+ */
5
+ export * from './daily';
6
+ export * from './presence';
7
+ export * from './race';
8
+ export * from './time';
@@ -0,0 +1,62 @@
1
+ /**
2
+ * How a friend's presence reads in every list: the status dot, label, game,
3
+ * detail and elapsed time (plan §5.2 of multiplayer.md: "consistent everywhere").
4
+ */
5
+ import type { Presence } from '../api/social/presence';
6
+ import { elapsed, relativeTime } from './time';
7
+
8
+ /** The pieces of a status line, so renderers can bold the game title. */
9
+ export interface StatusParts {
10
+ /** A coloured dot emoji. */
11
+ readonly dot: string;
12
+ /** 'Playing', 'In lobby', 'Away', 'Online', 'Last seen 3h ago'… */
13
+ readonly label: string;
14
+ /** The game title (rendered bold), or `''`. */
15
+ readonly game: string;
16
+ /** The game-provided detail, or `''`. */
17
+ readonly detail: string;
18
+ /** Elapsed time while playing (`'4:12'`), or `''`. */
19
+ readonly elapsed: string;
20
+ readonly online: boolean;
21
+ /** Their presence offers Join. */
22
+ readonly joinable: boolean;
23
+ /** Their presence offers Watch. */
24
+ readonly watchable: boolean;
25
+ }
26
+
27
+ /** Status line pieces for a friend; `p` is `null` when they are offline. */
28
+ export function statusParts(presence: Presence | null, now: number, lastSeenAt = 0): StatusParts {
29
+ const base = { game: '', detail: '', elapsed: '', online: true, joinable: false, watchable: false };
30
+ if (!presence) {
31
+ return { ...base, online: false, dot: '⚪', label: lastSeenAt ? `Last seen ${relativeTime(lastSeenAt, now)}` : 'Offline' };
32
+ }
33
+ const game = presence.gameTitle ?? '';
34
+ const detail = presence.detail ?? '';
35
+ const hasRoom = presence.room !== undefined && presence.room !== '';
36
+ const flags = { joinable: presence.joinable === true && hasRoom, watchable: presence.watchable === true && hasRoom };
37
+ switch (presence.kind) {
38
+ case 'playing':
39
+ return { ...base, ...flags, dot: '🟢', label: 'Playing', game, detail, elapsed: elapsed(presence.since, now) };
40
+ case 'watching':
41
+ return { ...base, ...flags, dot: '🟢', label: 'Watching', game, detail };
42
+ case 'lobby':
43
+ return { ...base, ...flags, dot: '🟠', label: 'In lobby', game, detail };
44
+ case 'away':
45
+ return { ...base, dot: '🟡', label: 'Away', game };
46
+ case 'menu':
47
+ return { ...base, dot: '🟢', label: 'Online', game };
48
+ }
49
+ }
50
+
51
+ /** The status line as one string, e.g. "🟢 Playing 8 Ball Pool · Table vs Sam · 4:12". */
52
+ export function statusText(parts: StatusParts): string {
53
+ const head = parts.label === 'Playing' || parts.label === 'Watching'
54
+ ? [parts.game ? `${parts.label} ${parts.game}` : parts.label]
55
+ : [parts.label, parts.game];
56
+ return `${parts.dot} ${[...head, parts.detail, parts.elapsed].filter(Boolean).join(' · ')}`;
57
+ }
58
+
59
+ /** The drawer header: "👥 3 friends online · 41 playing now". */
60
+ export function headerLine(friendsOnline: number, online: number): string {
61
+ return `👥 ${String(friendsOnline)} friend${friendsOnline === 1 ? '' : 's'} online · ${String(online)} playing now`;
62
+ }