@dreamboard-games/ui-sdk 0.0.43 → 0.0.46

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 (172) 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 -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
  167. package/type-stubs/manifest-contract.d.ts +42 -0
  168. package/type-stubs/manifest-contract.d.ts.map +1 -0
  169. package/type-stubs/manifest-contract.js +72 -0
  170. package/type-stubs/ui-contract.d.ts +5 -0
  171. package/type-stubs/ui-contract.d.ts.map +1 -0
  172. package/type-stubs/ui-contract.js +1 -0
@@ -1,18 +1,31 @@
1
1
  import {
2
2
  createContext,
3
3
  useContext,
4
+ useMemo,
4
5
  type ButtonHTMLAttributes,
5
6
  type ReactNode,
6
7
  } from "react";
7
- import { useBoardInteractions } from "../hooks/useBoardInteractions.js";
8
- import { useInteractionHandle } from "../hooks/useInteractionHandle.js";
8
+ import {
9
+ createHexBoardView,
10
+ type HexBoardView,
11
+ } from "../components/board/hex-board-view.js";
12
+ import {
13
+ HexGrid,
14
+ type HexGridBoardProps,
15
+ } from "../components/board/HexGrid.js";
16
+ import {
17
+ useBoardInteractions,
18
+ type BoardInteractionsContext,
19
+ type BoardSelectionResult,
20
+ type BoardTargetLayerOptions,
21
+ } from "../hooks/useBoardInteractions.js";
9
22
  import { usePluginState } from "../context/PluginStateContext.js";
23
+ import type { AnyHexBoardInput, BoardSpaceIdOf } from "../types/tiled-board.js";
10
24
  import type { InteractionDescriptor } from "../types/plugin-state.js";
11
25
  import type { BoardTargetKey } from "../ui-contract.js";
12
26
  import {
13
27
  inputByKey,
14
28
  inputKeyForTarget,
15
- interactionInputKeys,
16
29
  type BoardTargetKind,
17
30
  } from "../utils/interaction-inputs.js";
18
31
  import {
@@ -20,8 +33,10 @@ import {
20
33
  renderPrimitive,
21
34
  type PrimitiveCommonProps,
22
35
  } from "./primitive-props.js";
36
+ import { runInteractionAction } from "./interaction-submit.js";
37
+ import { useGameActionError } from "./game.js";
23
38
 
24
- type BoardContextValue = ReturnType<typeof useBoardInteractions>;
39
+ type BoardContextValue = BoardInteractionsContext;
25
40
 
26
41
  const BoardContext = createContext<BoardContextValue | null>(null);
27
42
 
@@ -51,6 +66,118 @@ export function BoardRoot({ children, targetKinds, ...props }: BoardRootProps) {
51
66
  );
52
67
  }
53
68
 
