@dreamboard-games/ui-sdk 0.0.43 → 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 +1 -1
  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
@@ -5,23 +5,29 @@ import { usePluginSession } from "../context/PluginSessionContext.js";
5
5
  import { usePluginState } from "../context/PluginStateContext.js";
6
6
  import { useRuntimeContext } from "../context/RuntimeContext.js";
7
7
  import { validationErrorFromUnknown } from "../errors/ValidationError.js";
8
- import type { InteractiveTargetLayer } from "../components/board/target-layer.js";
8
+ import type {
9
+ InteractiveTargetLayer,
10
+ InteractiveTargetState,
11
+ } from "../components/board/target-layer.js";
9
12
  import type { InteractionDescriptor } from "../types/plugin-state.js";
10
13
  import {
14
+ applyInteractionInputDefaults,
11
15
  eligibleTargetsByBoardKind,
12
16
  eligibleTargetsForInput,
13
17
  hasBoardTargetInput,
14
18
  hasCardTargetInput,
15
- hasInteractionFieldErrors,
16
19
  inputByTarget,
17
20
  inputKeyForTarget,
18
- isInputValueReady,
19
- interactionArmScope,
20
- interactionInputKeys,
21
- toggleManyValue,
22
- validateInteractionInputDomains,
23
21
  type BoardTargetKind,
24
22
  } from "../utils/interaction-inputs.js";
23
+ import {
24
+ claimInteractionSubmit,
25
+ clearInteractionRoute,
26
+ getInteractionDraftReadiness,
27
+ markInteractionPending,
28
+ routeInteractionTarget,
29
+ shouldRouteInteractionPending,
30
+ } from "../utils/interaction-router.js";
25
31
 
26
32
  export type BoardEligibleTargets = Readonly<
27
33
  Record<BoardTargetKind, ReadonlySet<string>>
@@ -29,6 +35,7 @@ export type BoardEligibleTargets = Readonly<
29
35
 
30
36
  export interface BoardTargetLayerOptions {
31
37
  enabled?: boolean;
38
+ interactionKeys?: readonly string[];
32
39
  extraInputs?:
33
40
  | Record<string, unknown>
34
41
  | ((targetId: string) => Record<string, unknown>);
@@ -40,6 +47,27 @@ export type BoardTargetLayerFactory = (
40
47
  options?: BoardTargetLayerOptions,
41
48
  ) => InteractiveTargetLayer;
42
49
 
50
+ export type BoardSelectionResult<I extends string = string> =
51
+ | { status: "none" }
52
+ | {
53
+ status: "pending";
54
+ interactionKey: I;
55
+ descriptor: InteractionDescriptor<I>;
56
+ missingInputs: readonly string[];
57
+ }
58
+ | {
59
+ status: "submitted";
60
+ interactionKey: I;
61
+ descriptor: InteractionDescriptor<I>;
62
+ };
63
+
64
+ export interface BoardPendingInteraction<I extends string = string> {
65
+ interactionKey: I;
66
+ descriptor: InteractionDescriptor<I>;
67
+ missingInputs: readonly string[];
68
+ clear(): void;
69
+ }
70
+
43
71
  export interface BoardInteractionsOptions {
44
72
  /**
45
73
  * Target kinds the hook pulls interactions from. Defaults to every board
@@ -106,23 +134,24 @@ export interface BoardInteractionsContext<I extends string = string> {
106
134
  * Target-kind dispatch. Routes by board geometry (`edge`, `vertex`,
107
135
  * `space`, `tile`) rather than authored input-key strings.
108
136
  */
137
+ pendingInteraction: BoardPendingInteraction<I> | null;
109
138
  select: {
110
139
  edge(
111
140
  targetId: string,
112
141
  extraInputs?: Record<string, unknown>,
113
- ): Promise<string | null>;
142
+ ): Promise<BoardSelectionResult<I>>;
114
143
  vertex(
115
144
  targetId: string,
116
145
  extraInputs?: Record<string, unknown>,
117
- ): Promise<string | null>;
146
+ ): Promise<BoardSelectionResult<I>>;
118
147
  space(
119
148
  targetId: string,
120
149
  extraInputs?: Record<string, unknown>,
121
- ): Promise<string | null>;
150
+ ): Promise<BoardSelectionResult<I>>;
122
151
  tile(
123
152
  targetId: string,
124
153
  extraInputs?: Record<string, unknown>,
125
- ): Promise<string | null>;
154
+ ): Promise<BoardSelectionResult<I>>;
126
155
  };
