@mindscraft/branch-video-agent-cli 0.4.4 → 0.4.6

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 CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  `@mindscraft/branch-video-agent-cli` 是一个可脱离当前代码仓库使用的发布版 CLI,用于通过 Bearer agent token 调用 branch-video、AIHub workflow 与 AI Gateway agent API。
4
4
 
5
+ ## 图片持久任务(待发布)
6
+
7
+ `ai-gateway image-generate` / `image-edit` 支持 `--async` 提交、`--wait` 等待,stdin JSON 必须含稳定 `idempotencyKey`。无这两个标记时保持同步兼容。
8
+
9
+ ```powershell
10
+ '{"idempotencyKey":"asset-shot09-v1","model":"gpt-image-2","prompt":"原提示词"}' | branch-video-agent ai-gateway image-generate --async --raw 2>receipt.jsonl
11
+ '{"idempotencyKey":"asset-shot09-v1"}' | branch-video-agent ai-gateway image-get --wait --raw
12
+ '{"taskId":"img_..."}' | branch-video-agent ai-gateway image-get --raw
13
+ ```
14
+
15
+ stderr 输出提交前幂等身份、提交后 taskId 回执;stdout 为单个 JSON。提交响应丢失后按同 key 查询或原样重提,不换 key。等待超时返回任务最后状态及 `waitStopped`,Ctrl+C 不取消服务器任务;unknown 需要人工核对,不能自动重新生成。成功结果保留 `data[].url`,URL 需同项目 Bearer 下载再进入现有资产上传流程,不是公开 CDN URL。
16
+
17
+ 完整 API、幂等与发布边界见仓库 `docs/ai-gateway-image-tasks.md`。本地构建验收需设 `BRANCH_VIDEO_AGENT_DISABLE_AUTO_UPDATE=1`,确保运行候选 dist 而非自动切换到 registry 版本;本任务不执行 npm 发布。
18
+
5
19
  ## 安装与执行
6
20
 
7
21
  推荐显式指定包和可执行名:
@@ -53,7 +67,9 @@ AI Gateway 生产媒体验收使用 `ai-gateway media-understand --raw`。P0 模
53
67
 
54
68
  场景规划保留较长路线,删除其完整前缀场景;沿途动作、条件、状态效果和检查仍随长路线执行。`maxScenarios` 限制去重后的场景数;无法在步骤、访问次数或搜索预算内到达的边仍会使规划失败。
55
69
 
56
- 搜索展开上限的数值不变;可安全去重的相同状态不重复计入,其他状态沿原搜索计数。
70
+ 搜索展开上限的数值不变;可安全去重的相同状态不重复计入,其他状态沿原搜索计数。
71
+
72
+ 实测范围不包含 Web 节点的 `branchConfig.defaultAction` 兜底分支,也不要求为仅落入该兜底的消息提供操作配方。业务脚本和兜底行为保持不变;显式消息分支(包括名为 `default` 的消息)、答对、答错及适用的超时仍须真实操作。仅通过豁免分支到达的节点不在本轮覆盖目标内;仍有正常路径可达的节点继续测试。JSON 报告以 `WEB_DEFAULT_NOT_TESTED` 逐边记录,JUnit 标为 `skipped`,不计作覆盖或通过;报告 `passed` 仅表示本轮要求实测的范围通过。该豁免不声称兜底不可达,也不影响其他类型的 default 校验。
57
73
 