69
+ export interface BoardStateProps {
70
+ children: (board: BoardInteractionsContext) => ReactNode;
71
+ }
72
+
73
+ export function BoardState({ children }: BoardStateProps) {
74
+ return <>{children(useBoardPrimitiveContext())}</>;
75
+ }
76
+
77
+ export interface BoardHexViewProps<
78
+ TBoard extends AnyHexBoardInput,
79
+ TSpaceView extends { id: BoardSpaceIdOf<TBoard> },
80
+ > {
81
+ board: TBoard;
82
+ spaces: readonly TSpaceView[];
83
+ children: (view: HexBoardView<TBoard, TSpaceView>) => ReactNode;
84
+ }
85
+
86
+ export function BoardHexView<
87
+ const TBoard extends AnyHexBoardInput,
88
+ const TSpaceView extends { id: BoardSpaceIdOf<TBoard> },
89
+ >({ board, spaces, children }: BoardHexViewProps<TBoard, TSpaceView>) {
90
+ const view = useMemo(
91
+ () => createHexBoardView<TBoard, TSpaceView>(board, { spaces }),
92
+ [board, spaces],
93
+ );
94
+ return <>{children(view)}</>;
95
+ }
96
+
97
+ export interface BoardHexGridInteractions {
98
+ edge?: BoardTargetLayerOptions;
99
+ vertex?: BoardTargetLayerOptions;
100
+ space?: BoardTargetLayerOptions;
101
+ }
102
+
103
+ export type BoardHexGridInteractionFilter =
104
+ | "auto"
105
+ | false
106
+ | {
107
+ edge?: readonly string[];
108
+ vertex?: readonly string[];
109
+ space?: readonly string[];
110
+ };
111
+
112
+ type BoardHexGridView<
113
+ TBoard extends AnyHexBoardInput,
114
+ TSpaceView extends { id: BoardSpaceIdOf<TBoard> },
115
+ > = HexBoardView<TBoard, TSpaceView> & AnyHexBoardInput;
116
+
117
+ export type BoardHexGridProps<
118
+ TBoard extends AnyHexBoardInput,
119
+ TSpaceView extends { id: BoardSpaceIdOf<TBoard> },
120
+ > = Omit<
121
+ HexGridBoardProps<BoardHexGridView<TBoard, TSpaceView>>,
122
+ "board" | "interactiveEdges" | "interactiveVertices" | "interactiveSpaces"
123
+ > &
124
+ Omit<BoardHexViewProps<TBoard, TSpaceView>, "children"> & {
125
+ interactions?: BoardHexGridInteractionFilter;
126
+ };
127
+
128
+ export function BoardHexGrid<
129
+ const TBoard extends AnyHexBoardInput,
130
+ const TSpaceView extends { id: BoardSpaceIdOf<TBoard> },
131
+ >({
132
+ board,
133
+ spaces,
134
+ interactions = "auto",
135
+ ...props
136
+ }: BoardHexGridProps<TBoard, TSpaceView>) {
137
+ const boardInteractions = useBoardPrimitiveContext();
138
+ const gameActionError = useGameActionError();
139
+ const edgeLayer =
140
+ interactions === false
141
+ ? undefined
142
+ : boardInteractions.targetLayers.edge({
143
+ enabled: boardInteractions.eligible.edge.size > 0,
144
+ interactionKeys:
145
+ interactions === "auto" ? undefined : interactions.edge,
146
+ onError: gameActionError ?? undefined,
147
+ });
148
+ const vertexLayer =
149
+ interactions === false
150
+ ? undefined
151
+ : boardInteractions.targetLayers.vertex({
152
+ enabled: boardInteractions.eligible.vertex.size > 0,
153
+ interactionKeys:
154
+ interactions === "auto" ? undefined : interactions.vertex,
155
+ onError: gameActionError ?? undefined,
156
+ });
157
+ const spaceLayer =
158
+ interactions === false
159
+ ? undefined
160
+ : boardInteractions.targetLayers.space({
161
+ enabled: boardInteractions.eligible.space.size > 0,
162
+ interactionKeys:
163
+ interactions === "auto" ? undefined : interactions.space,
164
+ onError: gameActionError ?? undefined,
165
+ });
166
+ return (
167
+ <BoardHexView board={board} spaces={spaces}>
168
+ {(view) => (
169
+ <HexGrid
170
+ {...props}
171
+ board={view as BoardHexGridView<TBoard, TSpaceView>}
172
+ interactiveEdges={edgeLayer}
173
+ interactiveVertices={vertexLayer}
174
+ interactiveSpaces={spaceLayer}
175
+ />
176
+ )}
177
+ </BoardHexView>
178
+ );
179
+ }
180
+
54
181
  export type BoardTargetExtraInputs =
55
182
  | Record<string, unknown>
56
183
  | ((targetId: string) => Record<string, unknown>);
@@ -63,7 +190,7 @@ export type BoardTargetProps<Target extends string = BoardTargetKey> =
63
190
  interaction?: string;
64
191
  input?: string;
65
192
  extraInputs?: BoardTargetExtraInputs;
66
- onSelect?: (interactionKey: string | null) => void;
193
+ onSelect?: (result: BoardSelectionResult) => void;
67
194
  onSelectError?: (error: unknown) => void;