127
156
  /**
128
157
  * Reducer-aware target layers for board primitives. Pass these directly
@@ -135,6 +164,18 @@ export interface BoardInteractionsContext<I extends string = string> {
135
164
  space: BoardTargetLayerFactory;
136
165
  tile: BoardTargetLayerFactory;
137
166
  };
167
+ targetState(
168
+ targetKind: BoardTargetKind,
169
+ targetId: string,
170
+ options?: Pick<BoardTargetLayerOptions, "enabled" | "interactionKeys">,
171
+ ): InteractiveTargetState;
172
+ selectTarget(
173
+ descriptor: InteractionDescriptor<I>,
174
+ targetKind: BoardTargetKind,
175
+ targetId: string,
176
+ inputKey: string,
177
+ extraInputs?: Record<string, unknown>,
178
+ ): Promise<BoardSelectionResult<I>>;
138
179
  }
139
180
 
140
181
  /**
@@ -142,9 +183,9 @@ export interface BoardInteractionsContext<I extends string = string> {
142
183
  * wiring many `useInteractionById(...)` calls, merging their
143
184
  * eligibility sets, and dispatching clicks to the right handle.
144
185
  *
145
- * Use this alongside (or instead of) `<BoardSurface>` when a game
146
- * keeps multiple board interactions live simultaneously and dispatch
147
- * is target-driven rather than armed-then-clicked. The typical
186
+ * Internal board primitive controller for games that keep multiple board
187
+ * interactions live simultaneously and dispatch by target geometry. The
188
+ * typical
148
189
  * Catan-class shape:
149
190
  *
150
191
  * ```tsx
@@ -154,15 +195,16 @@ export interface BoardInteractionsContext<I extends string = string> {
154
195
  * <HexGrid
155
196
  * interactiveVertices={board.targetLayers.vertex()}
156
197
  * interactiveEdges={board.targetLayers.edge()}
157
- * onTileClick={(id) =>
158
- * board.select.space(id, { stealFromPlayerId: null })
159
- * }
198
+ * onTileClick={(id) => board.select.space(id)}
160
199
  * />
161
200
  * );
162
201
  * ```
163
202
  *
203
+ * Mount generated interaction routes with `<Interaction.Switch routes={...}>`
204
+ * for interactions that need more input after a board target is selected.
205
+ *
164
206
  * Eligibility and availability remain authoritative on reducer-projected
165
- * descriptors. Armed follow-up descriptors beat ambient board descriptors.
207
+ * descriptors. Armed routed descriptors beat ambient board descriptors.
166
208
  * Multiple unarmed matches are ambiguous and throw
167
209
  * {@link BoardInteractionConflictError}.
168
210
  */
