@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
@@ -132,7 +132,72 @@ test("Prompt options stay actionable when a prompt descriptor is unavailable", (
132
132
  expect(html).toContain('data-option-value="decline"');
133
133
  });
134
134
 
135
- test("PromptInbox defaults render prompt descriptors and empty state", () => {
135
+ test("Prompt options honor reducer-projected disabled choices", () => {
136
+ const runtime = createRuntime(
137
+ createSnapshot([
138
+ {
139
+ ...tradePrompt,
140
+ inputs: [
141
+ {
142
+ ...tradePrompt.inputs[0],
143
+ domain: {
144
+ type: "choice",
145
+ choices: [
146
+ {
147
+ value: "accept",
148
+ label: "Accept",
149
+ disabled: true,
150
+ disabledReason: "You cannot afford this trade.",
151
+ },
152
+ { value: "decline", label: "Decline" },
153
+ ],
154
+ },
155
+ },
156
+ ],
157
+ },
158
+ ]),
159
+ );
160
+
161
+ const html = renderToString(
162
+ <GameUIProvider runtime={runtime}>
163
+ <Prompt.Root interaction="trade.respond">
164
+ <Prompt.Option value="accept" />
165
+ <Prompt.Option value="decline" />
166
+ </Prompt.Root>
167
+ </GameUIProvider>,
168
+ );
169
+
170
+ expect(html).toContain('data-option-value="accept"');
171
+ expect(html).toContain('disabled=""');
172
+ expect(html).toContain(
173
+ 'data-disabled-reason="You cannot afford this trade."',
174
+ );
175
+ expect(html).not.toContain('data-option-value="decline" disabled=""');
176
+ });
177
+
178
+ test("Prompt.Dialog exposes headless lifecycle state without chrome", () => {
179
+ const html = renderToString(
180
+ <Prompt.Dialog prompt="trade.respond" defaultOpen={false}>
181
+ {({ prompt, open, minimized, dismissed, state }) => (
182
+ <span
183
+ data-prompt={prompt}
184
+ data-open={open}
185
+ data-minimized={minimized}
186
+ data-dismissed={dismissed}
187
+ data-state={state}
188
+ />
189
+ )}
190
+ </Prompt.Dialog>,
191
+ );
192
+
193
+ expect(html).toContain('data-prompt="trade.respond"');
194
+ expect(html).toContain('data-open="false"');
195
+ expect(html).toContain('data-minimized="true"');
196
+ expect(html).toContain('data-dismissed="false"');
197
+ expect(html).toContain('data-state="minimized"');
198
+ });
199
+
200
+ test("PromptInbox renders prompt descriptors through author-owned composition", () => {
136
201
  const populatedRuntime = createRuntime(createSnapshot([tradePrompt]));
137
202
  const emptyRuntime = createRuntime(createSnapshot([]));
138
203
 
@@ -140,7 +205,24 @@ test("PromptInbox defaults render prompt descriptors and empty state", () => {
140
205
  <GameUIProvider runtime={populatedRuntime}>
141
206
  <PromptInbox.Root>
142
207
  <PromptInbox.Empty>No prompts</PromptInbox.Empty>
143
- <PromptInbox.Items />
208
+ <PromptInbox.Items>
209
+ {(prompt) => (
210
+ <Prompt.Root
211
+ key={prompt.interactionKey}
212
+ interaction={prompt.interactionKey}
213
+ >
214
+ <Prompt.Title />
215
+ <Prompt.Message />
216
+ <Prompt.Options>
217
+ {(option) => (
218
+ <Prompt.Option value={option.id}>
219
+ {option.label ?? option.id}
220
+ </Prompt.Option>
221
+ )}
222
+ </Prompt.Options>
223
+ </Prompt.Root>
224
+ )}
225
+ </PromptInbox.Items>
144
226
  </PromptInbox.Root>
145
227
  </GameUIProvider>,
146
228
  );
@@ -148,7 +230,16 @@ test("PromptInbox defaults render prompt descriptors and empty state", () => {
148
230
  <GameUIProvider runtime={emptyRuntime}>
149
231
  <PromptInbox.Root>
150
232
  <PromptInbox.Empty>No prompts</PromptInbox.Empty>
151
- <PromptInbox.Items />
233
+ <PromptInbox.Items>
234
+ {(prompt) => (
235
+ <Prompt.Root
236
+ key={prompt.interactionKey}
237
+ interaction={prompt.interactionKey}
238
+ >
239
+ <Prompt.Title />
240
+ </Prompt.Root>
241
+ )}
242
+ </PromptInbox.Items>
152
243
  </PromptInbox.Root>
153
244
  </GameUIProvider>,
154
245
  );
@@ -1,6 +1,8 @@
1
1
  import {
2
+ Fragment,
2
3
  createContext,
3
4
  useContext,
5
+ useMemo,
4
6
  type ButtonHTMLAttributes,
5
7
  type ReactNode,
6
8
  } from "react";
@@ -22,6 +24,15 @@ import {
22
24
  renderPrimitive,
23
25
  type PrimitiveCommonProps,
24
26
  } from "./primitive-props.js";
27
+ import {
28
+ submitInteractionParams,
29
+ type InteractionSubmitCallbacks,
30
+ } from "./interaction-submit.js";
31
+ import {
32
+ useDialogLifecycle,
33
+ type DialogLifecycleState,
34
+ } from "./dialog-lifecycle.js";
35
+ import { useGameActionError } from "./game.js";
25
36
 
26
37
  export type PromptRootProps<Prompt extends string = PromptKey> =
27
38
  InteractionRootProps<Prompt>;
@@ -59,8 +70,8 @@ export type PromptOptionProps<Option extends string = PromptOptionKey> =
59
70
  ButtonHTMLAttributes<HTMLButtonElement> & {
60
71
  value: Option;
61
72
  disableWhenUnavailable?: boolean;
62
- onSubmitError?: (error: unknown) => void;
63
- onSubmitSuccess?: () => void;
73
+ onSubmitError?: InteractionSubmitCallbacks["onSubmitError"];
74
+ onSubmitSuccess?: InteractionSubmitCallbacks["onSubmitSuccess"];
64
75
  };
65
76
 
66
77
  export function PromptOption<Option extends string = PromptOptionKey>({
@@ -74,16 +85,20 @@ export function PromptOption<Option extends string = PromptOptionKey>({
74
85
  ...props
75
86
  }: PromptOptionProps<Option>) {
76
87
  const { descriptor, handle } = useInteractionPrimitiveContext();
88
+ const gameActionError = useGameActionError();
77
89
  const option = descriptor?.context?.options?.find(
78
90
  (candidate) => candidate.id === value,
79
91
  );
80
92
  const inputKey = descriptor ? interactionInputKeys(descriptor)[0] : undefined;
93
+ const choice = descriptor?.inputs
94
+ .find((input) => input.key === inputKey)
95
+ ?.domain.choices?.find((candidate) => candidate.value === value);
81
96
  const isDisabled =
82
97
  disabled ??
83
98
  (!handle ||
84
99
  !inputKey ||
85
100
  handle.status !== "open" ||
86
- option?.data === false ||
101
+ choice?.disabled === true ||
87
102
  (disableWhenUnavailable && !descriptor?.available));
88
103
  return renderPrimitive("button", {
89
104
  type: "button",
@@ -94,43 +109,83 @@ export function PromptOption<Option extends string = PromptOptionKey>({
94
109
  "data-option-value": value,
95
110
  "data-disabled": isDisabled || undefined,
96
111
  "data-available": descriptor?.available ?? false,
112
+ "data-disabled-reason": choice?.disabledReason,
113
+ title: props.title ?? choice?.disabledReason,
97
114
  onClick: composeEventHandlers(onClick, () => {
98
115
  if (!handle || !inputKey || isDisabled) return;
99
- void handle
100
- .submit({ [inputKey]: value })
101
- .then(() => onSubmitSuccess?.())
102
- .catch((error: unknown) => {
103
- if (onSubmitError) {
104
- onSubmitError(error);
105
- return;
106
- }
107
- console.error(error);
108
- });
116
+ void submitInteractionParams(
117
+ handle,
118
+ { [inputKey]: value },
119
+ {
120
+ onSubmitSuccess,
121
+ onSubmitError: onSubmitError ?? gameActionError ?? undefined,
122
+ },
123
+ { unhandledError: "log" },
124
+ );
109
125
  }),
110
126
  children: children ?? option?.label ?? value,
111
127
  });
112
128
  }
113
129
 
114
- export function PromptOptions({
115
- children,
116
- }: {
117
- children?: (option: { id: string; label?: string }) => ReactNode;
118
- }) {
130
+ export interface PromptOptionRenderItem {
131
+ id: string;
132
+ label?: string;
133
+ }
134
+
135
+ export interface PromptOptionsProps {
136
+ children: (option: PromptOptionRenderItem) => ReactNode;
137
+ }
138
+
139
+ export function PromptOptions({ children }: PromptOptionsProps) {
119
140
  const { descriptor } = useInteractionPrimitiveContext();
120
141
  const options = descriptor?.context?.options ?? [];
121
142
  return (
122
143
  <>
123
- {options.map((option) =>
124
- children ? (
125
- children(option)
126
- ) : (
127
- <PromptOption key={option.id} value={option.id} />
128
- ),
129
- )}
144
+ {options.map((option) => (
145
+ <Fragment key={option.id}>{children(option)}</Fragment>
146
+ ))}
130
147
  </>
131
148
  );
132
149
  }
133
150
 
151
+ export type PromptDialogState = DialogLifecycleState;
152
+
153
+ export interface PromptDialogRenderState<Prompt extends string = PromptKey> {
154
+ prompt: Prompt;
155
+ state: PromptDialogState;
156
+ open: boolean;
157
+ minimized: boolean;
158
+ dismissed: boolean;
159
+ setOpen: (open: boolean) => void;
160
+ restore: () => void;
161
+ minimize: () => void;
162
+ dismiss: () => void;
163
+ }
164
+
165
+ export interface PromptDialogProps<Prompt extends string = PromptKey> {
166
+ prompt: Prompt;
167
+ defaultOpen?: boolean;
168
+ onStateChange?: (state: PromptDialogState) => void;
169
+ children: (state: PromptDialogRenderState<Prompt>) => ReactNode;
170
+ }
171
+
172
+ export function PromptDialog<Prompt extends string = PromptKey>({
173
+ prompt,
174
+ defaultOpen = true,
175
+ onStateChange,
176
+ children,
177
+ }: PromptDialogProps<Prompt>) {
178
+ const lifecycle = useDialogLifecycle({ defaultOpen, onStateChange });
179
+ const renderState = useMemo<PromptDialogRenderState<Prompt>>(
180
+ () => ({
181
+ prompt,
182
+ ...lifecycle,
183
+ }),
184
+ [lifecycle, prompt],
185
+ );
186
+ return <>{children(renderState)}</>;
187
+ }
188
+
134
189
  interface PromptInboxContextValue {
135
190
  prompts: readonly InteractionDescriptor[];
136
191
  }
@@ -162,30 +217,13 @@ export function PromptInboxEmpty({ children }: { children?: ReactNode }) {
162
217
  return <>{children}</>;
163
218
  }
164
219
 
165
- export function PromptInboxItems({
166
- children,
167
- }: {
168
- children?: (prompt: InteractionDescriptor) => ReactNode;
169
- }) {
220
+ export interface PromptInboxItemsProps {
221
+ children: (prompt: InteractionDescriptor) => ReactNode;
222
+ }
223
+
224
+ export function PromptInboxItems({ children }: PromptInboxItemsProps) {
170
225
  const { prompts } = usePromptInboxContext();
171
- return (
172
- <>
173
- {prompts.map((prompt) =>
174
- children ? (
175
- children(prompt)
176
- ) : (
177
- <PromptRoot
178
- key={prompt.interactionKey}
179
- interaction={prompt.interactionKey}
180
- >
181
- <PromptTitle />
182
- <PromptMessage />
183
- <PromptOptions />
184
- </PromptRoot>
185
- ),
186
- )}
187
- </>
188
- );
226
+ return <>{prompts.map((prompt) => children(prompt))}</>;
189
227
  }
190
228
 
191
229
  export const Prompt = {
@@ -194,6 +232,7 @@ export const Prompt = {
194
232
  Message: PromptMessage,
195
233
  Option: PromptOption,
196
234
  Options: PromptOptions,
235
+ Dialog: PromptDialog,
197
236
  };
198
237
 
199
238
  export const PromptInbox = {
@@ -0,0 +1,131 @@
1
+ import { expect, mock, test } from "bun:test";
2
+ import { renderToString } from "react-dom/server";
3
+ import { createInteractionUiStore } from "../context/InteractionDraftContext.js";
4
+ import {
5
+ useInteractionHandle,
6
+ type InteractionHandle,
7
+ } from "../hooks/useInteractionHandle.js";
8
+ import type { PluginStateSnapshot } from "../types/plugin-state.js";
9
+ import type { PluginSessionState, RuntimeAPI } from "../types/runtime-api.js";
10
+ import { GameUIProvider } from "./game-ui-provider.js";
11
+ import { UI } from "./ui.js";
12
+
13
+ function createSessionState(): PluginSessionState {
14
+ return {
15
+ status: "ready",
16
+ sessionId: "session-1",
17
+ controllablePlayerIds: ["player-1"],
18
+ controllingPlayerId: "player-1",
19
+ userId: "user-1",
20
+ };
21
+ }
22
+
23
+ function createSnapshot(): PluginStateSnapshot {
24
+ return {
25
+ view: {},
26
+ gameplay: {
27
+ currentPhase: "playing",
28
+ currentStage: null,
29
+ activePlayers: ["player-1"],
30
+ zones: {},
31
+ availableInteractions: [
32
+ {
33
+ phaseName: "playing",
34
+ interactionKey: "playing.moveRaider",
35
+ interactionId: "moveRaider",
36
+ kind: "action",
37
+ inputs: [
38
+ {
39
+ key: "spaceId",
40
+ kind: "board-space",
41
+ domain: {
42
+ type: "target",
43
+ targetKind: "space",
44
+ eligibleTargets: ["space-1"],
45
+ },
46
+ },
47
+ {
48
+ key: "stealFromPlayerId",
49
+ kind: "form",
50
+ domain: {
51
+ type: "choice",
52
+ choices: [{ value: "player-2", label: "Player 2" }],
53
+ dependsOn: ["spaceId"],
54
+ },
55
+ },
56
+ ],
57
+ commit: { mode: "autoWhenReady" },
58
+ available: true,
59
+ },
60
+ ],
61
+ },
62
+ lobby: null,
63
+ notifications: [],
64
+ session: createSessionState(),
65
+ history: null,
66
+ syncId: 1,
67
+ };
68
+ }
69
+
70
+ function createRuntime(snapshot: PluginStateSnapshot): RuntimeAPI {
71
+ return {
72
+ validateInteraction: async () => ({ valid: true }),
73
+ submitInteraction: mock(async () => undefined),
74
+ getSessionState: () => snapshot.session,
75
+ disconnect: () => undefined,
76
+ getSnapshot: () => snapshot,
77
+ subscribeToState: () => () => undefined,
78
+ } as RuntimeAPI;
79
+ }
80
+
81
+ test("UI.Root renders children without owning interaction routing", () => {
82
+ const runtime = createRuntime(createSnapshot());
83
+ const interactionStore = createInteractionUiStore();
84
+ interactionStore.setInput("playing.moveRaider", "spaceId", "space-1");
85
+ interactionStore.setPendingInteraction("playing.moveRaider");
86
+
87
+ const html = renderToString(
88
+ <GameUIProvider runtime={runtime} interactionStore={interactionStore}>
89
+ <UI.Root>
90
+ <main>Board</main>
91
+ </UI.Root>
92
+ </GameUIProvider>,
93
+ ).replace(/<!-- -->/g, "");
94
+
95
+ expect(html).toContain("<main>Board</main>");
96
+ expect(html).not.toContain("stealFromPlayerId");
97
+ });
98
+
99
+ test("pending interaction is cleared when the draft submits through the interaction handle", async () => {
100
+ const runtime = createRuntime(createSnapshot());
101
+ const interactionStore = createInteractionUiStore();
102
+ interactionStore.setInput("playing.moveRaider", "spaceId", "space-1");
103
+ interactionStore.setInput(
104
+ "playing.moveRaider",
105
+ "stealFromPlayerId",
106
+ "player-2",
107
+ );
108
+ interactionStore.setPendingInteraction("playing.moveRaider");
109
+ let handle: InteractionHandle | null = null;
110
+
111
+ function Probe() {
112
+ const descriptor = runtime
113
+ .getSnapshot()
114
+ .gameplay.availableInteractions.find(
115
+ (candidate) => candidate.interactionKey === "playing.moveRaider",
116
+ );
117
+ if (!descriptor) throw new Error("missing descriptor");
118
+ handle = useInteractionHandle(descriptor);
119
+ return null;
120
+ }
121
+
122
+ renderToString(
123
+ <GameUIProvider runtime={runtime} interactionStore={interactionStore}>
124
+ <Probe />
125
+ </GameUIProvider>,
126
+ );
127
+
128
+ await handle?.submitDraft();
129
+
130
+ expect(interactionStore.getPendingInteraction()).toBeNull();
131
+ });
@@ -0,0 +1,13 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export interface UIRootProps {
4
+ children: ReactNode;
5
+ }
6
+
7
+ export function UIRoot({ children }: UIRootProps) {
8
+ return <>{children}</>;
9
+ }
10
+
11
+ export const UI = {
12
+ Root: UIRoot,
13
+ };