@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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +25 -2
  3. package/dist/index.d.ts +8454 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +4855 -0
  6. package/dist/index.js.map +1 -0
  7. package/package.json +51 -4
  8. package/src/api/analytics.ts +81 -0
  9. package/src/api/auth.ts +77 -0
  10. package/src/api/catalog.ts +171 -0
  11. package/src/api/daily.ts +122 -0
  12. package/src/api/economy/bundle.ts +69 -0
  13. package/src/api/economy/inventory.ts +57 -0
  14. package/src/api/economy/trades.ts +154 -0
  15. package/src/api/economy.ts +15 -0
  16. package/src/api/index.ts +85 -0
  17. package/src/api/leaderboards.ts +151 -0
  18. package/src/api/messages.ts +142 -0
  19. package/src/api/mp/chat.ts +36 -0
  20. package/src/api/mp/parties.ts +108 -0
  21. package/src/api/mp/races.ts +211 -0
  22. package/src/api/mp/rooms.ts +90 -0
  23. package/src/api/mp/snapshot.ts +17 -0
  24. package/src/api/mp.ts +110 -0
  25. package/src/api/notify.ts +111 -0
  26. package/src/api/progress/game-data.ts +189 -0
  27. package/src/api/progress.ts +59 -0
  28. package/src/api/race.ts +47 -0
  29. package/src/api/reports.ts +208 -0
  30. package/src/api/social/config.ts +47 -0
  31. package/src/api/social/endpoints.ts +179 -0
  32. package/src/api/social/people.ts +118 -0
  33. package/src/api/social/presence.ts +95 -0
  34. package/src/api/social.ts +9 -0
  35. package/src/api/version.ts +40 -0
  36. package/src/coerce.ts +50 -0
  37. package/src/common.ts +42 -0
  38. package/src/entities.ts +62 -0
  39. package/src/errors/codes/core.ts +10 -0
  40. package/src/errors/codes/multiplayer.ts +43 -0
  41. package/src/errors/codes/play.ts +5 -0
  42. package/src/errors/codes/social.ts +31 -0
  43. package/src/errors/index.ts +88 -0
  44. package/src/index.ts +16 -0
  45. package/src/limits.ts +100 -0
  46. package/src/logic/daily.ts +54 -0
  47. package/src/logic/index.ts +8 -0
  48. package/src/logic/presence.ts +62 -0
  49. package/src/logic/race.ts +169 -0
  50. package/src/logic/time.ts +81 -0
  51. package/src/manifest/changes.ts +55 -0
  52. package/src/manifest/controls.ts +42 -0
  53. package/src/manifest/fields.ts +89 -0
  54. package/src/manifest/game.ts +132 -0
  55. package/src/manifest/index.ts +12 -0
  56. package/src/manifest/inject.ts +31 -0
  57. package/src/manifest/leaderboards.ts +29 -0
  58. package/src/manifest/multiplayer.ts +71 -0
  59. package/src/manifest/race.ts +93 -0
  60. package/src/middleware.ts +47 -0
  61. package/src/realtime/define.ts +52 -0
  62. package/src/realtime/envelope.ts +67 -0
  63. package/src/realtime/events/multiplayer.ts +8 -0
  64. package/src/realtime/events/parties.ts +31 -0
  65. package/src/realtime/events/races.ts +27 -0
  66. package/src/realtime/events/rooms.ts +48 -0
  67. package/src/realtime/events/social.ts +81 -0
  68. package/src/realtime/index.ts +24 -0
  69. package/src/realtime/ops/empty.ts +8 -0
  70. package/src/realtime/ops/hello.ts +40 -0
  71. package/src/realtime/ops/multiplayer.ts +8 -0
  72. package/src/realtime/ops/parties.ts +13 -0
  73. package/src/realtime/ops/races.ts +99 -0
  74. package/src/realtime/ops/rooms.ts +122 -0
  75. package/src/realtime/ops/social.ts +58 -0
