@dreamboard-games/ui-sdk 0.0.42 → 0.0.45

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 (166) hide show
  1. package/dist/components/ActionButton.d.ts.map +1 -1
  2. package/dist/components/ActionButton.js +2 -1
  3. package/dist/components/Card.d.ts +1 -1
  4. package/dist/components/Card.d.ts.map +1 -1
  5. package/dist/components/DiceRoller.d.ts +3 -2
  6. package/dist/components/DiceRoller.d.ts.map +1 -1
  7. package/dist/components/DiceRoller.js +4 -13
  8. package/dist/components/ErrorBoundary.d.ts.map +1 -1
  9. package/dist/components/ErrorBoundary.js +94 -2
  10. package/dist/components/InteractionForm.d.ts +1 -1
  11. package/dist/components/InteractionForm.d.ts.map +1 -1
  12. package/dist/components/InteractionForm.js +29 -15
  13. package/dist/components/PrimaryActionButton.d.ts.map +1 -1
  14. package/dist/components/PrimaryActionButton.js +7 -6
  15. package/dist/components/ResourceCounter.d.ts +59 -25
  16. package/dist/components/ResourceCounter.d.ts.map +1 -1
  17. package/dist/components/ResourceCounter.js +106 -115
  18. package/dist/components/Toast.d.ts +13 -6
  19. package/dist/components/Toast.d.ts.map +1 -1
  20. package/dist/components/Toast.js +10 -5
  21. package/dist/components/board/HexGrid.js +6 -6
  22. package/dist/components/board/target-layer.d.ts +18 -2
  23. package/dist/components/board/target-layer.d.ts.map +1 -1
  24. package/dist/components/board/target-layer.js +20 -3
  25. package/dist/components/index.d.ts +3 -4
  26. package/dist/components/index.d.ts.map +1 -1
  27. package/dist/components/index.js +3 -4
  28. package/dist/components/surfaces/InboxSurface.d.ts.map +1 -1
  29. package/dist/components/surfaces/InboxSurface.js +2 -6
  30. package/dist/components/surfaces/PlayerCardsSurface.js +2 -2
  31. package/dist/components/surfaces/internal/CardZoneRoutedForm.d.ts +7 -0
  32. package/dist/components/surfaces/internal/CardZoneRoutedForm.d.ts.map +1 -0
  33. package/dist/components/surfaces/internal/CardZoneRoutedForm.js +9 -0
  34. package/dist/components/surfaces/internal/DefaultInteractionButton.d.ts.map +1 -1
  35. package/dist/components/surfaces/internal/DefaultInteractionButton.js +5 -8
  36. package/dist/components/surfaces/internal/useCardZoneInteractions.d.ts +2 -2
  37. package/dist/components/surfaces/internal/useCardZoneInteractions.d.ts.map +1 -1
  38. package/dist/components/surfaces/internal/useCardZoneInteractions.js +19 -43
  39. package/dist/context/InteractionDraftContext.d.ts +11 -2
  40. package/dist/context/InteractionDraftContext.d.ts.map +1 -1
  41. package/dist/context/InteractionDraftContext.js +41 -4
  42. package/dist/defaults/components.d.ts +0 -5
  43. package/dist/defaults/components.d.ts.map +1 -1
  44. package/dist/defaults/components.js +7 -11
  45. package/dist/hooks/useBoardInteractions.d.ts +35 -12
  46. package/dist/hooks/useBoardInteractions.d.ts.map +1 -1
  47. package/dist/hooks/useBoardInteractions.js +186 -82
  48. package/dist/hooks/useInteractionHandle.d.ts +1 -1
  49. package/dist/hooks/useInteractionHandle.d.ts.map +1 -1
  50. package/dist/hooks/useInteractionHandle.js +12 -27
  51. package/dist/index.d.ts +11 -17
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +5 -14
  54. package/dist/primitives/board.d.ts +53 -3
  55. package/dist/primitives/board.d.ts.map +1 -1
  56. package/dist/primitives/board.js +65 -41
  57. package/dist/primitives/dialog-lifecycle.d.ts +17 -0
  58. package/dist/primitives/dialog-lifecycle.d.ts.map +1 -0
  59. package/dist/primitives/dialog-lifecycle.js +24 -0
  60. package/dist/primitives/dice.d.ts +31 -0
  61. package/dist/primitives/dice.d.ts.map +1 -0
  62. package/dist/primitives/dice.js +33 -0
  63. package/dist/primitives/game.d.ts +55 -0
  64. package/dist/primitives/game.d.ts.map +1 -0
  65. package/dist/primitives/game.js +101 -0
  66. package/dist/primitives/index.d.ts +7 -4
  67. package/dist/primitives/index.d.ts.map +1 -1
  68. package/dist/primitives/index.js +7 -4
  69. package/dist/primitives/interaction-form-binding.d.ts +12 -0
  70. package/dist/primitives/interaction-form-binding.d.ts.map +1 -0
  71. package/dist/primitives/interaction-form-binding.js +14 -0
  72. package/dist/primitives/interaction-submit.d.ts +23 -0
  73. package/dist/primitives/interaction-submit.d.ts.map +1 -0
  74. package/dist/primitives/interaction-submit.js +41 -0
  75. package/dist/primitives/interaction.d.ts +76 -6
  76. package/dist/primitives/interaction.d.ts.map +1 -1
  77. package/dist/primitives/interaction.js +210 -26
  78. package/dist/primitives/player-roster.d.ts +2 -1
  79. package/dist/primitives/player-roster.d.ts.map +1 -1
  80. package/dist/primitives/prompt.d.ts +36 -11
  81. package/dist/primitives/prompt.d.ts.map +1 -1
  82. package/dist/primitives/prompt.js +29 -17
  83. package/dist/primitives/ui.d.ts +9 -0
  84. package/dist/primitives/ui.d.ts.map +1 -0
  85. package/dist/primitives/ui.js +7 -0
  86. package/dist/primitives/zone.d.ts +111 -5
  87. package/dist/primitives/zone.d.ts.map +1 -1
  88. package/dist/primitives/zone.js +349 -9
  89. package/dist/reducer.d.ts +2 -14
  90. package/dist/reducer.d.ts.map +1 -1
  91. package/dist/reducer.js +1 -14
  92. package/dist/runtime/createPluginRuntimeAPI.js +1 -1
  93. package/dist/types/hex-color.d.ts +7 -0
  94. package/dist/types/hex-color.d.ts.map +1 -0
  95. package/dist/types/hex-color.js +13 -0
  96. package/dist/types/player-state.d.ts +28 -14
  97. package/dist/types/player-state.d.ts.map +1 -1
  98. package/dist/types/plugin-state.d.ts +9 -3
  99. package/dist/types/plugin-state.d.ts.map +1 -1
  100. package/dist/ui-contract.d.ts +119 -14
  101. package/dist/ui-contract.d.ts.map +1 -1
  102. package/dist/ui-contract.js +4 -3
  103. package/dist/ui-sdk.d.ts +1637 -1245
  104. package/dist/utils/interaction-inputs.d.ts +8 -5
  105. package/dist/utils/interaction-inputs.d.ts.map +1 -1
  106. package/dist/utils/interaction-inputs.js +82 -14
  107. package/dist/utils/interaction-router.d.ts +31 -0
  108. package/dist/utils/interaction-router.d.ts.map +1 -0
  109. package/dist/utils/interaction-router.js +114 -0
  110. package/package.json +2 -2
  111. package/src/components/ActionButton.tsx +2 -1
  112. package/src/components/Card.tsx +1 -1
  113. package/src/components/DiceRoller.tsx +13 -22
  114. package/src/components/ErrorBoundary.test.tsx +19 -0
  115. package/src/components/ErrorBoundary.tsx +113 -24
  116. package/src/components/InteractionForm.test.tsx +24 -0
  117. package/src/components/InteractionForm.tsx +48 -23
  118. package/src/components/PrimaryActionButton.tsx +19 -5
  119. package/src/components/ResourceCounter.test.tsx +13 -13
  120. package/src/components/ResourceCounter.tsx +238 -244
  121. package/src/components/Toast.tsx +23 -10
  122. package/src/components/__fixtures__/ResourceCounter.fixture.tsx +70 -169
  123. package/src/components/board/HexGrid.tsx +6 -6
  124. package/src/components/board/target-layer.ts +44 -5
  125. package/src/components/index.ts +17 -10
  126. package/src/components/surfaces/InboxSurface.tsx +7 -5
  127. package/src/components/surfaces/PlayerCardsSurface.tsx +6 -6
  128. package/src/components/surfaces/internal/CardZoneRoutedForm.tsx +35 -0
  129. package/src/components/surfaces/internal/DefaultInteractionButton.tsx +17 -7
  130. package/src/components/surfaces/internal/useCardZoneInteractions.ts +25 -67
  131. package/src/context/InteractionDraftContext.tsx +51 -5
  132. package/src/defaults/components.tsx +12 -50
  133. package/src/defaults/defaults.test.tsx +1 -50
  134. package/src/hooks/useBoardInteractions.test.tsx +240 -17
  135. package/src/hooks/useBoardInteractions.ts +330 -105
  136. package/src/hooks/useInteractionHandle.ts +23 -28
  137. package/src/index.test.ts +60 -40
  138. package/src/index.ts +30 -36
  139. package/src/primitives/board.test.tsx +73 -0
  140. package/src/primitives/board.tsx +191 -40
  141. package/src/primitives/dialog-lifecycle.ts +58 -0
  142. package/src/primitives/dice.test.tsx +47 -0
  143. package/src/primitives/dice.tsx +79 -0
  144. package/src/primitives/game.test.tsx +98 -0
  145. package/src/primitives/game.tsx +213 -0
  146. package/src/primitives/index.ts +84 -0
  147. package/src/primitives/interaction-form-binding.tsx +56 -0
  148. package/src/primitives/interaction-submit.ts +90 -0
  149. package/src/primitives/interaction.test.tsx +396 -0
  150. package/src/primitives/interaction.tsx +451 -31
  151. package/src/primitives/player-roster.tsx +2 -1
  152. package/src/primitives/prompt.test.tsx +94 -3
  153. package/src/primitives/prompt.tsx +87 -48
  154. package/src/primitives/ui.test.tsx +131 -0
  155. package/src/primitives/ui.tsx +13 -0
  156. package/src/primitives/zone.test.tsx +305 -0
  157. package/src/primitives/zone.tsx +660 -12
  158. package/src/reducer.ts +7 -20
  159. package/src/runtime/createPluginRuntimeAPI.ts +1 -1
  160. package/src/types/hex-color.ts +20 -0
  161. package/src/types/player-state.ts +36 -18
  162. package/src/types/plugin-state.ts +10 -3
  163. package/src/ui-contract.ts +253 -21
  164. package/src/utils/interaction-inputs.test.ts +400 -0
  165. package/src/utils/interaction-inputs.ts +113 -11
  166. package/src/utils/interaction-router.ts +200 -0