68
195
  };
69
196
 
@@ -97,6 +224,35 @@ export function BoardTarget<Target extends string = BoardTargetKey>({
97
224
  return <UnambiguousBoardTarget input={input} {...props} />;
98
225
  }
99
226
 
227
+ export type BoardSpaceTargetProps<Target extends string = BoardTargetKey> =
228
+ Omit<BoardTargetProps<Target>, "kind">;
229
+
230
+ export function BoardSpaceTarget<Target extends string = BoardTargetKey>(
231
+ props: BoardSpaceTargetProps<Target>,
232
+ ) {
233
+ return <BoardTarget kind="space" {...props} />;
234
+ }
235
+
236
+ export type BoardEdgeTargetProps<Target extends string = BoardTargetKey> = Omit<
237
+ BoardTargetProps<Target>,
238
+ "kind"
239
+ >;
240
+
241
+ export function BoardEdgeTarget<Target extends string = BoardTargetKey>(
242
+ props: BoardEdgeTargetProps<Target>,
243
+ ) {
244
+ return <BoardTarget kind="edge" {...props} />;
245
+ }
246
+
247
+ export type BoardVertexTargetProps<Target extends string = BoardTargetKey> =
248
+ Omit<BoardTargetProps<Target>, "kind">;
249
+
250
+ export function BoardVertexTarget<Target extends string = BoardTargetKey>(
251
+ props: BoardVertexTargetProps<Target>,
252
+ ) {
253
+ return <BoardTarget kind="vertex" {...props} />;
254
+ }
255
+
100
256
  function UnambiguousBoardTarget({
101
257
  kind,
102
258
  value,
@@ -109,6 +265,7 @@ function UnambiguousBoardTarget({
109
265
  ...props
110
266
  }: Omit<BoardTargetProps, "interaction">) {
111
267
  const board = useBoardPrimitiveContext();
268
+ const gameActionError = useGameActionError();
112
269
  const eligible = board.isEligible(value, kind);
113
270
  const ambiguous = isAmbiguousBoardTarget(board.interactions, kind, value);
114
271
  const isDisabled = disabled ?? (!eligible || ambiguous);
@@ -126,14 +283,13 @@ function UnambiguousBoardTarget({
126
283
  "data-disabled": isDisabled || undefined,
127
284
  onClick: composeEventHandlers(onClick, () => {
128
285
  if (isDisabled) return;
129
- void board.select[kind](value, resolveExtraInputs(extraInputs, value))
130
- .then((interactionKey) => {
131
- onSelect?.(interactionKey);
132
- })
133
- .catch((error) => {
134
- onSelectError?.(error);
135
- if (!onSelectError) throw error;
136
- });
286
+ void runInteractionAction(
287
+ () => board.select[kind](value, resolveExtraInputs(extraInputs, value)),
288
+ {
289
+ onSuccess: onSelect,
290
+ onError: onSelectError ?? gameActionError ?? undefined,
291
+ },
292
+ );
137
293
  }),
138
294
  });
139
295
  }
@@ -153,7 +309,8 @@ function ExplicitBoardTarget({
153
309
  }: Omit<BoardTargetProps, "interaction"> & {
154
310
  descriptor: InteractionDescriptor;
155
311
  }) {
156
- const handle = useInteractionHandle(descriptor);
312
+ const board = useBoardPrimitiveContext();
313
+ const gameActionError = useGameActionError();
157
314
  const inputKey = input ?? inputKeyForTarget(descriptor, kind, value);
158
315
  const inputDescriptor = inputKey
159
316
  ? inputByKey(descriptor, inputKey)
@@ -182,32 +339,20 @@ function ExplicitBoardTarget({
182
339
  onClick: composeEventHandlers(onClick, () => {
183
340
  if (isDisabled || !inputKey) return;
184
341
  const resolvedExtraInputs = resolveExtraInputs(extraInputs, value);
185
- for (const [key, extraValue] of Object.entries(resolvedExtraInputs)) {
186
- handle.setInput(key, extraValue);
187
- }
188
- handle.setInput(inputKey, value);
189
- if (descriptor.commit.mode !== "autoWhenReady") {
190
- handle.arm();
191
- onSelect?.(descriptor.interactionKey);
192
- return;
193
- }
194
- const params: Record<string, unknown> = {
195
- ...handle.draft,
196
- ...resolvedExtraInputs,
197
- [inputKey]: value,
198
- };
199
- for (const required of interactionInputKeys(descriptor)) {
200
- if (!(required in params)) params[required] = null;
201
- }
202
- void handle
203
- .submit(params)
204
- .then(() => {
205
- onSelect?.(descriptor.interactionKey);
206
- })
207
- .catch((error) => {
208
- onSelectError?.(error);
209
- if (!onSelectError) throw error;
210
- });
342
+ void runInteractionAction(
343
+ () =>
344
+ board.selectTarget(
345
+ descriptor,
346
+ kind,
347
+ value,
348
+ inputKey,
349
+ resolvedExtraInputs,
350
+ ),
351
+ {
352
+ onSuccess: onSelect,
353
+ onError: onSelectError ?? gameActionError ?? undefined,
354
+ },
355
+ );
211
356
  }),
212
357
  });
213
358
  }
@@ -263,5 +408,11 @@ function isAmbiguousBoardTarget(
263
408
 
264
409
  export const Board = {
265
410
  Root: BoardRoot,
411
+ State: BoardState,
412
+ HexGrid: BoardHexGrid,
413
+ HexView: BoardHexView,
266
414
  Target: BoardTarget,
415
+ SpaceTarget: BoardSpaceTarget,
416
+ EdgeTarget: BoardEdgeTarget,
417
+ VertexTarget: BoardVertexTarget,
267
418
  };
@@ -0,0 +1,58 @@
1
+ import { useCallback, useMemo, useState } from "react";
2
+
3
+ export type DialogLifecycleState = "open" | "minimized" | "dismissed";
4
+
5
+ export interface DialogLifecycleValue {
6
+ state: DialogLifecycleState;
7
+ open: boolean;
8
+ minimized: boolean;
9
+ dismissed: boolean;
10
+ setOpen: (open: boolean) => void;
11
+ restore: () => void;
12
+ minimize: () => void;
13
+ dismiss: () => void;
14
+ }
15
+
16
+ export interface DialogLifecycleOptions {
17
+ defaultOpen?: boolean;
18
+ onStateChange?: (state: DialogLifecycleState) => void;
19
+ }
20
+
21
+ export function useDialogLifecycle({
22
+ defaultOpen = true,
23
+ onStateChange,
24
+ }: DialogLifecycleOptions): DialogLifecycleValue {
25
+ const [state, setState] = useState<DialogLifecycleState>(
26
+ defaultOpen ? "open" : "minimized",
27
+ );
28
+ const updateState = useCallback(
29
+ (nextState: DialogLifecycleState) => {
30
+ setState(nextState);
31
+ onStateChange?.(nextState);
32
+ },
33
+ [onStateChange],
34
+ );
35
+ const restore = useCallback(() => updateState("open"), [updateState]);
36
+ const minimize = useCallback(() => updateState("minimized"), [updateState]);
37
+ const dismiss = useCallback(() => updateState("dismissed"), [updateState]);
38
+ const setOpen = useCallback(
39
+ (open: boolean) => {
40
+ updateState(open ? "open" : "minimized");
41
+ },
42
+ [updateState],
43
+ );
44
+
45
+ return useMemo<DialogLifecycleValue>(
46
+ () => ({
47
+ state,
48
+ open: state === "open",
49
+ minimized: state === "minimized",
50
+ dismissed: state === "dismissed",
51
+ setOpen,
52
+ restore,
53
+ minimize,
54
+ dismiss,
55
+ }),
56
+ [dismiss, minimize, restore, setOpen, state],
57
+ );
58
+ }
@@ -0,0 +1,47 @@
1
+ import { expect, test } from "bun:test";
2
+ import { renderToString } from "react-dom/server";
3
+ import { Dice } from "./dice.js";
4
+
5
+ test("Dice primitives expose normalized readonly values and sum", () => {
6
+ const html = renderToString(
7
+ <Dice.Root values={[3, 4] as const} count={2}>
8
+ <Dice.Values>
9
+ {({ values, sum, diceCount, allRolled }) => (
10
+ <span
11
+ data-values={values?.join(",")}
12
+ data-sum={sum}
13
+ data-count={diceCount}
14
+ data-all-rolled={allRolled}
15
+ />
16
+ )}
17
+ </Dice.Values>
18
+ </Dice.Root>,
19
+ );
20
+
21
+ expect(html).toContain('data-values="3,4"');
22
+ expect(html).toContain('data-sum="7"');
23
+ expect(html).toContain('data-count="2"');
24
+ expect(html).toContain('data-all-rolled="true"');
25
+ });
26
+
27
+ test("Dice primitives preserve unrolled state without a local adapter", () => {
28
+ const html = renderToString(
29
+ <Dice.Root values={null} count={2}>
30
+ <Dice.Values>
31
+ {({ values, sum, diceCount, allRolled }) => (
32
+ <span
33
+ data-values={values?.join(",") ?? "none"}
34
+ data-sum={sum ?? "none"}
35
+ data-count={diceCount}
36
+ data-all-rolled={allRolled}
37
+ />
38
+ )}
39
+ </Dice.Values>
40
+ </Dice.Root>,
41
+ );
42
+
43
+ expect(html).toContain('data-values="none"');
44
+ expect(html).toContain('data-sum="none"');
45
+ expect(html).toContain('data-count="2"');
46
+ expect(html).toContain('data-all-rolled="false"');
47
+ });
@@ -0,0 +1,79 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+
3
+ export type DiceValue = number | null | undefined;
4
+
5
+ export interface DiceState {
6
+ values: ReadonlyArray<number | undefined> | undefined;
7
+ /** Undefined if any die has not been rolled yet. */
8
+ sum: number | undefined;
9
+ diceCount: number;
10
+ allRolled: boolean;
11
+ }
12
+
13
+ export interface DiceRootProps {
14
+ values?: readonly DiceValue[] | null;
15
+ /** Used when values are not provided. */
16
+ count?: number;
17
+ children: ReactNode;
18
+ }
19
+
20
+ export interface DiceValuesProps {
21
+ children: (state: DiceState) => ReactNode;
22
+ }
23
+
24
+ export interface DiceComponents {
25
+ Root(props: DiceRootProps): ReactNode;
26
+ Values(props: DiceValuesProps): ReactNode;
27
+ }
28
+
29
+ const DiceContext = createContext<DiceState | null>(null);
30
+
31
+ export function normalizeDiceState({
32
+ values,
33
+ count = 2,
34
+ }: {
35
+ values?: readonly DiceValue[] | null;
36
+ count?: number;
37
+ }): DiceState {
38
+ const normalizedValues = values?.map((value) => value ?? undefined);
39
+ const allRolled =
40
+ normalizedValues?.every((value) => value !== undefined) ?? false;
41
+ const sum = allRolled
42
+ ? normalizedValues?.reduce<number>(
43
+ (total, value) => total + (value ?? 0),
44
+ 0,
45
+ )
46
+ : undefined;
47
+
48
+ return {
49
+ values: normalizedValues,
50
+ sum,
51
+ diceCount: normalizedValues?.length ?? count,
52
+ allRolled,
53
+ };
54
+ }
55
+
56
+ export function DiceRoot({ values, count, children }: DiceRootProps) {
57
+ return (
58
+ <DiceContext.Provider value={normalizeDiceState({ values, count })}>
59
+ {children}
60
+ </DiceContext.Provider>
61
+ );
62
+ }
63
+
64
+ export function useDicePrimitiveContext(): DiceState {
65
+ const value = useContext(DiceContext);
66
+ if (!value) {
67
+ throw new Error("Dice primitives must be rendered inside <Dice.Root>.");
68
+ }
69
+ return value;
70
+ }
71
+
72
+ export function DiceValues({ children }: DiceValuesProps) {
73
+ return children(useDicePrimitiveContext());
74
+ }
75
+
76
+ export const Dice: DiceComponents = {
77
+ Root: DiceRoot,
78
+ Values: DiceValues,
79
+ };
@@ -0,0 +1,98 @@
1
+ import { expect, test } from "bun:test";
2
+ import { renderToString } from "react-dom/server";
3
+ import type { PluginStateSnapshot } from "../types/plugin-state.js";
4
+ import type { PluginSessionState, RuntimeAPI } from "../types/runtime-api.js";
5
+ import { Game } from "./game.js";
6
+ import { GameUIProvider } from "./game-ui-provider.js";
7
+
8
+ function createSessionState(
9
+ overrides: Partial<PluginSessionState> = {},
10
+ ): PluginSessionState {
11
+ return {
12
+ status: "ready",
13
+ sessionId: "session-1",
14
+ controllablePlayerIds: ["player-1", "player-2"],
15
+ controllingPlayerId: "player-1",
16
+ userId: "user-1",
17
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ function createSnapshot(): PluginStateSnapshot {
22
+ return {
23
+ view: {
24
+ score: 7,
25
+ },
26
+ gameplay: {
27
+ currentPhase: "playing",
28
+ currentStage: "main",
29
+ activePlayers: ["player-2"],
30
+ zones: {},
31
+ availableInteractions: [],
32
+ },
33
+ lobby: {
34
+ hostUserId: "user-1",
35
+ canStart: true,
36
+ seats: [
37
+ {
38
+ playerId: "player-1",
39
+ displayName: "Ada",
40
+ playerColor: "#d04848",
41
+ isHost: true,
42
+ },
43
+ {
44
+ playerId: "player-2",
45
+ displayName: "Grace",
46
+ playerColor: "#2d5da1",
47
+ },
48
+ ],
49
+ },
50
+ notifications: [],
51
+ session: createSessionState(),
52
+ history: null,
53
+ syncId: 1,
54
+ };
55
+ }
56
+
57
+ function createRuntime(snapshot: PluginStateSnapshot): RuntimeAPI {
58
+ return {
59
+ validateInteraction: async () => ({ valid: true }),
60
+ submitInteraction: async () => undefined,
61
+ getSessionState: () => snapshot.session,
62
+ disconnect: () => undefined,
63
+ getSnapshot: () => snapshot,
64
+ subscribeToState: () => () => undefined,
65
+ } as RuntimeAPI;
66
+ }
67
+
68
+ test("Game.Root projects typed author-facing game, player, and turn state", () => {
69
+ const runtime = createRuntime(createSnapshot());
70
+
71
+ const html = renderToString(
72
+ <GameUIProvider runtime={runtime}>
73
+ <Game.Root<{ score: number }, "player-1" | "player-2", "playing">>
74
+ {({ view, phase, stage, me, players, turn }) => (
75
+ <section>
76
+ <span>score:{view.score}</span>
77
+ <span>phase:{phase}</span>
78
+ <span>stage:{stage}</span>
79
+ <span>me:{me.player?.name}</span>
80
+ <span>current:{players.current?.name}</span>
81
+ <span>active:{turn.currentPlayerId}</span>
82
+ <span>mine:{String(turn.isMine)}</span>
83
+ <span>entries:{players.entries.length}</span>
84
+ </section>
85
+ )}
86
+ </Game.Root>
87
+ </GameUIProvider>,
88
+ ).replace(/<!-- -->/g, "");
89
+
90
+ expect(html).toContain("score:7");
91
+ expect(html).toContain("phase:playing");
92
+ expect(html).toContain("stage:main");
93
+ expect(html).toContain("me:Ada");
94
+ expect(html).toContain("current:Grace");
95
+ expect(html).toContain("active:player-2");
96
+ expect(html).toContain("mine:false");
97
+ expect(html).toContain("entries:2");
98
+ });