@@ -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
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Wording for race UIs (HUD, result card, history): stat values, result
3
+ * labels and the "how it ended" line, identical on every screen. Copy rule
4
+ * (multiplayer.md ยง10): no violent words; racers "finish", "are out", "give
5
+ * up" or "drop out".
6
+ */
7
+ import type { RaceSummary } from '../api/catalog';
8
+ import type { RaceOutcome, RaceParams, RacerResult, RacerState, RaceResult, RaceStats, RaceStatValue } from '../api/mp/races';
9
+ import type { RaceStatFormat } from '../manifest/race';
10
+ import { raceClock } from './time';
11
+
12
+ /** One declared race stat (key, title, display format). */
13
+ export type RaceStatSpec = RaceSummary['stats'][number];
14
+
15
+ /** A stat value for display, per its declared format. Unknown or absent values show as `'โ€“'`. */
16
+ export function formatStat(value: RaceStatValue | undefined, format: RaceStatFormat): string {
17
+ if (value === undefined) {
18
+ return 'โ€“';
19
+ }
20
+ switch (format) {
21
+ case 'percent':
22
+ return typeof value === 'number' ? `${String(Math.round(Math.max(0, Math.min(1, value)) * 100))}%` : 'โ€“';
23
+ case 'time':
24
+ return typeof value === 'number' ? raceClock(value) : 'โ€“';
25
+ case 'bool':
26
+ return typeof value === 'boolean' ? (value ? 'โœ“' : 'โœ—') : 'โ€“';
27
+ case 'color':
28
+ return typeof value === 'string' ? 'โ—' : 'โ€“';
29
+ case 'int':
30
+ if (typeof value === 'number') {
31
+ return String(Math.round(value));
32
+ }
33
+ return typeof value === 'boolean' ? (value ? 'โœ“' : 'โœ—') : 'โ€“';
34
+ }
35
+ }
36
+
37
+ /** The declared stats with their values (the HUD shows at most `max`). */
38
+ export function shownStats(spec: RaceSummary | null, stats: RaceStats, max = 3): { spec: RaceStatSpec; value: RaceStatValue | undefined }[] {
39
+ return spec ? spec.stats.slice(0, max).map((s) => ({ spec: s, value: stats[s.key] })) : [];
40
+ }
41
+
42
+ /** "Board 4x4 ยท Level Hard" from the manifest's option titles; unknown values are skipped. */
43
+ export function paramsText(spec: RaceSummary | null, params: RaceParams): string {
44
+ if (!spec) {
45
+ return '';
46
+ }
47
+ const parts: string[] = [];
48
+ for (const param of spec.params) {
49
+ const matchingOption = param.options.find((option) => option.value === params[param.key]);
50
+ if (matchingOption) {
51
+ parts.push(param.title === matchingOption.title ? matchingOption.title : `${param.title} ${matchingOption.title}`);
52
+ }
53
+ }
54
+ return parts.join(' ยท ');
55
+ }
56
+
57
+ /** A racer's live state as a short tag (`''` while racing and connected). */
58
+ export function racerStateTag(state: RacerState, isConnected: boolean): string {
59
+ switch (state) {
60
+ case 'finished':
61
+ return '๐Ÿ Finished';
62
+ case 'lost':
63
+ return 'Out';
64
+ case 'forfeit':
65
+ return 'Gave up';
66
+ case 'disconnected':
67
+ return 'Dropped out';
68
+ case 'racing':
69
+ return isConnected ? '' : 'Reconnectingโ€ฆ';
70
+ }
71
+ }
72
+
73
+ const RESULT_LABEL: Readonly<Record<RacerResult, string>> = {
74
+ won: '๐Ÿ† Won', beaten: "Didn't finish", lost: 'Out', forfeit: 'Gave up', disconnected: 'Dropped out', timeout: "Time's up",
75
+ };
76
+
77
+ /** A racer's line in the result card and history. */
78
+ export function resultLabel(racerResult: RacerResult): string {
79
+ return RESULT_LABEL[racerResult];
80
+ }
81
+
82
+ /** The result card's title from my point of view (`me` is `null` when watching). */
83
+ export function resultTitle(result: RaceResult, me: number | null): string {
84
+ const winner = result.players.find((player) => player.userId === result.winnerId);
85
+ if (!winner) {
86
+ return "Time's up!";
87
+ }
88
+ return winner.userId === me ? 'You won! ๐Ÿ†' : `${winner.name} won`;
89
+ }
90
+
91
+ /** Names joined for a sentence: "Sam", "Sam and Ali", "Sam, Ali and Jo". */
92
+ export function nameList(names: readonly string[]): string {
93
+ const last = names.at(-1);
94
+ if (names.length <= 1 || last === undefined) {
95
+ return names[0] ?? 'Everyone';
96
+ }
97
+ return `${names.slice(0, -1).join(', ')} and ${last}`;
98
+ }
99
+
100
+ /** How the race ended, in one friendly sentence, from my point of view. */
101
+ export function outcomeLine(result: RaceResult, me: number | null): string {
102
+ const winner = result.players.find((player) => player.userId === result.winnerId);
103
+ if (!winner) {
104
+ return 'Nobody finished before the time ran out.';
105
+ }
106
+ const mine = result.players.find((player) => player.userId === me);
107
+ const others = result.players.filter((player) => player.userId !== result.winnerId);
108
+ const namesWithOutcome = (outcome: RaceOutcome): string[] => others.filter((player) => player.result === outcome).map((player) => player.name);
109
+ const didIWin = winner.userId === me;
110
+ if (result.outcome === 'finished') {
111
+ return `${didIWin ? 'You' : winner.name} finished first in ${raceClock(winner.ms)}.`;
112
+ }
113
+ if (mine && !didIWin) {
114
+ if (mine.result === 'forfeit') {
115
+ return 'You gave up.';
116
+ }
117
+ if (mine.result === 'lost') {
118
+ return "You're out โ€” better luck next race!";
119
+ }
120
+ if (mine.result === 'disconnected') {
121
+ return 'Your connection dropped.';
122
+ }
123
+ }
124
+ const lost = namesWithOutcome('lost');
125
+ const gaveUp = namesWithOutcome('forfeit');
126
+ const dropped = namesWithOutcome('disconnected');
127
+ const bits: string[] = [];
128
+ if (gaveUp.length > 0) {
129
+ bits.push(`${nameList(gaveUp)} gave up`);
130
+ }
131
+ if (lost.length > 0) {
132
+ bits.push(`${nameList(lost)} ${lost.length === 1 ? 'is' : 'are'} out`);
133
+ }
134
+ if (dropped.length > 0) {
135
+ bits.push(`${nameList(dropped)} dropped out`);
136
+ }
137
+ const why = bits.length > 0 ? bits.join(', ') : 'Everyone else is out';
138
+ return `${why.charAt(0).toUpperCase()}${why.slice(1)} โ€” ${didIWin ? 'you win' : `${winner.name} wins`}!`;
139
+ }
140
+
141
+ const HISTORY_VERB: Readonly<Record<RacerResult, string>> = {
142
+ won: 'Won', beaten: 'Lost', lost: 'Out', forfeit: 'Gave up', disconnected: 'Dropped out', timeout: "Time's up",
143
+ };
144
+
145
+ /** My race's headline verb in the history ("Won vs Sam", "Lost vs Sam"). */
146
+ export function historyVerb(racerResult: RacerResult): string {
147
+ return HISTORY_VERB[racerResult];
148
+ }
149
+
150
+ /** True when a racer's time means something (they finished or dropped out themselves). */
151
+ export function hasOwnTime(racerResult: RacerResult): boolean {
152
+ return racerResult !== 'beaten' && racerResult !== 'timeout';
153
+ }
154
+
155
+ /** The history badge for my result. */
156
+ export function historyBadge(racerResult: RacerResult | undefined): string {
157
+ switch (racerResult) {
158
+ case 'won':
159
+ return '๐Ÿ†';
160
+ case 'forfeit':
161
+ return '๐Ÿณ๏ธ';
162
+ case 'disconnected':
163
+ return '๐Ÿ“ถ';
164
+ case 'timeout':
165
+ return 'โฑ๏ธ';
166
+ default:
167
+ return '๐Ÿ';
168
+ }
169
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Time and count wording shared by every hub UI (the social drawer, the menu,
3
+ * the home page, race cards). One copy, so "3h ago" reads the same everywhere
4
+ * (plan ยง8.4: the menu and the catalog page each had their own).
5
+ */
6
+
7
+ const padTwoDigits = (count: number): string => String(count).padStart(2, '0');
8
+
9
+ /**
10
+ * `m:ss` (or `h:mm:ss`) elapsed since `since`.
11
+ * @example elapsed(0, 252_000) // "4:12"
12
+ */
13
+ export function elapsed(since: number, now: number): string {
14
+ const total = Math.max(0, Math.floor((now - since) / 1000));
15
+ const hours = Math.floor(total / 3600);
16
+ const minutes = Math.floor((total % 3600) / 60);
17
+ const paddedSeconds = padTwoDigits(total % 60);
18
+ return hours ? `${String(hours)}:${padTwoDigits(minutes)}:${paddedSeconds}` : `${String(minutes)}:${paddedSeconds}`;
19
+ }
20
+
21
+ /**
22
+ * "just now", "5m ago", "3h ago", "2d ago", then a short date (in the
23
+ * viewer's locale) after a week.
24
+ */
25
+ export function relativeTime(then: number, now: number): string {
26
+ const seconds = Math.max(0, Math.floor((now - then) / 1000));
27
+ if (seconds < 60) {
28
+ return 'just now';
29
+ }
30
+ if (seconds < 3600) {
31
+ return `${String(Math.floor(seconds / 60))}m ago`;
32
+ }
33
+ if (seconds < 86400) {
34
+ return `${String(Math.floor(seconds / 3600))}h ago`;
35
+ }
36
+ if (seconds < 7 * 86400) {
37
+ return `${String(Math.floor(seconds / 86400))}d ago`;
38
+ }
39
+ return new Date(then).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
40
+ }
41
+
42
+ /**
43
+ * A seconds countdown for mutes and rate limits: "42s" or "3:05".
44
+ * @param ms - time left, in ms
45
+ */
46
+ export function countdown(ms: number): string {
47
+ const seconds = Math.max(0, Math.ceil(ms / 1000));
48
+ return seconds < 60 ? `${String(seconds)}s` : `${String(Math.floor(seconds / 60))}:${padTwoDigits(seconds % 60)}`;
49
+ }
50
+
51
+ /** Badge text: `''` for 0, the number up to 9, then `'9+'`. */
52
+ export function badgeText(count: number): string {
53
+ if (count <= 0) {
54
+ return '';
55
+ }
56
+ return count > 9 ? '9+' : String(count);
57
+ }
58
+
59
+ /**
60
+ * When a suspension or mute ends, for the "can't use online play" notice.
61
+ * `''` when there is no end (0, or a permanent suspension far in the future).
62
+ */
63
+ export function untilText(until: number): string {
64
+ if (!until || until > 8e15) {
65
+ return '';
66
+ }
67
+ return new Date(until).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
68
+ }
69
+
70
+ /**
71
+ * A race clock: `83_400` โ†’ `"1:23.4"`; past an hour `"1:02:03"`.
72
+ * @param ms - elapsed race time, in ms
73
+ */
74
+ export function raceClock(ms: number): string {
75
+ const tenths = Math.max(0, Math.floor(ms / 100));
76
+ const seconds = Math.floor(tenths / 10);
77
+ const hours = Math.floor(seconds / 3600);
78
+ const minutes = Math.floor((seconds % 3600) / 60);
79
+ const paddedSeconds = padTwoDigits(seconds % 60);
80
+ return hours ? `${String(hours)}:${padTwoDigits(minutes)}:${paddedSeconds}` : `${String(minutes)}:${paddedSeconds}.${String(tenths % 10)}`;
81
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `game.changes`: player-facing "What's new" notes (docs/hub.md "What's new").
3
+ */
4
+ import { z } from 'zod';
5
+ import { Key } from '../common';
6
+ import { MANIFEST_LIMITS } from '../limits';
7
+ import { trimmedText } from './fields';
8
+
9
+ /** The text of one note: plain words for a player, at most 280 characters. */
10
+ export const ChangeText = trimmedText(MANIFEST_LIMITS.changeTextMax);
11
+
12
+ /**
13
+ * One note: a string (identified by its text, so rewording re-announces it)
14
+ * or `{ id, text }` (identified by `id`, so it can be reworded).
15
+ */
16
+ export const ChangeEntry = z.union([
17
+ ChangeText,
18
+ z.strictObject({
19
+ /** A stable id: letters, digits and `_ . : -`, 1 to 64 characters. */
20
+ id: Key,
21
+ /** The note. */
22
+ text: ChangeText,
23
+ }),
24
+ ]);
25
+
26
+ /** One "What's new" note. */
27
+ export type ChangeEntry = z.output<typeof ChangeEntry>;
28
+
29
+ /**
30
+ * The form a string note is identified by: lowercase, whitespace collapsed,
31
+ * trailing punctuation dropped. Two notes with the same form are duplicates.
32
+ */
33
+ export function normalizeChangeText(text: string): string {
34
+ return text.toLowerCase().replace(/\s+/g, ' ').trim().replace(/[\s.!?โ€ฆ]+$/u, '');
35
+ }
36
+
37
+ /** The identity the hub stores a note under. */
38
+ function noteIdentity(note: ChangeEntry): string {
39
+ return typeof note === 'string' ? `h:${normalizeChangeText(note)}` : `id:${note.id}`;
40
+ }
41
+
42
+ /** `game.changes`: at most 200 notes, no duplicates. Newest last. */
43
+ export const GameChanges = z
44
+ .array(ChangeEntry)
45
+ .max(MANIFEST_LIMITS.changesMax)
46
+ .superRefine((notes, ctx) => {
47
+ const seen = new Set<string>();
48
+ notes.forEach((note, i) => {
49
+ const id = noteIdentity(note);
50
+ if (seen.has(id)) {
51
+ ctx.addIssue({ code: 'custom', message: 'duplicate note (same id, or the same text ignoring case and trailing punctuation)', path: [i] });
52
+ }
53
+ seen.add(id);
54
+ });
55
+ });