@mindscraft/branch-video-agent-cli 0.4.5 → 0.4.7

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
@@ -1,20 +1,20 @@
1
1
  # Branch Video Agent CLI
2
2
 
3
- `@mindscraft/branch-video-agent-cli` 是一个可脱离当前代码仓库使用的发布版 CLI,用于通过 Bearer agent token 调用 branch-video、AIHub workflow 与 AI Gateway agent API。
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 发布。
3
+ `@mindscraft/branch-video-agent-cli` 是一个可脱离当前代码仓库使用的发布版 CLI,用于通过 Bearer agent token 调用 branch-video、AIHub workflow 与 AI Gateway agent API。
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
18
 
19
19
  ## 安装与执行
20
20
 
@@ -69,6 +69,8 @@ AI Gateway 生产媒体验收使用 `ai-gateway media-understand --raw`。P0 模
69
69
 
70
70
  搜索展开上限的数值不变;可安全去重的相同状态不重复计入,其他状态沿原搜索计数。
71
71
 
72
+ 实测范围不包含 Web 节点的 `branchConfig.defaultAction` 兜底分支,也不要求为仅落入该兜底的消息提供操作配方。业务脚本和兜底行为保持不变;显式消息分支(包括名为 `default` 的消息)、答对、答错及适用的超时仍须真实操作。仅通过豁免分支到达的节点不在本轮覆盖目标内;仍有正常路径可达的节点继续测试。JSON 报告以 `WEB_DEFAULT_NOT_TESTED` 逐边记录,JUnit 标为 `skipped`,不计作覆盖或通过;报告 `passed` 仅表示本轮要求实测的范围通过。该豁免不声称兜底不可达,也不影响其他类型的 default 校验。
73
+
72
74
  ```powershell
73
75
  @'
74
76
  {
@@ -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',
package/dist/lib/flags.js CHANGED
@@ -98,7 +98,7 @@ export function getCliHelpText() {
98
98
  'Groups:',
99
99
  ' preset list | upsert',
100
100
  ' workflow run | status | outputs',
101
- ' project list | get | create | import-aihub | update | delete',
101
+ ' project list | get | draft-get | create | import-aihub | update | delete',
102
102
  ' version list | get | publish',
103
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',
104
104
  ' aihub-production schema | start | status | retry',
@@ -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,108 @@ 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));
101
+ }
102
+ // Deliberately narrow: the image timer pauses at the first tick and every
103
+ // settlement result leaves this node. Ordinary resumable interactions do not qualify.
104
+ function settlementBlocksImageCompletion(nodeId, node, nodes) {
105
+ if (node.type !== 'image' || node.branchConfig || node.branches || node.events
106
+ || node.config?.events || node.config?.messageHandlers
107
+ || (node.config?.loop !== undefined && node.config.loop !== false)
108
+ || !Array.isArray(node.config?.slides) || node.config.slides.length !== 1
109
+ || !Array.isArray(node.interactions) || node.interactions.length !== 1)
110
+ return false;
111
+ const slide = node.config.slides[0];
112
+ if (!slide || typeof slide.id !== 'string' || !slide.id.trim()
113
+ || typeof slide.assetRef !== 'string' || !slide.assetRef.trim()
114
+ || (slide.duration !== undefined && (!Number.isFinite(slide.duration) || slide.duration < 0))
115
+ || (slide.duration || 3) <= 0.1)
116
+ return false;
117
+ const interaction = node.interactions[0];
118
+ if (!interaction || interaction.interactive?.type !== 'score_settlement'
119
+ || interaction.branchConfig || interaction.events
120
+ || (interaction.timeLimit !== undefined && (!Number.isFinite(interaction.timeLimit) || interaction.timeLimit > 0))
121
+ || !(interaction.trigger?.type === 'time' && interaction.trigger.value === 0
122
+ || interaction.trigger?.type === 'slide' && interaction.trigger.index === 0 && interaction.trigger.offsetSec === 0)
123
+ || !Array.isArray(interaction.branches))
124
+ return false;
125
+ const outcomes = settlementConditions(interaction.interactive);
126
+ if (!outcomes)
127
+ return false;
128
+ const leavesNode = (action) => action?.type === 'end'
129
+ || action?.type === 'goto' && typeof action.target === 'string'
130
+ && Boolean(nodes[action.target.trim()]) && action.target.trim() !== nodeId;
131
+ if (interaction.branches.some((branch) => !branch || branch.condition || !leavesNode(branch.then))
132
+ || [interaction.fallback?.onDefault, interaction.fallback?.onTimeout].some(action => action && !leavesNode(action)))
133
+ return false;
134
+ return [...outcomes.keys()].every(result => interaction.branches.some((branch) => (typeof branch.when === 'string' && branch.when.trim().toLowerCase() === result)));
47
135
  }
48
136
  export function extractPlaytestEdges(script) {
49
137
  const graph = script?.graph;
@@ -53,10 +141,22 @@ export function extractPlaytestEdges(script) {
53
141
  const node = rawNode || {};
54
142
  if (node.type === 'start')
55
143
  addEdge(edges, nodeId, 'node', nodeId, { type: 'enter' }, node.config?.next);
56
- if (node.type === 'video' || node.type === 'image') {
144
+ const avgCompletionInteraction = node.type === 'avg' && (node.interactions || []).some((interaction) => interaction.trigger?.type === 'complete');
145
+ const mediaCompletion = node.type === 'video' || node.type === 'image' || node.type === 'avg';
146
+ if (mediaCompletion && !node.branchConfig && !avgCompletionInteraction) {
57
147
  addEdge(edges, nodeId, 'node', nodeId, { type: 'complete' }, node.config?.onComplete);
148
+ if (settlementBlocksImageCompletion(nodeId, node, nodes)) {
149
+ const completion = edges.at(-1);
150
+ if (completion?.from === nodeId && completion.owner === 'node' && completion.trigger.type === 'complete') {
151
+ completion.unreachableReason = 'Immediate image settlement pauses completion and every result leaves the node';
152
+ }
153
+ }
58
154
  }
59
- addBranchConfigEdges(edges, nodeId, 'node', nodeId, node.branchConfig);
155
+ // Completion interactions preempt completion routing, not enter/message rules.
156
+ const nodeBranchConfig = avgCompletionInteraction && node.branchConfig
157
+ ? { ...node.branchConfig, rules: (node.branchConfig.rules || []).filter((rule) => rule.trigger?.type !== 'complete'), defaultAction: undefined }
158
+ : node.branchConfig;
159
+ addBranchConfigEdges(edges, nodeId, 'node', nodeId, nodeBranchConfig, mediaCompletion ? 'complete' : 'default');
60
160
  for (const handler of node.config?.messageHandlers || []) {
61
161
  addEdge(edges, nodeId, 'node', nodeId, handler.when || { type: 'message' }, handler.then, false, {
62
162
  variableActions: handler.variableActions,
@@ -71,19 +171,45 @@ export function extractPlaytestEdges(script) {
71
171
  });
72
172
  }
73
173
  for (const interaction of node.interactions || []) {
174
+ const firstEdge = edges.length;
175
+ const settlement = interaction.interactive?.type === 'score_settlement'
176
+ ? settlementConditions(interaction.interactive) : null;
74
177
  addBranchConfigEdges(edges, nodeId, 'interaction', interaction.id, interaction.branchConfig);
75
- for (const legacy of interaction.branches || []) {
178
+ for (const legacy of interaction.branchConfig ? [] : interaction.branches || []) {
76
179
  addEdge(edges, nodeId, 'interaction', interaction.id, { type: 'legacy', value: legacy.when }, legacy.then, false, {
77
- condition: typeof legacy.when === 'object' ? legacy.when : undefined,
180
+ condition: settlement?.get(String(legacy.when).trim().toLowerCase()) || (typeof legacy.when === 'object' ? legacy.when : undefined),
78
181
  variableActions: legacy.variableActions,
79
182
  inventoryActions: legacy.inventoryActions,
80
183
  });
81
184
  }
82
185
  addEdge(edges, nodeId, 'interaction', interaction.id, { type: 'timeout' }, interaction.fallback?.onTimeout);
83
- addEdge(edges, nodeId, 'interaction', interaction.id, { type: 'default' }, interaction.fallback?.onDefault, true);
186
+ if (!interaction.branchConfig)
187
+ addEdge(edges, nodeId, 'interaction', interaction.id, { type: 'default' }, interaction.fallback?.onDefault, true);
188
+ for (const edge of edges.slice(firstEdge)) {
189
+ if (edge.default && closedChoiceDefault(interaction)) {
190
+ edge.unreachableReason = 'Button story choice has no skip control and every option has an unconditional legacy branch';
191
+ }
192
+ if (edge.default && !interaction.branchConfig && settlement) {
193
+ const unmatched = [...settlement].filter(([result]) => !(interaction.branches || []).some((branch) => String(branch.when).trim().toLowerCase() === result));
194
+ if (!unmatched.length) {
195
+ edge.unreachableReason = 'Every configured settlement level, including extrema and gap fallback, emits an explicitly handled score result';
196
+ }
197
+ else {
198
+ edge.condition = { type: 'or', conditions: unmatched.map(([, condition]) => condition) };
199
+ }
200
+ }
201
+ if (edge.trigger.type === 'timeout' && interaction.interactive?.type === 'score_settlement'
202
+ && !(Number(interaction.timeLimit) > 0)) {
203
+ edge.unreachableReason = 'Settlement has no active interaction timer';
204
+ }
205
+ }
84
206
  }
85
207
  }
86
208
  for (const edge of edges) {
209
+ // Test-scope policy, not a claim that the business fallback is unreachable.
210
+ if (nodes[edge.from]?.type === 'web' && edge.owner === 'node' && edge.default) {
211
+ edge.skippedReason = 'Web default fallback is excluded from real-action testing by approved test scope; not tested';
212
+ }
87
213
  if (edge.actionType !== 'restart')
88
214
  continue;
89
215
  const oldTarget = edge.to;
@@ -99,6 +225,7 @@ export function getReachablePlaytestGraph(script) {
99
225
  const allEdges = extractPlaytestEdges(script);
100
226
  const reachableNodeIds = new Set();
101
227
  const reachableEdges = [];
228
+ const excludedEdges = [];
102
229
  const queue = entryNodeId ? [entryNodeId] : [];
103
230
  while (queue.length) {
104
231
  const nodeId = queue.shift();
@@ -106,10 +233,14 @@ export function getReachablePlaytestGraph(script) {
106
233
  continue;
107
234
  reachableNodeIds.add(nodeId);
108
235
  for (const edge of allEdges.filter((candidate) => candidate.from === nodeId)) {
236
+ if (edge.unreachableReason || edge.skippedReason) {
237
+ excludedEdges.push(edge);
238
+ continue;
239
+ }
109
240
  reachableEdges.push(edge);
110
241
  if (nodes[edge.to] && !reachableNodeIds.has(edge.to))
111
242
  queue.push(edge.to);
112
243
  }
113
244
  }
114
- return { entryNodeId, reachableNodeIds: [...reachableNodeIds], reachableEdges };
245
+ return { entryNodeId, reachableNodeIds: [...reachableNodeIds], reachableEdges, excludedEdges };
115
246
  }
@@ -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 });
@@ -1,5 +1,5 @@
1
- import { type Frame } from 'playwright';
2
- import type { PlaytestContract, PlaytestLimits, PlaytestScenario, PlaytestScenarioResult } from './types.js';
1
+ import { type ElementHandle, type Frame } from 'playwright';
2
+ import type { PlaytestContract, PlaytestEdge, PlaytestLimits, PlaytestScenario, PlaytestScenarioResult } from './types.js';
3
3
  type ScriptRecord = Record<string, any>;
4
4
  interface RunnerOptions {
5
5
  script: ScriptRecord;
@@ -15,7 +15,17 @@ interface RunnerOptions {
15
15
  height: number;
16
16
  };
17
17
  }
18
+ interface VerifiedMedia {
19
+ element: ElementHandle;
20
+ source: string;
21
+ nodeId: string;
22
+ }
18
23
  export declare function startPlayback(frame: Frame, entryNodeId: string, entryNodeType: string, timeout: number): Promise<void>;
24
+ export declare function assertNodeMedia(frame: Frame, node: ScriptRecord, deadline: number): Promise<VerifiedMedia | undefined>;
25
+ export declare function driveVerifiedVideo(media: VerifiedMedia | undefined, trigger: 'time' | 'complete' | 'complete-interaction', time?: number): Promise<void>;
26
+ export declare function executeWebEdge(frame: Frame, node: ScriptRecord, edge: PlaytestEdge, contract: PlaytestContract, deadline: number): Promise<void>;
27
+ export declare function completeAvgPlayback(frame: Frame, nodeId: string, deadline: number): Promise<void>;
28
+ export declare function executeEdge(frame: Frame, edge: PlaytestEdge, options: RunnerOptions, deadline: number): Promise<void>;
19
29
  export declare function runPlaytestScenarios(options: RunnerOptions): Promise<PlaytestScenarioResult[]>;
20
30
  export declare function createDraftPlayerUrl(baseUrl: string): {
21
31
  playerUrl: string;
@@ -11,24 +11,31 @@ function locatorFor(frame, spec) {
11
11
  return frame.getByTestId(spec.testId);
12
12
  return frame.locator(spec.selector);
13
13
  }
14
- async function runWebStep(frame, step) {
14
+ function remainingBudget(deadline) {
15
+ const remaining = deadline - Date.now();
16
+ if (remaining <= 0)
17
+ throw new PlaytestError('SCENARIO_TIMEOUT', 'Scenario deadline elapsed');
18
+ return remaining;
19
+ }
20
+ async function runWebStep(frame, step, deadline) {
15
21
  const locator = locatorFor(frame, step.locator);
22
+ const timeout = remainingBudget(deadline);
16
23
  if (step.action === 'click')
17
- await locator.click();
24
+ await locator.click({ timeout });
18
25
  else if (step.action === 'fill')
19
- await locator.fill(step.value);
26
+ await locator.fill(step.value, { timeout });
20
27
  else if (step.action === 'selectOption')
21
- await locator.selectOption(step.value);
28
+ await locator.selectOption(step.value, { timeout });
22
29
  else if (step.action === 'check')
23
- await locator.setChecked(step.checked ?? true);
30
+ await locator.setChecked(step.checked ?? true, { timeout });
24
31
  else if (step.action === 'press')
25
- await locator.press(step.key);
32
+ await locator.press(step.key, { timeout });
26
33
  else if (step.action === 'dragTo')
27
- await locator.dragTo(locatorFor(frame, step.target));
34
+ await locator.dragTo(locatorFor(frame, step.target), { timeout });
28
35
  else if (step.action === 'setInputFiles')
29
- await locator.setInputFiles(step.files);
36
+ await locator.setInputFiles(step.files, { timeout });
30
37
  else
31
- await locator.waitFor({ state: step.state ?? 'visible', timeout: step.timeoutMs });
38
+ await locator.waitFor({ state: step.state ?? 'visible', timeout: step.timeoutMs && step.timeoutMs > 0 ? Math.min(step.timeoutMs, timeout) : timeout });
32
39
  }
33
40
  function appendInstrumentationQuery(rawUrl) {
34
41
  const url = new URL(rawUrl);
@@ -86,20 +93,36 @@ async function waitForPlayerFrame(page, playerUrl, timeout) {
86
93
  }
87
94
  throw new PlaytestError('PLAYER_LOAD_FAILED', `Player iframe did not load ${playerUrl}`);
88
95
  }
96
+ async function playerStartupExceptions(frame) {
97
+ return frame.evaluate(() => {
98
+ const exceptions = globalThis.__bvPlaytestStartupExceptions;
99
+ return Array.isArray(exceptions) ? exceptions : [];
100
+ }).catch(() => []);
101
+ }
102
+ async function throwForPlayerStartupException(frame) {
103
+ const exceptions = await playerStartupExceptions(frame);
104
+ if (exceptions.length) {
105
+ throw new PlaytestError('PLAYER_INIT_FAILED', 'Player raised an exception before the playtest handshake', { startupExceptions: exceptions });
106
+ }
107
+ }
89
108
  async function assertPlayerProtocol(frame, timeout) {
90
109
  const root = frame.locator('[data-bv-playtest-protocol]').first();
91
110
  try {
92
- await root.waitFor({ state: 'attached', timeout: Math.min(timeout, 5000) });
111
+ if (timeout <= 0)
112
+ throw new Error('Scenario deadline elapsed');
113
+ await root.waitFor({ state: 'attached', timeout });
93
114
  }
94
115
  catch {
95
- throw new PlaytestError('PLAYER_PLAYTEST_PROTOCOL_UNSUPPORTED', 'Player does not expose the BV playtest protocol');
116
+ await throwForPlayerStartupException(frame);
117
+ throw new PlaytestError('PLAYER_HANDSHAKE_TIMEOUT', 'Player did not expose the BV playtest protocol within the scenario budget');
96
118
  }
97
119
  const protocol = await root.getAttribute('data-bv-playtest-protocol');
98
120
  if (protocol !== '1') {
99
121
  throw new PlaytestError('PLAYER_PLAYTEST_PROTOCOL_UNSUPPORTED', `Unsupported BV playtest protocol: ${protocol || 'missing'}`, { protocol });
100
122
  }
101
123
  if (await root.getAttribute('data-bv-player-state') === 'error') {
102
- throw new PlaytestError('PLAYER_INIT_FAILED', 'Player entered the error state before playback started');
124
+ const exceptions = await playerStartupExceptions(frame);
125
+ throw new PlaytestError('PLAYER_INIT_FAILED', 'Player entered the error state before playback started', exceptions.length ? { startupExceptions: exceptions } : undefined);
103
126
  }
104
127
  }
105
128
  async function waitForNode(frame, nodeId, timeout) {
@@ -137,42 +160,74 @@ export async function startPlayback(frame, entryNodeId, entryNodeType, timeout)
137
160
  }
138
161
  }
139
162
  }
140
- async function assertNodeMedia(frame, node) {
141
- if (node.type === 'video') {
142
- const video = frame.locator('video').first();
143
- await video.waitFor({ state: 'visible' });
144
- const state = await video.evaluate(async (element) => {
145
- if (element.readyState < 1) {
146
- await new Promise((resolve, reject) => {
147
- const timer = globalThis.setTimeout(() => reject(new Error('loadedmetadata timeout')), 10000);
148
- element.addEventListener('loadedmetadata', () => { globalThis.clearTimeout(timer); resolve(); }, { once: true });
149
- element.addEventListener('error', () => { globalThis.clearTimeout(timer); reject(new Error('video error')); }, { once: true });
150
- });
151
- }
152
- const before = element.currentTime;
153
- await element.play().catch(() => undefined);
154
- await new Promise((resolve) => globalThis.setTimeout(resolve, 200));
155
- return { readyState: element.readyState, duration: element.duration, progressed: element.currentTime > before };
156
- });
157
- if (state.readyState < 1 || !Number.isFinite(state.duration)) {
158
- throw new PlaytestError('MEDIA_LOAD_FAILED', `Video media failed on node ${node.id}`, state);
159
- }
160
- }
161
- if (node.type === 'image') {
162
- const image = frame.locator('img').filter({ visible: true }).first();
163
- await image.waitFor({ state: 'visible' });
164
- const loaded = await image.evaluate((element) => element.complete && element.naturalWidth > 0);
165
- if (!loaded)
166
- throw new PlaytestError('MEDIA_LOAD_FAILED', `Image media failed on node ${node.id}`);
163
+ function mainMedia(frame, node) {
164
+ return frame.locator(`[data-bv-media-role="main"][data-bv-media-node-id=${JSON.stringify(node.id)}]`);
165
+ }
166
+ export async function assertNodeMedia(frame, node, deadline) {
167
+ if (node.type === 'video' || node.type === 'image') {
168
+ const media = mainMedia(frame, node);
169
+ await media.waitFor({ state: 'visible', timeout: remainingBudget(deadline) });
170
+ const element = await media.elementHandle({ timeout: remainingBudget(deadline) });
171
+ if (!element)
172
+ throw new PlaytestError('NODE_MEDIA_CHANGED', `Main media disappeared on ${node.id}`);
173
+ const state = await element.evaluate(async (element, input) => {
174
+ const end = Date.now() + input.timeout;
175
+ const source = element.src;
176
+ do {
177
+ const current = globalThis.document.querySelector('[data-bv-node-id]')?.getAttribute('data-bv-node-id');
178
+ if (!element.isConnected || current !== input.nodeId || element.src !== source
179
+ || element.getAttribute('data-bv-media-node-id') !== input.nodeId)
180
+ return { status: 'changed', current };
181
+ if (input.type === 'image') {
182
+ if (element.complete)
183
+ return { status: element.naturalWidth > 0 ? 'loaded' : 'error', source };
184
+ }
185
+ else {
186
+ if (element.error)
187
+ return { status: 'error', error: element.error.code };
188
+ if (element.readyState >= 2 && Number.isFinite(element.duration) && element.currentSrc === source)
189
+ return { status: 'loaded', source };
190
+ }
191
+ await new Promise(resolve => globalThis.setTimeout(resolve, Math.min(50, Math.max(1, end - Date.now()))));
192
+ } while (Date.now() < end);
193
+ return { status: 'timeout', readyState: element.readyState, source };
194
+ }, { nodeId: node.id, type: node.type, timeout: remainingBudget(deadline) });
195
+ if (state.status === 'changed')
196
+ throw new PlaytestError('NODE_MEDIA_CHANGED', `Node or media source changed while loading ${node.id}`, state);
197
+ if (state.status !== 'loaded')
198
+ throw new PlaytestError('MEDIA_LOAD_FAILED', `Main ${node.type} media failed on node ${node.id}`, state);
199
+ return { element, source: state.source, nodeId: node.id };
167
200
  }
168
201
  if (node.type === 'web') {
169
- await frame.locator('iframe[title="Web 页面"]').waitFor({ state: 'visible' });
202
+ await frame.locator('iframe[title="Web 页面"]').waitFor({ state: 'visible', timeout: remainingBudget(deadline) });
170
203
  }
171
204
  }
205
+ export async function driveVerifiedVideo(media, trigger, time) {
206
+ if (!media)
207
+ throw new PlaytestError('NODE_MEDIA_CHANGED', 'Video was not verified before driving it');
208
+ const valid = await media.element.evaluate((element, input) => {
209
+ const current = globalThis.document.querySelector('[data-bv-node-id]')?.getAttribute('data-bv-node-id');
210
+ if (!element.isConnected || current !== input.nodeId || element.getAttribute('data-bv-media-node-id') !== input.nodeId
211
+ || element.src !== input.source || element.currentSrc !== input.source || element.readyState < 2 || element.error
212
+ || !Number.isFinite(element.duration))
213
+ return false;
214
+ if (input.trigger === 'time')
215
+ element.currentTime = Number(input.time) + 0.05;
216
+ else if (input.trigger === 'complete' && element.duration > 0)
217
+ element.currentTime = Math.max(0, element.duration - 0.01);
218
+ if (input.trigger !== 'complete-interaction')
219
+ element.dispatchEvent(new globalThis.Event('timeupdate', { bubbles: true }));
220
+ if (input.trigger !== 'time')
221
+ element.dispatchEvent(new globalThis.Event('ended', { bubbles: true }));
222
+ return true;
223
+ }, { source: media.source, nodeId: media.nodeId, trigger, time });
224
+ if (!valid)
225
+ throw new PlaytestError('NODE_MEDIA_CHANGED', `Verified video changed before driving ${media.nodeId}`);
226
+ }
172
227
  async function readObservedMessages(frame) {
173
228
  return frame.evaluate(() => globalThis.__bvPlaytestObservedMessages || []).catch(() => []);
174
229
  }
175
- async function executeWebEdge(frame, node, edge, contract) {
230
+ export async function executeWebEdge(frame, node, edge, contract, deadline) {
176
231
  const explicitRoutes = new Set((node.branchConfig?.rules || [])
177
232
  .filter((rule) => rule.trigger?.type === 'message')
178
233
  .map((rule) => String(rule.trigger.value)));
@@ -181,19 +236,44 @@ async function executeWebEdge(frame, node, edge, contract) {
181
236
  : contract.webResults.find((candidate) => candidate.nodeId === edge.from && !explicitRoutes.has(candidate.routeValue));
182
237
  if (!recipe)
183
238
  throw new PlaytestError('WEB_PLAYTEST_RECIPE_MISSING', `No recipe for ${edge.from}:${String(edge.trigger.value)}`);
184
- const webFrame = frame.childFrames().find((candidate) => candidate.url() !== 'about:blank');
239
+ const handle = await frame.locator('iframe[title="Web 页面"]').elementHandle({ timeout: remainingBudget(deadline) });
240
+ const webFrame = await handle?.contentFrame();
185
241
  if (!webFrame)
186
242
  throw new PlaytestError('WEB_FRAME_LOAD_FAILED', `Web iframe did not load on node ${edge.from}`);
243
+ const sourceId = await handle.evaluate((element) => {
244
+ const browserWindow = globalThis;
245
+ browserWindow.__bvPlaytestWebSources ||= new WeakMap();
246
+ const id = (browserWindow.__bvPlaytestWebSourceSequence || 0) + 1;
247
+ browserWindow.__bvPlaytestWebSourceSequence = id;
248
+ browserWindow.__bvPlaytestWebSources.set(element.contentWindow, id);
249
+ return id;
250
+ });
187
251
  const before = (await readObservedMessages(frame)).length;
188
- for (const step of recipe.steps)
189
- await runWebStep(webFrame, step);
252
+ for (let index = 0; index < recipe.steps.length; index += 1) {
253
+ const step = recipe.steps[index];
254
+ try {
255
+ await runWebStep(webFrame, step, deadline);
256
+ }
257
+ catch (error) {
258
+ // A final passive wait may lose its iframe when the real result navigates.
259
+ // Missing actions, wrong/stale messages and unrelated detach remain failures.
260
+ const message = error instanceof Error ? error.message : String(error);
261
+ const emitted = (await readObservedMessages(frame)).slice(before);
262
+ if (!(index === recipe.steps.length - 1 && step.action === 'waitFor'
263
+ && /frame.*detach/i.test(message) && webFrame.isDetached()
264
+ && emitted.some(entry => entry.sourceId === sourceId && matchesRawMessage(entry, recipe.rawMessage))))
265
+ throw error;
266
+ }
267
+ }
190
268
  try {
191
269
  await frame.waitForFunction((payload) => {
192
- const { start, rawMessage } = payload;
270
+ const { start, rawMessage, sourceId } = payload;
193
271
  const messages = (globalThis.__bvPlaytestObservedMessages || []).slice(start);
194
272
  const expectedEvent = rawMessage.eventName;
195
273
  const expectedValue = rawMessage.value ?? rawMessage.data?.value ?? rawMessage.data;
196
274
  return messages.some((entry) => {
275
+ if (entry.sourceId !== sourceId)
276
+ return false;
197
277
  const actual = entry.data;
198
278
  const eventName = actual?.eventName ?? actual?.type ?? actual?.event;
199
279
  const value = actual?.value
@@ -207,14 +287,14 @@ async function executeWebEdge(frame, node, edge, contract) {
207
287
  const comparableExpected = typeof expectedValue === 'string' || typeof expectedValue === 'number' ? String(expectedValue) : expectedValue;
208
288
  return eventName === expectedEvent && JSON.stringify(comparableActual) === JSON.stringify(comparableExpected);
209
289
  });
210
- }, { start: before, rawMessage: recipe.rawMessage }, { timeout: 10000 });
290
+ }, { start: before, rawMessage: recipe.rawMessage, sourceId }, { timeout: remainingBudget(deadline) });
211
291
  }
212
292
  catch {
213
293
  const observed = (await readObservedMessages(frame)).slice(before);
214
294
  throw new PlaytestError('WEB_MESSAGE_NOT_EMITTED', `Web actions did not emit the expected raw message for ${edge.from}:${recipe.routeValue}`, { observed });
215
295
  }
216
296
  const emitted = (await readObservedMessages(frame)).slice(before);
217
- if (!emitted.some((message) => matchesRawMessage(message, recipe.rawMessage))) {
297
+ if (!emitted.some((message) => message.sourceId === sourceId && matchesRawMessage(message, recipe.rawMessage))) {
218
298
  throw new PlaytestError('WEB_MESSAGE_NOT_EMITTED', `Expected Web raw message was not recorded for ${edge.from}:${recipe.routeValue}`);
219
299
  }
220
300
  }
@@ -237,66 +317,137 @@ function chooseResultOption(interaction, result) {
237
317
  }
238
318
  return wrong.length ? [wrong[0]] : options.slice(-1).map((option) => String(option.id));
239
319
  }
240
- async function clickAction(frame, action) {
320
+ async function clickAction(frame, action, deadline) {
241
321
  const control = frame.locator(`[data-bv-action="${action}"]`).first();
242
322
  const nestedButton = control.locator('button').first();
243
323
  if (await nestedButton.count())
244
- await nestedButton.click();
324
+ await nestedButton.click({ timeout: remainingBudget(deadline) });
245
325
  else
246
- await control.click();
326
+ await control.click({ timeout: remainingBudget(deadline) });
327
+ }
328
+ function normalizeSettlementResult(value) {
329
+ return String(value ?? '').trim().toLowerCase();
330
+ }
331
+ async function executeScoreSettlement(frame, interaction, edge, deadline) {
332
+ const target = (edge.trigger.type === 'legacy' || edge.trigger.type === 'result')
333
+ ? normalizeSettlementResult(edge.trigger.value) : '';
334
+ if (!edge.default && !['score_s', 'score_a', 'score_b', 'default'].includes(target)) {
335
+ throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `Unsupported score settlement target: ${edge.trigger.type}:${String(edge.trigger.value || '')}`);
336
+ }
337
+ const actual = normalizeSettlementResult(await frame.locator('[data-bv-settlement-result]').getAttribute('data-bv-settlement-result'));
338
+ if (!actual)
339
+ throw new PlaytestError('SETTLEMENT_RESULT_MISSING', `Score settlement did not expose a result for ${target}`);
340
+ if (!['score_s', 'score_a', 'score_b', 'default'].includes(actual)) {
341
+ throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `Unsupported score settlement result: ${actual}`);
342
+ }
343
+ if (edge.default) {
344
+ if (interaction.branchConfig) {
345
+ throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', 'Score settlement default does not support explicit branchConfig');
346
+ }
347
+ const matchedLegacy = (interaction.branches || []).some((branch) => (typeof branch?.when === 'string' && normalizeSettlementResult(branch.when) === actual));
348
+ if (matchedLegacy) {
349
+ throw new PlaytestError('SETTLEMENT_DEFAULT_NOT_REACHED', `Score settlement result ${actual} has an explicit branch`);
350
+ }
351
+ await clickAction(frame, 'confirm-settlement', deadline);
352
+ return;
353
+ }
354
+ if (actual !== target)
355
+ throw new PlaytestError('SETTLEMENT_RESULT_MISMATCH', `Score settlement result ${actual} does not match ${target}`, { actual, target });
356
+ await clickAction(frame, 'confirm-settlement', deadline);
357
+ }
358
+ export async function completeAvgPlayback(frame, nodeId, deadline) {
359
+ if (Date.now() >= deadline)
360
+ throw new PlaytestError('AVG_COMPLETION_TIMEOUT', `No scenario budget remains for AVG ${nodeId}`);
361
+ let clickedStep;
362
+ let observedAvg = false;
363
+ while (Date.now() < deadline) {
364
+ const state = await frame.evaluate((id) => {
365
+ const document = globalThis.document;
366
+ const current = document.querySelector('[data-bv-node-id]')?.getAttribute('data-bv-node-id');
367
+ const avg = Array.from(document.querySelectorAll('[data-bv-avg-node-id]')).find((element) => element.getAttribute('data-bv-avg-node-id') === id);
368
+ 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' };
369
+ }, nodeId);
370
+ if (state.ended || (state.nodeId && state.nodeId !== nodeId))
371
+ return;
372
+ if (state.step != null)
373
+ observedAvg = true;
374
+ if (state.completed)
375
+ return;
376
+ const next = frame.getByRole('button', { name: '下一句', exact: true });
377
+ if (state.step != null && state.step !== clickedStep && await next.isVisible()) {
378
+ const remaining = deadline - Date.now();
379
+ if (remaining <= 0)
380
+ break;
381
+ try {
382
+ await next.click({ timeout: Math.min(5000, remaining) });
383
+ }
384
+ catch (error) {
385
+ throw new PlaytestError('AVG_NEXT_CONTROL_BLOCKED', `AVG next control is not clickable on ${nodeId}`, {
386
+ nodeId, step: state.step, cause: error instanceof Error ? error.message : String(error),
387
+ });
388
+ }
389
+ clickedStep = state.step;
390
+ }
391
+ const remaining = deadline - Date.now();
392
+ if (remaining > 0)
393
+ await frame.page().waitForTimeout(Math.min(100, remaining));
394
+ }
395
+ throw new PlaytestError(observedAvg ? 'AVG_COMPLETION_TIMEOUT' : 'AVG_PLAYTEST_PROTOCOL_UNSUPPORTED', `AVG ${nodeId} did not expose completion within the remaining scenario budget`, { nodeId, clickedStep });
247
396
  }
248
- async function executeNativeInteraction(frame, node, edge, contract) {
397
+ async function executeNativeInteraction(frame, node, edge, contract, deadline, media) {
249
398
  const interaction = (node.interactions || []).find((candidate) => candidate.id === edge.ownerId);
250
399
  if (!interaction)
251
400
  throw new PlaytestError('INTERACTION_NOT_RENDERED', `Interaction ${edge.ownerId} is absent from node ${edge.from}`);
252
- if (node.type === 'web' && interaction.trigger?.type === 'message') {
401
+ if (node.type === 'avg' && interaction.trigger?.type === 'complete') {
402
+ await completeAvgPlayback(frame, edge.from, deadline);
403
+ }
404
+ else if (node.type === 'web' && interaction.trigger?.type === 'message') {
253
405
  await executeWebEdge(frame, node, {
254
406
  ...edge,
255
407
  owner: 'node',
256
408
  ownerId: node.id,
257
409
  trigger: { type: 'message', value: interaction.trigger.value },
258
- }, contract);
410
+ }, contract, deadline);
259
411
  }
260
412
  else if (interaction.trigger?.type === 'time' && node.type === 'video') {
261
- await frame.locator('video').first().evaluate((element, time) => {
262
- element.currentTime = time + 0.05;
263
- element.dispatchEvent(new globalThis.Event('timeupdate', { bubbles: true }));
264
- }, Number(interaction.trigger.value || 0));
413
+ await driveVerifiedVideo(media, 'time', Number(interaction.trigger.value || 0));
265
414
  }
266
415
  else if (interaction.trigger?.type === 'time' && Number(interaction.trigger.value || 0) > 0) {
267
416
  await frame.page().waitForTimeout(Number(interaction.trigger.value) * 1000 + 50);
268
417
  }
269
418
  else if (interaction.trigger?.type === 'complete' && node.type === 'video') {
270
- await frame.locator('video').first().evaluate((element) => {
271
- element.dispatchEvent(new globalThis.Event('ended', { bubbles: true }));
272
- });
419
+ await driveVerifiedVideo(media, 'complete-interaction');
273
420
  }
274
- await frame.locator(`[data-bv-interaction-id="${edge.ownerId}"]`).waitFor({ state: 'attached' });
421
+ await frame.locator(`[data-bv-interaction-id="${edge.ownerId}"]`).waitFor({ state: 'attached', timeout: remainingBudget(deadline) });
275
422
  if (edge.trigger.type === 'timeout') {
276
423
  await frame.page().waitForTimeout((Number(interaction.timeLimit || 0) + 2.5) * 1000);
277
424
  return;
278
425
  }
279
426
  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
427
  if (interactionType === 'score_settlement') {
290
- await clickAction(frame, 'confirm-settlement');
428
+ await executeScoreSettlement(frame, interaction, edge, deadline);
291
429
  return;
292
430
  }
431
+ const legacy = edge.trigger.type === 'legacy' ? String(edge.trigger.value) : undefined;
432
+ const exactOption = legacy === undefined ? undefined : interaction.interactive?.options?.find((option) => String(option.id) === legacy);
433
+ const legacyResult = new Map([['true', 'correct'], ['correct', 'correct'], ['partial', 'partial'], ['false', 'incorrect'], ['incorrect', 'incorrect'], ['wrong', 'incorrect']]).get(legacy || '');
434
+ if (legacy !== undefined && !exactOption && !legacyResult
435
+ && !(legacy === 'all_clicked' && interaction.interactive?.collectMode?.mode === 'all')) {
436
+ throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `Unknown legacy interaction trigger: ${legacy}`);
437
+ }
438
+ if (exactOption && ['logic_chain', 'blank_fill', 'input_fill'].includes(interactionType)) {
439
+ throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `Exact option trigger is not a result for ${interactionType}`);
440
+ }
441
+ const resultValue = edge.trigger.type === 'result'
442
+ ? String(edge.trigger.value)
443
+ : legacyResult || 'incorrect';
293
444
  if (interactionType === 'logic_chain') {
294
445
  const ids = (interaction.interactive?.chainOptions || []).map((option) => String(option.id));
295
446
  const orderedIds = resultValue === 'correct' ? ids : [...ids].reverse();
296
447
  for (const optionId of orderedIds) {
297
- await frame.locator(`[data-bv-option-id="${optionId}"]`).first().click();
448
+ await frame.locator(`[data-bv-option-id="${optionId}"]`).first().click({ timeout: remainingBudget(deadline) });
298
449
  }
299
- await clickAction(frame, 'submit');
450
+ await clickAction(frame, 'submit', deadline);
300
451
  return;
301
452
  }
302
453
  if (interactionType === 'blank_fill') {
@@ -307,9 +458,9 @@ async function executeNativeInteraction(frame, node, edge, contract) {
307
458
  const correctId = String(blank.correctOptionId || '');
308
459
  const wrongId = String(options.find((option) => String(option.id) !== correctId)?.id || correctId);
309
460
  const optionId = resultValue === 'incorrect' || (resultValue === 'partial' && index > 0) ? wrongId : correctId;
310
- await frame.locator(`[data-bv-option-id="${optionId}"]`).first().dragTo(frame.locator(`[data-bv-blank-id="${blank.id}"]`));
461
+ await frame.locator(`[data-bv-option-id="${optionId}"]`).first().dragTo(frame.locator(`[data-bv-blank-id="${blank.id}"]`), { timeout: remainingBudget(deadline) });
311
462
  }
312
- await clickAction(frame, 'submit');
463
+ await clickAction(frame, 'submit', deadline);
313
464
  return;
314
465
  }
315
466
  if (interactionType === 'input_fill') {
@@ -320,15 +471,15 @@ async function executeNativeInteraction(frame, node, edge, contract) {
320
471
  const answer = shouldBeWrong ? '__wrong__' : (blank.correctAnswers?.[0] ?? 'playtest');
321
472
  const control = frame.locator(`[data-bv-blank-id="${blank.id}"]`);
322
473
  if (blank.type === 'number') {
323
- await control.click();
474
+ await control.click({ timeout: remainingBudget(deadline) });
324
475
  for (const key of String(answer))
325
- await control.press(key);
476
+ await control.press(key, { timeout: remainingBudget(deadline) });
326
477
  }
327
478
  else {
328
- await control.fill(String(answer));
479
+ await control.fill(String(answer), { timeout: remainingBudget(deadline) });
329
480
  }
330
481
  }
331
- await clickAction(frame, 'submit');
482
+ await clickAction(frame, 'submit', deadline);
332
483
  return;
333
484
  }
334
485
  let optionIds = [];
@@ -339,12 +490,10 @@ async function executeNativeInteraction(frame, node, edge, contract) {
339
490
  else if (edge.trigger.type === 'result')
340
491
  optionIds = chooseResultOption(interaction, String(edge.trigger.value));
341
492
  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');
493
+ if (exactOption)
494
+ optionIds = [String(exactOption.id)];
495
+ else if (legacyResult)
496
+ optionIds = chooseResultOption(interaction, legacyResult);
348
497
  }
349
498
  if (interaction.interactive?.collectMode?.mode === 'all') {
350
499
  optionIds = (interaction.interactive?.options || []).map((option) => String(option.id));
@@ -353,17 +502,17 @@ async function executeNativeInteraction(frame, node, edge, contract) {
353
502
  for (const optionId of optionIds) {
354
503
  const option = frame.locator(`[data-bv-option-id="${optionId}"]`);
355
504
  if (await option.count())
356
- await option.first().click();
505
+ await option.first().click({ timeout: remainingBudget(deadline) });
357
506
  else {
358
507
  const optionText = interaction.interactive?.options?.find((candidate) => String(candidate.id) === optionId)?.text;
359
508
  if (!optionText)
360
509
  throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `No real control for option ${optionId} in interaction ${edge.ownerId}`);
361
- await frame.getByText(String(optionText), { exact: true }).click();
510
+ await frame.getByText(String(optionText), { exact: true }).click({ timeout: remainingBudget(deadline) });
362
511
  }
363
512
  }
364
513
  const submit = frame.locator('[data-bv-action="submit"]');
365
514
  if (await submit.isVisible().catch(() => false))
366
- await clickAction(frame, 'submit');
515
+ await clickAction(frame, 'submit', deadline);
367
516
  return;
368
517
  }
369
518
  const inputs = frame.locator(`[data-bv-interaction-id="${edge.ownerId}"] input`);
@@ -371,62 +520,81 @@ async function executeNativeInteraction(frame, node, edge, contract) {
371
520
  const blanks = interaction.interactive?.blanks || [];
372
521
  for (let index = 0; index < await inputs.count(); index += 1) {
373
522
  const answer = blanks[index]?.correctAnswers?.[0] ?? blanks[index]?.answer ?? blanks[index]?.correctAnswer ?? blanks[index]?.answers?.[0] ?? 'playtest';
374
- await inputs.nth(index).fill(String(edge.trigger.value === 'incorrect' ? '__wrong__' : answer));
523
+ await inputs.nth(index).fill(String(edge.trigger.value === 'incorrect' ? '__wrong__' : answer), { timeout: remainingBudget(deadline) });
375
524
  }
376
- await clickAction(frame, 'submit');
525
+ await clickAction(frame, 'submit', deadline);
377
526
  return;
378
527
  }
379
528
  throw new PlaytestError('INTERACTION_DRIVER_UNSUPPORTED', `No real DOM driver is available for ${edge.ownerId}:${edge.trigger.type}`);
380
529
  }
381
- async function executeEdge(frame, edge, options) {
530
+ export async function executeEdge(frame, edge, options, deadline) {
382
531
  const node = getNode(options.script, edge.from);
383
- await assertNodeMedia(frame, node);
532
+ const media = await assertNodeMedia(frame, node, deadline);
533
+ const sameNode = edge.from === edge.to && edge.actionType !== 'end';
534
+ let beforeSequence = 0;
535
+ if (sameNode) {
536
+ if (node.type !== 'video' || !media || !['loop', 'goto'].includes(edge.actionType)
537
+ || await media.element.getAttribute('data-bv-video-action-protocol') !== '1') {
538
+ throw new PlaytestError('EDGE_EFFECT_OBSERVATION_UNSUPPORTED', `No reliable same-node effect observation for ${edge.id}`);
539
+ }
540
+ beforeSequence = Number(await media.element.getAttribute('data-bv-video-action-sequence') || 0);
541
+ }
384
542
  if (edge.owner === 'interaction') {
385
- await executeNativeInteraction(frame, node, edge, options.contract);
543
+ await executeNativeInteraction(frame, node, edge, options.contract, deadline, media);
544
+ }
545
+ else if (node.type === 'avg' && (edge.trigger.type === 'complete' || edge.default)) {
546
+ await completeAvgPlayback(frame, edge.from, deadline);
386
547
  }
387
548
  else if (node.type === 'web' && (edge.trigger.type === 'message' || edge.default)) {
388
- await executeWebEdge(frame, node, edge, options.contract);
549
+ await executeWebEdge(frame, node, edge, options.contract, deadline);
389
550
  }
390
551
  else if (node.type === 'video' && edge.trigger.type === 'time') {
391
- await frame.locator('video').first().evaluate((element, time) => {
392
- element.currentTime = time + 0.05;
393
- element.dispatchEvent(new globalThis.Event('timeupdate', { bubbles: true }));
394
- }, Number(edge.trigger.value));
552
+ await driveVerifiedVideo(media, 'time', Number(edge.trigger.value));
395
553
  }
396
554
  else if (node.type === 'video' && edge.trigger.type === 'complete') {
397
- await frame.locator('video').first().evaluate((element) => {
398
- if (Number.isFinite(element.duration) && element.duration > 0)
399
- element.currentTime = Math.max(0, element.duration - 0.01);
400
- element.dispatchEvent(new globalThis.Event('timeupdate', { bubbles: true }));
401
- element.dispatchEvent(new globalThis.Event('ended', { bubbles: true }));
402
- });
555
+ await driveVerifiedVideo(media, 'complete');
403
556
  }
404
557
  else if (edge.trigger.type === 'enter') {
405
558
  // Start/enter transitions are produced by the runtime itself.
406
559
  }
407
560
  else if (node.type === 'image' && edge.trigger.type === 'complete') {
408
561
  const durationMs = (node.config?.slides || []).reduce((sum, slide) => sum + Number(slide.duration || 0) * 1000, 0);
409
- await frame.page().waitForTimeout(Math.min(durationMs + 250, options.limits.scenarioTimeoutMs / 2));
562
+ await frame.page().waitForTimeout(Math.min(durationMs + 250, remainingBudget(deadline)));
410
563
  }
411
564
  else {
412
565
  throw new PlaytestError('EDGE_DRIVER_UNSUPPORTED', `No real runtime driver for edge ${edge.id}`);
413
566
  }
414
- if (edge.actionType === 'end') {
567
+ if (sameNode) {
568
+ const segment = edge.action.segment;
569
+ const time = edge.actionType === 'loop' ? segment?.start ?? 0
570
+ : typeof edge.action.time === 'number' && edge.action.time >= 0 ? edge.action.time : 0;
571
+ const end = edge.actionType === 'loop' ? segment?.end ?? await media.element.evaluate((video) => video.duration) : undefined;
415
572
  try {
416
- await frame.waitForFunction(() => (globalThis.document.querySelector('[data-bv-ended]')?.getAttribute('data-bv-ended') === 'true'), undefined, { timeout: Math.min(10000, options.limits.scenarioTimeoutMs) });
573
+ await frame.waitForFunction(({ element, sequence, nodeId, type, time, end, source }) => {
574
+ if (!element?.isConnected || element.src !== source || element.currentSrc !== source || element.error
575
+ || element.getAttribute('data-bv-media-node-id') !== nodeId
576
+ || globalThis.document.querySelector('[data-bv-node-id]')?.getAttribute('data-bv-node-id') !== nodeId
577
+ || Number(element.getAttribute('data-bv-video-action-sequence') || 0) <= sequence)
578
+ return false;
579
+ const action = JSON.parse(element.getAttribute('data-bv-video-action') || 'null');
580
+ return action?.nodeId === nodeId && action.type === type && action.time === time && action.end === end
581
+ && Number.isFinite(action.position) && Math.abs(action.position - time) <= 0.5;
582
+ }, { element: media.element, sequence: beforeSequence, nodeId: edge.from, type: edge.actionType, time, end, source: media.source }, { timeout: remainingBudget(deadline) });
417
583
  }
418
584
  catch {
419
- throw new PlaytestError('ROUTE_TARGET_MISMATCH', `Edge ${edge.id} did not reach the ended state`);
585
+ throw new PlaytestError('SAME_NODE_EFFECT_NOT_OBSERVED', `No new matching runtime action receipt for ${edge.id}`);
420
586
  }
421
587
  }
422
- else if (edge.actionType === 'seek' && node.type === 'video' && Number.isFinite(Number(edge.action.time))) {
423
- await frame.waitForFunction((expectedTime) => {
424
- const video = globalThis.document.querySelector('video');
425
- return video && Math.abs(Number(video.currentTime) - Number(expectedTime)) < 0.5;
426
- }, Number(edge.action.time), { timeout: Math.min(10000, options.limits.scenarioTimeoutMs) });
588
+ else if (edge.actionType === 'end') {
589
+ try {
590
+ await frame.waitForFunction(() => (globalThis.document.querySelector('[data-bv-ended]')?.getAttribute('data-bv-ended') === 'true'), undefined, { timeout: remainingBudget(deadline) });
591
+ }
592
+ catch {
593
+ throw new PlaytestError('ROUTE_TARGET_MISMATCH', `Edge ${edge.id} did not reach the ended state`);
594
+ }
427
595
  }
428
596
  else {
429
- await waitForNode(frame, edge.to, Math.min(10000, options.limits.scenarioTimeoutMs));
597
+ await waitForNode(frame, edge.to, remainingBudget(deadline));
430
598
  }
431
599
  }
432
600
  async function createContext(browser, options, scenarioDir) {
@@ -478,8 +646,23 @@ async function runScenario(browser, scenario, options) {
478
646
  await context.addInitScript(() => {
479
647
  const browserWindow = globalThis;
480
648
  browserWindow.__bvPlaytestObservedMessages = [];
649
+ browserWindow.__bvPlaytestStartupExceptions = [];
650
+ const recordStartupException = (reason) => {
651
+ const error = reason instanceof Error ? reason : undefined;
652
+ const message = error?.message || reason?.message || String(reason);
653
+ const stack = error?.stack || reason?.stack;
654
+ browserWindow.__bvPlaytestStartupExceptions.push(stack ? { message, stack } : { message });
655
+ };
656
+ browserWindow.addEventListener('error', (event) => {
657
+ if (event?.error instanceof Error || event?.message)
658
+ recordStartupException(event.error || event.message);
659
+ }, true);
660
+ browserWindow.addEventListener('unhandledrejection', (event) => recordStartupException(event.reason));
481
661
  browserWindow.addEventListener('message', (event) => {
482
- browserWindow.__bvPlaytestObservedMessages.push({ data: event.data, origin: event.origin, at: Date.now() });
662
+ browserWindow.__bvPlaytestObservedMessages.push({
663
+ data: event.data, origin: event.origin, at: Date.now(),
664
+ sourceId: event.source ? browserWindow.__bvPlaytestWebSources?.get(event.source) : undefined,
665
+ });
483
666
  });
484
667
  });
485
668
  await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
@@ -493,25 +676,30 @@ async function runScenario(browser, scenario, options) {
493
676
  }
494
677
  let timeoutHandle;
495
678
  const scenarioDeadline = Date.now() + options.limits.scenarioTimeoutMs;
679
+ let playerFrame;
680
+ let playerHandshakeComplete = false;
681
+ let activeEdge;
496
682
  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);
683
+ 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
684
  });
499
685
  try {
500
686
  await Promise.race([(async () => {
501
687
  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
688
  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);
689
+ playerFrame = await waitForPlayerFrame(page, options.playerUrl, options.limits.scenarioTimeoutMs);
690
+ await assertPlayerProtocol(playerFrame, scenarioDeadline - Date.now());
691
+ playerHandshakeComplete = true;
505
692
  const entryNodeId = String(options.script.graph?.entryNodeId || '');
506
693
  const entryTimeout = scenarioDeadline - Date.now();
507
694
  if (entryTimeout <= 0)
508
695
  throw new PlaytestError('SCENARIO_TIMEOUT', `Scenario ${scenario.id} exceeded ${options.limits.scenarioTimeoutMs}ms`);
509
696
  await startPlayback(playerFrame, entryNodeId, String(getNode(options.script, entryNodeId).type || ''), entryTimeout);
510
697
  playerReady = true;
511
- await assertNodeMedia(playerFrame, getNode(options.script, entryNodeId));
698
+ await assertNodeMedia(playerFrame, getNode(options.script, entryNodeId), scenarioDeadline);
512
699
  visitedNodes.push(String(options.script.graph?.entryNodeId || ''));
513
700
  for (const edge of scenario.edges) {
514
- await executeEdge(playerFrame, edge, options);
701
+ activeEdge = edge;
702
+ await executeEdge(playerFrame, edge, options, scenarioDeadline);
515
703
  coveredEdges.push(edge.id);
516
704
  if (!edge.to.startsWith('@'))
517
705
  visitedNodes.push(edge.to);
@@ -519,8 +707,16 @@ async function runScenario(browser, scenario, options) {
519
707
  })(), scenarioTimeout]);
520
708
  }
521
709
  catch (error) {
522
- const failure = error instanceof PlaytestError ? error : new PlaytestError('SCENARIO_FAILED', error instanceof Error ? error.message : String(error));
523
- findings.push({ code: failure.code, message: failure.message, scenarioId: scenario.id, edgeId: scenario.targetEdgeId, details: failure.details });
710
+ let failure = error instanceof PlaytestError ? error : new PlaytestError('SCENARIO_FAILED', error instanceof Error ? error.message : String(error));
711
+ if (failure.code === 'SCENARIO_TIMEOUT' && playerFrame && !playerHandshakeComplete) {
712
+ const exceptions = await playerStartupExceptions(playerFrame);
713
+ if (exceptions.length)
714
+ failure = new PlaytestError('PLAYER_INIT_FAILED', 'Player raised an exception before the playtest handshake', { startupExceptions: exceptions });
715
+ else
716
+ failure = new PlaytestError('PLAYER_HANDSHAKE_TIMEOUT', 'Player did not expose the BV playtest protocol within the scenario budget');
717
+ }
718
+ findings.push({ code: failure.code, message: failure.message, scenarioId: scenario.id, edgeId: activeEdge?.id || scenario.targetEdgeId,
719
+ details: activeEdge ? { ...(failure.details || {}), targetEdgeId: scenario.targetEdgeId } : failure.details });
524
720
  const screenshotPath = path.join(scenarioDir, 'failure.png');
525
721
  await page.screenshot({ path: screenshotPath, fullPage: true }).catch(() => undefined);
526
722
  artifacts.screenshot = screenshotPath;
@@ -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') {
@@ -42,6 +46,9 @@ function conditionMatches(condition, variables, inventory) {
42
46
  function applyEffects(state, edge, targetNode) {
43
47
  const variables = { ...state.variables };
44
48
  const inventory = { items: { ...state.inventory.items }, cards: { ...state.inventory.cards } };
49
+ // Same-node replay/media actions do not rerun entry effects; rule effects still run.
50
+ if (edge.from === edge.to && ['loop', 'seek', 'segment', 'goto'].includes(edge.actionType))
51
+ targetNode = undefined;
45
52
  const variableActions = [...(edge.variableActions || []), ...(targetNode?.variableActions || [])];
46
53
  for (const action of variableActions) {
47
54
  const current = variables[action.variable];
@@ -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.5",
3
+ "version": "0.4.7",
4
4
  "description": "Published CLI for branch-video and AIHub agent APIs.",
5
5
  "type": "module",
6
6
  "bin": {