58
74
  ```powershell
59
75
  @'
@@ -94,11 +110,11 @@ sidecar 使用 `branch-video-playtest/1`,Web 结果必须提供 iframe 内的
94
110
 
95
111
  支持 `click`、`fill`、`selectOption`、`check`、`press`、`dragTo`、`setInputFiles` 和 `waitFor`。只要输入中有合法 `reportDir`,静态门禁、契约、版本漂移和浏览器启动等前置失败也会输出 `branch-video-playtest-report.json`、`junit.xml`;失败场景另存截图、trace、视频和浏览器日志。若 Chromium 缺失,执行 `npx playwright install chromium`。
96
112
 
97
- CI 顺序为:V3 静态 self-test → 草稿 `playtest run` → 外部发布步骤 → `version get` 精确内容读回。发布内容与已测草稿一致时无需再跑发布版全量测试;内容变化后应重新测试。`playtest run` 自身始终只读,不承担发布。
98
-
99
- 从 `0.4.3` 起,规划器删除被更长场景完整包含的有序前缀场景,保留条件、状态效果与全部目标边;场景数由实际图决定,不固定为 96。重复搜索状态也会合并以减少规划开销。覆盖或路径预算失败仍返回失败,不通过删边取得通过。复测时记录实际可执行文件路径;升级全局 npm 包不会更新任务内复制的旧 CLI。
100
-
101
- 从 `0.4.4` 起,runner 在现有超时内等待“进入游戏”或“开始体验”入口;已开始播放时直接继续,后续节点和路由断言保持不变。
113
+ CI 顺序为:V3 静态 self-test → 草稿 `playtest run` → 外部发布步骤 → `version get` 精确内容读回。发布内容与已测草稿一致时无需再跑发布版全量测试;内容变化后应重新测试。`playtest run` 自身始终只读,不承担发布。
114
+
115
+ 从 `0.4.3` 起,规划器删除被更长场景完整包含的有序前缀场景,保留条件、状态效果与全部目标边;场景数由实际图决定,不固定为 96。重复搜索状态也会合并以减少规划开销。覆盖或路径预算失败仍返回失败,不通过删边取得通过。复测时记录实际可执行文件路径;升级全局 npm 包不会更新任务内复制的旧 CLI。
116
+
117
+ 从 `0.4.4` 起,runner 在现有超时内等待“进入游戏”或“开始体验”入口;已开始播放时直接继续,后续节点和路由断言保持不变。
102
118
 
103
119
  AIHub 导入公共库示例:
104
120
 
@@ -127,6 +127,58 @@ function normalizeImageEditPayload(payload) {
127
127
  files,
128
128
  };
129
129
  }
130
+ const imageTasksPath = '/api/agent/ai-gateway/images/tasks';
131
+ function validateImageWait(context) {
132
+ if (context.flags.wait && [context.flags.timeoutMs, context.flags.pollIntervalMs].some((value) => !Number.isSafeInteger(value) || value <= 0)) {
133
+ throw new CliCommandError('Image wait timeout and poll interval must be positive integer milliseconds', 'VALIDATION_ERROR');
134
+ }
135
+ }
136
+ async function finishImageTask(context, initial) {
137
+ let task = initial;
138
+ context.runtime.writeStderr(`${JSON.stringify({ event: 'image-task', taskId: task.taskId, idempotencyKey: task.idempotencyKey, status: task.status })}\n`);
139
+ if (!context.flags.wait)
140
+ return { data: task };
141
+ const deadline = context.runtime.now() + context.flags.timeoutMs;
142
+ while (true) {
143
+ if (task.status === 'succeeded') {
144
+ const result = task.result || {};
145
+ return { data: { ...result, data: (result.data || []).map((item) => ({ ...item, url: context.client.resolveUrl(item.url) })),
146
+ taskId: task.taskId, idempotencyKey: task.idempotencyKey, status: task.status } };
147
+ }
148
+ if (task.status === 'failed' || task.status === 'unknown') {
149
+ throw new CliCommandError(task.error?.message || `Image task ${task.status}`, task.status === 'unknown' ? 'RECONCILIATION_REQUIRED' : 'RUN_FAILED', task);
150
+ }
151
+ const remaining = deadline - context.runtime.now();
152
+ if (remaining <= 0)
153
+ return { data: { ...task, waitStopped: 'timeout', message: 'Local wait stopped; query this task to continue. No new submission was made.' } };
154
+ await context.runtime.sleep(Math.min(context.flags.pollIntervalMs, remaining));
155
+ if (context.runtime.now() >= deadline)
156
+ return { data: { ...task, waitStopped: 'timeout', message: 'Local wait stopped; query this task to continue. No new submission was made.' } };
157
+ try {
158
+ task = (await context.client.request(`${imageTasksPath}/${encodeURIComponent(task.taskId)}`, { method: 'GET', timeoutMs: Math.max(1, Math.min(15000, deadline - context.runtime.now())) })).data;
159
+ }
160
+ catch {
161
+ return { data: { ...task, waitStopped: context.runtime.now() >= deadline ? 'timeout' : 'query_error', message: 'Local wait stopped; task status is last observed. Recover using taskId.' } };
162
+ }
163
+ }
164
+ }
165
+ async function submitImage(context, operation) {
166
+ validateImageWait(context);
167
+ const idempotencyKey = getRequiredString(context.payload, 'idempotencyKey');
168
+ if (!/^[A-Za-z0-9._:-]{1,128}$/.test(idempotencyKey))
169
+ throw new CliCommandError('Invalid idempotencyKey', 'VALIDATION_ERROR');
170
+ const payload = omitKeys(context.payload, ['idempotencyKey']);
171
+ context.runtime.writeStderr(`${JSON.stringify({ event: 'image-submit', idempotencyKey, recovery: 'ai-gateway image-get' })}\n`);
172
+ let result;
173
+ try {
174
+ result = await context.client.request(imageTasksPath, { method: 'POST', timeoutMs: 15000,
175
+ body: { idempotencyKey, operation, body: operation === 'edits' ? normalizeImageEditPayload(payload) : payload } });
176
+ }
177
+ catch (error) {
178
+ throw new CliCommandError('Submission response unavailable or rejected; recover by idempotencyKey before any new generation.', 'IMAGE_SUBMIT_ERROR', { idempotencyKey, status: 'submission_unconfirmed', error: error instanceof Error ? error.message : String(error) });
179
+ }
180
+ return finishImageTask(context, result.data);
181
+ }
130
182
  function inferMusicQueryOperation(operation) {
131
183
  switch (operation) {
132
184
  case 'vocal-removal':
@@ -197,20 +249,33 @@ export const aiGatewayCommands = {
197
249
  });
198
250
  return { data: result.data, requestId: result.requestId };
199
251
  },
200
- 'image-generate': async ({ client, payload }) => {
252
+ 'image-generate': async (context) => {
253
+ if (context.flags.async || context.flags.wait)
254
+ return submitImage(context, 'generations');
255
+ const { client, payload } = context;
201
256
  const result = await client.request('/api/agent/ai-gateway/images/generations', {
202
257
  method: 'POST',
203
258
  body: payload,
204
259
  });
205
260
  return { data: result.data, requestId: result.requestId };
206
261
  },
207
- 'image-edit': async ({ client, payload }) => {
262
+ 'image-edit': async (context) => {
263
+ if (context.flags.async || context.flags.wait)
264
+ return submitImage(context, 'edits');
265
+ const { client, payload } = context;
208
266
  const result = await client.request('/api/agent/ai-gateway/images/edits', {
209
267
  method: 'POST',
210
268
  body: normalizeImageEditPayload(payload),
211
269
  });
212
270
  return { data: result.data, requestId: result.requestId };
213
271
  },
272
+ 'image-get': async (context) => {
273
+ validateImageWait(context);
274
+ const taskId = getOptionalString(context.payload, 'taskId');
275
+ const idempotencyKey = taskId ? '' : getRequiredString(context.payload, 'idempotencyKey');
276
+ const result = await context.client.request(taskId ? `${imageTasksPath}/${encodeURIComponent(taskId)}` : imageTasksPath, { method: 'GET', query: idempotencyKey ? { idempotencyKey } : undefined, timeoutMs: 15000 });
277
+ return finishImageTask(context, result.data);
278
+ },
214
279
  'video-create': async (context) => {
215
280
  const result = await context.client.request('/api/agent/ai-gateway/predictions', {
216
281
  method: 'POST',
@@ -59,6 +59,7 @@ async function runPlaytest({ client, payload, runtime }) {
59
59
  let reachableNodeIds = [];
60
60
  let reachableEdgeIds = [];
61
61
  let scenarioResults = [];
62
+ let reachabilityWarnings = [];
62
63
  let phase = 'input';
63
64
  try {
64
65
  input = parseInput(payload);
@@ -70,6 +71,13 @@ async function runPlaytest({ client, payload, runtime }) {
70
71
  fingerprint = createScriptFingerprint(script);
71
72
  phase = 'static-preflight';
72
73
  assertPlaytestStaticPreflight(script);
74
+ const graph = getReachablePlaytestGraph(script);
75
+ reachabilityWarnings = graph.excludedEdges.map(edge => ({
76
+ code: edge.skippedReason ? 'WEB_DEFAULT_NOT_TESTED' : 'EDGE_NOT_USER_TRIGGERABLE',
77
+ message: (edge.skippedReason || edge.unreachableReason),
78
+ phase: 'scenario-generation', nodeId: edge.from, edgeId: edge.id,
79
+ details: { edge, interaction: script.graph.nodes[edge.from]?.interactions?.find((item) => item.id === edge.ownerId) },
80
+ }));
73
81
  phase = 'contract';
74
82
  let rawContract;
75
83
  try {
@@ -94,10 +102,11 @@ async function runPlaytest({ client, payload, runtime }) {
94
102
  maxNodeVisits: normalizePositiveInt(input.limits?.maxNodeVisits, DEFAULT_LIMITS.maxNodeVisits, 'limits.maxNodeVisits'),
95
103
  };
96
104
  const concurrency = Math.min(8, normalizePositiveInt(input.concurrency, 2, 'concurrency'));
105
+ if (limits.scenarioTimeoutMs > 2147483647)
106
+ throw new PlaytestError('VALIDATION_ERROR', 'limits.scenarioTimeoutMs must not exceed 2147483647');
97
107
  const viewport = input.viewport || { width: 1280, height: 720 };
98
108
  phase = 'scenario-generation';
99
109
  const scenarios = buildPlaytestScenarios(script, limits, contract.nodeVisitLimits);
100
- const graph = getReachablePlaytestGraph(script);
101
110
  reachableNodeIds = graph.reachableNodeIds;
102
111
  reachableEdgeIds = graph.reachableEdges.map((edge) => edge.id);
103
112
  const draftPlayer = input.source.kind === 'draft' ? createDraftPlayerUrl(client.resolveUrl('/')) : null;
@@ -144,7 +153,7 @@ async function runPlaytest({ client, payload, runtime }) {
144
153
  coveredEdgeIds,
145
154
  scenarios: scenarioResults,
146
155
  findings,
147
- warnings,
156
+ warnings: [...reachabilityWarnings, ...warnings],
148
157
  };
149
158
  const reportPaths = await writePlaytestReport(input.reportDir, report);
150
159
  if (report.status === 'failed') {
@@ -178,7 +187,7 @@ async function runPlaytest({ client, payload, runtime }) {
178
187
  coveredEdgeIds: [...new Set(scenarioResults.flatMap((result) => result.coveredEdges))],
179
188
  scenarios: scenarioResults,
180
189
  findings: [finding],
181
- warnings: [],
190
+ warnings: reachabilityWarnings,
182
191
  };
183
192
  const reportPaths = await writePlaytestReport(reportDir, report);
184
193
  throw new PlaytestError(failure.code, failure.message, { reportPaths, report });
@@ -14,6 +14,11 @@ export const projectCommands = {
14
14
  });
15
15
  return { data: result.data, requestId: result.requestId };
16
16
  },
17
+ 'draft-get': async ({ client, payload }) => {
18
+ const projectId = getRequiredString(payload, 'projectId');
19
+ const result = await client.request(`/api/branch-video/${encodeURIComponent(projectId)}`, { method: 'GET' });
20
+ return { data: result.data, requestId: result.requestId };
21
+ },
17
22
  create: async ({ client, payload }) => {
18
23
  const result = await client.request('/api/branch-video', {
19
24
  method: 'POST',
@@ -3,6 +3,7 @@ import type { CliRuntime } from './runtime.js';
3
3
  export type CliFlags = {
4
4
  raw: boolean;
5
5
  wait: boolean;
6
+ async?: boolean;
6
7
  help: boolean;
7
8
  timeoutMs: number;
8
9
  pollIntervalMs: number;
package/dist/lib/flags.js CHANGED
@@ -58,6 +58,9 @@ export function parseCliArgs(argv) {
58
58
  case '--wait':
59
59
  flags.wait = true;
60
60
  break;
61
+ case '--async':
62
+ flags.async = true;
63
+ break;
61
64
  case '--timeout':
62
65
  flags.timeoutMs = Number(readValue(argv, index, arg));
63
66
  index += 1;
@@ -95,12 +98,13 @@ export function getCliHelpText() {
95
98
  'Groups:',
96
99
  ' preset list | upsert',
97
100
  ' workflow run | status | outputs',
98
- ' project list | get | create | import-aihub | update | delete',
101
+ ' project list | get | draft-get | create | import-aihub | update | delete',
99
102
  ' version list | get | publish',
100
103
  ' agent-production create | status | cancel | finalize | autopilot-binding | events | metrics | multica-comment-payload | node claim|inputs|complete|submit|block|fail|retry|expire | blocker resolve | artifact submit|list | asset-job poll|retry|revise|attach-artifact|fail | gate evaluate|results',
101
104
  ' aihub-production schema | start | status | retry',
102
105
  ' character search | create | get',
103
- ' ai-gateway capabilities | request | media-understand | image-generate | image-edit | video-create | video-get | seed-audio-create | seed-audio-get | music-submit | music-get | tts-create | tts-get',
106
+ ' ai-gateway capabilities | request | media-understand | image-generate | image-edit | image-get | video-create | video-get | seed-audio-create | seed-audio-get | music-submit | music-get | tts-create | tts-get',
107
+ ' Images: --async (or --wait) requires stdin idempotencyKey; image-get accepts taskId or idempotencyKey. Recovery receipts go to stderr; stdout remains one JSON document.',
104
108
  ' playtest run',
105
109
  '',
106
110
  'Config precedence:',
@@ -2,6 +2,7 @@ type RequestOptions = {
2
2
  method: string;
3
3
  body?: unknown;
4
4
  query?: Record<string, unknown>;
5
+ timeoutMs?: number;
5
6
  };
6
7
  type RequestResult<T> = {
7
8
  data: T;
package/dist/lib/http.js CHANGED
@@ -42,6 +42,7 @@ export class CliHttpClient {
42
42
  method: options.method,
43
43
  headers,
44
44
  body: options.body === undefined ? undefined : JSON.stringify(options.body),
45
+ signal: options.timeoutMs ? AbortSignal.timeout(options.timeoutMs) : undefined,
45
46
  });
46
47
  }
47
48
  catch (error) {
@@ -66,13 +66,24 @@ export function validatePlaytestContract(script, rawContract) {
66
66
  result.steps.forEach((step, stepIndex) => assertStep(step, `webResults[${resultIndex}].steps[${stepIndex}]`));
67
67
  }
68
68
  const nodes = script?.graph?.nodes || {};
69
- const { reachableEdges, reachableNodeIds } = getReachablePlaytestGraph(script);
69
+ const { reachableEdges, reachableNodeIds, excludedEdges } = getReachablePlaytestGraph(script);
70
70
  for (const nodeId of reachableNodeIds) {
71
71
  const node = nodes[nodeId];
72
72
  if (node?.type !== 'web')
73
73
  continue;
74
74
  const mapping = node.config?.messageMapping || {};
75
+ const skipsDefault = excludedEdges.some(edge => edge.from === nodeId && edge.skippedReason);
76
+ const explicitRoutes = new Set(reachableEdges
77
+ .filter(edge => edge.from === nodeId && edge.owner === 'node' && edge.trigger.type === 'message')
78
+ .map(edge => String(edge.trigger.value)));
79
+ // A message may open a native interaction rather than route the Web node.
80
+ for (const interaction of node.interactions || []) {
81
+ if (interaction.trigger?.type === 'message')
82
+ explicitRoutes.add(String(interaction.trigger.value));
83
+ }
75
84
  for (const routeValue of new Set(Object.values(mapping).filter((value) => typeof value === 'string'))) {
85
+ if (skipsDefault && !explicitRoutes.has(routeValue))
86
+ continue;
76
87
  const recipe = rawContract.webResults.find((candidate) => candidate.nodeId === nodeId && candidate.routeValue === routeValue);
77
88
  if (!recipe) {
78
89
  throw new PlaytestError('WEB_PLAYTEST_RECIPE_MISSING', `Reachable Web result ${nodeId}:${routeValue} has no real-action recipe`, {
@@ -100,14 +111,9 @@ export function validatePlaytestContract(script, rawContract) {
100
111
  for (const edge of reachableEdges) {
101
112
  if (nodes[edge.from]?.type !== 'web' || edge.owner !== 'node')
102
113
  continue;
103
- const explicitRoutes = new Set(reachableEdges
104
- .filter((candidate) => candidate.from === edge.from && candidate.trigger.type === 'message')
105
- .map((candidate) => String(candidate.trigger.value)));
106
114
  const recipe = edge.trigger.type === 'message'
107
115
  ? rawContract.webResults.find((candidate) => candidate.nodeId === edge.from && candidate.routeValue === String(edge.trigger.value))
108
- : edge.default
109
- ? rawContract.webResults.find((candidate) => candidate.nodeId === edge.from && !explicitRoutes.has(candidate.routeValue))
110
- : null;
116
+ : null;
111
117
  if (!recipe) {
112
118
  throw new PlaytestError('WEB_PLAYTEST_RECIPE_MISSING', `Reachable Web edge ${edge.id} has no real-action recipe`, {
113
119
  nodeId: edge.from,
@@ -4,4 +4,5 @@ export declare function getReachablePlaytestGraph(script: unknown): {
4
4
  entryNodeId: string;
5
5
  reachableNodeIds: string[];
6
6
  reachableEdges: PlaytestEdge[];
7
+ excludedEdges: PlaytestEdge[];
7
8
  };
@@ -30,20 +30,74 @@ function addEdge(edges, from, owner, ownerId, trigger, action, isDefault = false
30
30
  default: isDefault || undefined,
31
31
  });
32
32
  }
33
- function addBranchConfigEdges(edges, from, owner, ownerId, config) {
33
+ function addBranchConfigEdges(edges, from, owner, ownerId, config, defaultTrigger = 'default') {
34
+ const earlierConditions = new Map();
34
35
  for (const rule of config?.rules || []) {
35
36
  const rawTrigger = rule.trigger || { type: 'unknown' };
36
37
  const trigger = {
37
38
  type: rawTrigger.type,
38
39
  value: rawTrigger.value ?? rawTrigger.optionId ?? rawTrigger.optionIds,
39
40
  };
41
+ const matchValue = trigger.type === 'option'
42
+ ? String(trigger.value ?? '').trim().toLowerCase()
43
+ : trigger.type === 'message' ? String(trigger.value ?? '').trim() : trigger.value;
44
+ const key = JSON.stringify([trigger.type, matchValue]);
45
+ const earlier = earlierConditions.get(key) || [];
46
+ const conditions = [
47
+ ...(rule.condition ? [rule.condition] : []),
48
+ ...earlier.map((condition) => ({ type: 'not', condition })),
49
+ ];
40
50
  addEdge(edges, from, owner, ownerId, trigger, rule.action, false, {
41
- condition: rule.condition,
51
+ condition: conditions.length > 1 ? { type: 'and', conditions } : conditions[0],
42
52
  variableActions: rule.variableActions,
43
53
  inventoryActions: rule.inventoryActions,
44
54
  });
55
+ earlierConditions.set(key, [...earlier, rule.condition || { type: 'and', conditions: [] }]);
45
56
  }
46
- addEdge(edges, from, owner, ownerId, { type: 'default' }, config?.defaultAction, true);
57
+ const completeConditions = defaultTrigger === 'complete' ? earlierConditions.get(JSON.stringify(['complete', undefined])) || [] : [];
58
+ addEdge(edges, from, owner, ownerId, { type: defaultTrigger }, config?.defaultAction, true, {
59
+ condition: completeConditions.length ? { type: 'and', conditions: completeConditions.map((condition) => ({ type: 'not', condition })) } : undefined,
60
+ });
61
+ }
62
+ // Mirror ScoreSettlement's sorted ranges, extrema and lowest-level gap fallback.
63
+ function settlementConditions(interactive) {
64
+ const config = interactive.settlementConfig || interactive;
65
+ if (!Array.isArray(config.levels) || !config.levels.length
66
+ || !config.levels.every((level) => level && typeof level.id === 'string' && Number.isFinite(level.minScore) && Number.isFinite(level.maxScore) && level.minScore <= level.maxScore)
67
+ || !Array.isArray(config.values))
68
+ return null;
69
+ const levels = [...config.levels].sort((a, b) => b.minScore - a.minScore);
70
+ const primary = config.values?.find((value) => value.isPrimary) || config.values?.[0];
71
+ const and = (...conditions) => ({ type: 'and', conditions });
72
+ const not = (condition) => ({ type: 'not', condition });
73
+ const compare = (op, value) => ({ type: 'settlement_score', variable: primary?.variableName, op, value });
74
+ const high = compare('gte', Math.max(...levels.map(level => level.maxScore)));
75
+ const low = compare('lte', Math.min(...levels.map(level => level.minScore)));
76
+ const ranges = levels.map(level => and(compare('gte', level.minScore), compare('lte', level.maxScore)));
77
+ const result = new Map();
78
+ for (const [index, level] of levels.entries()) {
79
+ const id = level.id.toLowerCase();
80
+ const grade = id.includes('level_s') || id.includes('score_s') ? 'score_s'
81
+ : id.includes('level_a') || id.includes('score_a') ? 'score_a'
82
+ : id.includes('level_b') || id.includes('score_b') ? 'score_b' : 'default';
83
+ const conditions = primary ? [
84
+ and(not(high), not(low), ranges[index], ...ranges.slice(0, index).map(not)),
85
+ ...(index === 0 ? [high] : []),
86
+ ...(index === levels.length - 1 ? [and(not(high), low), and(not(high), not(low), ...ranges.map(not))] : []),
87
+ ] : [level === config.levels[config.levels.length - 1] ? and() : not(and())];
88
+ const prior = result.get(grade);
89
+ result.set(grade, { type: 'or', conditions: [...(prior?.conditions || []), ...conditions] });
90
+ }
91
+ return result;
92
+ }
93
+ function closedChoiceDefault(interaction) {
94
+ const interactive = interaction.interactive || {};
95
+ return !interaction.branchConfig && interactive.type === 'story_choice'
96
+ && interactive.inputMode === 'button' && !interactive.collectMode
97
+ && !(interaction.branches || []).some((branch) => branch.when === 'all_clicked')
98
+ && Array.isArray(interactive.options) && interactive.options.length > 0
99
+ && interactive.options.every((option) => typeof option.id === 'string'
100
+ && (interaction.branches || []).some((branch) => branch.when === option.id && branch.then));
47
101
  }
48
102
  export function extractPlaytestEdges(script) {
49
103
  const graph = script?.graph;
@@ -53,10 +107,15 @@ export function extractPlaytestEdges(script) {
53
107
  const node = rawNode || {};
54
108
  if (node.type === 'start')
55
109
  addEdge(edges, nodeId, 'node', nodeId, { type: 'enter' }, node.config?.next);
56
- if (node.type === 'video' || node.type === 'image') {
110
+ const avgCompletionInteraction = node.type === 'avg' && (node.interactions || []).some((interaction) => interaction.trigger?.type === 'complete');
111
+ if (node.type === 'video' || node.type === 'image' || (node.type === 'avg' && !node.branchConfig && !avgCompletionInteraction)) {
57
112
  addEdge(edges, nodeId, 'node', nodeId, { type: 'complete' }, node.config?.onComplete);
58
113
  }
59
- addBranchConfigEdges(edges, nodeId, 'node', nodeId, node.branchConfig);
114
+ // Completion interactions preempt completion routing, not enter/message rules.
115
+ const nodeBranchConfig = avgCompletionInteraction && node.branchConfig
116
+ ? { ...node.branchConfig, rules: (node.branchConfig.rules || []).filter((rule) => rule.trigger?.type !== 'complete'), defaultAction: undefined }
117
+ : node.branchConfig;
118
+ addBranchConfigEdges(edges, nodeId, 'node', nodeId, nodeBranchConfig, node.type === 'avg' ? 'complete' : 'default');
60
119
  for (const handler of node.config?.messageHandlers || []) {
61
120
  addEdge(edges, nodeId, 'node', nodeId, handler.when || { type: 'message' }, handler.then, false, {
62
121
  variableActions: handler.variableActions,
@@ -71,19 +130,45 @@ export function extractPlaytestEdges(script) {
71
130
  });
72
131
  }
73
132
  for (const interaction of node.interactions || []) {
133
+ const firstEdge = edges.length;
134
+ const settlement = interaction.interactive?.type === 'score_settlement'
135
+ ? settlementConditions(interaction.interactive) : null;
74
136
  addBranchConfigEdges(edges, nodeId, 'interaction', interaction.id, interaction.branchConfig);
75
- for (const legacy of interaction.branches || []) {
137
+ for (const legacy of interaction.branchConfig ? [] : interaction.branches || []) {
76
138
  addEdge(edges, nodeId, 'interaction', interaction.id, { type: 'legacy', value: legacy.when }, legacy.then, false, {
77
- condition: typeof legacy.when === 'object' ? legacy.when : undefined,
139
+ condition: settlement?.get(String(legacy.when).trim().toLowerCase()) || (typeof legacy.when === 'object' ? legacy.when : undefined),
78
140
  variableActions: legacy.variableActions,
79
141
  inventoryActions: legacy.inventoryActions,
80
142
  });
81
143
  }
82
144
  addEdge(edges, nodeId, 'interaction', interaction.id, { type: 'timeout' }, interaction.fallback?.onTimeout);
83
- addEdge(edges, nodeId, 'interaction', interaction.id, { type: 'default' }, interaction.fallback?.onDefault, true);
145
+ if (!interaction.branchConfig)
146
+ addEdge(edges, nodeId, 'interaction', interaction.id, { type: 'default' }, interaction.fallback?.onDefault, true);
147
+ for (const edge of edges.slice(firstEdge)) {
148
+ if (edge.default && closedChoiceDefault(interaction)) {
149
+ edge.unreachableReason = 'Button story choice has no skip control and every option has an unconditional legacy branch';
150
+ }
151
+ if (edge.default && !interaction.branchConfig && settlement) {
152
+ const unmatched = [...settlement].filter(([result]) => !(interaction.branches || []).some((branch) => String(branch.when).trim().toLowerCase() === result));
153
+ if (!unmatched.length) {
154
+ edge.unreachableReason = 'Every configured settlement level, including extrema and gap fallback, emits an explicitly handled score result';
155
+ }
156
+ else {
157
+ edge.condition = { type: 'or', conditions: unmatched.map(([, condition]) => condition) };
158
+ }
159
+ }
160
+ if (edge.trigger.type === 'timeout' && interaction.interactive?.type === 'score_settlement'
161
+ && !(Number(interaction.timeLimit) > 0)) {
162
+ edge.unreachableReason = 'Settlement has no active interaction timer';
163
+ }
164
+ }
84
165
  }
85
166
  }
86
167
  for (const edge of edges) {
168
+ // Test-scope policy, not a claim that the business fallback is unreachable.
169
+ if (nodes[edge.from]?.type === 'web' && edge.owner === 'node' && edge.default) {
170
+ edge.skippedReason = 'Web default fallback is excluded from real-action testing by approved test scope; not tested';
171
+ }
87
172
  if (edge.actionType !== 'restart')
88
173
  continue;
89
174
  const oldTarget = edge.to;
@@ -99,6 +184,7 @@ export function getReachablePlaytestGraph(script) {
99
184
  const allEdges = extractPlaytestEdges(script);
100
185
  const reachableNodeIds = new Set();
101
186
  const reachableEdges = [];
187
+ const excludedEdges = [];
102
188
  const queue = entryNodeId ? [entryNodeId] : [];
103
189
  while (queue.length) {
104
190
  const nodeId = queue.shift();
@@ -106,10 +192,14 @@ export function getReachablePlaytestGraph(script) {
106
192
  continue;
107
193
  reachableNodeIds.add(nodeId);
108
194
  for (const edge of allEdges.filter((candidate) => candidate.from === nodeId)) {
195
+ if (edge.unreachableReason || edge.skippedReason) {
196
+ excludedEdges.push(edge);
197
+ continue;
198
+ }
109
199
  reachableEdges.push(edge);
110
200
  if (nodes[edge.to] && !reachableNodeIds.has(edge.to))
111
201
  queue.push(edge.to);
112
202
  }
113
203
  }
114
- return { entryNodeId, reachableNodeIds: [...reachableNodeIds], reachableEdges };
204
+ return { entryNodeId, reachableNodeIds: [...reachableNodeIds], reachableEdges, excludedEdges };
115
205
  }
@@ -21,8 +21,10 @@ export function createJUnitXml(report) {
21
21
  return `<testcase classname="branch-video-playtest" name="${escapeXml(scenario.id)}" time="${(scenario.durationMs / 1000).toFixed(3)}">${failure}</testcase>`;
22
22
  }).join('');
23
23
  const gateCases = gateFindings.map((finding, index) => (`<testcase classname="branch-video-playtest.gate" name="gate-${index + 1}-${escapeXml(finding.code)}" time="0.000"><failure type="${escapeXml(finding.code)}" message="${escapeXml(finding.message)}">${escapeXml(JSON.stringify(finding.details ?? null))}</failure></testcase>`)).join('');
24
- const tests = report.scenarios.length + gateFindings.length;
25
- return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuite name="branch-video-playtest" tests="${tests}" failures="${failures}" time="${duration.toFixed(3)}">${scenarioCases}${gateCases}</testsuite>\n`;
24
+ const skipped = report.warnings.filter(warning => warning.code === 'WEB_DEFAULT_NOT_TESTED');
25
+ const skippedCases = skipped.map(warning => (`<testcase classname="branch-video-playtest.excluded" name="${escapeXml(warning.edgeId)}" time="0.000"><skipped message="${escapeXml(warning.message)}"/></testcase>`)).join('');
26
+ const tests = report.scenarios.length + gateFindings.length + skipped.length;
27
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuite name="branch-video-playtest" tests="${tests}" failures="${failures}" skipped="${skipped.length}" time="${duration.toFixed(3)}">${scenarioCases}${gateCases}${skippedCases}</testsuite>\n`;
26
28
  }
27
29
  export async function writePlaytestReport(reportDir, report) {
28
30
  await mkdir(reportDir, { recursive: true });
@@ -16,6 +16,7 @@ interface RunnerOptions {
16
16
  };
17
17
  }
18
18
  export declare function startPlayback(frame: Frame, entryNodeId: string, entryNodeType: string, timeout: number): Promise<void>;
19
+ export declare function completeAvgPlayback(frame: Frame, nodeId: string, deadline: number): Promise<void>;
19
20
  export declare function runPlaytestScenarios(options: RunnerOptions): Promise<PlaytestScenarioResult[]>;
20
21
  export declare function createDraftPlayerUrl(baseUrl: string): {
21
22
  playerUrl: string;
@@ -86,20 +86,36 @@ async function waitForPlayerFrame(page, playerUrl, timeout) {
86
86
  }
87
87
  throw new PlaytestError('PLAYER_LOAD_FAILED', `Player iframe did not load ${playerUrl}`);
88
88
  }
89
+ async function playerStartupExceptions(frame) {
90
+ return frame.evaluate(() => {
91
+ const exceptions = globalThis.__bvPlaytestStartupExceptions;
92
+ return Array.isArray(exceptions) ? exceptions : [];
93
+ }).catch(() => []);
94
+ }
95
+ async function throwForPlayerStartupException(frame) {
96
+ const exceptions = await playerStartupExceptions(frame);
97
+ if (exceptions.length) {
98
+ throw new PlaytestError('PLAYER_INIT_FAILED', 'Player raised an exception before the playtest handshake', { startupExceptions: exceptions });
99
+ }
100
+ }
89
101
  async function assertPlayerProtocol(frame, timeout) {
90
102
  const root = frame.locator('[data-bv-playtest-protocol]').first();
91
103
  try {
92
- await root.waitFor({ state: 'attached', timeout: Math.min(timeout, 5000) });
104
+ if (timeout <= 0)
105
+ throw new Error('Scenario deadline elapsed');
106
+ await root.waitFor({ state: 'attached', timeout });
93
107
  }
94
108
  catch {
95
- throw new PlaytestError('PLAYER_PLAYTEST_PROTOCOL_UNSUPPORTED', 'Player does not expose the BV playtest protocol');
109
+ await throwForPlayerStartupException(frame);
110
+ throw new PlaytestError('PLAYER_HANDSHAKE_TIMEOUT', 'Player did not expose the BV playtest protocol within the scenario budget');
96
111
  }
97
112
  const protocol = await root.getAttribute('data-bv-playtest-protocol');
98
113
  if (protocol !== '1') {
99
114
  throw new PlaytestError('PLAYER_PLAYTEST_PROTOCOL_UNSUPPORTED', `Unsupported BV playtest protocol: ${protocol || 'missing'}`, { protocol });
100
115
  }
101
116
  if (await root.getAttribute('data-bv-player-state') === 'error') {
102
- throw new PlaytestError('PLAYER_INIT_FAILED', 'Player entered the error state before playback started');
117
+ const exceptions = await playerStartupExceptions(frame);
118
+ throw new PlaytestError('PLAYER_INIT_FAILED', 'Player entered the error state before playback started', exceptions.length ? { startupExceptions: exceptions } : undefined);
103
119
  }
104
120
  }
105
121
  async function waitForNode(frame, nodeId, timeout) {
@@ -245,11 +261,76 @@ async function clickAction(frame, action) {
245
261
  else
246
262
  await control.click();
247
263
  }
248
- async function executeNativeInteraction(frame, node, edge, contract) {
264
+ function normalizeSettlementResult(value) {
265
+ return String(value ?? '').trim().toLowerCase();
266
+ }
267
+ async function executeScoreSettlement(frame, interaction, edge) {
268
+ const target = (edge.trigger.type === 'legacy' || edge.trigger.type === 'result')
269
+ ? normalizeSettlementResult(edge.trigger.value) : '';
270
+ if (!edge.default && !['score_s', 'score_a', 'score_b', 'default'].includes(target)) {
271
+ throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `Unsupported score settlement target: ${edge.trigger.type}:${String(edge.trigger.value || '')}`);
272
+ }
273
+ const actual = normalizeSettlementResult(await frame.locator('[data-bv-settlement-result]').getAttribute('data-bv-settlement-result'));
274
+ if (!actual)
275
+ throw new PlaytestError('SETTLEMENT_RESULT_MISSING', `Score settlement did not expose a result for ${target}`);
276
+ if (!['score_s', 'score_a', 'score_b', 'default'].includes(actual)) {
277
+ throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `Unsupported score settlement result: ${actual}`);
278
+ }
279
+ if (edge.default) {
280
+ if (interaction.branchConfig) {
281
+ throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', 'Score settlement default does not support explicit branchConfig');
282
+ }
283
+ const matchedLegacy = (interaction.branches || []).some((branch) => (typeof branch?.when === 'string' && normalizeSettlementResult(branch.when) === actual));
284
+ if (matchedLegacy) {
285
+ throw new PlaytestError('SETTLEMENT_DEFAULT_NOT_REACHED', `Score settlement result ${actual} has an explicit branch`);
286
+ }
287
+ await clickAction(frame, 'confirm-settlement');
288
+ return;
289
+ }
290
+ if (actual !== target)
291
+ throw new PlaytestError('SETTLEMENT_RESULT_MISMATCH', `Score settlement result ${actual} does not match ${target}`, { actual, target });
292
+ await clickAction(frame, 'confirm-settlement');
293
+ }
294
+ export async function completeAvgPlayback(frame, nodeId, deadline) {
295
+ if (Date.now() >= deadline)
296
+ throw new PlaytestError('AVG_COMPLETION_TIMEOUT', `No scenario budget remains for AVG ${nodeId}`);
297
+ let clickedStep;
298
+ let observedAvg = false;
299
+ while (Date.now() < deadline) {
300
+ const state = await frame.evaluate((id) => {
301
+ const document = globalThis.document;
302
+ const current = document.querySelector('[data-bv-node-id]')?.getAttribute('data-bv-node-id');
303
+ const avg = Array.from(document.querySelectorAll('[data-bv-avg-node-id]')).find((element) => element.getAttribute('data-bv-avg-node-id') === id);
304
+ return { nodeId: current, step: avg?.getAttribute('data-bv-avg-step'), completed: avg?.getAttribute('data-bv-avg-completed') === 'true', ended: document.querySelector('[data-bv-ended]')?.getAttribute('data-bv-ended') === 'true' };
305
+ }, nodeId);
306
+ if (state.ended || (state.nodeId && state.nodeId !== nodeId))
307
+ return;
308
+ if (state.step != null)
309
+ observedAvg = true;
310
+ if (state.completed)
311
+ return;
312
+ const next = frame.getByRole('button', { name: '下一句', exact: true });
313
+ if (state.step != null && state.step !== clickedStep && await next.isVisible()) {
314
+ const remaining = deadline - Date.now();
315
+ if (remaining <= 0)
316
+ break;
317
+ await next.click({ timeout: remaining });
318
+ clickedStep = state.step;
319
+ }
320
+ const remaining = deadline - Date.now();
321
+ if (remaining > 0)
322
+ await frame.page().waitForTimeout(Math.min(100, remaining));
323
+ }
324
+ throw new PlaytestError(observedAvg ? 'AVG_COMPLETION_TIMEOUT' : 'AVG_PLAYTEST_PROTOCOL_UNSUPPORTED', `AVG ${nodeId} did not expose completion within the remaining scenario budget`, { nodeId, clickedStep });
325
+ }
326
+ async function executeNativeInteraction(frame, node, edge, contract, deadline) {
249
327
  const interaction = (node.interactions || []).find((candidate) => candidate.id === edge.ownerId);
250
328
  if (!interaction)
251
329
  throw new PlaytestError('INTERACTION_NOT_RENDERED', `Interaction ${edge.ownerId} is absent from node ${edge.from}`);
252
- if (node.type === 'web' && interaction.trigger?.type === 'message') {
330
+ if (node.type === 'avg' && interaction.trigger?.type === 'complete') {
331
+ await completeAvgPlayback(frame, edge.from, deadline);
332
+ }
333
+ else if (node.type === 'web' && interaction.trigger?.type === 'message') {
253
334
  await executeWebEdge(frame, node, {
254
335
  ...edge,
255
336
  owner: 'node',
@@ -277,19 +358,23 @@ async function executeNativeInteraction(frame, node, edge, contract) {
277
358
  return;
278
359
  }
279
360
  const interactionType = String(interaction.interactive?.type || '');
280
- const resultValue = edge.trigger.type === 'result'
281
- ? String(edge.trigger.value)
282
- : edge.trigger.type === 'legacy'
283
- ? String(edge.trigger.value) === 'true'
284
- ? 'correct'
285
- : String(edge.trigger.value) === 'partial'
286
- ? 'partial'
287
- : 'incorrect'
288
- : 'incorrect';
289
361
  if (interactionType === 'score_settlement') {
290
- await clickAction(frame, 'confirm-settlement');
362
+ await executeScoreSettlement(frame, interaction, edge);
291
363
  return;
292
364
  }
365
+ const legacy = edge.trigger.type === 'legacy' ? String(edge.trigger.value) : undefined;
366
+ const exactOption = legacy === undefined ? undefined : interaction.interactive?.options?.find((option) => String(option.id) === legacy);
367
+ const legacyResult = new Map([['true', 'correct'], ['correct', 'correct'], ['partial', 'partial'], ['false', 'incorrect'], ['incorrect', 'incorrect'], ['wrong', 'incorrect']]).get(legacy || '');
368
+ if (legacy !== undefined && !exactOption && !legacyResult
369
+ && !(legacy === 'all_clicked' && interaction.interactive?.collectMode?.mode === 'all')) {
370
+ throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `Unknown legacy interaction trigger: ${legacy}`);
371
+ }
372
+ if (exactOption && ['logic_chain', 'blank_fill', 'input_fill'].includes(interactionType)) {
373
+ throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `Exact option trigger is not a result for ${interactionType}`);
374
+ }
375
+ const resultValue = edge.trigger.type === 'result'
376
+ ? String(edge.trigger.value)
377
+ : legacyResult || 'incorrect';
293
378
  if (interactionType === 'logic_chain') {
294
379
  const ids = (interaction.interactive?.chainOptions || []).map((option) => String(option.id));
295
380
  const orderedIds = resultValue === 'correct' ? ids : [...ids].reverse();
@@ -339,12 +424,10 @@ async function executeNativeInteraction(frame, node, edge, contract) {
339
424
  else if (edge.trigger.type === 'result')
340
425
  optionIds = chooseResultOption(interaction, String(edge.trigger.value));
341
426
  else if (edge.trigger.type === 'legacy') {
342
- const legacy = String(edge.trigger.value);
343
- optionIds = legacy === 'true'
344
- ? chooseResultOption(interaction, 'correct')
345
- : legacy === 'partial'
346
- ? chooseResultOption(interaction, 'partial')
347
- : chooseResultOption(interaction, 'incorrect');
427
+ if (exactOption)
428
+ optionIds = [String(exactOption.id)];
429
+ else if (legacyResult)
430
+ optionIds = chooseResultOption(interaction, legacyResult);
348
431
  }
349
432
  if (interaction.interactive?.collectMode?.mode === 'all') {
350
433
  optionIds = (interaction.interactive?.options || []).map((option) => String(option.id));
@@ -378,11 +461,14 @@ async function executeNativeInteraction(frame, node, edge, contract) {
378
461
  }
379
462
  throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `No real DOM driver is available for ${edge.ownerId}:${edge.trigger.type}`);
380
463
  }
381
- async function executeEdge(frame, edge, options) {
464
+ async function executeEdge(frame, edge, options, deadline) {
382
465
  const node = getNode(options.script, edge.from);
383
466
  await assertNodeMedia(frame, node);
384
467
  if (edge.owner === 'interaction') {
385
- await executeNativeInteraction(frame, node, edge, options.contract);
468
+ await executeNativeInteraction(frame, node, edge, options.contract, deadline);
469
+ }
470
+ else if (node.type === 'avg' && (edge.trigger.type === 'complete' || edge.default)) {
471
+ await completeAvgPlayback(frame, edge.from, deadline);
386
472
  }
387
473
  else if (node.type === 'web' && (edge.trigger.type === 'message' || edge.default)) {
388
474
  await executeWebEdge(frame, node, edge, options.contract);
@@ -478,6 +564,18 @@ async function runScenario(browser, scenario, options) {
478
564
  await context.addInitScript(() => {
479
565
  const browserWindow = globalThis;
480
566
  browserWindow.__bvPlaytestObservedMessages = [];
567
+ browserWindow.__bvPlaytestStartupExceptions = [];
568
+ const recordStartupException = (reason) => {
569
+ const error = reason instanceof Error ? reason : undefined;
570
+ const message = error?.message || reason?.message || String(reason);
571
+ const stack = error?.stack || reason?.stack;
572
+ browserWindow.__bvPlaytestStartupExceptions.push(stack ? { message, stack } : { message });
573
+ };
574
+ browserWindow.addEventListener('error', (event) => {
575
+ if (event?.error instanceof Error || event?.message)
576
+ recordStartupException(event.error || event.message);
577
+ }, true);
578
+ browserWindow.addEventListener('unhandledrejection', (event) => recordStartupException(event.reason));
481
579
  browserWindow.addEventListener('message', (event) => {
482
580
  browserWindow.__bvPlaytestObservedMessages.push({ data: event.data, origin: event.origin, at: Date.now() });
483
581
  });
@@ -493,15 +591,19 @@ async function runScenario(browser, scenario, options) {
493
591
  }
494
592
  let timeoutHandle;
495
593
  const scenarioDeadline = Date.now() + options.limits.scenarioTimeoutMs;
594
+ let playerFrame;
595
+ let playerHandshakeComplete = false;
596
+ let activeEdge;
496
597
  const scenarioTimeout = new Promise((_, reject) => {
497
- timeoutHandle = globalThis.setTimeout(() => reject(new PlaytestError('SCENARIO_TIMEOUT', `Scenario ${scenario.id} exceeded ${options.limits.scenarioTimeoutMs}ms`)), options.limits.scenarioTimeoutMs);
598
+ timeoutHandle = globalThis.setTimeout(() => reject(new PlaytestError('SCENARIO_TIMEOUT', `Scenario ${scenario.id} exceeded ${options.limits.scenarioTimeoutMs}ms`, activeEdge ? { edgeId: activeEdge.id, nodeId: activeEdge.from, nodeType: getNode(options.script, activeEdge.from).type } : undefined)), options.limits.scenarioTimeoutMs);
498
599
  });
499
600
  try {
500
601
  await Promise.race([(async () => {
501
602
  await page.setContent('<!doctype html><html><body style="margin:0"><iframe title="BV playtest player" style="border:0;width:1280px;height:720px"></iframe></body></html>');
502
603
  await page.locator('iframe[title="BV playtest player"]').evaluate((element, url) => { element.src = url; }, options.playerUrl);
503
- const playerFrame = await waitForPlayerFrame(page, options.playerUrl, options.limits.scenarioTimeoutMs);
504
- await assertPlayerProtocol(playerFrame, options.limits.scenarioTimeoutMs);
604
+ playerFrame = await waitForPlayerFrame(page, options.playerUrl, options.limits.scenarioTimeoutMs);
605
+ await assertPlayerProtocol(playerFrame, scenarioDeadline - Date.now());
606
+ playerHandshakeComplete = true;
505
607
  const entryNodeId = String(options.script.graph?.entryNodeId || '');
506
608
  const entryTimeout = scenarioDeadline - Date.now();
507
609
  if (entryTimeout <= 0)
@@ -511,7 +613,8 @@ async function runScenario(browser, scenario, options) {
511
613
  await assertNodeMedia(playerFrame, getNode(options.script, entryNodeId));
512
614
  visitedNodes.push(String(options.script.graph?.entryNodeId || ''));
513
615
  for (const edge of scenario.edges) {
514
- await executeEdge(playerFrame, edge, options);
616
+ activeEdge = edge;
617
+ await executeEdge(playerFrame, edge, options, scenarioDeadline);
515
618
  coveredEdges.push(edge.id);
516
619
  if (!edge.to.startsWith('@'))
517
620
  visitedNodes.push(edge.to);
@@ -519,7 +622,14 @@ async function runScenario(browser, scenario, options) {
519
622
  })(), scenarioTimeout]);
520
623
  }
521
624
  catch (error) {
522
- const failure = error instanceof PlaytestError ? error : new PlaytestError('SCENARIO_FAILED', error instanceof Error ? error.message : String(error));
625
+ let failure = error instanceof PlaytestError ? error : new PlaytestError('SCENARIO_FAILED', error instanceof Error ? error.message : String(error));
626
+ if (failure.code === 'SCENARIO_TIMEOUT' && playerFrame && !playerHandshakeComplete) {
627
+ const exceptions = await playerStartupExceptions(playerFrame);
628
+ if (exceptions.length)
629
+ failure = new PlaytestError('PLAYER_INIT_FAILED', 'Player raised an exception before the playtest handshake', { startupExceptions: exceptions });
630
+ else
631
+ failure = new PlaytestError('PLAYER_HANDSHAKE_TIMEOUT', 'Player did not expose the BV playtest protocol within the scenario budget');
632
+ }
523
633
  findings.push({ code: failure.code, message: failure.message, scenarioId: scenario.id, edgeId: scenario.targetEdgeId, details: failure.details });
524
634
  const screenshotPath = path.join(scenarioDir, 'failure.png');
525
635
  await page.screenshot({ path: screenshotPath, fullPage: true }).catch(() => undefined);
@@ -19,6 +19,10 @@ function compare(actual, op, expected) {
19
19
  function conditionMatches(condition, variables, inventory) {
20
20
  if (!condition)
21
21
  return true;
22
+ if (condition.type === 'settlement_score') {
23
+ const score = Number(variables[condition.variable] ?? 0);
24
+ return compare(Number.isFinite(score) ? score : 0, condition.op, condition.value);
25
+ }
22
26
  if (condition.type === 'variable' || condition.variable)
23
27
  return compare(variables[condition.variable], condition.op, condition.value);
24
28
  if (condition.type === 'inventory') {
@@ -81,6 +81,8 @@ export interface PlaytestEdge {
81
81
  actionType: string;
82
82
  action: Record<string, unknown>;
83
83
  default?: boolean;
84
+ unreachableReason?: string;
85
+ skippedReason?: string;
84
86
  }
85
87
  export interface PlaytestScenario {
86
88
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindscraft/branch-video-agent-cli",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "Published CLI for branch-video and AIHub agent APIs.",
5
5
  "type": "module",
6
6
  "bin": {