@dreamboard-games/cli 0.1.30-alpha.0

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 (134) hide show
  1. package/README.md +50 -0
  2. package/dist/agent-verifier/agent-workspace-verifier.mjs +227 -0
  3. package/dist/agent-verifier/chunk-2E5P5NWG.mjs +835 -0
  4. package/dist/agent-verifier/chunk-2GBBP27W.mjs +301 -0
  5. package/dist/agent-verifier/chunk-2NZNKIND.mjs +166 -0
  6. package/dist/agent-verifier/chunk-2QMNAVV4.mjs +14522 -0
  7. package/dist/agent-verifier/chunk-2SZHMP6F.mjs +264 -0
  8. package/dist/agent-verifier/chunk-54TAYXUD.mjs +12 -0
  9. package/dist/agent-verifier/chunk-6A5HRJMQ.mjs +3174 -0
  10. package/dist/agent-verifier/chunk-6UUJEYDV.mjs +213 -0
  11. package/dist/agent-verifier/chunk-7653FPGJ.mjs +381 -0
  12. package/dist/agent-verifier/chunk-BVVNBJM4.mjs +221 -0
  13. package/dist/agent-verifier/chunk-CEDUHGNH.mjs +74 -0
  14. package/dist/agent-verifier/chunk-CEQ2VJWN.mjs +149 -0
  15. package/dist/agent-verifier/chunk-CFU5EWIC.mjs +69 -0
  16. package/dist/agent-verifier/chunk-DTMJCPS4.mjs +730 -0
  17. package/dist/agent-verifier/chunk-EIQWDQWJ.mjs +186 -0
  18. package/dist/agent-verifier/chunk-EOQIV6PS.mjs +649 -0
  19. package/dist/agent-verifier/chunk-HBNDKQT5.mjs +8381 -0
  20. package/dist/agent-verifier/chunk-HJFQDSTU.mjs +225 -0
  21. package/dist/agent-verifier/chunk-LI3ZR3BI.mjs +41 -0
  22. package/dist/agent-verifier/chunk-LM3OZLZG.mjs +48 -0
  23. package/dist/agent-verifier/chunk-MINCYHXN.mjs +106 -0
  24. package/dist/agent-verifier/chunk-MRCUP5SW.mjs +128 -0
  25. package/dist/agent-verifier/chunk-PM3SVG6R.mjs +38 -0
  26. package/dist/agent-verifier/chunk-RBDDIIPM.mjs +19 -0
  27. package/dist/agent-verifier/chunk-RJBLBYHX.mjs +1681 -0
  28. package/dist/agent-verifier/chunk-SHUMAVAP.mjs +59 -0
  29. package/dist/agent-verifier/chunk-SYPLYRGB.mjs +2812 -0
  30. package/dist/agent-verifier/chunk-U6OJN7XS.mjs +8092 -0
  31. package/dist/agent-verifier/chunk-VYJTHSYR.mjs +44 -0
  32. package/dist/agent-verifier/chunk-XYDL7GY6.mjs +10 -0
  33. package/dist/agent-verifier/compile-WNCQQVOF.mjs +313 -0
  34. package/dist/agent-verifier/global-config-WX3ZZIVU.mjs +17 -0
  35. package/dist/agent-verifier/keychain-backend-TNOPQV3Z.mjs +134 -0
  36. package/dist/agent-verifier/local-files-MTPLP62S.mjs +46 -0
  37. package/dist/agent-verifier/local-typecheck-QFYYAZOK.mjs +9 -0
  38. package/dist/agent-verifier/materialize-workspace-EWGZIVOY.mjs +90 -0
  39. package/dist/agent-verifier/project-state-7GR6BQTQ.mjs +32 -0
  40. package/dist/agent-verifier/prompt-3BAINGAQ.mjs +755 -0
  41. package/dist/agent-verifier/reducer-bundle-preflight-C73LEXI2.mjs +23 -0
  42. package/dist/agent-verifier/reducer-contract-preflight-22X7DSZW.mjs +10 -0
  43. package/dist/agent-verifier/reducer-native-test-harness-GMWBUISX.mjs +53 -0
  44. package/dist/agent-verifier/static-scaffold-4YEQME5N.mjs +28 -0
  45. package/dist/agent-verifier/sync-LOQAH4RC.mjs +594 -0
  46. package/dist/agent-verifier/test-YOJERVHN.mjs +356 -0
  47. package/dist/agent-verifier/testing-5K2BJYF2.mjs +674 -0
  48. package/dist/agent-verifier/workspace-codegen-JDZJRSDV.mjs +11 -0
  49. package/dist/agent-verifier/workspace-dependencies-HZ6VVS4G.mjs +14 -0
  50. package/dist/chunk-2H7UOFLK.js +11 -0
  51. package/dist/chunk-2H7UOFLK.js.map +1 -0
  52. package/dist/chunk-3XNJT3RK.js +39809 -0
  53. package/dist/chunk-3XNJT3RK.js.map +1 -0
  54. package/dist/chunk-7FOO4AJI.js +50 -0
  55. package/dist/chunk-7FOO4AJI.js.map +1 -0
  56. package/dist/chunk-TSJVWTJO.js +430 -0
  57. package/dist/chunk-TSJVWTJO.js.map +1 -0
  58. package/dist/dev-host/components/drawer.tsx +132 -0
  59. package/dist/dev-host/components/input.tsx +21 -0
  60. package/dist/dev-host/dev-api-proxy-plugin.ts +328 -0
  61. package/dist/dev-host/dev-author-dom-warnings.ts +100 -0
  62. package/dist/dev-host/dev-diagnostics.ts +62 -0
  63. package/dist/dev-host/dev-fallback-stylesheet.ts +53 -0
  64. package/dist/dev-host/dev-hmr-guard-plugin.ts +47 -0
  65. package/dist/dev-host/dev-host-controller.ts +674 -0
  66. package/dist/dev-host/dev-host-player-query.ts +17 -0
  67. package/dist/dev-host/dev-host-session-transport.ts +52 -0
  68. package/dist/dev-host/dev-host-storage.ts +56 -0
  69. package/dist/dev-host/dev-log-relay-plugin.ts +510 -0
  70. package/dist/dev-host/dev-runtime-config.ts +14 -0
  71. package/dist/dev-host/dev-runtime-platform.ts +335 -0
  72. package/dist/dev-host/dev-virtual-modules-plugin.ts +64 -0
  73. package/dist/dev-host/host-main.css +224 -0
  74. package/dist/dev-host/host-main.tsx +948 -0
  75. package/dist/dev-host/index.html +56 -0
  76. package/dist/dev-host/lib/utils.ts +6 -0
  77. package/dist/dev-host/plugin-main.ts +61 -0
  78. package/dist/dev-host/plugin.html +24 -0
  79. package/dist/dev-host/shared-styles.css +144 -0
  80. package/dist/dev-host/start-dev-server.ts +140 -0
  81. package/dist/dev-host/virtual-modules.d.ts +27 -0
  82. package/dist/global-config-UKSWNDTX.js +15 -0
  83. package/dist/global-config-UKSWNDTX.js.map +1 -0
  84. package/dist/index.js +8473 -0
  85. package/dist/index.js.map +1 -0
  86. package/dist/internal.d.ts +311 -0
  87. package/dist/internal.js +52 -0
  88. package/dist/internal.js.map +1 -0
  89. package/dist/keychain-backend-JHTXAKWC.js +135 -0
  90. package/dist/keychain-backend-JHTXAKWC.js.map +1 -0
  91. package/dist/prompt-GMZABCJC.js +756 -0
  92. package/dist/prompt-GMZABCJC.js.map +1 -0
  93. package/dist/runtime-packages/ui-host-runtime/src/actor-principal.ts +71 -0
  94. package/dist/runtime-packages/ui-host-runtime/src/browser-interaction.ts +139 -0
  95. package/dist/runtime-packages/ui-host-runtime/src/components/host-controls.tsx +374 -0
  96. package/dist/runtime-packages/ui-host-runtime/src/components/host-feedback-toaster.tsx +266 -0
  97. package/dist/runtime-packages/ui-host-runtime/src/components/host-feedback.tsx +212 -0
  98. package/dist/runtime-packages/ui-host-runtime/src/components/host-primitives.tsx +271 -0
  99. package/dist/runtime-packages/ui-host-runtime/src/components/host-session-metadata.tsx +135 -0
  100. package/dist/runtime-packages/ui-host-runtime/src/components/index.ts +5 -0
  101. package/dist/runtime-packages/ui-host-runtime/src/components/perf-overlay.tsx +194 -0
  102. package/dist/runtime-packages/ui-host-runtime/src/gameplay-authority-transport.ts +626 -0
  103. package/dist/runtime-packages/ui-host-runtime/src/host-controls.tsx +1 -0
  104. package/dist/runtime-packages/ui-host-runtime/src/host-feedback.tsx +1 -0
  105. package/dist/runtime-packages/ui-host-runtime/src/host-session-transport.ts +294 -0
  106. package/dist/runtime-packages/ui-host-runtime/src/index.ts +3 -0
  107. package/dist/runtime-packages/ui-host-runtime/src/logger.ts +11 -0
  108. package/dist/runtime-packages/ui-host-runtime/src/perf.ts +324 -0
  109. package/dist/runtime-packages/ui-host-runtime/src/plugin-bridge.ts +195 -0
  110. package/dist/runtime-packages/ui-host-runtime/src/plugin-health-check.ts +138 -0
  111. package/dist/runtime-packages/ui-host-runtime/src/plugin-messages.ts +159 -0
  112. package/dist/runtime-packages/ui-host-runtime/src/plugin-session-gateway.ts +551 -0
  113. package/dist/runtime-packages/ui-host-runtime/src/runtime/index.ts +13 -0
  114. package/dist/runtime-packages/ui-host-runtime/src/screenshot/projection-to-snapshot.ts +122 -0
  115. package/dist/runtime-packages/ui-host-runtime/src/screenshot/static-store-api.ts +26 -0
  116. package/dist/runtime-packages/ui-host-runtime/src/session-ingress-controller.ts +583 -0
  117. package/dist/runtime-packages/ui-host-runtime/src/session-ingress.ts +219 -0
  118. package/dist/runtime-packages/ui-host-runtime/src/session-live-runtime.ts +117 -0
  119. package/dist/runtime-packages/ui-host-runtime/src/session-model.ts +431 -0
  120. package/dist/runtime-packages/ui-host-runtime/src/session-projection.ts +211 -0
  121. package/dist/runtime-packages/ui-host-runtime/src/session-recovery.ts +80 -0
  122. package/dist/runtime-packages/ui-host-runtime/src/session-state-reducer.ts +1034 -0
  123. package/dist/runtime-packages/ui-host-runtime/src/sse-manager.ts +416 -0
  124. package/dist/runtime-packages/ui-host-runtime/src/unified-session-store.ts +184 -0
  125. package/dist/scaffold/assets/static/app/tsconfig.framework.json +26 -0
  126. package/dist/scaffold/assets/static/app/tsconfig.json +3 -0
  127. package/dist/scaffold/assets/static/ui/index.tsx +13 -0
  128. package/dist/scaffold/assets/static/ui/style.css +4 -0
  129. package/dist/scaffold/assets/static/ui/tsconfig.framework.json +19 -0
  130. package/dist/scaffold/assets/static/ui/tsconfig.json +5 -0
  131. package/dist/testing-KLSV6CPJ.js +674 -0
  132. package/dist/testing-KLSV6CPJ.js.map +1 -0
  133. package/package.json +72 -0
  134. package/skills/dreamboard/SKILL.md +130 -0
