@mindscraft/branch-video-agent-cli 0.3.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -26
- package/dist/bin.js +21 -1
- package/dist/commands/playtest.d.ts +2 -0
- package/dist/commands/playtest.js +200 -0
- package/dist/index.js +3 -1
- package/dist/lib/autoUpdate.d.ts +26 -0
- package/dist/lib/autoUpdate.js +90 -0
- package/dist/lib/flags.js +1 -0
- package/dist/lib/http.d.ts +1 -0
- package/dist/lib/http.js +3 -0
- package/dist/playtest/contract.d.ts +2 -0
- package/dist/playtest/contract.js +119 -0
- package/dist/playtest/errors.d.ts +5 -0
- package/dist/playtest/errors.js +10 -0
- package/dist/playtest/fingerprint.d.ts +2 -0
- package/dist/playtest/fingerprint.js +46 -0
- package/dist/playtest/graph.d.ts +7 -0
- package/dist/playtest/graph.js +115 -0
- package/dist/playtest/report.d.ts +6 -0
- package/dist/playtest/report.js +34 -0
- package/dist/playtest/runner.d.ts +23 -0
- package/dist/playtest/runner.js +571 -0
- package/dist/playtest/scenarios.d.ts +2 -0
- package/dist/playtest/scenarios.js +130 -0
- package/dist/playtest/staticPreflight.d.ts +1 -0
- package/dist/playtest/staticPreflight.js +30 -0
- package/dist/playtest/types.d.ts +152 -0
- package/dist/playtest/types.js +1 -0
- package/package.json +4 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
const EMPTY_ASSETS = {
|
|
3
|
+
videos: {},
|
|
4
|
+
images: {},
|
|
5
|
+
audios: {},
|
|
6
|
+
speeches: {},
|
|
7
|
+
};
|
|
8
|
+
function isRecord(value) {
|
|
9
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
function canonicalize(value) {
|
|
12
|
+
if (Array.isArray(value)) {
|
|
13
|
+
return value.map((item) => canonicalize(item));
|
|
14
|
+
}
|
|
15
|
+
if (!isRecord(value))
|
|
16
|
+
return value;
|
|
17
|
+
return Object.fromEntries(Object.entries(value)
|
|
18
|
+
.filter(([, item]) => item !== undefined)
|
|
19
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
20
|
+
.map(([key, item]) => [key, canonicalize(item)]));
|
|
21
|
+
}
|
|
22
|
+
export function normalizeScriptForFingerprint(script) {
|
|
23
|
+
if (!isRecord(script))
|
|
24
|
+
return canonicalize(script);
|
|
25
|
+
const normalized = { ...script };
|
|
26
|
+
delete normalized.editorData;
|
|
27
|
+
normalized.assets = {
|
|
28
|
+
...EMPTY_ASSETS,
|
|
29
|
+
...(isRecord(script.assets) ? script.assets : {}),
|
|
30
|
+
};
|
|
31
|
+
normalized.resources = {
|
|
32
|
+
variables: [],
|
|
33
|
+
...(isRecord(script.resources) ? script.resources : {}),
|
|
34
|
+
};
|
|
35
|
+
if (isRecord(script.metadata)) {
|
|
36
|
+
const metadata = { ...script.metadata };
|
|
37
|
+
delete metadata.createdAt;
|
|
38
|
+
delete metadata.updatedAt;
|
|
39
|
+
normalized.metadata = metadata;
|
|
40
|
+
}
|
|
41
|
+
return canonicalize(normalized);
|
|
42
|
+
}
|
|
43
|
+
export function createScriptFingerprint(script) {
|
|
44
|
+
const canonicalJson = JSON.stringify(normalizeScriptForFingerprint(script));
|
|
45
|
+
return `sha256:${createHash('sha256').update(canonicalJson).digest('hex')}`;
|
|
46
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { PlaytestEdge } from './types.js';
|
|
2
|
+
export declare function extractPlaytestEdges(script: unknown): PlaytestEdge[];
|
|
3
|
+
export declare function getReachablePlaytestGraph(script: unknown): {
|
|
4
|
+
entryNodeId: string;
|
|
5
|
+
reachableNodeIds: string[];
|
|
6
|
+
reachableEdges: PlaytestEdge[];
|
|
7
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
function actionTarget(action, from) {
|
|
2
|
+
const candidate = action;
|
|
3
|
+
if (candidate?.type === 'goto' && typeof candidate.target === 'string')
|
|
4
|
+
return candidate.target;
|
|
5
|
+
if (candidate?.type === 'end')
|
|
6
|
+
return '@ended';
|
|
7
|
+
if (candidate?.type === 'restart' || candidate?.type === 'loop' || candidate?.type === 'seek' || candidate?.type === 'segment')
|
|
8
|
+
return from;
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
function addEdge(edges, from, owner, ownerId, trigger, action, isDefault = false, effects = {}) {
|
|
12
|
+
const actionRecord = action;
|
|
13
|
+
const to = actionTarget(action, from);
|
|
14
|
+
if (!to)
|
|
15
|
+
return;
|
|
16
|
+
const valuePart = trigger.value === undefined ? '' : `:${JSON.stringify(trigger.value)}`;
|
|
17
|
+
const defaultPart = isDefault ? ':default' : '';
|
|
18
|
+
edges.push({
|
|
19
|
+
id: `${from}:${owner}:${ownerId}:${trigger.type}${valuePart}${defaultPart}->${to}`,
|
|
20
|
+
from,
|
|
21
|
+
to,
|
|
22
|
+
owner,
|
|
23
|
+
ownerId,
|
|
24
|
+
trigger,
|
|
25
|
+
condition: effects.condition,
|
|
26
|
+
variableActions: effects.variableActions,
|
|
27
|
+
inventoryActions: effects.inventoryActions,
|
|
28
|
+
actionType: String(actionRecord?.type || ''),
|
|
29
|
+
action: { ...(actionRecord || {}) },
|
|
30
|
+
default: isDefault || undefined,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function addBranchConfigEdges(edges, from, owner, ownerId, config) {
|
|
34
|
+
for (const rule of config?.rules || []) {
|
|
35
|
+
const rawTrigger = rule.trigger || { type: 'unknown' };
|
|
36
|
+
const trigger = {
|
|
37
|
+
type: rawTrigger.type,
|
|
38
|
+
value: rawTrigger.value ?? rawTrigger.optionId ?? rawTrigger.optionIds,
|
|
39
|
+
};
|
|
40
|
+
addEdge(edges, from, owner, ownerId, trigger, rule.action, false, {
|
|
41
|
+
condition: rule.condition,
|
|
42
|
+
variableActions: rule.variableActions,
|
|
43
|
+
inventoryActions: rule.inventoryActions,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
addEdge(edges, from, owner, ownerId, { type: 'default' }, config?.defaultAction, true);
|
|
47
|
+
}
|
|
48
|
+
export function extractPlaytestEdges(script) {
|
|
49
|
+
const graph = script?.graph;
|
|
50
|
+
const nodes = graph?.nodes || {};
|
|
51
|
+
const edges = [];
|
|
52
|
+
for (const [nodeId, rawNode] of Object.entries(nodes)) {
|
|
53
|
+
const node = rawNode || {};
|
|
54
|
+
if (node.type === 'start')
|
|
55
|
+
addEdge(edges, nodeId, 'node', nodeId, { type: 'enter' }, node.config?.next);
|
|
56
|
+
if (node.type === 'video' || node.type === 'image') {
|
|
57
|
+
addEdge(edges, nodeId, 'node', nodeId, { type: 'complete' }, node.config?.onComplete);
|
|
58
|
+
}
|
|
59
|
+
addBranchConfigEdges(edges, nodeId, 'node', nodeId, node.branchConfig);
|
|
60
|
+
for (const handler of node.config?.messageHandlers || []) {
|
|
61
|
+
addEdge(edges, nodeId, 'node', nodeId, handler.when || { type: 'message' }, handler.then, false, {
|
|
62
|
+
variableActions: handler.variableActions,
|
|
63
|
+
inventoryActions: handler.inventoryActions,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
for (const legacy of node.branches || []) {
|
|
67
|
+
addEdge(edges, nodeId, 'node', nodeId, { type: 'legacy', value: legacy.when }, legacy.then, false, {
|
|
68
|
+
condition: typeof legacy.when === 'object' ? legacy.when : undefined,
|
|
69
|
+
variableActions: legacy.variableActions,
|
|
70
|
+
inventoryActions: legacy.inventoryActions,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
for (const interaction of node.interactions || []) {
|
|
74
|
+
addBranchConfigEdges(edges, nodeId, 'interaction', interaction.id, interaction.branchConfig);
|
|
75
|
+
for (const legacy of interaction.branches || []) {
|
|
76
|
+
addEdge(edges, nodeId, 'interaction', interaction.id, { type: 'legacy', value: legacy.when }, legacy.then, false, {
|
|
77
|
+
condition: typeof legacy.when === 'object' ? legacy.when : undefined,
|
|
78
|
+
variableActions: legacy.variableActions,
|
|
79
|
+
inventoryActions: legacy.inventoryActions,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
addEdge(edges, nodeId, 'interaction', interaction.id, { type: 'timeout' }, interaction.fallback?.onTimeout);
|
|
83
|
+
addEdge(edges, nodeId, 'interaction', interaction.id, { type: 'default' }, interaction.fallback?.onDefault, true);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (const edge of edges) {
|
|
87
|
+
if (edge.actionType !== 'restart')
|
|
88
|
+
continue;
|
|
89
|
+
const oldTarget = edge.to;
|
|
90
|
+
edge.to = String(graph?.entryNodeId || edge.from);
|
|
91
|
+
edge.id = edge.id.replace(`->${oldTarget}`, `->${edge.to}`);
|
|
92
|
+
}
|
|
93
|
+
return [...new Map(edges.map((edge) => [edge.id, edge])).values()];
|
|
94
|
+
}
|
|
95
|
+
export function getReachablePlaytestGraph(script) {
|
|
96
|
+
const graph = script?.graph;
|
|
97
|
+
const nodes = graph?.nodes || {};
|
|
98
|
+
const entryNodeId = String(graph?.entryNodeId || '');
|
|
99
|
+
const allEdges = extractPlaytestEdges(script);
|
|
100
|
+
const reachableNodeIds = new Set();
|
|
101
|
+
const reachableEdges = [];
|
|
102
|
+
const queue = entryNodeId ? [entryNodeId] : [];
|
|
103
|
+
while (queue.length) {
|
|
104
|
+
const nodeId = queue.shift();
|
|
105
|
+
if (reachableNodeIds.has(nodeId))
|
|
106
|
+
continue;
|
|
107
|
+
reachableNodeIds.add(nodeId);
|
|
108
|
+
for (const edge of allEdges.filter((candidate) => candidate.from === nodeId)) {
|
|
109
|
+
reachableEdges.push(edge);
|
|
110
|
+
if (nodes[edge.to] && !reachableNodeIds.has(edge.to))
|
|
111
|
+
queue.push(edge.to);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { entryNodeId, reachableNodeIds: [...reachableNodeIds], reachableEdges };
|
|
115
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PlaytestReport } from './types.js';
|
|
2
|
+
export declare function createJUnitXml(report: PlaytestReport): string;
|
|
3
|
+
export declare function writePlaytestReport(reportDir: string, report: PlaytestReport): Promise<{
|
|
4
|
+
jsonPath: string;
|
|
5
|
+
junitPath: string;
|
|
6
|
+
}>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
function escapeXml(value) {
|
|
4
|
+
return String(value)
|
|
5
|
+
.replaceAll('&', '&')
|
|
6
|
+
.replaceAll('<', '<')
|
|
7
|
+
.replaceAll('>', '>')
|
|
8
|
+
.replaceAll('"', '"')
|
|
9
|
+
.replaceAll("'", ''');
|
|
10
|
+
}
|
|
11
|
+
export function createJUnitXml(report) {
|
|
12
|
+
const scenarioFindingKeys = new Set(report.scenarios.flatMap((scenario) => scenario.findings.map((finding) => `${finding.code}\u0000${finding.message}`)));
|
|
13
|
+
const gateFindings = report.findings.filter((finding) => !scenarioFindingKeys.has(`${finding.code}\u0000${finding.message}`));
|
|
14
|
+
const failures = report.scenarios.filter((scenario) => scenario.status === 'failed').length + gateFindings.length;
|
|
15
|
+
const duration = report.scenarios.reduce((sum, scenario) => sum + scenario.durationMs, 0) / 1000;
|
|
16
|
+
const scenarioCases = report.scenarios.map((scenario) => {
|
|
17
|
+
const finding = scenario.findings[0];
|
|
18
|
+
const failure = finding
|
|
19
|
+
? `<failure type="${escapeXml(finding.code)}" message="${escapeXml(finding.message)}">${escapeXml(JSON.stringify(finding.details ?? null))}</failure>`
|
|
20
|
+
: '';
|
|
21
|
+
return `<testcase classname="branch-video-playtest" name="${escapeXml(scenario.id)}" time="${(scenario.durationMs / 1000).toFixed(3)}">${failure}</testcase>`;
|
|
22
|
+
}).join('');
|
|
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`;
|
|
26
|
+
}
|
|
27
|
+
export async function writePlaytestReport(reportDir, report) {
|
|
28
|
+
await mkdir(reportDir, { recursive: true });
|
|
29
|
+
const jsonPath = path.join(reportDir, 'branch-video-playtest-report.json');
|
|
30
|
+
const junitPath = path.join(reportDir, 'junit.xml');
|
|
31
|
+
await writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
32
|
+
await writeFile(junitPath, createJUnitXml(report), 'utf8');
|
|
33
|
+
return { jsonPath, junitPath };
|
|
34
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PlaytestContract, PlaytestLimits, PlaytestScenario, PlaytestScenarioResult } from './types.js';
|
|
2
|
+
type ScriptRecord = Record<string, any>;
|
|
3
|
+
interface RunnerOptions {
|
|
4
|
+
script: ScriptRecord;
|
|
5
|
+
contract: PlaytestContract;
|
|
6
|
+
scenarios: PlaytestScenario[];
|
|
7
|
+
playerUrl: string;
|
|
8
|
+
draftScriptUrl?: string;
|
|
9
|
+
reportDir: string;
|
|
10
|
+
concurrency: number;
|
|
11
|
+
limits: PlaytestLimits;
|
|
12
|
+
viewport: {
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export declare function runPlaytestScenarios(options: RunnerOptions): Promise<PlaytestScenarioResult[]>;
|
|
18
|
+
export declare function createDraftPlayerUrl(baseUrl: string): {
|
|
19
|
+
playerUrl: string;
|
|
20
|
+
scriptUrl: string;
|
|
21
|
+
};
|
|
22
|
+
export declare function createPublishedPlayerUrl(playUrl: string): string;
|
|
23
|
+
export {};
|