@@ -175,7 +217,13 @@ export function useBoardInteractions<I extends string = string>(
175
217
  const { controllingPlayerId } = usePluginSession();
176
218
  const store = useInteractionUiStore();
177
219
  const subscribedArmedBySurface = useStore(store, (state) => state.arms);
220
+ const subscribedDrafts = useStore(store, (state) => state.drafts);
221
+ const pendingInteractionKey = useStore(
222
+ store,
223
+ (state) => state.pendingInteractionKey,
224
+ );
178
225
  const armedBySurface = store.getState().arms ?? subscribedArmedBySurface;
226
+ const drafts = store.getState().drafts ?? subscribedDrafts;
179
227
  const descriptors = usePluginState(
180
228
  (state) => state.gameplay.availableInteractions ?? [],
181
229
  );
@@ -190,16 +238,28 @@ export function useBoardInteractions<I extends string = string>(
190
238
  );
191
239
 
192
240
  const interactions = useMemo<ReadonlyArray<InteractionDescriptor<I>>>(() => {
193
- return descriptors.filter((descriptor) => {
194
- if (armedIds.has(descriptor.interactionKey)) return true;
195
- if (hasCardTargetInput(descriptor)) return false;
196
- if (!hasBoardTargetInput(descriptor)) return false;
197
- if (!targetKinds || targetKinds.length === 0) return true;
198
- const byKind = eligibleTargetsByBoardKind(descriptor);
199
- return (Object.keys(byKind) as BoardTargetKind[]).some((kind) =>
200
- targetKindSet.has(kind),
201
- );
202
- }) as unknown as ReadonlyArray<InteractionDescriptor<I>>;
241
+ return descriptors.flatMap(
242
+ (descriptor): Array<InteractionDescriptor<I>> => {
243
+ if (armedIds.has(descriptor.interactionKey)) {
244
+ return [
245
+ { ...descriptor, interactionKey: descriptor.interactionKey as I },
246
+ ];
247
+ }
248
+ if (hasCardTargetInput(descriptor)) return [];
249
+ if (!hasBoardTargetInput(descriptor)) return [];
250
+ const include =
251
+ !targetKinds || targetKinds.length === 0
252
+ ? true
253
+ : (
254
+ Object.keys(
255
+ eligibleTargetsByBoardKind(descriptor),
256
+ ) as BoardTargetKind[]
257
+ ).some((kind) => targetKindSet.has(kind));
258
+ return include
259
+ ? [{ ...descriptor, interactionKey: descriptor.interactionKey as I }]
260
+ : [];
261
+ },
262
+ );
203
263
  }, [armedIds, descriptors, targetKindSet, targetKinds]);
204
264
 
205
265
  const eligible = useMemo<BoardEligibleTargets>(() => {
@@ -211,7 +271,10 @@ export function useBoardInteractions<I extends string = string>(
211
271
  };
212
272
  for (const descriptor of interactions) {
213
273
  if (!descriptor.available) continue;
214
- const targetsByKind = eligibleTargetsByBoardKind(descriptor);
274
+ const targetsByKind = eligibleTargetsByBoardKind(
275
+ descriptor,
276
+ drafts[descriptor.interactionKey] ?? {},
277
+ );
215
278
  for (const [targetKind, ids] of Object.entries(targetsByKind) as Array<
216
279
  [BoardTargetKind, readonly string[] | undefined]
217
280
  >) {
@@ -221,7 +284,7 @@ export function useBoardInteractions<I extends string = string>(
221
284
  }
222
285
  }
223
286
  return acc;
224
- }, [interactions]);
287
+ }, [drafts, interactions]);
225
288
 
