@diffenderfer-games/hub 2.0.0-next.1 → 2.0.0-next.3

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 (102) hide show
  1. package/CHANGELOG.md +40 -6
  2. package/README.md +25 -10
  3. package/dist/auto.d.ts +7 -7
  4. package/dist/auto.js +3 -3
  5. package/dist/doctor/cli.mjs +14 -7
  6. package/dist/doctor/cli.mjs.map +1 -1
  7. package/dist/index.d.ts +7 -7
  8. package/dist/index.js +3 -3
  9. package/dist/input.d.ts +2 -2
  10. package/dist/input.js +2 -2
  11. package/dist/mp.d.ts +3 -3
  12. package/dist/race.d.ts +1 -1
  13. package/dist/rooms.d.ts +3 -3
  14. package/dist/shared/bridge.js +9 -3
  15. package/dist/shared/bridge.js.map +1 -1
  16. package/dist/shared/client-version.gen.js +1 -1
  17. package/dist/shared/daily.d.ts +10 -4
  18. package/dist/shared/daily.d.ts.map +1 -1
  19. package/dist/shared/daily.js +12 -2
  20. package/dist/shared/daily.js.map +1 -1
  21. package/dist/shared/index.d.ts +315 -36
  22. package/dist/shared/index.d.ts.map +1 -1
  23. package/dist/shared/input.d.ts +47 -5
  24. package/dist/shared/input.d.ts.map +1 -1
  25. package/dist/shared/input.js +139 -23
  26. package/dist/shared/input.js.map +1 -1
  27. package/dist/shared/latest-state.js +1 -1
  28. package/dist/shared/mp.js +10 -2
  29. package/dist/shared/mp.js.map +1 -1
  30. package/dist/shared/multiplayer-types.d.ts +15 -2
  31. package/dist/shared/multiplayer-types.d.ts.map +1 -1
  32. package/dist/shared/race-types.d.ts +1 -1
  33. package/dist/shared/race.js +1 -1
  34. package/dist/shared/room-types.d.ts +28 -3
  35. package/dist/shared/room-types.d.ts.map +1 -1
  36. package/dist/shared/rooms.d.ts.map +1 -1
  37. package/dist/shared/rooms.js +10 -2
  38. package/dist/shared/rooms.js.map +1 -1
  39. package/dist/shared/runtime-opener.js +1 -1
  40. package/dist/shared/social.d.ts +23 -8
  41. package/dist/shared/social.d.ts.map +1 -1
  42. package/dist/shared/social.js +6 -1
  43. package/dist/shared/social.js.map +1 -1
  44. package/dist/shared/src.js +1588 -388
  45. package/dist/shared/src.js.map +1 -1
  46. package/dist/shared/wire-types.d.ts +6 -2
  47. package/dist/shared/wire-types.d.ts.map +1 -1
  48. package/dist/social.d.ts +5 -5
  49. package/dist/social.js +1 -1
  50. package/package.json +2 -2
  51. package/src/core/connection.ts +187 -92
  52. package/src/core/game-data.ts +11 -9
  53. package/src/core/hub-economy.ts +47 -10
  54. package/src/core/hub-progress.ts +13 -16
  55. package/src/core/hub.ts +4 -2
  56. package/src/core/offline/hub-kinds/daily-kind.ts +68 -0
  57. package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
  58. package/src/core/offline/hub-kinds/game-fields.ts +2 -0
  59. package/src/core/offline/hub-kinds/index.ts +35 -0
  60. package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
  61. package/src/core/offline/hub-kinds/progress-kinds.ts +148 -0
  62. package/src/core/offline/kind-adapters.ts +48 -0
  63. package/src/core/offline/local-inventory.ts +64 -0
  64. package/src/core/offline/local-store.ts +20 -22
  65. package/src/core/offline/offline-api.ts +65 -0
  66. package/src/core/offline/outbox-entry.ts +59 -0
  67. package/src/core/offline/outbox-kinds.ts +78 -0
  68. package/src/core/offline/outbox-merge.ts +39 -0
  69. package/src/core/offline/outbox-replayer.ts +141 -0
  70. package/src/core/offline/outbox-store.ts +85 -0
  71. package/src/core/offline/outbox.ts +232 -0
  72. package/src/core/offline/queue.ts +4 -49
  73. package/src/core/offline/replay-lease.ts +87 -0
  74. package/src/core/offline/replay-policy.ts +52 -0
  75. package/src/core/rest/rest-client.ts +14 -3
  76. package/src/core/rest/routes.gen.ts +108 -87
  77. package/src/core/storage/json-guards.ts +6 -0
  78. package/src/core/version/client-version.gen.ts +1 -1
  79. package/src/daily/daily-system.ts +8 -1
  80. package/src/daily/types.ts +3 -2
  81. package/src/doctor/checks.ts +10 -2
  82. package/src/index.ts +6 -1
  83. package/src/input/focus-ring.ts +13 -0
  84. package/src/input/input-registry.ts +9 -2
  85. package/src/input/input-system.ts +4 -0
  86. package/src/input/modality.ts +122 -0
  87. package/src/input/nav-state.ts +13 -4
  88. package/src/input/zero-config.ts +1 -14
  89. package/src/input.ts +3 -0
  90. package/src/mp.ts +1 -1
  91. package/src/overlay/overlay.ts +5 -3
  92. package/src/overlay/styles.ts +4 -3
  93. package/src/realtime/bridge.ts +5 -0
  94. package/src/realtime/multiplayer-forwarder.ts +13 -1
  95. package/src/realtime/multiplayer-types.ts +15 -1
  96. package/src/realtime/room-types.ts +28 -2
  97. package/src/realtime/rooms-forwarder.ts +13 -1
  98. package/src/realtime/runtime-api.ts +19 -8
  99. package/src/realtime/social-forwarder.ts +5 -0
  100. package/src/realtime/social-types.ts +8 -0
  101. package/src/realtime/wire-types.ts +7 -0
  102. package/src/rooms.ts +1 -1