@@ -0,0 +1,1034 @@
1
+ import type {
2
+ GameplayPerspective,
3
+ GameplayViewport,
4
+ SessionContext,
5
+ UnifiedSessionModel,
6
+ } from "./session-model.js";
7
+ import {
8
+ gameplayViewportForPlayer,
9
+ getGameplayViewport,
10
+ getSessionContext,
11
+ resolveControllablePlayerIds,
12
+ withContext,
13
+ } from "./session-model.js";
14
+ import type {
15
+ NormalizedSessionEvent,
16
+ NormalizedSessionSnapshot,
17
+ } from "./session-ingress.js";
18
+
19
+ interface ActivityItemBase<
20
+ TType extends string,
21
+ TPayload extends { type: TType },
22
+ > {
23
+ id: string;
24
+ type: TType;
25
+ payload: TPayload;
26
+ timestamp: number;
27
+ }
28
+
29
+ export type Notification =
30
+ | (ActivityItemBase<
31
+ "YOUR_TURN",
32
+ { type: "YOUR_TURN"; activePlayers: string[] }
33
+ > & {
34
+ read: boolean;
35
+ })
36
+ | (ActivityItemBase<
37
+ "PROMPT_OPENED",
38
+ {
39
+ type: "PROMPT_OPENED";
40
+ promptId: string;
41
+ promptInstanceId: string;
42
+ targetPlayer: string;
43
+ title?: string;
44
+ }
45
+ > & { read: boolean })
46
+ | (ActivityItemBase<
47
+ "ACTION_EXECUTED",
48
+ { type: "ACTION_EXECUTED"; playerId: string; actionType: string }
49
+ > & { read: boolean })
50
+ | (ActivityItemBase<
51
+ "ACTION_REJECTED",
52
+ { type: "ACTION_REJECTED"; reason: string; targetPlayer?: string }
53
+ > & { read: boolean })
54
+ | (ActivityItemBase<
55
+ "TURN_CHANGED",
56
+ {
57
+ type: "TURN_CHANGED";
58
+ previousPlayers: string[];
59
+ currentPlayers: string[];
60
+ }
61
+ > & { read: boolean })
62
+ | (ActivityItemBase<
63
+ "STATE_CHANGED",
64
+ { type: "STATE_CHANGED"; newState: string }
65
+ > & {
66
+ read: boolean;
67
+ })
68
+ | (ActivityItemBase<
69
+ "GAME_ENDED",
70
+ {
71
+ type: "GAME_ENDED";
72
+ winner?: string;
73
+ finalScores: Record<string, number>;
74
+ reason: string;
75
+ }
76
+ > & { read: boolean })
77
+ | (ActivityItemBase<
78
+ "ERROR",
79
+ { type: "ERROR"; message: string; code?: string }
80
+ > & {
81
+ read: boolean;
82
+ });
83
+
84
+ export type NotificationType = Notification["type"];
85
+ export type NotificationPayload = Notification["payload"];
86
+
87
+ export type HostFeedback =
88
+ | ActivityItemBase<
89
+ "ACTION_REJECTED",
90
+ { type: "ACTION_REJECTED"; reason: string; targetPlayer?: string }
91
+ >
92
+ | ActivityItemBase<
93
+ "PROMPT_OPENED",
94
+ {
95
+ type: "PROMPT_OPENED";
96
+ promptId: string;
97
+ promptInstanceId: string;
98
+ targetPlayer: string;
99
+ title?: string;
100
+ }
101
+ >
102
+ | ActivityItemBase<
103
+ "YOUR_TURN",
104
+ { type: "YOUR_TURN"; activePlayers: string[] }
105
+ >;
106
+
107
+ export type HostFeedbackType = HostFeedback["type"];
108
+ export type HostFeedbackPayload = HostFeedback["payload"];
109
+
110
+ export interface SSEEventEntry {
111
+ id: number;
112
+ eventType: string;
113
+ data: unknown;
114
+ timestamp: string;
115
+ }
116
+
117
+ export interface ConnectionState {
118
+ lobby: boolean;
119
+ gameplay: boolean;
120
+ error: string | null;
121
+ isConnected: boolean;
122
+ recovery: ConnectionRecoveryState;
123
+ }
124
+
125
+ export interface ConnectionRecoveryState {
126
+ active: boolean;
127
+ message: string | null;
128
+ retryAfterMs: number | null;
129
+ attempt: number | null;
130
+ since: number | null;
131
+ }
132
+
133
+ export interface ActivityState {
134
+ notifications: Notification[];
135
+ hostFeedback: HostFeedback[];
136
+ syncId: number;
137
+ lastSyncTimestamp: number | null;
138
+ lastAckTimestamp: number | null;
139
+ }
140
+
141
+ export interface DebugState {
142
+ sseEvents: SSEEventEntry[];
143
+ maxEvents: number;
144
+ }
145
+
146
+ export interface UnifiedSessionState {
147
+ session: UnifiedSessionModel;
148
+ connection: ConnectionState;
149
+ activity: ActivityState;
150
+ debug: DebugState;
151
+ }
152
+
153
+ export type SnapshotIngressSource =
154
+ | "short-code"
155
+ | "snapshot"
156
+ | "start"
157
+ | "dev-new";
158
+
159
+ export type EventIngressSource = "sse" | "submit-response";
160
+
161
+ export type SessionStateIngress =
162
+ | {
163
+ type: "command.loading";
164
+ target?: { sessionId?: string; shortCode?: string };
165
+ }
166
+ | { type: "command.failed"; message: string }
167
+ | {
168
+ type: "snapshot.loaded";
169
+ source: SnapshotIngressSource;
170
+ snapshot: NormalizedSessionSnapshot;
171
+ expectedPerspectivePlayerId?: string | null;
172
+ }
173
+ | {
174
+ type: "event.received";
175
+ source: EventIngressSource;
176
+ event: NormalizedSessionEvent;
177
+ debugEvent?: { eventType: string; data: unknown };
178
+ clientActionId?: string | null;
179
+ }
180
+ | {
181
+ type: "connection.prepared";
182
+ userId: string | null;
183
+ switchablePlayerIds: string[];
184
+ }
185
+ | {
186
+ type: "connection.changed";
187
+ channel: "lobby" | "gameplay";
188
+ connected: boolean;
189
+ }
190
+ | { type: "connection.failed"; message: string }
191
+ | {
192
+ type: "connection.recovering";
193
+ message: string;
194
+ retryAfterMs: number;
195
+ attempt: number;
196
+ }
197
+ | { type: "connection.errorCleared" }
198
+ | {
199
+ type: "feedback.actionRejected";
200
+ reason: string;
201
+ targetPlayer?: string;
202
+ }
203
+ | { type: "local.playerSelected"; playerId: string; sessionId: string }
204
+ | { type: "activity.stateAcked" }
205
+ | { type: "activity.notificationRead"; id: string }
206
+ | { type: "activity.notificationsCleared" }
207
+ | { type: "activity.hostFeedbackDismissed"; id: string }
208
+ | { type: "activity.hostFeedbackCleared" }
209
+ | { type: "debug.sseEventsCleared" }
210
+ | { type: "streams.closed" }
211
+ | { type: "session.reset" };
212
+
213
+ export type SessionStateEffect =
214
+ | {
215
+ type: "requestGameplayResync";
216
+ sessionId: string;
217
+ playerId: string;
218
+ reason: string;
219
+ }
220
+ | {
221
+ type: "reconnectGameplay";
222
+ sessionId: string;
223
+ playerId: string;
224
+ source: "player-switch";
225
+ }
226
+ | {
227
+ type: "perf.storeApplied";
228
+ gameplayVersion: number;
229
+ syncId: number;
230
+ }
231
+ | {
232
+ type: "log.warn";
233
+ message: string;
234
+ };
235
+
236
+ export interface SessionStateReducerEnvironment {
237
+ fallbackToAllSeatsWhenUserIdMissing: boolean;
238
+ nextEventId: () => number;
239
+ nextNotificationId: () => string;
240
+ nowMs: () => number;
241
+ nowIso: () => string;
242
+ }
243
+
244
+ export interface SessionStateReducerResult {
245
+ state: UnifiedSessionState;
246
+ effects: SessionStateEffect[];
247
+ }
248
+
249
+ export function createInitialUnifiedSessionState(): UnifiedSessionState {
250
+ return {
251
+ session: { type: "idle" },
252
+ connection: {
253
+ lobby: false,
254
+ gameplay: false,
255
+ error: null,
256
+ isConnected: false,
257
+ recovery: {
258
+ active: false,
259
+ message: null,
260
+ retryAfterMs: null,
261
+ attempt: null,
262
+ since: null,
263
+ },
264
+ },
265
+ activity: {
266
+ notifications: [],
267
+ hostFeedback: [],
268
+ syncId: 0,
269
+ lastSyncTimestamp: null,
270
+ lastAckTimestamp: null,
271
+ },
272
+ debug: { sseEvents: [], maxEvents: 500 },
273
+ };
274
+ }
275
+
276
+ function appendSseEvent(
277
+ state: UnifiedSessionState,
278
+ debugEvent: { eventType: string; data: unknown },
279
+ env: SessionStateReducerEnvironment,
280
+ ) {
281
+ const newEvent: SSEEventEntry = {
282
+ id: env.nextEventId(),
283
+ eventType: debugEvent.eventType,
284
+ data: debugEvent.data,
285
+ timestamp: env.nowIso(),
286
+ };
287
+ return [...state.debug.sseEvents, newEvent].slice(-state.debug.maxEvents);
288
+ }
289
+
290
+ function createActionRejectedArtifacts(
291
+ reason: string,
292
+ targetPlayer: string | undefined,
293
+ env: SessionStateReducerEnvironment,
294
+ ) {
295
+ return {
296
+ notification: {
297
+ id: env.nextNotificationId(),
298
+ type: "ACTION_REJECTED" as const,
299
+ payload: { type: "ACTION_REJECTED" as const, reason, targetPlayer },
300
+ timestamp: env.nowMs(),
301
+ read: false,
302
+ },
303
+ hostFeedback: {
304
+ id: env.nextNotificationId(),
305
+ type: "ACTION_REJECTED" as const,
306
+ payload: { type: "ACTION_REJECTED" as const, reason, targetPlayer },
307
+ timestamp: env.nowMs(),
308
+ },
309
+ };
310
+ }
311
+
312
+ function createPromptOpenedArtifacts(
313
+ prompt: {
314
+ interactionId: string;
315
+ context?: { to?: string; title?: string };
316
+ },
317
+ env: SessionStateReducerEnvironment,
318
+ ) {
319
+ const payload = {
320
+ type: "PROMPT_OPENED" as const,
321
+ promptId: prompt.interactionId,
322
+ promptInstanceId: prompt.interactionId,
323
+ targetPlayer: prompt.context?.to ?? "",
324
+ title: prompt.context?.title ?? undefined,
325
+ };
326
+ return {
327
+ notification: {
328
+ id: env.nextNotificationId(),
329
+ type: "PROMPT_OPENED" as const,
330
+ payload,
331
+ timestamp: env.nowMs(),
332
+ read: false,
333
+ },
334
+ hostFeedback: {
335
+ id: env.nextNotificationId(),
336
+ type: "PROMPT_OPENED" as const,
337
+ payload,
338
+ timestamp: env.nowMs(),
339
+ },
340
+ };
341
+ }
342
+
343
+ function getOpenedPrompts(
344
+ previous: ReadonlyArray<{ kind: string; interactionId: string }>,
345
+ next: ReadonlyArray<{
346
+ kind: string;
347
+ interactionId: string;
348
+ context?: { to?: string; title?: string };
349
+ }>,
350
+ ) {
351
+ const previousPromptIds = new Set(
352
+ previous
353
+ .filter((descriptor) => descriptor.kind === "prompt")
354
+ .map((prompt) => prompt.interactionId),
355
+ );
356
+ return next.filter(
357
+ (descriptor) =>
358
+ descriptor.kind === "prompt" &&
359
+ !previousPromptIds.has(descriptor.interactionId),
360
+ );
361
+ }
362
+
363
+ function hasBoardStaticHashMismatch(
364
+ gameplay: GameplayViewport | null,
365
+ incomingGameplay: GameplayViewport,
366
+ ): boolean {
367
+ const incomingHash = incomingGameplay.boardStaticHash ?? null;
368
+ const cachedHash = gameplay?.boardStaticHash ?? null;
369
+ return (
370
+ cachedHash !== null && incomingHash !== null && incomingHash !== cachedHash
371
+ );
372
+ }
373
+
374
+ function hasRenderableGameplayView(gameplay: GameplayViewport): boolean {
375
+ return gameplay.view !== null || gameplay.boardStatic !== null;
376
+ }
377
+
378
+ function shouldIgnoreGameplayUpdate(
379
+ currentGameplay: GameplayViewport | null,
380
+ incomingGameplay: GameplayViewport,
381
+ ): boolean {
382
+ if (!currentGameplay) return false;
383
+ if (incomingGameplay.version < currentGameplay.version) return true;
384
+ if (incomingGameplay.version > currentGameplay.version) return false;
385
+ if (
386
+ currentGameplay.actionSetVersion.endsWith(":history") &&
387
+ !incomingGameplay.actionSetVersion.endsWith(":history")
388
+ ) {
389
+ return false;
390
+ }
391
+ if (
392
+ !hasRenderableGameplayView(currentGameplay) &&
393
+ hasRenderableGameplayView(incomingGameplay)
394
+ ) {
395
+ return false;
396
+ }
397
+ return true;
398
+ }
399
+
400
+ function applyGameplaySnapshotToState(
401
+ state: UnifiedSessionState,
402
+ gameplay: GameplayViewport,
403
+ context: SessionContext,
404
+ options: {
405
+ nextSyncId: number;
406
+ perspective: GameplayPerspective;
407
+ debugEvent?: { eventType: string; data: unknown };
408
+ env: SessionStateReducerEnvironment;
409
+ },
410
+ ): UnifiedSessionState {
411
+ const previousGameplay = getGameplayViewport(state.session);
412
+ const promptArtifacts = getOpenedPrompts(
413
+ previousGameplay?.availableInteractions ?? [],
414
+ gameplay.availableInteractions,
415
+ ).map((prompt) => createPromptOpenedArtifacts(prompt, options.env));
416
+ return {
417
+ ...state,
418
+ session: {
419
+ type: "gameplay",
420
+ context,
421
+ perspective: options.perspective,
422
+ gameplay,
423
+ },
424
+ activity: {
425
+ ...state.activity,
426
+ notifications: [
427
+ ...state.activity.notifications,
428
+ ...promptArtifacts.map((artifact) => artifact.notification),
429
+ ],
430
+ hostFeedback: [
431
+ ...state.activity.hostFeedback,
432
+ ...promptArtifacts.map((artifact) => artifact.hostFeedback),
433
+ ],
434
+ syncId: options.nextSyncId,
435
+ lastSyncTimestamp: options.env.nowMs(),
436
+ },
437
+ debug: {
438
+ ...state.debug,
439
+ sseEvents: options.debugEvent
440
+ ? appendSseEvent(state, options.debugEvent, options.env)
441
+ : state.debug.sseEvents,
442
+ },
443
+ connection: {
444
+ ...state.connection,
445
+ error: null,
446
+ recovery: {
447
+ active: false,
448
+ message: null,
449
+ retryAfterMs: null,
450
+ attempt: null,
451
+ since: null,
452
+ },
453
+ },
454
+ };
455
+ }
456
+
457
+ function assertExpectedPerspectivePlayer(
458
+ snapshot: NormalizedSessionSnapshot,
459
+ expectedPlayerId: string | null | undefined,
460
+ ) {
461
+ if (!expectedPlayerId || snapshot.type !== "gameplay") {
462
+ return;
463
+ }
464
+ if (snapshot.perspective.playerId !== expectedPlayerId) {
465
+ throw new Error(
466
+ `Switch snapshot resolved ${snapshot.perspective.playerId} instead of ${expectedPlayerId}.`,
467
+ );
468
+ }
469
+ }
470
+
471
+ function reduceSnapshotLoaded(
472
+ state: UnifiedSessionState,
473
+ ingress: Extract<SessionStateIngress, { type: "snapshot.loaded" }>,
474
+ env: SessionStateReducerEnvironment,
475
+ ): UnifiedSessionState {
476
+ assertExpectedPerspectivePlayer(
477
+ ingress.snapshot,
478
+ ingress.expectedPerspectivePlayerId,
479
+ );
480
+ const context = ingress.snapshot.context;
481
+ const nextSyncId = state.activity.syncId + 1;
482
+
483
+ if (ingress.snapshot.type !== "gameplay") {
484
+ const session: UnifiedSessionModel =
485
+ ingress.snapshot.type === "ended"
486
+ ? { type: "ended", context }
487
+ : {
488
+ type: "lobby",
489
+ context,
490
+ preferredPlayerId: ingress.snapshot.preferredPlayerId,
491
+ };
492
+ return {
493
+ ...state,
494
+ session,
495
+ activity: {
496
+ ...state.activity,
497
+ syncId: nextSyncId,
498
+ lastSyncTimestamp: env.nowMs(),
499
+ },
500
+ connection: {
501
+ ...state.connection,
502
+ error: null,
503
+ recovery: {
504
+ active: false,
505
+ message: null,
506
+ retryAfterMs: null,
507
+ attempt: null,
508
+ since: null,
509
+ },
510
+ },
511
+ };
512
+ }
513
+
514
+ return {
515
+ ...applyGameplaySnapshotToState(state, ingress.snapshot.gameplay, context, {
516
+ nextSyncId,
517
+ perspective: ingress.snapshot.perspective,
518
+ env,
519
+ }),
520
+ connection: {
521
+ ...state.connection,
522
+ error: null,
523
+ recovery: {
524
+ active: false,
525
+ message: null,
526
+ retryAfterMs: null,
527
+ attempt: null,
528
+ since: null,
529
+ },
530
+ },
531
+ };
532
+ }
533
+
534
+ function reduceEventReceived(
535
+ state: UnifiedSessionState,
536
+ ingress: Extract<SessionStateIngress, { type: "event.received" }>,
537
+ env: SessionStateReducerEnvironment,
538
+ ): SessionStateReducerResult {
539
+ const context = getSessionContext(state.session);
540
+ const currentGameplay = getGameplayViewport(state.session);
541
+ const nextSyncId = state.activity.syncId + 1;
542
+ const debugEvent = ingress.source === "sse" ? ingress.debugEvent : undefined;
543
+
544
+ switch (ingress.event.type) {
545
+ case "session.snapshot": {
546
+ const snapshotState = reduceSnapshotLoaded(
547
+ state,
548
+ {
549
+ type: "snapshot.loaded",
550
+ source: "snapshot",
551
+ snapshot: ingress.event.snapshot,
552
+ },
553
+ env,
554
+ );
555
+ return {
556
+ state: debugEvent
557
+ ? {
558
+ ...snapshotState,
559
+ debug: {
560
+ ...snapshotState.debug,
561
+ sseEvents: appendSseEvent(state, debugEvent, env),
562
+ },
563
+ }
564
+ : snapshotState,
565
+ effects: [],
566
+ };
567
+ }
568
+ case "session.lobbyUpdated": {
569
+ const nextContext = ingress.event.context;
570
+ if (!nextContext) return { state, effects: [] };
571
+ return {
572
+ state: {
573
+ ...state,
574
+ session: withContext(state.session, nextContext),
575
+ activity: {
576
+ ...state.activity,
577
+ syncId: nextSyncId,
578
+ lastSyncTimestamp: env.nowMs(),
579
+ },
580
+ debug: {
581
+ ...state.debug,
582
+ sseEvents: debugEvent
583
+ ? appendSseEvent(state, debugEvent, env)
584
+ : state.debug.sseEvents,
585
+ },
586
+ connection: {
587
+ ...state.connection,
588
+ error: null,
589
+ recovery: {
590
+ active: false,
591
+ message: null,
592
+ retryAfterMs: null,
593
+ attempt: null,
594
+ since: null,
595
+ },
596
+ },
597
+ },
598
+ effects: [],
599
+ };
600
+ }
601
+ case "session.gameplayUpdated": {
602
+ if (!context || !ingress.event.context) return { state, effects: [] };
603
+ if (
604
+ ingress.source === "sse" &&
605
+ ((state.session.type === "gameplay" &&
606
+ ingress.event.perspective.playerId !==
607
+ state.session.perspective.playerId) ||
608
+ (state.session.type === "gameplayLoading" &&
609
+ ingress.event.perspective.playerId !==
610
+ state.session.requestedPlayerId))
611
+ ) {
612
+ return { state, effects: [] };
613
+ }
614
+ if (shouldIgnoreGameplayUpdate(currentGameplay, ingress.event.gameplay)) {
615
+ return { state, effects: [] };
616
+ }
617
+ const updatedState = applyGameplaySnapshotToState(
618
+ state,
619
+ ingress.event.gameplay,
620
+ ingress.event.context,
621
+ {
622
+ nextSyncId,
623
+ perspective: ingress.event.perspective,
624
+ debugEvent,
625
+ env,
626
+ },
627
+ );
628
+ const effects: SessionStateEffect[] = [
629
+ {
630
+ type: "perf.storeApplied",
631
+ gameplayVersion: ingress.event.gameplay.version,
632
+ syncId: nextSyncId,
633
+ },
634
+ ];
635
+ if (hasBoardStaticHashMismatch(currentGameplay, ingress.event.gameplay)) {
636
+ effects.push({
637
+ type: "requestGameplayResync",
638
+ sessionId: context.identity.sessionId,
639
+ playerId: ingress.event.perspective.playerId,
640
+ reason: `boardStaticHash mismatch cached=${currentGameplay?.boardStaticHash} incoming=${ingress.event.gameplay.boardStaticHash}`,
641
+ });
642
+ }
643
+ return { state: updatedState, effects };
644
+ }
645
+ case "session.historyUpdated": {
646
+ const nextContext = ingress.event.context;
647
+ if (!nextContext) return { state, effects: [] };
648
+ return {
649
+ state: {
650
+ ...state,
651
+ session: withContext(state.session, nextContext),
652
+ activity: {
653
+ ...state.activity,
654
+ syncId: nextSyncId,
655
+ lastSyncTimestamp: env.nowMs(),
656
+ },
657
+ debug: {
658
+ ...state.debug,
659
+ sseEvents: debugEvent
660
+ ? appendSseEvent(state, debugEvent, env)
661
+ : state.debug.sseEvents,
662
+ },
663
+ connection: {
664
+ ...state.connection,
665
+ error: null,
666
+ recovery: {
667
+ active: false,
668
+ message: null,
669
+ retryAfterMs: null,
670
+ attempt: null,
671
+ since: null,
672
+ },
673
+ },
674
+ },
675
+ effects: [],
676
+ };
677
+ }
678
+ case "session.ended": {
679
+ const nextContext = ingress.event.context;
680
+ if (!nextContext) return { state, effects: [] };
681
+ return {
682
+ state: {
683
+ ...state,
684
+ session: { type: "ended", context: nextContext },
685
+ activity: {
686
+ ...state.activity,
687
+ syncId: nextSyncId,
688
+ lastSyncTimestamp: env.nowMs(),
689
+ },
690
+ debug: {
691
+ ...state.debug,
692
+ sseEvents: debugEvent
693
+ ? appendSseEvent(state, debugEvent, env)
694
+ : state.debug.sseEvents,
695
+ },
696
+ connection: {
697
+ ...state.connection,
698
+ error: null,
699
+ recovery: {
700
+ active: false,
701
+ message: null,
702
+ retryAfterMs: null,
703
+ attempt: null,
704
+ since: null,
705
+ },
706
+ },
707
+ },
708
+ effects: [],
709
+ };
710
+ }
711
+ case "session.error":
712
+ return {
713
+ state: {
714
+ ...state,
715
+ session: {
716
+ type: "error",
717
+ message: ingress.event.message,
718
+ context: context ?? undefined,
719
+ },
720
+ activity: {
721
+ ...state.activity,
722
+ syncId: nextSyncId,
723
+ lastSyncTimestamp: env.nowMs(),
724
+ },
725
+ debug: {
726
+ ...state.debug,
727
+ sseEvents: debugEvent
728
+ ? appendSseEvent(state, debugEvent, env)
729
+ : state.debug.sseEvents,
730
+ },
731
+ },
732
+ effects: [],
733
+ };
734
+ }
735
+ }
736
+
737
+ export function reduceSessionState(
738
+ state: UnifiedSessionState,
739
+ ingress: SessionStateIngress,
740
+ env: SessionStateReducerEnvironment,
741
+ ): SessionStateReducerResult {
742
+ switch (ingress.type) {
743
+ case "command.loading":
744
+ return {
745
+ state: {
746
+ ...state,
747
+ session: { type: "loading", target: ingress.target },
748
+ connection: {
749
+ ...state.connection,
750
+ error: null,
751
+ recovery: {
752
+ active: false,
753
+ message: null,
754
+ retryAfterMs: null,
755
+ attempt: null,
756
+ since: null,
757
+ },
758
+ },
759
+ },
760
+ effects: [],
761
+ };
762
+ case "command.failed":
763
+ return {
764
+ state: {
765
+ ...state,
766
+ session: {
767
+ type: "error",
768
+ message: ingress.message,
769
+ context: getSessionContext(state.session) ?? undefined,
770
+ },
771
+ },
772
+ effects: [],
773
+ };
774
+ case "snapshot.loaded":
775
+ return {
776
+ state: reduceSnapshotLoaded(state, ingress, env),
777
+ effects: [],
778
+ };
779
+ case "event.received":
780
+ return reduceEventReceived(state, ingress, env);
781
+ case "connection.prepared": {
782
+ const context = getSessionContext(state.session);
783
+ if (!context) return { state, effects: [] };
784
+ return {
785
+ state: {
786
+ ...state,
787
+ session: withContext(state.session, {
788
+ ...context,
789
+ userId: ingress.userId,
790
+ switchablePlayerIds: ingress.switchablePlayerIds,
791
+ }),
792
+ connection: {
793
+ ...state.connection,
794
+ error: null,
795
+ recovery: {
796
+ active: false,
797
+ message: null,
798
+ retryAfterMs: null,
799
+ attempt: null,
800
+ since: null,
801
+ },
802
+ },
803
+ },
804
+ effects: [],
805
+ };
806
+ }
807
+ case "connection.changed": {
808
+ const connection = {
809
+ ...state.connection,
810
+ [ingress.channel]: ingress.connected,
811
+ };
812
+ return {
813
+ state: {
814
+ ...state,
815
+ connection: {
816
+ ...connection,
817
+ isConnected: connection.lobby || connection.gameplay,
818
+ recovery: ingress.connected
819
+ ? {
820
+ active: false,
821
+ message: null,
822
+ retryAfterMs: null,
823
+ attempt: null,
824
+ since: null,
825
+ }
826
+ : connection.recovery,
827
+ },
828
+ },
829
+ effects: [],
830
+ };
831
+ }
832
+ case "connection.failed":
833
+ return {
834
+ state: {
835
+ ...state,
836
+ connection: {
837
+ ...state.connection,
838
+ error: ingress.message,
839
+ recovery: {
840
+ active: false,
841
+ message: null,
842
+ retryAfterMs: null,
843
+ attempt: null,
844
+ since: null,
845
+ },
846
+ },
847
+ },
848
+ effects: [],
849
+ };
850
+ case "connection.recovering":
851
+ return {
852
+ state: {
853
+ ...state,
854
+ connection: {
855
+ ...state.connection,
856
+ error: null,
857
+ recovery: {
858
+ active: true,
859
+ message: ingress.message,
860
+ retryAfterMs: ingress.retryAfterMs,
861
+ attempt: ingress.attempt,
862
+ since: state.connection.recovery.since ?? env.nowMs(),
863
+ },
864
+ },
865
+ },
866
+ effects: [],
867
+ };
868
+ case "connection.errorCleared":
869
+ return {
870
+ state: {
871
+ ...state,
872
+ connection: { ...state.connection, error: null },
873
+ },
874
+ effects: [],
875
+ };
876
+ case "feedback.actionRejected": {
877
+ const { notification, hostFeedback } = createActionRejectedArtifacts(
878
+ ingress.reason,
879
+ ingress.targetPlayer,
880
+ env,
881
+ );
882
+ return {
883
+ state: {
884
+ ...state,
885
+ activity: {
886
+ ...state.activity,
887
+ notifications: [...state.activity.notifications, notification],
888
+ hostFeedback: [...state.activity.hostFeedback, hostFeedback],
889
+ },
890
+ },
891
+ effects: [],
892
+ };
893
+ }
894
+ case "local.playerSelected": {
895
+ const context = getSessionContext(state.session);
896
+ if (!context) return { state, effects: [] };
897
+ const controllablePlayerIds = resolveControllablePlayerIds(
898
+ context.switchablePlayerIds,
899
+ context.seats,
900
+ context.userId,
901
+ env.fallbackToAllSeatsWhenUserIdMissing,
902
+ );
903
+ if (!controllablePlayerIds.includes(ingress.playerId)) {
904
+ return {
905
+ state,
906
+ effects: [
907
+ {
908
+ type: "log.warn",
909
+ message: `[UnifiedSession] Cannot switch to ${ingress.playerId} - not controllable`,
910
+ },
911
+ ],
912
+ };
913
+ }
914
+ const currentGameplay = getGameplayViewport(state.session);
915
+ const localGameplay = currentGameplay
916
+ ? gameplayViewportForPlayer(currentGameplay, ingress.playerId)
917
+ : null;
918
+ if (localGameplay) {
919
+ return {
920
+ state: applyGameplaySnapshotToState(state, localGameplay, context, {
921
+ nextSyncId: state.activity.syncId + 1,
922
+ perspective: { playerId: ingress.playerId },
923
+ env,
924
+ }),
925
+ effects: [
926
+ {
927
+ type: "reconnectGameplay",
928
+ sessionId: ingress.sessionId,
929
+ playerId: ingress.playerId,
930
+ source: "player-switch",
931
+ },
932
+ ],
933
+ };
934
+ }
935
+ return {
936
+ state: {
937
+ ...state,
938
+ session: {
939
+ type: "gameplayLoading",
940
+ context,
941
+ requestedPlayerId: ingress.playerId,
942
+ },
943
+ },
944
+ effects: [
945
+ {
946
+ type: "reconnectGameplay",
947
+ sessionId: ingress.sessionId,
948
+ playerId: ingress.playerId,
949
+ source: "player-switch",
950
+ },
951
+ ],
952
+ };
953
+ }
954
+ case "activity.stateAcked":
955
+ return {
956
+ state: {
957
+ ...state,
958
+ activity: { ...state.activity, lastAckTimestamp: env.nowMs() },
959
+ },
960
+ effects: [],
961
+ };
962
+ case "activity.notificationRead":
963
+ return {
964
+ state: {
965
+ ...state,
966
+ activity: {
967
+ ...state.activity,
968
+ notifications: state.activity.notifications.map((notification) =>
969
+ notification.id === ingress.id
970
+ ? { ...notification, read: true }
971
+ : notification,
972
+ ),
973
+ },
974
+ },
975
+ effects: [],
976
+ };
977
+ case "activity.notificationsCleared":
978
+ return {
979
+ state: {
980
+ ...state,
981
+ activity: { ...state.activity, notifications: [] },
982
+ },
983
+ effects: [],
984
+ };
985
+ case "activity.hostFeedbackDismissed":
986
+ return {
987
+ state: {
988
+ ...state,
989
+ activity: {
990
+ ...state.activity,
991
+ hostFeedback: state.activity.hostFeedback.filter(
992
+ (item) => item.id !== ingress.id,
993
+ ),
994
+ },
995
+ },
996
+ effects: [],
997
+ };
998
+ case "activity.hostFeedbackCleared":
999
+ return {
1000
+ state: {
1001
+ ...state,
1002
+ activity: { ...state.activity, hostFeedback: [] },
1003
+ },
1004
+ effects: [],
1005
+ };
1006
+ case "debug.sseEventsCleared":
1007
+ return {
1008
+ state: { ...state, debug: { ...state.debug, sseEvents: [] } },
1009
+ effects: [],
1010
+ };
1011
+ case "streams.closed":
1012
+ return {
1013
+ state: {
1014
+ ...state,
1015
+ connection: {
1016
+ ...state.connection,
1017
+ lobby: false,
1018
+ gameplay: false,
1019
+ isConnected: false,
1020
+ recovery: {
1021
+ active: false,
1022
+ message: null,
1023
+ retryAfterMs: null,
1024
+ attempt: null,
1025
+ since: null,
1026
+ },
1027
+ },
1028
+ },
1029
+ effects: [],
1030
+ };
1031
+ case "session.reset":
1032
+ return { state: createInitialUnifiedSessionState(), effects: [] };
1033
+ }
1034
+ }