226
289
  const isEligible = useCallback(
227
290
  (targetId: string, targetKind?: BoardTargetKind) => {
@@ -236,18 +299,93 @@ export function useBoardInteractions<I extends string = string>(
236
299
  [eligible],
237
300
  );
238
301
 
239
- const selectByKind = useCallback(
302
+ const clearPendingInteraction = useCallback(
303
+ (descriptor: InteractionDescriptor) => {
304
+ clearInteractionRoute(store, descriptor);
305
+ },
306
+ [store],
307
+ );
308
+
309
+ const resolveSelection = useCallback(
240
310
  async (
241
- targetKind: BoardTargetKind,
311
+ descriptor: InteractionDescriptor<I>,
312
+ inputKey: string,
242
313
  targetId: string,
243
314
  extraInputs?: Record<string, unknown>,
244
- ): Promise<string | null> => {
245
- if (!controllingPlayerId) return null;
315
+ ): Promise<BoardSelectionResult<I>> => {
316
+ if (!controllingPlayerId) return { status: "none" };
317
+ const { params, readiness } = routeInteractionTarget(store, descriptor, {
318
+ inputKey,
319
+ value: targetId,
320
+ extraInputs,
321
+ });
322
+ if (shouldRouteInteractionPending(descriptor, readiness)) {
323
+ markInteractionPending(store, descriptor);
324
+ return {
325
+ status: "pending",
326
+ interactionKey: descriptor.interactionKey as I,
327
+ descriptor,
328
+ missingInputs: readiness.missingInputs,
329
+ };
330
+ }
331
+
332
+ const submitParams = applyInteractionInputDefaults<
333
+ Record<string, unknown>
334
+ >(descriptor, params);
335
+ if (!claimInteractionSubmit(store, descriptor)) {
336
+ return {
337
+ status: "submitted",
338
+ interactionKey: descriptor.interactionKey as I,
339
+ descriptor,
340
+ };
341
+ }
342
+ try {
343
+ await runtime.submitInteraction(
344
+ controllingPlayerId,
345
+ descriptor.interactionId,
346
+ submitParams as Record<string, unknown>,
347
+ );
348
+ clearPendingInteraction(descriptor);
349
+ return {
350
+ status: "submitted",
351
+ interactionKey: descriptor.interactionKey as I,
352
+ descriptor,
353
+ };
354
+ } catch (error) {
355
+ throw validationErrorFromUnknown(error);
356
+ } finally {
357
+ store.setSubmitting(descriptor.interactionKey, false);
358
+ }
359
+ },
360
+ [clearPendingInteraction, controllingPlayerId, runtime, store],
361
+ );
362
+
363
+ const resolveTargetMatches = useCallback(
364
+ (
365
+ targetKind: BoardTargetKind,
366
+ targetId: string,
367
+ interactionKeys?: readonly string[],
368
+ ): Array<MatchingDescriptor<I>> => {
369
+ const allowedInteractionKeys = interactionKeys
370
+ ? new Set(interactionKeys)
371
+ : null;
246
372
  const matches = interactions.flatMap((descriptor) => {
373
+ if (
374
+ allowedInteractionKeys &&
375
+ !allowedInteractionKeys.has(descriptor.interactionKey)
376
+ ) {
377
+ return [];
378
+ }
247
379
  if (!descriptor.available) return [];
248
- const inputKey = inputKeyForTarget(descriptor, targetKind, targetId);
380
+ const draft = store.getDraft(descriptor.interactionKey);
381
+ const inputKey = inputKeyForTarget(
382
+ descriptor,
383
+ targetKind,
384
+ targetId,
385
+ draft,
386
+ );
249
387
  if (!inputKey) return [];
250
- const input = inputByTarget(descriptor, targetKind, targetId);
388
+ const input = inputByTarget(descriptor, targetKind, targetId, draft);
251
389
  if (
252
390
  input?.domain.type === "target" &&
253
391
  !isTargetSelectable(
@@ -258,7 +396,7 @@ export function useBoardInteractions<I extends string = string>(
258
396
  ) {
259
397
  return [];
260
398
  }
261
- const targets = eligibleTargetsForInput(descriptor, inputKey);
399
+ const targets = eligibleTargetsForInput(descriptor, inputKey, draft);
262
400
  if (!targets || !targets.includes(targetId)) return [];
263
401
  return [
264
402
  {
@@ -268,62 +406,32 @@ export function useBoardInteractions<I extends string = string>(
268
406
  },
269
407
  ];
270
408
  });
409
+ return matches;
410
+ },
411
+ [armedIds, interactions, store],
412
+ );
413
+
414
+ const selectByKind = useCallback(
415
+ async (
416
+ targetKind: BoardTargetKind,
417
+ targetId: string,
418
+ extraInputs?: Record<string, unknown>,
419
+ interactionKeys?: readonly string[],
420
+ ): Promise<BoardSelectionResult<I>> => {
421
+ if (!controllingPlayerId) return { status: "none" };
422
+ const matches = resolveTargetMatches(
423
+ targetKind,
424
+ targetId,
425
+ interactionKeys,
426
+ );
271
427
  const selected = selectDispatchCandidate(matches, targetKind, targetId);
272
428
  if (selected) {
273
429
  const { descriptor, inputKey } = selected;
274
- const input = inputByTarget(descriptor, targetKind, targetId);
275
- const currentDraft = store.getDraft(descriptor.interactionKey);
276
- const selection =
277
- input?.domain.type === "target" ? input.domain.selection : undefined;
278
- const nextTargetValue =
279
- selection?.mode === "many"
280
- ? toggleManyValue(currentDraft[inputKey], targetId, selection)
281
- : targetId;
282
- const params: Record<string, unknown> = {
283
- ...currentDraft,
284
- ...(extraInputs ?? {}),
285
- [inputKey]: nextTargetValue,
286
- };
287
- for (const [key, value] of Object.entries(extraInputs ?? {})) {
288
- store.setInput(descriptor.interactionKey, key, value);
289
- }
290
- store.setInput(descriptor.interactionKey, inputKey, nextTargetValue);
291
- if (
292
- descriptor.commit.mode !== "autoWhenReady" ||
293
- (selection?.mode === "many" && !isDraftReady(descriptor, params))
294
- ) {
295
- store.arm(interactionArmScope(descriptor), descriptor.interactionKey);
296
- return descriptor.interactionKey;
297
- }
298
- // Fill any remaining required inputs with `null` so the reducer
299
- // bundle can decide whether to reject (missing) or accept
300
- // (optional). This matches the Catan "spaceId + null stealee"
301
- // pattern without forcing callers to memorise each interaction's
302
- // full input shape.
303
- for (const required of interactionInputKeys(descriptor)) {
304
- if (!(required in params)) params[required] = null;
305
- }
306
- try {
307
- await runtime.submitInteraction(
308
- controllingPlayerId,
309
- descriptor.interactionId,
310
- params,
311
- );
312
- store.clearInput(descriptor.interactionKey);
313
- if (
314
- store.getArmed(interactionArmScope(descriptor)) ===
315
- descriptor.interactionKey
316
- ) {
317
- store.arm(interactionArmScope(descriptor), null);
318
- }
319
- return descriptor.interactionKey;
320
- } catch (error) {
321
- throw validationErrorFromUnknown(error);
322
- }
430
+ return resolveSelection(descriptor, inputKey, targetId, extraInputs);
323
431
  }
324
- return null;
432
+ return { status: "none" };
325
433
  },
326
- [armedIds, controllingPlayerId, interactions, runtime, store],
434
+ [controllingPlayerId, resolveSelection, resolveTargetMatches],
327
435
  );
328
436
 
329
437
  const select = useMemo(
@@ -340,11 +448,55 @@ export function useBoardInteractions<I extends string = string>(
340
448
  [selectByKind],
341
449
  );
342
450
 
451
+ const targetState = useCallback(
452
+ (
453
+ targetKind: BoardTargetKind,
454
+ targetId: string,
455
+ options: Pick<
456
+ BoardTargetLayerOptions,
457
+ "enabled" | "interactionKeys"
458
+ > = {},
459
+ ): InteractiveTargetState => {
460
+ const enabled = options.enabled !== false;
461
+ const matches = enabled
462
+ ? resolveTargetMatches(targetKind, targetId, options.interactionKeys)
463
+ : [];
464
+ const armed = matches.filter((match) => match.armed);
465
+ const candidates = armed.length > 0 ? armed : matches;
466
+ const selected = candidates[0] ?? null;
467
+ const conflict = candidates.length > 1;
468
+ const pending = selected
469
+ ? pendingInteractionKey === selected.descriptor.interactionKey
470
+ : false;
471
+ const eligible = enabled && !!selected && !conflict;
472
+ return {
473
+ kind: targetKind,
474
+ id: targetId,
475
+ eligible,
476
+ selectable: eligible && !!controllingPlayerId,
477
+ hovered: false,
478
+ interactionKey: selected?.descriptor.interactionKey,
479
+ interactionId: selected?.descriptor.interactionId,
480
+ inputKey: selected?.inputKey,
481
+ pending,
482
+ conflict,
483
+ unavailableReason: candidateUnavailableReason(selected, conflict),
484
+ };
485
+ },
486
+ [controllingPlayerId, pendingInteractionKey, resolveTargetMatches],
487
+ );
488
+
343
489
  const targetLayers = useMemo(() => {
344
490
  const createLayer =
345
491
  (targetKind: BoardTargetKind): BoardTargetLayerFactory =>
346
492
  (layerOptions = {}) => {
347
- const { enabled, extraInputs, onBeforeSelect, onError } = layerOptions;
493
+ const {
494
+ enabled,
495
+ interactionKeys,
496
+ extraInputs,
497
+ onBeforeSelect,
498
+ onError,
499
+ } = layerOptions;
348
500
  const resolveExtraInputs = (targetId: string) =>
349
501
  typeof extraInputs === "function"
350
502
  ? extraInputs(targetId)
@@ -352,14 +504,39 @@ export function useBoardInteractions<I extends string = string>(
352
504
  return {
353
505
  enabled,
354
506
  eligible: eligible[targetKind],
507
+ targetState: (targetId: string) => ({
508
+ ...targetState(targetKind, targetId, { enabled, interactionKeys }),
509
+ select: async () => {
510
+ if (enabled === false) return { status: "none" };
511
+ onBeforeSelect?.();
512
+ try {
513
+ return await selectByKind(
514
+ targetKind,
515
+ targetId,
516
+ resolveExtraInputs(targetId),
517
+ interactionKeys,
518
+ );
519
+ } catch (error) {
520
+ onError?.(error);
521
+ if (!onError) throw error;
522
+ return { status: "none" };
523
+ }
524
+ },
525
+ }),
355
526
  selectTargetId: async (targetId: string) => {
356
- if (enabled === false) return;
527
+ if (enabled === false) return { status: "none" };
357
528
  onBeforeSelect?.();
358
529
  try {
359
- await select[targetKind](targetId, resolveExtraInputs(targetId));
530
+ return await selectByKind(
531
+ targetKind,
532
+ targetId,
533
+ resolveExtraInputs(targetId),
534
+ interactionKeys,
535
+ );
360
536
  } catch (error) {
361
537
  onError?.(error);
362
538
  if (!onError) throw error;
539
+ return { status: "none" };
363
540
  }
364
541
  },
365
542
  };
@@ -370,11 +547,57 @@ export function useBoardInteractions<I extends string = string>(
370
547
  space: createLayer("space"),
371
548
  tile: createLayer("tile"),
372
549
  };
373
- }, [eligible, select]);
550
+ }, [eligible, selectByKind, targetState]);
551
+
552
+ const pendingInteraction = useMemo<BoardPendingInteraction<I> | null>(() => {
553
+ if (!pendingInteractionKey) return null;
554
+ const descriptor = interactions.find(
555
+ (candidate) => candidate.interactionKey === pendingInteractionKey,
556
+ );
557
+ if (!descriptor) return null;
558
+ return {
559
+ interactionKey: descriptor.interactionKey as I,
560
+ descriptor,
561
+ missingInputs: missingInputsForDraft(
562
+ descriptor,
563
+ drafts[descriptor.interactionKey] ?? {},
564
+ ),
565
+ clear: () => clearPendingInteraction(descriptor),
566
+ };
567
+ }, [clearPendingInteraction, drafts, interactions, pendingInteractionKey]);
568
+
569
+ const selectTarget = useCallback(
570
+ (
571
+ descriptor: InteractionDescriptor<I>,
572
+ _targetKind: BoardTargetKind,
573
+ targetId: string,
574
+ inputKey: string,
575
+ extraInputs?: Record<string, unknown>,
576
+ ) => resolveSelection(descriptor, inputKey, targetId, extraInputs),
577
+ [resolveSelection],
578
+ );
374
579
 
375
580
  return useMemo(
376
- () => ({ interactions, eligible, isEligible, select, targetLayers }),
377
- [interactions, eligible, isEligible, select, targetLayers],
581
+ () => ({
582
+ interactions,
583
+ eligible,
584
+ isEligible,
585
+ pendingInteraction,
586
+ select,
587
+ targetLayers,
588
+ targetState,
589
+ selectTarget,
590
+ }),
591
+ [
592
+ interactions,
593
+ eligible,
594
+ isEligible,
595
+ pendingInteraction,
596
+ select,
597
+ targetLayers,
598
+ targetState,
599
+ selectTarget,
600
+ ],
378
601
  );
379
602
  }
380
603
 
@@ -392,19 +615,11 @@ function isTargetSelectable(
392
615
  return selection.max === undefined || current.length < selection.max;
393
616
  }
394
617
 
395
- function isDraftReady(
618
+ function missingInputsForDraft(
396
619
  descriptor: InteractionDescriptor,
397
620
  draft: Readonly<Record<string, unknown>>,
398
- ): boolean {
399
- const fieldErrors = validateInteractionInputDomains(descriptor, draft);
400
- if (hasInteractionFieldErrors(fieldErrors)) return false;
401
- return interactionInputKeys(descriptor).every((key) => {
402
- const input = descriptor.inputs.find((candidate) => candidate.key === key);
403
- const value =
404
- draft[key] ??
405
- (input && "defaultValue" in input ? input.defaultValue : undefined);
406
- return input ? isInputValueReady(input, value) : value !== undefined;
407
- });
621
+ ): string[] {
622
+ return [...getInteractionDraftReadiness(descriptor, draft).missingInputs];
408
623
  }
409
624
 
410
625
  interface MatchingDescriptor<I extends string> {
@@ -432,3 +647,13 @@ function selectDispatchCandidate<I extends string>(
432
647
  }
433
648
  return candidates[0] ?? null;
434
649
  }
650
+
651
+ function candidateUnavailableReason<I extends string>(
652
+ match: MatchingDescriptor<I> | null,
653
+ conflict: boolean,
654
+ ): string | undefined {
655
+ if (conflict) return "conflict";
656
+ if (!match) return "unavailable";
657
+ if (!match.descriptor.available) return "unavailable";
658
+ return undefined;
659
+ }