@@ -1,12 +1,8 @@
1
1
  /**
2
- * The offline mutation queue. Writes apply to the local cache at once and
3
- * queue a durable op; when the hub is reachable again the queue replays in
4
- * order. Ops collapse as they are queued (a save's last write wins, `incr`s
5
- * add up, `max`es keep the largest, a board keeps its best, profile patches
6
- * merge), so the backlog stays small.
7
- *
8
- * The op shapes are the v1 client's, unchanged: a page upgraded from a
9
- * vendored copy replays the queue that copy left in `localStorage`.
2
+ * The v1 client's offline queue (`hub:<slug>:queue`): its op shapes and the
3
+ * reader for what a vendored v1 copy left in `localStorage`. This client
4
+ * queues into the outbox instead (`outbox.ts`) and moves any v1 ops there on
5
+ * start (`hub-kinds/legacy-ops.ts`), so nothing a v1 copy queued is lost.
10
6
  */
11
7
  import type { Json } from '@diffenderfer-games/hub-contract';
12
8
  import type { SortDir } from '@diffenderfer-games/hub-contract';
@@ -128,44 +124,3 @@ export function readQueue(stored: unknown): QueueOp[] {
128
124
  export function betterScore(first: number, second: number, sort: SortDir | undefined): number {
129
125
  return sort === 'asc' ? Math.min(first, second) : Math.max(first, second);
130
126
  }
131
-
132
- /** The op that `queued` becomes when `incoming` collapses into it, or `undefined` when they don't collapse. */
133
- function collapsedOp(queued: QueueOp, incoming: QueueOp): QueueOp | undefined {
134
- if (queued.kind === 'incrStat' && incoming.kind === 'incrStat' && queued.key === incoming.key) {
135
- return { ...queued, amount: queued.amount + incoming.amount };
136
- }
137
- if (queued.kind === 'maxStat' && incoming.kind === 'maxStat' && queued.key === incoming.key) {
138
- return { ...queued, value: Math.max(queued.value, incoming.value) };
139
- }
140
- if (queued.kind === 'setStat' && incoming.kind === 'setStat' && queued.key === incoming.key) {
141
- return { ...queued, value: incoming.value };
142
- }
143
- if (queued.kind === 'submitScore' && incoming.kind === 'submitScore' && queued.key === incoming.key) {
144
- return { ...queued, score: betterScore(queued.score, incoming.score, incoming.opts.sort), opts: incoming.opts };
145
- }
146
- if (queued.kind === 'setProfile' && incoming.kind === 'setProfile') {
147
- return { ...queued, patch: { ...queued.patch, ...incoming.patch } };
148
- }
149
- return undefined;
150
- }
151
-
152
- /** Whether an op writes save slot `slot`. */
153
- function writesSlot(op: QueueOp, slot: string): boolean {
154
- return (op.kind === 'putSave' || op.kind === 'deleteSave') && op.slot === slot;
155
- }
156
-
157
- /**
158
- * The queue with `incoming` added: a save or delete replaces any queued
159
- * write to the same slot (and goes last); a compatible op merges into the
160
- * first queued one in place; anything else is appended.
161
- */
162
- export function enqueueOp(queue: readonly QueueOp[], incoming: QueueOp): QueueOp[] {
163
- if (incoming.kind === 'putSave' || incoming.kind === 'deleteSave') {
164
- return [...queue.filter((op) => !writesSlot(op, incoming.slot)), incoming];
165
- }
166
- const mergeAt = queue.findIndex((op) => collapsedOp(op, incoming) !== undefined);
167
- if (mergeAt < 0) {
168
- return [...queue, incoming];
169
- }
170
- return queue.map((op, i) => (i === mergeAt ? (collapsedOp(op, incoming) ?? op) : op));
171
- }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * One replayer at a time per game, across tabs and across the client copies
3
+ * on one page: a Web Lock where the browser has them, else a lease in
4
+ * `localStorage` that its holder renews while it works and that lapses if
5
+ * the tab dies. A replayer that can't get it simply doesn't run; the holder
6
+ * replays for everyone.
7
+ */
8
+ import { isRecord, optionalNumber, optionalString, parseJsonText } from '../storage/json-guards';
9
+ import type { KeyValueStore } from '../storage/key-value';
10
+
11
+ /** Runs work while holding the lease. */
12
+ export interface ReplayLease {
13
+ /** Run `work` holding the lease; `false` (without running it) when another replayer holds it. */
14
+ hold(work: () => Promise<void>): Promise<boolean>;
15
+ }
16
+
17
+ /** How long a `localStorage` lease lasts without renewal. */
18
+ export const STORAGE_LEASE_MS = 15_000;
19
+
20
+ /** How often the holder renews it. */
21
+ const RENEW_EVERY_MS = 5_000;
22
+
23
+ /** A lease on the Web Locks API. */
24
+ function webLockLease(locks: LockManager, name: string): ReplayLease {
25
+ return {
26
+ hold: (work) => locks.request(name, { ifAvailable: true }, async (lock) => {
27
+ if (lock === null) {
28
+ return false;
29
+ }
30
+ await work();
31
+ return true;
32
+ }),
33
+ };
34
+ }
35
+
36
+ /** The lease's holder and expiry, or `undefined` when there is none. */
37
+ function readLease(text: string | null): { readonly owner: string; readonly until: number } | undefined {
38
+ const stored = text === null ? undefined : parseJsonText(text);
39
+ if (!isRecord(stored)) {
40
+ return undefined;
41
+ }
42
+ const owner = optionalString(stored.owner);
43
+ const until = optionalNumber(stored.until);
44
+ return owner === undefined || until === undefined ? undefined : { owner, until };
45
+ }
46
+
47
+ /** A lease kept in `store` under `key`, for pages without Web Locks. */
48
+ export function storageLease(store: KeyValueStore, key: string, now: () => number = Date.now): ReplayLease {
49
+ const owner = `${String(now())}-${Math.random().toString(36).slice(2)}`;
50
+ const claim = (): boolean => {
51
+ const current = readLease(store.read(key));
52
+ if (current !== undefined && current.owner !== owner && current.until > now()) {
53
+ return false;
54
+ }
55
+ store.write(key, JSON.stringify({ owner, until: now() + STORAGE_LEASE_MS }));
56
+ return readLease(store.read(key))?.owner === owner;
57
+ };
58
+ return {
59
+ async hold(work) {
60
+ if (!claim()) {
61
+ return false;
62
+ }
63
+ const renewal = setInterval(claim, RENEW_EVERY_MS);
64
+ try {
65
+ await work();
66
+ } finally {
67
+ clearInterval(renewal);
68
+ if (readLease(store.read(key))?.owner === owner) {
69
+ store.remove(key);
70
+ }
71
+ }
72
+ return true;
73
+ },
74
+ };
75
+ }
76
+
77
+ /** The page's Web Locks, when it has them. */
78
+ function pageLocks(): LockManager | undefined {
79
+ return typeof navigator !== 'undefined' && 'locks' in navigator ? navigator.locks : undefined;
80
+ }
81
+
82
+ /** The lease for game `slug`'s outbox: a Web Lock when available, else one in `store`. */
83
+ export function createReplayLease(slug: string, store: KeyValueStore): ReplayLease {
84
+ const name = `hub:${slug}:outbox-lease`;
85
+ const locks = pageLocks();
86
+ return locks === undefined ? storageLease(store, name) : webLockLease(locks, name);
87
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * What a failed replay does next, and how long it waits:
3
+ *
4
+ * - `offline`: the hub (or the network) can't be reached. The op stays at
5
+ * the head of the queue and replay waits for the connection.
6
+ * - `retry`: the hub answered with a passing problem (408, 429, 5xx) or a
7
+ * game handler's fetch failed. The op stays at the head and is retried with
8
+ * backoff, up to {@link MAX_RETRIES} times, then dropped.
9
+ * - `drop`: the hub refused it (any other status) or the handler threw
10
+ * something else. The op is dropped and reported, and the queue moves on.
11
+ */
12
+ import { HubError } from '../errors';
13
+
14
+ /** What to do after a failed replay. */
15
+ export type FailureVerdict = 'offline' | 'retry' | 'drop';
16
+
17
+ /** The most tries for an op that keeps getting a passing failure. */
18
+ export const MAX_RETRIES = 8;
19
+
20
+ /** The first retry's delay. */
21
+ const BASE_DELAY_MS = 1000;
22
+
23
+ /** The longest delay between tries. */
24
+ const MAX_DELAY_MS = 60_000;
25
+
26
+ /** Statuses that mean "try again later". */
27
+ const PASSING_STATUSES: ReadonlySet<number> = new Set([408, 425, 429]);
28
+
29
+ /** The verdict for a failure. */
30
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: whatever a replay handler threw
31
+ export function verdictFor(failure: unknown): FailureVerdict {
32
+ if (failure instanceof HubError) {
33
+ if (failure.status === undefined) {
34
+ return 'offline';
35
+ }
36
+ return failure.status >= 500 || PASSING_STATUSES.has(failure.status) ? 'retry' : 'drop';
37
+ }
38
+ // A game handler's own fetch rejects with a TypeError when the network is down.
39
+ return failure instanceof TypeError ? 'retry' : 'drop';
40
+ }
41
+
42
+ /** The delay before try `attempts + 1`: doubling from 1 s, capped at 60 s, with jitter so tabs and players spread out. */
43
+ export function backoffMs(attempts: number, random: () => number = Math.random): number {
44
+ const ceiling = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** Math.max(0, attempts - 1));
45
+ return Math.round(ceiling / 2 + (ceiling / 2) * random());
46
+ }
47
+
48
+ /** A failure's message, for reports. */
49
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: whatever a replay handler threw
50
+ export function failureMessage(failure: unknown): string {
51
+ return failure instanceof Error ? failure.message : String(failure);
52
+ }
@@ -9,7 +9,7 @@ import { errorFor, HubError, readErrorFields, UNSUPPORTED_BY_HUB } from '../erro
9
9
  import { isRecord } from '../storage/json-guards';
10
10
  import { HUB_CLIENT_VERSION } from '../version/client-version.gen';
11
11
  import type { EndpointFields, EndpointName, EndpointReply } from './endpoint-types';
12
- import { ROUTES } from './routes.gen';
12
+ import { OFFLINE_OP_HEADER, ROUTES } from './routes.gen';
13
13
 
14
14
  /** The header that tells the hub which client version is calling. */
15
15
  export const CLIENT_VERSION_HEADER = 'x-hub-client';
@@ -38,6 +38,12 @@ export interface RestClient {
38
38
  readonly apiBase: string;
39
39
  /** Call an endpoint; resolves with its reply body, rejects with a {@link HubError}. */
40
40
  call<K extends EndpointName>(name: K, ...fieldsArgument: CallArguments<K>): Promise<EndpointReply<K>>;
41
+ /**
42
+ * Call an endpoint as part of an offline operation: an endpoint marked for
43
+ * offline replay gets `opKey` in the operation header, so the hub runs it
44
+ * once however often it is sent; any other endpoint is called as usual.
45
+ */
46
+ callOnce<K extends EndpointName>(opKey: string, name: K, ...fieldsArgument: CallArguments<K>): Promise<EndpointReply<K>>;
41
47
  }
42
48
 
43
49
  /** One field value as URL text. */
@@ -126,14 +132,18 @@ async function failureOf(name: EndpointName, response: Response): Promise<HubErr
126
132
  /** A REST client for one API base. */
127
133
  export function createRestClient(options: RestClientOptions): RestClient {
128
134
  // eslint-disable-next-line no-restricted-syntax -- trust boundary: the caller's typed fields, walked generically
129
- async function send<Reply>(name: EndpointName, fields: unknown): Promise<Reply> {
135
+ async function send<Reply>(name: EndpointName, fields: unknown, opKey?: string): Promise<Reply> {
130
136
  const planned = planRequest(name, fields);
131
137
  let response: Response;
132
138
  try {
133
139
  response = await options.fetch(options.apiBase + planned.pathAndQuery, {
134
140
  method: planned.method,
135
141
  credentials: 'same-origin',
136
- headers: { 'content-type': 'application/json', [CLIENT_VERSION_HEADER]: HUB_CLIENT_VERSION },
142
+ headers: {
143
+ 'content-type': 'application/json',
144
+ [CLIENT_VERSION_HEADER]: HUB_CLIENT_VERSION,
145
+ ...(opKey !== undefined && ROUTES[name].offline === 'replay' ? { [OFFLINE_OP_HEADER]: opKey } : {}),
146
+ },
137
147
  ...(planned.bodyText === undefined ? {} : { body: planned.bodyText }),
138
148
  });
139
149
  } catch (failure) {
@@ -148,5 +158,6 @@ export function createRestClient(options: RestClientOptions): RestClient {
148
158
  return {
149
159
  apiBase: options.apiBase,
150
160
  call: (name, ...fieldsArgument) => send(name, fieldsArgument[0]),
161
+ callOnce: (opKey, name, ...fieldsArgument) => send(name, fieldsArgument[0], opKey),
151
162
  };
152
163
  }
@@ -1,90 +1,111 @@
1
1
  // Generated by tools/hub-client/generate.ts. Do not edit; run `npm run gen:hub-client` in next/.
2
- /** Every public REST endpoint's method, path (under `/_api`) and query keys, from the contract. */
2
+ /** Every public REST endpoint's method, path (under `/_api`), query keys and offline mode (`'replay'` when it may be queued offline), from the contract. */
3
3
  export const ROUTES = {
4
- ensureGuestSession: { method: 'POST', path: '/auth/guest', query: [] },
5
- signUpWithPassword: { method: 'POST', path: '/auth/signup', query: [] },
6
- logInWithPassword: { method: 'POST', path: '/auth/login', query: [] },
7
- logOut: { method: 'POST', path: '/auth/logout', query: [] },
8
- getCurrentUser: { method: 'GET', path: '/me', query: [] },
9
- claimGuestAccount: { method: 'POST', path: '/auth/claim', query: [] },
10
- listGames: { method: 'GET', path: '/games', query: [] },
11
- listWhatsNewNotes: { method: 'GET', path: '/changes', query: ['game', 'since'] },
12
- listFavoriteGames: { method: 'GET', path: '/favorites', query: [] },
13
- setGameFavorite: { method: 'PUT', path: '/favorites/:slug', query: [] },
14
- markWhatsNewSeen: { method: 'POST', path: '/changes/seen', query: [] },
15
- getGameStats: { method: 'GET', path: '/games/:slug/stats', query: ['user'] },
16
- getGameProfile: { method: 'GET', path: '/games/:slug/profile', query: [] },
17
- setGameProfile: { method: 'PUT', path: '/games/:slug/profile', query: [] },
18
- listSaves: { method: 'GET', path: '/games/:slug/saves', query: [] },
19
- getSave: { method: 'GET', path: '/games/:slug/saves/:slot', query: [] },
20
- writeSave: { method: 'PUT', path: '/games/:slug/saves/:slot', query: [] },
21
- deleteSave: { method: 'DELETE', path: '/games/:slug/saves/:slot', query: [] },
22
- updateGameStat: { method: 'POST', path: '/games/:slug/stats', query: [] },
23
- resetMyGameProgress: { method: 'POST', path: '/games/:slug/reset', query: [] },
24
- listMyLeaderboardRecords: { method: 'GET', path: '/me/records', query: ['game'] },
25
- getMyOfflineSnapshot: { method: 'GET', path: '/me/snapshot', query: ['top'] },
26
- listTopOfEveryLeaderboard: { method: 'GET', path: '/leaderboards', query: ['top'] },
27
- listGameLeaderboards: { method: 'GET', path: '/games/:slug/leaderboards', query: [] },
28
- getLeaderboard: { method: 'GET', path: '/games/:slug/leaderboards/:key', query: ['limit'] },
29
- submitScore: { method: 'POST', path: '/games/:slug/leaderboards/:key/scores', query: [] },
30
- listDailyChallengesForAllGames: { method: 'GET', path: '/daily', query: [] },
31
- getGameDailyChallenges: { method: 'GET', path: '/games/:slug/daily', query: [] },
32
- startDailyChallenge: { method: 'POST', path: '/games/:slug/daily/:day/start', query: [] },
33
- completeDailyChallenge: { method: 'POST', path: '/games/:slug/daily/:day/complete', query: [] },
34
- getMyInventory: { method: 'GET', path: '/games/:slug/inventory', query: [] },
35
- grantInventoryItems: { method: 'POST', path: '/games/:slug/inventory/grant', query: [] },
36
- exchangeInventoryItems: { method: 'POST', path: '/games/:slug/inventory/exchange', query: [] },
37
- getPlayerInventory: { method: 'GET', path: '/games/:slug/players/:userId/inventory', query: [] },
38
- listTradePartners: { method: 'GET', path: '/games/:slug/trades/players', query: [] },
39
- listMyTrades: { method: 'GET', path: '/games/:slug/trades', query: ['box', 'status'] },
40
- declineTrade: { method: 'POST', path: '/games/:slug/trades/:id/decline', query: [] },
41
- cancelTrade: { method: 'POST', path: '/games/:slug/trades/:id/cancel', query: [] },
42
- createTrade: { method: 'POST', path: '/games/:slug/trades', query: [] },
43
- acceptTrade: { method: 'POST', path: '/games/:slug/trades/:id/accept', query: [] },
44
- counterTradeOffer: { method: 'POST', path: '/games/:slug/trades/:id/counter', query: [] },
45
- getSiteAnalytics: { method: 'GET', path: '/analytics', query: [] },
46
- reportClientError: { method: 'POST', path: '/client-log', query: [] },
47
- recordGameEvent: { method: 'POST', path: '/games/:slug/events', query: [] },
48
- getGameAnalytics: { method: 'GET', path: '/games/:slug/analytics', query: [] },
49
- getSocialConfig: { method: 'GET', path: '/social/config', query: [] },
50
- getOnlineCounts: { method: 'GET', path: '/social/counts', query: [] },
51
- getMySocialProfile: { method: 'GET', path: '/social/me', query: [] },
52
- listFriendsAndRequests: { method: 'GET', path: '/social/friends', query: [] },
53
- findPlayerByName: { method: 'GET', path: '/social/players/by-name/:name', query: [] },
54
- getPlayerCard: { method: 'GET', path: '/social/players/:id', query: [] },
55
- updateMySocialProfile: { method: 'PUT', path: '/social/me', query: [] },
56
- setParentChatLock: { method: 'POST', path: '/social/chat-lock', query: [] },
57
- sendFriendRequest: { method: 'POST', path: '/social/friends', query: [] },
58
- acceptFriendRequest: { method: 'POST', path: '/social/friends/:id/accept', query: [] },
59
- declineFriendRequest: { method: 'POST', path: '/social/friends/:id/decline', query: [] },
60
- removeFriend: { method: 'DELETE', path: '/social/friends/:id', query: [] },
61
- setFriendNotifications: { method: 'PUT', path: '/social/friends/:id/notify', query: [] },
62
- blockPlayer: { method: 'POST', path: '/social/blocks', query: [] },
63
- unblockPlayer: { method: 'DELETE', path: '/social/blocks/:id', query: [] },
64
- listRecentPlayers: { method: 'GET', path: '/social/recent', query: [] },
65
- listConversations: { method: 'GET', path: '/social/conversations', query: [] },
66
- listDirectMessages: { method: 'GET', path: '/social/messages/:userId', query: ['before'] },
67
- sendDirectMessage: { method: 'POST', path: '/social/messages/:userId', query: [] },
68
- markDirectMessagesRead: { method: 'POST', path: '/social/messages/:userId/read', query: [] },
69
- getNotificationPreferences: { method: 'GET', path: '/social/notify', query: [] },
70
- unsubscribeFromPush: { method: 'DELETE', path: '/social/push', query: [] },
71
- setNotificationPreferences: { method: 'PUT', path: '/social/notify', query: [] },
72
- subscribeToPush: { method: 'POST', path: '/social/push', query: [] },
73
- fileReport: { method: 'POST', path: '/social/reports', query: [] },
74
- listPublicSuggestions: { method: 'GET', path: '/suggestions', query: ['sort'] },
75
- submitSuggestion: { method: 'POST', path: '/suggestions', query: [] },
76
- voteForSuggestion: { method: 'POST', path: '/suggestions/:id/vote', query: [] },
77
- removeSuggestionVote: { method: 'DELETE', path: '/suggestions/:id/vote', query: [] },
78
- withdrawSuggestion: { method: 'DELETE', path: '/suggestions/:id', query: [] },
79
- sendGameInvite: { method: 'POST', path: '/mp/invites', query: [] },
80
- acceptGameInvite: { method: 'POST', path: '/mp/invites/:id/accept', query: [] },
81
- declineGameInvite: { method: 'POST', path: '/mp/invites/:id/decline', query: [] },
82
- cancelGameInvite: { method: 'DELETE', path: '/mp/invites/:id', query: [] },
83
- redeemLaunchToken: { method: 'GET', path: '/mp/launch/:token', query: [] },
84
- setPartyJoinInfo: { method: 'POST', path: '/mp/parties/:id/join-info', query: [] },
85
- leaveParty: { method: 'POST', path: '/mp/parties/:id/leave', query: [] },
86
- launchIntoFriendsRoom: { method: 'POST', path: '/mp/join', query: [] },
87
- getGameServerTicket: { method: 'POST', path: '/mp/ticket', query: [] },
88
- listMyRaceHistory: { method: 'GET', path: '/race/history', query: ['game', 'with', 'before', 'limit'] },
89
- getHubVersion: { method: 'GET', path: '/version', query: [] },
4
+ ensureGuestSession: { method: 'POST', path: '/auth/guest', query: [], offline: null },
5
+ signUpWithPassword: { method: 'POST', path: '/auth/signup', query: [], offline: null },
6
+ logInWithPassword: { method: 'POST', path: '/auth/login', query: [], offline: null },
7
+ logOut: { method: 'POST', path: '/auth/logout', query: [], offline: null },
8
+ getCurrentUser: { method: 'GET', path: '/me', query: [], offline: null },
9
+ claimGuestAccount: { method: 'POST', path: '/auth/claim', query: [], offline: null },
10
+ getGamePlayerCounts: { method: 'GET', path: '/catalog/players', query: [], offline: null },
11
+ getMyGameActivity: { method: 'GET', path: '/games/:slug/me/activity', query: [], offline: null },
12
+ listGames: { method: 'GET', path: '/games', query: [], offline: null },
13
+ listWhatsNewNotes: { method: 'GET', path: '/changes', query: ['game', 'since'], offline: null },
14
+ listFavoriteGames: { method: 'GET', path: '/favorites', query: [], offline: null },
15
+ setGameFavorite: { method: 'PUT', path: '/favorites/:slug', query: [], offline: 'replay' },
16
+ markWhatsNewSeen: { method: 'POST', path: '/changes/seen', query: [], offline: null },
17
+ getGameStats: { method: 'GET', path: '/games/:slug/stats', query: ['user'], offline: null },
18
+ getGameProfile: { method: 'GET', path: '/games/:slug/profile', query: [], offline: null },
19
+ setGameProfile: { method: 'PUT', path: '/games/:slug/profile', query: [], offline: 'replay' },
20
+ listSaves: { method: 'GET', path: '/games/:slug/saves', query: [], offline: null },
21
+ getSave: { method: 'GET', path: '/games/:slug/saves/:slot', query: [], offline: null },
22
+ writeSave: { method: 'PUT', path: '/games/:slug/saves/:slot', query: [], offline: 'replay' },
23
+ deleteSave: { method: 'DELETE', path: '/games/:slug/saves/:slot', query: [], offline: 'replay' },
24
+ updateGameStat: { method: 'POST', path: '/games/:slug/stats', query: [], offline: 'replay' },
25
+ resetMyGameProgress: { method: 'POST', path: '/games/:slug/reset', query: [], offline: null },
26
+ listMyLeaderboardRecords: { method: 'GET', path: '/me/records', query: ['game'], offline: null },
27
+ getMyOfflineSnapshot: { method: 'GET', path: '/me/snapshot', query: ['top'], offline: null },
28
+ listTopOfEveryLeaderboard: { method: 'GET', path: '/leaderboards', query: ['top'], offline: null },
29
+ listGameLeaderboards: { method: 'GET', path: '/games/:slug/leaderboards', query: [], offline: null },
30
+ getLeaderboard: { method: 'GET', path: '/games/:slug/leaderboards/:key', query: ['limit'], offline: null },
31
+ submitScore: { method: 'POST', path: '/games/:slug/leaderboards/:key/scores', query: [], offline: 'replay' },
32
+ listDailyChallengesForAllGames: { method: 'GET', path: '/daily', query: [], offline: null },
33
+ getGameDailyChallenges: { method: 'GET', path: '/games/:slug/daily', query: [], offline: null },
34
+ startDailyChallenge: { method: 'POST', path: '/games/:slug/daily/:day/start', query: [], offline: null },
35
+ completeDailyChallenge: { method: 'POST', path: '/games/:slug/daily/:day/complete', query: [], offline: 'replay' },
36
+ getMyInventory: { method: 'GET', path: '/games/:slug/inventory', query: [], offline: null },
37
+ grantInventoryItems: { method: 'POST', path: '/games/:slug/inventory/grant', query: [], offline: 'replay' },
38
+ exchangeInventoryItems: { method: 'POST', path: '/games/:slug/inventory/exchange', query: [], offline: 'replay' },
39
+ getPlayerInventory: { method: 'GET', path: '/games/:slug/players/:userId/inventory', query: [], offline: null },
40
+ listTradePartners: { method: 'GET', path: '/games/:slug/trades/players', query: [], offline: null },
41
+ listMyTrades: { method: 'GET', path: '/games/:slug/trades', query: ['box', 'status'], offline: null },
42
+ declineTrade: { method: 'POST', path: '/games/:slug/trades/:id/decline', query: [], offline: null },
43
+ cancelTrade: { method: 'POST', path: '/games/:slug/trades/:id/cancel', query: [], offline: null },
44
+ createTrade: { method: 'POST', path: '/games/:slug/trades', query: [], offline: null },
45
+ acceptTrade: { method: 'POST', path: '/games/:slug/trades/:id/accept', query: [], offline: null },
46
+ counterTradeOffer: { method: 'POST', path: '/games/:slug/trades/:id/counter', query: [], offline: null },
47
+ getSiteAnalytics: { method: 'GET', path: '/analytics', query: [], offline: null },
48
+ reportClientError: { method: 'POST', path: '/client-log', query: [], offline: null },
49
+ recordGameEvent: { method: 'POST', path: '/games/:slug/events', query: [], offline: null },
50
+ getGameAnalytics: { method: 'GET', path: '/games/:slug/analytics', query: [], offline: null },
51
+ getSocialConfig: { method: 'GET', path: '/social/config', query: [], offline: null },
52
+ getOnlineCounts: { method: 'GET', path: '/social/counts', query: [], offline: null },
53
+ getMySocialProfile: { method: 'GET', path: '/social/me', query: [], offline: null },
54
+ listFriendsAndRequests: { method: 'GET', path: '/social/friends', query: [], offline: null },
55
+ findPlayerByName: { method: 'GET', path: '/social/players/by-name/:name', query: [], offline: null },
56
+ getPlayerCard: { method: 'GET', path: '/social/players/:id', query: [], offline: null },
57
+ updateMySocialProfile: { method: 'PUT', path: '/social/me', query: [], offline: null },
58
+ setParentChatLock: { method: 'POST', path: '/social/chat-lock', query: [], offline: null },
59
+ sendFriendRequest: { method: 'POST', path: '/social/friends', query: [], offline: null },
60
+ acceptFriendRequest: { method: 'POST', path: '/social/friends/:id/accept', query: [], offline: null },
61
+ declineFriendRequest: { method: 'POST', path: '/social/friends/:id/decline', query: [], offline: null },
62
+ removeFriend: { method: 'DELETE', path: '/social/friends/:id', query: [], offline: null },
63
+ setFriendNotifications: { method: 'PUT', path: '/social/friends/:id/notify', query: [], offline: null },
64
+ blockPlayer: { method: 'POST', path: '/social/blocks', query: [], offline: null },
65
+ unblockPlayer: { method: 'DELETE', path: '/social/blocks/:id', query: [], offline: null },
66
+ listRecentPlayers: { method: 'GET', path: '/social/recent', query: [], offline: null },
67
+ listConversations: { method: 'GET', path: '/social/conversations', query: [], offline: null },
68
+ listDirectMessages: { method: 'GET', path: '/social/messages/:userId', query: ['before'], offline: null },
69
+ sendDirectMessage: { method: 'POST', path: '/social/messages/:userId', query: [], offline: null },
70
+ markDirectMessagesRead: { method: 'POST', path: '/social/messages/:userId/read', query: [], offline: null },
71
+ getNotificationPreferences: { method: 'GET', path: '/social/notify', query: [], offline: null },
72
+ unsubscribeFromPush: { method: 'DELETE', path: '/social/push', query: [], offline: null },
73
+ setNotificationPreferences: { method: 'PUT', path: '/social/notify', query: [], offline: null },
74
+ subscribeToPush: { method: 'POST', path: '/social/push', query: [], offline: null },
75
+ fileReport: { method: 'POST', path: '/social/reports', query: [], offline: null },
76
+ listPublicSuggestions: { method: 'GET', path: '/suggestions', query: ['sort'], offline: null },
77
+ submitSuggestion: { method: 'POST', path: '/suggestions', query: [], offline: null },
78
+ voteForSuggestion: { method: 'POST', path: '/suggestions/:id/vote', query: [], offline: null },
79
+ removeSuggestionVote: { method: 'DELETE', path: '/suggestions/:id/vote', query: [], offline: null },
80
+ withdrawSuggestion: { method: 'DELETE', path: '/suggestions/:id', query: [], offline: null },
81
+ sendGameInvite: { method: 'POST', path: '/mp/invites', query: [], offline: null },
82
+ acceptGameInvite: { method: 'POST', path: '/mp/invites/:id/accept', query: [], offline: null },
83
+ declineGameInvite: { method: 'POST', path: '/mp/invites/:id/decline', query: [], offline: null },
84
+ createInviteLink: { method: 'POST', path: '/mp/links', query: [], offline: null },
85
+ listInviteLinks: { method: 'GET', path: '/mp/links', query: ['game'], offline: null },
86
+ revokeInviteLink: { method: 'DELETE', path: '/mp/links/:token', query: [], offline: null },
87
+ cancelGameInvite: { method: 'DELETE', path: '/mp/invites/:id', query: [], offline: null },
88
+ redeemLaunchToken: { method: 'GET', path: '/mp/launch/:token', query: [], offline: null },
89
+ openInviteLink: { method: 'POST', path: '/mp/links/:token/open', query: [], offline: null },
90
+ setPartyJoinInfo: { method: 'POST', path: '/mp/parties/:id/join-info', query: [], offline: null },
91
+ leaveParty: { method: 'POST', path: '/mp/parties/:id/leave', query: [], offline: null },
92
+ launchIntoFriendsRoom: { method: 'POST', path: '/mp/join', query: [], offline: null },
93
+ getGameServerTicket: { method: 'POST', path: '/mp/ticket', query: [], offline: null },
94
+ listMyRaceHistory: { method: 'GET', path: '/race/history', query: ['game', 'with', 'before', 'limit'], offline: null },
95
+ listTournaments: { method: 'GET', path: '/tournaments', query: ['game'], offline: null },
96
+ getTournament: { method: 'GET', path: '/tournaments/:id', query: ['key'], offline: null },
97
+ createTournament: { method: 'POST', path: '/tournaments', query: [], offline: null },
98
+ inviteToTournament: { method: 'POST', path: '/tournaments/:id/invites', query: [], offline: null },
99
+ followTournament: { method: 'PUT', path: '/tournaments/:id/follow', query: [], offline: null },
100
+ unfollowTournament: { method: 'DELETE', path: '/tournaments/:id/follow', query: [], offline: null },
101
+ startTournament: { method: 'POST', path: '/tournaments/:id/start', query: [], offline: null },
102
+ cancelTournament: { method: 'DELETE', path: '/tournaments/:id', query: [], offline: null },
103
+ joinTournament: { method: 'POST', path: '/tournaments/:id/join', query: [], offline: null },
104
+ leaveTournament: { method: 'POST', path: '/tournaments/:id/leave', query: [], offline: null },
105
+ launchTournamentMatch: { method: 'POST', path: '/tournaments/:id/matches/:match/launch', query: [], offline: null },
106
+ getPlayOnline: { method: 'GET', path: '/play-online', query: [], offline: null },
107
+ getHubVersion: { method: 'GET', path: '/version', query: [], offline: null },
90
108
  } as const;
109
+
110
+ /** The request header that carries an offline operation key (the contract's `OFFLINE_OP_HEADER`). */
111
+ export const OFFLINE_OP_HEADER = 'x-hub-op';
@@ -72,3 +72,9 @@ export function parseJsonText(text: string): unknown {
72
72
  return undefined;
73
73
  }
74
74
  }
75
+
76
+ /** Whether `candidate` is a JSON object (not an array or `null`). */
77
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: checks a value read from storage
78
+ export function isJsonObject(candidate: unknown): candidate is { readonly [key: string]: Json } {
79
+ return isRecord(candidate) && Object.values(candidate).every(isJson);
80
+ }
@@ -1,3 +1,3 @@
1
1
  // Generated by tools/hub-client/generate.ts. Do not edit; run `npm run gen:hub-client` in next/.
2
2
  /** This client's package version, sent as `x-hub-client` on REST calls and `client` in `hello`. */
3
- export const HUB_CLIENT_VERSION = '2.0.0-next.1';
3
+ export const HUB_CLIENT_VERSION = '2.0.0-next.3';
@@ -113,7 +113,9 @@ export class DailySystem {
113
113
  /**
114
114
  * Record completion of the active challenge with optional metadata.
115
115
  * Resolves with the new completion count and rank, or `null` when no
116
- * challenge is active or the hub refused.
116
+ * challenge is active, the hub refused, or the hub can't be reached: then
117
+ * the completion is queued (`hub:daily-queued`) and replays when it is
118
+ * back (`hub:daily-complete` follows).
117
119
  */
118
120
  async complete(meta: DailyCompletionMeta = {}): Promise<DailyCompleteResponse | null> {
119
121
  const challenge = this.active;
@@ -123,6 +125,11 @@ export class DailySystem {
123
125
  try {
124
126
  const completed = await this.host.complete(challenge.day, challenge.token, meta);
125
127
  this.active = null;
128
+ if (completed === null) {
129
+ // Queued offline: `hub:daily-complete` follows when it replays.
130
+ this.host.emit('hub:daily-queued', { day: challenge.day, queued: true, meta });
131
+ return null;
132
+ }
126
133
  this.host.emit('hub:daily-complete', { day: challenge.day, count: completed.count, rank: completed.rank, meta });
127
134
  return completed;
128
135
  } catch (error) {
@@ -54,8 +54,8 @@ export interface DailyHost {
54
54
  all(): Promise<DailyAll>;
55
55
  /** `POST /games/:slug/daily/:day/start`. */
56
56
  start(day: string): Promise<DailyStartResponse>;
57
- /** `POST /games/:slug/daily/:day/complete`. */
58
- complete(day: string, token: string, meta: DailyCompletionMeta): Promise<DailyCompleteResponse>;
57
+ /** `POST /games/:slug/daily/:day/complete`; `null` when it was queued offline, to replay when the hub is back. */
58
+ complete(day: string, token: string, meta: DailyCompletionMeta): Promise<DailyCompleteResponse | null>;
59
59
  /** Fire a `hub:*` window event (the menu listens). */
60
60
  emit(name: string, detail: DailyEventDetail): void;
61
61
  }
@@ -64,4 +64,5 @@ export interface DailyHost {
64
64
  export type DailyEventDetail =
65
65
  | { day: string; seed: number; alreadyCompleted: boolean }
66
66
  | { day: string; count: number; rank: number | null; meta: DailyCompletionMeta }
67
+ | { day: string; queued: true; meta: DailyCompletionMeta }
67
68
  | { day: string; error: string };
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { existsSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
- import { GamePackageJson, type HubVersion } from '@diffenderfer-games/hub-contract';
7
+ import { GAME_LABELS, GamePackageJson, splitGameLabels, type HubVersion } from '@diffenderfer-games/hub-contract';
8
8
  import { clientSupport } from '../core/version/hub-version';
9
9
  import { isHubUnreachable, type HubUnreachable } from './hub-version-fetch';
10
10
 
@@ -68,7 +68,15 @@ export function checkVendoredCopies(gameDir: string): Finding[] {
68
68
  export function checkManifest(packageJson: unknown): Finding[] {
69
69
  const parsed = GamePackageJson.safeParse(packageJson);
70
70
  if (parsed.success) {
71
- return [{ level: 'info', check: 'manifest', message: `the game.* block is valid (${parsed.data.game.type})` }];
71
+ const unknownLabels = splitGameLabels(parsed.data.game.labels ?? []).unknown;
72
+ return [
73
+ { level: 'info', check: 'manifest', message: `the game.* block is valid (${parsed.data.game.type})` },
74
+ ...unknownLabels.map((label): Finding => ({
75
+ level: 'warning',
76
+ check: 'manifest',
77
+ message: `game.labels: "${label}" is not a known label (the catalog ignores it); use any of ${GAME_LABELS.join(', ')}`,
78
+ })),
79
+ ];
72
80
  }
73
81
  return parsed.error.issues.map((issue) => ({
74
82
  level: 'error',
package/src/index.ts CHANGED
@@ -13,7 +13,8 @@
13
13
  * {@link createHub} for others. Every call rejects with a {@link HubError}
14
14
  * when it fails, except the fire-and-forget analytics (`recordPlay`,
15
15
  * `heartbeat`, `event`). When the hub can't be reached, reads come from an
16
- * offline cache and writes are queued until it is back.
16
+ * offline cache and writes are queued in an outbox until it is back; games
17
+ * queue their own operations with `hub.offline` (docs: "Offline and replay").
17
18
  *
18
19
  * The page-wide namespaces hang off `hub` as lazy getters (`hub.input`,
19
20
  * `hub.daily`, `hub.social`, `hub.mp`, `hub.rooms`, `hub.race`), and each has
@@ -36,6 +37,10 @@ export type { AuthResponse, ProfileUpdate, StatResult } from './core/hub-progres
36
37
  export type { GrantBundle, TradeBundle, TradeId, TradeListOptions, TradeNoteOptions } from './core/hub-economy';
37
38
  export type { HubMenuControl } from './core/menu-control';
38
39
  export type { QueueOp, QueueOpInput, QueuedScoreOptions, ProfilePatch } from './core/offline/queue';
40
+ export { OfflineApi } from './core/offline/offline-api';
41
+ export { OUTBOX_FULL_CODE, type OfflineEventDetails, type OfflineEventName, type OfflineListener, type OfflineOp } from './core/offline/outbox';
42
+ export type { OfflineHandler, OfflineKindDefinition, ReplayContext } from './core/offline/outbox-kinds';
43
+ export { OUTBOX_MAX_CHARS, OUTBOX_MAX_ENTRIES } from './core/offline/outbox-store';
39
44
  export type { BufferedEvent } from './core/offline/events';
40
45
  export type { EndpointFields, EndpointName, EndpointReply } from './core/rest/endpoint-types';
41
46
  export { CLIENT_VERSION_HEADER } from './core/rest/rest-client';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The hub's focus ring: a two-tone box-shadow (a dark gap, then a light ring)
3
+ * that follows the element's own border-radius and shows on light and dark
4
+ * buttons alike. Pages tune it with CSS variables:
5
+ * `--hub-focus-color` (the ring), `--hub-focus-inner` (the gap),
6
+ * `--hub-focus-width` and `--hub-focus-gap`.
7
+ */
8
+
9
+ const GAP = 'var(--hub-focus-gap, 2px)';
10
+ const WIDTH = 'var(--hub-focus-width, 3px)';
11
+
12
+ /** The ring as a `box-shadow` value. */
13
+ export const FOCUS_RING_SHADOW = `0 0 0 ${GAP} var(--hub-focus-inner, rgba(13, 15, 20, 0.85)), 0 0 0 calc(${GAP} + ${WIDTH}) var(--hub-focus-color, #fff)`;
@@ -132,13 +132,20 @@ export class InputRegistry {
132
132
  return { value: raw, raw, isDown: this.wentDown(name), isUp: this.wentUp(name) };
133
133
  }
134
134
 
135
- /** A named axis (or an ad-hoc negative/positive pair) as -1..1. */
135
+ /**
136
+ * A named axis (or an ad-hoc negative/positive pair) as -1..1: its `x`, or
137
+ * its `y` when it declares only `y` (a vertical one-axis control, like a
138
+ * paddle).
139
+ */
136
140
  axis(name: string, positive?: string): number {
137
141
  if (positive !== undefined) {
138
142
  return clampUnit(this.value(positive)) - clampUnit(this.value(name));
139
143
  }
140
144
  const axis = this.axes.get(name);
141
- return axis === undefined ? this.value(name) : readAxisSide(axis.x, (input) => this.value(input));
145
+ if (axis === undefined) {
146
+ return this.value(name);
147
+ }
148
+ return readAxisSide(axis.x ?? axis.y, (input) => this.value(input));
142
149
  }
143
150
 
144
151
  /** A named axis pair as a vector clamped to the unit circle. */