@@ -1,9 +1,18 @@
1
1
  import { expect, test } from "bun:test";
2
2
  import type { InteractionDescriptor } from "../types/plugin-state.js";
3
3
  import {
4
+ dependentInputKeys,
5
+ eligibleTargetsByBoardKind,
4
6
  hasInteractionFieldErrors,
7
+ inputKeyForTarget,
8
+ resolveInteractionInputs,
5
9
  validateInteractionInputDomains,
6
10
  } from "./interaction-inputs.js";
11
+ import {
12
+ applyInteractionDraftMutation,
13
+ getInteractionDraftReadiness,
14
+ shouldRouteInteractionPending,
15
+ } from "./interaction-router.js";
7
16
 
8
17
  function descriptor(
9
18
  inputs: InteractionDescriptor["inputs"],
@@ -107,3 +116,394 @@ test("many target domains validate count, distinctness, and eligibility", () =>
107
116
  ),
108
117
  ).toBe(false);
109
118
  });
119
+
120
+ test("dependent domains resolve from current draft and invalidate downstream keys", () => {
121
+ const interaction = descriptor([
122
+ {
123
+ key: "spaceId",
124
+ kind: "form",
125
+ domain: {
126
+ type: "choice",
127
+ choices: [
128
+ { value: "hex-a", label: "Hex A" },
129
+ { value: "hex-b", label: "Hex B" },
130
+ ],
131
+ },
132
+ },
133
+ {
134
+ key: "targetPlayerId",
135
+ kind: "form",
136
+ domain: {
137
+ type: "choice",
138
+ choices: [],
139
+ dependsOn: ["spaceId"],
140
+ dependentCases: [
141
+ {
142
+ when: { spaceId: "hex-a" },
143
+ domain: {
144
+ type: "choice",
145
+ choices: [{ value: "player-1", label: "Player 1" }],
146
+ },
147
+ },
148
+ {
149
+ when: { spaceId: "hex-b" },
150
+ domain: {
151
+ type: "choice",
152
+ choices: [{ value: "player-2", label: "Player 2" }],
153
+ },
154
+ },
155
+ ],
156
+ },
157
+ },
158
+ ]);
159
+
160
+ const resolved = resolveInteractionInputs(interaction, { spaceId: "hex-b" });
161
+ expect(resolved[1]?.domain.choices).toEqual([
162
+ { value: "player-2", label: "Player 2" },
163
+ ]);
164
+ expect(
165
+ validateInteractionInputDomains(interaction, {
166
+ spaceId: "hex-b",
167
+ targetPlayerId: "player-1",
168
+ }),
169
+ ).toEqual({
170
+ targetPlayerId: ["Selected choice is not eligible."],
171
+ });
172
+ expect(dependentInputKeys(interaction, "spaceId")).toEqual([
173
+ "targetPlayerId",
174
+ ]);
175
+ });
176
+
177
+ test("router draft mutation clears dependent inputs unless they are supplied together", () => {
178
+ const interaction = descriptor([
179
+ {
180
+ key: "spaceId",
181
+ kind: "form",
182
+ domain: {
183
+ type: "choice",
184
+ choices: [
185
+ { value: "hex-a", label: "Hex A" },
186
+ { value: "hex-b", label: "Hex B" },
187
+ ],
188
+ },
189
+ },
190
+ {
191
+ key: "targetPlayerId",
192
+ kind: "form",
193
+ domain: {
194
+ type: "choice",
195
+ choices: [],
196
+ dependsOn: ["spaceId"],
197
+ dependentCases: [
198
+ {
199
+ when: { spaceId: "hex-a" },
200
+ domain: {
201
+ type: "choice",
202
+ choices: [{ value: "player-1", label: "Player 1" }],
203
+ },
204
+ },
205
+ {
206
+ when: { spaceId: "hex-b" },
207
+ domain: {
208
+ type: "choice",
209
+ choices: [{ value: "player-2", label: "Player 2" }],
210
+ },
211
+ },
212
+ ],
213
+ },
214
+ },
215
+ ]);
216
+ const draft: Record<string, unknown> = {
217
+ spaceId: "hex-a",
218
+ targetPlayerId: "player-1",
219
+ };
220
+ const store = {
221
+ getDraft: () => draft,
222
+ setInput: (_interactionKey: string, key: string, value: unknown) => {
223
+ draft[key] = value;
224
+ },
225
+ clearInput: (_interactionKey: string, key?: string) => {
226
+ if (key) delete draft[key];
227
+ },
228
+ };
229
+
230
+ expect(
231
+ applyInteractionDraftMutation(store, interaction, [
232
+ { key: "spaceId", value: "hex-b" },
233
+ ]),
234
+ ).toEqual({ spaceId: "hex-b" });
235
+ expect(draft).toEqual({ spaceId: "hex-b" });
236
+
237
+ expect(
238
+ applyInteractionDraftMutation(store, interaction, [
239
+ { key: "spaceId", value: "hex-a" },
240
+ { key: "targetPlayerId", value: "player-1" },
241
+ ]),
242
+ ).toEqual({ spaceId: "hex-a", targetPlayerId: "player-1" });
243
+ expect(draft).toEqual({ spaceId: "hex-a", targetPlayerId: "player-1" });
244
+ });
245
+
246
+ test("draft mutation preserves dependent target values that remain eligible", () => {
247
+ const interaction = descriptor([
248
+ {
249
+ key: "componentId",
250
+ kind: "form",
251
+ domain: {
252
+ type: "choice",
253
+ choices: [
254
+ { value: "worker-1", label: "Worker 1" },
255
+ { value: "worker-2", label: "Worker 2" },
256
+ ],
257
+ },
258
+ },
259
+ {
260
+ key: "spaceId",
261
+ kind: "board-space",
262
+ domain: {
263
+ type: "target",
264
+ targetKind: "space",
265
+ eligibleTargets: ["lumberyard"],
266
+ dependsOn: ["componentId"],
267
+ dependentCases: [
268
+ {
269
+ when: { componentId: "worker-1" },
270
+ domain: {
271
+ type: "target",
272
+ targetKind: "space",
273
+ eligibleTargets: ["lumberyard"],
274
+ },
275
+ },
276
+ {
277
+ when: { componentId: "worker-2" },
278
+ domain: {
279
+ type: "target",
280
+ targetKind: "space",
281
+ eligibleTargets: ["quarry"],
282
+ },
283
+ },
284
+ ],
285
+ },
286
+ },
287
+ ]);
288
+ const draft: Record<string, unknown> = { spaceId: "lumberyard" };
289
+ const store = {
290
+ getDraft: () => draft,
291
+ setInput: (_interactionKey: string, key: string, value: unknown) => {
292
+ draft[key] = value;
293
+ },
294
+ clearInput: (_interactionKey: string, key?: string) => {
295
+ if (key) delete draft[key];
296
+ },
297
+ };
298
+
299
+ expect(
300
+ applyInteractionDraftMutation(store, interaction, [
301
+ { key: "componentId", value: "worker-1" },
302
+ ]),
303
+ ).toEqual({ spaceId: "lumberyard", componentId: "worker-1" });
304
+ expect(draft).toEqual({ spaceId: "lumberyard", componentId: "worker-1" });
305
+
306
+ expect(
307
+ applyInteractionDraftMutation(store, interaction, [
308
+ { key: "componentId", value: "worker-2" },
309
+ ]),
310
+ ).toEqual({ componentId: "worker-2" });
311
+ expect(draft).toEqual({ componentId: "worker-2" });
312
+ });
313
+
314
+ test("router readiness includes defaults, dependent domains, and pending routing", () => {
315
+ const interaction = descriptor([
316
+ {
317
+ key: "spaceId",
318
+ kind: "form",
319
+ domain: {
320
+ type: "choice",
321
+ choices: [{ value: "hex-a", label: "Hex A" }],
322
+ },
323
+ defaultValue: "hex-a",
324
+ },
325
+ {
326
+ key: "targetPlayerId",
327
+ kind: "form",
328
+ domain: {
329
+ type: "choice",
330
+ choices: [{ value: "player-1", label: "Player 1" }],
331
+ dependsOn: ["spaceId"],
332
+ },
333
+ },
334
+ ]);
335
+ const missing = getInteractionDraftReadiness(interaction, {});
336
+
337
+ expect(missing.values).toEqual({ spaceId: "hex-a" });
338
+ expect(missing.missingInputs).toEqual(["targetPlayerId"]);
339
+ expect(missing.readyFrontier).toEqual(["targetPlayerId"]);
340
+ expect(missing.blockedInputs).toEqual([]);
341
+ expect(missing.ready).toBe(false);
342
+ expect(shouldRouteInteractionPending(interaction, missing)).toBe(true);
343
+
344
+ const ready = getInteractionDraftReadiness(interaction, {
345
+ targetPlayerId: "player-1",
346
+ });
347
+ expect(ready.ready).toBe(true);
348
+ expect(shouldRouteInteractionPending(interaction, ready)).toBe(true);
349
+ expect(
350
+ shouldRouteInteractionPending(
351
+ { ...interaction, commit: { mode: "autoWhenReady" } },
352
+ ready,
353
+ ),
354
+ ).toBe(false);
355
+ });
356
+
357
+ test("router readiness exposes only dependency-ready missing inputs in the frontier", () => {
358
+ const interaction = descriptor([
359
+ {
360
+ key: "itemId",
361
+ kind: "form",
362
+ domain: {
363
+ type: "choice",
364
+ choices: [
365
+ { value: "anvil", label: "Anvil" },
366
+ { value: "loom", label: "Loom" },
367
+ ],
368
+ },
369
+ },
370
+ {
371
+ key: "cell",
372
+ kind: "board-space",
373
+ domain: {
374
+ type: "target",
375
+ targetKind: "space",
376
+ eligibleTargets: [],
377
+ dependsOn: ["itemId"],
378
+ dependentCases: [
379
+ {
380
+ when: { itemId: "anvil" },
381
+ domain: {
382
+ type: "target",
383
+ targetKind: "space",
384
+ eligibleTargets: ["workshop-a"],
385
+ },
386
+ },
387
+ {
388
+ when: { itemId: "loom" },
389
+ domain: {
390
+ type: "target",
391
+ targetKind: "space",
392
+ eligibleTargets: ["workshop-b"],
393
+ },
394
+ },
395
+ ],
396
+ },
397
+ },
398
+ ]);
399
+
400
+ const initial = getInteractionDraftReadiness(interaction, {});
401
+ expect(initial.missingInputs).toEqual(["itemId", "cell"]);
402
+ expect(initial.readyFrontier).toEqual(["itemId"]);
403
+ expect(initial.blockedInputs).toEqual(["cell"]);
404
+
405
+ const afterItem = getInteractionDraftReadiness(interaction, {
406
+ itemId: "anvil",
407
+ });
408
+ expect(afterItem.missingInputs).toEqual(["cell"]);
409
+ expect(afterItem.readyFrontier).toEqual(["cell"]);
410
+ expect(afterItem.blockedInputs).toEqual([]);
411
+ expect(
412
+ resolveInteractionInputs(interaction, afterItem.values)[1]?.domain,
413
+ ).toMatchObject({
414
+ type: "target",
415
+ targetKind: "space",
416
+ eligibleTargets: ["workshop-a"],
417
+ });
418
+ });
419
+
420
+ test("router readiness exposes all independent missing inputs in the same frontier", () => {
421
+ const interaction = descriptor([
422
+ {
423
+ key: "giveWood",
424
+ kind: "form",
425
+ domain: { type: "number", min: 0, max: 3 },
426
+ },
427
+ {
428
+ key: "giveStone",
429
+ kind: "form",
430
+ domain: { type: "number", min: 0, max: 3 },
431
+ },
432
+ {
433
+ key: "summary",
434
+ kind: "form",
435
+ domain: {
436
+ type: "choice",
437
+ choices: [{ value: "confirm", label: "Confirm" }],
438
+ dependsOn: ["giveWood", "giveStone"],
439
+ },
440
+ },
441
+ ]);
442
+
443
+ const initial = getInteractionDraftReadiness(interaction, {});
444
+ expect(initial.missingInputs).toEqual(["giveWood", "giveStone", "summary"]);
445
+ expect(initial.readyFrontier).toEqual(["giveWood", "giveStone"]);
446
+ expect(initial.blockedInputs).toEqual(["summary"]);
447
+
448
+ const afterResources = getInteractionDraftReadiness(interaction, {
449
+ giveWood: 1,
450
+ giveStone: 2,
451
+ });
452
+ expect(afterResources.missingInputs).toEqual(["summary"]);
453
+ expect(afterResources.readyFrontier).toEqual(["summary"]);
454
+ expect(afterResources.blockedInputs).toEqual([]);
455
+ });
456
+
457
+ test("target helpers resolve dependent domains against the current draft", () => {
458
+ const interaction = descriptor([
459
+ {
460
+ key: "itemId",
461
+ kind: "form",
462
+ domain: {
463
+ type: "choice",
464
+ choices: [{ value: "anvil", label: "Anvil" }],
465
+ },
466
+ },
467
+ {
468
+ key: "cell",
469
+ kind: "board-space",
470
+ domain: {
471
+ type: "target",
472
+ targetKind: "space",
473
+ eligibleTargets: [],
474
+ dependsOn: ["itemId"],
475
+ dependentCases: [
476
+ {
477
+ when: { itemId: "anvil" },
478
+ domain: {
479
+ type: "target",
480
+ targetKind: "space",
481
+ eligibleTargets: ["workshop-a"],
482
+ },
483
+ },
484
+ ],
485
+ },
486
+ },
487
+ ]);
488
+
489
+ expect(interaction.inputs[1]?.domain).toMatchObject({
490
+ type: "target",
491
+ eligibleTargets: [],
492
+ });
493
+ expect(
494
+ resolveInteractionInputs(interaction, { itemId: "anvil" })[1]?.domain,
495
+ ).toMatchObject({
496
+ type: "target",
497
+ eligibleTargets: ["workshop-a"],
498
+ });
499
+ expect(eligibleTargetsByBoardKind(interaction, {})).toEqual({});
500
+ expect(eligibleTargetsByBoardKind(interaction, { itemId: "anvil" })).toEqual({
501
+ space: ["workshop-a"],
502
+ });
503
+ expect(inputKeyForTarget(interaction, "space", "workshop-a", {})).toBeNull();
504
+ expect(
505
+ inputKeyForTarget(interaction, "space", "workshop-a", {
506
+ itemId: "anvil",
507
+ }),
508
+ ).toBe("cell");
509
+ });
@@ -27,13 +27,70 @@ export function applyInteractionInputDefaults<
27
27
  return next as Partial<Params>;
