@dreamboard-games/sdk 0.5.0-alpha.2 → 0.5.0-alpha.3
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.
- package/README.md +136 -45
- package/dist/chunk-DMFVAZI2.js +16 -0
- package/dist/chunk-DMFVAZI2.js.map +1 -0
- package/dist/chunk-FMM5LVJW.js +833 -0
- package/dist/chunk-FMM5LVJW.js.map +1 -0
- package/dist/{chunk-S2JEDVWH.js → chunk-LRILEVKC.js} +6113 -6489
- package/dist/chunk-LRILEVKC.js.map +1 -0
- package/dist/chunk-SP234IY4.js +1363 -0
- package/dist/chunk-SP234IY4.js.map +1 -0
- package/dist/chunk-VUWNJXLJ.js +52 -0
- package/dist/chunk-VUWNJXLJ.js.map +1 -0
- package/dist/chunk-ZPRKWPEP.js +511 -0
- package/dist/chunk-ZPRKWPEP.js.map +1 -0
- package/dist/definition-BaHNPZol.d.ts +4639 -0
- package/dist/diagnostics-B0lgnuZ6.d.ts +65 -0
- package/dist/index.d.ts +3553 -1
- package/dist/index.js +1045 -6
- package/dist/index.js.map +1 -1
- package/dist/model-BHro6yXu.d.ts +288 -0
- package/dist/react.d.ts +32 -0
- package/dist/react.js +80 -0
- package/dist/react.js.map +1 -0
- package/dist/reducer.d.ts +1194 -79
- package/dist/reducer.js +4326 -121
- package/dist/reducer.js.map +1 -1
- package/dist/testing.d.ts +518 -564
- package/dist/testing.js +2448 -915
- package/dist/testing.js.map +1 -1
- package/dist/types-DimqnJBq.d.ts +86 -0
- package/package.json +26 -131
- package/dist/HandView-Bi2lxzQP.d.ts +0 -79
- package/dist/ResourceCounter-BFxJknqp.d.ts +0 -98
- package/dist/ThemeProvider-CElaL2ik.d.ts +0 -99
- package/dist/attributes-DbvyMbXw.d.ts +0 -68
- package/dist/browser-interaction.d.ts +0 -910
- package/dist/browser-interaction.js +0 -119
- package/dist/browser-interaction.js.map +0 -1
- package/dist/chunk-32PFKDV7.js +0 -11705
- package/dist/chunk-32PFKDV7.js.map +0 -1
- package/dist/chunk-3BAD4VC2.js +0 -263
- package/dist/chunk-3BAD4VC2.js.map +0 -1
- package/dist/chunk-3SKDNDPG.js +0 -104
- package/dist/chunk-3SKDNDPG.js.map +0 -1
- package/dist/chunk-5YV2WHJ4.js +0 -25
- package/dist/chunk-5YV2WHJ4.js.map +0 -1
- package/dist/chunk-ANRB4XKA.js +0 -90
- package/dist/chunk-ANRB4XKA.js.map +0 -1
- package/dist/chunk-BI4G3PM2.js +0 -1864
- package/dist/chunk-BI4G3PM2.js.map +0 -1
- package/dist/chunk-EVCLTY4Q.js +0 -935
- package/dist/chunk-EVCLTY4Q.js.map +0 -1
- package/dist/chunk-GDPKTBUR.js +0 -564
- package/dist/chunk-GDPKTBUR.js.map +0 -1
- package/dist/chunk-H6VDGFL5.js +0 -2903
- package/dist/chunk-H6VDGFL5.js.map +0 -1
- package/dist/chunk-LR3ZTWQF.js +0 -5960
- package/dist/chunk-LR3ZTWQF.js.map +0 -1
- package/dist/chunk-PF7L4BMG.js +0 -117
- package/dist/chunk-PF7L4BMG.js.map +0 -1
- package/dist/chunk-PN5O6GG2.js +0 -32
- package/dist/chunk-PN5O6GG2.js.map +0 -1
- package/dist/chunk-PZ5AY32C.js +0 -10
- package/dist/chunk-PZ5AY32C.js.map +0 -1
- package/dist/chunk-S2JEDVWH.js.map +0 -1
- package/dist/chunk-T3ZKNUZ7.js +0 -1
- package/dist/chunk-T3ZKNUZ7.js.map +0 -1
- package/dist/chunk-UGH54WZ2.js +0 -223
- package/dist/chunk-UGH54WZ2.js.map +0 -1
- package/dist/chunk-VCDWP5VL.js +0 -746
- package/dist/chunk-VCDWP5VL.js.map +0 -1
- package/dist/chunk-VDXOF4FW.js +0 -69
- package/dist/chunk-VDXOF4FW.js.map +0 -1
- package/dist/chunk-WHR5UW3F.js +0 -1988
- package/dist/chunk-WHR5UW3F.js.map +0 -1
- package/dist/chunk-Y75CFE77.js +0 -17
- package/dist/chunk-Y75CFE77.js.map +0 -1
- package/dist/chunk-Z7QCREWI.js +0 -3435
- package/dist/chunk-Z7QCREWI.js.map +0 -1
- package/dist/chunk-ZZGN2ATS.js +0 -1941
- package/dist/chunk-ZZGN2ATS.js.map +0 -1
- package/dist/components-gAbIz7hK.d.ts +0 -1284
- package/dist/definitions-BgmV_GhC.d.ts +0 -300
- package/dist/diagnostics-DIi3ina7.d.ts +0 -50
- package/dist/digest.d.ts +0 -8
- package/dist/game-CsZScpcn.d.ts +0 -763
- package/dist/hex-board-view-aKkblDp6.d.ts +0 -1228
- package/dist/index-CcT9Q7N7.d.ts +0 -193
- package/dist/index.d-DjzoK7zn.d.ts +0 -2101
- package/dist/package-set.d.ts +0 -13
- package/dist/package-set.js +0 -12
- package/dist/package-set.js.map +0 -1
- package/dist/player-state-Cqpyeql0.d.ts +0 -371
- package/dist/plugin-runtime-contract.d.ts +0 -17
- package/dist/plugin-runtime-contract.js +0 -92
- package/dist/plugin-runtime-contract.js.map +0 -1
- package/dist/primitive-props-BNHDkgd7.d.ts +0 -16
- package/dist/protocol-dYgafTYY.d.ts +0 -314
- package/dist/reducer/advanced.d.ts +0 -82
- package/dist/reducer/advanced.js +0 -51
- package/dist/reducer/advanced.js.map +0 -1
- package/dist/reducer-contract.d.ts +0 -11
- package/dist/reducer-contract.js +0 -16
- package/dist/reducer-contract.js.map +0 -1
- package/dist/reference-games/index.d.ts +0 -31
- package/dist/reference-games/index.js +0 -48
- package/dist/reference-games/index.js.map +0 -1
- package/dist/runtime/primitives.d.ts +0 -250
- package/dist/runtime/primitives.js +0 -189
- package/dist/runtime/primitives.js.map +0 -1
- package/dist/runtime/runtime-api.d.ts +0 -2
- package/dist/runtime/runtime-api.js +0 -1
- package/dist/runtime/runtime-api.js.map +0 -1
- package/dist/runtime/workspace-contract.d.ts +0 -392
- package/dist/runtime/workspace-contract.js +0 -28
- package/dist/runtime/workspace-contract.js.map +0 -1
- package/dist/runtime-RJ5orDJU.d.ts +0 -1699
- package/dist/runtime-api-Bz5pwNU_.d.ts +0 -296
- package/dist/runtime-json-CQ9QbLZ5.d.ts +0 -5
- package/dist/runtime.d.ts +0 -83
- package/dist/runtime.js +0 -242
- package/dist/runtime.js.map +0 -1
- package/dist/schema.d.ts +0 -379
- package/dist/stale-contract-artifact-error-XLaweZtF.d.ts +0 -18
- package/dist/testing-compiler.d.ts +0 -37
- package/dist/testing-compiler.js +0 -272
- package/dist/testing-compiler.js.map +0 -1
- package/dist/testing-runtime.d.ts +0 -119
- package/dist/testing-runtime.js +0 -212
- package/dist/testing-runtime.js.map +0 -1
- package/dist/types-DJj5MJkl.d.ts +0 -256
- package/dist/types-DR7DoB1x.d.ts +0 -28
- package/dist/types-JWCYHmu7.d.ts +0 -122
- package/dist/types.d.ts +0 -1958
- package/dist/types.js +0 -14
- package/dist/types.js.map +0 -1
- package/dist/ui/components.d.ts +0 -17
- package/dist/ui/components.js +0 -216
- package/dist/ui/components.js.map +0 -1
- package/dist/ui/defaults.d.ts +0 -19
- package/dist/ui/defaults.js +0 -104
- package/dist/ui/defaults.js.map +0 -1
- package/dist/ui/player-state.d.ts +0 -2
- package/dist/ui/player-state.js +0 -1
- package/dist/ui/player-state.js.map +0 -1
- package/dist/ui/plugin-styles.css +0 -2
- package/dist/ui-contract-GTUPkf8z.d.ts +0 -1168
- package/dist/ui.d.ts +0 -317
- package/dist/ui.js +0 -277
- package/dist/ui.js.map +0 -1
- package/dist/views-BpuWcOyN.d.ts +0 -1641
package/dist/testing.js
CHANGED
|
@@ -1,52 +1,1913 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
compareCanonicalScenarioJson,
|
|
8
|
-
createExpectApi,
|
|
9
|
-
createScenarioAuthoring,
|
|
10
|
-
digestScenarioJson,
|
|
11
|
-
enumerateScenarioInteractionParams,
|
|
12
|
-
inspectScenarioReplayAuthority,
|
|
13
|
-
probeScenarioCommand,
|
|
14
|
-
projectScenarioCommandParams,
|
|
15
|
-
replayScenario,
|
|
16
|
-
resolveScenarioCheckpoint,
|
|
17
|
-
toScenarioReplayDefinition
|
|
18
|
-
} from "./chunk-BI4G3PM2.js";
|
|
19
|
-
import {
|
|
20
|
-
StaleContractArtifactError,
|
|
21
|
-
isStaleContractArtifactError
|
|
22
|
-
} from "./chunk-S2JEDVWH.js";
|
|
2
|
+
SourceSnapshotSchema,
|
|
3
|
+
computePluginActionSetVersion,
|
|
4
|
+
createSourceLifecycle,
|
|
5
|
+
materializePluginGameplayFrame
|
|
6
|
+
} from "./chunk-ZPRKWPEP.js";
|
|
23
7
|
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
} from "./chunk-3BAD4VC2.js";
|
|
27
|
-
import "./chunk-PN5O6GG2.js";
|
|
28
|
-
import "./chunk-GDPKTBUR.js";
|
|
8
|
+
immutableCopy
|
|
9
|
+
} from "./chunk-DMFVAZI2.js";
|
|
29
10
|
import {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
11
|
+
assertReducerBundleContract,
|
|
12
|
+
collectReducerDefinitionIndex,
|
|
13
|
+
createClientParamSchemasByPhase,
|
|
14
|
+
createIngressRuntimeCodec,
|
|
15
|
+
createInteractionResolver,
|
|
16
|
+
createProjectionBuilder,
|
|
17
|
+
createReducerBundle,
|
|
18
|
+
createTrustedRuntimeScope,
|
|
19
|
+
manifestSchemaFamily,
|
|
20
|
+
nextRandomInt
|
|
21
|
+
} from "./chunk-LRILEVKC.js";
|
|
34
22
|
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
PluginProtocolFrameSchema,
|
|
38
|
-
PluginProtocolStepSchema,
|
|
39
|
-
PluginProtocolTapeSchema,
|
|
40
|
-
computePluginActionSetVersion,
|
|
23
|
+
GameOutcomeSchema,
|
|
24
|
+
ReducerSessionStateSchema,
|
|
41
25
|
digestPluginRuntimeJson,
|
|
42
|
-
|
|
43
|
-
} from "./chunk-
|
|
44
|
-
import "./chunk-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
} from "
|
|
48
|
-
|
|
49
|
-
|
|
26
|
+
encodeCanonicalPluginRuntimeJson
|
|
27
|
+
} from "./chunk-FMM5LVJW.js";
|
|
28
|
+
import "./chunk-VUWNJXLJ.js";
|
|
29
|
+
|
|
30
|
+
// src/testing/scenario-definition-validation.ts
|
|
31
|
+
import { z as z2 } from "zod";
|
|
32
|
+
|
|
33
|
+
// src/testing/scenario-schema.ts
|
|
34
|
+
import { z } from "zod";
|
|
35
|
+
var ScenarioSchemaValueError = class extends Error {
|
|
36
|
+
path;
|
|
37
|
+
constructor(path, message) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "ScenarioSchemaValueError";
|
|
40
|
+
this.path = path;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
function appendScenarioPath(path, key) {
|
|
44
|
+
if (typeof key === "number") {
|
|
45
|
+
return `${path}[${key}]`;
|
|
46
|
+
}
|
|
47
|
+
if (typeof key === "symbol") {
|
|
48
|
+
return `${path}[${String(key)}]`;
|
|
49
|
+
}
|
|
50
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? `${path}.${key}` : `${path}[${JSON.stringify(key)}]`;
|
|
51
|
+
}
|
|
52
|
+
function isPlainObject(value) {
|
|
53
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
const prototype = Object.getPrototypeOf(value);
|
|
57
|
+
return prototype === Object.prototype || prototype === null;
|
|
58
|
+
}
|
|
59
|
+
function resolvePlayerSeatReference(options) {
|
|
60
|
+
if (!isPlainObject(options.value)) {
|
|
61
|
+
throw new ScenarioSchemaValueError(
|
|
62
|
+
options.path,
|
|
63
|
+
"expected a seat reference shaped as { seat: number }"
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
const keys = Object.keys(options.value);
|
|
67
|
+
if (keys.length !== 1 || keys[0] !== "seat") {
|
|
68
|
+
throw new ScenarioSchemaValueError(
|
|
69
|
+
options.path,
|
|
70
|
+
"expected a seat reference with only the 'seat' field"
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const seat = options.value.seat;
|
|
74
|
+
const seatPath = appendScenarioPath(options.path, "seat");
|
|
75
|
+
if (typeof seat !== "number" || !Number.isSafeInteger(seat)) {
|
|
76
|
+
throw new ScenarioSchemaValueError(
|
|
77
|
+
seatPath,
|
|
78
|
+
"expected a finite safe integer"
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (seat < 0 || seat >= options.playerIds.length) {
|
|
82
|
+
throw new ScenarioSchemaValueError(
|
|
83
|
+
seatPath,
|
|
84
|
+
`expected a seat from 0 through ${Math.max(0, options.playerIds.length - 1)}`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const playerId = options.playerIds[seat];
|
|
88
|
+
if (playerId === void 0) {
|
|
89
|
+
throw new ScenarioSchemaValueError(
|
|
90
|
+
seatPath,
|
|
91
|
+
`could not resolve seat ${seat}`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return playerId;
|
|
95
|
+
}
|
|
96
|
+
function projectPlayerSeatReference(options) {
|
|
97
|
+
if (typeof options.value !== "string") {
|
|
98
|
+
throw new ScenarioSchemaValueError(
|
|
99
|
+
options.path,
|
|
100
|
+
"expected a runtime player id string"
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const seat = options.playerIds.indexOf(options.value);
|
|
104
|
+
if (seat < 0) {
|
|
105
|
+
throw new ScenarioSchemaValueError(
|
|
106
|
+
options.path,
|
|
107
|
+
`runtime player id '${options.value}' is not assigned to a scenario seat`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return { seat };
|
|
111
|
+
}
|
|
112
|
+
function tupleDefinition(schema) {
|
|
113
|
+
return schema._zod.def;
|
|
114
|
+
}
|
|
115
|
+
function intersectionDefinition(schema) {
|
|
116
|
+
return schema._zod.def;
|
|
117
|
+
}
|
|
118
|
+
function asClassicSchema(schema) {
|
|
119
|
+
return schema;
|
|
120
|
+
}
|
|
121
|
+
function resolveScenarioSeatReferences(options) {
|
|
122
|
+
return transformScenarioSchemaValue({ ...options, direction: "resolve" });
|
|
123
|
+
}
|
|
124
|
+
function projectScenarioSeatReferences(options) {
|
|
125
|
+
return transformScenarioSchemaValue({ ...options, direction: "project" });
|
|
126
|
+
}
|
|
127
|
+
function transformScenarioSchemaValue(options) {
|
|
128
|
+
const { schema, value, playerIds, path } = options;
|
|
129
|
+
if (manifestSchemaFamily(schema) === "playerId") {
|
|
130
|
+
return options.direction === "resolve" ? resolvePlayerSeatReference({ value, playerIds, path }) : projectPlayerSeatReference({ value, playerIds, path });
|
|
131
|
+
}
|
|
132
|
+
if (schema instanceof z.ZodOptional || schema instanceof z.ZodExactOptional) {
|
|
133
|
+
return value === void 0 ? value : transformScenarioSchemaValue({
|
|
134
|
+
schema: asClassicSchema(schema.unwrap()),
|
|
135
|
+
value,
|
|
136
|
+
playerIds,
|
|
137
|
+
path,
|
|
138
|
+
direction: options.direction
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (schema instanceof z.ZodNullable) {
|
|
142
|
+
return value === null ? value : transformScenarioSchemaValue({
|
|
143
|
+
schema: asClassicSchema(schema.unwrap()),
|
|
144
|
+
value,
|
|
145
|
+
playerIds,
|
|
146
|
+
path,
|
|
147
|
+
direction: options.direction
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
if (schema instanceof z.ZodDefault || schema instanceof z.ZodPrefault || schema instanceof z.ZodCatch || schema instanceof z.ZodReadonly || schema instanceof z.ZodNonOptional) {
|
|
151
|
+
return transformScenarioSchemaValue({
|
|
152
|
+
schema: asClassicSchema(schema.unwrap()),
|
|
153
|
+
value,
|
|
154
|
+
playerIds,
|
|
155
|
+
path,
|
|
156
|
+
direction: options.direction
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
if (schema instanceof z.ZodArray) {
|
|
160
|
+
if (!Array.isArray(value)) return value;
|
|
161
|
+
return value.map(
|
|
162
|
+
(item, index) => transformScenarioSchemaValue({
|
|
163
|
+
schema: asClassicSchema(schema.element),
|
|
164
|
+
value: item,
|
|
165
|
+
playerIds,
|
|
166
|
+
path: appendScenarioPath(path, index),
|
|
167
|
+
direction: options.direction
|
|
168
|
+
})
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (schema instanceof z.ZodTuple) {
|
|
172
|
+
if (!Array.isArray(value)) return value;
|
|
173
|
+
const definition = tupleDefinition(schema);
|
|
174
|
+
const items = definition.items ?? [];
|
|
175
|
+
return value.map((item, index) => {
|
|
176
|
+
const itemSchema = items[index] ?? definition.rest;
|
|
177
|
+
return itemSchema ? transformScenarioSchemaValue({
|
|
178
|
+
schema: asClassicSchema(itemSchema),
|
|
179
|
+
value: item,
|
|
180
|
+
playerIds,
|
|
181
|
+
path: appendScenarioPath(path, index),
|
|
182
|
+
direction: options.direction
|
|
183
|
+
}) : item;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
if (schema instanceof z.ZodObject) {
|
|
187
|
+
if (!isPlainObject(value)) return value;
|
|
188
|
+
const output = { ...value };
|
|
189
|
+
for (const [key, childSchema] of Object.entries(schema.shape)) {
|
|
190
|
+
if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
|
|
191
|
+
output[key] = transformScenarioSchemaValue({
|
|
192
|
+
schema: asClassicSchema(childSchema),
|
|
193
|
+
value: value[key],
|
|
194
|
+
playerIds,
|
|
195
|
+
path: appendScenarioPath(path, key),
|
|
196
|
+
direction: options.direction
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return output;
|
|
200
|
+
}
|
|
201
|
+
if (schema instanceof z.ZodRecord) {
|
|
202
|
+
if (!isPlainObject(value)) return value;
|
|
203
|
+
if (manifestSchemaFamily(schema.keyType) === "playerId") {
|
|
204
|
+
throw new ScenarioSchemaValueError(
|
|
205
|
+
path,
|
|
206
|
+
"player-valued record keys cannot be represented by scenario seat references"
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return Object.fromEntries(
|
|
210
|
+
Object.entries(value).map(([key, item]) => [
|
|
211
|
+
key,
|
|
212
|
+
transformScenarioSchemaValue({
|
|
213
|
+
schema: asClassicSchema(schema.valueType),
|
|
214
|
+
value: item,
|
|
215
|
+
playerIds,
|
|
216
|
+
path: appendScenarioPath(path, key),
|
|
217
|
+
direction: options.direction
|
|
218
|
+
})
|
|
219
|
+
])
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (schema instanceof z.ZodUnion) {
|
|
223
|
+
let firstSeatError;
|
|
224
|
+
for (const option of schema.options) {
|
|
225
|
+
try {
|
|
226
|
+
if (options.direction === "project" && !asClassicSchema(option).safeParse(value).success) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const resolved = transformScenarioSchemaValue({
|
|
230
|
+
schema: asClassicSchema(option),
|
|
231
|
+
value,
|
|
232
|
+
playerIds,
|
|
233
|
+
path,
|
|
234
|
+
direction: options.direction
|
|
235
|
+
});
|
|
236
|
+
if (options.direction === "project" || asClassicSchema(option).safeParse(resolved).success) {
|
|
237
|
+
return resolved;
|
|
238
|
+
}
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (firstSeatError === void 0 && error instanceof ScenarioSchemaValueError) {
|
|
241
|
+
firstSeatError = error;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (firstSeatError) throw firstSeatError;
|
|
246
|
+
return value;
|
|
247
|
+
}
|
|
248
|
+
if (schema instanceof z.ZodIntersection) {
|
|
249
|
+
const definition = intersectionDefinition(schema);
|
|
250
|
+
const leftResolved = transformScenarioSchemaValue({
|
|
251
|
+
schema: asClassicSchema(definition.left),
|
|
252
|
+
value,
|
|
253
|
+
playerIds,
|
|
254
|
+
path,
|
|
255
|
+
direction: options.direction
|
|
256
|
+
});
|
|
257
|
+
return transformScenarioSchemaValue({
|
|
258
|
+
schema: asClassicSchema(definition.right),
|
|
259
|
+
value: leftResolved,
|
|
260
|
+
playerIds,
|
|
261
|
+
path,
|
|
262
|
+
direction: options.direction
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
if (schema instanceof z.ZodPipe) {
|
|
266
|
+
return transformScenarioSchemaValue({
|
|
267
|
+
schema: asClassicSchema(schema.in),
|
|
268
|
+
value,
|
|
269
|
+
playerIds,
|
|
270
|
+
path,
|
|
271
|
+
direction: options.direction
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
if (schema instanceof z.ZodLazy) {
|
|
275
|
+
return transformScenarioSchemaValue({
|
|
276
|
+
schema: asClassicSchema(schema.unwrap()),
|
|
277
|
+
value,
|
|
278
|
+
playerIds,
|
|
279
|
+
path,
|
|
280
|
+
direction: options.direction
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return value;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// src/testing/scenario-definition-validation.ts
|
|
287
|
+
var ScenarioDefinitionValidationError = class extends Error {
|
|
288
|
+
code;
|
|
289
|
+
path;
|
|
290
|
+
constructor(options) {
|
|
291
|
+
super(
|
|
292
|
+
`Invalid scenario definition at '${options.path}': ${options.reason}.`
|
|
293
|
+
);
|
|
294
|
+
this.name = "ScenarioDefinitionValidationError";
|
|
295
|
+
this.code = options.code;
|
|
296
|
+
this.path = options.path;
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
var checkpointIdPattern = /^[a-z0-9][a-z0-9-]*$/u;
|
|
300
|
+
function validateCheckpoints(definition) {
|
|
301
|
+
if (!Object.prototype.hasOwnProperty.call(definition, "checkpoints")) return;
|
|
302
|
+
const checkpoints = requireObject(
|
|
303
|
+
definition.checkpoints,
|
|
304
|
+
"scenario.checkpoints"
|
|
305
|
+
);
|
|
306
|
+
assertJsonSerializable(checkpoints, "scenario.checkpoints");
|
|
307
|
+
const occupied = /* @__PURE__ */ new Map();
|
|
308
|
+
for (const [id, candidate] of Object.entries(checkpoints)) {
|
|
309
|
+
const path = appendScenarioPath("scenario.checkpoints", id);
|
|
310
|
+
if (!checkpointIdPattern.test(id) || id === "setup") {
|
|
311
|
+
fail({
|
|
312
|
+
code: "INVALID_VALUE",
|
|
313
|
+
path,
|
|
314
|
+
reason: "checkpoint ids must be lowercase kebab-case and cannot be 'setup'"
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
const checkpoint = requireObject(candidate, path);
|
|
318
|
+
assertExactKeys(checkpoint, ["segment", "completed"], path);
|
|
319
|
+
assertOwn(checkpoint, "segment", path);
|
|
320
|
+
assertOwn(checkpoint, "completed", path);
|
|
321
|
+
if (!["setup", "given", "when"].includes(String(checkpoint.segment))) {
|
|
322
|
+
fail({
|
|
323
|
+
code: "INVALID_VALUE",
|
|
324
|
+
path: appendScenarioPath(path, "segment"),
|
|
325
|
+
reason: "expected setup, given, or when"
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
const completed = assertSafeInteger(
|
|
329
|
+
checkpoint.completed,
|
|
330
|
+
appendScenarioPath(path, "completed")
|
|
331
|
+
);
|
|
332
|
+
const segment = checkpoint.segment;
|
|
333
|
+
const maximum = segment === "setup" ? 0 : definition[segment].length;
|
|
334
|
+
if (completed < 0 || completed > maximum || segment === "setup" && completed !== 0) {
|
|
335
|
+
fail({
|
|
336
|
+
code: "OUT_OF_RANGE",
|
|
337
|
+
path: appendScenarioPath(path, "completed"),
|
|
338
|
+
reason: `expected 0 through ${maximum} for ${segment}`
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
const coordinate = `${segment}:${completed}`;
|
|
342
|
+
const previous = occupied.get(coordinate);
|
|
343
|
+
if (previous !== void 0) {
|
|
344
|
+
fail({
|
|
345
|
+
code: "INVALID_VALUE",
|
|
346
|
+
path,
|
|
347
|
+
reason: `duplicates checkpoint '${previous}' at ${coordinate}`
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
occupied.set(coordinate, id);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function fail(options) {
|
|
354
|
+
throw new ScenarioDefinitionValidationError(options);
|
|
355
|
+
}
|
|
356
|
+
function isPlainObject2(value) {
|
|
357
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
const prototype = Object.getPrototypeOf(value);
|
|
361
|
+
return prototype === Object.prototype || prototype === null;
|
|
362
|
+
}
|
|
363
|
+
function requireObject(value, path) {
|
|
364
|
+
if (!isPlainObject2(value)) {
|
|
365
|
+
fail({ code: "INVALID_TYPE", path, reason: "expected a plain object" });
|
|
366
|
+
}
|
|
367
|
+
return value;
|
|
368
|
+
}
|
|
369
|
+
function assertExactKeys(value, allowed, path) {
|
|
370
|
+
const allowedSet = new Set(allowed);
|
|
371
|
+
for (const key of Object.keys(value)) {
|
|
372
|
+
if (!allowedSet.has(key)) {
|
|
373
|
+
fail({
|
|
374
|
+
code: "UNKNOWN_FIELD",
|
|
375
|
+
path: appendScenarioPath(path, key),
|
|
376
|
+
reason: "field is not part of the scenario contract"
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
function assertOwn(value, key, path) {
|
|
382
|
+
if (!Object.prototype.hasOwnProperty.call(value, key)) {
|
|
383
|
+
fail({
|
|
384
|
+
code: "INVALID_TYPE",
|
|
385
|
+
path: appendScenarioPath(path, key),
|
|
386
|
+
reason: "required field is missing"
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function assertSafeInteger(value, path) {
|
|
391
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value)) {
|
|
392
|
+
fail({
|
|
393
|
+
code: "UNSAFE_INTEGER",
|
|
394
|
+
path,
|
|
395
|
+
reason: "expected a finite safe integer"
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
return value;
|
|
399
|
+
}
|
|
400
|
+
function assertJsonSerializable(value, path, ancestors = /* @__PURE__ */ new Set()) {
|
|
401
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (typeof value === "number") {
|
|
405
|
+
if (!Number.isFinite(value)) {
|
|
406
|
+
fail({
|
|
407
|
+
code: "NON_SERIALIZABLE",
|
|
408
|
+
path,
|
|
409
|
+
reason: "non-finite numbers are not JSON-serializable"
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (typeof value !== "object") {
|
|
415
|
+
fail({
|
|
416
|
+
code: "NON_SERIALIZABLE",
|
|
417
|
+
path,
|
|
418
|
+
reason: `${typeof value} values are not JSON-serializable`
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
if (ancestors.has(value)) {
|
|
422
|
+
fail({
|
|
423
|
+
code: "NON_SERIALIZABLE",
|
|
424
|
+
path,
|
|
425
|
+
reason: "cyclic values are not JSON-serializable"
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
if (!Array.isArray(value) && !isPlainObject2(value)) {
|
|
429
|
+
fail({
|
|
430
|
+
code: "NON_SERIALIZABLE",
|
|
431
|
+
path,
|
|
432
|
+
reason: "class instances are not JSON-serializable scenario data"
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
ancestors.add(value);
|
|
436
|
+
if (Array.isArray(value)) {
|
|
437
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
438
|
+
if (!Object.prototype.hasOwnProperty.call(value, index)) {
|
|
439
|
+
fail({
|
|
440
|
+
code: "NON_SERIALIZABLE",
|
|
441
|
+
path: appendScenarioPath(path, index),
|
|
442
|
+
reason: "sparse array entries are not allowed"
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
assertJsonSerializable(
|
|
446
|
+
value[index],
|
|
447
|
+
appendScenarioPath(path, index),
|
|
448
|
+
ancestors
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
const extraKeys = Reflect.ownKeys(value).filter((key) => {
|
|
452
|
+
if (key === "length") return false;
|
|
453
|
+
return typeof key !== "string" || !/^(0|[1-9][0-9]*)$/.test(key);
|
|
454
|
+
});
|
|
455
|
+
if (extraKeys.length > 0) {
|
|
456
|
+
const key = extraKeys[0];
|
|
457
|
+
fail({
|
|
458
|
+
code: "NON_SERIALIZABLE",
|
|
459
|
+
path: appendScenarioPath(path, key),
|
|
460
|
+
reason: "array metadata is not JSON-serializable"
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
ancestors.delete(value);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
467
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
468
|
+
if (typeof key === "symbol") {
|
|
469
|
+
fail({
|
|
470
|
+
code: "NON_SERIALIZABLE",
|
|
471
|
+
path: appendScenarioPath(path, key),
|
|
472
|
+
reason: "symbol keys are not JSON-serializable"
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
const descriptor = descriptors[key];
|
|
476
|
+
if (descriptor === void 0 || !descriptor.enumerable || !("value" in descriptor)) {
|
|
477
|
+
fail({
|
|
478
|
+
code: "NON_SERIALIZABLE",
|
|
479
|
+
path: appendScenarioPath(path, key),
|
|
480
|
+
reason: "only enumerable data properties are allowed"
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
assertJsonSerializable(
|
|
484
|
+
descriptor.value,
|
|
485
|
+
appendScenarioPath(path, key),
|
|
486
|
+
ancestors
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
ancestors.delete(value);
|
|
490
|
+
}
|
|
491
|
+
function playerIdsForDefinition(game, players) {
|
|
492
|
+
const declared = game.contract.manifest.literals.playerIds;
|
|
493
|
+
return Array.from(
|
|
494
|
+
{ length: players },
|
|
495
|
+
(_, index) => declared[index] ?? `player-${index + 1}`
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
function commandSchemas(game, interactionId) {
|
|
499
|
+
const schemasByPhase = createClientParamSchemasByPhase(
|
|
500
|
+
game
|
|
501
|
+
);
|
|
502
|
+
const schemas = Object.values(schemasByPhase).flatMap((schemas2) => {
|
|
503
|
+
const schema = schemas2[interactionId];
|
|
504
|
+
return schema ? [schema] : [];
|
|
505
|
+
});
|
|
506
|
+
const index = collectReducerDefinitionIndex(
|
|
507
|
+
game
|
|
508
|
+
);
|
|
509
|
+
for (const phase of index.phasesByName.values()) {
|
|
510
|
+
const steps = phase.interactions.find(([id]) => id === interactionId)?.[1].steps;
|
|
511
|
+
if (steps)
|
|
512
|
+
schemas.push(
|
|
513
|
+
z2.partialRecord(
|
|
514
|
+
z2.enum(steps.entries.map((step) => step.key)),
|
|
515
|
+
z2.unknown()
|
|
516
|
+
).refine((params) => Object.keys(params).length === 1)
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
return schemas;
|
|
520
|
+
}
|
|
521
|
+
function issuePath(path, issue) {
|
|
522
|
+
return issue.path.reduce(appendScenarioPath, path);
|
|
523
|
+
}
|
|
524
|
+
function validateCommand(options) {
|
|
525
|
+
const command = requireObject(options.command, options.path);
|
|
526
|
+
assertJsonSerializable(command, options.path);
|
|
527
|
+
assertExactKeys(command, ["actor", "interactionId", "params"], options.path);
|
|
528
|
+
for (const field of ["actor", "interactionId", "params"]) {
|
|
529
|
+
assertOwn(command, field, options.path);
|
|
530
|
+
}
|
|
531
|
+
const actorPath = appendScenarioPath(options.path, "actor");
|
|
532
|
+
const actor = requireObject(command.actor, actorPath);
|
|
533
|
+
assertExactKeys(actor, ["seat"], actorPath);
|
|
534
|
+
assertOwn(actor, "seat", actorPath);
|
|
535
|
+
const actorSeat = assertSafeInteger(
|
|
536
|
+
actor.seat,
|
|
537
|
+
appendScenarioPath(actorPath, "seat")
|
|
538
|
+
);
|
|
539
|
+
if (actorSeat < 0 || actorSeat >= options.playerIds.length) {
|
|
540
|
+
fail({
|
|
541
|
+
code: "OUT_OF_RANGE",
|
|
542
|
+
path: appendScenarioPath(actorPath, "seat"),
|
|
543
|
+
reason: `expected a seat from 0 through ${Math.max(0, options.playerIds.length - 1)}`
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
const interactionPath = appendScenarioPath(options.path, "interactionId");
|
|
547
|
+
if (typeof command.interactionId !== "string" || command.interactionId === "") {
|
|
548
|
+
fail({
|
|
549
|
+
code: "INVALID_TYPE",
|
|
550
|
+
path: interactionPath,
|
|
551
|
+
reason: "expected a non-empty string"
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
const paramsPath = appendScenarioPath(options.path, "params");
|
|
555
|
+
const schemas = commandSchemas(options.game, command.interactionId);
|
|
556
|
+
if (schemas.length === 0) {
|
|
557
|
+
fail({
|
|
558
|
+
code: "UNKNOWN_INTERACTION",
|
|
559
|
+
path: interactionPath,
|
|
560
|
+
reason: `interaction '${command.interactionId}' is not declared by the game`
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
let firstSeatError;
|
|
564
|
+
let firstIssue;
|
|
565
|
+
for (const schema of schemas) {
|
|
566
|
+
try {
|
|
567
|
+
const resolved = resolveScenarioSeatReferences({
|
|
568
|
+
schema,
|
|
569
|
+
value: command.params,
|
|
570
|
+
playerIds: options.playerIds,
|
|
571
|
+
path: paramsPath
|
|
572
|
+
});
|
|
573
|
+
const parsed = schema.safeParse(resolved);
|
|
574
|
+
if (parsed.success) return;
|
|
575
|
+
firstIssue ??= parsed.error.issues[0];
|
|
576
|
+
} catch (error) {
|
|
577
|
+
if (error instanceof ScenarioSchemaValueError) {
|
|
578
|
+
firstSeatError ??= error;
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
throw error;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (firstSeatError) {
|
|
585
|
+
fail({
|
|
586
|
+
code: firstSeatError.message.includes("seat from") ? "OUT_OF_RANGE" : "INVALID_COMMAND_PARAMS",
|
|
587
|
+
path: firstSeatError.path,
|
|
588
|
+
reason: firstSeatError.message
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
fail({
|
|
592
|
+
code: "INVALID_COMMAND_PARAMS",
|
|
593
|
+
path: firstIssue ? issuePath(paramsPath, firstIssue) : paramsPath,
|
|
594
|
+
reason: firstIssue?.message ?? "parameters do not match the interaction contract"
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
function validateScenarioDefinition(game, value) {
|
|
598
|
+
const definition = requireObject(value, "scenario");
|
|
599
|
+
assertExactKeys(
|
|
600
|
+
definition,
|
|
601
|
+
["id", "description", "setup", "given", "when", "checkpoints", "then"],
|
|
602
|
+
"scenario"
|
|
603
|
+
);
|
|
604
|
+
for (const field of ["id", "setup", "given", "when", "then"]) {
|
|
605
|
+
assertOwn(definition, field, "scenario");
|
|
606
|
+
}
|
|
607
|
+
if (typeof definition.id !== "string" || definition.id === "") {
|
|
608
|
+
fail({
|
|
609
|
+
code: "INVALID_TYPE",
|
|
610
|
+
path: "scenario.id",
|
|
611
|
+
reason: "expected a non-empty string"
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
if (Object.prototype.hasOwnProperty.call(definition, "description") && typeof definition.description !== "string") {
|
|
615
|
+
fail({
|
|
616
|
+
code: "INVALID_TYPE",
|
|
617
|
+
path: "scenario.description",
|
|
618
|
+
reason: "expected a string when provided"
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
const setup = requireObject(definition.setup, "scenario.setup");
|
|
622
|
+
assertExactKeys(setup, ["players", "seed", "options"], "scenario.setup");
|
|
623
|
+
assertOwn(setup, "players", "scenario.setup");
|
|
624
|
+
assertOwn(setup, "seed", "scenario.setup");
|
|
625
|
+
const players = assertSafeInteger(setup.players, "scenario.setup.players");
|
|
626
|
+
const normalSetup = game.contract.manifest.normalSetup;
|
|
627
|
+
if (normalSetup === void 0 || !Number.isSafeInteger(normalSetup.minPlayers) || !Number.isSafeInteger(normalSetup.maxPlayers) || normalSetup.minPlayers <= 0 || normalSetup.maxPlayers < normalSetup.minPlayers) {
|
|
628
|
+
fail({
|
|
629
|
+
code: "NORMAL_SETUP_UNAVAILABLE",
|
|
630
|
+
path: "scenario.setup.players",
|
|
631
|
+
reason: "the game manifest does not provide valid normal setup bounds"
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
if (players < normalSetup.minPlayers || players > normalSetup.maxPlayers) {
|
|
635
|
+
fail({
|
|
636
|
+
code: "OUT_OF_RANGE",
|
|
637
|
+
path: "scenario.setup.players",
|
|
638
|
+
reason: `expected ${normalSetup.minPlayers} through ${normalSetup.maxPlayers} players`
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
assertSafeInteger(setup.seed, "scenario.setup.seed");
|
|
642
|
+
assertJsonSerializable(definition.id, "scenario.id");
|
|
643
|
+
if (Object.prototype.hasOwnProperty.call(definition, "description")) {
|
|
644
|
+
assertJsonSerializable(definition.description, "scenario.description");
|
|
645
|
+
}
|
|
646
|
+
assertJsonSerializable(setup, "scenario.setup");
|
|
647
|
+
if (!Array.isArray(definition.given)) {
|
|
648
|
+
fail({
|
|
649
|
+
code: "INVALID_TYPE",
|
|
650
|
+
path: "scenario.given",
|
|
651
|
+
reason: "expected an array"
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
if (!Array.isArray(definition.when)) {
|
|
655
|
+
fail({
|
|
656
|
+
code: "INVALID_TYPE",
|
|
657
|
+
path: "scenario.when",
|
|
658
|
+
reason: "expected an array"
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
if (typeof definition.then !== "function") {
|
|
662
|
+
fail({
|
|
663
|
+
code: "INVALID_TYPE",
|
|
664
|
+
path: "scenario.then",
|
|
665
|
+
reason: "expected an assertion function"
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
validateCheckpoints(definition);
|
|
669
|
+
const playerIds = playerIdsForDefinition(game, players);
|
|
670
|
+
definition.given.forEach(
|
|
671
|
+
(command, index) => validateCommand({
|
|
672
|
+
game,
|
|
673
|
+
command,
|
|
674
|
+
path: `scenario.given[${index}]`,
|
|
675
|
+
playerIds
|
|
676
|
+
})
|
|
677
|
+
);
|
|
678
|
+
definition.when.forEach(
|
|
679
|
+
(command, index) => validateCommand({
|
|
680
|
+
game,
|
|
681
|
+
command,
|
|
682
|
+
path: `scenario.when[${index}]`,
|
|
683
|
+
playerIds
|
|
684
|
+
})
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// src/testing/definitions.ts
|
|
689
|
+
function resolveScenarioCheckpoint(definition, selector) {
|
|
690
|
+
if (typeof selector !== "string") {
|
|
691
|
+
return structuredClone(selector);
|
|
692
|
+
}
|
|
693
|
+
const checkpoint = definition.checkpoints?.[selector];
|
|
694
|
+
if (checkpoint === void 0) {
|
|
695
|
+
const available = Object.keys(definition.checkpoints ?? {}).sort();
|
|
696
|
+
throw new ScenarioDefinitionValidationError({
|
|
697
|
+
code: "INVALID_VALUE",
|
|
698
|
+
path: "scenario.checkpoints",
|
|
699
|
+
reason: `checkpoint '${selector}' is not declared by scenario '${definition.id}'` + (available.length === 0 ? "; no named checkpoints are available" : `; available checkpoints: ${available.join(", ")}`)
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
return structuredClone(checkpoint);
|
|
703
|
+
}
|
|
704
|
+
var ScenarioReplayError = class extends Error {
|
|
705
|
+
scenarioId;
|
|
706
|
+
segment;
|
|
707
|
+
index;
|
|
708
|
+
interactionId;
|
|
709
|
+
errorCode;
|
|
710
|
+
reducerMessage;
|
|
711
|
+
trace;
|
|
712
|
+
constructor(options) {
|
|
713
|
+
super(
|
|
714
|
+
`Scenario '${options.scenarioId}' rejected ${options.segment}[${options.index}] interaction '${options.interactionId}' with ${options.errorCode}` + (options.reducerMessage ? `: ${options.reducerMessage}` : ".")
|
|
715
|
+
);
|
|
716
|
+
this.name = "ScenarioReplayError";
|
|
717
|
+
this.scenarioId = options.scenarioId;
|
|
718
|
+
this.segment = options.segment;
|
|
719
|
+
this.index = options.index;
|
|
720
|
+
this.interactionId = options.interactionId;
|
|
721
|
+
this.errorCode = options.errorCode;
|
|
722
|
+
this.reducerMessage = options.reducerMessage;
|
|
723
|
+
this.trace = options.trace ?? [];
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
function createScenarioAuthoring(game) {
|
|
727
|
+
return {
|
|
728
|
+
defineScenario(definition) {
|
|
729
|
+
validateScenarioDefinition(game, definition);
|
|
730
|
+
return definition;
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
function toScenarioReplayDefinition(definition) {
|
|
735
|
+
const replay = {
|
|
736
|
+
id: definition.id,
|
|
737
|
+
...definition.description === void 0 ? {} : { description: definition.description },
|
|
738
|
+
setup: definition.setup,
|
|
739
|
+
given: definition.given,
|
|
740
|
+
when: definition.when
|
|
741
|
+
};
|
|
742
|
+
return structuredClone(replay);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// src/testing/scenario-assertion-error.ts
|
|
746
|
+
var SCENARIO_ASSERTION_ERROR_CODE = "SCENARIO_ASSERTION_FAILED";
|
|
747
|
+
var ScenarioAssertionError = class extends Error {
|
|
748
|
+
code = SCENARIO_ASSERTION_ERROR_CODE;
|
|
749
|
+
constructor(message) {
|
|
750
|
+
super(message);
|
|
751
|
+
this.name = "ScenarioAssertionError";
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
function scenarioAssertionFailure(message) {
|
|
755
|
+
throw new ScenarioAssertionError(message);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// src/testing/create-expect-api.ts
|
|
759
|
+
function isRecord(value) {
|
|
760
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
761
|
+
}
|
|
762
|
+
function isDeepStrictEqual(actual, expected) {
|
|
763
|
+
if (Object.is(actual, expected)) return true;
|
|
764
|
+
if (typeof actual !== typeof expected) return false;
|
|
765
|
+
if (actual === null || expected === null) return false;
|
|
766
|
+
if (typeof actual !== "object" || typeof expected !== "object") return false;
|
|
767
|
+
if (actual instanceof Date || expected instanceof Date) {
|
|
768
|
+
return actual instanceof Date && expected instanceof Date && Object.is(actual.getTime(), expected.getTime());
|
|
769
|
+
}
|
|
770
|
+
if (actual instanceof RegExp || expected instanceof RegExp) {
|
|
771
|
+
return actual instanceof RegExp && expected instanceof RegExp && actual.source === expected.source && actual.flags === expected.flags;
|
|
772
|
+
}
|
|
773
|
+
if (Array.isArray(actual) || Array.isArray(expected)) {
|
|
774
|
+
return Array.isArray(actual) && Array.isArray(expected) && actual.length === expected.length && actual.every((value, index) => isDeepStrictEqual(value, expected[index]));
|
|
775
|
+
}
|
|
776
|
+
const actualPrototype = Object.getPrototypeOf(actual);
|
|
777
|
+
const expectedPrototype = Object.getPrototypeOf(expected);
|
|
778
|
+
if (actualPrototype !== expectedPrototype) return false;
|
|
779
|
+
const actualRecord = actual;
|
|
780
|
+
const expectedRecord = expected;
|
|
781
|
+
const actualKeys = Object.keys(actualRecord);
|
|
782
|
+
const expectedKeys = Object.keys(expectedRecord);
|
|
783
|
+
return actualKeys.length === expectedKeys.length && actualKeys.every(
|
|
784
|
+
(key) => Object.prototype.hasOwnProperty.call(expectedRecord, key) && isDeepStrictEqual(actualRecord[key], expectedRecord[key])
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
function matchPartial(actual, expected, path = "value") {
|
|
788
|
+
if (!isRecord(expected)) {
|
|
789
|
+
return isDeepStrictEqual(actual, expected) ? null : `${path} does not match`;
|
|
790
|
+
}
|
|
791
|
+
if (!isRecord(actual)) {
|
|
792
|
+
return `${path} is not an object`;
|
|
793
|
+
}
|
|
794
|
+
for (const [key, expectedValue] of Object.entries(expected)) {
|
|
795
|
+
const actualValue = actual[key];
|
|
796
|
+
if (!(key in actual)) {
|
|
797
|
+
return `${path}.${key} is missing`;
|
|
798
|
+
}
|
|
799
|
+
const mismatch = matchPartial(actualValue, expectedValue, `${path}.${key}`);
|
|
800
|
+
if (mismatch) {
|
|
801
|
+
return mismatch;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
function asDescriptorList(actual) {
|
|
807
|
+
if (!Array.isArray(actual)) {
|
|
808
|
+
scenarioAssertionFailure("Expected interaction descriptor array.");
|
|
809
|
+
}
|
|
810
|
+
return actual;
|
|
811
|
+
}
|
|
812
|
+
function findInteraction(descriptors, interactionId) {
|
|
813
|
+
return descriptors.find(
|
|
814
|
+
(descriptor) => descriptor.interactionId === interactionId
|
|
815
|
+
) ?? null;
|
|
816
|
+
}
|
|
817
|
+
function assertDescriptorMatches(descriptor, opts) {
|
|
818
|
+
if (!opts) {
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const mismatch = matchPartial(descriptor, opts, "interaction");
|
|
822
|
+
if (mismatch) {
|
|
823
|
+
scenarioAssertionFailure(mismatch);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
function createSubmissionErrorMatcher(actual, expected, options) {
|
|
827
|
+
if (isRecord(actual) && (actual.kind === "accepted" || actual.kind === "rejected")) {
|
|
828
|
+
if (actual.kind === "accepted") {
|
|
829
|
+
scenarioAssertionFailure(
|
|
830
|
+
"Expected probe command to reject, but it was accepted."
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
const errorCode = typeof actual.errorCode === "string" ? actual.errorCode : void 0;
|
|
834
|
+
const message = typeof actual.message === "string" ? actual.message : "";
|
|
835
|
+
if (expected.errorCode !== void 0 && errorCode !== expected.errorCode) {
|
|
836
|
+
scenarioAssertionFailure(
|
|
837
|
+
`Expected rejection errorCode '${expected.errorCode}', received '${errorCode ?? "undefined"}'.`
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
if (typeof expected.message === "string" && message !== expected.message) {
|
|
841
|
+
scenarioAssertionFailure(
|
|
842
|
+
`Expected rejection message '${expected.message}', received '${message}'.`
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
if (expected.message instanceof RegExp && !expected.message.test(message)) {
|
|
846
|
+
scenarioAssertionFailure(
|
|
847
|
+
`Expected rejection message '${message}' to match ${String(expected.message)}.`
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
return Promise.resolve();
|
|
851
|
+
}
|
|
852
|
+
const resolvePromise = () => {
|
|
853
|
+
if (typeof actual === "function") {
|
|
854
|
+
return Promise.resolve().then(() => actual());
|
|
855
|
+
}
|
|
856
|
+
return Promise.resolve(actual);
|
|
857
|
+
};
|
|
858
|
+
return resolvePromise().then(
|
|
859
|
+
() => scenarioAssertionFailure("Expected promise to reject."),
|
|
860
|
+
(error) => {
|
|
861
|
+
if (!(error instanceof Error)) {
|
|
862
|
+
scenarioAssertionFailure("Expected rejection to be an Error.");
|
|
863
|
+
}
|
|
864
|
+
if (expected.errorCode !== void 0 && error.errorCode !== expected.errorCode) {
|
|
865
|
+
scenarioAssertionFailure(
|
|
866
|
+
`Expected rejection errorCode '${expected.errorCode}', received '${error.errorCode ?? "undefined"}'.${formatLastDiagnosticRejection(options)}`
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
if (typeof expected.message === "string" && error.message !== expected.message) {
|
|
870
|
+
scenarioAssertionFailure(
|
|
871
|
+
`Expected rejection message '${expected.message}', received '${error.message}'.${formatLastDiagnosticRejection(
|
|
872
|
+
options
|
|
873
|
+
)}`
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
if (expected.message instanceof RegExp && !expected.message.test(error.message)) {
|
|
877
|
+
scenarioAssertionFailure(
|
|
878
|
+
`Expected rejection message '${error.message}' to match ${String(
|
|
879
|
+
expected.message
|
|
880
|
+
)}.${formatLastDiagnosticRejection(options)}`
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
function formatLastDiagnosticRejection(options) {
|
|
887
|
+
const rejection = options.lastDiagnosticRejection?.();
|
|
888
|
+
if (!rejection) return "";
|
|
889
|
+
const rule = rejection.ruleId ? ` ruleId=${rejection.ruleId}` : "";
|
|
890
|
+
const message = rejection.message ? ` message=${rejection.message}` : "";
|
|
891
|
+
return `
|
|
892
|
+
last rejection: errorCode=${rejection.errorCode}${rule}${message}`;
|
|
893
|
+
}
|
|
894
|
+
function assertLength(actual, expected) {
|
|
895
|
+
if (actual === null || actual === void 0 || typeof actual.length !== "number") {
|
|
896
|
+
scenarioAssertionFailure(
|
|
897
|
+
"toHaveLength expects a value with a numeric length."
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
const length = actual.length;
|
|
901
|
+
if (length !== expected) {
|
|
902
|
+
scenarioAssertionFailure(
|
|
903
|
+
`Expected length ${expected}, received ${length}.`
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
function formatInteractionExplanation(explanation) {
|
|
908
|
+
if (!explanation) {
|
|
909
|
+
return "";
|
|
910
|
+
}
|
|
911
|
+
const lines = [
|
|
912
|
+
`availability: ${explanation.availability}`,
|
|
913
|
+
...explanation.rules.map((rule) => {
|
|
914
|
+
const code = rule.errorCode ? ` (${rule.errorCode})` : "";
|
|
915
|
+
const message = rule.message ? `: ${rule.message}` : "";
|
|
916
|
+
return `rule ${rule.ruleId} ${rule.outcome}${code}${message}`;
|
|
917
|
+
}),
|
|
918
|
+
...explanation.inputs.map(
|
|
919
|
+
(input) => `input ${input.key} eligibleCount=${input.eligibleCount}`
|
|
920
|
+
)
|
|
921
|
+
];
|
|
922
|
+
return `
|
|
923
|
+
${lines.join("\n")}`;
|
|
924
|
+
}
|
|
925
|
+
function assertThrown(actual, predicate) {
|
|
926
|
+
if (typeof actual !== "function") {
|
|
927
|
+
scenarioAssertionFailure("toThrow expects a function.");
|
|
928
|
+
}
|
|
929
|
+
try {
|
|
930
|
+
actual();
|
|
931
|
+
} catch (error) {
|
|
932
|
+
if (!(error instanceof Error)) {
|
|
933
|
+
scenarioAssertionFailure("Thrown value is not an Error.");
|
|
934
|
+
}
|
|
935
|
+
if (predicate === void 0) {
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
if (typeof predicate === "string" && error.message !== predicate) {
|
|
939
|
+
scenarioAssertionFailure(
|
|
940
|
+
`Expected thrown message '${predicate}', received '${error.message}'.`
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
if (predicate instanceof RegExp && !predicate.test(error.message)) {
|
|
944
|
+
scenarioAssertionFailure(
|
|
945
|
+
`Expected thrown message '${error.message}' to match ${String(predicate)}.`
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
if (typeof predicate === "function" && !predicate(error)) {
|
|
949
|
+
scenarioAssertionFailure("Thrown error did not satisfy predicate.");
|
|
950
|
+
}
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
scenarioAssertionFailure("Expected function to throw.");
|
|
954
|
+
}
|
|
955
|
+
function createExpectApi(options = {}) {
|
|
956
|
+
const buildMatchers = (actual) => ({
|
|
957
|
+
toBe: (expected) => {
|
|
958
|
+
if (actual !== expected) {
|
|
959
|
+
scenarioAssertionFailure(
|
|
960
|
+
`Expected '${String(actual)}' to be '${String(expected)}'.`
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
},
|
|
964
|
+
toEqual: (expected) => {
|
|
965
|
+
if (!isDeepStrictEqual(actual, expected)) {
|
|
966
|
+
scenarioAssertionFailure("Expected values to be deeply equal.");
|
|
967
|
+
}
|
|
968
|
+
},
|
|
969
|
+
toMatchObject: (expected) => {
|
|
970
|
+
const mismatch = matchPartial(actual, expected);
|
|
971
|
+
if (mismatch) {
|
|
972
|
+
scenarioAssertionFailure(mismatch);
|
|
973
|
+
}
|
|
974
|
+
},
|
|
975
|
+
toBeDefined: () => {
|
|
976
|
+
if (actual === void 0) {
|
|
977
|
+
scenarioAssertionFailure("Expected value to be defined.");
|
|
978
|
+
}
|
|
979
|
+
},
|
|
980
|
+
toBeUndefined: () => {
|
|
981
|
+
if (actual !== void 0) {
|
|
982
|
+
scenarioAssertionFailure(
|
|
983
|
+
`Expected value to be undefined, but received '${String(actual)}'.`
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
},
|
|
987
|
+
toBeNull: () => {
|
|
988
|
+
if (actual !== null) {
|
|
989
|
+
scenarioAssertionFailure(
|
|
990
|
+
`Expected value to be null, but received '${String(actual)}'.`
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
},
|
|
994
|
+
toContain: (expected) => {
|
|
995
|
+
if (Array.isArray(actual)) {
|
|
996
|
+
if (!actual.includes(expected)) {
|
|
997
|
+
scenarioAssertionFailure("Expected array to contain value.");
|
|
998
|
+
}
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
if (typeof actual === "string") {
|
|
1002
|
+
if (!actual.includes(String(expected))) {
|
|
1003
|
+
scenarioAssertionFailure("Expected string to contain value.");
|
|
1004
|
+
}
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
scenarioAssertionFailure(
|
|
1008
|
+
"toContain expects an array or string actual value."
|
|
1009
|
+
);
|
|
1010
|
+
},
|
|
1011
|
+
toContainEqual: (expected) => {
|
|
1012
|
+
if (!Array.isArray(actual)) {
|
|
1013
|
+
scenarioAssertionFailure(
|
|
1014
|
+
"toContainEqual expects an array actual value."
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
if (!actual.some((value) => isDeepStrictEqual(value, expected))) {
|
|
1018
|
+
scenarioAssertionFailure("Expected array to contain an equal value.");
|
|
1019
|
+
}
|
|
1020
|
+
},
|
|
1021
|
+
toHaveLength: (expected) => {
|
|
1022
|
+
assertLength(actual, expected);
|
|
1023
|
+
},
|
|
1024
|
+
toBeGreaterThan: (expected) => {
|
|
1025
|
+
if (typeof actual !== "number") {
|
|
1026
|
+
scenarioAssertionFailure(
|
|
1027
|
+
"toBeGreaterThan expects a number actual value."
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
if (actual <= expected) {
|
|
1031
|
+
scenarioAssertionFailure(`Expected ${actual} to be > ${expected}.`);
|
|
1032
|
+
}
|
|
1033
|
+
},
|
|
1034
|
+
toBeGreaterThanOrEqual: (expected) => {
|
|
1035
|
+
if (typeof actual !== "number") {
|
|
1036
|
+
scenarioAssertionFailure(
|
|
1037
|
+
"toBeGreaterThanOrEqual expects a number actual value."
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
if (actual < expected) {
|
|
1041
|
+
scenarioAssertionFailure(`Expected ${actual} to be >= ${expected}.`);
|
|
1042
|
+
}
|
|
1043
|
+
},
|
|
1044
|
+
toThrow: (predicate) => {
|
|
1045
|
+
assertThrown(actual, predicate);
|
|
1046
|
+
},
|
|
1047
|
+
toMatchSnapshot: (filename) => {
|
|
1048
|
+
if (!options.matchSnapshot) {
|
|
1049
|
+
scenarioAssertionFailure(
|
|
1050
|
+
"Snapshot matching is not configured for this expect API."
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
options.matchSnapshot(filename, actual);
|
|
1054
|
+
},
|
|
1055
|
+
toRejectWith: (expected) => createSubmissionErrorMatcher(actual, expected, options),
|
|
1056
|
+
toHaveInteraction: (interactionId, opts) => {
|
|
1057
|
+
const descriptors = asDescriptorList(actual);
|
|
1058
|
+
const descriptor = findInteraction(descriptors, interactionId);
|
|
1059
|
+
if (!descriptor) {
|
|
1060
|
+
scenarioAssertionFailure(
|
|
1061
|
+
`Expected interaction '${interactionId}' to exist.`
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
assertDescriptorMatches(descriptor, opts);
|
|
1065
|
+
},
|
|
1066
|
+
toBeGatedBy: (reason, opts) => {
|
|
1067
|
+
const descriptor = Array.isArray(actual) ? (() => {
|
|
1068
|
+
if (!opts?.interactionId) {
|
|
1069
|
+
scenarioAssertionFailure(
|
|
1070
|
+
"toBeGatedBy on a descriptor array requires opts.interactionId."
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
return findInteraction(
|
|
1074
|
+
asDescriptorList(actual),
|
|
1075
|
+
opts.interactionId
|
|
1076
|
+
);
|
|
1077
|
+
})() : actual;
|
|
1078
|
+
if (!descriptor) {
|
|
1079
|
+
scenarioAssertionFailure("Expected interaction descriptor to exist.");
|
|
1080
|
+
}
|
|
1081
|
+
if (descriptor.availability?.status === "available") {
|
|
1082
|
+
scenarioAssertionFailure("Expected interaction to be unavailable.");
|
|
1083
|
+
}
|
|
1084
|
+
const actualReason = descriptor.availability?.status === "notYourTurn" || descriptor.availability?.status === "blocked" ? descriptor.availability.reason : void 0;
|
|
1085
|
+
if (actualReason !== reason) {
|
|
1086
|
+
scenarioAssertionFailure(
|
|
1087
|
+
`Expected unavailable reason '${reason}', received '${actualReason ?? "undefined"}'.`
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
},
|
|
1091
|
+
toBeAvailable: (explanation) => {
|
|
1092
|
+
const descriptor = Array.isArray(actual) ? (() => {
|
|
1093
|
+
if (!explanation?.interactionId) {
|
|
1094
|
+
scenarioAssertionFailure(
|
|
1095
|
+
"toBeAvailable on a descriptor array requires an explanation."
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
return findInteraction(
|
|
1099
|
+
asDescriptorList(actual),
|
|
1100
|
+
explanation.interactionId
|
|
1101
|
+
);
|
|
1102
|
+
})() : actual;
|
|
1103
|
+
if (!descriptor) {
|
|
1104
|
+
scenarioAssertionFailure(
|
|
1105
|
+
`Expected interaction descriptor to exist.${formatInteractionExplanation(
|
|
1106
|
+
explanation
|
|
1107
|
+
)}`
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
if (descriptor.availability?.status !== "available") {
|
|
1111
|
+
scenarioAssertionFailure(
|
|
1112
|
+
`Expected interaction '${descriptor.interactionId ?? "unknown"}' to be available.${formatInteractionExplanation(
|
|
1113
|
+
explanation
|
|
1114
|
+
)}`
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
},
|
|
1118
|
+
not: {
|
|
1119
|
+
toHaveInteraction: (interactionId) => {
|
|
1120
|
+
const descriptors = asDescriptorList(actual);
|
|
1121
|
+
if (findInteraction(descriptors, interactionId)) {
|
|
1122
|
+
scenarioAssertionFailure(
|
|
1123
|
+
`Expected interaction '${interactionId}' to be absent.`
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
});
|
|
1129
|
+
return (actual) => buildMatchers(actual);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// src/testing/player-roster.ts
|
|
1133
|
+
function resolvePlayerRoster(game, count) {
|
|
1134
|
+
const setup = game.contract.manifest.normalSetup;
|
|
1135
|
+
if (!setup) throw new Error("Game manifest does not expose normal setup.");
|
|
1136
|
+
if (!Number.isSafeInteger(count) || count < setup.minPlayers || count > setup.maxPlayers)
|
|
1137
|
+
throw new Error("Player count is outside manifest limits.");
|
|
1138
|
+
const declared = game.contract.manifest.literals.playerIds;
|
|
1139
|
+
if (declared.length < count)
|
|
1140
|
+
throw new Error("Manifest roster does not contain enough players.");
|
|
1141
|
+
return declared.slice(0, count);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// src/testing/reducer-runtime.ts
|
|
1145
|
+
function createReducerTestingRuntime(definition, options = {}, bundle = createReducerBundle(definition, options)) {
|
|
1146
|
+
const codec = createIngressRuntimeCodec(definition);
|
|
1147
|
+
const parseState = (state) => codec.parseState(state);
|
|
1148
|
+
const parseInput = (input) => codec.parseInput(input);
|
|
1149
|
+
const scope = createTrustedRuntimeScope(definition);
|
|
1150
|
+
const interactions = createInteractionResolver(scope, {
|
|
1151
|
+
diagnostics: options.descriptorDiagnostics ?? (options.diagnostics === "verbose" ? "verbose" : void 0)
|
|
1152
|
+
});
|
|
1153
|
+
const projection = createProjectionBuilder(scope, interactions);
|
|
1154
|
+
function inspect({ state, playerId, interactionId }) {
|
|
1155
|
+
if (typeof playerId !== "string")
|
|
1156
|
+
throw new Error("Expected a string playerId.");
|
|
1157
|
+
return {
|
|
1158
|
+
state: scope.toCombinedState(parseState(state)),
|
|
1159
|
+
playerId: codec.parsePlayerId(playerId),
|
|
1160
|
+
interactionId
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
return {
|
|
1164
|
+
...bundle,
|
|
1165
|
+
async validateInput({ state, input }) {
|
|
1166
|
+
return interactions.validateClientInput(
|
|
1167
|
+
scope.toCombinedState(parseState(state)),
|
|
1168
|
+
parseInput(input)
|
|
1169
|
+
);
|
|
1170
|
+
},
|
|
1171
|
+
async reduce(input) {
|
|
1172
|
+
const result = await bundle.dispatch(input);
|
|
1173
|
+
if (result.kind === "reject") return result;
|
|
1174
|
+
return {
|
|
1175
|
+
kind: result.kind,
|
|
1176
|
+
state: result.state,
|
|
1177
|
+
events: result.events,
|
|
1178
|
+
...result.terminal ? { terminal: result.terminal } : {}
|
|
1179
|
+
};
|
|
1180
|
+
},
|
|
1181
|
+
project({ state, playerIds, projectionMode }) {
|
|
1182
|
+
if (projectionMode !== "actionsOnly")
|
|
1183
|
+
return bundle.project({ state, playerIds });
|
|
1184
|
+
return projection.project({
|
|
1185
|
+
state: parseState(state),
|
|
1186
|
+
playerIds: playerIds.map((id) => codec.parsePlayerId(id)),
|
|
1187
|
+
projectionMode
|
|
1188
|
+
});
|
|
1189
|
+
},
|
|
1190
|
+
explainInteraction(input) {
|
|
1191
|
+
return interactions.explainInteraction(inspect(input));
|
|
1192
|
+
},
|
|
1193
|
+
currentClientParamSchema(input) {
|
|
1194
|
+
return interactions.currentClientParamSchema(inspect(input));
|
|
1195
|
+
},
|
|
1196
|
+
resolveInteractionActionability(input) {
|
|
1197
|
+
return interactions.resolveInteractionActionability(inspect(input));
|
|
1198
|
+
},
|
|
1199
|
+
enumerateInteractionParams(input) {
|
|
1200
|
+
return interactions.enumerateInteractionParams({
|
|
1201
|
+
...inspect(input),
|
|
1202
|
+
maxEvaluations: input.maxEvaluations
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// src/testing/scenario-player-refs.ts
|
|
1209
|
+
var ScenarioCommandParamsError = class extends Error {
|
|
1210
|
+
path;
|
|
1211
|
+
constructor(path, message) {
|
|
1212
|
+
super(`${path}: ${message}`);
|
|
1213
|
+
this.name = "ScenarioCommandParamsError";
|
|
1214
|
+
this.path = path;
|
|
1215
|
+
}
|
|
1216
|
+
};
|
|
1217
|
+
function isScenarioSeatRef(value) {
|
|
1218
|
+
if (!isPlainRecord(value)) return false;
|
|
1219
|
+
const keys = Object.keys(value);
|
|
1220
|
+
return keys.length === 1 && keys[0] === "seat" && Number.isSafeInteger(value.seat) && value.seat >= 0;
|
|
1221
|
+
}
|
|
1222
|
+
function resolveScenarioSeatRef(options) {
|
|
1223
|
+
if (!isScenarioSeatRef(options.ref)) {
|
|
1224
|
+
throw new ScenarioCommandParamsError(
|
|
1225
|
+
options.path,
|
|
1226
|
+
"expected a scenario seat reference"
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
const playerId = options.playerIds[options.ref.seat];
|
|
1230
|
+
if (playerId === void 0) {
|
|
1231
|
+
throw new ScenarioCommandParamsError(
|
|
1232
|
+
`${options.path}.seat`,
|
|
1233
|
+
`seat ${options.ref.seat} is out of range for ${options.playerIds.length} player(s)`
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
return playerId;
|
|
1237
|
+
}
|
|
1238
|
+
function resolveScenarioCommandParams(options) {
|
|
1239
|
+
const candidateSchemas = clientParamSchemaCandidates(options);
|
|
1240
|
+
let firstSeatError;
|
|
1241
|
+
let firstIssue;
|
|
1242
|
+
for (const schema of candidateSchemas) {
|
|
1243
|
+
try {
|
|
1244
|
+
const resolved = resolveScenarioSeatReferences({
|
|
1245
|
+
schema,
|
|
1246
|
+
value: options.params,
|
|
1247
|
+
playerIds: options.playerIds,
|
|
1248
|
+
path: `${options.path}.params`
|
|
1249
|
+
});
|
|
1250
|
+
const parsed = schema.safeParse(resolved);
|
|
1251
|
+
if (parsed.success) {
|
|
1252
|
+
return resolved;
|
|
1253
|
+
}
|
|
1254
|
+
firstIssue ??= parsed.error.issues[0];
|
|
1255
|
+
} catch (error) {
|
|
1256
|
+
if (error instanceof ScenarioSchemaValueError) {
|
|
1257
|
+
firstSeatError ??= error;
|
|
1258
|
+
continue;
|
|
1259
|
+
}
|
|
1260
|
+
throw error;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
if (firstSeatError) {
|
|
1264
|
+
throw new ScenarioCommandParamsError(
|
|
1265
|
+
firstSeatError.path,
|
|
1266
|
+
firstSeatError.message
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
const suffix = firstIssue?.path.map(
|
|
1270
|
+
(part) => typeof part === "number" ? `[${part}]` : `.${String(part)}`
|
|
1271
|
+
).join("") ?? "";
|
|
1272
|
+
throw new ScenarioCommandParamsError(
|
|
1273
|
+
`${options.path}.params${suffix}`,
|
|
1274
|
+
firstIssue?.message ?? "invalid interaction parameters"
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1277
|
+
function projectScenarioCommandParams(options) {
|
|
1278
|
+
const candidateSchemas = clientParamSchemaCandidates(options);
|
|
1279
|
+
let firstSeatError;
|
|
1280
|
+
for (const schema of candidateSchemas) {
|
|
1281
|
+
if (!schema.safeParse(options.params).success) continue;
|
|
1282
|
+
try {
|
|
1283
|
+
const projected = projectScenarioSeatReferences({
|
|
1284
|
+
schema,
|
|
1285
|
+
value: options.params,
|
|
1286
|
+
playerIds: options.playerIds,
|
|
1287
|
+
path: `${options.path}.params`
|
|
1288
|
+
});
|
|
1289
|
+
if (isPlainRecord(projected)) return projected;
|
|
1290
|
+
} catch (error) {
|
|
1291
|
+
if (error instanceof ScenarioSchemaValueError) {
|
|
1292
|
+
firstSeatError ??= error;
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
throw error;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
if (firstSeatError) {
|
|
1299
|
+
throw new ScenarioCommandParamsError(
|
|
1300
|
+
firstSeatError.path,
|
|
1301
|
+
firstSeatError.message
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1304
|
+
throw new ScenarioCommandParamsError(
|
|
1305
|
+
`${options.path}.params`,
|
|
1306
|
+
"runtime interaction parameters do not match a client parameter schema"
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1309
|
+
function clientParamSchemaCandidates(options) {
|
|
1310
|
+
if (options.currentSchema) return [options.currentSchema];
|
|
1311
|
+
const schemas = createClientParamSchemasByPhase(options.game);
|
|
1312
|
+
const currentPhaseSchema = schemas[options.phase]?.[options.interactionId];
|
|
1313
|
+
const candidateSchemas = [
|
|
1314
|
+
...currentPhaseSchema ? [currentPhaseSchema] : [],
|
|
1315
|
+
...Object.entries(schemas).flatMap(([phase, schemasForPhase]) => {
|
|
1316
|
+
if (phase === options.phase) return [];
|
|
1317
|
+
const schema = schemasForPhase[options.interactionId];
|
|
1318
|
+
return schema ? [schema] : [];
|
|
1319
|
+
})
|
|
1320
|
+
];
|
|
1321
|
+
if (candidateSchemas.length === 0) {
|
|
1322
|
+
throw new ScenarioCommandParamsError(
|
|
1323
|
+
`${options.path}.interactionId`,
|
|
1324
|
+
`interaction '${options.interactionId}' has no client parameter schema`
|
|
1325
|
+
);
|
|
1326
|
+
}
|
|
1327
|
+
return candidateSchemas;
|
|
1328
|
+
}
|
|
1329
|
+
function isPlainRecord(value) {
|
|
1330
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1331
|
+
return false;
|
|
1332
|
+
}
|
|
1333
|
+
const prototype = Object.getPrototypeOf(value);
|
|
1334
|
+
return prototype === Object.prototype || prototype === null;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// src/testing/scenario-replay.ts
|
|
1338
|
+
async function replayScenario(options) {
|
|
1339
|
+
validateReplayDefinition(options.game, options.scenario);
|
|
1340
|
+
const scenario = cloneReplayDefinition(options.scenario);
|
|
1341
|
+
const checkpoint = normalizeCheckpoint(scenario, options.at);
|
|
1342
|
+
const playerIds = resolvePlayerRoster(options.game, scenario.setup.players);
|
|
1343
|
+
const events = [];
|
|
1344
|
+
const bundle = createScenarioBundle(options.game, events, options.bundle);
|
|
1345
|
+
const normalSetup = options.game.contract.manifest.normalSetup;
|
|
1346
|
+
if (!normalSetup) {
|
|
1347
|
+
throw new ScenarioDefinitionValidationError({
|
|
1348
|
+
code: "NORMAL_SETUP_UNAVAILABLE",
|
|
1349
|
+
path: "scenario.setup.players",
|
|
1350
|
+
reason: "the game manifest does not expose normal setup"
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
const table = normalSetup.createInitialTable({ playerIds });
|
|
1354
|
+
const initialized = await bundle.initialize({
|
|
1355
|
+
table,
|
|
1356
|
+
playerIds: [...playerIds],
|
|
1357
|
+
rngSeed: scenario.setup.seed,
|
|
1358
|
+
options: scenario.setup.options
|
|
1359
|
+
});
|
|
1360
|
+
const runtime = new ScenarioReplayImplementation({
|
|
1361
|
+
game: options.game,
|
|
1362
|
+
productionBundle: options.bundle,
|
|
1363
|
+
scenario,
|
|
1364
|
+
playerIds,
|
|
1365
|
+
reducerState: initialized.state,
|
|
1366
|
+
terminal: initialized.terminal ?? null,
|
|
1367
|
+
checkpoint: { segment: "setup", completed: 0 },
|
|
1368
|
+
trace: [],
|
|
1369
|
+
events
|
|
1370
|
+
});
|
|
1371
|
+
await runtime.replayTo(checkpoint);
|
|
1372
|
+
return runtime;
|
|
1373
|
+
}
|
|
1374
|
+
async function materializeScenarioRuntimeCheckpoint(options) {
|
|
1375
|
+
const replay = requireScenarioReplayImplementation(
|
|
1376
|
+
await replayScenario(options)
|
|
1377
|
+
);
|
|
1378
|
+
return {
|
|
1379
|
+
checkpoint: structuredClone(replay.checkpoint),
|
|
1380
|
+
checkpointDigest: replay.checkpointDigest,
|
|
1381
|
+
playerIds: [...replay.playerIds],
|
|
1382
|
+
state: structuredClone(replay.reducerState),
|
|
1383
|
+
terminal: structuredClone(replay.terminal)
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
function cloneReplayDefinition(scenario) {
|
|
1387
|
+
return structuredClone({
|
|
1388
|
+
id: scenario.id,
|
|
1389
|
+
...scenario.description === void 0 ? {} : { description: scenario.description },
|
|
1390
|
+
setup: scenario.setup,
|
|
1391
|
+
given: scenario.given,
|
|
1392
|
+
when: scenario.when
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
async function assertScenario(options) {
|
|
1396
|
+
const replay = requireScenarioReplayImplementation(options.replay);
|
|
1397
|
+
if (!replay.complete) {
|
|
1398
|
+
throw new Error(
|
|
1399
|
+
`Scenario '${replay.scenarioId}' assertions require a full replay; checkpoint is ${replay.checkpoint.segment}:${replay.checkpoint.completed}.`
|
|
1400
|
+
);
|
|
1401
|
+
}
|
|
1402
|
+
const context = {
|
|
1403
|
+
expect: createExpectApi({
|
|
1404
|
+
lastDiagnosticRejection: () => lastRejectedEvent(replay.diagnostics)
|
|
1405
|
+
}),
|
|
1406
|
+
state: replay.state,
|
|
1407
|
+
view: replay.view,
|
|
1408
|
+
interactions: replay.interactions,
|
|
1409
|
+
explain: replay.explain,
|
|
1410
|
+
get diagnostics() {
|
|
1411
|
+
return replay.diagnostics;
|
|
1412
|
+
},
|
|
1413
|
+
probe: (command) => replay.probe(command)
|
|
1414
|
+
};
|
|
1415
|
+
await options.assertion(context);
|
|
1416
|
+
}
|
|
1417
|
+
async function probeScenarioCommand(options) {
|
|
1418
|
+
return requireScenarioReplayImplementation(options.replay).probe(
|
|
1419
|
+
options.command
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
var ScenarioReplayImplementation = class _ScenarioReplayImplementation {
|
|
1423
|
+
scenarioId;
|
|
1424
|
+
scenario;
|
|
1425
|
+
game;
|
|
1426
|
+
playerIds;
|
|
1427
|
+
bundle;
|
|
1428
|
+
productionBundle;
|
|
1429
|
+
events;
|
|
1430
|
+
checkpoint;
|
|
1431
|
+
reducerState;
|
|
1432
|
+
terminal;
|
|
1433
|
+
trace;
|
|
1434
|
+
constructor(state) {
|
|
1435
|
+
this.scenarioId = state.scenario.id;
|
|
1436
|
+
this.scenario = state.scenario;
|
|
1437
|
+
this.game = state.game;
|
|
1438
|
+
this.playerIds = [...state.playerIds];
|
|
1439
|
+
this.reducerState = structuredClone(state.reducerState);
|
|
1440
|
+
this.terminal = structuredClone(state.terminal);
|
|
1441
|
+
this.checkpoint = structuredClone(state.checkpoint);
|
|
1442
|
+
this.trace = structuredClone(
|
|
1443
|
+
state.trace
|
|
1444
|
+
);
|
|
1445
|
+
this.events = structuredClone(state.events);
|
|
1446
|
+
this.productionBundle = state.productionBundle;
|
|
1447
|
+
this.bundle = createScenarioBundle(
|
|
1448
|
+
state.game,
|
|
1449
|
+
this.events,
|
|
1450
|
+
this.productionBundle
|
|
1451
|
+
);
|
|
1452
|
+
this.state = this.state.bind(this);
|
|
1453
|
+
this.view = this.view.bind(this);
|
|
1454
|
+
this.interactions = this.interactions.bind(this);
|
|
1455
|
+
this.explain = this.explain.bind(this);
|
|
1456
|
+
}
|
|
1457
|
+
get checkpointDigest() {
|
|
1458
|
+
return digestPluginRuntimeJson({
|
|
1459
|
+
digestVersion: "scenario-checkpoint@1",
|
|
1460
|
+
scenarioId: this.scenarioId,
|
|
1461
|
+
checkpoint: this.checkpoint,
|
|
1462
|
+
reducerState: this.reducerState,
|
|
1463
|
+
terminal: this.terminal,
|
|
1464
|
+
trace: this.trace
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
get complete() {
|
|
1468
|
+
return this.checkpoint.segment === "when" && this.checkpoint.completed === this.scenario.when.length;
|
|
1469
|
+
}
|
|
1470
|
+
get diagnostics() {
|
|
1471
|
+
const flow = readFlowState(this.reducerState);
|
|
1472
|
+
return {
|
|
1473
|
+
events: structuredClone(this.events),
|
|
1474
|
+
lastDispatch: lastAcceptedDispatch(this.events),
|
|
1475
|
+
flow: {
|
|
1476
|
+
currentPhase: flow.currentPhase,
|
|
1477
|
+
activeSeats: flow.activePlayers.flatMap((playerId) => {
|
|
1478
|
+
const seat = this.playerIds.indexOf(playerId);
|
|
1479
|
+
return seat < 0 ? [] : [{ seat }];
|
|
1480
|
+
})
|
|
1481
|
+
}
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
state() {
|
|
1485
|
+
return structuredClone(this.reducerState.domain);
|
|
1486
|
+
}
|
|
1487
|
+
view(seat) {
|
|
1488
|
+
const playerId = this.playerId(seat, "seat");
|
|
1489
|
+
const projection = this.project(playerId);
|
|
1490
|
+
return structuredClone(projection.seats?.[playerId]?.view ?? null);
|
|
1491
|
+
}
|
|
1492
|
+
interactions(seat) {
|
|
1493
|
+
const playerId = this.playerId(seat, "seat");
|
|
1494
|
+
const projection = this.project(playerId);
|
|
1495
|
+
const refs = projection.seats?.[playerId]?.availableInteractionRefs ?? [];
|
|
1496
|
+
const interactionsByRef = projection.interactionsByRef ?? {};
|
|
1497
|
+
return structuredClone(
|
|
1498
|
+
refs.flatMap((ref) => {
|
|
1499
|
+
const descriptor = interactionsByRef[ref];
|
|
1500
|
+
return descriptor ? [descriptor] : [];
|
|
1501
|
+
})
|
|
1502
|
+
);
|
|
1503
|
+
}
|
|
1504
|
+
explain(seat, interactionId) {
|
|
1505
|
+
return structuredClone(
|
|
1506
|
+
this.bundle.explainInteraction({
|
|
1507
|
+
state: this.reducerState,
|
|
1508
|
+
playerId: this.playerId(seat, "seat"),
|
|
1509
|
+
interactionId
|
|
1510
|
+
})
|
|
1511
|
+
);
|
|
1512
|
+
}
|
|
1513
|
+
clone() {
|
|
1514
|
+
return new _ScenarioReplayImplementation({
|
|
1515
|
+
game: this.game,
|
|
1516
|
+
productionBundle: this.productionBundle,
|
|
1517
|
+
scenario: this.scenario,
|
|
1518
|
+
playerIds: this.playerIds,
|
|
1519
|
+
reducerState: this.reducerState,
|
|
1520
|
+
terminal: this.terminal,
|
|
1521
|
+
checkpoint: this.checkpoint,
|
|
1522
|
+
trace: this.trace,
|
|
1523
|
+
events: this.events
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
inspectionAuthority(perspective) {
|
|
1527
|
+
const selectedPlayerId = perspective.kind === "player" ? this.playerId({ seat: perspective.seat }, "perspective.seat") : null;
|
|
1528
|
+
const projection = this.bundle.project({
|
|
1529
|
+
state: this.reducerState,
|
|
1530
|
+
playerIds: selectedPlayerId ? [selectedPlayerId] : []
|
|
1531
|
+
});
|
|
1532
|
+
const descriptors = selectedPlayerId ? descriptorsForPlayer(projection, selectedPlayerId) : [];
|
|
1533
|
+
const flow = readFlowState(this.reducerState);
|
|
1534
|
+
return {
|
|
1535
|
+
game: this.game,
|
|
1536
|
+
scenario: structuredClone(this.scenario),
|
|
1537
|
+
playerIds: [...this.playerIds],
|
|
1538
|
+
checkpoint: structuredClone(this.checkpoint),
|
|
1539
|
+
checkpointDigest: this.checkpointDigest,
|
|
1540
|
+
publicState: structuredClone(this.reducerState.domain.publicState),
|
|
1541
|
+
view: structuredClone(
|
|
1542
|
+
selectedPlayerId ? projection.seats[selectedPlayerId]?.view ?? null : null
|
|
1543
|
+
),
|
|
1544
|
+
scheduler: {
|
|
1545
|
+
phase: flow.currentPhase ?? "",
|
|
1546
|
+
step: readPhaseStep(this.reducerState.domain.phase),
|
|
1547
|
+
activePlayerIds: [...projection.schedulerFlow?.activePlayerIds ?? []],
|
|
1548
|
+
pendingPlayerIds: [
|
|
1549
|
+
...projection.schedulerFlow?.pendingPlayerIds ?? projection.simultaneousPhase?.pendingPlayerIds ?? []
|
|
1550
|
+
],
|
|
1551
|
+
continuationDependencies: structuredClone(
|
|
1552
|
+
projection.schedulerFlow?.continuationDependencies ?? []
|
|
1553
|
+
)
|
|
1554
|
+
},
|
|
1555
|
+
interactions: descriptors.map((descriptor) => ({
|
|
1556
|
+
descriptor: structuredClone(descriptor),
|
|
1557
|
+
explanation: structuredClone(
|
|
1558
|
+
this.bundle.explainInteraction({
|
|
1559
|
+
state: this.reducerState,
|
|
1560
|
+
playerId: selectedPlayerId,
|
|
1561
|
+
interactionId: String(descriptor.interactionId ?? "")
|
|
1562
|
+
})
|
|
1563
|
+
),
|
|
1564
|
+
actionability: structuredClone(
|
|
1565
|
+
this.bundle.resolveInteractionActionability({
|
|
1566
|
+
state: this.reducerState,
|
|
1567
|
+
playerId: selectedPlayerId,
|
|
1568
|
+
interactionId: String(descriptor.interactionId ?? "")
|
|
1569
|
+
})
|
|
1570
|
+
)
|
|
1571
|
+
})),
|
|
1572
|
+
entropy: {
|
|
1573
|
+
seed: this.reducerState.runtime.rng.seed,
|
|
1574
|
+
draws: structuredClone(this.reducerState.runtime.rng.draws ?? [])
|
|
1575
|
+
},
|
|
1576
|
+
commandTrace: structuredClone(this.trace)
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
currentClientParamSchema(seat, interactionId) {
|
|
1580
|
+
return this.bundle.currentClientParamSchema({
|
|
1581
|
+
state: this.reducerState,
|
|
1582
|
+
playerId: this.playerId({ seat }, "actor"),
|
|
1583
|
+
interactionId
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
enumerateInteractionParams(options) {
|
|
1587
|
+
return structuredClone(
|
|
1588
|
+
this.bundle.enumerateInteractionParams({
|
|
1589
|
+
state: this.reducerState,
|
|
1590
|
+
playerId: this.playerId({ seat: options.seat }, "perspective.seat"),
|
|
1591
|
+
interactionId: options.interactionId,
|
|
1592
|
+
maxEvaluations: options.maxEvaluations
|
|
1593
|
+
})
|
|
1594
|
+
);
|
|
1595
|
+
}
|
|
1596
|
+
async advance(command) {
|
|
1597
|
+
const clone = new _ScenarioReplayImplementation({
|
|
1598
|
+
game: this.game,
|
|
1599
|
+
productionBundle: this.productionBundle,
|
|
1600
|
+
scenario: this.scenario,
|
|
1601
|
+
playerIds: this.playerIds,
|
|
1602
|
+
reducerState: this.reducerState,
|
|
1603
|
+
terminal: this.terminal,
|
|
1604
|
+
checkpoint: this.checkpoint,
|
|
1605
|
+
trace: this.trace,
|
|
1606
|
+
events: this.events
|
|
1607
|
+
});
|
|
1608
|
+
const result = await clone.dispatch(command, "probe", 0);
|
|
1609
|
+
if (result.kind === "accept") {
|
|
1610
|
+
clone.trace.push({
|
|
1611
|
+
segment: this.checkpoint.segment === "when" ? "when" : "given",
|
|
1612
|
+
index: this.checkpoint.completed,
|
|
1613
|
+
command: structuredClone(command),
|
|
1614
|
+
trace: structuredClone(result.trace)
|
|
1615
|
+
});
|
|
1616
|
+
return {
|
|
1617
|
+
kind: "accepted",
|
|
1618
|
+
replay: clone,
|
|
1619
|
+
trace: structuredClone(result.trace)
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
return {
|
|
1623
|
+
kind: "rejected",
|
|
1624
|
+
errorCode: result.errorCode,
|
|
1625
|
+
...result.message === void 0 ? {} : { message: result.message },
|
|
1626
|
+
trace: structuredClone(result.trace)
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
async replayTo(target) {
|
|
1630
|
+
const givenCount = target.segment === "setup" ? 0 : target.segment === "given" ? target.completed : this.scenario.given.length;
|
|
1631
|
+
const whenCount = target.segment === "when" ? target.completed : 0;
|
|
1632
|
+
for (let index = 0; index < givenCount; index += 1) {
|
|
1633
|
+
await this.dispatchRequired("given", index, this.scenario.given[index]);
|
|
1634
|
+
}
|
|
1635
|
+
this.checkpoint = { segment: "given", completed: givenCount };
|
|
1636
|
+
for (let index = 0; index < whenCount; index += 1) {
|
|
1637
|
+
await this.dispatchRequired("when", index, this.scenario.when[index]);
|
|
1638
|
+
}
|
|
1639
|
+
this.checkpoint = structuredClone(target);
|
|
1640
|
+
}
|
|
1641
|
+
async probe(command) {
|
|
1642
|
+
const clone = requireScenarioReplayImplementation(this.clone());
|
|
1643
|
+
const result = await clone.dispatch(command, "probe", 0);
|
|
1644
|
+
if (result.kind === "accept") {
|
|
1645
|
+
const accepted = {
|
|
1646
|
+
kind: "accepted",
|
|
1647
|
+
command,
|
|
1648
|
+
checkpointDigest: digestPluginRuntimeJson({
|
|
1649
|
+
digestVersion: "scenario-probe@1",
|
|
1650
|
+
from: this.checkpointDigest,
|
|
1651
|
+
command,
|
|
1652
|
+
reducerState: clone.reducerState,
|
|
1653
|
+
trace: result.trace
|
|
1654
|
+
}),
|
|
1655
|
+
trace: result.trace,
|
|
1656
|
+
toBeAccepted() {
|
|
1657
|
+
},
|
|
1658
|
+
toRejectWith(expected) {
|
|
1659
|
+
scenarioAssertionFailure(
|
|
1660
|
+
`Expected probe to reject${expected.errorCode ? ` with ${expected.errorCode}` : ""}, but it was accepted.`
|
|
1661
|
+
);
|
|
1662
|
+
}
|
|
1663
|
+
};
|
|
1664
|
+
return accepted;
|
|
1665
|
+
}
|
|
1666
|
+
const rejected = {
|
|
1667
|
+
kind: "rejected",
|
|
1668
|
+
command,
|
|
1669
|
+
errorCode: result.errorCode,
|
|
1670
|
+
...result.message === void 0 ? {} : { message: result.message },
|
|
1671
|
+
trace: result.trace,
|
|
1672
|
+
toBeAccepted() {
|
|
1673
|
+
scenarioAssertionFailure(
|
|
1674
|
+
`Expected probe to be accepted, but it rejected with ${result.errorCode}.`
|
|
1675
|
+
);
|
|
1676
|
+
},
|
|
1677
|
+
toRejectWith(expected) {
|
|
1678
|
+
assertRejectionMatches(result, expected);
|
|
1679
|
+
}
|
|
1680
|
+
};
|
|
1681
|
+
return rejected;
|
|
1682
|
+
}
|
|
1683
|
+
async dispatchRequired(segment, index, command) {
|
|
1684
|
+
const result = await this.dispatch(command, segment, index);
|
|
1685
|
+
if (result.kind === "reject") {
|
|
1686
|
+
throw new ScenarioReplayError({
|
|
1687
|
+
scenarioId: this.scenarioId,
|
|
1688
|
+
segment,
|
|
1689
|
+
index,
|
|
1690
|
+
interactionId: command.interactionId,
|
|
1691
|
+
errorCode: result.errorCode,
|
|
1692
|
+
reducerMessage: result.message,
|
|
1693
|
+
trace: result.trace
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
this.trace.push({
|
|
1697
|
+
segment,
|
|
1698
|
+
index,
|
|
1699
|
+
command: structuredClone(command),
|
|
1700
|
+
trace: result.trace
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
async dispatch(command, segment, index) {
|
|
1704
|
+
if (this.terminal)
|
|
1705
|
+
return { kind: "reject", errorCode: "game-ended", trace: [] };
|
|
1706
|
+
const phase = readFlowState(this.reducerState).currentPhase ?? "";
|
|
1707
|
+
const playerId = resolveScenarioSeatRef({
|
|
1708
|
+
ref: command.actor,
|
|
1709
|
+
playerIds: this.playerIds,
|
|
1710
|
+
path: `scenario.${segment}[${index}].actor`
|
|
1711
|
+
});
|
|
1712
|
+
const params = resolveScenarioCommandParams({
|
|
1713
|
+
game: this.game,
|
|
1714
|
+
currentSchema: this.currentClientParamSchema(
|
|
1715
|
+
command.actor.seat,
|
|
1716
|
+
command.interactionId
|
|
1717
|
+
),
|
|
1718
|
+
phase,
|
|
1719
|
+
interactionId: command.interactionId,
|
|
1720
|
+
params: command.params,
|
|
1721
|
+
playerIds: this.playerIds,
|
|
1722
|
+
path: `scenario.${segment}[${index}]`
|
|
1723
|
+
});
|
|
1724
|
+
const result = await this.bundle.dispatch({
|
|
1725
|
+
state: this.reducerState,
|
|
1726
|
+
input: {
|
|
1727
|
+
kind: "interaction",
|
|
1728
|
+
playerId,
|
|
1729
|
+
interactionId: command.interactionId,
|
|
1730
|
+
params
|
|
1731
|
+
}
|
|
1732
|
+
});
|
|
1733
|
+
if (result.kind === "reject") {
|
|
1734
|
+
return {
|
|
1735
|
+
kind: "reject",
|
|
1736
|
+
errorCode: result.errorCode,
|
|
1737
|
+
...result.message === void 0 ? {} : { message: result.message },
|
|
1738
|
+
trace: []
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
this.reducerState = structuredClone(result.state);
|
|
1742
|
+
this.terminal = result.terminal ?? null;
|
|
1743
|
+
return {
|
|
1744
|
+
kind: "accept",
|
|
1745
|
+
trace: summarizeWireTrace(result.trace)
|
|
1746
|
+
};
|
|
1747
|
+
}
|
|
1748
|
+
playerId(seat, path) {
|
|
1749
|
+
return resolveScenarioSeatRef({
|
|
1750
|
+
ref: seat,
|
|
1751
|
+
playerIds: this.playerIds,
|
|
1752
|
+
path
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
project(playerId) {
|
|
1756
|
+
return this.bundle.project({
|
|
1757
|
+
state: this.reducerState,
|
|
1758
|
+
playerIds: [playerId]
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
};
|
|
1762
|
+
function inspectScenarioReplayAuthority(options) {
|
|
1763
|
+
return requireScenarioReplayImplementation(
|
|
1764
|
+
options.replay
|
|
1765
|
+
).inspectionAuthority(options.perspective);
|
|
1766
|
+
}
|
|
1767
|
+
function currentScenarioClientParamSchema(replay, seat, interactionId) {
|
|
1768
|
+
return requireScenarioReplayImplementation(replay).currentClientParamSchema(
|
|
1769
|
+
seat,
|
|
1770
|
+
interactionId
|
|
1771
|
+
);
|
|
1772
|
+
}
|
|
1773
|
+
function enumerateScenarioInteractionParams(options) {
|
|
1774
|
+
return requireScenarioReplayImplementation(
|
|
1775
|
+
options.replay
|
|
1776
|
+
).enumerateInteractionParams(options);
|
|
1777
|
+
}
|
|
1778
|
+
function advanceScenarioReplay(options) {
|
|
1779
|
+
return requireScenarioReplayImplementation(options.replay).advance(
|
|
1780
|
+
options.command
|
|
1781
|
+
);
|
|
1782
|
+
}
|
|
1783
|
+
function descriptorsForPlayer(projection, playerId) {
|
|
1784
|
+
const refs = projection.seats[playerId]?.availableInteractionRefs ?? [];
|
|
1785
|
+
const interactionsByRef = projection.interactionsByRef ?? {};
|
|
1786
|
+
return refs.flatMap((ref) => {
|
|
1787
|
+
const descriptor = interactionsByRef[ref];
|
|
1788
|
+
return descriptor ? [descriptor] : [];
|
|
1789
|
+
});
|
|
1790
|
+
}
|
|
1791
|
+
function createScenarioBundle(game, events, bundle) {
|
|
1792
|
+
return createReducerTestingRuntime(
|
|
1793
|
+
game,
|
|
1794
|
+
{
|
|
1795
|
+
diagnostics: {
|
|
1796
|
+
event(event) {
|
|
1797
|
+
events.push(structuredClone(event));
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
},
|
|
1801
|
+
bundle
|
|
1802
|
+
);
|
|
1803
|
+
}
|
|
1804
|
+
function validateReplayDefinition(game, replay) {
|
|
1805
|
+
validateScenarioDefinition(game, {
|
|
1806
|
+
...replay,
|
|
1807
|
+
then: () => void 0
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1810
|
+
function normalizeCheckpoint(scenario, checkpoint) {
|
|
1811
|
+
if (checkpoint === void 0) {
|
|
1812
|
+
return { segment: "when", completed: scenario.when.length };
|
|
1813
|
+
}
|
|
1814
|
+
const maximum = checkpoint.segment === "setup" ? 0 : checkpoint.segment === "given" ? scenario.given.length : scenario.when.length;
|
|
1815
|
+
if (!Number.isSafeInteger(checkpoint.completed) || checkpoint.completed < 0 || checkpoint.completed > maximum || checkpoint.segment === "setup" && checkpoint.completed !== 0) {
|
|
1816
|
+
throw new ScenarioDefinitionValidationError({
|
|
1817
|
+
code: "OUT_OF_RANGE",
|
|
1818
|
+
path: "at.completed",
|
|
1819
|
+
reason: `expected 0 through ${maximum} for ${checkpoint.segment}`
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1822
|
+
return structuredClone(checkpoint);
|
|
1823
|
+
}
|
|
1824
|
+
function readFlowState(state) {
|
|
1825
|
+
const flow = state.domain?.flow;
|
|
1826
|
+
if (typeof flow !== "object" || flow === null) {
|
|
1827
|
+
return { currentPhase: null, activePlayers: [] };
|
|
1828
|
+
}
|
|
1829
|
+
const record = flow;
|
|
1830
|
+
return {
|
|
1831
|
+
currentPhase: typeof record.currentPhase === "string" ? record.currentPhase : null,
|
|
1832
|
+
activePlayers: Array.isArray(record.activePlayers) ? record.activePlayers.filter(
|
|
1833
|
+
(playerId) => typeof playerId === "string"
|
|
1834
|
+
) : []
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
function readPhaseStep(phase) {
|
|
1838
|
+
if (!phase || typeof phase !== "object") return null;
|
|
1839
|
+
const step = phase.step;
|
|
1840
|
+
return typeof step === "string" ? step : null;
|
|
1841
|
+
}
|
|
1842
|
+
function summarizeWireTrace(trace) {
|
|
1843
|
+
return trace.flatMap((entry) => {
|
|
1844
|
+
switch (entry.kind) {
|
|
1845
|
+
case "acceptedClientInput":
|
|
1846
|
+
return [
|
|
1847
|
+
{
|
|
1848
|
+
kind: "acceptedClientInput",
|
|
1849
|
+
interactionId: entry.input.interactionId,
|
|
1850
|
+
playerId: entry.input.playerId
|
|
1851
|
+
}
|
|
1852
|
+
];
|
|
1853
|
+
case "phaseEntered":
|
|
1854
|
+
return [{ kind: "phaseEntered", from: entry.from, to: entry.to }];
|
|
1855
|
+
case "rngConsumption":
|
|
1856
|
+
return [
|
|
1857
|
+
{
|
|
1858
|
+
kind: "rngConsumption",
|
|
1859
|
+
version: entry.version,
|
|
1860
|
+
operation: entry.operation,
|
|
1861
|
+
drawIndex: entry.drawIndex
|
|
1862
|
+
}
|
|
1863
|
+
];
|
|
1864
|
+
}
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
function lastAcceptedDispatch(events) {
|
|
1868
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
1869
|
+
const event = events[index];
|
|
1870
|
+
if (event?.type === "submitAccepted") {
|
|
1871
|
+
return {
|
|
1872
|
+
submissionId: event.submissionId,
|
|
1873
|
+
trace: structuredClone(event.trace)
|
|
1874
|
+
};
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
return null;
|
|
1878
|
+
}
|
|
1879
|
+
function lastRejectedEvent(diagnostics) {
|
|
1880
|
+
for (let index = diagnostics.events.length - 1; index >= 0; index -= 1) {
|
|
1881
|
+
const event = diagnostics.events[index];
|
|
1882
|
+
if (event?.type === "submitRejected") return event;
|
|
1883
|
+
}
|
|
1884
|
+
return null;
|
|
1885
|
+
}
|
|
1886
|
+
function assertRejectionMatches(rejection, expected) {
|
|
1887
|
+
if (expected.errorCode !== void 0 && rejection.errorCode !== expected.errorCode) {
|
|
1888
|
+
scenarioAssertionFailure(
|
|
1889
|
+
`Expected rejection errorCode '${expected.errorCode}', received '${rejection.errorCode}'.`
|
|
1890
|
+
);
|
|
1891
|
+
}
|
|
1892
|
+
if (typeof expected.message === "string" && rejection.message !== expected.message) {
|
|
1893
|
+
scenarioAssertionFailure(
|
|
1894
|
+
`Expected rejection message '${expected.message}', received '${rejection.message ?? "undefined"}'.`
|
|
1895
|
+
);
|
|
1896
|
+
}
|
|
1897
|
+
if (expected.message instanceof RegExp && !expected.message.test(rejection.message ?? "")) {
|
|
1898
|
+
scenarioAssertionFailure(
|
|
1899
|
+
`Expected rejection message '${rejection.message ?? ""}' to match ${String(expected.message)}.`
|
|
1900
|
+
);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
function requireScenarioReplayImplementation(replay) {
|
|
1904
|
+
if (!(replay instanceof ScenarioReplayImplementation)) {
|
|
1905
|
+
throw new Error(
|
|
1906
|
+
"Scenario replay was not created by replayScenario(); alternate replay implementations are not supported."
|
|
1907
|
+
);
|
|
1908
|
+
}
|
|
1909
|
+
return replay;
|
|
1910
|
+
}
|
|
50
1911
|
|
|
51
1912
|
// src/testing/inspection/flow-diagnostic.ts
|
|
52
1913
|
function createFlowDiagnostic(options) {
|
|
@@ -234,11 +2095,12 @@ function projectDispatchTrace(commandTrace, playerIds, perspective) {
|
|
|
234
2095
|
}
|
|
235
2096
|
];
|
|
236
2097
|
}
|
|
237
|
-
case "
|
|
2098
|
+
case "phaseEntered":
|
|
238
2099
|
return [
|
|
239
2100
|
{
|
|
240
|
-
kind: "
|
|
241
|
-
|
|
2101
|
+
kind: "phaseEntered",
|
|
2102
|
+
from: trace.from,
|
|
2103
|
+
to: trace.to
|
|
242
2104
|
}
|
|
243
2105
|
];
|
|
244
2106
|
case "rngConsumption":
|
|
@@ -266,8 +2128,20 @@ function assertSeed(seed) {
|
|
|
266
2128
|
}
|
|
267
2129
|
}
|
|
268
2130
|
|
|
2131
|
+
// src/testing/canonical.ts
|
|
2132
|
+
function canonicalScenarioJson(value) {
|
|
2133
|
+
return encodeCanonicalPluginRuntimeJson(value);
|
|
2134
|
+
}
|
|
2135
|
+
function digestScenarioJson(value) {
|
|
2136
|
+
return digestPluginRuntimeJson(value);
|
|
2137
|
+
}
|
|
2138
|
+
function compareCanonicalScenarioJson(left, right) {
|
|
2139
|
+
const leftJson = canonicalScenarioJson(left);
|
|
2140
|
+
const rightJson = canonicalScenarioJson(right);
|
|
2141
|
+
return leftJson === rightJson ? 0 : leftJson < rightJson ? -1 : 1;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
269
2144
|
// src/testing/exploration/cursor.ts
|
|
270
|
-
import { Buffer } from "buffer";
|
|
271
2145
|
var CURSOR_VERSION = 1;
|
|
272
2146
|
var ExploreCursorError = class extends Error {
|
|
273
2147
|
code = "TEST_EXPLORE_CURSOR_STALE";
|
|
@@ -290,9 +2164,12 @@ function createExploreCursor(options) {
|
|
|
290
2164
|
seedOverride: options.seedOverride ?? null,
|
|
291
2165
|
nextOrdinal: options.nextOrdinal
|
|
292
2166
|
};
|
|
293
|
-
const encoded =
|
|
294
|
-
|
|
295
|
-
|
|
2167
|
+
const encoded = btoa(
|
|
2168
|
+
Array.from(
|
|
2169
|
+
new TextEncoder().encode(JSON.stringify(payload)),
|
|
2170
|
+
(byte) => String.fromCharCode(byte)
|
|
2171
|
+
).join("")
|
|
2172
|
+
).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
296
2173
|
const digest = digestPluginRuntimeJson(payload).slice("sha256:".length);
|
|
297
2174
|
return `dbx1.${encoded}.${digest}`;
|
|
298
2175
|
}
|
|
@@ -308,7 +2185,12 @@ function readExploreCursor(options) {
|
|
|
308
2185
|
let payload;
|
|
309
2186
|
try {
|
|
310
2187
|
payload = JSON.parse(
|
|
311
|
-
|
|
2188
|
+
new TextDecoder("utf-8", { fatal: true }).decode(
|
|
2189
|
+
Uint8Array.from(
|
|
2190
|
+
atob(encoded.replaceAll("-", "+").replaceAll("_", "/")),
|
|
2191
|
+
(character) => character.charCodeAt(0)
|
|
2192
|
+
)
|
|
2193
|
+
)
|
|
312
2194
|
);
|
|
313
2195
|
} catch {
|
|
314
2196
|
return stale();
|
|
@@ -436,6 +2318,11 @@ async function exploreTransitions(options) {
|
|
|
436
2318
|
interactionId: action.interactionId,
|
|
437
2319
|
params: projectScenarioCommandParams({
|
|
438
2320
|
game: options.game,
|
|
2321
|
+
currentSchema: currentScenarioClientParamSchema(
|
|
2322
|
+
replay,
|
|
2323
|
+
action.actor.seat,
|
|
2324
|
+
action.interactionId
|
|
2325
|
+
),
|
|
439
2326
|
phase: node.flow.phase,
|
|
440
2327
|
interactionId: action.interactionId,
|
|
441
2328
|
params: assignment,
|
|
@@ -539,7 +2426,7 @@ async function exploreSeeds(options, range) {
|
|
|
539
2426
|
actions: inspected.node.actions.map(({ actor, interactionId }) => ({
|
|
540
2427
|
seat: actor.seat,
|
|
541
2428
|
interactionId,
|
|
542
|
-
concreteOptionCount: counts.get(`${actor.seat}:${interactionId}`) ?? "
|
|
2429
|
+
concreteOptionCount: counts.get(`${actor.seat}:${interactionId}`) ?? "unknown"
|
|
543
2430
|
}))
|
|
544
2431
|
}
|
|
545
2432
|
});
|
|
@@ -582,7 +2469,7 @@ async function concreteOptionCounts(options) {
|
|
|
582
2469
|
for (const action of options.node.actions) {
|
|
583
2470
|
const remaining = options.maxEvaluations - evaluated;
|
|
584
2471
|
if (remaining <= 0) {
|
|
585
|
-
counts.set(`${action.actor.seat}:${action.interactionId}`, "
|
|
2472
|
+
counts.set(`${action.actor.seat}:${action.interactionId}`, "unknown");
|
|
586
2473
|
continue;
|
|
587
2474
|
}
|
|
588
2475
|
const result = enumerateScenarioInteractionParams({
|
|
@@ -595,7 +2482,7 @@ async function concreteOptionCounts(options) {
|
|
|
595
2482
|
evaluated += result.enumeration.evaluated;
|
|
596
2483
|
counts.set(
|
|
597
2484
|
`${action.actor.seat}:${action.interactionId}`,
|
|
598
|
-
result.enumeration.status === "enumerated" ? result.enumeration.assignments.length : "
|
|
2485
|
+
result.enumeration.status === "enumerated" ? result.enumeration.assignments.length : "unknown"
|
|
599
2486
|
);
|
|
600
2487
|
}
|
|
601
2488
|
return counts;
|
|
@@ -644,90 +2531,6 @@ function assertSeedRange(range) {
|
|
|
644
2531
|
}
|
|
645
2532
|
}
|
|
646
2533
|
|
|
647
|
-
// src/testing/ui-fixture/canonical.ts
|
|
648
|
-
import { createHash } from "crypto";
|
|
649
|
-
var sha256DigestPattern = /^sha256:[a-f0-9]{64}$/;
|
|
650
|
-
function compareCanonicalStrings(left, right) {
|
|
651
|
-
return left === right ? 0 : left < right ? -1 : 1;
|
|
652
|
-
}
|
|
653
|
-
function isSha256Digest(value) {
|
|
654
|
-
return sha256DigestPattern.test(value);
|
|
655
|
-
}
|
|
656
|
-
function canonicalizeUIFixtureJson(value) {
|
|
657
|
-
if (value === null) {
|
|
658
|
-
return null;
|
|
659
|
-
}
|
|
660
|
-
switch (typeof value) {
|
|
661
|
-
case "boolean":
|
|
662
|
-
case "string":
|
|
663
|
-
return value;
|
|
664
|
-
case "number":
|
|
665
|
-
if (Number.isFinite(value)) {
|
|
666
|
-
return value;
|
|
667
|
-
}
|
|
668
|
-
break;
|
|
669
|
-
case "object": {
|
|
670
|
-
if (Array.isArray(value)) {
|
|
671
|
-
return value.map((item) => canonicalizeUIFixtureJson(item));
|
|
672
|
-
}
|
|
673
|
-
if (Object.getPrototypeOf(value) !== Object.prototype) {
|
|
674
|
-
break;
|
|
675
|
-
}
|
|
676
|
-
const entries = Object.entries(value);
|
|
677
|
-
return Object.fromEntries(
|
|
678
|
-
entries.filter(([, item]) => item !== void 0).sort(([left], [right]) => compareCanonicalStrings(left, right)).map(([key, item]) => [key, canonicalizeUIFixtureJson(item)])
|
|
679
|
-
);
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
throw new Error(
|
|
683
|
-
`UI fixture values must be deterministic JSON. Received ${typeof value}.`
|
|
684
|
-
);
|
|
685
|
-
}
|
|
686
|
-
function canonicalUIFixtureJson(value) {
|
|
687
|
-
return JSON.stringify(canonicalizeUIFixtureJson(value));
|
|
688
|
-
}
|
|
689
|
-
function digestUIFixtureJson(value) {
|
|
690
|
-
return sha256(canonicalUIFixtureJson(value));
|
|
691
|
-
}
|
|
692
|
-
function digestUIFixtureRequest(request) {
|
|
693
|
-
return digestUIFixtureJson({
|
|
694
|
-
digestVersion: "ui-replay-request@2",
|
|
695
|
-
request
|
|
696
|
-
});
|
|
697
|
-
}
|
|
698
|
-
function digestUIFixtureTransportRequest(request) {
|
|
699
|
-
return digestUIFixtureJson({
|
|
700
|
-
digestVersion: "ui-fixture-runtime-command@3",
|
|
701
|
-
request
|
|
702
|
-
});
|
|
703
|
-
}
|
|
704
|
-
function digestPluginProtocolTape(tape) {
|
|
705
|
-
return digestUIFixtureJson({
|
|
706
|
-
digestVersion: "plugin-protocol-tape@2",
|
|
707
|
-
tape
|
|
708
|
-
});
|
|
709
|
-
}
|
|
710
|
-
function canonicalizeUIScenarioFixture(fixture) {
|
|
711
|
-
return canonicalizeUIFixtureJson({
|
|
712
|
-
...fixture,
|
|
713
|
-
tags: [...fixture.tags].sort(),
|
|
714
|
-
environment: {
|
|
715
|
-
...fixture.environment,
|
|
716
|
-
viewportTags: [...fixture.environment.viewportTags].sort()
|
|
717
|
-
}
|
|
718
|
-
});
|
|
719
|
-
}
|
|
720
|
-
function serializeUIScenarioFixture(fixture) {
|
|
721
|
-
return `${JSON.stringify(canonicalizeUIScenarioFixture(fixture), null, 2)}
|
|
722
|
-
`;
|
|
723
|
-
}
|
|
724
|
-
function digestUIScenarioFixture(fixture) {
|
|
725
|
-
return sha256(serializeUIScenarioFixture(fixture));
|
|
726
|
-
}
|
|
727
|
-
function sha256(value) {
|
|
728
|
-
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
729
|
-
}
|
|
730
|
-
|
|
731
2534
|
// src/testing/reducer-scenario/create-reducer-scenario-runner.ts
|
|
732
2535
|
function createReducerScenarioRunner(options) {
|
|
733
2536
|
return {
|
|
@@ -742,7 +2545,7 @@ function createReducerScenarioRunner(options) {
|
|
|
742
2545
|
playerIds: [options.viewer.playerId]
|
|
743
2546
|
});
|
|
744
2547
|
const staticProjection = options.bundle.boardStatic();
|
|
745
|
-
const flow =
|
|
2548
|
+
const flow = readFlowState2(state);
|
|
746
2549
|
const materialized = materializePluginGameplayFrame({
|
|
747
2550
|
currentPhase: flow.currentPhase,
|
|
748
2551
|
activePlayers: flow.activePlayers,
|
|
@@ -763,7 +2566,7 @@ function createReducerScenarioRunner(options) {
|
|
|
763
2566
|
staticProjection,
|
|
764
2567
|
gameVersion,
|
|
765
2568
|
actionSetVersion,
|
|
766
|
-
projectionDigest:
|
|
2569
|
+
projectionDigest: digestScenarioJson({
|
|
767
2570
|
digestVersion: "reducer-scenario-frame@2",
|
|
768
2571
|
scenarioId: options.scenarioId,
|
|
769
2572
|
frameId: id,
|
|
@@ -847,857 +2650,587 @@ function createReducerScenarioRunner(options) {
|
|
|
847
2650
|
}
|
|
848
2651
|
};
|
|
849
2652
|
}
|
|
850
|
-
function
|
|
2653
|
+
function readFlowState2(state) {
|
|
851
2654
|
return {
|
|
852
2655
|
currentPhase: state.domain?.flow?.currentPhase ?? null,
|
|
853
2656
|
activePlayers: [...state.domain?.flow?.activePlayers ?? []]
|
|
854
2657
|
};
|
|
855
2658
|
}
|
|
856
2659
|
|
|
857
|
-
// src/testing/
|
|
858
|
-
function
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
let onMessage = null;
|
|
877
|
-
let firstFixtureError = null;
|
|
878
|
-
const pendingHandlers = /* @__PURE__ */ new Set();
|
|
879
|
-
const record = (event) => {
|
|
880
|
-
const entry = {
|
|
881
|
-
sequence: ++eventSequence,
|
|
882
|
-
atMs: options.nowMs?.() ?? eventSequence,
|
|
883
|
-
...event
|
|
884
|
-
};
|
|
885
|
-
events.push(entry);
|
|
886
|
-
options.onEvent?.(entry);
|
|
887
|
-
};
|
|
888
|
-
const createFixtureError = (code, message) => {
|
|
889
|
-
record({ kind: "diagnostic" });
|
|
890
|
-
const error = new Error(message);
|
|
891
|
-
error.name = "FixtureHostHarnessError";
|
|
892
|
-
Object.assign(error, { errorCode: code });
|
|
893
|
-
return error;
|
|
894
|
-
};
|
|
895
|
-
const rememberFixtureError = (error) => {
|
|
896
|
-
if (firstFixtureError) {
|
|
897
|
-
return;
|
|
898
|
-
}
|
|
899
|
-
firstFixtureError = error instanceof Error ? error : createFixtureError(
|
|
900
|
-
"fixture-unknown-error",
|
|
901
|
-
"Fixture host failed with an unknown error."
|
|
902
|
-
);
|
|
903
|
-
};
|
|
904
|
-
const throwFixtureError = () => {
|
|
905
|
-
if (firstFixtureError) {
|
|
906
|
-
throw firstFixtureError;
|
|
907
|
-
}
|
|
908
|
-
};
|
|
909
|
-
const sendHostPayload = (payload) => {
|
|
910
|
-
if (!onMessage) {
|
|
911
|
-
return;
|
|
912
|
-
}
|
|
913
|
-
onMessage({
|
|
914
|
-
protocol: DREAMBOARD_PLUGIN_PROTOCOL,
|
|
915
|
-
version: DREAMBOARD_PLUGIN_PROTOCOL_VERSION,
|
|
916
|
-
channelId,
|
|
917
|
-
sequence: ++envelopeSequence,
|
|
918
|
-
payload
|
|
919
|
-
});
|
|
920
|
-
};
|
|
921
|
-
const sendInit = () => {
|
|
922
|
-
sendHostPayload({
|
|
923
|
-
type: "runtime.init",
|
|
924
|
-
session: options.tape.session
|
|
925
|
-
});
|
|
926
|
-
};
|
|
927
|
-
const publishFrame = (frameId) => {
|
|
928
|
-
const frame = frameById.get(frameId);
|
|
929
|
-
if (!frame) {
|
|
930
|
-
throw createFixtureError(
|
|
931
|
-
"fixture-missing-frame",
|
|
932
|
-
`Plugin protocol tape references missing frame '${frameId}'.`
|
|
2660
|
+
// src/testing/sources/test-source.ts
|
|
2661
|
+
function createTestSource(initial) {
|
|
2662
|
+
const sessionId = crypto.randomUUID();
|
|
2663
|
+
const submissions = [];
|
|
2664
|
+
const lifecycle = createSourceLifecycle({
|
|
2665
|
+
send(command) {
|
|
2666
|
+
submissions.push(
|
|
2667
|
+
Object.freeze({
|
|
2668
|
+
operation: command.type === "interaction.submit" ? "submit" : "cancel",
|
|
2669
|
+
interactionId: command.interactionId,
|
|
2670
|
+
...command.type === "interaction.submit" ? { params: immutableCopy(command.params) } : {},
|
|
2671
|
+
resolve(result) {
|
|
2672
|
+
lifecycle.result({
|
|
2673
|
+
...result,
|
|
2674
|
+
type: "interaction.result",
|
|
2675
|
+
clientActionId: command.clientActionId
|
|
2676
|
+
});
|
|
2677
|
+
}
|
|
2678
|
+
})
|
|
933
2679
|
);
|
|
2680
|
+
},
|
|
2681
|
+
recover() {
|
|
2682
|
+
},
|
|
2683
|
+
close() {
|
|
934
2684
|
}
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
2685
|
+
});
|
|
2686
|
+
function emit(value) {
|
|
2687
|
+
const snapshot = SourceSnapshotSchema.parse(value);
|
|
2688
|
+
lifecycle.session({ sessionId, players: snapshot.players });
|
|
2689
|
+
lifecycle.frame({
|
|
2690
|
+
...snapshot.frame,
|
|
2691
|
+
basis: {
|
|
2692
|
+
version: snapshot.version,
|
|
2693
|
+
perspectivePlayerId: snapshot.me,
|
|
2694
|
+
actionSetVersion: computePluginActionSetVersion({
|
|
2695
|
+
version: snapshot.version,
|
|
2696
|
+
availableInteractions: snapshot.frame.availableInteractions
|
|
2697
|
+
})
|
|
2698
|
+
}
|
|
944
2699
|
});
|
|
2700
|
+
}
|
|
2701
|
+
emit(initial);
|
|
2702
|
+
return {
|
|
2703
|
+
...lifecycle.source,
|
|
2704
|
+
get submissions() {
|
|
2705
|
+
return [...submissions];
|
|
2706
|
+
},
|
|
2707
|
+
emit
|
|
945
2708
|
};
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
// src/testing/sources/local-source.ts
|
|
2712
|
+
import { createStore } from "@tanstack/store";
|
|
2713
|
+
import { z as z3 } from "zod";
|
|
2714
|
+
var checkpointSchema = z3.object({
|
|
2715
|
+
state: ReducerSessionStateSchema,
|
|
2716
|
+
terminal: GameOutcomeSchema.nullable()
|
|
2717
|
+
}).strict();
|
|
2718
|
+
async function localSource(game, options) {
|
|
2719
|
+
const setup = game.contract.manifest.normalSetup;
|
|
2720
|
+
if (!setup) throw new Error("Game manifest does not expose normal setup.");
|
|
2721
|
+
const playerIds = resolvePlayerRoster(game, options.players);
|
|
2722
|
+
const runtime = createReducerTestingRuntime(game);
|
|
2723
|
+
const initial = await runtime.initialize({
|
|
2724
|
+
table: setup.createInitialTable({ playerIds }),
|
|
2725
|
+
playerIds,
|
|
2726
|
+
rngSeed: options.seed,
|
|
2727
|
+
options: options.options
|
|
2728
|
+
});
|
|
2729
|
+
return createLocalProvider(
|
|
2730
|
+
game,
|
|
2731
|
+
runtime,
|
|
2732
|
+
playerIds,
|
|
2733
|
+
{ state: initial.state, terminal: initial.terminal ?? null },
|
|
2734
|
+
options.as ?? playerIds[0]
|
|
2735
|
+
);
|
|
2736
|
+
}
|
|
2737
|
+
function createLocalProvider(game, runtime, playerIds, initial, selectedPlayerId) {
|
|
2738
|
+
let checkpoint = immutableCopy(checkpointSchema.parse(initial));
|
|
2739
|
+
let revision = 1;
|
|
2740
|
+
let playerId = selectedPlayerId;
|
|
2741
|
+
let epoch = 0;
|
|
2742
|
+
let disposed = false;
|
|
2743
|
+
let inFlight = false;
|
|
2744
|
+
let disconnect = () => {
|
|
967
2745
|
};
|
|
968
|
-
const
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
throw createFixtureError(
|
|
972
|
-
"fixture-protocol-exhausted",
|
|
973
|
-
`Plugin protocol tape has no remaining step for ${kind}.`
|
|
974
|
-
);
|
|
975
|
-
}
|
|
976
|
-
if (step.kind !== kind) {
|
|
977
|
-
throw createFixtureError(
|
|
978
|
-
"fixture-protocol-step-mismatch",
|
|
979
|
-
`Expected protocol step '${step.kind}' but received '${kind}'.`
|
|
980
|
-
);
|
|
981
|
-
}
|
|
982
|
-
if (step.fromFrameId !== currentFrameId) {
|
|
983
|
-
throw createFixtureError(
|
|
984
|
-
"fixture-protocol-frame-mismatch",
|
|
985
|
-
`Expected protocol from frame '${step.fromFrameId}' but current frame is '${currentFrameId ?? "<none>"}'.`
|
|
986
|
-
);
|
|
987
|
-
}
|
|
988
|
-
if (step.requestDigest !== requestDigest) {
|
|
989
|
-
const mode = strict ? "strict" : "compatible";
|
|
990
|
-
throw createFixtureError(
|
|
991
|
-
"fixture-protocol-digest-mismatch",
|
|
992
|
-
`${mode} fixture protocol digest mismatch for ${kind}: expected ${step.requestDigest}, received ${requestDigest}.`
|
|
993
|
-
);
|
|
994
|
-
}
|
|
995
|
-
stepCursor += 1;
|
|
996
|
-
return step;
|
|
2746
|
+
const session = {
|
|
2747
|
+
sessionId: crypto.randomUUID(),
|
|
2748
|
+
players: playerIds.map((id) => ({ playerId: id, displayName: id }))
|
|
997
2749
|
};
|
|
998
|
-
const
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
2750
|
+
const store = createStore({
|
|
2751
|
+
snapshot: null,
|
|
2752
|
+
connection: "connecting",
|
|
2753
|
+
request: null
|
|
2754
|
+
});
|
|
2755
|
+
let lifecycle;
|
|
2756
|
+
function assertOpen() {
|
|
2757
|
+
if (disposed) throw new Error("Local source disposed.");
|
|
2758
|
+
}
|
|
2759
|
+
function assertIdle() {
|
|
2760
|
+
assertOpen();
|
|
2761
|
+
if (inFlight || store.get().request)
|
|
2762
|
+
throw new Error("A gameplay interaction is already pending.");
|
|
2763
|
+
}
|
|
2764
|
+
function makeFrame(state = checkpoint.state) {
|
|
2765
|
+
const projection = runtime.project({ state, playerIds: [playerId] });
|
|
2766
|
+
const provisional = materializePluginGameplayFrame({
|
|
2767
|
+
currentPhase: state.domain.flow.currentPhase,
|
|
2768
|
+
activePlayers: projection.schedulerFlow?.activePlayerIds ?? [],
|
|
2769
|
+
dynamicProjection: projection,
|
|
2770
|
+
staticProjection: runtime.boardStatic(),
|
|
2771
|
+
perspectivePlayerId: playerId,
|
|
2772
|
+
version: revision,
|
|
2773
|
+
actionSetVersion: "pending"
|
|
1015
2774
|
});
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
promise.catch((error) => {
|
|
1025
|
-
rememberFixtureError(error);
|
|
1026
|
-
}).finally(() => {
|
|
1027
|
-
pendingHandlers.delete(promise);
|
|
1028
|
-
});
|
|
1029
|
-
};
|
|
1030
|
-
switch (payload.type) {
|
|
1031
|
-
case "runtime.ready":
|
|
1032
|
-
record({ kind: "ready-received" });
|
|
1033
|
-
break;
|
|
1034
|
-
case "runtime.ack":
|
|
1035
|
-
record({ kind: "ack-received" });
|
|
1036
|
-
break;
|
|
1037
|
-
case "interaction.submit":
|
|
1038
|
-
trackHandler(handleSubmit(payload));
|
|
1039
|
-
break;
|
|
1040
|
-
case "runtime.error":
|
|
1041
|
-
record({ kind: "diagnostic" });
|
|
1042
|
-
break;
|
|
1043
|
-
default: {
|
|
1044
|
-
const _exhaustive = payload;
|
|
1045
|
-
return _exhaustive;
|
|
2775
|
+
return {
|
|
2776
|
+
...provisional,
|
|
2777
|
+
basis: {
|
|
2778
|
+
...provisional.basis,
|
|
2779
|
+
actionSetVersion: computePluginActionSetVersion({
|
|
2780
|
+
version: revision,
|
|
2781
|
+
availableInteractions: provisional.availableInteractions
|
|
2782
|
+
})
|
|
1046
2783
|
}
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
2784
|
+
};
|
|
2785
|
+
}
|
|
2786
|
+
function publish() {
|
|
2787
|
+
lifecycle.frame(makeFrame());
|
|
2788
|
+
}
|
|
2789
|
+
async function execute(input) {
|
|
2790
|
+
assertOpen();
|
|
2791
|
+
if (inFlight) throw new Error("A gameplay interaction is already pending.");
|
|
2792
|
+
if (checkpoint.terminal)
|
|
2793
|
+
return { accepted: false, errorCode: "game-ended" };
|
|
2794
|
+
inFlight = true;
|
|
2795
|
+
const generation = epoch;
|
|
2796
|
+
try {
|
|
2797
|
+
const result = await runtime.dispatch({ state: checkpoint.state, input });
|
|
2798
|
+
if (disposed || generation !== epoch)
|
|
2799
|
+
throw new Error("Local source lifetime changed.");
|
|
2800
|
+
if (result.kind === "reject")
|
|
2801
|
+
return {
|
|
2802
|
+
accepted: false,
|
|
2803
|
+
errorCode: result.errorCode,
|
|
2804
|
+
...result.message === void 0 ? {} : { message: result.message }
|
|
2805
|
+
};
|
|
2806
|
+
checkpoint = immutableCopy({
|
|
2807
|
+
state: result.state,
|
|
2808
|
+
terminal: result.terminal ?? null
|
|
1055
2809
|
});
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
};
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
handleClientPayload(message);
|
|
2810
|
+
revision++;
|
|
2811
|
+
publish();
|
|
2812
|
+
return { accepted: true };
|
|
2813
|
+
} finally {
|
|
2814
|
+
if (generation === epoch) inFlight = false;
|
|
1062
2815
|
}
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
2816
|
+
}
|
|
2817
|
+
function replaceLifetime() {
|
|
2818
|
+
if (!playerIds.includes(playerId))
|
|
2819
|
+
throw new Error("Unknown local source seat.");
|
|
2820
|
+
epoch++;
|
|
2821
|
+
inFlight = false;
|
|
2822
|
+
disconnect();
|
|
2823
|
+
lifecycle?.source.dispose();
|
|
2824
|
+
const generation = epoch;
|
|
2825
|
+
lifecycle = createSourceLifecycle({
|
|
2826
|
+
context: { sessionId: session.sessionId, playerId },
|
|
2827
|
+
send(command) {
|
|
2828
|
+
const current = lifecycle;
|
|
2829
|
+
void execute(
|
|
2830
|
+
command.type === "interaction.cancel" ? {
|
|
2831
|
+
kind: "interaction.cancel",
|
|
2832
|
+
playerId,
|
|
2833
|
+
interactionId: command.interactionId
|
|
2834
|
+
} : {
|
|
2835
|
+
kind: "interaction",
|
|
2836
|
+
playerId,
|
|
2837
|
+
interactionId: command.interactionId,
|
|
2838
|
+
params: command.params
|
|
2839
|
+
}
|
|
2840
|
+
).then((result) => {
|
|
2841
|
+
if (generation === epoch)
|
|
2842
|
+
current.result({
|
|
2843
|
+
...result,
|
|
2844
|
+
type: "interaction.result",
|
|
2845
|
+
clientActionId: command.clientActionId
|
|
2846
|
+
});
|
|
2847
|
+
}).catch((error) => {
|
|
2848
|
+
if (generation === epoch)
|
|
2849
|
+
current.fail(
|
|
2850
|
+
error instanceof Error ? error : new Error(String(error))
|
|
2851
|
+
);
|
|
2852
|
+
});
|
|
2853
|
+
},
|
|
2854
|
+
recover: publish,
|
|
2855
|
+
close() {
|
|
1078
2856
|
}
|
|
2857
|
+
});
|
|
2858
|
+
lifecycle.session(session);
|
|
2859
|
+
publish();
|
|
2860
|
+
store.setState(() => lifecycle.source.store.get());
|
|
2861
|
+
const subscription = lifecycle.source.store.subscribe(
|
|
2862
|
+
() => store.setState(() => lifecycle.source.store.get())
|
|
2863
|
+
);
|
|
2864
|
+
disconnect = () => subscription.unsubscribe();
|
|
2865
|
+
}
|
|
2866
|
+
replaceLifetime();
|
|
2867
|
+
return {
|
|
2868
|
+
store: {
|
|
2869
|
+
get: () => store.get(),
|
|
2870
|
+
subscribe: (listener) => store.subscribe(listener)
|
|
1079
2871
|
},
|
|
1080
|
-
async
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
while (pendingHandlers.size > 0) {
|
|
1084
|
-
await Promise.allSettled([...pendingHandlers]);
|
|
1085
|
-
}
|
|
1086
|
-
throwFixtureError();
|
|
2872
|
+
async submit(interactionId, params) {
|
|
2873
|
+
assertIdle();
|
|
2874
|
+
return lifecycle.source.submit(interactionId, params);
|
|
1087
2875
|
},
|
|
1088
|
-
async
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
throwFixtureError();
|
|
2876
|
+
async cancel(interactionId) {
|
|
2877
|
+
assertIdle();
|
|
2878
|
+
return lifecycle.source.cancel(interactionId);
|
|
1092
2879
|
},
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
}
|
|
1100
|
-
|
|
2880
|
+
async apply(command) {
|
|
2881
|
+
assertIdle();
|
|
2882
|
+
const actor = resolveScenarioSeatRef({
|
|
2883
|
+
ref: command.actor,
|
|
2884
|
+
playerIds,
|
|
2885
|
+
path: "command.actor"
|
|
2886
|
+
});
|
|
2887
|
+
const currentSchema = runtime.currentClientParamSchema({
|
|
2888
|
+
state: checkpoint.state,
|
|
2889
|
+
playerId: actor,
|
|
2890
|
+
interactionId: command.interactionId
|
|
2891
|
+
});
|
|
2892
|
+
const params = resolveScenarioCommandParams({
|
|
2893
|
+
game,
|
|
2894
|
+
currentSchema,
|
|
2895
|
+
phase: checkpoint.state.domain.flow.currentPhase,
|
|
2896
|
+
interactionId: command.interactionId,
|
|
2897
|
+
params: command.params,
|
|
2898
|
+
playerIds,
|
|
2899
|
+
path: "command"
|
|
2900
|
+
});
|
|
2901
|
+
return execute({
|
|
2902
|
+
kind: "interaction",
|
|
2903
|
+
playerId: actor,
|
|
2904
|
+
interactionId: command.interactionId,
|
|
2905
|
+
params
|
|
2906
|
+
});
|
|
1101
2907
|
},
|
|
1102
|
-
|
|
1103
|
-
|
|
2908
|
+
switchSeat(next) {
|
|
2909
|
+
assertOpen();
|
|
2910
|
+
if (!playerIds.includes(next))
|
|
2911
|
+
throw new Error("Unknown local source seat.");
|
|
2912
|
+
if (next === playerId) return;
|
|
2913
|
+
playerId = next;
|
|
2914
|
+
replaceLifetime();
|
|
1104
2915
|
},
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
2916
|
+
checkpoint() {
|
|
2917
|
+
assertOpen();
|
|
2918
|
+
return structuredClone(checkpoint);
|
|
2919
|
+
},
|
|
2920
|
+
restore(value) {
|
|
2921
|
+
assertOpen();
|
|
2922
|
+
const next = checkpointSchema.parse(value);
|
|
2923
|
+
runtime.project({ state: next.state, playerIds: [...playerIds] });
|
|
2924
|
+
const restoredRoster = next.state.domain.table.playerOrder;
|
|
2925
|
+
if (restoredRoster.length !== playerIds.length || restoredRoster.some((id, index) => id !== playerIds[index]))
|
|
2926
|
+
throw new Error("Checkpoint roster does not match the local session.");
|
|
2927
|
+
checkpoint = immutableCopy(next);
|
|
2928
|
+
revision++;
|
|
2929
|
+
replaceLifetime();
|
|
2930
|
+
},
|
|
2931
|
+
inspect() {
|
|
2932
|
+
assertOpen();
|
|
2933
|
+
return store.get().snapshot;
|
|
2934
|
+
},
|
|
2935
|
+
async explore({ maxEvaluations }) {
|
|
2936
|
+
assertOpen();
|
|
2937
|
+
if (!Number.isSafeInteger(maxEvaluations) || maxEvaluations < 1)
|
|
2938
|
+
throw new Error("Enumeration budget must be a positive integer.");
|
|
2939
|
+
if (checkpoint.terminal) return [];
|
|
2940
|
+
const commands = [];
|
|
2941
|
+
let remaining = maxEvaluations;
|
|
2942
|
+
const capturedState = checkpoint.state;
|
|
2943
|
+
const capturedEpoch = epoch;
|
|
2944
|
+
const capturedRevision = revision;
|
|
2945
|
+
for (const descriptor of store.get().snapshot.frame.availableInteractions) {
|
|
2946
|
+
if (remaining <= 0) break;
|
|
2947
|
+
const result = runtime.enumerateInteractionParams({
|
|
2948
|
+
state: checkpoint.state,
|
|
2949
|
+
playerId,
|
|
2950
|
+
interactionId: descriptor.interactionId,
|
|
2951
|
+
maxEvaluations: remaining
|
|
2952
|
+
});
|
|
2953
|
+
if (!result.found || !result.visible || !result.enumeration) continue;
|
|
2954
|
+
remaining -= result.enumeration.evaluated;
|
|
2955
|
+
for (const params of result.enumeration.assignments) {
|
|
2956
|
+
const probe = await runtime.dispatch({
|
|
2957
|
+
state: structuredClone(capturedState),
|
|
2958
|
+
input: {
|
|
2959
|
+
kind: "interaction",
|
|
2960
|
+
playerId,
|
|
2961
|
+
interactionId: descriptor.interactionId,
|
|
2962
|
+
params
|
|
2963
|
+
}
|
|
2964
|
+
});
|
|
2965
|
+
if (disposed || capturedEpoch !== epoch || capturedRevision !== revision)
|
|
2966
|
+
throw new Error("Source changed during exploration.");
|
|
2967
|
+
if (probe.kind === "reject") continue;
|
|
2968
|
+
const projected = projectScenarioCommandParams({
|
|
2969
|
+
game,
|
|
2970
|
+
currentSchema: runtime.currentClientParamSchema({
|
|
2971
|
+
state: checkpoint.state,
|
|
2972
|
+
playerId,
|
|
2973
|
+
interactionId: descriptor.interactionId
|
|
2974
|
+
}),
|
|
2975
|
+
phase: checkpoint.state.domain.flow.currentPhase,
|
|
2976
|
+
interactionId: descriptor.interactionId,
|
|
2977
|
+
params,
|
|
2978
|
+
playerIds,
|
|
2979
|
+
path: "explore"
|
|
2980
|
+
});
|
|
2981
|
+
commands.push({
|
|
2982
|
+
actor: { seat: playerIds.indexOf(playerId) },
|
|
2983
|
+
interactionId: descriptor.interactionId,
|
|
2984
|
+
params: projected
|
|
2985
|
+
});
|
|
2986
|
+
}
|
|
1112
2987
|
}
|
|
2988
|
+
return commands;
|
|
2989
|
+
},
|
|
2990
|
+
dispose() {
|
|
2991
|
+
if (disposed) return;
|
|
2992
|
+
disposed = true;
|
|
2993
|
+
epoch++;
|
|
2994
|
+
lifecycle.source.dispose();
|
|
2995
|
+
disconnect();
|
|
1113
2996
|
}
|
|
1114
2997
|
};
|
|
1115
2998
|
}
|
|
1116
2999
|
|
|
1117
|
-
// src/testing/
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
()
|
|
1127
|
-
|
|
3000
|
+
// src/testing/sources/scenario-source.ts
|
|
3001
|
+
async function scenarioSource(game, scenario, options = {}) {
|
|
3002
|
+
const materialized = await materializeScenarioRuntimeCheckpoint({
|
|
3003
|
+
game,
|
|
3004
|
+
scenario,
|
|
3005
|
+
...options.at === void 0 ? {} : { at: resolveScenarioCheckpoint(scenario, options.at) }
|
|
3006
|
+
});
|
|
3007
|
+
return createLocalProvider(
|
|
3008
|
+
game,
|
|
3009
|
+
createReducerTestingRuntime(game),
|
|
3010
|
+
materialized.playerIds,
|
|
3011
|
+
{ state: materialized.state, terminal: materialized.terminal },
|
|
3012
|
+
options.as ?? materialized.playerIds[0]
|
|
1128
3013
|
);
|
|
1129
|
-
return /* @__PURE__ */ jsx(PluginRuntimeBoundary, { runtime: fixtureRuntime, children });
|
|
1130
3014
|
}
|
|
1131
3015
|
|
|
1132
|
-
// src/testing/
|
|
1133
|
-
function
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
const
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
);
|
|
1156
|
-
}
|
|
1157
|
-
if (exchange.operation === "validate") {
|
|
1158
|
-
continue;
|
|
1159
|
-
}
|
|
1160
|
-
steps.push({
|
|
1161
|
-
id: exchange.id,
|
|
1162
|
-
kind: "client.submit",
|
|
1163
|
-
fromFrameId: exchange.fromFrameId,
|
|
1164
|
-
requestDigest: digestRuntimeCommand(exchange, fromFrame),
|
|
1165
|
-
response: exchange.result.kind === "accepted" ? {
|
|
1166
|
-
type: "interaction.result",
|
|
1167
|
-
clientActionId: exchange.id,
|
|
1168
|
-
accepted: true
|
|
1169
|
-
} : {
|
|
1170
|
-
type: "interaction.result",
|
|
1171
|
-
clientActionId: exchange.id,
|
|
1172
|
-
accepted: false,
|
|
1173
|
-
errorCode: exchange.result.diagnostics[0]?.code ?? "fixture-rejected",
|
|
1174
|
-
message: exchange.result.diagnostics[0]?.message
|
|
1175
|
-
}
|
|
1176
|
-
});
|
|
1177
|
-
if (exchange.result.kind === "accepted") {
|
|
1178
|
-
if (!frameById.has(exchange.result.toFrameId)) {
|
|
1179
|
-
throw new Error(
|
|
1180
|
-
`Reducer exchange '${exchange.id}' references missing toFrameId '${exchange.result.toFrameId}'.`
|
|
1181
|
-
);
|
|
3016
|
+
// src/testing/sources/fuzz.ts
|
|
3017
|
+
async function fuzz(game, options) {
|
|
3018
|
+
if (!Number.isSafeInteger(options.steps) || options.steps < 0)
|
|
3019
|
+
throw new Error("Fuzz steps must be a nonnegative integer.");
|
|
3020
|
+
const players = options.players ?? game.contract.manifest.normalSetup?.minPlayers;
|
|
3021
|
+
if (!players) throw new Error("Game manifest does not expose normal setup.");
|
|
3022
|
+
const source = await localSource(game, { players, seed: options.seed });
|
|
3023
|
+
let rng = {
|
|
3024
|
+
seed: options.seed,
|
|
3025
|
+
cursor: 0,
|
|
3026
|
+
draws: [],
|
|
3027
|
+
trace: []
|
|
3028
|
+
};
|
|
3029
|
+
const commands = [];
|
|
3030
|
+
try {
|
|
3031
|
+
for (let step = 0; step < options.steps && !source.checkpoint().terminal; step++) {
|
|
3032
|
+
const actors = [];
|
|
3033
|
+
for (const player of source.inspect().players) {
|
|
3034
|
+
source.switchSeat(player.playerId);
|
|
3035
|
+
if (source.inspect().frame.availableInteractions.some(
|
|
3036
|
+
(interaction) => interaction.availability.status === "available"
|
|
3037
|
+
))
|
|
3038
|
+
actors.push(player.playerId);
|
|
1182
3039
|
}
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
3040
|
+
if (actors.length === 0) break;
|
|
3041
|
+
const [actorIndex, actorRng] = nextRandomInt(actors.length, rng);
|
|
3042
|
+
rng = actorRng;
|
|
3043
|
+
source.switchSeat(actors[actorIndex]);
|
|
3044
|
+
const choices = await source.explore({
|
|
3045
|
+
maxEvaluations: options.maxEvaluations ?? 5e3
|
|
1187
3046
|
});
|
|
3047
|
+
if (choices.length === 0) break;
|
|
3048
|
+
const [index, next] = nextRandomInt(choices.length, rng);
|
|
3049
|
+
rng = next;
|
|
3050
|
+
const command = choices[index];
|
|
3051
|
+
commands.push(command);
|
|
3052
|
+
const result = await source.apply(command);
|
|
3053
|
+
if (!result.accepted)
|
|
3054
|
+
throw new Error(`Explored command rejected: ${result.errorCode}`);
|
|
1188
3055
|
}
|
|
3056
|
+
return { commands, checkpoint: source.checkpoint() };
|
|
3057
|
+
} catch (error) {
|
|
3058
|
+
return {
|
|
3059
|
+
commands,
|
|
3060
|
+
checkpoint: source.checkpoint(),
|
|
3061
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
3062
|
+
};
|
|
3063
|
+
} finally {
|
|
3064
|
+
source.dispose();
|
|
1189
3065
|
}
|
|
1190
|
-
return {
|
|
1191
|
-
session: options.session,
|
|
1192
|
-
frames,
|
|
1193
|
-
steps
|
|
1194
|
-
};
|
|
1195
|
-
}
|
|
1196
|
-
function materializeTraceFrame(frame, perspectivePlayerId) {
|
|
1197
|
-
const flow = readFlowState2(frame.reducerState);
|
|
1198
|
-
return {
|
|
1199
|
-
id: frame.id,
|
|
1200
|
-
frame: materializePluginGameplayFrame({
|
|
1201
|
-
currentPhase: flow.currentPhase,
|
|
1202
|
-
activePlayers: flow.activePlayers,
|
|
1203
|
-
dynamicProjection: frame.dynamicProjection,
|
|
1204
|
-
staticProjection: frame.staticProjection,
|
|
1205
|
-
perspectivePlayerId,
|
|
1206
|
-
version: frame.gameVersion,
|
|
1207
|
-
actionSetVersion: frame.actionSetVersion
|
|
1208
|
-
}),
|
|
1209
|
-
projectionDigest: frame.projectionDigest
|
|
1210
|
-
};
|
|
1211
|
-
}
|
|
1212
|
-
function digestRuntimeCommand(exchange, frame) {
|
|
1213
|
-
return digestUIFixtureTransportRequest({
|
|
1214
|
-
operation: exchange.operation === "validate" ? "validate" : "submit",
|
|
1215
|
-
basis: frame.frame.basis,
|
|
1216
|
-
interactionId: exchange.input.interactionId,
|
|
1217
|
-
payload: exchange.input.params
|
|
1218
|
-
});
|
|
1219
|
-
}
|
|
1220
|
-
function readFlowState2(state) {
|
|
1221
|
-
return {
|
|
1222
|
-
currentPhase: state.domain?.flow?.currentPhase ?? null,
|
|
1223
|
-
activePlayers: [...state.domain?.flow?.activePlayers ?? []]
|
|
1224
|
-
};
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
|
-
// src/testing/ui-fixture/deterministic-environment.ts
|
|
1228
|
-
var defaultFixtureEnvironmentInit = {
|
|
1229
|
-
clockIso: "2026-01-01T00:00:00.000Z",
|
|
1230
|
-
randomSeed: "dreamboard-ui-fixture",
|
|
1231
|
-
locale: "en-US",
|
|
1232
|
-
timezone: "UTC",
|
|
1233
|
-
reducedMotion: true,
|
|
1234
|
-
network: "blocked"
|
|
1235
|
-
};
|
|
1236
|
-
function createDeterministicIdFactory(seed) {
|
|
1237
|
-
let cursor = 0;
|
|
1238
|
-
return () => {
|
|
1239
|
-
cursor += 1;
|
|
1240
|
-
return `fixture-${slug(seed)}-${cursor.toString(36).padStart(4, "0")}`;
|
|
1241
|
-
};
|
|
1242
|
-
}
|
|
1243
|
-
function fixtureEnvironmentInitFor(options) {
|
|
1244
|
-
return {
|
|
1245
|
-
...defaultFixtureEnvironmentInit,
|
|
1246
|
-
clockIso: options.clockIso,
|
|
1247
|
-
randomSeed: options.randomSeed
|
|
1248
|
-
};
|
|
1249
|
-
}
|
|
1250
|
-
function slug(value) {
|
|
1251
|
-
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 48) || "seed";
|
|
1252
3066
|
}
|
|
1253
3067
|
|
|
1254
|
-
// src/testing/
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
message: "Expected a sha256:<64 lowercase hex> digest."
|
|
1262
|
-
});
|
|
1263
|
-
var nonEmptyStringSchema = z.string().min(1);
|
|
1264
|
-
var jsonSchema = RuntimeJsonSchema;
|
|
1265
|
-
var environmentSchema = z.object({
|
|
1266
|
-
clockIso: nonEmptyStringSchema,
|
|
1267
|
-
randomSeed: nonEmptyStringSchema,
|
|
1268
|
-
locale: z.literal("en-US"),
|
|
1269
|
-
timezone: z.literal("UTC"),
|
|
1270
|
-
viewportTags: z.array(z.enum(["desktop", "tablet", "phone", "touch"]))
|
|
1271
|
-
}).strict();
|
|
1272
|
-
var uiFixtureFrameSchema = PluginProtocolFrameSchema;
|
|
1273
|
-
var uiFixtureProtocolStepSchema = PluginProtocolStepSchema;
|
|
1274
|
-
var pluginProtocolTapeSchema = PluginProtocolTapeSchema.refine(
|
|
1275
|
-
(tape) => tape.frames.length > 0,
|
|
1276
|
-
"UI fixture protocol tapes require at least one gameplay frame."
|
|
1277
|
-
);
|
|
1278
|
-
var browserInteractionIntentRequestSchema = z.object({
|
|
1279
|
-
surface: nonEmptyStringSchema,
|
|
1280
|
-
scopeId: nonEmptyStringSchema.optional(),
|
|
1281
|
-
interactionKey: nonEmptyStringSchema.optional(),
|
|
1282
|
-
interactionId: nonEmptyStringSchema.optional(),
|
|
1283
|
-
intent: nonEmptyStringSchema,
|
|
1284
|
-
inputKey: nonEmptyStringSchema.optional(),
|
|
1285
|
-
candidateValue: jsonSchema.optional(),
|
|
1286
|
-
candidateValueKey: nonEmptyStringSchema.optional(),
|
|
1287
|
-
actuatorKind: z.enum(BROWSER_INTERACTION_ACTUATOR_KINDS).optional(),
|
|
1288
|
-
allowDisabled: z.boolean().optional()
|
|
1289
|
-
}).strict();
|
|
1290
|
-
var browserInteractionEffectRequestSchema = z.object({
|
|
1291
|
-
surface: nonEmptyStringSchema,
|
|
1292
|
-
scopeId: nonEmptyStringSchema.optional(),
|
|
1293
|
-
interactionKey: nonEmptyStringSchema.optional(),
|
|
1294
|
-
interactionId: nonEmptyStringSchema.optional(),
|
|
1295
|
-
effect: browserInteractionEffectSchema,
|
|
1296
|
-
allowDisabled: z.boolean().optional()
|
|
1297
|
-
}).strict();
|
|
1298
|
-
var uiReplayRequestSchema = z.union([
|
|
1299
|
-
browserInteractionIntentRequestSchema,
|
|
1300
|
-
browserInteractionEffectRequestSchema
|
|
1301
|
-
]);
|
|
1302
|
-
var uiReplayExecutionSchema = z.union([
|
|
1303
|
-
z.object({
|
|
1304
|
-
kind: z.literal("activate")
|
|
1305
|
-
}).strict(),
|
|
1306
|
-
z.object({
|
|
1307
|
-
kind: z.literal("fill"),
|
|
1308
|
-
value: z.string()
|
|
1309
|
-
}).strict(),
|
|
1310
|
-
z.object({
|
|
1311
|
-
kind: z.literal("drag"),
|
|
1312
|
-
target: browserInteractionEffectRequestSchema
|
|
1313
|
-
}).strict()
|
|
1314
|
-
]);
|
|
1315
|
-
var uiStepExpectationSchema = z.object({
|
|
1316
|
-
frameId: nonEmptyStringSchema.optional(),
|
|
1317
|
-
projectionDigest: digestSchema.optional(),
|
|
1318
|
-
semanticDigest: digestSchema.optional(),
|
|
1319
|
-
draftDigest: digestSchema.optional(),
|
|
1320
|
-
submissionDigest: digestSchema.optional(),
|
|
1321
|
-
focusedInteractionKey: nonEmptyStringSchema.optional(),
|
|
1322
|
-
visibleInteractionKeys: z.array(nonEmptyStringSchema).optional()
|
|
1323
|
-
}).strict();
|
|
1324
|
-
var uiResolvedReplayIdentitySchema = z.object({
|
|
1325
|
-
stepId: nonEmptyStringSchema,
|
|
1326
|
-
surface: nonEmptyStringSchema,
|
|
1327
|
-
scopeId: nonEmptyStringSchema,
|
|
1328
|
-
interactionKey: nonEmptyStringSchema,
|
|
1329
|
-
interactionId: nonEmptyStringSchema,
|
|
1330
|
-
actuatorId: nonEmptyStringSchema,
|
|
1331
|
-
descriptorDigest: digestSchema.optional(),
|
|
1332
|
-
draftDigest: digestSchema.optional()
|
|
1333
|
-
}).strict();
|
|
1334
|
-
var portableSemanticReplayStepSchema = z.object({
|
|
1335
|
-
stepId: nonEmptyStringSchema,
|
|
1336
|
-
requestDigest: digestSchema,
|
|
1337
|
-
resolve: uiReplayRequestSchema,
|
|
1338
|
-
execute: uiReplayExecutionSchema,
|
|
1339
|
-
expectedIdentity: uiResolvedReplayIdentitySchema.optional(),
|
|
1340
|
-
expect: uiStepExpectationSchema
|
|
1341
|
-
}).strict();
|
|
1342
|
-
var uiScenarioReplayStepSchema = z.union([
|
|
1343
|
-
portableSemanticReplayStepSchema,
|
|
1344
|
-
z.object({
|
|
1345
|
-
stepId: nonEmptyStringSchema,
|
|
1346
|
-
kind: z.literal("assert"),
|
|
1347
|
-
expect: uiStepExpectationSchema
|
|
1348
|
-
}).strict()
|
|
3068
|
+
// src/testing/candidate-verification.ts
|
|
3069
|
+
var CANDIDATE_INPUT_FIELDS = /* @__PURE__ */ new Set([
|
|
3070
|
+
"reducer",
|
|
3071
|
+
"bundle",
|
|
3072
|
+
"scenarios",
|
|
3073
|
+
"maxScenarios",
|
|
3074
|
+
"maxStepsPerScenario"
|
|
1349
3075
|
]);
|
|
1350
|
-
var
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
)
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
gameId: nonEmptyStringSchema,
|
|
1359
|
-
tags: z.array(nonEmptyStringSchema),
|
|
1360
|
-
source: z.object({
|
|
1361
|
-
scenarioId: nonEmptyStringSchema,
|
|
1362
|
-
reducerFingerprint: nonEmptyStringSchema,
|
|
1363
|
-
uiContractFingerprint: nonEmptyStringSchema,
|
|
1364
|
-
renderModule: nonEmptyStringSchema,
|
|
1365
|
-
renderModuleDigest: digestSchema,
|
|
1366
|
-
sourceDigest: digestSchema,
|
|
1367
|
-
sourceFiles: z.array(nonEmptyStringSchema).optional()
|
|
1368
|
-
}).strict(),
|
|
1369
|
-
viewer: z.object({
|
|
1370
|
-
seatId: nonEmptyStringSchema,
|
|
1371
|
-
playerId: nonEmptyStringSchema.optional()
|
|
1372
|
-
}).strict(),
|
|
1373
|
-
environment: environmentSchema,
|
|
1374
|
-
protocol: pluginProtocolTapeSchema,
|
|
1375
|
-
replay: z.array(uiScenarioReplayStepSchema),
|
|
1376
|
-
expected: z.object({
|
|
1377
|
-
initialProjectionDigest: digestSchema,
|
|
1378
|
-
finalProjectionDigest: digestSchema,
|
|
1379
|
-
finalSemanticDigest: digestSchema,
|
|
1380
|
-
submissionDigest: digestSchema
|
|
1381
|
-
}).strict()
|
|
1382
|
-
}).strict();
|
|
1383
|
-
var uiScenarioFixtureBundleIndexSchema = z.object({
|
|
1384
|
-
schemaVersion: z.literal(UI_SCENARIO_FIXTURE_BUNDLE_SCHEMA_VERSION),
|
|
1385
|
-
bundleId: nonEmptyStringSchema,
|
|
1386
|
-
sdkCommit: nonEmptyStringSchema,
|
|
1387
|
-
pluginRuntimeProtocol: z.literal(DREAMBOARD_PLUGIN_PROTOCOL_VERSION),
|
|
1388
|
-
browserInteractionProtocol: z.literal(
|
|
1389
|
-
DREAMBOARD_BROWSER_INTERACTION_PROTOCOL_VERSION
|
|
1390
|
-
),
|
|
1391
|
-
fixtures: z.array(
|
|
1392
|
-
z.object({
|
|
1393
|
-
id: nonEmptyStringSchema,
|
|
1394
|
-
file: nonEmptyStringSchema,
|
|
1395
|
-
sha256: digestSchema,
|
|
1396
|
-
renderModule: nonEmptyStringSchema,
|
|
1397
|
-
renderModuleSha256: digestSchema,
|
|
1398
|
-
components: z.array(nonEmptyStringSchema),
|
|
1399
|
-
capabilities: z.array(nonEmptyStringSchema)
|
|
1400
|
-
}).strict()
|
|
1401
|
-
)
|
|
1402
|
-
}).strict();
|
|
1403
|
-
function parseUIScenarioFixture(value) {
|
|
1404
|
-
const fixture = uiScenarioFixtureSchema.parse(
|
|
1405
|
-
value
|
|
1406
|
-
);
|
|
1407
|
-
validateUIScenarioFixtureReferences(fixture);
|
|
1408
|
-
return fixture;
|
|
1409
|
-
}
|
|
1410
|
-
function parseUIScenarioFixtureBundleIndex(value) {
|
|
1411
|
-
const bundle = uiScenarioFixtureBundleIndexSchema.parse(value);
|
|
1412
|
-
assertSupportedBrowserInteractionProtocol(bundle.browserInteractionProtocol);
|
|
1413
|
-
const ids = /* @__PURE__ */ new Set();
|
|
1414
|
-
for (const entry of bundle.fixtures) {
|
|
1415
|
-
if (ids.has(entry.id)) {
|
|
1416
|
-
throw new Error(`Duplicate fixture id '${entry.id}' in bundle index.`);
|
|
1417
|
-
}
|
|
1418
|
-
ids.add(entry.id);
|
|
3076
|
+
var MAX_DIAGNOSTIC_MESSAGE_LENGTH = 2e3;
|
|
3077
|
+
var MAX_DIAGNOSTIC_TRACE_ENTRIES = 100;
|
|
3078
|
+
async function runCandidateVerification(input) {
|
|
3079
|
+
assertCandidateInputFields(input);
|
|
3080
|
+
assertReducerBundleContract(input.bundle, "candidate verification");
|
|
3081
|
+
const scenarios = normalizeScenarios(input.scenarios);
|
|
3082
|
+
if (scenarios.length === 0) {
|
|
3083
|
+
throw new Error("Candidate verification requires at least one scenario.");
|
|
1419
3084
|
}
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
const major = Number(version.split(".")[0]);
|
|
1424
|
-
if (major !== UI_SCENARIO_FIXTURE_SUPPORTED_BROWSER_PROTOCOL_MAJOR) {
|
|
3085
|
+
const maxScenarios = input.maxScenarios ?? scenarios.length;
|
|
3086
|
+
assertPositiveInteger(maxScenarios, "maxScenarios");
|
|
3087
|
+
if (scenarios.length > maxScenarios) {
|
|
1425
3088
|
throw new Error(
|
|
1426
|
-
`
|
|
3089
|
+
`Candidate verification contains ${scenarios.length} scenarios, exceeding limit ${maxScenarios}.`
|
|
1427
3090
|
);
|
|
1428
3091
|
}
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
const
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
3092
|
+
const maxStepsPerScenario = input.maxStepsPerScenario ?? 1e3;
|
|
3093
|
+
assertPositiveInteger(maxStepsPerScenario, "maxStepsPerScenario");
|
|
3094
|
+
const results = [];
|
|
3095
|
+
for (const scenario of scenarios) {
|
|
3096
|
+
results.push(
|
|
3097
|
+
await runScenario({
|
|
3098
|
+
reducer: input.reducer,
|
|
3099
|
+
bundle: input.bundle,
|
|
3100
|
+
scenario,
|
|
3101
|
+
maxStepsPerScenario
|
|
3102
|
+
})
|
|
3103
|
+
);
|
|
1439
3104
|
}
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
`Protocol step '${step.id}' references missing frameId '${step.frameId}'.`
|
|
1449
|
-
);
|
|
1450
|
-
}
|
|
1451
|
-
continue;
|
|
1452
|
-
}
|
|
1453
|
-
if (!frameIds.has(step.fromFrameId)) {
|
|
1454
|
-
throw new Error(
|
|
1455
|
-
`Protocol step '${step.id}' references missing fromFrameId '${step.fromFrameId}'.`
|
|
1456
|
-
);
|
|
3105
|
+
const failed = results.filter((result) => result.status === "failed").length;
|
|
3106
|
+
return {
|
|
3107
|
+
status: failed === 0 ? "passed" : "failed",
|
|
3108
|
+
scenarioSummary: {
|
|
3109
|
+
total: results.length,
|
|
3110
|
+
passed: results.length - failed,
|
|
3111
|
+
failed,
|
|
3112
|
+
scenarios: results
|
|
1457
3113
|
}
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
3114
|
+
};
|
|
3115
|
+
}
|
|
3116
|
+
async function runScenario(input) {
|
|
3117
|
+
const actualSteps = input.scenario.given.length + input.scenario.when.length;
|
|
3118
|
+
if (actualSteps > input.maxStepsPerScenario) {
|
|
3119
|
+
return {
|
|
3120
|
+
id: input.scenario.id,
|
|
3121
|
+
status: "failed",
|
|
3122
|
+
diagnostic: {
|
|
3123
|
+
kind: "limit",
|
|
3124
|
+
message: `Scenario contains ${actualSteps} replay steps, exceeding maxStepsPerScenario limit ${input.maxStepsPerScenario}.`,
|
|
3125
|
+
actualSteps,
|
|
3126
|
+
maxStepsPerScenario: input.maxStepsPerScenario
|
|
1468
3127
|
}
|
|
1469
|
-
}
|
|
1470
|
-
const expectedFrameId = step.expect.frameId;
|
|
1471
|
-
if (expectedFrameId && !frameIds.has(expectedFrameId)) {
|
|
1472
|
-
throw new Error(
|
|
1473
|
-
`Replay step '${step.stepId}' expects missing frameId '${expectedFrameId}'.`
|
|
1474
|
-
);
|
|
1475
|
-
}
|
|
3128
|
+
};
|
|
1476
3129
|
}
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
3130
|
+
let replay;
|
|
3131
|
+
try {
|
|
3132
|
+
replay = await replayScenario({
|
|
3133
|
+
game: input.reducer,
|
|
3134
|
+
bundle: input.bundle,
|
|
3135
|
+
scenario: input.scenario
|
|
3136
|
+
});
|
|
3137
|
+
} catch (error) {
|
|
3138
|
+
return {
|
|
3139
|
+
id: input.scenario.id,
|
|
3140
|
+
status: "failed",
|
|
3141
|
+
diagnostic: replayDiagnostic(error)
|
|
3142
|
+
};
|
|
1487
3143
|
}
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
fixtureId: options.id,
|
|
1499
|
-
frameId: finalFrame.id,
|
|
1500
|
-
projectionDigest: finalFrame.projectionDigest
|
|
1501
|
-
});
|
|
1502
|
-
return parseUIScenarioFixture(
|
|
1503
|
-
canonicalizeUIScenarioFixture({
|
|
1504
|
-
schemaVersion: 2,
|
|
1505
|
-
browserInteractionProtocol: DREAMBOARD_BROWSER_INTERACTION_PROTOCOL_VERSION,
|
|
1506
|
-
pluginRuntimeProtocol: DREAMBOARD_PLUGIN_PROTOCOL_VERSION,
|
|
1507
|
-
id: options.id,
|
|
1508
|
-
title: options.title,
|
|
1509
|
-
gameId: options.gameId,
|
|
1510
|
-
tags: [...options.tags ?? []],
|
|
1511
|
-
source: options.source,
|
|
1512
|
-
viewer: options.viewer,
|
|
1513
|
-
environment: options.environment,
|
|
1514
|
-
protocol: options.protocol,
|
|
1515
|
-
replay,
|
|
1516
|
-
expected: {
|
|
1517
|
-
initialProjectionDigest: options.expected?.initialProjectionDigest ?? firstFrame.projectionDigest,
|
|
1518
|
-
finalProjectionDigest: options.expected?.finalProjectionDigest ?? finalFrame.projectionDigest,
|
|
1519
|
-
finalSemanticDigest,
|
|
1520
|
-
submissionDigest
|
|
3144
|
+
try {
|
|
3145
|
+
await assertScenario({ replay, assertion: input.scenario.then });
|
|
3146
|
+
return { id: input.scenario.id, status: "passed" };
|
|
3147
|
+
} catch (error) {
|
|
3148
|
+
return {
|
|
3149
|
+
id: input.scenario.id,
|
|
3150
|
+
status: "failed",
|
|
3151
|
+
diagnostic: {
|
|
3152
|
+
kind: "assertion",
|
|
3153
|
+
message: boundedMessage(error)
|
|
1521
3154
|
}
|
|
1522
|
-
}
|
|
1523
|
-
|
|
3155
|
+
};
|
|
3156
|
+
}
|
|
1524
3157
|
}
|
|
1525
|
-
function
|
|
1526
|
-
const
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
3158
|
+
function assertCandidateInputFields(input) {
|
|
3159
|
+
for (const field of Object.keys(input)) {
|
|
3160
|
+
if (!CANDIDATE_INPUT_FIELDS.has(field)) {
|
|
3161
|
+
throw new Error(
|
|
3162
|
+
`Candidate verification input contains unsupported field '${field}'.`
|
|
3163
|
+
);
|
|
1531
3164
|
}
|
|
1532
3165
|
}
|
|
1533
|
-
return protocol.frames[protocol.frames.length - 1];
|
|
1534
3166
|
}
|
|
1535
|
-
function
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
if (firstBytes !== secondBytes) {
|
|
1539
|
-
throw new Error(
|
|
1540
|
-
`UI scenario fixture '${first.id}' is non-deterministic across compilation runs.`
|
|
1541
|
-
);
|
|
3167
|
+
function assertPositiveInteger(value, field) {
|
|
3168
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
3169
|
+
throw new Error(`${field} must be a positive safe integer.`);
|
|
1542
3170
|
}
|
|
1543
3171
|
}
|
|
1544
|
-
function
|
|
1545
|
-
if (
|
|
3172
|
+
function normalizeScenarios(input) {
|
|
3173
|
+
if (typeof input !== "object" || input === null) {
|
|
1546
3174
|
throw new Error(
|
|
1547
|
-
|
|
3175
|
+
"Candidate verification scenarios must be an array or record."
|
|
1548
3176
|
);
|
|
1549
3177
|
}
|
|
1550
|
-
const
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
if (!expected) {
|
|
1556
|
-
return candidate;
|
|
1557
|
-
}
|
|
1558
|
-
for (const key of [
|
|
1559
|
-
"surface",
|
|
1560
|
-
"scopeId",
|
|
1561
|
-
"interactionKey",
|
|
1562
|
-
"interactionId",
|
|
1563
|
-
"actuatorId",
|
|
1564
|
-
"descriptorDigest",
|
|
1565
|
-
"draftDigest"
|
|
1566
|
-
]) {
|
|
1567
|
-
const expectedValue = expected[key];
|
|
1568
|
-
if (expectedValue !== void 0 && candidate[key] !== expectedValue) {
|
|
1569
|
-
throw new Error(
|
|
1570
|
-
`Replay step '${step.stepId}' resolved ${key} '${String(
|
|
1571
|
-
candidate[key]
|
|
1572
|
-
)}', expected '${expectedValue}'.`
|
|
1573
|
-
);
|
|
1574
|
-
}
|
|
1575
|
-
}
|
|
1576
|
-
return candidate;
|
|
1577
|
-
}
|
|
1578
|
-
function assertUIStepExpectationSatisfied(stepId, expected, actual) {
|
|
1579
|
-
for (const key of [
|
|
1580
|
-
"frameId",
|
|
1581
|
-
"projectionDigest",
|
|
1582
|
-
"semanticDigest",
|
|
1583
|
-
"draftDigest",
|
|
1584
|
-
"submissionDigest",
|
|
1585
|
-
"focusedInteractionKey"
|
|
1586
|
-
]) {
|
|
1587
|
-
if (expected[key] !== void 0 && expected[key] !== actual[key]) {
|
|
1588
|
-
throw new Error(
|
|
1589
|
-
`Replay step '${stepId}' expected ${key} '${expected[key]}', received '${actual[key]}'.`
|
|
1590
|
-
);
|
|
3178
|
+
const scenarios = Array.isArray(input) ? [...input] : Object.values(input);
|
|
3179
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
3180
|
+
for (const scenario of scenarios) {
|
|
3181
|
+
if (typeof scenario?.id !== "string" || scenario.id.length === 0) {
|
|
3182
|
+
throw new Error("Candidate verification scenario id is required.");
|
|
1591
3183
|
}
|
|
1592
|
-
|
|
1593
|
-
if (expected.visibleInteractionKeys) {
|
|
1594
|
-
const actualKeys = new Set(actual.visibleInteractionKeys ?? []);
|
|
1595
|
-
const missing = expected.visibleInteractionKeys.filter(
|
|
1596
|
-
(key) => !actualKeys.has(key)
|
|
1597
|
-
);
|
|
1598
|
-
if (missing.length > 0) {
|
|
3184
|
+
if (seenIds.has(scenario.id)) {
|
|
1599
3185
|
throw new Error(
|
|
1600
|
-
`
|
|
1601
|
-
", "
|
|
1602
|
-
)}.`
|
|
3186
|
+
`Candidate verification scenario id '${scenario.id}' is duplicated.`
|
|
1603
3187
|
);
|
|
1604
3188
|
}
|
|
3189
|
+
seenIds.add(scenario.id);
|
|
1605
3190
|
}
|
|
3191
|
+
return scenarios;
|
|
1606
3192
|
}
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
}
|
|
1622
|
-
function fill(options) {
|
|
1623
|
-
return semanticStep(options, { kind: "fill", value: options.value });
|
|
1624
|
-
}
|
|
1625
|
-
function press(options) {
|
|
1626
|
-
return activate(options);
|
|
1627
|
-
}
|
|
1628
|
-
function drag(options) {
|
|
1629
|
-
return semanticStep(options, { kind: "drag", target: options.target });
|
|
3193
|
+
function replayDiagnostic(error) {
|
|
3194
|
+
if (error instanceof ScenarioReplayError) {
|
|
3195
|
+
return {
|
|
3196
|
+
kind: "replay",
|
|
3197
|
+
message: boundedMessage(error),
|
|
3198
|
+
scenarioId: error.scenarioId,
|
|
3199
|
+
segment: error.segment,
|
|
3200
|
+
index: error.index,
|
|
3201
|
+
interactionId: error.interactionId,
|
|
3202
|
+
errorCode: error.errorCode,
|
|
3203
|
+
...error.reducerMessage === void 0 ? {} : { reducerMessage: boundedText(error.reducerMessage) },
|
|
3204
|
+
trace: error.trace.slice(0, MAX_DIAGNOSTIC_TRACE_ENTRIES)
|
|
3205
|
+
};
|
|
3206
|
+
}
|
|
3207
|
+
return { kind: "replay", message: boundedMessage(error) };
|
|
1630
3208
|
}
|
|
1631
|
-
function
|
|
1632
|
-
return
|
|
3209
|
+
function boundedMessage(error) {
|
|
3210
|
+
return boundedText(error instanceof Error ? error.message : String(error));
|
|
1633
3211
|
}
|
|
1634
|
-
function
|
|
1635
|
-
return
|
|
1636
|
-
stepId,
|
|
1637
|
-
kind: "assert",
|
|
1638
|
-
expect
|
|
1639
|
-
};
|
|
3212
|
+
function boundedText(value) {
|
|
3213
|
+
return value.slice(0, MAX_DIAGNOSTIC_MESSAGE_LENGTH);
|
|
1640
3214
|
}
|
|
1641
3215
|
export {
|
|
1642
|
-
FixturePluginRuntime,
|
|
1643
3216
|
ScenarioAssertionError,
|
|
1644
3217
|
ScenarioDefinitionValidationError,
|
|
1645
3218
|
ScenarioReplayError,
|
|
1646
|
-
StaleContractArtifactError,
|
|
1647
|
-
UI_SCENARIO_FIXTURE_BUNDLE_SCHEMA_VERSION,
|
|
1648
|
-
UI_SCENARIO_FIXTURE_PLUGIN_RUNTIME_PROTOCOL,
|
|
1649
|
-
UI_SCENARIO_FIXTURE_SCHEMA_VERSION,
|
|
1650
|
-
UI_SCENARIO_FIXTURE_SUPPORTED_BROWSER_PROTOCOL_MAJOR,
|
|
1651
|
-
activate,
|
|
1652
|
-
assertDeterministicUIScenarioFixture,
|
|
1653
3219
|
assertScenario,
|
|
1654
|
-
assertStep,
|
|
1655
|
-
assertSupportedBrowserInteractionProtocol,
|
|
1656
|
-
assertUIStepExpectationSatisfied,
|
|
1657
|
-
assertUniqueReplayIdentity,
|
|
1658
|
-
canonicalUIFixtureJson,
|
|
1659
|
-
canonicalizeUIFixtureJson,
|
|
1660
|
-
canonicalizeUIScenarioFixture,
|
|
1661
|
-
compilePluginProtocolTape,
|
|
1662
|
-
compileUIScenarioFixture,
|
|
1663
|
-
createDeterministicIdFactory,
|
|
1664
3220
|
createExpectApi,
|
|
1665
|
-
createFixtureHostHarness,
|
|
1666
3221
|
createReducerScenarioRunner,
|
|
1667
3222
|
createScenarioAuthoring,
|
|
1668
|
-
|
|
1669
|
-
defineUIScenario,
|
|
1670
|
-
digestPluginProtocolTape,
|
|
1671
|
-
digestUIFixtureJson,
|
|
1672
|
-
digestUIFixtureRequest,
|
|
1673
|
-
digestUIFixtureTransportRequest,
|
|
1674
|
-
digestUIScenarioFixture,
|
|
1675
|
-
drag,
|
|
3223
|
+
createTestSource,
|
|
1676
3224
|
exploreScenario,
|
|
1677
|
-
|
|
1678
|
-
fixtureEnvironmentInitFor,
|
|
3225
|
+
fuzz,
|
|
1679
3226
|
inspectScenario,
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
parseUIScenarioFixture,
|
|
1683
|
-
parseUIScenarioFixtureBundleIndex,
|
|
1684
|
-
pluginProtocolTapeSchema,
|
|
1685
|
-
portableSemanticReplayStepSchema,
|
|
1686
|
-
press,
|
|
3227
|
+
localSource,
|
|
3228
|
+
materializeScenarioRuntimeCheckpoint,
|
|
1687
3229
|
probeScenarioCommand,
|
|
1688
3230
|
replayScenario,
|
|
1689
3231
|
resolveScenarioCheckpoint,
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
toScenarioReplayDefinition
|
|
1693
|
-
uiFixtureFrameSchema,
|
|
1694
|
-
uiFixtureProtocolStepSchema,
|
|
1695
|
-
uiReplayExecutionSchema,
|
|
1696
|
-
uiReplayRequestSchema,
|
|
1697
|
-
uiResolvedReplayIdentitySchema,
|
|
1698
|
-
uiScenarioFixtureBundleIndexSchema,
|
|
1699
|
-
uiScenarioFixtureSchema,
|
|
1700
|
-
uiScenarioReplayStepSchema,
|
|
1701
|
-
uiStepExpectationSchema
|
|
3232
|
+
runCandidateVerification,
|
|
3233
|
+
scenarioSource,
|
|
3234
|
+
toScenarioReplayDefinition
|
|
1702
3235
|
};
|
|
1703
3236
|
//# sourceMappingURL=testing.js.map
|