@diffenderfer-games/hub 2.0.0-next.0 → 2.0.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +21 -21
  3. package/README.md +206 -139
  4. package/dist/auto.d.ts +6 -6
  5. package/dist/auto.js +2 -2
  6. package/dist/doctor/cli.mjs +62 -25
  7. package/dist/doctor/cli.mjs.map +1 -1
  8. package/dist/index.d.ts +6 -6
  9. package/dist/index.js +2 -2
  10. package/dist/mp.d.ts +3 -3
  11. package/dist/race.d.ts +1 -1
  12. package/dist/rooms.d.ts +3 -3
  13. package/dist/shared/bridge.js +9 -3
  14. package/dist/shared/bridge.js.map +1 -1
  15. package/dist/shared/client-version.gen.js +1 -1
  16. package/dist/shared/daily.d.ts +10 -4
  17. package/dist/shared/daily.d.ts.map +1 -1
  18. package/dist/shared/daily.js +12 -2
  19. package/dist/shared/daily.js.map +1 -1
  20. package/dist/shared/globals.js +8 -3
  21. package/dist/shared/globals.js.map +1 -1
  22. package/dist/shared/index.d.ts +314 -35
  23. package/dist/shared/index.d.ts.map +1 -1
  24. package/dist/shared/input.js +7 -2
  25. package/dist/shared/input.js.map +1 -1
  26. package/dist/shared/latest-state.js +1 -1
  27. package/dist/shared/mp.js +10 -2
  28. package/dist/shared/mp.js.map +1 -1
  29. package/dist/shared/multiplayer-types.d.ts +15 -2
  30. package/dist/shared/multiplayer-types.d.ts.map +1 -1
  31. package/dist/shared/race-types.d.ts +1 -1
  32. package/dist/shared/race.js +1 -1
  33. package/dist/shared/room-types.d.ts +28 -3
  34. package/dist/shared/room-types.d.ts.map +1 -1
  35. package/dist/shared/rooms.d.ts.map +1 -1
  36. package/dist/shared/rooms.js +10 -2
  37. package/dist/shared/rooms.js.map +1 -1
  38. package/dist/shared/runtime-opener.js +1 -1
  39. package/dist/shared/social.d.ts +23 -8
  40. package/dist/shared/social.d.ts.map +1 -1
  41. package/dist/shared/social.js +6 -1
  42. package/dist/shared/social.js.map +1 -1
  43. package/dist/shared/src.js +1588 -388
  44. package/dist/shared/src.js.map +1 -1
  45. package/dist/shared/wire-types.d.ts +6 -2
  46. package/dist/shared/wire-types.d.ts.map +1 -1
  47. package/dist/social.d.ts +5 -5
  48. package/dist/social.js +1 -1
  49. package/package.json +89 -89
  50. package/src/auto.ts +39 -39
  51. package/src/core/connection.ts +394 -299
  52. package/src/core/errors.ts +147 -147
  53. package/src/core/game-data.ts +40 -38
  54. package/src/core/hub-economy.ts +216 -179
  55. package/src/core/hub-progress.ts +202 -205
  56. package/src/core/hub.ts +133 -131
  57. package/src/core/menu-control.ts +61 -61
  58. package/src/core/offline/events.ts +50 -50
  59. package/src/core/offline/favorites.ts +21 -21
  60. package/src/core/offline/hub-kinds/daily-kind.ts +68 -0
  61. package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
  62. package/src/core/offline/hub-kinds/game-fields.ts +2 -0
  63. package/src/core/offline/hub-kinds/index.ts +35 -0
  64. package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
  65. package/src/core/offline/hub-kinds/progress-kinds.ts +148 -0
  66. package/src/core/offline/kind-adapters.ts +48 -0
  67. package/src/core/offline/local-inventory.ts +64 -0
  68. package/src/core/offline/local-store.ts +259 -261
  69. package/src/core/offline/offline-api.ts +65 -0
  70. package/src/core/offline/outbox-entry.ts +59 -0
  71. package/src/core/offline/outbox-kinds.ts +78 -0
  72. package/src/core/offline/outbox-merge.ts +39 -0
  73. package/src/core/offline/outbox-replayer.ts +141 -0
  74. package/src/core/offline/outbox-store.ts +85 -0
  75. package/src/core/offline/outbox.ts +232 -0
  76. package/src/core/offline/queue.ts +126 -171
  77. package/src/core/offline/replay-lease.ts +87 -0
  78. package/src/core/offline/replay-policy.ts +52 -0
  79. package/src/core/page.ts +74 -74
  80. package/src/core/rest/endpoint-types.ts +22 -22
  81. package/src/core/rest/rest-client.ts +163 -152
  82. package/src/core/rest/routes.gen.ts +108 -87
  83. package/src/core/storage/json-guards.ts +80 -74
  84. package/src/core/storage/key-value.ts +66 -66
  85. package/src/core/ticket.ts +43 -43
  86. package/src/core/types.ts +134 -134
  87. package/src/core/version/client-version.gen.ts +1 -1
  88. package/src/core/version/hub-version.ts +62 -62
  89. package/src/core/version/semver.ts +97 -97
  90. package/src/daily/daily-system.ts +168 -161
  91. package/src/daily/page-daily.ts +36 -36
  92. package/src/daily/types.ts +68 -67
  93. package/src/daily.ts +10 -10
  94. package/src/doctor/checks.ts +86 -76
  95. package/src/doctor/cli.ts +98 -106
  96. package/src/doctor/hub-version-fetch.ts +43 -0
  97. package/src/doctor/tsconfig.json +12 -12
  98. package/src/globals.ts +115 -110
  99. package/src/index.ts +55 -50
  100. package/src/input/device-state.ts +146 -146
  101. package/src/input/edges.ts +55 -55
  102. package/src/input/gamepad.ts +61 -61
  103. package/src/input/input-events.ts +32 -32
  104. package/src/input/input-registry.ts +171 -164
  105. package/src/input/input-system.ts +393 -393
  106. package/src/input/legacy-controls.ts +64 -64
  107. package/src/input/nav-state.ts +191 -191
  108. package/src/input/navigator.ts +171 -171
  109. package/src/input/on-screen-controls.ts +101 -101
  110. package/src/input/page-control.ts +43 -43
  111. package/src/input/page-input.ts +193 -193
  112. package/src/input/player-remaps.ts +77 -77
  113. package/src/input/source-values.ts +116 -116
  114. package/src/input/spatial.ts +47 -47
  115. package/src/input/types.ts +331 -331
  116. package/src/input/zero-config.ts +177 -177
  117. package/src/input.ts +22 -22
  118. package/src/mp.ts +10 -10
  119. package/src/overlay/controls-panel.ts +163 -163
  120. package/src/overlay/dom.ts +46 -46
  121. package/src/overlay/layout-clusters.ts +162 -162
  122. package/src/overlay/layout-geometry.ts +136 -136
  123. package/src/overlay/layout.ts +203 -203
  124. package/src/overlay/listener-scope.ts +23 -23
  125. package/src/overlay/on-screen-keyboard.ts +137 -137
  126. package/src/overlay/overlay.ts +208 -208
  127. package/src/overlay/styles.ts +9 -9
  128. package/src/overlay/virtual-controls.ts +262 -262
  129. package/src/race.ts +10 -10
  130. package/src/realtime/bridge.ts +126 -121
  131. package/src/realtime/cross-bundle.ts +29 -29
  132. package/src/realtime/fallback-notice.ts +48 -48
  133. package/src/realtime/hub-link.ts +65 -65
  134. package/src/realtime/latest-state.ts +33 -33
  135. package/src/realtime/multiplayer-forwarder.ts +40 -28
  136. package/src/realtime/multiplayer-types.ts +66 -52
  137. package/src/realtime/page-runtime.ts +60 -60
  138. package/src/realtime/race-forwarder.ts +59 -59
  139. package/src/realtime/race-types.ts +132 -132
  140. package/src/realtime/room-types.ts +151 -125
  141. package/src/realtime/rooms-forwarder.ts +91 -79
  142. package/src/realtime/runtime-api.ts +60 -49
  143. package/src/realtime/runtime-opener.ts +61 -61
  144. package/src/realtime/social-forwarder.ts +77 -72
  145. package/src/realtime/social-types.ts +86 -78
  146. package/src/realtime/types.ts +53 -53
  147. package/src/realtime/wire-types.ts +79 -72
  148. package/src/rooms.ts +10 -10
  149. package/src/social.ts +18 -18
  150. package/src/types.ts +13 -13
  151. package/src/uistack/page-stack.ts +45 -45
  152. package/src/uistack/play-suspension.ts +82 -82
  153. package/src/uistack/types.ts +76 -76
  154. package/src/uistack/ui-stack.ts +144 -144
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The hub's economy and catalog kinds: inventory grants and house
3
+ * exchanges (each one replayable call, never folded: every grant counts),
4
+ * and starring a game (a game's last star or unstar wins).
5
+ */
6
+ import type { Json } from '@diffenderfer-games/hub-contract';
7
+ import { isRecord, optionalNumber, optionalString } from '../../storage/json-guards';
8
+ import { checkedKind } from '../kind-adapters';
9
+ import type { GoodsPayload } from '../local-inventory';
10
+ import type { RegisteredKind } from '../outbox-kinds';
11
+ import type { GameFieldsSource } from './game-fields';
12
+
13
+ /** A house exchange: what the player gives up and gets. */
14
+ export type ExchangePayload = { readonly take?: GoodsPayload; readonly give?: GoodsPayload };
15
+
16
+ /** A star or unstar. */
17
+ export type FavoritePayload = { readonly slug: string; readonly on: boolean };
18
+
19
+ /** The kind names. */
20
+ export const ECONOMY_KINDS = { grant: 'hub.grant', exchange: 'hub.exchange', favorite: 'hub.favorite' } as const;
21
+
22
+ /** Goods read back, or `undefined` when malformed. */
23
+ export function readGoods(stored: Json | undefined): GoodsPayload | undefined {
24
+ if (!isRecord(stored)) {
25
+ return undefined;
26
+ }
27
+ const coins = stored.coins === undefined ? undefined : optionalNumber(stored.coins);
28
+ if (stored.coins !== undefined && coins === undefined) {
29
+ return undefined;
30
+ }
31
+ const items: Record<string, number> = {};
32
+ if (stored.items !== undefined) {
33
+ if (!isRecord(stored.items)) {
34
+ return undefined;
35
+ }
36
+ for (const [key, qty] of Object.entries(stored.items)) {
37
+ const count = optionalNumber(qty);
38
+ if (count === undefined) {
39
+ return undefined;
40
+ }
41
+ items[key] = count;
42
+ }
43
+ }
44
+ return { ...(stored.items === undefined ? {} : { items }), ...(coins === undefined ? {} : { coins }) };
45
+ }
46
+
47
+ function readExchange(stored: Json): ExchangePayload | undefined {
48
+ if (!isRecord(stored)) {
49
+ return undefined;
50
+ }
51
+ const take = stored.take === undefined ? undefined : readGoods(stored.take);
52
+ const give = stored.give === undefined ? undefined : readGoods(stored.give);
53
+ if ((stored.take !== undefined && take === undefined) || (stored.give !== undefined && give === undefined)) {
54
+ return undefined;
55
+ }
56
+ return { ...(take === undefined ? {} : { take }), ...(give === undefined ? {} : { give }) };
57
+ }
58
+
59
+ function readFavorite(stored: Json): FavoritePayload | undefined {
60
+ if (!isRecord(stored) || typeof stored.on !== 'boolean') {
61
+ return undefined;
62
+ }
63
+ const slug = optionalString(stored.slug);
64
+ return slug === undefined ? undefined : { slug, on: stored.on };
65
+ }
66
+
67
+ /** The economy kinds, by name; `onFavorites` hears the starred list each star answers with. */
68
+ export function economyKinds(game: GameFieldsSource, onFavorites: (games: string[]) => void): Record<string, RegisteredKind> {
69
+ return {
70
+ [ECONOMY_KINDS.grant]: checkedKind(readGoods, {
71
+ run: (goods, context) => context.call('grantInventoryItems', { ...game(), ...goods }),
72
+ }),
73
+ [ECONOMY_KINDS.exchange]: checkedKind(readExchange, {
74
+ run: (exchange, context) => context.call('exchangeInventoryItems', { ...game(), ...exchange }),
75
+ }),
76
+ [ECONOMY_KINDS.favorite]: checkedKind(readFavorite, {
77
+ mergeKey: (favorite) => favorite.slug,
78
+ replaces: true,
79
+ run: async (favorite, context) => {
80
+ const reply = await context.call('setGameFavorite', { slug: favorite.slug, on: favorite.on });
81
+ onFavorites(reply.games);
82
+ return reply;
83
+ },
84
+ }),
85
+ };
86
+ }
@@ -0,0 +1,2 @@
1
+ /** The `{ slug }` path field the hub kinds' calls need, read when they run (it throws when the slug is unknown). */
2
+ export type GameFieldsSource = () => { readonly slug: string };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Every kind the hub itself queues offline (`hub.*`), registered on a
3
+ * client's outbox. The docs' "Offline and replay" table lists them.
4
+ */
5
+ import { writeLocalFavorites } from '../favorites';
6
+ import type { Outbox } from '../outbox';
7
+ import { dailyKinds, type DailyCompletion, type DailyPayload } from './daily-kind';
8
+ import { economyKinds } from './economy-kinds';
9
+ import type { GameFieldsSource } from './game-fields';
10
+ import { progressKinds } from './progress-kinds';
11
+
12
+ export { DAILY_KIND, type DailyPayload } from './daily-kind';
13
+ export { ECONOMY_KINDS, type ExchangePayload, type FavoritePayload } from './economy-kinds';
14
+ export { legacyOperation } from './legacy-ops';
15
+ export { PROGRESS_KINDS, type ProfilePayload, type SavePayload, type ScorePayload, type StatPayload } from './progress-kinds';
16
+
17
+ /** What the hub kinds report back to their client. */
18
+ export interface HubKindHooks {
19
+ /** This game's `{ slug }`. */
20
+ readonly game: GameFieldsSource;
21
+ /** A daily completion landed (the menu's calendar listens for `hub:daily-complete`). */
22
+ readonly onDailyComplete: (payload: DailyPayload, completion: DailyCompletion) => void;
23
+ }
24
+
25
+ /** Register every hub kind on `outbox`. */
26
+ export function registerHubKinds(outbox: Outbox, hooks: HubKindHooks): void {
27
+ const kinds = {
28
+ ...progressKinds(hooks.game),
29
+ ...dailyKinds(hooks.game, hooks.onDailyComplete),
30
+ ...economyKinds(hooks.game, writeLocalFavorites),
31
+ };
32
+ for (const [kind, definition] of Object.entries(kinds)) {
33
+ outbox.define(kind, definition);
34
+ }
35
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The v1 client's queued writes (`hub:<slug>:queue`) as outbox operations,
3
+ * so a page upgraded from a vendored copy still replays what that copy queued.
4
+ */
5
+ import type { Json } from '@diffenderfer-games/hub-contract';
6
+ import type { QueueOp } from '../queue';
7
+ import { PROGRESS_KINDS, readProfile, readScoreOptions } from './progress-kinds';
8
+
9
+ /** One outbox operation, before it is stamped. */
10
+ export interface KindedPayload {
11
+ readonly kind: string;
12
+ readonly payload: Json;
13
+ }
14
+
15
+ /** The outbox operation for a v1 queued write, or `undefined` when it no longer reads as one. */
16
+ export function legacyOperation(op: QueueOp): KindedPayload | undefined {
17
+ switch (op.kind) {
18
+ case 'putSave':
19
+ return { kind: PROGRESS_KINDS.save, payload: { slot: op.slot, data: op.data, label: op.label } };
20
+ case 'deleteSave':
21
+ return { kind: PROGRESS_KINDS.save, payload: { slot: op.slot, deleted: true } };
22
+ case 'setStat':
23
+ return { kind: PROGRESS_KINDS.stat, payload: { key: op.key, mode: 'set', value: op.value } };
24
+ case 'maxStat':
25
+ return { kind: PROGRESS_KINDS.stat, payload: { key: op.key, mode: 'max', value: op.value } };
26
+ case 'incrStat':
27
+ return { kind: PROGRESS_KINDS.stat, payload: { key: op.key, mode: 'incr', value: op.amount } };
28
+ case 'submitScore': {
29
+ const opts = readScoreOptions({ ...op.opts });
30
+ return opts === undefined ? undefined : { kind: PROGRESS_KINDS.score, payload: { key: op.key, score: op.score, opts } };
31
+ }
32
+ case 'setProfile': {
33
+ const patch = readProfile({ ...op.patch });
34
+ return patch === undefined ? undefined : { kind: PROGRESS_KINDS.profile, payload: patch };
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The hub's progress kinds: saves, stats, scores and the game profile. Each
3
+ * is one replayable call, and each folds new writes into waiting ones so the
4
+ * backlog stays small: a save slot's last write wins, `incr`s add up, `max`es
5
+ * keep the largest, `set`s keep the last, a board keeps its best, and profile
6
+ * patches merge.
7
+ */
8
+ import type { Json, SortDir } from '@diffenderfer-games/hub-contract';
9
+ import { isJson, isNullableString, isRecord, optionalNumber, optionalString } from '../../storage/json-guards';
10
+ import { checkedKind } from '../kind-adapters';
11
+ import type { RegisteredKind } from '../outbox-kinds';
12
+ import { betterScore } from '../queue';
13
+ import type { GameFieldsSource } from './game-fields';
14
+
15
+ /** A save slot write: new contents, or a delete. */
16
+ export type SavePayload =
17
+ | { readonly slot: string; readonly data: Json; readonly label: string | null }
18
+ | { readonly slot: string; readonly deleted: true };
19
+
20
+ /** How a stat update combines with the stored value. */
21
+ export type StatMode = 'set' | 'incr' | 'max';
22
+
23
+ /** One stat update. */
24
+ export type StatPayload = { readonly key: string; readonly mode: StatMode; readonly value: number };
25
+
26
+ /** A score's board options, as queued. */
27
+ export type ScoreOptionsPayload = { readonly title?: string; readonly sort?: SortDir; readonly meta?: Json };
28
+
29
+ /** One score submission. */
30
+ export type ScorePayload = { readonly key: string; readonly score: number; readonly opts: ScoreOptionsPayload };
31
+
32
+ /** A profile change; either part may be left out. */
33
+ export type ProfilePayload = { readonly displayName?: string | null; readonly data?: Json };
34
+
35
+ /** The kind names. */
36
+ export const PROGRESS_KINDS = { save: 'hub.save', stat: 'hub.stat', score: 'hub.score', profile: 'hub.profile' } as const;
37
+
38
+ function readSave(stored: Json): SavePayload | undefined {
39
+ if (!isRecord(stored)) {
40
+ return undefined;
41
+ }
42
+ const slot = optionalString(stored.slot);
43
+ if (slot === undefined) {
44
+ return undefined;
45
+ }
46
+ if (stored.deleted === true) {
47
+ return { slot, deleted: true };
48
+ }
49
+ return isJson(stored.data) && isNullableString(stored.label) ? { slot, data: stored.data, label: stored.label } : undefined;
50
+ }
51
+
52
+ function readStat(stored: Json): StatPayload | undefined {
53
+ if (!isRecord(stored)) {
54
+ return undefined;
55
+ }
56
+ const key = optionalString(stored.key);
57
+ const value = optionalNumber(stored.value);
58
+ const mode = stored.mode === 'set' || stored.mode === 'incr' || stored.mode === 'max' ? stored.mode : undefined;
59
+ return key === undefined || value === undefined || mode === undefined ? undefined : { key, mode, value };
60
+ }
61
+
62
+ /** Score options read back, or `undefined` when malformed. */
63
+ export function readScoreOptions(stored: Json): ScoreOptionsPayload | undefined {
64
+ if (!isRecord(stored)) {
65
+ return undefined;
66
+ }
67
+ const title = optionalString(stored.title);
68
+ const sort = stored.sort === 'asc' || stored.sort === 'desc' ? stored.sort : undefined;
69
+ if (stored.meta !== undefined && !isJson(stored.meta)) {
70
+ return undefined;
71
+ }
72
+ return { ...(title === undefined ? {} : { title }), ...(sort === undefined ? {} : { sort }), ...(stored.meta === undefined ? {} : { meta: stored.meta }) };
73
+ }
74
+
75
+ function readScore(stored: Json): ScorePayload | undefined {
76
+ if (!isRecord(stored) || !isJson(stored.opts)) {
77
+ return undefined;
78
+ }
79
+ const key = optionalString(stored.key);
80
+ const score = optionalNumber(stored.score);
81
+ const opts = readScoreOptions(stored.opts);
82
+ return key === undefined || score === undefined || opts === undefined ? undefined : { key, score, opts };
83
+ }
84
+
85
+ /** A profile patch read back, or `undefined` when malformed. */
86
+ export function readProfile(stored: Json): ProfilePayload | undefined {
87
+ if (!isRecord(stored)) {
88
+ return undefined;
89
+ }
90
+ const { displayName, data: profileData } = stored;
91
+ if ((displayName !== undefined && !isNullableString(displayName)) || (profileData !== undefined && !isJson(profileData))) {
92
+ return undefined;
93
+ }
94
+ return { ...(displayName === undefined ? {} : { displayName }), ...(profileData === undefined ? {} : { data: profileData }) };
95
+ }
96
+
97
+ /** Two waiting stat updates of one key folded together, or `undefined` when their modes differ. */
98
+ export function mergeStats(waiting: StatPayload, incoming: StatPayload): StatPayload | undefined {
99
+ if (waiting.mode !== incoming.mode) {
100
+ return undefined;
101
+ }
102
+ switch (incoming.mode) {
103
+ case 'incr':
104
+ return { ...waiting, value: waiting.value + incoming.value };
105
+ case 'max':
106
+ return { ...waiting, value: Math.max(waiting.value, incoming.value) };
107
+ case 'set':
108
+ return incoming;
109
+ }
110
+ }
111
+
112
+ /** The progress kinds, by name. */
113
+ export function progressKinds(game: GameFieldsSource): Record<string, RegisteredKind> {
114
+ return {
115
+ [PROGRESS_KINDS.save]: checkedKind(readSave, {
116
+ mergeKey: (save) => save.slot,
117
+ replaces: true,
118
+ run: async (save, context) => {
119
+ if ('deleted' in save) {
120
+ await context.call('deleteSave', { ...game(), slot: save.slot });
121
+ } else {
122
+ await context.call('writeSave', { ...game(), slot: save.slot, data: save.data, label: save.label });
123
+ }
124
+ },
125
+ }),
126
+ [PROGRESS_KINDS.stat]: checkedKind(readStat, {
127
+ mergeKey: (stat) => stat.key,
128
+ merge: mergeStats,
129
+ run: async (stat, context) => {
130
+ await context.call('updateGameStat', { ...game(), key: stat.key, value: stat.value, mode: stat.mode });
131
+ },
132
+ }),
133
+ [PROGRESS_KINDS.score]: checkedKind(readScore, {
134
+ mergeKey: (submission) => submission.key,
135
+ merge: (waiting, incoming) => ({ ...waiting, score: betterScore(waiting.score, incoming.score, incoming.opts.sort), opts: incoming.opts }),
136
+ run: async (submission, context) => {
137
+ await context.call('submitScore', { ...game(), key: submission.key, score: submission.score, ...submission.opts });
138
+ },
139
+ }),
140
+ [PROGRESS_KINDS.profile]: checkedKind(readProfile, {
141
+ mergeKey: () => 'profile',
142
+ merge: (waiting, incoming) => ({ ...waiting, ...incoming }),
143
+ run: async (patch, context) => {
144
+ await context.call('setGameProfile', { ...game(), ...patch });
145
+ },
146
+ }),
147
+ };
148
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Turning a typed kind definition into the JSON-in, JSON-out form the outbox
3
+ * runs: the hub's kinds check their stored payloads with a reader; a game's
4
+ * kinds vouch for their own payload type, as with saves.
5
+ */
6
+ import type { Json } from '@diffenderfer-games/hub-contract';
7
+ import { toGameData, toStoredJson, type GamePayload } from '../game-data';
8
+ import type { OfflineHandler, OfflineKindDefinition, RegisteredKind } from './outbox-kinds';
9
+
10
+ /** Reads a stored payload back, or `undefined` when it is malformed. */
11
+ export type PayloadReader<Payload> = (stored: Json) => Payload | undefined;
12
+
13
+ /** A kind whose payloads are checked by `read` (the hub's own kinds). */
14
+ export function checkedKind<Payload extends Json>(read: PayloadReader<Payload>, definition: OfflineKindDefinition<Payload>): RegisteredKind {
15
+ const readOrThrow = (stored: Json): Payload => {
16
+ const payload = read(stored);
17
+ if (payload === undefined) {
18
+ throw new Error('Malformed offline operation payload.');
19
+ }
20
+ return payload;
21
+ };
22
+ const { mergeKey, merge } = definition;
23
+ return {
24
+ run: (stored, context) => definition.run(readOrThrow(stored), context),
25
+ accepts: (stored) => read(stored) !== undefined,
26
+ ...(mergeKey === undefined ? {} : { mergeKey: (stored: Json) => mergeKey(readOrThrow(stored)) }),
27
+ ...(merge === undefined ? {} : { merge: (waiting: Json, incoming: Json) => merge(readOrThrow(waiting), readOrThrow(incoming)) }),
28
+ replaces: definition.replaces === true,
29
+ };
30
+ }
31
+
32
+ /** A game's kind: a bare handler or a full definition, its payload typed by the game. */
33
+ export function gameKind<Payload extends GamePayload>(definition: OfflineHandler<Payload> | OfflineKindDefinition<Payload>): RegisteredKind {
34
+ const full: OfflineKindDefinition<Payload> = typeof definition === 'function' ? { run: definition } : definition;
35
+ const { mergeKey, merge } = full;
36
+ return {
37
+ run: (stored, context) => full.run(toGameData<Payload>(stored), context),
38
+ accepts: () => true,
39
+ ...(mergeKey === undefined ? {} : { mergeKey: (stored: Json) => mergeKey(toGameData<Payload>(stored)) }),
40
+ ...(merge === undefined ? {} : {
41
+ merge: (waiting: Json, incoming: Json) => {
42
+ const merged = merge(toGameData<Payload>(waiting), toGameData<Payload>(incoming));
43
+ return merged === undefined ? undefined : toStoredJson(merged);
44
+ },
45
+ }),
46
+ replaces: full.replaces === true,
47
+ };
48
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The cached inventory, changed the way the hub would change it, so grants
3
+ * and house exchanges made offline show at once. The hub has the last word
4
+ * when they replay.
5
+ */
6
+ import type { Inventory } from '@diffenderfer-games/hub-contract';
7
+ import { isRecord, optionalNumber, optionalString } from '../storage/json-guards';
8
+
9
+ /** Goods moved by a grant or an exchange: item key to quantity, and coins. */
10
+ export type GoodsPayload = { readonly items?: { readonly [key: string]: number }; readonly coins?: number };
11
+
12
+ /** An empty inventory. */
13
+ export const EMPTY_INVENTORY: Inventory = { items: [], coins: 0 };
14
+
15
+ /** A cached inventory, or `null` when there is none or it is malformed. */
16
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: an inventory read back from localStorage
17
+ export function readInventory(stored: unknown): Inventory | null {
18
+ if (!isRecord(stored) || !Array.isArray(stored.items)) {
19
+ return null;
20
+ }
21
+ const coins = optionalNumber(stored.coins);
22
+ const items = stored.items.flatMap((item): Inventory['items'] => {
23
+ const key = isRecord(item) ? optionalString(item.key) : undefined;
24
+ const qty = isRecord(item) ? optionalNumber(item.qty) : undefined;
25
+ return key === undefined || qty === undefined ? [] : [{ key, qty }];
26
+ });
27
+ return coins === undefined ? null : { items, coins };
28
+ }
29
+
30
+ /** The holdings as a key-to-quantity map. */
31
+ function quantities(inventory: Inventory): Map<string, number> {
32
+ return new Map(inventory.items.map((item) => [item.key, item.qty]));
33
+ }
34
+
35
+ /** The holdings from a map (positive counts only, sorted by key). */
36
+ function fromQuantities(held: ReadonlyMap<string, number>, coins: number): Inventory {
37
+ const items = [...held].filter(([, qty]) => qty > 0).sort(([first], [second]) => first.localeCompare(second)).map(([key, qty]) => ({ key, qty }));
38
+ return { items, coins };
39
+ }
40
+
41
+ /** `inventory` with `goods` added. */
42
+ export function withGoods(inventory: Inventory, goods: GoodsPayload): Inventory {
43
+ const held = quantities(inventory);
44
+ for (const [key, qty] of Object.entries(goods.items ?? {})) {
45
+ held.set(key, (held.get(key) ?? 0) + qty);
46
+ }
47
+ return fromQuantities(held, inventory.coins + (goods.coins ?? 0));
48
+ }
49
+
50
+ /** `inventory` with `goods` removed, or the hub's refusal message when it can't cover them. */
51
+ export function withoutGoods(inventory: Inventory, goods: GoodsPayload): Inventory | string {
52
+ if ((goods.coins ?? 0) > inventory.coins) {
53
+ return "You don't have enough coins.";
54
+ }
55
+ const held = quantities(inventory);
56
+ for (const [key, qty] of Object.entries(goods.items ?? {})) {
57
+ const have = held.get(key) ?? 0;
58
+ if (qty > have) {
59
+ return `You don't have ${String(qty)}× "${key}".`;
60
+ }
61
+ held.set(key, have - qty);
62
+ }
63
+ return fromQuantities(held, inventory.coins - (goods.coins ?? 0));
64
+ }