28
28
  }
29
29
 
30
+ export function resolveInputDomain(
31
+ input: InteractionInputDescriptor,
32
+ params: Readonly<Record<string, unknown>>,
33
+ ): InteractionInputDescriptor {
34
+ const dependencies = input.domain.dependsOn ?? [];
35
+ if (dependencies.length === 0) return input;
36
+ const caseMatch = input.domain.dependentCases?.find((candidate) =>
37
+ dependencies.every(
38
+ (key) =>
39
+ params[key] !== undefined &&
40
+ params[key] !== null &&
41
+ String(params[key]) === candidate.when[key],
42
+ ),
43
+ );
44
+ if (!caseMatch) return input;
45
+ return {
46
+ ...input,
47
+ domain: {
48
+ ...caseMatch.domain,
49
+ dependsOn: dependencies,
50
+ dependentCases: input.domain.dependentCases,
51
+ },
52
+ };
53
+ }
54
+
55
+ export function resolveInteractionInputs(
56
+ descriptor: Pick<InteractionDescriptor, "inputs">,
57
+ params: Readonly<Record<string, unknown>>,
58
+ ): InteractionInputDescriptor[] {
59
+ return descriptor.inputs.map((input) => resolveInputDomain(input, params));
60
+ }
61
+
62
+ export function dependentInputKeys(
63
+ descriptor: Pick<InteractionDescriptor, "inputs">,
64
+ changedKey: string,
65
+ ): string[] {
66
+ const dependentsByKey = new Map<string, string[]>();
67
+ for (const input of descriptor.inputs) {
68
+ for (const dependency of input.domain.dependsOn ?? []) {
69
+ dependentsByKey.set(dependency, [
70
+ ...(dependentsByKey.get(dependency) ?? []),
71
+ input.key,
72
+ ]);
73
+ }
74
+ }
75
+ const result: string[] = [];
76
+ const queue = [...(dependentsByKey.get(changedKey) ?? [])];
77
+ while (queue.length > 0) {
78
+ const key = queue.shift();
79
+ if (!key || result.includes(key)) continue;
80
+ result.push(key);
81
+ queue.push(...(dependentsByKey.get(key) ?? []));
82
+ }
83
+ return result;
84
+ }
85
+
30
86
  export function validateInteractionInputDomains(
31
87
  descriptor: Pick<InteractionDescriptor, "inputs">,
32
88
  params: Readonly<Record<string, unknown>>,
33
89
  ): Partial<Record<string, readonly string[]>> {
34
90
  const fieldErrors: Record<string, string[]> = {};
35
91
 
36
- for (const input of descriptor.inputs) {
92
+ for (const rawInput of descriptor.inputs) {
93
+ const input = resolveInputDomain(rawInput, params);
37
94
  const value = params[input.key];
38
95
  if (value === undefined || value === null) continue;
39
96
 
@@ -67,6 +124,32 @@ export function validateInteractionInputDomains(
67
124
  `Choose at most ${max} ${pluralize("option", max)}.`,
68
125
  );
69
126
  }
127
+ const allowed = new Set(
128
+ input.domain.choices?.map((choice) => choice.value),
129
+ );
130
+ if (
131
+ allowed.size > 0 &&
132
+ value.some((item) => !allowed.has(String(item)))
133
+ ) {
134
+ pushFieldError(
135
+ fieldErrors,
136
+ input.key,
137
+ "Selected choice is not eligible.",
138
+ );
139
+ }
140
+ }
141
+
142
+ if (input.domain.type === "choice") {
143
+ const allowed = new Set(
144
+ input.domain.choices?.map((choice) => choice.value),
145
+ );
146
+ if (allowed.size > 0 && !allowed.has(value as string | null)) {
147
+ pushFieldError(
148
+ fieldErrors,
149
+ input.key,
150
+ "Selected choice is not eligible.",
151
+ );
152
+ }
70
153
  }
71
154
 
72
155
  if (input.domain.type === "target") {
@@ -176,8 +259,10 @@ export function inputByTarget(
176
259
  descriptor: Pick<InteractionDescriptor, "inputs">,
177
260
  targetKind: BoardTargetKind | "card",
178
261
  targetId: string,
262
+ params: Readonly<Record<string, unknown>> = {},
179
263
  ): InteractionInputDescriptor | null {
180
- for (const input of descriptor.inputs) {
264
+ for (const rawInput of descriptor.inputs) {
265
+ const input = resolveInputDomain(rawInput, params);
181
266
  if (input.domain.type !== "target") continue;
182
267
  if (input.domain.targetKind !== targetKind) continue;
183
268
  if (input.domain.eligibleTargets?.includes(targetId)) return input;
@@ -188,16 +273,20 @@ export function inputByTarget(
188
273
  export function eligibleTargetsForInput(
189
274
  descriptor: Pick<InteractionDescriptor, "inputs">,
190
275
  key: string,
276
+ params: Readonly<Record<string, unknown>> = {},
191
277
  ): readonly string[] | undefined {
192
- const domain = inputByKey(descriptor, key)?.domain;
278
+ const rawInput = inputByKey(descriptor, key);
279
+ const domain = rawInput ? resolveInputDomain(rawInput, params).domain : null;
193
280
  return domain?.type === "target" ? domain.eligibleTargets : undefined;
194
281
  }
195
282
 
196
283
  export function eligibleTargetsByInput(
197
284
  descriptor: Pick<InteractionDescriptor, "inputs">,
285
+ params: Readonly<Record<string, unknown>> = {},
198
286
  ): Record<string, readonly string[]> {
199
287
  return Object.fromEntries(
200
- descriptor.inputs.flatMap((input) => {
288
+ descriptor.inputs.flatMap((rawInput) => {
289
+ const input = resolveInputDomain(rawInput, params);
201
290
  const targets =
202
291
  input.domain.type === "target"
203
292
  ? input.domain.eligibleTargets
@@ -209,6 +298,7 @@ export function eligibleTargetsByInput(
209
298
 
210
299
  export function eligibleTargetsByBoardKind(
211
300
  descriptor: Pick<InteractionDescriptor, "inputs">,
301
+ params: Readonly<Record<string, unknown>> = {},
212
302
  ): Partial<Record<BoardTargetKind, readonly string[]>> {
213
303
  const result: Record<BoardTargetKind, Set<string>> = {
214
304
  edge: new Set<string>(),
@@ -216,7 +306,8 @@ export function eligibleTargetsByBoardKind(
216
306
  space: new Set<string>(),
217
307
  tile: new Set<string>(),
218
308
  };
219
- for (const input of descriptor.inputs) {
309
+ for (const rawInput of descriptor.inputs) {
310
+ const input = resolveInputDomain(rawInput, params);
220
311
  if (input.domain.type !== "target") continue;
221
312
  const targetKind = input.domain.targetKind;
222
313
  if (!isBoardTargetKind(targetKind)) continue;
@@ -234,7 +325,7 @@ export function eligibleTargetsByBoardKind(
234
325
  export function hasBoardTargetInput(
235
326
  descriptor: Pick<InteractionDescriptor, "inputs">,
236
327
  ): boolean {
237
- return Object.keys(eligibleTargetsByBoardKind(descriptor)).length > 0;
328
+ return boardTargetKindsOf(descriptor).length > 0;
238
329
  }
239
330
 
240
331
  export function hasCardTargetInput(
@@ -252,10 +343,7 @@ export function interactionArmScope(
252
343
  "inputs" | "interactionKey" | "zoneId"
253
344
  >,
254
345
  ): string {
255
- const byKind = eligibleTargetsByBoardKind(descriptor);
256
- const boardKinds = (Object.keys(byKind) as BoardTargetKind[]).filter(
257
- (kind) => byKind[kind] !== undefined,
258
- );
346
+ const boardKinds = boardTargetKindsOf(descriptor);
259
347
  if (boardKinds.length === 1) return `board:${boardKinds[0]}`;
260
348
  if (boardKinds.length > 1) return "board";
261
349
  if (descriptor.zoneId) return `zone:${descriptor.zoneId}`;
@@ -266,8 +354,22 @@ export function inputKeyForTarget(
266
354
  descriptor: Pick<InteractionDescriptor, "inputs">,
267
355
  targetKind: BoardTargetKind | "card",
268
356
  targetId: string,
357
+ params: Readonly<Record<string, unknown>> = {},
269
358
  ): string | null {
270
- return inputByTarget(descriptor, targetKind, targetId)?.key ?? null;
359
+ return inputByTarget(descriptor, targetKind, targetId, params)?.key ?? null;
360
+ }
361
+
362
+ function boardTargetKindsOf(
363
+ descriptor: Pick<InteractionDescriptor, "inputs">,
364
+ ): BoardTargetKind[] {
365
+ const kinds = new Set<BoardTargetKind>();
366
+ for (const input of descriptor.inputs) {
367
+ if (input.domain.type !== "target") continue;
368
+ if (isBoardTargetKind(input.domain.targetKind)) {
369
+ kinds.add(input.domain.targetKind);
370
+ }
371
+ }
372
+ return [...kinds];
271
373
  }
272
374
 
273
375
  function validateInputSelection(