@lingjingai/scriptctl 0.20.0 → 0.22.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/dist/cli.js +105 -11
- package/dist/cli.js.map +1 -1
- package/dist/common.d.ts +1 -0
- package/dist/common.js.map +1 -1
- package/dist/domain/script/address.d.ts +30 -0
- package/dist/domain/script/address.js +141 -0
- package/dist/domain/script/address.js.map +1 -0
- package/dist/domain/script/lookups.d.ts +33 -0
- package/dist/domain/script/lookups.js +309 -0
- package/dist/domain/script/lookups.js.map +1 -0
- package/dist/domain/script/patch/apply.d.ts +4 -0
- package/dist/domain/script/patch/apply.js +38 -0
- package/dist/domain/script/patch/apply.js.map +1 -0
- package/dist/domain/script/patch/helpers.d.ts +11 -0
- package/dist/domain/script/patch/helpers.js +141 -0
- package/dist/domain/script/patch/helpers.js.map +1 -0
- package/dist/domain/script/patch/ops-action.d.ts +12 -0
- package/dist/domain/script/patch/ops-action.js +371 -0
- package/dist/domain/script/patch/ops-action.js.map +1 -0
- package/dist/domain/script/patch/ops-asset.d.ts +13 -0
- package/dist/domain/script/patch/ops-asset.js +363 -0
- package/dist/domain/script/patch/ops-asset.js.map +1 -0
- package/dist/domain/script/patch/ops-dialogue.d.ts +5 -0
- package/dist/domain/script/patch/ops-dialogue.js +233 -0
- package/dist/domain/script/patch/ops-dialogue.js.map +1 -0
- package/dist/domain/script/patch/ops-meta.d.ts +3 -0
- package/dist/domain/script/patch/ops-meta.js +47 -0
- package/dist/domain/script/patch/ops-meta.js.map +1 -0
- package/dist/domain/script/patch/ops-scene.d.ts +6 -0
- package/dist/domain/script/patch/ops-scene.js +345 -0
- package/dist/domain/script/patch/ops-scene.js.map +1 -0
- package/dist/domain/script/patch/ops-state.d.ts +8 -0
- package/dist/domain/script/patch/ops-state.js +146 -0
- package/dist/domain/script/patch/ops-state.js.map +1 -0
- package/dist/domain/script/patch/registry.d.ts +2 -0
- package/dist/domain/script/patch/registry.js +50 -0
- package/dist/domain/script/patch/registry.js.map +1 -0
- package/dist/domain/script/patch/types.d.ts +10 -0
- package/dist/domain/script/patch/types.js +2 -0
- package/dist/domain/script/patch/types.js.map +1 -0
- package/dist/domain/script/refs.d.ts +4 -0
- package/dist/domain/script/refs.js +152 -0
- package/dist/domain/script/refs.js.map +1 -0
- package/dist/domain/script/schema.d.ts +6 -0
- package/dist/domain/script/schema.js +53 -0
- package/dist/domain/script/schema.js.map +1 -0
- package/dist/domain/script/selector.d.ts +45 -0
- package/dist/domain/script/selector.js +187 -0
- package/dist/domain/script/selector.js.map +1 -0
- package/dist/domain/script/shared.d.ts +15 -0
- package/dist/domain/script/shared.js +37 -0
- package/dist/domain/script/shared.js.map +1 -0
- package/dist/domain/script/validate.d.ts +22 -0
- package/dist/domain/script/validate.js +844 -0
- package/dist/domain/script/validate.js.map +1 -0
- package/dist/help-text.js +33 -8
- package/dist/help-text.js.map +1 -1
- package/dist/infra/script-output-store.d.ts +46 -10
- package/dist/infra/script-output-store.js +83 -11
- package/dist/infra/script-output-store.js.map +1 -1
- package/dist/output.d.ts +1 -1
- package/dist/output.js +11 -1
- package/dist/output.js.map +1 -1
- package/dist/usecases/direct.js +1 -1
- package/dist/usecases/direct.js.map +1 -1
- package/dist/usecases/doctor.js +32 -22
- package/dist/usecases/doctor.js.map +1 -1
- package/dist/usecases/parse.js +10 -3
- package/dist/usecases/parse.js.map +1 -1
- package/dist/usecases/script/actions.d.ts +3 -0
- package/dist/usecases/script/actions.js +71 -0
- package/dist/usecases/script/actions.js.map +1 -0
- package/dist/usecases/script/actor.d.ts +3 -0
- package/dist/usecases/script/actor.js +27 -0
- package/dist/usecases/script/actor.js.map +1 -0
- package/dist/usecases/script/actors.d.ts +3 -0
- package/dist/usecases/script/actors.js +15 -0
- package/dist/usecases/script/actors.js.map +1 -0
- package/dist/usecases/script/add-actor.d.ts +3 -0
- package/dist/usecases/script/add-actor.js +13 -0
- package/dist/usecases/script/add-actor.js.map +1 -0
- package/dist/usecases/script/add-episode.d.ts +3 -0
- package/dist/usecases/script/add-episode.js +9 -0
- package/dist/usecases/script/add-episode.js.map +1 -0
- package/dist/usecases/script/add-location.d.ts +3 -0
- package/dist/usecases/script/add-location.js +11 -0
- package/dist/usecases/script/add-location.js.map +1 -0
- package/dist/usecases/script/add-prop.d.ts +3 -0
- package/dist/usecases/script/add-prop.js +11 -0
- package/dist/usecases/script/add-prop.js.map +1 -0
- package/dist/usecases/script/add-speaker.d.ts +3 -0
- package/dist/usecases/script/add-speaker.js +22 -0
- package/dist/usecases/script/add-speaker.js.map +1 -0
- package/dist/usecases/script/alias.d.ts +3 -0
- package/dist/usecases/script/alias.js +40 -0
- package/dist/usecases/script/alias.js.map +1 -0
- package/dist/usecases/script/assets.d.ts +3 -0
- package/dist/usecases/script/assets.js +21 -0
- package/dist/usecases/script/assets.js.map +1 -0
- package/dist/usecases/script/context.d.ts +3 -0
- package/dist/usecases/script/context.js +20 -0
- package/dist/usecases/script/context.js.map +1 -0
- package/dist/usecases/script/create.d.ts +3 -0
- package/dist/usecases/script/create.js +67 -0
- package/dist/usecases/script/create.js.map +1 -0
- package/dist/usecases/script/delete.d.ts +3 -0
- package/dist/usecases/script/delete.js +79 -0
- package/dist/usecases/script/delete.js.map +1 -0
- package/dist/usecases/script/describe.d.ts +3 -0
- package/dist/usecases/script/describe.js +31 -0
- package/dist/usecases/script/describe.js.map +1 -0
- package/dist/usecases/script/dialogue.d.ts +3 -0
- package/dist/usecases/script/dialogue.js +28 -0
- package/dist/usecases/script/dialogue.js.map +1 -0
- package/dist/usecases/script/do.d.ts +3 -0
- package/dist/usecases/script/do.js +130 -0
- package/dist/usecases/script/do.js.map +1 -0
- package/dist/usecases/script/episodes.d.ts +3 -0
- package/dist/usecases/script/episodes.js +36 -0
- package/dist/usecases/script/episodes.js.map +1 -0
- package/dist/usecases/script/export.d.ts +3 -0
- package/dist/usecases/script/export.js +117 -0
- package/dist/usecases/script/export.js.map +1 -0
- package/dist/usecases/script/insert.d.ts +3 -0
- package/dist/usecases/script/insert.js +87 -0
- package/dist/usecases/script/insert.js.map +1 -0
- package/dist/usecases/script/issues.d.ts +3 -0
- package/dist/usecases/script/issues.js +27 -0
- package/dist/usecases/script/issues.js.map +1 -0
- package/dist/usecases/script/lib.d.ts +77 -0
- package/dist/usecases/script/lib.js +849 -0
- package/dist/usecases/script/lib.js.map +1 -0
- package/dist/usecases/script/locations.d.ts +3 -0
- package/dist/usecases/script/locations.js +15 -0
- package/dist/usecases/script/locations.js.map +1 -0
- package/dist/usecases/script/merge.d.ts +3 -0
- package/dist/usecases/script/merge.js +43 -0
- package/dist/usecases/script/merge.js.map +1 -0
- package/dist/usecases/script/move.d.ts +3 -0
- package/dist/usecases/script/move.js +33 -0
- package/dist/usecases/script/move.js.map +1 -0
- package/dist/usecases/script/overlap.d.ts +3 -0
- package/dist/usecases/script/overlap.js +21 -0
- package/dist/usecases/script/overlap.js.map +1 -0
- package/dist/usecases/script/props.d.ts +3 -0
- package/dist/usecases/script/props.js +15 -0
- package/dist/usecases/script/props.js.map +1 -0
- package/dist/usecases/script/refs.d.ts +3 -0
- package/dist/usecases/script/refs.js +61 -0
- package/dist/usecases/script/refs.js.map +1 -0
- package/dist/usecases/script/rename.d.ts +3 -0
- package/dist/usecases/script/rename.js +17 -0
- package/dist/usecases/script/rename.js.map +1 -0
- package/dist/usecases/script/replace.d.ts +3 -0
- package/dist/usecases/script/replace.js +35 -0
- package/dist/usecases/script/replace.js.map +1 -0
- package/dist/usecases/script/role.d.ts +3 -0
- package/dist/usecases/script/role.js +17 -0
- package/dist/usecases/script/role.js.map +1 -0
- package/dist/usecases/script/scenes.d.ts +3 -0
- package/dist/usecases/script/scenes.js +45 -0
- package/dist/usecases/script/scenes.js.map +1 -0
- package/dist/usecases/script/script-patch.d.ts +3 -0
- package/dist/usecases/script/script-patch.js +187 -0
- package/dist/usecases/script/script-patch.js.map +1 -0
- package/dist/usecases/script/script-validate.d.ts +3 -0
- package/dist/usecases/script/script-validate.js +30 -0
- package/dist/usecases/script/script-validate.js.map +1 -0
- package/dist/usecases/script/session.d.ts +56 -0
- package/dist/usecases/script/session.js +568 -0
- package/dist/usecases/script/session.js.map +1 -0
- package/dist/usecases/script/speakers.d.ts +3 -0
- package/dist/usecases/script/speakers.js +28 -0
- package/dist/usecases/script/speakers.js.map +1 -0
- package/dist/usecases/script/split.d.ts +3 -0
- package/dist/usecases/script/split.js +37 -0
- package/dist/usecases/script/split.js.map +1 -0
- package/dist/usecases/script/state-add.d.ts +3 -0
- package/dist/usecases/script/state-add.js +23 -0
- package/dist/usecases/script/state-add.js.map +1 -0
- package/dist/usecases/script/state-change.d.ts +3 -0
- package/dist/usecases/script/state-change.js +24 -0
- package/dist/usecases/script/state-change.js.map +1 -0
- package/dist/usecases/script/state-delete.d.ts +3 -0
- package/dist/usecases/script/state-delete.js +23 -0
- package/dist/usecases/script/state-delete.js.map +1 -0
- package/dist/usecases/script/state-rename.d.ts +3 -0
- package/dist/usecases/script/state-rename.js +17 -0
- package/dist/usecases/script/state-rename.js.map +1 -0
- package/dist/usecases/script/summary.d.ts +3 -0
- package/dist/usecases/script/summary.js +45 -0
- package/dist/usecases/script/summary.js.map +1 -0
- package/dist/usecases/script/transition.d.ts +3 -0
- package/dist/usecases/script/transition.js +24 -0
- package/dist/usecases/script/transition.js.map +1 -0
- package/dist/usecases/script/type.d.ts +3 -0
- package/dist/usecases/script/type.js +24 -0
- package/dist/usecases/script/type.js.map +1 -0
- package/dist/usecases/script/worldview.d.ts +3 -0
- package/dist/usecases/script/worldview.js +18 -0
- package/dist/usecases/script/worldview.js.map +1 -0
- package/package.json +1 -1
- package/dist/domain/script-core.d.ts +0 -78
- package/dist/domain/script-core.js +0 -3026
- package/dist/domain/script-core.js.map +0 -1
- package/dist/usecases/script.d.ts +0 -67
- package/dist/usecases/script.js +0 -2348
- package/dist/usecases/script.js.map +0 -1
|
@@ -1,3026 +0,0 @@
|
|
|
1
|
-
import { ACTION_TYPE_VALUES, CliError, DELIVERY_VALUES, EFFECTIVE_FROM_VALUES, EXIT_INPUT, EXIT_USAGE, ROLE_TYPE_VALUES, SCRIPT_SCHEMA_VERSION, SCRIPT_TARGET_KINDS, SPEAKER_SOURCE_KINDS, WORLDVIEW_VALUES, directDir, exists, fmtId, readJson, readText, writeJson, } from "../common.js";
|
|
2
|
-
import { cleanName, contentHasSpeakerPrefix, inferNonActorSpeakerKind, isTechnicalEpisodeTitle, sceneContext, setSceneContext, stateLabelError, stateRejectionReason, } from "./direct-core.js";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
function strOf(v) {
|
|
5
|
-
if (v === null || v === undefined)
|
|
6
|
-
return "";
|
|
7
|
-
return String(v);
|
|
8
|
-
}
|
|
9
|
-
function isDict(v) {
|
|
10
|
-
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
11
|
-
}
|
|
12
|
-
function isList(v) {
|
|
13
|
-
return Array.isArray(v);
|
|
14
|
-
}
|
|
15
|
-
function asList(v) {
|
|
16
|
-
return Array.isArray(v) ? v : [];
|
|
17
|
-
}
|
|
18
|
-
// ---------------------------------------------------------------------------
|
|
19
|
-
// Find helpers
|
|
20
|
-
// ---------------------------------------------------------------------------
|
|
21
|
-
export function findAction(script, epId, sceneId, actionIndex) {
|
|
22
|
-
for (const ep of asList(script["episodes"])) {
|
|
23
|
-
if (ep["episode_id"] !== epId)
|
|
24
|
-
continue;
|
|
25
|
-
for (const scene of asList(ep["scenes"])) {
|
|
26
|
-
if (scene["scene_id"] === sceneId) {
|
|
27
|
-
const actions = asList(scene["actions"]);
|
|
28
|
-
if (actionIndex >= 0 && actionIndex < actions.length)
|
|
29
|
-
return actions[actionIndex];
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
throw new CliError("PATCH BLOCKED: Action not found", "Action not found.", {
|
|
34
|
-
exitCode: EXIT_USAGE,
|
|
35
|
-
required: ["existing episode_id, scene_id, action_index"],
|
|
36
|
-
received: [`${epId} ${sceneId} ${actionIndex}`],
|
|
37
|
-
nextSteps: ["Inspect episodes and fix the patch."],
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
export function findEpisode(script, epId) {
|
|
41
|
-
for (const ep of asList(script["episodes"])) {
|
|
42
|
-
if (ep["episode_id"] === epId)
|
|
43
|
-
return ep;
|
|
44
|
-
}
|
|
45
|
-
throw new CliError("PATCH BLOCKED: Episode not found", "Episode not found.", {
|
|
46
|
-
exitCode: EXIT_USAGE,
|
|
47
|
-
required: ["existing episode_id"],
|
|
48
|
-
received: [epId],
|
|
49
|
-
nextSteps: ["Inspect episodes and fix the patch."],
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
export function findScene(script, epId, sceneId) {
|
|
53
|
-
const ep = findEpisode(script, epId);
|
|
54
|
-
for (const scene of asList(ep["scenes"])) {
|
|
55
|
-
if (scene["scene_id"] === sceneId)
|
|
56
|
-
return scene;
|
|
57
|
-
}
|
|
58
|
-
throw new CliError("PATCH BLOCKED: Scene not found", "Scene not found.", {
|
|
59
|
-
exitCode: EXIT_USAGE,
|
|
60
|
-
required: ["existing episode_id and scene_id"],
|
|
61
|
-
received: [`${epId} ${sceneId}`],
|
|
62
|
-
nextSteps: ["Inspect episodes and fix the patch."],
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
// ---------------------------------------------------------------------------
|
|
66
|
-
// blank scaffold
|
|
67
|
-
// ---------------------------------------------------------------------------
|
|
68
|
-
/**
|
|
69
|
-
* Minimal schema-v2 script skeleton: every required top-level field present,
|
|
70
|
-
* all collections empty. It is the starting point for building a script
|
|
71
|
-
* bottom-up with the atomic add/insert verbs instead of assembling it from
|
|
72
|
-
* source material (direct) or md (parse). `worldview`/`style`/`title` are
|
|
73
|
-
* present-but-empty so the field-presence checks pass; they get filled in
|
|
74
|
-
* later via `worldview` / `patch`. A blank script validates clean: NO_EPISODES
|
|
75
|
-
* is a completeness warning (`incomplete_ok`), not a blocker.
|
|
76
|
-
*/
|
|
77
|
-
export function blankScript(title = "") {
|
|
78
|
-
return {
|
|
79
|
-
version: SCRIPT_SCHEMA_VERSION,
|
|
80
|
-
title: title,
|
|
81
|
-
worldview: "",
|
|
82
|
-
style: "",
|
|
83
|
-
actors: [],
|
|
84
|
-
locations: [],
|
|
85
|
-
props: [],
|
|
86
|
-
speakers: [],
|
|
87
|
-
episodes: [],
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
// Completeness vs. integrity. A script can be structurally sound but unfinished
|
|
91
|
-
// (no episodes yet, an empty episode/scene, a scene without a location, an asset
|
|
92
|
-
// without a description). Those gaps are emitted as `severity: "warning"` with
|
|
93
|
-
// `incomplete_ok: true` directly at their source below — they never block, so an
|
|
94
|
-
// unfinished-but-sound script is editable AND publishable. Only integrity
|
|
95
|
-
// violations (dangling refs, duplicate ids, invalid enums, missing ids/names —
|
|
96
|
-
// things that would corrupt the normalized DB graph) are `severity: "error"`.
|
|
97
|
-
// One SCHEMA_ENTITY_FIELD_MISSING issue for a missing asset field. A missing
|
|
98
|
-
// description is a completeness gap (warning); a missing id/name is an identity
|
|
99
|
-
// error that would dangle in the DB graph. Shared by the actor/location/prop
|
|
100
|
-
// validation loops so the severity/incomplete_ok/repair-hint policy lives once.
|
|
101
|
-
function entityFieldMissingIssue(kindLabel, field, idValue) {
|
|
102
|
-
const isDescription = field === "description";
|
|
103
|
-
return {
|
|
104
|
-
code: "SCHEMA_ENTITY_FIELD_MISSING",
|
|
105
|
-
severity: isDescription ? "warning" : "error",
|
|
106
|
-
...(isDescription ? { incomplete_ok: true } : {}),
|
|
107
|
-
summary: `${kindLabel} missing ${field}: ${idValue}`,
|
|
108
|
-
repair_hint: isDescription
|
|
109
|
-
? "Describe with `describe` when ready (an undescribed asset is publishable)."
|
|
110
|
-
: `Patch ${kindLabel.toLowerCase()} metadata.`,
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
// ---------------------------------------------------------------------------
|
|
114
|
-
// validate_script
|
|
115
|
-
// ---------------------------------------------------------------------------
|
|
116
|
-
/**
|
|
117
|
-
* Validate a script's schema and asset wiring.
|
|
118
|
-
*
|
|
119
|
-
* Pass either:
|
|
120
|
-
* - `scriptPath: string` (or null → default to direct's script.initial.json), or
|
|
121
|
-
* - `opts.scriptData: Dict` to validate an in-memory object without writing to disk.
|
|
122
|
-
*
|
|
123
|
-
* Callers that already have the script as a parsed object (episode draft / publish
|
|
124
|
-
* after assembly) should prefer `scriptData` to avoid the write-validate-rm dance.
|
|
125
|
-
*
|
|
126
|
-
* Completeness issues (no episodes, empty episode/scene, scene without location,
|
|
127
|
-
* missing asset description) are emitted as warnings (`incomplete_ok`), not
|
|
128
|
-
* errors — an unfinished but structurally-sound script passes. Only integrity
|
|
129
|
-
* violations (dangling refs, dup ids, invalid enums, missing ids/names) fail it.
|
|
130
|
-
*/
|
|
131
|
-
export function validateScript(workspace, scriptPath = null, opts = {}) {
|
|
132
|
-
const requireSource = opts.requireSource ?? true;
|
|
133
|
-
const dd = directDir(workspace);
|
|
134
|
-
const sourcePath = path.join(workspace, "source.txt");
|
|
135
|
-
if (requireSource && !exists(sourcePath)) {
|
|
136
|
-
throw new CliError("VALIDATE BLOCKED: source.txt not found", "source.txt not found.", {
|
|
137
|
-
exitCode: EXIT_INPUT,
|
|
138
|
-
required: ["workspace/source.txt"],
|
|
139
|
-
received: [sourcePath],
|
|
140
|
-
nextSteps: ["Run scriptctl direct init first."],
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
if (exists(sourcePath))
|
|
144
|
-
readText(sourcePath); // touch, mirror Python
|
|
145
|
-
let script;
|
|
146
|
-
if (opts.scriptData !== undefined) {
|
|
147
|
-
script = opts.scriptData;
|
|
148
|
-
}
|
|
149
|
-
else {
|
|
150
|
-
const finalScriptPath = scriptPath ?? path.join(dd, "script.initial.json");
|
|
151
|
-
if (!exists(finalScriptPath)) {
|
|
152
|
-
throw new CliError("VALIDATE BLOCKED: script not found", "Script JSON not found.", {
|
|
153
|
-
exitCode: EXIT_INPUT,
|
|
154
|
-
required: ["script.initial.json or --script-path"],
|
|
155
|
-
received: [finalScriptPath],
|
|
156
|
-
nextSteps: ["Run scriptctl direct init first."],
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
try {
|
|
160
|
-
script = readJson(finalScriptPath);
|
|
161
|
-
}
|
|
162
|
-
catch (exc) {
|
|
163
|
-
throw new CliError("VALIDATE BLOCKED: script JSON invalid", "Script JSON invalid.", {
|
|
164
|
-
exitCode: EXIT_INPUT,
|
|
165
|
-
required: ["valid script JSON"],
|
|
166
|
-
received: [`${finalScriptPath}: ${exc.message}`],
|
|
167
|
-
nextSteps: ["Fix script.initial.json or rerun init."],
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
const issues = [];
|
|
172
|
-
for (const field of ["version", "title", "worldview", "style", "actors", "locations", "props", "speakers", "episodes"]) {
|
|
173
|
-
if (!(field in script)) {
|
|
174
|
-
issues.push({
|
|
175
|
-
code: "SCHEMA_TOP_FIELD_MISSING",
|
|
176
|
-
severity: "error",
|
|
177
|
-
summary: `Top-level field missing: ${field}`,
|
|
178
|
-
repair_hint: "Patch script.json or rerun init.",
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
if (script["version"] !== SCRIPT_SCHEMA_VERSION && script["version"] !== String(SCRIPT_SCHEMA_VERSION)) {
|
|
183
|
-
issues.push({
|
|
184
|
-
code: "SCHEMA_VERSION_UNSUPPORTED",
|
|
185
|
-
severity: "warning",
|
|
186
|
-
summary: `script version should be ${SCRIPT_SCHEMA_VERSION}.`,
|
|
187
|
-
repair_hint: "Update the internal numeric version when migrating script.json.",
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
const actorIds = new Set();
|
|
191
|
-
for (const item of asList(script["actors"])) {
|
|
192
|
-
const id = strOf(item["actor_id"]);
|
|
193
|
-
if (id)
|
|
194
|
-
actorIds.add(id);
|
|
195
|
-
}
|
|
196
|
-
const locationIds = new Set();
|
|
197
|
-
for (const item of asList(script["locations"])) {
|
|
198
|
-
const id = strOf(item["location_id"]);
|
|
199
|
-
if (id)
|
|
200
|
-
locationIds.add(id);
|
|
201
|
-
}
|
|
202
|
-
const propIds = new Set();
|
|
203
|
-
for (const item of asList(script["props"])) {
|
|
204
|
-
const id = strOf(item["prop_id"]);
|
|
205
|
-
if (id)
|
|
206
|
-
propIds.add(id);
|
|
207
|
-
}
|
|
208
|
-
const speakerDisplayNames = new Set();
|
|
209
|
-
for (const item of asList(script["speakers"])) {
|
|
210
|
-
if (!isDict(item))
|
|
211
|
-
continue;
|
|
212
|
-
const name = strOf(item["display_name"]).trim();
|
|
213
|
-
if (name)
|
|
214
|
-
speakerDisplayNames.add(cleanName(name));
|
|
215
|
-
}
|
|
216
|
-
const actors = asList(script["actors"]);
|
|
217
|
-
for (let idx = 0; idx < actors.length; idx++) {
|
|
218
|
-
const actor = actors[idx];
|
|
219
|
-
if (!isDict(actor)) {
|
|
220
|
-
issues.push({
|
|
221
|
-
code: "SCHEMA_ENTITY_FIELD_MISSING",
|
|
222
|
-
severity: "error",
|
|
223
|
-
summary: `actors[${idx}] is not an object.`,
|
|
224
|
-
repair_hint: "Patch actor entry.",
|
|
225
|
-
});
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
228
|
-
const actorName = strOf(actor["actor_name"]).trim();
|
|
229
|
-
for (const field of ["actor_id", "actor_name", "description"]) {
|
|
230
|
-
if (!strOf(actor[field]).trim()) {
|
|
231
|
-
issues.push(entityFieldMissingIssue("Actor", field, strOf(actor["actor_id"]) || String(idx)));
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
if (actorName && cleanName(actorName) !== actorName) {
|
|
235
|
-
issues.push({
|
|
236
|
-
code: "ASSET_NAME_HAS_STATE_ANNOTATION",
|
|
237
|
-
severity: "error",
|
|
238
|
-
summary: `Actor name contains state annotation: ${actorName}`,
|
|
239
|
-
repair_hint: "Keep actor_name canonical and move state text to states[] or action content only if reusable.",
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
if (actorName && inferNonActorSpeakerKind(actorName) !== "actor") {
|
|
243
|
-
issues.push({
|
|
244
|
-
code: "NONHUMAN_AS_ACTOR",
|
|
245
|
-
severity: "error",
|
|
246
|
-
summary: `Non-human speaker is registered as actor: ${actorName}`,
|
|
247
|
-
repair_hint: "Use speakers[] with source_kind system/broadcast/prop/group/other; do not create an actor.",
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
if (actor["role_type"] && !ROLE_TYPE_VALUES.includes(strOf(actor["role_type"]))) {
|
|
251
|
-
issues.push({
|
|
252
|
-
code: "ENUM_ROLE_TYPE",
|
|
253
|
-
severity: "error",
|
|
254
|
-
summary: `Actor role_type invalid: ${actor["role_type"]}`,
|
|
255
|
-
repair_hint: "Patch role_type to 主角 or 配角.",
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
const locations = asList(script["locations"]);
|
|
260
|
-
for (let idx = 0; idx < locations.length; idx++) {
|
|
261
|
-
const loc = locations[idx];
|
|
262
|
-
if (!isDict(loc)) {
|
|
263
|
-
issues.push({
|
|
264
|
-
code: "SCHEMA_ENTITY_FIELD_MISSING",
|
|
265
|
-
severity: "error",
|
|
266
|
-
summary: `locations[${idx}] is not an object.`,
|
|
267
|
-
repair_hint: "Patch location entry.",
|
|
268
|
-
});
|
|
269
|
-
continue;
|
|
270
|
-
}
|
|
271
|
-
const locationName = strOf(loc["location_name"]).trim();
|
|
272
|
-
for (const field of ["location_id", "location_name", "description"]) {
|
|
273
|
-
if (!strOf(loc[field]).trim()) {
|
|
274
|
-
issues.push(entityFieldMissingIssue("Location", field, strOf(loc["location_id"]) || String(idx)));
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
if (locationName && cleanName(locationName) !== locationName) {
|
|
278
|
-
issues.push({
|
|
279
|
-
code: "ASSET_NAME_HAS_STATE_ANNOTATION",
|
|
280
|
-
severity: "error",
|
|
281
|
-
summary: `Location name contains state annotation: ${locationName}`,
|
|
282
|
-
repair_hint: "Keep location_name canonical and move durable visual variants to states[] only when reusable.",
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
const propsArr = asList(script["props"]);
|
|
287
|
-
for (let idx = 0; idx < propsArr.length; idx++) {
|
|
288
|
-
const prop = propsArr[idx];
|
|
289
|
-
if (!isDict(prop)) {
|
|
290
|
-
issues.push({
|
|
291
|
-
code: "SCHEMA_ENTITY_FIELD_MISSING",
|
|
292
|
-
severity: "error",
|
|
293
|
-
summary: `props[${idx}] is not an object.`,
|
|
294
|
-
repair_hint: "Patch prop entry.",
|
|
295
|
-
});
|
|
296
|
-
continue;
|
|
297
|
-
}
|
|
298
|
-
const propName = strOf(prop["prop_name"]).trim();
|
|
299
|
-
for (const field of ["prop_id", "prop_name", "description"]) {
|
|
300
|
-
if (!strOf(prop[field]).trim()) {
|
|
301
|
-
issues.push(entityFieldMissingIssue("Prop", field, strOf(prop["prop_id"]) || String(idx)));
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
if (propName && cleanName(propName) !== propName) {
|
|
305
|
-
issues.push({
|
|
306
|
-
code: "ASSET_NAME_HAS_STATE_ANNOTATION",
|
|
307
|
-
severity: "error",
|
|
308
|
-
summary: `Prop name contains state annotation: ${propName}`,
|
|
309
|
-
repair_hint: "Keep prop_name canonical and move durable visual variants to states[] only when reusable.",
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
const stateIdsByAsset = new Map();
|
|
314
|
-
for (const [collection, idKey] of [["actors", "actor_id"], ["locations", "location_id"], ["props", "prop_id"]]) {
|
|
315
|
-
const targetKind = { actors: "actor", locations: "location", props: "prop" }[collection];
|
|
316
|
-
for (const asset of asList(script[collection])) {
|
|
317
|
-
const assetId = strOf(asset[idKey]);
|
|
318
|
-
const rawStates = asset["states"];
|
|
319
|
-
stateIdsByAsset.set(assetId, new Set());
|
|
320
|
-
const seenStateNames = new Set();
|
|
321
|
-
// Mirror Python `asset.get("states", []) or []`: absent/null → empty list (no issue)
|
|
322
|
-
if (rawStates !== undefined && rawStates !== null && !isList(rawStates)) {
|
|
323
|
-
issues.push({
|
|
324
|
-
code: "SCHEMA_STATE_LIST_INVALID",
|
|
325
|
-
severity: "error",
|
|
326
|
-
summary: `${collection} ${assetId} states must be an array.`,
|
|
327
|
-
repair_hint: "Patch states to an array.",
|
|
328
|
-
});
|
|
329
|
-
continue;
|
|
330
|
-
}
|
|
331
|
-
const states = isList(rawStates) ? rawStates : [];
|
|
332
|
-
for (const state of states) {
|
|
333
|
-
if (!isDict(state)) {
|
|
334
|
-
issues.push({
|
|
335
|
-
code: "SCHEMA_STATE_INVALID",
|
|
336
|
-
severity: "error",
|
|
337
|
-
summary: `${collection} ${assetId} contains a non-object state.`,
|
|
338
|
-
repair_hint: "Patch or remove the invalid state.",
|
|
339
|
-
});
|
|
340
|
-
continue;
|
|
341
|
-
}
|
|
342
|
-
const sid = strOf(state["state_id"]);
|
|
343
|
-
const sname = strOf(state["state_name"] || state["name"]);
|
|
344
|
-
if (!sid || !sname) {
|
|
345
|
-
issues.push({
|
|
346
|
-
code: "SCHEMA_STATE_FIELD_MISSING",
|
|
347
|
-
severity: "error",
|
|
348
|
-
summary: `${collection} ${assetId} state missing state_id or state_name.`,
|
|
349
|
-
repair_hint: "Patch state_id/state_name.",
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
|
-
if (sid && stateIdsByAsset.get(assetId).has(sid)) {
|
|
353
|
-
issues.push({
|
|
354
|
-
code: "DUPLICATE_STATE_ID",
|
|
355
|
-
severity: "error",
|
|
356
|
-
summary: `Duplicate state_id in ${assetId}: ${sid}`,
|
|
357
|
-
repair_hint: "Rename or merge duplicate states.",
|
|
358
|
-
});
|
|
359
|
-
}
|
|
360
|
-
if (sname && seenStateNames.has(sname)) {
|
|
361
|
-
issues.push({
|
|
362
|
-
code: "DUPLICATE_STATE_NAME",
|
|
363
|
-
severity: "warning",
|
|
364
|
-
summary: `Duplicate state_name in ${assetId}: ${sname}`,
|
|
365
|
-
repair_hint: "Rename duplicate states if they are different shapes.",
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
const reason = stateRejectionReason(targetKind, sname);
|
|
369
|
-
if (sname && reason) {
|
|
370
|
-
issues.push({
|
|
371
|
-
code: "STATE_LABEL_INVALID",
|
|
372
|
-
severity: "error",
|
|
373
|
-
summary: `${collection} ${assetId} state label invalid: ${sname}`,
|
|
374
|
-
repair_hint: "Use one clear state label; do not combine multiple states into one label.",
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
if (sid)
|
|
378
|
-
stateIdsByAsset.get(assetId).add(sid);
|
|
379
|
-
if (sname)
|
|
380
|
-
seenStateNames.add(sname);
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
const speakerIds = new Set();
|
|
385
|
-
const speakersArr = asList(script["speakers"]);
|
|
386
|
-
for (let idx = 0; idx < speakersArr.length; idx++) {
|
|
387
|
-
const speaker = speakersArr[idx];
|
|
388
|
-
if (!isDict(speaker)) {
|
|
389
|
-
issues.push({
|
|
390
|
-
code: "SCHEMA_SPEAKER_INVALID",
|
|
391
|
-
severity: "error",
|
|
392
|
-
summary: `speakers[${idx}] is not an object.`,
|
|
393
|
-
repair_hint: "Patch speaker entry.",
|
|
394
|
-
});
|
|
395
|
-
continue;
|
|
396
|
-
}
|
|
397
|
-
const speakerId = strOf(speaker["speaker_id"]);
|
|
398
|
-
const sourceKind = strOf(speaker["source_kind"]);
|
|
399
|
-
const sourceId = speaker["source_id"];
|
|
400
|
-
if (!speakerId || !strOf(speaker["display_name"]).trim()) {
|
|
401
|
-
issues.push({
|
|
402
|
-
code: "SCHEMA_SPEAKER_FIELD_MISSING",
|
|
403
|
-
severity: "error",
|
|
404
|
-
summary: `Speaker missing speaker_id or display_name: ${idx}`,
|
|
405
|
-
repair_hint: "Patch speaker metadata.",
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
if (speakerIds.has(speakerId)) {
|
|
409
|
-
issues.push({
|
|
410
|
-
code: "DUPLICATE_SPEAKER_ID",
|
|
411
|
-
severity: "error",
|
|
412
|
-
summary: `Duplicate speaker_id: ${speakerId}`,
|
|
413
|
-
repair_hint: "Rename or merge duplicate speakers.",
|
|
414
|
-
});
|
|
415
|
-
}
|
|
416
|
-
if (speakerId)
|
|
417
|
-
speakerIds.add(speakerId);
|
|
418
|
-
if (!SPEAKER_SOURCE_KINDS.has(sourceKind)) {
|
|
419
|
-
issues.push({
|
|
420
|
-
code: "ENUM_SPEAKER_SOURCE_KIND",
|
|
421
|
-
severity: "error",
|
|
422
|
-
summary: `speaker.source_kind invalid: ${sourceKind || "<empty>"}`,
|
|
423
|
-
repair_hint: "Use actor/location/prop/system/broadcast/group/other.",
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
if (sourceKind === "actor" && !actorIds.has(strOf(sourceId))) {
|
|
427
|
-
issues.push({
|
|
428
|
-
code: "DANGLING_SPEAKER_SOURCE_REF",
|
|
429
|
-
severity: "error",
|
|
430
|
-
summary: `Speaker actor source not found: ${sourceId}`,
|
|
431
|
-
repair_hint: "Patch source_id or source_kind.",
|
|
432
|
-
});
|
|
433
|
-
}
|
|
434
|
-
if (sourceKind === "location" && !locationIds.has(strOf(sourceId))) {
|
|
435
|
-
issues.push({
|
|
436
|
-
code: "DANGLING_SPEAKER_SOURCE_REF",
|
|
437
|
-
severity: "error",
|
|
438
|
-
summary: `Speaker location source not found: ${sourceId}`,
|
|
439
|
-
repair_hint: "Patch source_id or source_kind.",
|
|
440
|
-
});
|
|
441
|
-
}
|
|
442
|
-
if (sourceKind === "prop" && !propIds.has(strOf(sourceId))) {
|
|
443
|
-
issues.push({
|
|
444
|
-
code: "DANGLING_SPEAKER_SOURCE_REF",
|
|
445
|
-
severity: "error",
|
|
446
|
-
summary: `Speaker prop source not found: ${sourceId}`,
|
|
447
|
-
repair_hint: "Patch source_id or source_kind.",
|
|
448
|
-
});
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
const planPath = path.join(dd, "episode_plan.json");
|
|
452
|
-
if (exists(planPath)) {
|
|
453
|
-
const plan = readJson(planPath);
|
|
454
|
-
if (plan["boundary_confidence"] === "low") {
|
|
455
|
-
issues.push({
|
|
456
|
-
code: "LOW_CONFIDENCE_EPISODE_PLAN",
|
|
457
|
-
severity: "warning",
|
|
458
|
-
summary: "Episode plan used a single-episode fallback.",
|
|
459
|
-
repair_hint: "Inspect the initial script and adjust structure with patch if needed.",
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
const episodes = asList(script["episodes"]);
|
|
464
|
-
if (episodes.length === 0) {
|
|
465
|
-
issues.push({ code: "NO_EPISODES", severity: "warning", incomplete_ok: true, summary: "No episodes in script.", repair_hint: "Add episodes with add-episode (an empty script is publishable but renders to nothing)." });
|
|
466
|
-
}
|
|
467
|
-
let totalScenes = 0;
|
|
468
|
-
let totalActions = 0;
|
|
469
|
-
let lastSceneNum = 0;
|
|
470
|
-
for (const ep of episodes) {
|
|
471
|
-
const epTitle = strOf(ep["title"]).trim();
|
|
472
|
-
if (!epTitle || isTechnicalEpisodeTitle(epTitle)) {
|
|
473
|
-
issues.push({
|
|
474
|
-
code: "EPISODE_TITLE_INVALID",
|
|
475
|
-
severity: "warning",
|
|
476
|
-
episode: ep["episode_id"],
|
|
477
|
-
summary: `Episode title is missing or technical: ${epTitle || "<empty>"}`,
|
|
478
|
-
repair_hint: 'Rerun `direct init` to regenerate, or `script patch episode <episode_id> --field title --value "第N集:短标题"`.',
|
|
479
|
-
});
|
|
480
|
-
}
|
|
481
|
-
const scenes = asList(ep["scenes"]);
|
|
482
|
-
if (scenes.length === 0) {
|
|
483
|
-
issues.push({
|
|
484
|
-
code: "EPISODE_WITHOUT_SCENES",
|
|
485
|
-
severity: "warning",
|
|
486
|
-
incomplete_ok: true,
|
|
487
|
-
episode: ep["episode_id"],
|
|
488
|
-
summary: "Episode has no scenes.",
|
|
489
|
-
repair_hint: "Add scenes with insert (an empty episode is publishable but renders to nothing).",
|
|
490
|
-
});
|
|
491
|
-
}
|
|
492
|
-
for (const scene of scenes) {
|
|
493
|
-
totalScenes += 1;
|
|
494
|
-
const rawSceneId = strOf(scene["scene_id"]);
|
|
495
|
-
const match = /^scn_(\d+)$/.exec(rawSceneId);
|
|
496
|
-
if (match) {
|
|
497
|
-
const sceneNum = parseInt(match[1], 10);
|
|
498
|
-
if (sceneNum <= lastSceneNum) {
|
|
499
|
-
issues.push({
|
|
500
|
-
code: "REF_SCENE_ID_NONMONO",
|
|
501
|
-
severity: "error",
|
|
502
|
-
episode: ep["episode_id"],
|
|
503
|
-
scene: rawSceneId,
|
|
504
|
-
summary: `scene_id must increase globally: ${rawSceneId}`,
|
|
505
|
-
repair_hint: "Rerun init or patch scene ids to a global monotonic sequence.",
|
|
506
|
-
});
|
|
507
|
-
}
|
|
508
|
-
lastSceneNum = Math.max(lastSceneNum, sceneNum);
|
|
509
|
-
}
|
|
510
|
-
else {
|
|
511
|
-
issues.push({
|
|
512
|
-
code: "REF_SCENE_ID_INVALID",
|
|
513
|
-
severity: "error",
|
|
514
|
-
episode: ep["episode_id"],
|
|
515
|
-
scene: rawSceneId || null,
|
|
516
|
-
summary: "scene_id must use scn_### format.",
|
|
517
|
-
repair_hint: "Patch scene_id to scn_###.",
|
|
518
|
-
});
|
|
519
|
-
}
|
|
520
|
-
const ctx = sceneContext(scene);
|
|
521
|
-
const ctxLocations = asList(ctx["locations"]);
|
|
522
|
-
if (ctxLocations.length === 0) {
|
|
523
|
-
issues.push({
|
|
524
|
-
code: "SCENE_WITHOUT_LOCATION",
|
|
525
|
-
severity: "warning",
|
|
526
|
-
incomplete_ok: true,
|
|
527
|
-
episode: ep["episode_id"],
|
|
528
|
-
scene: scene["scene_id"],
|
|
529
|
-
summary: "Scene has no location.",
|
|
530
|
-
repair_hint: "Set a location with context (an unlocated scene is publishable but cannot render).",
|
|
531
|
-
});
|
|
532
|
-
}
|
|
533
|
-
for (const ref of ctxLocations) {
|
|
534
|
-
const locationId = strOf(ref["location_id"]);
|
|
535
|
-
const stateId = strOf(ref["state_id"]);
|
|
536
|
-
if (!locationIds.has(locationId)) {
|
|
537
|
-
issues.push({
|
|
538
|
-
code: "DANGLING_LOCATION_REF",
|
|
539
|
-
severity: "error",
|
|
540
|
-
episode: ep["episode_id"],
|
|
541
|
-
scene: scene["scene_id"],
|
|
542
|
-
summary: `Location ref not found: ${locationId || "<empty>"}`,
|
|
543
|
-
repair_hint: "Patch scene location to an existing location_id.",
|
|
544
|
-
});
|
|
545
|
-
}
|
|
546
|
-
if (stateId && !(stateIdsByAsset.get(locationId) ?? new Set()).has(stateId)) {
|
|
547
|
-
issues.push({
|
|
548
|
-
code: "DANGLING_LOCATION_STATE_REF",
|
|
549
|
-
severity: "error",
|
|
550
|
-
episode: ep["episode_id"],
|
|
551
|
-
scene: scene["scene_id"],
|
|
552
|
-
summary: `Location state ref not found: ${stateId}`,
|
|
553
|
-
repair_hint: "Patch location state_id or asset states.",
|
|
554
|
-
});
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
for (const ref of asList(ctx["actors"])) {
|
|
558
|
-
const actorId = strOf(ref["actor_id"]);
|
|
559
|
-
const stateId = strOf(ref["state_id"]);
|
|
560
|
-
if (!actorIds.has(actorId)) {
|
|
561
|
-
issues.push({
|
|
562
|
-
code: "DANGLING_ACTOR_REF",
|
|
563
|
-
severity: "error",
|
|
564
|
-
episode: ep["episode_id"],
|
|
565
|
-
scene: scene["scene_id"],
|
|
566
|
-
summary: `Actor ref not found: ${actorId || "<empty>"}`,
|
|
567
|
-
repair_hint: "Patch scene actor refs to existing actor_id.",
|
|
568
|
-
});
|
|
569
|
-
}
|
|
570
|
-
if (stateId && !(stateIdsByAsset.get(actorId) ?? new Set()).has(stateId)) {
|
|
571
|
-
issues.push({
|
|
572
|
-
code: "DANGLING_ACTOR_STATE_REF",
|
|
573
|
-
severity: "error",
|
|
574
|
-
episode: ep["episode_id"],
|
|
575
|
-
scene: scene["scene_id"],
|
|
576
|
-
summary: `Actor state ref not found: ${stateId}`,
|
|
577
|
-
repair_hint: "Patch actor state_id or asset states.",
|
|
578
|
-
});
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
for (const ref of asList(ctx["props"])) {
|
|
582
|
-
const propId = strOf(ref["prop_id"]);
|
|
583
|
-
const stateId = strOf(ref["state_id"]);
|
|
584
|
-
if (!propIds.has(propId)) {
|
|
585
|
-
issues.push({
|
|
586
|
-
code: "DANGLING_PROP_REF",
|
|
587
|
-
severity: "error",
|
|
588
|
-
episode: ep["episode_id"],
|
|
589
|
-
scene: scene["scene_id"],
|
|
590
|
-
summary: `Prop ref not found: ${propId || "<empty>"}`,
|
|
591
|
-
repair_hint: "Patch scene prop refs to existing prop_id.",
|
|
592
|
-
});
|
|
593
|
-
}
|
|
594
|
-
if (stateId && !(stateIdsByAsset.get(propId) ?? new Set()).has(stateId)) {
|
|
595
|
-
issues.push({
|
|
596
|
-
code: "DANGLING_PROP_STATE_REF",
|
|
597
|
-
severity: "error",
|
|
598
|
-
episode: ep["episode_id"],
|
|
599
|
-
scene: scene["scene_id"],
|
|
600
|
-
summary: `Prop state ref not found: ${stateId}`,
|
|
601
|
-
repair_hint: "Patch prop state_id or asset states.",
|
|
602
|
-
});
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
const actionsArr = asList(scene["actions"]);
|
|
606
|
-
if (actionsArr.length === 0) {
|
|
607
|
-
issues.push({
|
|
608
|
-
code: "SCENE_WITHOUT_ACTIONS",
|
|
609
|
-
severity: "warning",
|
|
610
|
-
incomplete_ok: true,
|
|
611
|
-
episode: ep["episode_id"],
|
|
612
|
-
scene: scene["scene_id"],
|
|
613
|
-
summary: "Scene has no actions.",
|
|
614
|
-
repair_hint: "Add actions with insert (an empty scene is publishable but renders to nothing).",
|
|
615
|
-
});
|
|
616
|
-
}
|
|
617
|
-
for (let actionIndex = 0; actionIndex < actionsArr.length; actionIndex++) {
|
|
618
|
-
const action = actionsArr[actionIndex];
|
|
619
|
-
totalActions += 1;
|
|
620
|
-
const kind = strOf(action["type"]).trim();
|
|
621
|
-
if (!ACTION_TYPE_VALUES.has(kind)) {
|
|
622
|
-
issues.push({
|
|
623
|
-
code: "ENUM_ACTION_TYPE",
|
|
624
|
-
severity: "error",
|
|
625
|
-
episode: ep["episode_id"],
|
|
626
|
-
scene: scene["scene_id"],
|
|
627
|
-
action_index: actionIndex,
|
|
628
|
-
summary: `action.type invalid: ${kind || "<empty>"}`,
|
|
629
|
-
repair_hint: "Patch action.type to dialogue, inner_thought, or action.",
|
|
630
|
-
});
|
|
631
|
-
}
|
|
632
|
-
const content = strOf(action["content"]).trim();
|
|
633
|
-
const hasOverlapLines = action["delivery"] === "overlap" && isList(action["lines"]) && action["lines"].length > 0;
|
|
634
|
-
if (!content && !hasOverlapLines) {
|
|
635
|
-
issues.push({
|
|
636
|
-
code: "EMPTY_ACTION_CONTENT",
|
|
637
|
-
severity: "error",
|
|
638
|
-
episode: ep["episode_id"],
|
|
639
|
-
scene: scene["scene_id"],
|
|
640
|
-
action_index: actionIndex,
|
|
641
|
-
summary: "Action content is empty.",
|
|
642
|
-
repair_hint: "Patch content from a source span.",
|
|
643
|
-
});
|
|
644
|
-
}
|
|
645
|
-
if (content && contentHasSpeakerPrefix(content, speakerDisplayNames)) {
|
|
646
|
-
issues.push({
|
|
647
|
-
code: "DIALOGUE_CONTENT_SPEAKER_PREFIX",
|
|
648
|
-
severity: "error",
|
|
649
|
-
episode: ep["episode_id"],
|
|
650
|
-
scene: scene["scene_id"],
|
|
651
|
-
action_index: actionIndex,
|
|
652
|
-
summary: "Dialogue content appears to contain speaker prefixes.",
|
|
653
|
-
repair_hint: "Move speaker labels into speaker_id/speakers/lines and keep content as spoken text only.",
|
|
654
|
-
});
|
|
655
|
-
}
|
|
656
|
-
if (action["type"] === "dialogue") {
|
|
657
|
-
const delivery = strOf(action["delivery"] || "single");
|
|
658
|
-
if (!DELIVERY_VALUES.has(delivery)) {
|
|
659
|
-
issues.push({
|
|
660
|
-
code: "ENUM_DIALOGUE_DELIVERY",
|
|
661
|
-
severity: "error",
|
|
662
|
-
episode: ep["episode_id"],
|
|
663
|
-
scene: scene["scene_id"],
|
|
664
|
-
action_index: actionIndex,
|
|
665
|
-
summary: `dialogue.delivery invalid: ${delivery}`,
|
|
666
|
-
repair_hint: "Use single, simultaneous, overlap, or group.",
|
|
667
|
-
});
|
|
668
|
-
}
|
|
669
|
-
if (delivery === "overlap") {
|
|
670
|
-
const lines = asList(action["lines"]);
|
|
671
|
-
if (lines.length === 0) {
|
|
672
|
-
issues.push({
|
|
673
|
-
code: "DIALOGUE_OVERLAP_LINES_EMPTY",
|
|
674
|
-
severity: "error",
|
|
675
|
-
episode: ep["episode_id"],
|
|
676
|
-
scene: scene["scene_id"],
|
|
677
|
-
action_index: actionIndex,
|
|
678
|
-
summary: "Overlapping dialogue has no lines.",
|
|
679
|
-
repair_hint: "Add lines with speaker_id and content.",
|
|
680
|
-
});
|
|
681
|
-
}
|
|
682
|
-
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
683
|
-
const line = lines[lineIdx] ?? {};
|
|
684
|
-
const sid = strOf(line["speaker_id"]);
|
|
685
|
-
const lineContent = strOf(line["content"]).trim();
|
|
686
|
-
if (!speakerIds.has(sid)) {
|
|
687
|
-
issues.push({
|
|
688
|
-
code: "DANGLING_DIALOGUE_SPEAKER_REF",
|
|
689
|
-
severity: "error",
|
|
690
|
-
episode: ep["episode_id"],
|
|
691
|
-
scene: scene["scene_id"],
|
|
692
|
-
action_index: actionIndex,
|
|
693
|
-
summary: `Overlap line speaker not found: ${sid || "<empty>"}`,
|
|
694
|
-
repair_hint: "Patch speaker_id or add a speaker.",
|
|
695
|
-
});
|
|
696
|
-
}
|
|
697
|
-
if (!lineContent) {
|
|
698
|
-
issues.push({
|
|
699
|
-
code: "EMPTY_DIALOGUE_LINE",
|
|
700
|
-
severity: "error",
|
|
701
|
-
episode: ep["episode_id"],
|
|
702
|
-
scene: scene["scene_id"],
|
|
703
|
-
action_index: actionIndex,
|
|
704
|
-
summary: `Overlap line ${lineIdx} content is empty.`,
|
|
705
|
-
repair_hint: "Patch line content.",
|
|
706
|
-
});
|
|
707
|
-
}
|
|
708
|
-
else if (contentHasSpeakerPrefix(lineContent, speakerDisplayNames)) {
|
|
709
|
-
issues.push({
|
|
710
|
-
code: "DIALOGUE_CONTENT_SPEAKER_PREFIX",
|
|
711
|
-
severity: "error",
|
|
712
|
-
episode: ep["episode_id"],
|
|
713
|
-
scene: scene["scene_id"],
|
|
714
|
-
action_index: actionIndex,
|
|
715
|
-
summary: `Overlap line ${lineIdx} content contains a speaker prefix.`,
|
|
716
|
-
repair_hint: "Keep each overlap line content as spoken text only.",
|
|
717
|
-
});
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
else if (action["speakers"]) {
|
|
722
|
-
const speakersInAction = asList(action["speakers"]);
|
|
723
|
-
if (speakersInAction.length === 0) {
|
|
724
|
-
issues.push({
|
|
725
|
-
code: "DIALOGUE_SPEAKERS_INVALID",
|
|
726
|
-
severity: "error",
|
|
727
|
-
episode: ep["episode_id"],
|
|
728
|
-
scene: scene["scene_id"],
|
|
729
|
-
action_index: actionIndex,
|
|
730
|
-
summary: "dialogue.speakers must be an array.",
|
|
731
|
-
repair_hint: "Patch speakers list.",
|
|
732
|
-
});
|
|
733
|
-
}
|
|
734
|
-
for (const speaker of speakersInAction) {
|
|
735
|
-
const sid = strOf(speaker["speaker_id"]);
|
|
736
|
-
if (!speakerIds.has(sid)) {
|
|
737
|
-
issues.push({
|
|
738
|
-
code: "DANGLING_DIALOGUE_SPEAKER_REF",
|
|
739
|
-
severity: "error",
|
|
740
|
-
episode: ep["episode_id"],
|
|
741
|
-
scene: scene["scene_id"],
|
|
742
|
-
action_index: actionIndex,
|
|
743
|
-
summary: `Dialogue speaker not found: ${sid || "<empty>"}`,
|
|
744
|
-
repair_hint: "Patch speaker_id or add a speaker.",
|
|
745
|
-
});
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
else if (action["speaker_id"]) {
|
|
750
|
-
const sid = strOf(action["speaker_id"]);
|
|
751
|
-
if (!speakerIds.has(sid)) {
|
|
752
|
-
issues.push({
|
|
753
|
-
code: "DANGLING_DIALOGUE_SPEAKER_REF",
|
|
754
|
-
severity: "error",
|
|
755
|
-
episode: ep["episode_id"],
|
|
756
|
-
scene: scene["scene_id"],
|
|
757
|
-
action_index: actionIndex,
|
|
758
|
-
summary: `Dialogue speaker not found: ${sid}`,
|
|
759
|
-
repair_hint: "Patch speaker_id or add a speaker.",
|
|
760
|
-
});
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
else if (!action["actor_id"]) {
|
|
764
|
-
issues.push({
|
|
765
|
-
code: "DIALOGUE_WITHOUT_SPEAKER",
|
|
766
|
-
severity: "error",
|
|
767
|
-
episode: ep["episode_id"],
|
|
768
|
-
scene: scene["scene_id"],
|
|
769
|
-
action_index: actionIndex,
|
|
770
|
-
summary: "Dialogue has no speaker.",
|
|
771
|
-
repair_hint: "Patch speaker_id, speakers, lines, or legacy actor_id.",
|
|
772
|
-
});
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
if (action["type"] === "inner_thought") {
|
|
776
|
-
const sid = strOf(action["speaker_id"]);
|
|
777
|
-
if (sid && !speakerIds.has(sid)) {
|
|
778
|
-
issues.push({
|
|
779
|
-
code: "DANGLING_DIALOGUE_SPEAKER_REF",
|
|
780
|
-
severity: "error",
|
|
781
|
-
episode: ep["episode_id"],
|
|
782
|
-
scene: scene["scene_id"],
|
|
783
|
-
action_index: actionIndex,
|
|
784
|
-
summary: `Inner thought speaker not found: ${sid}`,
|
|
785
|
-
repair_hint: "Patch speaker_id or add a speaker.",
|
|
786
|
-
});
|
|
787
|
-
}
|
|
788
|
-
if (!action["actor_id"] && !sid) {
|
|
789
|
-
issues.push({
|
|
790
|
-
code: "DIALOGUE_WITHOUT_ACTOR",
|
|
791
|
-
severity: "error",
|
|
792
|
-
episode: ep["episode_id"],
|
|
793
|
-
scene: scene["scene_id"],
|
|
794
|
-
action_index: actionIndex,
|
|
795
|
-
summary: "Inner thought has no actor_id or speaker_id.",
|
|
796
|
-
repair_hint: "Patch actor_id, speaker_id, or speaker registry.",
|
|
797
|
-
});
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
if (action["actor_id"] && !actorIds.has(strOf(action["actor_id"]))) {
|
|
801
|
-
issues.push({
|
|
802
|
-
code: "DANGLING_ACTION_ACTOR_REF",
|
|
803
|
-
severity: "error",
|
|
804
|
-
episode: ep["episode_id"],
|
|
805
|
-
scene: scene["scene_id"],
|
|
806
|
-
action_index: actionIndex,
|
|
807
|
-
summary: `Action actor_id not found: ${action["actor_id"]}`,
|
|
808
|
-
repair_hint: "Patch action actor_id to an existing actor.",
|
|
809
|
-
});
|
|
810
|
-
}
|
|
811
|
-
for (const change of asList(action["state_changes"])) {
|
|
812
|
-
const targetKind = strOf(change["target_kind"]);
|
|
813
|
-
const targetId = strOf(change["target_id"]);
|
|
814
|
-
const fromStateId = strOf(change["from_state_id"]);
|
|
815
|
-
const toStateId = strOf(change["to_state_id"]);
|
|
816
|
-
const effectiveFrom = strOf(change["effective_from"] || "after_action");
|
|
817
|
-
if (!SCRIPT_TARGET_KINDS.has(targetKind)) {
|
|
818
|
-
issues.push({
|
|
819
|
-
code: "STATE_CHANGE_TARGET_KIND_INVALID",
|
|
820
|
-
severity: "error",
|
|
821
|
-
episode: ep["episode_id"],
|
|
822
|
-
scene: scene["scene_id"],
|
|
823
|
-
action_index: actionIndex,
|
|
824
|
-
summary: `state_changes target_kind invalid: ${targetKind || "<empty>"}`,
|
|
825
|
-
repair_hint: "Use actor, location, or prop.",
|
|
826
|
-
});
|
|
827
|
-
continue;
|
|
828
|
-
}
|
|
829
|
-
const validIds = { actor: actorIds, location: locationIds, prop: propIds }[targetKind];
|
|
830
|
-
if (!validIds.has(targetId)) {
|
|
831
|
-
issues.push({
|
|
832
|
-
code: "DANGLING_STATE_CHANGE_TARGET_REF",
|
|
833
|
-
severity: "error",
|
|
834
|
-
episode: ep["episode_id"],
|
|
835
|
-
scene: scene["scene_id"],
|
|
836
|
-
action_index: actionIndex,
|
|
837
|
-
summary: `State change target not found: ${targetKind}:${targetId}`,
|
|
838
|
-
repair_hint: "Patch target_id.",
|
|
839
|
-
});
|
|
840
|
-
}
|
|
841
|
-
for (const [label, sid] of [["from_state_id", fromStateId], ["to_state_id", toStateId]]) {
|
|
842
|
-
if (sid && !(stateIdsByAsset.get(targetId) ?? new Set()).has(sid)) {
|
|
843
|
-
issues.push({
|
|
844
|
-
code: "DANGLING_STATE_CHANGE_STATE_REF",
|
|
845
|
-
severity: "error",
|
|
846
|
-
episode: ep["episode_id"],
|
|
847
|
-
scene: scene["scene_id"],
|
|
848
|
-
action_index: actionIndex,
|
|
849
|
-
summary: `State change ${label} not found: ${sid}`,
|
|
850
|
-
repair_hint: "Patch state change or asset states.",
|
|
851
|
-
});
|
|
852
|
-
}
|
|
853
|
-
}
|
|
854
|
-
if (!EFFECTIVE_FROM_VALUES.has(effectiveFrom)) {
|
|
855
|
-
issues.push({
|
|
856
|
-
code: "ENUM_STATE_CHANGE_EFFECTIVE_FROM",
|
|
857
|
-
severity: "error",
|
|
858
|
-
episode: ep["episode_id"],
|
|
859
|
-
scene: scene["scene_id"],
|
|
860
|
-
action_index: actionIndex,
|
|
861
|
-
summary: `effective_from invalid: ${effectiveFrom}`,
|
|
862
|
-
repair_hint: "Use before_action or after_action.",
|
|
863
|
-
});
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
const transition = action["transition_prompt"];
|
|
867
|
-
if (transition !== null && transition !== undefined) {
|
|
868
|
-
if (!isDict(transition)) {
|
|
869
|
-
issues.push({
|
|
870
|
-
code: "TRANSITION_PROMPT_INVALID",
|
|
871
|
-
severity: "error",
|
|
872
|
-
episode: ep["episode_id"],
|
|
873
|
-
scene: scene["scene_id"],
|
|
874
|
-
action_index: actionIndex,
|
|
875
|
-
summary: "transition_prompt must be an object.",
|
|
876
|
-
repair_hint: "Patch or clear transition_prompt.",
|
|
877
|
-
});
|
|
878
|
-
}
|
|
879
|
-
else {
|
|
880
|
-
const targetKind = strOf(transition["target_kind"]);
|
|
881
|
-
const targetId = strOf(transition["target_id"]);
|
|
882
|
-
if (!SCRIPT_TARGET_KINDS.has(targetKind)) {
|
|
883
|
-
issues.push({
|
|
884
|
-
code: "TRANSITION_PROMPT_TARGET_KIND_INVALID",
|
|
885
|
-
severity: "error",
|
|
886
|
-
episode: ep["episode_id"],
|
|
887
|
-
scene: scene["scene_id"],
|
|
888
|
-
action_index: actionIndex,
|
|
889
|
-
summary: `transition target_kind invalid: ${targetKind || "<empty>"}`,
|
|
890
|
-
repair_hint: "Use actor, location, or prop.",
|
|
891
|
-
});
|
|
892
|
-
}
|
|
893
|
-
else {
|
|
894
|
-
const validIds = { actor: actorIds, location: locationIds, prop: propIds }[targetKind];
|
|
895
|
-
if (!validIds.has(targetId)) {
|
|
896
|
-
issues.push({
|
|
897
|
-
code: "DANGLING_TRANSITION_TARGET_REF",
|
|
898
|
-
severity: "error",
|
|
899
|
-
episode: ep["episode_id"],
|
|
900
|
-
scene: scene["scene_id"],
|
|
901
|
-
action_index: actionIndex,
|
|
902
|
-
summary: `Transition target not found: ${targetKind}:${targetId}`,
|
|
903
|
-
repair_hint: "Patch transition target.",
|
|
904
|
-
});
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
const hasBlocking = issues.some((it) => it["severity"] === "blocking");
|
|
913
|
-
const hasErrors = issues.some((it) => it["severity"] === "error");
|
|
914
|
-
const validation = {
|
|
915
|
-
passed: !hasBlocking && !hasErrors,
|
|
916
|
-
has_blocking: hasBlocking,
|
|
917
|
-
issues,
|
|
918
|
-
stats: {
|
|
919
|
-
episodes: episodes.length,
|
|
920
|
-
scenes: totalScenes,
|
|
921
|
-
actions: totalActions,
|
|
922
|
-
actors: asList(script["actors"]).length,
|
|
923
|
-
locations: asList(script["locations"]).length,
|
|
924
|
-
props: asList(script["props"]).length,
|
|
925
|
-
speakers: asList(script["speakers"]).length,
|
|
926
|
-
},
|
|
927
|
-
script_path: opts.scriptData !== undefined ? "<in-memory>" : (scriptPath ?? path.join(dd, "script.initial.json")),
|
|
928
|
-
validated_at: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
|
|
929
|
-
};
|
|
930
|
-
// persist defaults to true (direct/write flows write validation.json into
|
|
931
|
-
// directDir); callers that want a side-effect-free check (e.g. `parse`) pass
|
|
932
|
-
// persist:false so no direct artifacts are created.
|
|
933
|
-
if (opts.persist !== false)
|
|
934
|
-
writeJson(path.join(dd, "validation.json"), validation);
|
|
935
|
-
return validation;
|
|
936
|
-
}
|
|
937
|
-
// ---------------------------------------------------------------------------
|
|
938
|
-
// Resolve/lookup helpers
|
|
939
|
-
// ---------------------------------------------------------------------------
|
|
940
|
-
function resolveAsset(items, idKey, nameKey, raw) {
|
|
941
|
-
for (const item of items) {
|
|
942
|
-
if (raw === item[idKey] || raw === item[nameKey])
|
|
943
|
-
return item;
|
|
944
|
-
}
|
|
945
|
-
return null;
|
|
946
|
-
}
|
|
947
|
-
const ASSET_KEY_MAP = {
|
|
948
|
-
actor: ["actors", "actor_id", "actor_name"],
|
|
949
|
-
location: ["locations", "location_id", "location_name"],
|
|
950
|
-
prop: ["props", "prop_id", "prop_name"],
|
|
951
|
-
};
|
|
952
|
-
export function assetKeys(assetType) {
|
|
953
|
-
const entry = ASSET_KEY_MAP[assetType];
|
|
954
|
-
if (!entry) {
|
|
955
|
-
throw new CliError("PATCH BLOCKED: Asset type invalid", "Asset type invalid.", {
|
|
956
|
-
exitCode: EXIT_USAGE,
|
|
957
|
-
required: ["asset_type: actor, location, or prop"],
|
|
958
|
-
received: [`asset_type: ${assetType}`],
|
|
959
|
-
nextSteps: ["Fix the patch operation and rerun patch."],
|
|
960
|
-
});
|
|
961
|
-
}
|
|
962
|
-
return entry;
|
|
963
|
-
}
|
|
964
|
-
function resolvePatchAsset(script, op, assetType = null) {
|
|
965
|
-
const kind = strOf(assetType || op["asset_type"]);
|
|
966
|
-
const [key, idKey, nameKey] = assetKeys(kind);
|
|
967
|
-
const raw = op[idKey] ?? op["id"] ?? op["name"] ?? op[nameKey];
|
|
968
|
-
const asset = resolveAsset(asList(script[key]), idKey, nameKey, strOf(raw));
|
|
969
|
-
if (!asset) {
|
|
970
|
-
throw new CliError("PATCH BLOCKED: Asset not found", "Asset not found.", {
|
|
971
|
-
exitCode: EXIT_USAGE,
|
|
972
|
-
required: [`existing ${idKey} or ${nameKey}`],
|
|
973
|
-
received: [JSON.stringify(op)],
|
|
974
|
-
nextSteps: ["Inspect assets and fix the patch."],
|
|
975
|
-
});
|
|
976
|
-
}
|
|
977
|
-
return [asset, idKey, nameKey];
|
|
978
|
-
}
|
|
979
|
-
function nextStateId(script) {
|
|
980
|
-
const ids = [];
|
|
981
|
-
for (const key of ["actors", "locations", "props"]) {
|
|
982
|
-
for (const asset of asList(script[key])) {
|
|
983
|
-
for (const state of asList(asset["states"]))
|
|
984
|
-
ids.push(state["state_id"]);
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
return nextSeqId(ids, "st");
|
|
988
|
-
}
|
|
989
|
-
function normalizeStates(script, rawStates, assetKind = "") {
|
|
990
|
-
if (!isList(rawStates)) {
|
|
991
|
-
throw new CliError("PATCH BLOCKED: States invalid", "States invalid.", {
|
|
992
|
-
exitCode: EXIT_USAGE,
|
|
993
|
-
required: ["states: array of state names or {state_id,state_name} objects"],
|
|
994
|
-
received: [typeof rawStates],
|
|
995
|
-
nextSteps: ["Fix the patch operation and rerun patch."],
|
|
996
|
-
});
|
|
997
|
-
}
|
|
998
|
-
let maxSeen = 0;
|
|
999
|
-
for (const key of ["actors", "locations", "props"]) {
|
|
1000
|
-
for (const asset of asList(script[key])) {
|
|
1001
|
-
for (const state of asList(asset["states"])) {
|
|
1002
|
-
const m = /^st_(\d+)$/.exec(strOf(state["state_id"]));
|
|
1003
|
-
if (m)
|
|
1004
|
-
maxSeen = Math.max(maxSeen, parseInt(m[1], 10));
|
|
1005
|
-
}
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
const states = [];
|
|
1009
|
-
const seenNames = new Set();
|
|
1010
|
-
for (const raw of rawStates) {
|
|
1011
|
-
let name = "";
|
|
1012
|
-
let sid = "";
|
|
1013
|
-
if (typeof raw === "string") {
|
|
1014
|
-
name = raw.trim();
|
|
1015
|
-
maxSeen += 1;
|
|
1016
|
-
sid = fmtId("st", maxSeen);
|
|
1017
|
-
}
|
|
1018
|
-
else if (isDict(raw)) {
|
|
1019
|
-
name = strOf(raw["state_name"] || raw["name"]).trim();
|
|
1020
|
-
sid = strOf(raw["state_id"] || raw["id"]).trim();
|
|
1021
|
-
if (!sid) {
|
|
1022
|
-
maxSeen += 1;
|
|
1023
|
-
sid = fmtId("st", maxSeen);
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
else
|
|
1027
|
-
continue;
|
|
1028
|
-
if (!name || seenNames.has(name))
|
|
1029
|
-
continue;
|
|
1030
|
-
if (assetKind === "actor") {
|
|
1031
|
-
const reason = stateLabelError(name);
|
|
1032
|
-
if (reason) {
|
|
1033
|
-
throw new CliError("PATCH BLOCKED: State label invalid", "State label invalid.", {
|
|
1034
|
-
exitCode: EXIT_USAGE,
|
|
1035
|
-
required: ["one clear state label"],
|
|
1036
|
-
received: [name],
|
|
1037
|
-
nextSteps: ["Do not combine multiple states into one label."],
|
|
1038
|
-
errorCode: "STATE_LABEL_INVALID",
|
|
1039
|
-
});
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
seenNames.add(name);
|
|
1043
|
-
states.push({ state_id: sid, state_name: name });
|
|
1044
|
-
}
|
|
1045
|
-
return states;
|
|
1046
|
-
}
|
|
1047
|
-
export function ensureRefId(script, assetType, rawId, opts = {}) {
|
|
1048
|
-
const allowNone = opts.allowNone ?? false;
|
|
1049
|
-
if (rawId === null && allowNone)
|
|
1050
|
-
return null;
|
|
1051
|
-
const value = strOf(rawId).trim();
|
|
1052
|
-
if (!value && allowNone)
|
|
1053
|
-
return null;
|
|
1054
|
-
const [key, idKey] = assetKeys(assetType);
|
|
1055
|
-
const valid = new Set();
|
|
1056
|
-
for (const item of asList(script[key]))
|
|
1057
|
-
valid.add(strOf(item[idKey]));
|
|
1058
|
-
if (!valid.has(value)) {
|
|
1059
|
-
throw new CliError("PATCH BLOCKED: Reference target not found", "Reference target not found.", {
|
|
1060
|
-
exitCode: EXIT_USAGE,
|
|
1061
|
-
required: [`existing ${idKey}`],
|
|
1062
|
-
received: [`${idKey}: ${value || "<empty>"}`],
|
|
1063
|
-
nextSteps: ["Inspect assets and fix the patch."],
|
|
1064
|
-
});
|
|
1065
|
-
}
|
|
1066
|
-
return value;
|
|
1067
|
-
}
|
|
1068
|
-
function idKeyForKind(kind) {
|
|
1069
|
-
const map = { actor: "actor_id", location: "location_id", prop: "prop_id" };
|
|
1070
|
-
return map[kind];
|
|
1071
|
-
}
|
|
1072
|
-
function pluralForKind(kind) {
|
|
1073
|
-
const map = { actor: "actors", location: "locations", prop: "props" };
|
|
1074
|
-
return map[kind];
|
|
1075
|
-
}
|
|
1076
|
-
export function parseAssetTarget(raw) {
|
|
1077
|
-
const value = strOf(raw).trim();
|
|
1078
|
-
if (!value.includes(":")) {
|
|
1079
|
-
throw new CliError("SCRIPT OP BLOCKED: Target invalid", "Target invalid.", {
|
|
1080
|
-
exitCode: EXIT_USAGE,
|
|
1081
|
-
required: ["target: actor:<id>, location:<id>, or prop:<id>"],
|
|
1082
|
-
received: [value || "<empty>"],
|
|
1083
|
-
nextSteps: ["Use a concrete asset target."],
|
|
1084
|
-
errorCode: "TARGET_INVALID",
|
|
1085
|
-
});
|
|
1086
|
-
}
|
|
1087
|
-
const idx = value.indexOf(":");
|
|
1088
|
-
const kind = value.slice(0, idx).trim();
|
|
1089
|
-
const targetId = value.slice(idx + 1).trim();
|
|
1090
|
-
if (!SCRIPT_TARGET_KINDS.has(kind) || !targetId) {
|
|
1091
|
-
throw new CliError("SCRIPT OP BLOCKED: Target invalid", "Target invalid.", {
|
|
1092
|
-
exitCode: EXIT_USAGE,
|
|
1093
|
-
required: ["target: actor:<id>, location:<id>, or prop:<id>"],
|
|
1094
|
-
received: [value],
|
|
1095
|
-
nextSteps: ["Use actor, location, or prop with a non-empty id."],
|
|
1096
|
-
errorCode: "TARGET_INVALID",
|
|
1097
|
-
});
|
|
1098
|
-
}
|
|
1099
|
-
return [kind, targetId];
|
|
1100
|
-
}
|
|
1101
|
-
export function parseStateTarget(raw) {
|
|
1102
|
-
const value = strOf(raw).trim();
|
|
1103
|
-
if (!value.includes("/")) {
|
|
1104
|
-
throw new CliError("SCRIPT OP BLOCKED: State target invalid", "State target invalid.", {
|
|
1105
|
-
exitCode: EXIT_USAGE,
|
|
1106
|
-
required: ["state target: actor:<id>/<state_id>"],
|
|
1107
|
-
received: [value || "<empty>"],
|
|
1108
|
-
nextSteps: ["Use a concrete state target."],
|
|
1109
|
-
errorCode: "STATE_TARGET_INVALID",
|
|
1110
|
-
});
|
|
1111
|
-
}
|
|
1112
|
-
const lastSlash = value.lastIndexOf("/");
|
|
1113
|
-
const assetRaw = value.slice(0, lastSlash);
|
|
1114
|
-
const stateId = value.slice(lastSlash + 1).trim();
|
|
1115
|
-
const [kind, targetId] = parseAssetTarget(assetRaw);
|
|
1116
|
-
if (!stateId) {
|
|
1117
|
-
throw new CliError("SCRIPT OP BLOCKED: State target invalid", "State target invalid.", {
|
|
1118
|
-
exitCode: EXIT_USAGE,
|
|
1119
|
-
required: ["state_id after '/'"],
|
|
1120
|
-
received: [value],
|
|
1121
|
-
nextSteps: ["Use a non-empty state_id."],
|
|
1122
|
-
errorCode: "STATE_TARGET_INVALID",
|
|
1123
|
-
});
|
|
1124
|
-
}
|
|
1125
|
-
return [kind, targetId, stateId];
|
|
1126
|
-
}
|
|
1127
|
-
export function parseSceneRef(raw) {
|
|
1128
|
-
const value = strOf(raw).trim();
|
|
1129
|
-
if (!value.includes("/")) {
|
|
1130
|
-
throw new CliError("SCRIPT OP BLOCKED: Scene ref invalid", "Scene ref invalid.", {
|
|
1131
|
-
exitCode: EXIT_USAGE,
|
|
1132
|
-
required: ["scene ref: ep_001/scn_001"],
|
|
1133
|
-
received: [value || "<empty>"],
|
|
1134
|
-
nextSteps: ["Use ep_id/scn_id."],
|
|
1135
|
-
errorCode: "SCENE_REF_INVALID",
|
|
1136
|
-
});
|
|
1137
|
-
}
|
|
1138
|
-
const idx = value.indexOf("/");
|
|
1139
|
-
return [value.slice(0, idx).trim(), value.slice(idx + 1).trim()];
|
|
1140
|
-
}
|
|
1141
|
-
export function parseActionRef(raw) {
|
|
1142
|
-
const value = strOf(raw).trim();
|
|
1143
|
-
if (!value.includes("#")) {
|
|
1144
|
-
throw new CliError("SCRIPT OP BLOCKED: Action ref invalid", "Action ref invalid.", {
|
|
1145
|
-
exitCode: EXIT_USAGE,
|
|
1146
|
-
required: ["action ref: ep_001/scn_001#4"],
|
|
1147
|
-
received: [value || "<empty>"],
|
|
1148
|
-
nextSteps: ["Use ep_id/scn_id#action_index."],
|
|
1149
|
-
errorCode: "ACTION_REF_INVALID",
|
|
1150
|
-
});
|
|
1151
|
-
}
|
|
1152
|
-
const lastHash = value.lastIndexOf("#");
|
|
1153
|
-
const sceneRaw = value.slice(0, lastHash);
|
|
1154
|
-
const indexRaw = value.slice(lastHash + 1);
|
|
1155
|
-
const [epId, sceneId] = parseSceneRef(sceneRaw);
|
|
1156
|
-
const actionIndex = parseInt(indexRaw, 10);
|
|
1157
|
-
if (Number.isNaN(actionIndex)) {
|
|
1158
|
-
throw new CliError("SCRIPT OP BLOCKED: Action index invalid", "Action index invalid.", {
|
|
1159
|
-
exitCode: EXIT_USAGE,
|
|
1160
|
-
required: ["integer action index after '#'"],
|
|
1161
|
-
received: [value],
|
|
1162
|
-
nextSteps: ["Use a zero-based action index."],
|
|
1163
|
-
errorCode: "ACTION_INDEX_INVALID",
|
|
1164
|
-
});
|
|
1165
|
-
}
|
|
1166
|
-
return [epId, sceneId, actionIndex];
|
|
1167
|
-
}
|
|
1168
|
-
function resolveAssetByTarget(script, kind, targetId) {
|
|
1169
|
-
const [key, idKey] = assetKeys(kind);
|
|
1170
|
-
for (const item of asList(script[key])) {
|
|
1171
|
-
if (strOf(item[idKey]) === targetId)
|
|
1172
|
-
return item;
|
|
1173
|
-
}
|
|
1174
|
-
throw new CliError("SCRIPT OP BLOCKED: Asset not found", "Asset not found.", {
|
|
1175
|
-
exitCode: EXIT_USAGE,
|
|
1176
|
-
required: [`existing ${kind} id`],
|
|
1177
|
-
received: [`${kind}:${targetId}`],
|
|
1178
|
-
nextSteps: ["Inspect assets and fix the target."],
|
|
1179
|
-
errorCode: "ASSET_NOT_FOUND",
|
|
1180
|
-
});
|
|
1181
|
-
}
|
|
1182
|
-
function resolveState(script, kind, targetId, stateId) {
|
|
1183
|
-
const asset = resolveAssetByTarget(script, kind, targetId);
|
|
1184
|
-
for (const state of asList(asset["states"])) {
|
|
1185
|
-
if (strOf(state["state_id"]) === stateId)
|
|
1186
|
-
return state;
|
|
1187
|
-
}
|
|
1188
|
-
throw new CliError("SCRIPT OP BLOCKED: State not found", "State not found.", {
|
|
1189
|
-
exitCode: EXIT_USAGE,
|
|
1190
|
-
required: ["existing state_id on the target asset"],
|
|
1191
|
-
received: [`${kind}:${targetId}/${stateId}`],
|
|
1192
|
-
nextSteps: ["Inspect states and fix the target."],
|
|
1193
|
-
errorCode: "STATE_NOT_FOUND",
|
|
1194
|
-
});
|
|
1195
|
-
}
|
|
1196
|
-
function validateStateForTarget(script, kind, targetId, stateId) {
|
|
1197
|
-
const value = strOf(stateId).trim();
|
|
1198
|
-
if (!value)
|
|
1199
|
-
return null;
|
|
1200
|
-
resolveState(script, kind, targetId, value);
|
|
1201
|
-
return value;
|
|
1202
|
-
}
|
|
1203
|
-
function nextSpeakerId(script, sourceKind, sourceId = null) {
|
|
1204
|
-
const existing = new Set();
|
|
1205
|
-
for (const item of asList(script["speakers"]))
|
|
1206
|
-
existing.add(strOf(item["speaker_id"]));
|
|
1207
|
-
if (sourceKind === "actor" && sourceId) {
|
|
1208
|
-
const candidate = `spk_${sourceId}`;
|
|
1209
|
-
if (!existing.has(candidate))
|
|
1210
|
-
return candidate;
|
|
1211
|
-
}
|
|
1212
|
-
let maxSeen = 0;
|
|
1213
|
-
const prefix = `spk_${sourceKind}_`;
|
|
1214
|
-
for (const id of existing) {
|
|
1215
|
-
if (id.startsWith(prefix)) {
|
|
1216
|
-
const m = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\d+)$`).exec(id);
|
|
1217
|
-
if (m)
|
|
1218
|
-
maxSeen = Math.max(maxSeen, parseInt(m[1], 10));
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1221
|
-
return `${prefix}${String(maxSeen + 1).padStart(3, "0")}`;
|
|
1222
|
-
}
|
|
1223
|
-
function speakerSourceActorId(script, speakerId) {
|
|
1224
|
-
for (const speaker of asList(script["speakers"])) {
|
|
1225
|
-
if (strOf(speaker["speaker_id"]) === speakerId && speaker["source_kind"] === "actor") {
|
|
1226
|
-
return strOf(speaker["source_id"]).trim() || null;
|
|
1227
|
-
}
|
|
1228
|
-
}
|
|
1229
|
-
return null;
|
|
1230
|
-
}
|
|
1231
|
-
function ensureSpeakerId(script, speakerId) {
|
|
1232
|
-
const value = strOf(speakerId).trim();
|
|
1233
|
-
const valid = new Set();
|
|
1234
|
-
for (const item of asList(script["speakers"]))
|
|
1235
|
-
valid.add(strOf(item["speaker_id"]));
|
|
1236
|
-
if (!valid.has(value)) {
|
|
1237
|
-
throw new CliError("SCRIPT OP BLOCKED: Speaker not found", "Speaker not found.", {
|
|
1238
|
-
exitCode: EXIT_USAGE,
|
|
1239
|
-
required: ["existing speaker_id"],
|
|
1240
|
-
received: [value || "<empty>"],
|
|
1241
|
-
nextSteps: ["Add a speaker first, or fix the dialogue patch."],
|
|
1242
|
-
errorCode: "SPEAKER_NOT_FOUND",
|
|
1243
|
-
});
|
|
1244
|
-
}
|
|
1245
|
-
return value;
|
|
1246
|
-
}
|
|
1247
|
-
function contextRefsForKind(context, kind) {
|
|
1248
|
-
return asList(context[pluralForKind(kind)]);
|
|
1249
|
-
}
|
|
1250
|
-
function findContextRef(context, kind, targetId) {
|
|
1251
|
-
const idKey = idKeyForKind(kind);
|
|
1252
|
-
for (const ref of contextRefsForKind(context, kind)) {
|
|
1253
|
-
if (strOf(ref[idKey]) === targetId)
|
|
1254
|
-
return ref;
|
|
1255
|
-
}
|
|
1256
|
-
return null;
|
|
1257
|
-
}
|
|
1258
|
-
function setContextState(script, epId, sceneId, kind, targetId, stateId) {
|
|
1259
|
-
ensureRefId(script, kind, targetId);
|
|
1260
|
-
const normalizedState = validateStateForTarget(script, kind, targetId, stateId);
|
|
1261
|
-
const scene = findScene(script, epId, sceneId);
|
|
1262
|
-
const ctx = sceneContext(scene);
|
|
1263
|
-
const idKey = idKeyForKind(kind);
|
|
1264
|
-
// location is single-valued per scene by direct-init invariant; the legacy
|
|
1265
|
-
// set_scene_location_ref op replaced the whole locations[] array to preserve
|
|
1266
|
-
// that. Mirror it here: for location kind, context.set REPLACES (one entry
|
|
1267
|
-
// total). actor/prop stay upsert because multi-actor / multi-prop per scene
|
|
1268
|
-
// is intentional.
|
|
1269
|
-
if (kind === "location") {
|
|
1270
|
-
ctx[pluralForKind(kind)] = [{ [idKey]: targetId, state_id: normalizedState }];
|
|
1271
|
-
setSceneContext(scene, ctx);
|
|
1272
|
-
return;
|
|
1273
|
-
}
|
|
1274
|
-
const refs = [...contextRefsForKind(ctx, kind)];
|
|
1275
|
-
const existing = refs.find((ref) => strOf(ref[idKey]) === targetId);
|
|
1276
|
-
if (existing) {
|
|
1277
|
-
existing["state_id"] = normalizedState;
|
|
1278
|
-
}
|
|
1279
|
-
else {
|
|
1280
|
-
refs.push({ [idKey]: targetId, state_id: normalizedState });
|
|
1281
|
-
}
|
|
1282
|
-
ctx[pluralForKind(kind)] = refs;
|
|
1283
|
-
setSceneContext(scene, ctx);
|
|
1284
|
-
}
|
|
1285
|
-
function inferStateBeforeAction(script, epId, sceneId, actionIndex, kind, targetId) {
|
|
1286
|
-
const scene = findScene(script, epId, sceneId);
|
|
1287
|
-
const ctx = sceneContext(scene);
|
|
1288
|
-
let current = null;
|
|
1289
|
-
const ref = findContextRef(ctx, kind, targetId);
|
|
1290
|
-
if (ref)
|
|
1291
|
-
current = strOf(ref["state_id"]).trim() || null;
|
|
1292
|
-
const actions = asList(scene["actions"]);
|
|
1293
|
-
for (let i = 0; i < actionIndex && i < actions.length; i++) {
|
|
1294
|
-
for (const change of asList(actions[i]["state_changes"])) {
|
|
1295
|
-
if (strOf(change["target_kind"]) === kind && strOf(change["target_id"]) === targetId) {
|
|
1296
|
-
const toState = strOf(change["to_state_id"]).trim();
|
|
1297
|
-
if (toState)
|
|
1298
|
-
current = toState;
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
1301
|
-
}
|
|
1302
|
-
return current;
|
|
1303
|
-
}
|
|
1304
|
-
function stateRefSummary(kind, targetId, stateId, location, role) {
|
|
1305
|
-
return { target: `${kind}:${targetId}/${stateId}`, location, role };
|
|
1306
|
-
}
|
|
1307
|
-
export function collectStateRefs(script, kind, targetId, stateId) {
|
|
1308
|
-
const refs = [];
|
|
1309
|
-
const idKey = idKeyForKind(kind);
|
|
1310
|
-
for (const ep of asList(script["episodes"])) {
|
|
1311
|
-
const epId = strOf(ep["episode_id"]);
|
|
1312
|
-
for (const scene of asList(ep["scenes"])) {
|
|
1313
|
-
const sceneId = strOf(scene["scene_id"]);
|
|
1314
|
-
const ctx = sceneContext(scene);
|
|
1315
|
-
const refsList = contextRefsForKind(ctx, kind);
|
|
1316
|
-
for (let idx = 0; idx < refsList.length; idx++) {
|
|
1317
|
-
const ref = refsList[idx];
|
|
1318
|
-
if (strOf(ref[idKey]) === targetId && strOf(ref["state_id"]) === stateId) {
|
|
1319
|
-
refs.push(stateRefSummary(kind, targetId, stateId, `${epId}/${sceneId}.context.${pluralForKind(kind)}[${idx}]`, "scene_initial_state"));
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
const actions = asList(scene["actions"]);
|
|
1323
|
-
for (let actionIdx = 0; actionIdx < actions.length; actionIdx++) {
|
|
1324
|
-
const changes = asList(actions[actionIdx]["state_changes"]);
|
|
1325
|
-
for (let changeIdx = 0; changeIdx < changes.length; changeIdx++) {
|
|
1326
|
-
const change = changes[changeIdx];
|
|
1327
|
-
if (strOf(change["target_kind"]) !== kind || strOf(change["target_id"]) !== targetId)
|
|
1328
|
-
continue;
|
|
1329
|
-
if (strOf(change["from_state_id"]) === stateId) {
|
|
1330
|
-
refs.push(stateRefSummary(kind, targetId, stateId, `${epId}/${sceneId}#${actionIdx}.state_changes[${changeIdx}].from_state_id`, "state_change_from"));
|
|
1331
|
-
}
|
|
1332
|
-
if (strOf(change["to_state_id"]) === stateId) {
|
|
1333
|
-
refs.push(stateRefSummary(kind, targetId, stateId, `${epId}/${sceneId}#${actionIdx}.state_changes[${changeIdx}].to_state_id`, "state_change_to"));
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1336
|
-
}
|
|
1337
|
-
}
|
|
1338
|
-
}
|
|
1339
|
-
return refs;
|
|
1340
|
-
}
|
|
1341
|
-
// Try each format in order. The order matters: action (has `#`) is checked
|
|
1342
|
-
// before scene (just `/`), and asset+state (has `:`) is checked before scene.
|
|
1343
|
-
// Episode and speaker are bare ids (no separators) distinguished by prefix.
|
|
1344
|
-
export function parseAnyAddress(raw) {
|
|
1345
|
-
const value = strOf(raw).trim();
|
|
1346
|
-
if (!value) {
|
|
1347
|
-
throw new CliError("ADDRESS BLOCKED: Address empty", "Address empty.", {
|
|
1348
|
-
exitCode: EXIT_USAGE,
|
|
1349
|
-
required: ["one of the supported address formats"],
|
|
1350
|
-
received: ["<empty>"],
|
|
1351
|
-
nextSteps: ["Pass an address like ep_001/scn_001#3, ep_001/scn_001, actor:act_001, actor:act_001/st_001, ep_001, or spk_001."],
|
|
1352
|
-
errorCode: "ADDRESS_EMPTY",
|
|
1353
|
-
});
|
|
1354
|
-
}
|
|
1355
|
-
if (value.includes("#")) {
|
|
1356
|
-
const [episodeId, sceneId, actionIndex] = parseActionRef(value);
|
|
1357
|
-
return { kind: "action", episodeId, sceneId, actionIndex };
|
|
1358
|
-
}
|
|
1359
|
-
if (value.includes(":")) {
|
|
1360
|
-
if (value.slice(value.indexOf(":") + 1).includes("/")) {
|
|
1361
|
-
const [assetKind, assetId, stateId] = parseStateTarget(value);
|
|
1362
|
-
return { kind: "state", assetKind, assetId, stateId };
|
|
1363
|
-
}
|
|
1364
|
-
const [assetKind, assetId] = parseAssetTarget(value);
|
|
1365
|
-
return { kind: "asset", assetKind, assetId };
|
|
1366
|
-
}
|
|
1367
|
-
if (value.includes("/")) {
|
|
1368
|
-
const [episodeId, sceneId] = parseSceneRef(value);
|
|
1369
|
-
return { kind: "scene", episodeId, sceneId };
|
|
1370
|
-
}
|
|
1371
|
-
if (/^ep_/.test(value)) {
|
|
1372
|
-
return { kind: "episode", episodeId: value };
|
|
1373
|
-
}
|
|
1374
|
-
if (/^spk_/.test(value)) {
|
|
1375
|
-
return { kind: "speaker", speakerId: value };
|
|
1376
|
-
}
|
|
1377
|
-
throw new CliError("ADDRESS BLOCKED: Address invalid", "Address invalid.", {
|
|
1378
|
-
exitCode: EXIT_USAGE,
|
|
1379
|
-
required: [
|
|
1380
|
-
"address: ep_NNN/scn_NNN#idx (action) | ep_NNN/scn_NNN (scene) | actor|location|prop:id (asset) | actor|location|prop:id/state_id (state) | ep_NNN (episode) | spk_XXX (speaker)",
|
|
1381
|
-
],
|
|
1382
|
-
received: [value],
|
|
1383
|
-
nextSteps: ["Use a recognized address format."],
|
|
1384
|
-
errorCode: "ADDRESS_INVALID",
|
|
1385
|
-
});
|
|
1386
|
-
}
|
|
1387
|
-
// Reverse lookup for asset / speaker references. Parallel to collectStateRefs
|
|
1388
|
-
// but tracks where an actor/location/prop/speaker is mentioned rather than
|
|
1389
|
-
// where a specific state is mentioned. Returns one `{target, location, role}`
|
|
1390
|
-
// entry per reference site. Walks the same edges the validator does
|
|
1391
|
-
// (script-core.ts validateScript) so coverage is complete:
|
|
1392
|
-
// - scene.context.{actors|locations|props}[]
|
|
1393
|
-
// - action.actor_id (actor only)
|
|
1394
|
-
// - action.speaker_id + action.speakers[] + action.lines[].speaker_id (speaker only)
|
|
1395
|
-
// - action.state_changes[].target_{kind,id}
|
|
1396
|
-
// - action.transition_prompt.target_{kind,id}
|
|
1397
|
-
// - script.speakers[].source_{kind,id} (for actor/location/prop reverse)
|
|
1398
|
-
export function collectAssetRefs(script, kind, targetId) {
|
|
1399
|
-
const refs = [];
|
|
1400
|
-
const isSpeaker = kind === "speaker";
|
|
1401
|
-
const target = isSpeaker ? `speaker:${targetId}` : `${kind}:${targetId}`;
|
|
1402
|
-
const idKey = isSpeaker ? null : idKeyForKind(kind);
|
|
1403
|
-
for (const ep of asList(script["episodes"])) {
|
|
1404
|
-
const epId = strOf(ep["episode_id"]);
|
|
1405
|
-
for (const scene of asList(ep["scenes"])) {
|
|
1406
|
-
const sceneId = strOf(scene["scene_id"]);
|
|
1407
|
-
if (!isSpeaker) {
|
|
1408
|
-
const ctx = sceneContext(scene);
|
|
1409
|
-
const refsList = contextRefsForKind(ctx, kind);
|
|
1410
|
-
for (let i = 0; i < refsList.length; i++) {
|
|
1411
|
-
if (strOf(refsList[i][idKey]) === targetId) {
|
|
1412
|
-
refs.push({
|
|
1413
|
-
target,
|
|
1414
|
-
location: `${epId}/${sceneId}.context.${pluralForKind(kind)}[${i}]`,
|
|
1415
|
-
role: `scene_${pluralForKind(kind)}`,
|
|
1416
|
-
});
|
|
1417
|
-
}
|
|
1418
|
-
}
|
|
1419
|
-
}
|
|
1420
|
-
const actions = asList(scene["actions"]);
|
|
1421
|
-
for (let actionIdx = 0; actionIdx < actions.length; actionIdx++) {
|
|
1422
|
-
const action = actions[actionIdx];
|
|
1423
|
-
if (kind === "actor" && strOf(action["actor_id"]) === targetId) {
|
|
1424
|
-
refs.push({
|
|
1425
|
-
target,
|
|
1426
|
-
location: `${epId}/${sceneId}#${actionIdx}.actor_id`,
|
|
1427
|
-
role: "action_actor",
|
|
1428
|
-
});
|
|
1429
|
-
}
|
|
1430
|
-
if (isSpeaker) {
|
|
1431
|
-
if (strOf(action["speaker_id"]) === targetId) {
|
|
1432
|
-
refs.push({
|
|
1433
|
-
target,
|
|
1434
|
-
location: `${epId}/${sceneId}#${actionIdx}.speaker_id`,
|
|
1435
|
-
role: "action_speaker",
|
|
1436
|
-
});
|
|
1437
|
-
}
|
|
1438
|
-
const speakers = asList(action["speakers"]);
|
|
1439
|
-
for (let i = 0; i < speakers.length; i++) {
|
|
1440
|
-
if (strOf(speakers[i]["speaker_id"]) === targetId) {
|
|
1441
|
-
refs.push({
|
|
1442
|
-
target,
|
|
1443
|
-
location: `${epId}/${sceneId}#${actionIdx}.speakers[${i}]`,
|
|
1444
|
-
role: "dialogue_speaker",
|
|
1445
|
-
});
|
|
1446
|
-
}
|
|
1447
|
-
}
|
|
1448
|
-
const lines = asList(action["lines"]);
|
|
1449
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1450
|
-
if (strOf(lines[i]["speaker_id"]) === targetId) {
|
|
1451
|
-
refs.push({
|
|
1452
|
-
target,
|
|
1453
|
-
location: `${epId}/${sceneId}#${actionIdx}.lines[${i}].speaker_id`,
|
|
1454
|
-
role: "overlap_line_speaker",
|
|
1455
|
-
});
|
|
1456
|
-
}
|
|
1457
|
-
}
|
|
1458
|
-
}
|
|
1459
|
-
if (!isSpeaker) {
|
|
1460
|
-
const changes = asList(action["state_changes"]);
|
|
1461
|
-
for (let i = 0; i < changes.length; i++) {
|
|
1462
|
-
const change = changes[i];
|
|
1463
|
-
if (strOf(change["target_kind"]) === kind && strOf(change["target_id"]) === targetId) {
|
|
1464
|
-
refs.push({
|
|
1465
|
-
target,
|
|
1466
|
-
location: `${epId}/${sceneId}#${actionIdx}.state_changes[${i}]`,
|
|
1467
|
-
role: "state_change_target",
|
|
1468
|
-
});
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
const transition = action["transition_prompt"];
|
|
1472
|
-
if (isDict(transition) && strOf(transition["target_kind"]) === kind && strOf(transition["target_id"]) === targetId) {
|
|
1473
|
-
refs.push({
|
|
1474
|
-
target,
|
|
1475
|
-
location: `${epId}/${sceneId}#${actionIdx}.transition_prompt`,
|
|
1476
|
-
role: "transition_target",
|
|
1477
|
-
});
|
|
1478
|
-
}
|
|
1479
|
-
}
|
|
1480
|
-
}
|
|
1481
|
-
}
|
|
1482
|
-
}
|
|
1483
|
-
// Speaker registrations pointing at this asset (actor/location/prop only).
|
|
1484
|
-
// A speaker.source_id ties an actor/location/prop to a speaker entity; the
|
|
1485
|
-
// speaker entry itself counts as a reference, so deleting / merging the
|
|
1486
|
-
// asset must surface it.
|
|
1487
|
-
if (!isSpeaker) {
|
|
1488
|
-
const speakers = asList(script["speakers"]);
|
|
1489
|
-
for (let i = 0; i < speakers.length; i++) {
|
|
1490
|
-
const s = speakers[i];
|
|
1491
|
-
if (strOf(s["source_kind"]) === kind && strOf(s["source_id"]) === targetId) {
|
|
1492
|
-
refs.push({
|
|
1493
|
-
target,
|
|
1494
|
-
location: `speakers[${i}]`,
|
|
1495
|
-
role: "speaker_source",
|
|
1496
|
-
});
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1500
|
-
return refs;
|
|
1501
|
-
}
|
|
1502
|
-
// Asset id prefixes. props use `prp_` (not `prop_`); mirrors direct-init output.
|
|
1503
|
-
const ASSET_ID_PREFIX = { actor: "act", location: "loc", prop: "prp" };
|
|
1504
|
-
// Next free `<prefix>_NNN` id given the existing ids in a collection. Scans for
|
|
1505
|
-
// the max NNN so a manually-chosen id (e.g. act_007) never collides with a later
|
|
1506
|
-
// auto-id. The single id-allocation routine behind nextScene/Asset/Episode/State.
|
|
1507
|
-
function nextSeqId(existingIds, prefix) {
|
|
1508
|
-
const re = new RegExp(`^${prefix}_(\\d+)$`);
|
|
1509
|
-
let maxSeen = 0;
|
|
1510
|
-
for (const raw of existingIds) {
|
|
1511
|
-
const m = re.exec(strOf(raw));
|
|
1512
|
-
if (m)
|
|
1513
|
-
maxSeen = Math.max(maxSeen, parseInt(m[1], 10));
|
|
1514
|
-
}
|
|
1515
|
-
return fmtId(prefix, maxSeen + 1);
|
|
1516
|
-
}
|
|
1517
|
-
function nextSceneId(script) {
|
|
1518
|
-
const ids = asList(script["episodes"]).flatMap((ep) => asList(ep["scenes"]).map((s) => s["scene_id"]));
|
|
1519
|
-
return nextSeqId(ids, "scn");
|
|
1520
|
-
}
|
|
1521
|
-
function nextAssetId(script, kind) {
|
|
1522
|
-
const [key, idKey] = assetKeys(kind);
|
|
1523
|
-
return nextSeqId(asList(script[key]).map((item) => item[idKey]), ASSET_ID_PREFIX[kind]);
|
|
1524
|
-
}
|
|
1525
|
-
function nextEpisodeId(script) {
|
|
1526
|
-
return nextSeqId(asList(script["episodes"]).map((ep) => ep["episode_id"]), "ep");
|
|
1527
|
-
}
|
|
1528
|
-
function sceneIdExists(script, sceneId) {
|
|
1529
|
-
for (const ep of asList(script["episodes"])) {
|
|
1530
|
-
for (const scene of asList(ep["scenes"])) {
|
|
1531
|
-
if (scene["scene_id"] === sceneId)
|
|
1532
|
-
return true;
|
|
1533
|
-
}
|
|
1534
|
-
}
|
|
1535
|
-
return false;
|
|
1536
|
-
}
|
|
1537
|
-
// Shared body for actor.add / location.add / prop.add: validate the name,
|
|
1538
|
-
// allocate or accept an id, reject duplicates, and append a minimal entity
|
|
1539
|
-
// ({id, name, description, states:[]}). The caller layers on kind-specific
|
|
1540
|
-
// fields (actor role_type/aliases). Returns the pushed entity so the caller can
|
|
1541
|
-
// extend it. description defaults to "" — an empty description is a completeness
|
|
1542
|
-
// warning, not a hard error, so you can register an asset now and describe it
|
|
1543
|
-
// later.
|
|
1544
|
-
function pushAsset(script, kind, op) {
|
|
1545
|
-
const [collKey, idKey, nameKey] = assetKeys(kind);
|
|
1546
|
-
const name = strOf(op["name"] ?? op[nameKey]).trim();
|
|
1547
|
-
if (!name) {
|
|
1548
|
-
throw opErr("SCRIPT OP BLOCKED: Asset name empty", "Asset name empty.", {
|
|
1549
|
-
required: ["name"], received: [JSON.stringify(op)],
|
|
1550
|
-
nextSteps: [`Provide a ${kind} name.`], op: `${kind}.add`, errorCode: "ASSET_NAME_EMPTY",
|
|
1551
|
-
});
|
|
1552
|
-
}
|
|
1553
|
-
// name is already whitespace-trimmed; cleanName additionally strips trailing/
|
|
1554
|
-
// leading punctuation (:,、…) and embedded 【】() state annotations. Reuse the
|
|
1555
|
-
// same errorCode validateScript uses for this rule (ASSET_NAME_HAS_STATE_ANNOTATION)
|
|
1556
|
-
// so op-time and validate-time speak one vocabulary; the message stays accurate
|
|
1557
|
-
// for the punctuation-only case too. Suggest the canonical form when there is one
|
|
1558
|
-
// (a name that is entirely punctuation/annotation reduces to "").
|
|
1559
|
-
const canonical = cleanName(name);
|
|
1560
|
-
if (canonical !== name) {
|
|
1561
|
-
const suggestion = canonical
|
|
1562
|
-
? `Use "${canonical}", and register durable looks with state-add instead of putting them in the name.`
|
|
1563
|
-
: "Provide a real name (this one is only punctuation/annotation); register durable looks with state-add.";
|
|
1564
|
-
throw opErr("SCRIPT OP BLOCKED: Asset name not canonical", "Asset name is not canonical (trailing/leading punctuation or an embedded state annotation).", {
|
|
1565
|
-
required: [canonical ? `canonical name, e.g. "${canonical}"` : "a canonical name without punctuation/annotation"],
|
|
1566
|
-
received: [name],
|
|
1567
|
-
nextSteps: [suggestion], op: `${kind}.add`, errorCode: "ASSET_NAME_HAS_STATE_ANNOTATION",
|
|
1568
|
-
});
|
|
1569
|
-
}
|
|
1570
|
-
const id = strOf(op[idKey] ?? op["id"]).trim() || nextAssetId(script, kind);
|
|
1571
|
-
if (!isList(script[collKey]))
|
|
1572
|
-
script[collKey] = [];
|
|
1573
|
-
const coll = script[collKey];
|
|
1574
|
-
if (coll.some((a) => strOf(a[idKey]) === id)) {
|
|
1575
|
-
throw opErr("SCRIPT OP BLOCKED: Asset id exists", "Asset id exists.", {
|
|
1576
|
-
required: ["unused id"], received: [id],
|
|
1577
|
-
nextSteps: ["Use another --id or omit it to auto-allocate."], op: `${kind}.add`, errorCode: "ASSET_ID_EXISTS",
|
|
1578
|
-
});
|
|
1579
|
-
}
|
|
1580
|
-
const entity = { [idKey]: id, [nameKey]: name, description: strOf(op["description"]).trim(), states: [] };
|
|
1581
|
-
coll.push(entity);
|
|
1582
|
-
return entity;
|
|
1583
|
-
}
|
|
1584
|
-
function renumberSceneIds(script) {
|
|
1585
|
-
let counter = 0;
|
|
1586
|
-
for (const ep of asList(script["episodes"])) {
|
|
1587
|
-
for (const scene of asList(ep["scenes"])) {
|
|
1588
|
-
counter += 1;
|
|
1589
|
-
scene["scene_id"] = fmtId("scn", counter);
|
|
1590
|
-
}
|
|
1591
|
-
}
|
|
1592
|
-
}
|
|
1593
|
-
// ---------------------------------------------------------------------------
|
|
1594
|
-
// apply_patch_operations
|
|
1595
|
-
// ---------------------------------------------------------------------------
|
|
1596
|
-
function opErr(title, message, opts = {}) {
|
|
1597
|
-
return new CliError(title, message, {
|
|
1598
|
-
exitCode: opts.exitCode ?? EXIT_USAGE,
|
|
1599
|
-
required: opts.required,
|
|
1600
|
-
received: opts.received,
|
|
1601
|
-
nextSteps: opts.nextSteps,
|
|
1602
|
-
op: opts.op,
|
|
1603
|
-
errorCode: opts.errorCode,
|
|
1604
|
-
});
|
|
1605
|
-
}
|
|
1606
|
-
export const PATCH_OP_SCHEMA = {
|
|
1607
|
-
// meta
|
|
1608
|
-
"meta.worldview.set": { required: ["worldview"], optional: ["worldview_raw"], description: "Set the script.worldview enum." },
|
|
1609
|
-
// state
|
|
1610
|
-
"state.add": { required: ["target", "name"], optional: ["description", "state_id"], description: "Add a state to an asset's states[]." },
|
|
1611
|
-
"state.rename": { required: ["target", "name"], optional: [], description: "Rename an asset state." },
|
|
1612
|
-
"state.describe": { required: ["target", "description"], optional: [], description: "Set a state's description." },
|
|
1613
|
-
"state.delete": { required: ["target", "strategy"], optional: ["replacement"], description: "Delete a state. strategy: replace|remove." },
|
|
1614
|
-
// asset
|
|
1615
|
-
"actor.add": { required: ["name"], optional: ["actor_id", "role_type", "description", "aliases"], description: "Register a new actor (human/character). role_type: 主角|配角." },
|
|
1616
|
-
"location.add": { required: ["name"], optional: ["location_id", "description"], description: "Register a new location." },
|
|
1617
|
-
"prop.add": { required: ["name"], optional: ["prop_id", "description"], description: "Register a new prop." },
|
|
1618
|
-
"episode.add": { required: [], optional: ["episode_id", "title"], description: "Append a new empty episode (no scenes)." },
|
|
1619
|
-
"asset.rename": { required: ["target", "name"], optional: [], description: "Rename an asset (actor / location / prop)." },
|
|
1620
|
-
"asset.describe": { required: ["target", "description"], optional: [], description: "Set asset description." },
|
|
1621
|
-
"asset.alias.set": { required: ["target", "aliases"], optional: [], description: "Bulk replace aliases[]." },
|
|
1622
|
-
"asset.alias.add": { required: ["target", "alias"], optional: [], description: "Append alias(es)." },
|
|
1623
|
-
"asset.alias.remove": { required: ["target", "alias"], optional: [], description: "Remove alias(es)." },
|
|
1624
|
-
"asset.states.set": { required: ["target", "states"], optional: ["asset_type"], description: "Bulk replace states[]." },
|
|
1625
|
-
"asset.role.set": { required: ["target", "role_type"], optional: [], description: "Set actor role_type (主角|配角)." },
|
|
1626
|
-
"asset.merge": { required: ["from", "into"], optional: [], description: "Merge from asset into another; from disappears." },
|
|
1627
|
-
"asset.delete": { required: ["target"], optional: ["strategy", "replacement"], description: "Delete an asset. strategy: replace|remove (required if refs exist)." },
|
|
1628
|
-
// action
|
|
1629
|
-
"action.type.set": { required: ["at", "type"], optional: [], description: "Set action.type (dialogue|action|inner_thought)." },
|
|
1630
|
-
"action.actor.set": { required: ["at"], optional: ["actor_id"], description: "Set/clear action.actor_id." },
|
|
1631
|
-
"action.content.replace": { required: ["at", "from"], optional: ["to", "all"], description: "Literal substring replace in action.content." },
|
|
1632
|
-
"action.state.change": { required: ["at", "target", "to"], optional: ["from", "effective"], description: "Add/update a state_change on an action." },
|
|
1633
|
-
"action.state.remove": { required: ["at", "target"], optional: [], description: "Remove a state_change from an action." },
|
|
1634
|
-
"action.transition.set": { required: ["at", "target", "process", "contrast"], optional: [], description: "Set transition_prompt." },
|
|
1635
|
-
"action.transition.clear": { required: ["at", "target"], optional: [], description: "Clear transition_prompt." },
|
|
1636
|
-
"action.delete": { required: ["at"], optional: [], description: "Remove an action; later indices shift." },
|
|
1637
|
-
"action.insert": { required: ["at", "type"], optional: ["content", "at_index", "before", "after", "actor_id", "speaker_id"], description: "Insert an action into a scene." },
|
|
1638
|
-
"action.move": { required: ["at", "to"], optional: [], description: "Move an action (within or across scenes)." },
|
|
1639
|
-
// scene
|
|
1640
|
-
"scene.split": { required: ["at", "at_index"], optional: ["new_scene_id"], description: "Split a scene at an action boundary." },
|
|
1641
|
-
"scene.merge": { required: ["from", "into"], optional: [], description: "Merge two scenes within the same episode." },
|
|
1642
|
-
"scene.delete": { required: ["at"], optional: ["force"], description: "Delete a scene; empty by default, force=true for non-empty." },
|
|
1643
|
-
"scene.move": { required: ["at", "to"], optional: ["at_index"], description: "Move a scene to another episode." },
|
|
1644
|
-
"scene.insert": { required: ["at", "location"], optional: ["time", "space", "at_index", "before", "after", "scene_id"], description: "Insert a new empty scene." },
|
|
1645
|
-
// context
|
|
1646
|
-
"context.set": { required: ["at", "target"], optional: ["state", "state_id"], description: "Upsert (actor/prop) or replace (location) scene context ref + state." },
|
|
1647
|
-
"context.clear": { required: ["at", "target"], optional: [], description: "Keep ref, clear state_id." },
|
|
1648
|
-
"context.ref.remove": { required: ["at", "target"], optional: [], description: "Remove the ref entirely from scene context." },
|
|
1649
|
-
// dialogue / speaker
|
|
1650
|
-
"dialogue.speakers": { required: ["at", "speakers"], optional: ["delivery"], description: "Set dialogue speakers; delivery: single|simultaneous|group." },
|
|
1651
|
-
"dialogue.overlap": { required: ["at", "lines"], optional: [], description: "Set overlap dialogue lines[]." },
|
|
1652
|
-
"speaker.add": { required: ["kind", "name"], optional: ["source_id", "voice_desc", "speaker_id"], description: "Register a new speaker." },
|
|
1653
|
-
"speaker.delete": { required: ["target"], optional: ["strategy", "replacement"], description: "Delete a speaker. strategy: replace|remove (required if refs exist)." },
|
|
1654
|
-
};
|
|
1655
|
-
export function applyPatchOperations(script, sourceText, operations) {
|
|
1656
|
-
const applied = [];
|
|
1657
|
-
for (const op of operations) {
|
|
1658
|
-
const kind = strOf(op["op"]);
|
|
1659
|
-
if (kind === "state.add") {
|
|
1660
|
-
const [targetKind, targetId] = parseAssetTarget(op["target"]);
|
|
1661
|
-
const asset = resolveAssetByTarget(script, targetKind, targetId);
|
|
1662
|
-
const name = strOf(op["name"] || op["state_name"]).trim();
|
|
1663
|
-
const description = strOf(op["description"]).trim();
|
|
1664
|
-
const stateId = strOf(op["state_id"]).trim() || nextStateId(script);
|
|
1665
|
-
if (!name) {
|
|
1666
|
-
throw opErr("SCRIPT OP BLOCKED: State name empty", "State name empty.", {
|
|
1667
|
-
required: ["name"], received: [JSON.stringify(op)],
|
|
1668
|
-
nextSteps: ["Provide a state name."], op: kind, errorCode: "STATE_NAME_EMPTY",
|
|
1669
|
-
});
|
|
1670
|
-
}
|
|
1671
|
-
const reason = stateRejectionReason(targetKind, name);
|
|
1672
|
-
if (reason) {
|
|
1673
|
-
throw opErr("SCRIPT OP BLOCKED: State label invalid", "State label invalid.", {
|
|
1674
|
-
required: ["one clear state label"], received: [name],
|
|
1675
|
-
nextSteps: ["Do not combine multiple states into one label."], op: kind, errorCode: "STATE_LABEL_INVALID",
|
|
1676
|
-
});
|
|
1677
|
-
}
|
|
1678
|
-
if (!isList(asset["states"]))
|
|
1679
|
-
asset["states"] = [];
|
|
1680
|
-
const existingStates = asset["states"];
|
|
1681
|
-
if (existingStates.some((s) => strOf(s["state_id"]) === stateId)) {
|
|
1682
|
-
throw opErr("SCRIPT OP BLOCKED: State id exists", "State id exists.", { required: ["unused state_id"], received: [stateId], nextSteps: ["Use another state_id or edit the existing state."], op: kind, errorCode: "STATE_ID_EXISTS" });
|
|
1683
|
-
}
|
|
1684
|
-
if (existingStates.some((s) => strOf(s["state_name"] || s["name"]) === name)) {
|
|
1685
|
-
throw opErr("SCRIPT OP BLOCKED: State name exists", "State name exists.", { required: ["unused state name"], received: [name], nextSteps: ["Rename the existing state or choose another name."], op: kind, errorCode: "STATE_NAME_EXISTS" });
|
|
1686
|
-
}
|
|
1687
|
-
const state = { state_id: stateId, state_name: name };
|
|
1688
|
-
if (description)
|
|
1689
|
-
state["description"] = description;
|
|
1690
|
-
existingStates.push(state);
|
|
1691
|
-
op["state_id"] = stateId;
|
|
1692
|
-
applied.push(kind);
|
|
1693
|
-
}
|
|
1694
|
-
else if (kind === "state.rename") {
|
|
1695
|
-
const [targetKind, targetId, stateId] = parseStateTarget(op["target"]);
|
|
1696
|
-
const state = resolveState(script, targetKind, targetId, stateId);
|
|
1697
|
-
const name = strOf(op["name"] || op["state_name"]).trim();
|
|
1698
|
-
if (!name) {
|
|
1699
|
-
throw opErr("SCRIPT OP BLOCKED: State name empty", "State name empty.", { required: ["name"], received: [JSON.stringify(op)], nextSteps: ["Provide a state name."], op: kind, errorCode: "STATE_NAME_EMPTY" });
|
|
1700
|
-
}
|
|
1701
|
-
const reason = stateRejectionReason(targetKind, name);
|
|
1702
|
-
if (reason) {
|
|
1703
|
-
throw opErr("SCRIPT OP BLOCKED: State label invalid", "State label invalid.", { required: ["one clear state label"], received: [name], nextSteps: ["Do not combine multiple states into one label."], op: kind, errorCode: "STATE_LABEL_INVALID" });
|
|
1704
|
-
}
|
|
1705
|
-
state["state_name"] = name;
|
|
1706
|
-
applied.push(kind);
|
|
1707
|
-
}
|
|
1708
|
-
else if (kind === "state.describe") {
|
|
1709
|
-
const [targetKind, targetId, stateId] = parseStateTarget(op["target"]);
|
|
1710
|
-
const state = resolveState(script, targetKind, targetId, stateId);
|
|
1711
|
-
state["description"] = strOf(op["description"]).trim();
|
|
1712
|
-
applied.push(kind);
|
|
1713
|
-
}
|
|
1714
|
-
else if (kind === "state.delete") {
|
|
1715
|
-
const [targetKind, targetId, stateId] = parseStateTarget(op["target"]);
|
|
1716
|
-
const asset = resolveAssetByTarget(script, targetKind, targetId);
|
|
1717
|
-
resolveState(script, targetKind, targetId, stateId);
|
|
1718
|
-
const strategy = strOf(op["strategy"]).trim();
|
|
1719
|
-
const replacement = strOf(op["replacement"]).trim();
|
|
1720
|
-
if (strategy !== "replace" && strategy !== "remove") {
|
|
1721
|
-
throw opErr("SCRIPT OP BLOCKED: Delete strategy invalid", "Delete strategy invalid.", { required: ["strategy: replace or remove"], received: [strategy || "<empty>"], nextSteps: ["Choose replace with --replacement, or remove."], op: kind, errorCode: "DELETE_STRATEGY_INVALID" });
|
|
1722
|
-
}
|
|
1723
|
-
if (strategy === "replace") {
|
|
1724
|
-
if (!replacement) {
|
|
1725
|
-
throw opErr("SCRIPT OP BLOCKED: Replacement missing", "Replacement missing.", { required: ["replacement state_id"], received: ["<empty>"], nextSteps: ["Pass --replacement with an existing state_id."], op: kind, errorCode: "REPLACEMENT_MISSING" });
|
|
1726
|
-
}
|
|
1727
|
-
validateStateForTarget(script, targetKind, targetId, replacement);
|
|
1728
|
-
}
|
|
1729
|
-
const contextField = pluralForKind(targetKind);
|
|
1730
|
-
const idKey = idKeyForKind(targetKind);
|
|
1731
|
-
for (const ep of asList(script["episodes"])) {
|
|
1732
|
-
for (const scene of asList(ep["scenes"])) {
|
|
1733
|
-
const ctx = sceneContext(scene);
|
|
1734
|
-
for (const ref of contextRefsForKind(ctx, targetKind)) {
|
|
1735
|
-
if (strOf(ref[idKey]) === targetId && strOf(ref["state_id"]) === stateId) {
|
|
1736
|
-
ref["state_id"] = strategy === "replace" ? replacement : null;
|
|
1737
|
-
}
|
|
1738
|
-
}
|
|
1739
|
-
ctx[contextField] = contextRefsForKind(ctx, targetKind);
|
|
1740
|
-
setSceneContext(scene, ctx);
|
|
1741
|
-
for (const action of asList(scene["actions"])) {
|
|
1742
|
-
const nextChanges = [];
|
|
1743
|
-
for (const change of asList(action["state_changes"])) {
|
|
1744
|
-
if (strOf(change["target_kind"]) === targetKind && strOf(change["target_id"]) === targetId) {
|
|
1745
|
-
let touched = false;
|
|
1746
|
-
for (const stateField of ["from_state_id", "to_state_id"]) {
|
|
1747
|
-
if (strOf(change[stateField]) === stateId) {
|
|
1748
|
-
touched = true;
|
|
1749
|
-
if (strategy === "replace")
|
|
1750
|
-
change[stateField] = replacement;
|
|
1751
|
-
}
|
|
1752
|
-
}
|
|
1753
|
-
if (strategy === "remove" && touched)
|
|
1754
|
-
continue;
|
|
1755
|
-
}
|
|
1756
|
-
nextChanges.push(change);
|
|
1757
|
-
}
|
|
1758
|
-
if (action["state_changes"] !== undefined && action["state_changes"] !== null) {
|
|
1759
|
-
if (nextChanges.length > 0)
|
|
1760
|
-
action["state_changes"] = nextChanges;
|
|
1761
|
-
else
|
|
1762
|
-
delete action["state_changes"];
|
|
1763
|
-
}
|
|
1764
|
-
}
|
|
1765
|
-
}
|
|
1766
|
-
}
|
|
1767
|
-
asset["states"] = asList(asset["states"]).filter((s) => strOf(s["state_id"]) !== stateId);
|
|
1768
|
-
applied.push(kind);
|
|
1769
|
-
}
|
|
1770
|
-
else if (kind === "context.set") {
|
|
1771
|
-
const [epId, sceneId] = parseSceneRef(op["at"]);
|
|
1772
|
-
const [targetKind, targetId] = parseAssetTarget(op["target"]);
|
|
1773
|
-
setContextState(script, epId, sceneId, targetKind, targetId, strOf(op["state"] || op["state_id"]).trim());
|
|
1774
|
-
applied.push(kind);
|
|
1775
|
-
}
|
|
1776
|
-
else if (kind === "context.clear") {
|
|
1777
|
-
const [epId, sceneId] = parseSceneRef(op["at"]);
|
|
1778
|
-
const [targetKind, targetId] = parseAssetTarget(op["target"]);
|
|
1779
|
-
setContextState(script, epId, sceneId, targetKind, targetId, null);
|
|
1780
|
-
applied.push(kind);
|
|
1781
|
-
}
|
|
1782
|
-
else if (kind === "action.state.change") {
|
|
1783
|
-
const [epId, sceneId, actionIndex] = parseActionRef(op["at"]);
|
|
1784
|
-
const [targetKind, targetId] = parseAssetTarget(op["target"]);
|
|
1785
|
-
ensureRefId(script, targetKind, targetId);
|
|
1786
|
-
const toState = validateStateForTarget(script, targetKind, targetId, strOf(op["to"] || op["to_state_id"]).trim());
|
|
1787
|
-
if (!toState) {
|
|
1788
|
-
throw opErr("SCRIPT OP BLOCKED: Target state missing", "Target state missing.", { required: ["to state_id"], received: [JSON.stringify(op)], nextSteps: ["Pass --to with an existing state_id."], op: kind, errorCode: "TO_STATE_MISSING" });
|
|
1789
|
-
}
|
|
1790
|
-
const fromStateExplicit = strOf(op["from"] || op["from_state_id"]).trim();
|
|
1791
|
-
const fromState = fromStateExplicit || inferStateBeforeAction(script, epId, sceneId, actionIndex, targetKind, targetId);
|
|
1792
|
-
if (!fromState) {
|
|
1793
|
-
throw opErr("SCRIPT OP BLOCKED: From state ambiguous", "Unable to infer current state before this action.", { required: ["scene initial state or --from"], received: [`${op["at"]} ${op["target"]}`], nextSteps: ["Set scene context first, or pass --from explicitly.", "先设置场景初始状态,或修复前序状态变化。"], op: kind, errorCode: "FROM_STATE_AMBIGUOUS" });
|
|
1794
|
-
}
|
|
1795
|
-
validateStateForTarget(script, targetKind, targetId, fromState);
|
|
1796
|
-
const effective = strOf(op["effective"] || op["effective_from"] || "after").trim();
|
|
1797
|
-
const effectiveFrom = effective === "after" ? "after_action" : effective === "before" ? "before_action" : effective;
|
|
1798
|
-
if (!EFFECTIVE_FROM_VALUES.has(effectiveFrom)) {
|
|
1799
|
-
throw opErr("SCRIPT OP BLOCKED: Effective invalid", "Effective invalid.", { required: ["effective: after or before"], received: [effective], nextSteps: ["Use after or before."], op: kind, errorCode: "EFFECTIVE_INVALID" });
|
|
1800
|
-
}
|
|
1801
|
-
const action = findAction(script, epId, sceneId, actionIndex);
|
|
1802
|
-
const changes = asList(action["state_changes"]).filter((c) => !(strOf(c["target_kind"]) === targetKind && strOf(c["target_id"]) === targetId));
|
|
1803
|
-
changes.push({
|
|
1804
|
-
target_kind: targetKind,
|
|
1805
|
-
target_id: targetId,
|
|
1806
|
-
from_state_id: fromState,
|
|
1807
|
-
to_state_id: toState,
|
|
1808
|
-
effective_from: effectiveFrom,
|
|
1809
|
-
});
|
|
1810
|
-
action["state_changes"] = changes;
|
|
1811
|
-
applied.push(kind);
|
|
1812
|
-
}
|
|
1813
|
-
else if (kind === "action.state.remove") {
|
|
1814
|
-
const [epId, sceneId, actionIndex] = parseActionRef(op["at"]);
|
|
1815
|
-
const [targetKind, targetId] = parseAssetTarget(op["target"]);
|
|
1816
|
-
const action = findAction(script, epId, sceneId, actionIndex);
|
|
1817
|
-
const changes = asList(action["state_changes"]).filter((c) => !(strOf(c["target_kind"]) === targetKind && strOf(c["target_id"]) === targetId));
|
|
1818
|
-
if (changes.length > 0)
|
|
1819
|
-
action["state_changes"] = changes;
|
|
1820
|
-
else
|
|
1821
|
-
delete action["state_changes"];
|
|
1822
|
-
applied.push(kind);
|
|
1823
|
-
}
|
|
1824
|
-
else if (kind === "action.transition.set") {
|
|
1825
|
-
const [epId, sceneId, actionIndex] = parseActionRef(op["at"]);
|
|
1826
|
-
const [targetKind, targetId] = parseAssetTarget(op["target"]);
|
|
1827
|
-
ensureRefId(script, targetKind, targetId);
|
|
1828
|
-
const process = strOf(op["process"]).trim();
|
|
1829
|
-
const contrast = strOf(op["contrast"]).trim();
|
|
1830
|
-
if (!process || !contrast) {
|
|
1831
|
-
throw opErr("SCRIPT OP BLOCKED: Transition prompt incomplete", "Transition prompt incomplete.", { required: ["process and contrast"], received: [JSON.stringify(op)], nextSteps: ["Provide both process and contrast."], op: kind, errorCode: "TRANSITION_PROMPT_INCOMPLETE" });
|
|
1832
|
-
}
|
|
1833
|
-
const action = findAction(script, epId, sceneId, actionIndex);
|
|
1834
|
-
action["transition_prompt"] = { target_kind: targetKind, target_id: targetId, process, contrast };
|
|
1835
|
-
applied.push(kind);
|
|
1836
|
-
}
|
|
1837
|
-
else if (kind === "action.transition.clear") {
|
|
1838
|
-
const [epId, sceneId, actionIndex] = parseActionRef(op["at"]);
|
|
1839
|
-
const [targetKind, targetId] = parseAssetTarget(op["target"]);
|
|
1840
|
-
const action = findAction(script, epId, sceneId, actionIndex);
|
|
1841
|
-
const transition = action["transition_prompt"];
|
|
1842
|
-
if (isDict(transition) && strOf(transition["target_kind"]) === targetKind && strOf(transition["target_id"]) === targetId) {
|
|
1843
|
-
delete action["transition_prompt"];
|
|
1844
|
-
}
|
|
1845
|
-
applied.push(kind);
|
|
1846
|
-
}
|
|
1847
|
-
else if (kind === "action.content.replace") {
|
|
1848
|
-
// Literal substring replace on action.content. Designed for "find this
|
|
1849
|
-
// phrase and edit it" — the script-stage equivalent of
|
|
1850
|
-
// set_action_content_from_span, but without needing source.txt (the DB-
|
|
1851
|
-
// backed script doesn't ship source). Refuses ambiguous matches by
|
|
1852
|
-
// default so an off-by-one edit can't silently rewrite the wrong copy.
|
|
1853
|
-
const [epId, sceneId, actionIndex] = parseActionRef(op["at"]);
|
|
1854
|
-
const from = strOf(op["from"]);
|
|
1855
|
-
const to = strOf(op["to"]);
|
|
1856
|
-
const all = Boolean(op["all"]);
|
|
1857
|
-
if (!from) {
|
|
1858
|
-
throw opErr("SCRIPT OP BLOCKED: From text empty", "From text empty.", {
|
|
1859
|
-
required: ["from: non-empty string"],
|
|
1860
|
-
received: [JSON.stringify(op)],
|
|
1861
|
-
nextSteps: ["Pass --from with the literal substring to replace."],
|
|
1862
|
-
op: kind,
|
|
1863
|
-
errorCode: "FROM_TEXT_EMPTY",
|
|
1864
|
-
});
|
|
1865
|
-
}
|
|
1866
|
-
const action = findAction(script, epId, sceneId, actionIndex);
|
|
1867
|
-
if (strOf(action["type"]) === "dialogue" && strOf(action["delivery"]) === "overlap") {
|
|
1868
|
-
throw opErr("SCRIPT OP BLOCKED: Overlap dialogue not supported", "action.content.replace cannot edit overlap dialogue lines.", {
|
|
1869
|
-
required: ["non-overlap action"],
|
|
1870
|
-
received: [`${op["at"]} delivery=overlap`],
|
|
1871
|
-
nextSteps: ["Use `script dialogue overlap` to rewrite the lines."],
|
|
1872
|
-
op: kind,
|
|
1873
|
-
errorCode: "OVERLAP_DIALOGUE_UNSUPPORTED",
|
|
1874
|
-
});
|
|
1875
|
-
}
|
|
1876
|
-
const current = strOf(action["content"]);
|
|
1877
|
-
let count = 0;
|
|
1878
|
-
let cursor = 0;
|
|
1879
|
-
while (true) {
|
|
1880
|
-
const i = current.indexOf(from, cursor);
|
|
1881
|
-
if (i < 0)
|
|
1882
|
-
break;
|
|
1883
|
-
count += 1;
|
|
1884
|
-
cursor = i + from.length;
|
|
1885
|
-
}
|
|
1886
|
-
if (count === 0) {
|
|
1887
|
-
throw opErr("SCRIPT OP BLOCKED: From text not found", "From text not found in action content.", {
|
|
1888
|
-
required: ["from substring present in action.content"],
|
|
1889
|
-
received: [`${op["at"]} from=${JSON.stringify(from)}`],
|
|
1890
|
-
nextSteps: ["Inspect the action with `scriptctl actions --in <addr> --grep` and adjust --from."],
|
|
1891
|
-
op: kind,
|
|
1892
|
-
errorCode: "FROM_TEXT_NOT_FOUND",
|
|
1893
|
-
});
|
|
1894
|
-
}
|
|
1895
|
-
if (count > 1 && !all) {
|
|
1896
|
-
throw opErr("SCRIPT OP BLOCKED: From text ambiguous", `From text matches ${count} times in this action.`, {
|
|
1897
|
-
required: ["unique substring, or pass all=true to replace every occurrence"],
|
|
1898
|
-
received: [`${op["at"]} matches=${count}`],
|
|
1899
|
-
nextSteps: ["Use a longer/more specific --from, or pass --all."],
|
|
1900
|
-
op: kind,
|
|
1901
|
-
errorCode: "FROM_TEXT_AMBIGUOUS",
|
|
1902
|
-
});
|
|
1903
|
-
}
|
|
1904
|
-
if (all) {
|
|
1905
|
-
action["content"] = current.split(from).join(to);
|
|
1906
|
-
}
|
|
1907
|
-
else {
|
|
1908
|
-
const i = current.indexOf(from);
|
|
1909
|
-
action["content"] = current.slice(0, i) + to + current.slice(i + from.length);
|
|
1910
|
-
}
|
|
1911
|
-
applied.push(kind);
|
|
1912
|
-
}
|
|
1913
|
-
else if (kind === "speaker.add") {
|
|
1914
|
-
const sourceKind = strOf(op["kind"] || op["source_kind"]).trim();
|
|
1915
|
-
if (!SPEAKER_SOURCE_KINDS.has(sourceKind)) {
|
|
1916
|
-
throw opErr("SCRIPT OP BLOCKED: Speaker kind invalid", "Speaker kind invalid.", { required: ["kind: actor/location/prop/system/broadcast/group/other"], received: [sourceKind || "<empty>"], nextSteps: ["Use a supported speaker kind."], op: kind, errorCode: "SPEAKER_KIND_INVALID" });
|
|
1917
|
-
}
|
|
1918
|
-
const name = strOf(op["name"] || op["display_name"]).trim();
|
|
1919
|
-
if (!name) {
|
|
1920
|
-
throw opErr("SCRIPT OP BLOCKED: Speaker name empty", "Speaker name empty.", { required: ["name"], received: [JSON.stringify(op)], nextSteps: ["Provide display name."], op: kind, errorCode: "SPEAKER_NAME_EMPTY" });
|
|
1921
|
-
}
|
|
1922
|
-
const sourceId = strOf(op["source_id"]).trim() || null;
|
|
1923
|
-
if (SCRIPT_TARGET_KINDS.has(sourceKind)) {
|
|
1924
|
-
if (!sourceId) {
|
|
1925
|
-
throw opErr("SCRIPT OP BLOCKED: Speaker source missing", "Speaker source missing.", { required: ["source_id for actor/location/prop speakers"], received: ["<empty>"], nextSteps: ["Pass --source-id."], op: kind, errorCode: "SPEAKER_SOURCE_MISSING" });
|
|
1926
|
-
}
|
|
1927
|
-
ensureRefId(script, sourceKind, sourceId);
|
|
1928
|
-
}
|
|
1929
|
-
const speakerId = strOf(op["speaker_id"]).trim() || nextSpeakerId(script, sourceKind, sourceId);
|
|
1930
|
-
if (asList(script["speakers"]).some((s) => strOf(s["speaker_id"]) === speakerId)) {
|
|
1931
|
-
throw opErr("SCRIPT OP BLOCKED: Speaker id exists", "Speaker id exists.", { required: ["unused speaker_id"], received: [speakerId], nextSteps: ["Use another speaker_id."], op: kind, errorCode: "SPEAKER_ID_EXISTS" });
|
|
1932
|
-
}
|
|
1933
|
-
if (!isList(script["speakers"]))
|
|
1934
|
-
script["speakers"] = [];
|
|
1935
|
-
script["speakers"].push({
|
|
1936
|
-
speaker_id: speakerId,
|
|
1937
|
-
display_name: name,
|
|
1938
|
-
source_kind: sourceKind,
|
|
1939
|
-
source_id: sourceId,
|
|
1940
|
-
voice_desc: strOf(op["voice_desc"]).trim(),
|
|
1941
|
-
});
|
|
1942
|
-
applied.push(kind);
|
|
1943
|
-
}
|
|
1944
|
-
else if (kind === "dialogue.speakers") {
|
|
1945
|
-
const [epId, sceneId, actionIndex] = parseActionRef(op["at"]);
|
|
1946
|
-
const delivery = strOf(op["delivery"] || "simultaneous").trim();
|
|
1947
|
-
if (delivery !== "single" && delivery !== "simultaneous" && delivery !== "group") {
|
|
1948
|
-
throw opErr("SCRIPT OP BLOCKED: Delivery invalid", "Delivery invalid.", { required: ["delivery: single, simultaneous, or group"], received: [delivery], nextSteps: ["Use a supported delivery."], op: kind, errorCode: "DELIVERY_INVALID" });
|
|
1949
|
-
}
|
|
1950
|
-
let rawSpeakers = op["speakers"] ?? op["speaker"] ?? [];
|
|
1951
|
-
if (typeof rawSpeakers === "string")
|
|
1952
|
-
rawSpeakers = [rawSpeakers];
|
|
1953
|
-
if (!isList(rawSpeakers) || rawSpeakers.length === 0) {
|
|
1954
|
-
throw opErr("SCRIPT OP BLOCKED: Speakers empty", "Speakers empty.", { required: ["speaker list"], received: [JSON.stringify(op)], nextSteps: ["Pass at least one --speaker."], op: kind, errorCode: "SPEAKERS_EMPTY" });
|
|
1955
|
-
}
|
|
1956
|
-
const speakerIds = rawSpeakers.map((sid) => ensureSpeakerId(script, strOf(sid)));
|
|
1957
|
-
const action = findAction(script, epId, sceneId, actionIndex);
|
|
1958
|
-
action["type"] = "dialogue";
|
|
1959
|
-
action["delivery"] = delivery;
|
|
1960
|
-
action["speakers"] = speakerIds.map((sid) => ({ speaker_id: sid }));
|
|
1961
|
-
delete action["lines"];
|
|
1962
|
-
const actorId = speakerIds.length === 1 ? speakerSourceActorId(script, speakerIds[0]) : null;
|
|
1963
|
-
if (actorId) {
|
|
1964
|
-
action["actor_id"] = actorId;
|
|
1965
|
-
action["speaker_id"] = speakerIds[0];
|
|
1966
|
-
}
|
|
1967
|
-
else {
|
|
1968
|
-
delete action["actor_id"];
|
|
1969
|
-
delete action["speaker_id"];
|
|
1970
|
-
}
|
|
1971
|
-
applied.push(kind);
|
|
1972
|
-
}
|
|
1973
|
-
else if (kind === "dialogue.overlap") {
|
|
1974
|
-
const [epId, sceneId, actionIndex] = parseActionRef(op["at"]);
|
|
1975
|
-
let rawLines = op["lines"] ?? op["line"] ?? [];
|
|
1976
|
-
if (typeof rawLines === "string")
|
|
1977
|
-
rawLines = [rawLines];
|
|
1978
|
-
if (!isList(rawLines) || rawLines.length === 0) {
|
|
1979
|
-
throw opErr("SCRIPT OP BLOCKED: Lines empty", "Lines empty.", { required: ["line list"], received: [JSON.stringify(op)], nextSteps: ["Pass at least one --line speaker_id:content."], op: kind, errorCode: "LINES_EMPTY" });
|
|
1980
|
-
}
|
|
1981
|
-
const lines = [];
|
|
1982
|
-
for (const line of rawLines) {
|
|
1983
|
-
let speakerId;
|
|
1984
|
-
let content;
|
|
1985
|
-
if (isDict(line)) {
|
|
1986
|
-
speakerId = ensureSpeakerId(script, strOf(line["speaker_id"]));
|
|
1987
|
-
content = strOf(line["content"]).trim();
|
|
1988
|
-
}
|
|
1989
|
-
else {
|
|
1990
|
-
const rawLine = strOf(line);
|
|
1991
|
-
if (!rawLine.includes(":") && !rawLine.includes(":")) {
|
|
1992
|
-
throw opErr("SCRIPT OP BLOCKED: Line invalid", "Line invalid.", { required: ["speaker_id:content"], received: [rawLine], nextSteps: ["Use --line spk_xxx:对白."], op: kind, errorCode: "LINE_INVALID" });
|
|
1993
|
-
}
|
|
1994
|
-
const m = /^([^::]+)[::](.*)$/.exec(rawLine);
|
|
1995
|
-
if (!m) {
|
|
1996
|
-
throw opErr("SCRIPT OP BLOCKED: Line invalid", "Line invalid.", { required: ["speaker_id:content"], received: [rawLine], nextSteps: ["Use --line spk_xxx:对白."], op: kind, errorCode: "LINE_INVALID" });
|
|
1997
|
-
}
|
|
1998
|
-
speakerId = ensureSpeakerId(script, m[1]);
|
|
1999
|
-
content = m[2].trim();
|
|
2000
|
-
}
|
|
2001
|
-
if (!content) {
|
|
2002
|
-
throw opErr("SCRIPT OP BLOCKED: Line content empty", "Line content empty.", { required: ["non-empty line content"], received: [JSON.stringify(line)], nextSteps: ["Provide line content."], op: kind, errorCode: "LINE_CONTENT_EMPTY" });
|
|
2003
|
-
}
|
|
2004
|
-
lines.push({ speaker_id: speakerId, content });
|
|
2005
|
-
}
|
|
2006
|
-
const action = findAction(script, epId, sceneId, actionIndex);
|
|
2007
|
-
action["type"] = "dialogue";
|
|
2008
|
-
action["delivery"] = "overlap";
|
|
2009
|
-
action["lines"] = lines;
|
|
2010
|
-
action["content"] = lines.map((l) => l["content"]).join(" / ");
|
|
2011
|
-
delete action["speakers"];
|
|
2012
|
-
delete action["actor_id"];
|
|
2013
|
-
delete action["speaker_id"];
|
|
2014
|
-
applied.push(kind);
|
|
2015
|
-
}
|
|
2016
|
-
else if (kind === "actor.add") {
|
|
2017
|
-
const name = strOf(op["name"] ?? op["actor_name"]).trim();
|
|
2018
|
-
// Reject non-human speakers as actors up front (validate would flag
|
|
2019
|
-
// NONHUMAN_AS_ACTOR otherwise) — system/broadcast/prop voices belong in
|
|
2020
|
-
// speakers[], not actors[].
|
|
2021
|
-
if (name && inferNonActorSpeakerKind(name) !== "actor") {
|
|
2022
|
-
throw opErr("SCRIPT OP BLOCKED: Non-human as actor", "Non-human speaker registered as actor.", {
|
|
2023
|
-
required: ["a human/character name"], received: [name],
|
|
2024
|
-
nextSteps: ["Register non-human voices with add-speaker (kind system/broadcast/prop/group/other)."],
|
|
2025
|
-
op: kind, errorCode: "NONHUMAN_AS_ACTOR",
|
|
2026
|
-
});
|
|
2027
|
-
}
|
|
2028
|
-
const entity = pushAsset(script, "actor", op);
|
|
2029
|
-
const roleType = strOf(op["role_type"] ?? op["role"]).trim();
|
|
2030
|
-
if (roleType) {
|
|
2031
|
-
if (!ROLE_TYPE_VALUES.includes(roleType)) {
|
|
2032
|
-
throw opErr("SCRIPT OP BLOCKED: Role type invalid", "Role type invalid.", {
|
|
2033
|
-
required: ["role_type: 主角 or 配角"], received: [roleType],
|
|
2034
|
-
nextSteps: ["Use a supported actor role_type, or omit to leave it unset."],
|
|
2035
|
-
op: kind, errorCode: "ROLE_TYPE_INVALID",
|
|
2036
|
-
});
|
|
2037
|
-
}
|
|
2038
|
-
entity["role_type"] = roleType;
|
|
2039
|
-
}
|
|
2040
|
-
// Only stamp aliases when there's at least one — keeps the entity shape
|
|
2041
|
-
// identical to direct/parse-produced actors (which omit the key when empty).
|
|
2042
|
-
const aliases = isList(op["aliases"]) ? op["aliases"].map((a) => strOf(a).trim()).filter(Boolean) : [];
|
|
2043
|
-
if (aliases.length)
|
|
2044
|
-
entity["aliases"] = aliases;
|
|
2045
|
-
applied.push(kind);
|
|
2046
|
-
}
|
|
2047
|
-
else if (kind === "location.add") {
|
|
2048
|
-
pushAsset(script, "location", op);
|
|
2049
|
-
applied.push(kind);
|
|
2050
|
-
}
|
|
2051
|
-
else if (kind === "prop.add") {
|
|
2052
|
-
pushAsset(script, "prop", op);
|
|
2053
|
-
applied.push(kind);
|
|
2054
|
-
}
|
|
2055
|
-
else if (kind === "episode.add") {
|
|
2056
|
-
// Append an empty episode. Scenes/actions are added afterwards with
|
|
2057
|
-
// scene.insert / action.insert. episode_id auto-allocates as ep_NNN.
|
|
2058
|
-
const epId = strOf(op["episode_id"] ?? op["id"]).trim() || nextEpisodeId(script);
|
|
2059
|
-
if (!/^ep_\d+$/.test(epId)) {
|
|
2060
|
-
throw opErr("SCRIPT OP BLOCKED: Episode id invalid", "Episode id must use ep_NNN format.", {
|
|
2061
|
-
required: ["episode_id: ep_NNN"], received: [epId],
|
|
2062
|
-
nextSteps: ["Pass --id ep_004 or omit to auto-allocate."],
|
|
2063
|
-
op: kind, errorCode: "EPISODE_ID_INVALID",
|
|
2064
|
-
});
|
|
2065
|
-
}
|
|
2066
|
-
if (!isList(script["episodes"]))
|
|
2067
|
-
script["episodes"] = [];
|
|
2068
|
-
const eps = script["episodes"];
|
|
2069
|
-
if (eps.some((e) => strOf(e["episode_id"]) === epId)) {
|
|
2070
|
-
throw opErr("SCRIPT OP BLOCKED: Episode id exists", "Episode id exists.", {
|
|
2071
|
-
required: ["unused episode_id"], received: [epId],
|
|
2072
|
-
nextSteps: ["Use another --id or omit it to auto-allocate."],
|
|
2073
|
-
op: kind, errorCode: "EPISODE_ID_EXISTS",
|
|
2074
|
-
});
|
|
2075
|
-
}
|
|
2076
|
-
eps.push({ episode_id: epId, title: strOf(op["title"]).trim(), scenes: [] });
|
|
2077
|
-
applied.push(kind);
|
|
2078
|
-
}
|
|
2079
|
-
else if (kind === "meta.worldview.set") {
|
|
2080
|
-
// 0.6.0 dot-style equivalent of the legacy `set_worldview` op.
|
|
2081
|
-
const worldview = strOf(op["worldview"] ?? op["value"]).trim();
|
|
2082
|
-
if (!WORLDVIEW_VALUES.includes(worldview)) {
|
|
2083
|
-
throw opErr("SCRIPT OP BLOCKED: Worldview invalid", "Worldview invalid.", {
|
|
2084
|
-
required: [`worldview: one of ${WORLDVIEW_VALUES.join(", ")}`],
|
|
2085
|
-
received: [worldview || "<empty>"],
|
|
2086
|
-
nextSteps: ["Use a supported worldview enum."],
|
|
2087
|
-
op: kind,
|
|
2088
|
-
errorCode: "WORLDVIEW_INVALID",
|
|
2089
|
-
});
|
|
2090
|
-
}
|
|
2091
|
-
script["worldview"] = worldview;
|
|
2092
|
-
if ("worldview_raw" in op)
|
|
2093
|
-
script["worldview_raw"] = strOf(op["worldview_raw"]).trim();
|
|
2094
|
-
applied.push(kind);
|
|
2095
|
-
}
|
|
2096
|
-
else if (kind === "asset.role.set") {
|
|
2097
|
-
const [assetKind, assetId] = parseAssetTarget(op["target"]);
|
|
2098
|
-
if (assetKind !== "actor") {
|
|
2099
|
-
throw opErr("SCRIPT OP BLOCKED: Role only on actors", "Role applies to actor only.", {
|
|
2100
|
-
required: ["actor:<id>"],
|
|
2101
|
-
received: [strOf(op["target"])],
|
|
2102
|
-
nextSteps: ["Use an actor target."],
|
|
2103
|
-
op: kind,
|
|
2104
|
-
errorCode: "ROLE_TARGET_INVALID",
|
|
2105
|
-
});
|
|
2106
|
-
}
|
|
2107
|
-
const asset = resolveAssetByTarget(script, "actor", assetId);
|
|
2108
|
-
const roleType = strOf(op["role_type"] ?? op["role"]).trim();
|
|
2109
|
-
if (!ROLE_TYPE_VALUES.includes(roleType)) {
|
|
2110
|
-
throw opErr("SCRIPT OP BLOCKED: Role type invalid", "Role type invalid.", {
|
|
2111
|
-
required: ["role_type: 主角 or 配角"],
|
|
2112
|
-
received: [roleType || "<empty>"],
|
|
2113
|
-
nextSteps: ["Use a supported actor role_type."],
|
|
2114
|
-
op: kind,
|
|
2115
|
-
errorCode: "ROLE_TYPE_INVALID",
|
|
2116
|
-
});
|
|
2117
|
-
}
|
|
2118
|
-
asset["role_type"] = roleType;
|
|
2119
|
-
applied.push(kind);
|
|
2120
|
-
}
|
|
2121
|
-
else if (kind === "asset.rename") {
|
|
2122
|
-
const [assetKind, assetId] = parseAssetTarget(op["target"]);
|
|
2123
|
-
const asset = resolveAssetByTarget(script, assetKind, assetId);
|
|
2124
|
-
const newName = strOf(op["name"] ?? op["new_name"]).trim();
|
|
2125
|
-
if (!newName) {
|
|
2126
|
-
throw opErr("SCRIPT OP BLOCKED: Name empty", "Name empty.", {
|
|
2127
|
-
required: ["name"],
|
|
2128
|
-
received: [JSON.stringify(op)],
|
|
2129
|
-
nextSteps: ["Provide a non-empty name."],
|
|
2130
|
-
op: kind,
|
|
2131
|
-
errorCode: "NAME_EMPTY",
|
|
2132
|
-
});
|
|
2133
|
-
}
|
|
2134
|
-
const [, , nameKey] = assetKeys(assetKind);
|
|
2135
|
-
asset[nameKey] = newName;
|
|
2136
|
-
applied.push(kind);
|
|
2137
|
-
}
|
|
2138
|
-
else if (kind === "asset.describe") {
|
|
2139
|
-
const [assetKind, assetId] = parseAssetTarget(op["target"]);
|
|
2140
|
-
const asset = resolveAssetByTarget(script, assetKind, assetId);
|
|
2141
|
-
const description = strOf(op["description"]).trim();
|
|
2142
|
-
if (!description) {
|
|
2143
|
-
throw opErr("SCRIPT OP BLOCKED: Description empty", "Description empty.", {
|
|
2144
|
-
required: ["description"],
|
|
2145
|
-
received: [JSON.stringify(op)],
|
|
2146
|
-
nextSteps: ["Provide a concise grounded description."],
|
|
2147
|
-
op: kind,
|
|
2148
|
-
errorCode: "DESCRIPTION_EMPTY",
|
|
2149
|
-
});
|
|
2150
|
-
}
|
|
2151
|
-
asset["description"] = description;
|
|
2152
|
-
applied.push(kind);
|
|
2153
|
-
}
|
|
2154
|
-
else if (kind === "asset.alias.set") {
|
|
2155
|
-
// Bulk replace. Rare; primarily for migration tools.
|
|
2156
|
-
const [assetKind, assetId] = parseAssetTarget(op["target"]);
|
|
2157
|
-
const asset = resolveAssetByTarget(script, assetKind, assetId);
|
|
2158
|
-
const aliases = op["aliases"];
|
|
2159
|
-
if (!isList(aliases) || aliases.some((it) => typeof it !== "string")) {
|
|
2160
|
-
throw opErr("SCRIPT OP BLOCKED: Aliases invalid", "Aliases invalid.", {
|
|
2161
|
-
required: ["aliases: array of strings"],
|
|
2162
|
-
received: [JSON.stringify(op)],
|
|
2163
|
-
nextSteps: ["Pass a string array."],
|
|
2164
|
-
op: kind,
|
|
2165
|
-
errorCode: "ALIASES_INVALID",
|
|
2166
|
-
});
|
|
2167
|
-
}
|
|
2168
|
-
const seen = new Set();
|
|
2169
|
-
const cleaned = [];
|
|
2170
|
-
for (const item of aliases) {
|
|
2171
|
-
const trimmed = item.trim();
|
|
2172
|
-
if (trimmed && !seen.has(trimmed)) {
|
|
2173
|
-
seen.add(trimmed);
|
|
2174
|
-
cleaned.push(trimmed);
|
|
2175
|
-
}
|
|
2176
|
-
}
|
|
2177
|
-
asset["aliases"] = cleaned;
|
|
2178
|
-
applied.push(kind);
|
|
2179
|
-
}
|
|
2180
|
-
else if (kind === "asset.alias.add") {
|
|
2181
|
-
const [assetKind, assetId] = parseAssetTarget(op["target"]);
|
|
2182
|
-
const asset = resolveAssetByTarget(script, assetKind, assetId);
|
|
2183
|
-
const rawAdd = op["alias"] ?? op["aliases"];
|
|
2184
|
-
const toAdd = isList(rawAdd)
|
|
2185
|
-
? rawAdd.map((v) => strOf(v).trim()).filter((v) => v)
|
|
2186
|
-
: strOf(rawAdd).trim() ? [strOf(rawAdd).trim()] : [];
|
|
2187
|
-
if (toAdd.length === 0) {
|
|
2188
|
-
throw opErr("SCRIPT OP BLOCKED: Alias empty", "Alias empty.", {
|
|
2189
|
-
required: ["alias: non-empty string or array"],
|
|
2190
|
-
received: [JSON.stringify(op)],
|
|
2191
|
-
nextSteps: ["Pass --add <alias> at least once."],
|
|
2192
|
-
op: kind,
|
|
2193
|
-
errorCode: "ALIAS_EMPTY",
|
|
2194
|
-
});
|
|
2195
|
-
}
|
|
2196
|
-
const existing = asList(asset["aliases"]).map((s) => strOf(s));
|
|
2197
|
-
const seen = new Set(existing);
|
|
2198
|
-
for (const alias of toAdd) {
|
|
2199
|
-
if (!seen.has(alias)) {
|
|
2200
|
-
existing.push(alias);
|
|
2201
|
-
seen.add(alias);
|
|
2202
|
-
}
|
|
2203
|
-
}
|
|
2204
|
-
asset["aliases"] = existing;
|
|
2205
|
-
applied.push(kind);
|
|
2206
|
-
}
|
|
2207
|
-
else if (kind === "asset.alias.remove") {
|
|
2208
|
-
const [assetKind, assetId] = parseAssetTarget(op["target"]);
|
|
2209
|
-
const asset = resolveAssetByTarget(script, assetKind, assetId);
|
|
2210
|
-
const rawRemove = op["alias"] ?? op["aliases"];
|
|
2211
|
-
const toRemove = new Set(isList(rawRemove)
|
|
2212
|
-
? rawRemove.map((v) => strOf(v).trim()).filter((v) => v)
|
|
2213
|
-
: strOf(rawRemove).trim() ? [strOf(rawRemove).trim()] : []);
|
|
2214
|
-
if (toRemove.size === 0) {
|
|
2215
|
-
throw opErr("SCRIPT OP BLOCKED: Alias empty", "Alias empty.", {
|
|
2216
|
-
required: ["alias: non-empty string or array"],
|
|
2217
|
-
received: [JSON.stringify(op)],
|
|
2218
|
-
nextSteps: ["Pass --remove <alias> at least once."],
|
|
2219
|
-
op: kind,
|
|
2220
|
-
errorCode: "ALIAS_EMPTY",
|
|
2221
|
-
});
|
|
2222
|
-
}
|
|
2223
|
-
asset["aliases"] = asList(asset["aliases"]).map((s) => strOf(s)).filter((a) => !toRemove.has(a));
|
|
2224
|
-
applied.push(kind);
|
|
2225
|
-
}
|
|
2226
|
-
else if (kind === "asset.states.set") {
|
|
2227
|
-
const [assetKind, assetId] = parseAssetTarget(op["target"]);
|
|
2228
|
-
const asset = resolveAssetByTarget(script, assetKind, assetId);
|
|
2229
|
-
asset["states"] = normalizeStates(script, op["states"], assetKind);
|
|
2230
|
-
applied.push(kind);
|
|
2231
|
-
}
|
|
2232
|
-
else if (kind === "asset.merge") {
|
|
2233
|
-
// Direction is explicit: `from` disappears, `into` keeps everything.
|
|
2234
|
-
const fromRaw = op["from"] ?? op["source"];
|
|
2235
|
-
const intoRaw = op["into"] ?? op["target"];
|
|
2236
|
-
const [fromKind, fromId] = parseAssetTarget(fromRaw);
|
|
2237
|
-
const [intoKind, intoId] = parseAssetTarget(intoRaw);
|
|
2238
|
-
if (fromKind !== intoKind) {
|
|
2239
|
-
throw opErr("SCRIPT OP BLOCKED: Merge kind mismatch", "Merge source and target must be the same kind.", {
|
|
2240
|
-
required: ["same kind on both sides (actor/actor, location/location, prop/prop)"],
|
|
2241
|
-
received: [`${fromKind}:${fromId} → ${intoKind}:${intoId}`],
|
|
2242
|
-
nextSteps: ["Use a same-kind target."],
|
|
2243
|
-
op: kind,
|
|
2244
|
-
errorCode: "MERGE_KIND_MISMATCH",
|
|
2245
|
-
});
|
|
2246
|
-
}
|
|
2247
|
-
const [key, idKey] = assetKeys(fromKind);
|
|
2248
|
-
// Verify both exist.
|
|
2249
|
-
resolveAssetByTarget(script, fromKind, fromId);
|
|
2250
|
-
resolveAssetByTarget(script, intoKind, intoId);
|
|
2251
|
-
// Pre-flight: refuse merge if any scene context already references BOTH
|
|
2252
|
-
// from and into with conflicting state_ids. Silently picking one (the
|
|
2253
|
-
// array-order-first) was a quiet data-loss bug — we'd rather make the
|
|
2254
|
-
// user resolve the state divergence explicitly.
|
|
2255
|
-
for (const ep of asList(script["episodes"])) {
|
|
2256
|
-
for (const scene of asList(ep["scenes"])) {
|
|
2257
|
-
const ctx = sceneContext(scene);
|
|
2258
|
-
const refList = contextRefsForKind(ctx, fromKind);
|
|
2259
|
-
const fromRef = refList.find((ref) => strOf(ref[idKey]) === fromId);
|
|
2260
|
-
const intoRef = refList.find((ref) => strOf(ref[idKey]) === intoId);
|
|
2261
|
-
if (fromRef && intoRef) {
|
|
2262
|
-
const fromState = strOf(fromRef["state_id"]);
|
|
2263
|
-
const intoState = strOf(intoRef["state_id"]);
|
|
2264
|
-
if (fromState !== intoState) {
|
|
2265
|
-
throw opErr("SCRIPT OP BLOCKED: Merge state conflict", "Merge has conflicting state_ids in a shared scene.", {
|
|
2266
|
-
required: ["from and into agree on state_id in every scene where both appear"],
|
|
2267
|
-
received: [`${ep["episode_id"]}/${scene["scene_id"]}: ${fromId}=${fromState || "null"}, ${intoId}=${intoState || "null"}`],
|
|
2268
|
-
nextSteps: ["Resolve the state divergence (run `scriptctl context <scene> <kind>:<into-id> --state <state>`) then retry merge."],
|
|
2269
|
-
op: kind,
|
|
2270
|
-
errorCode: "MERGE_STATE_CONFLICT",
|
|
2271
|
-
});
|
|
2272
|
-
}
|
|
2273
|
-
}
|
|
2274
|
-
}
|
|
2275
|
-
}
|
|
2276
|
-
// Drop source from the asset list.
|
|
2277
|
-
script[key] = asList(script[key]).filter((it) => strOf(it[idKey]) !== fromId);
|
|
2278
|
-
// Rewrite refs: scene.context, action.actor_id (actor only), state_changes targets, transition targets, speaker.source_id.
|
|
2279
|
-
for (const ep of asList(script["episodes"])) {
|
|
2280
|
-
for (const scene of asList(ep["scenes"])) {
|
|
2281
|
-
const ctx = sceneContext(scene);
|
|
2282
|
-
const refList = contextRefsForKind(ctx, fromKind);
|
|
2283
|
-
for (const ref of refList) {
|
|
2284
|
-
if (strOf(ref[idKey]) === fromId)
|
|
2285
|
-
ref[idKey] = intoId;
|
|
2286
|
-
}
|
|
2287
|
-
// After rewrite, dedupe (intoId may now appear twice). State_id was
|
|
2288
|
-
// pre-validated to agree above, so first-seen wins is safe.
|
|
2289
|
-
const seen = new Set();
|
|
2290
|
-
ctx[pluralForKind(fromKind)] = refList.filter((ref) => {
|
|
2291
|
-
const key2 = strOf(ref[idKey]);
|
|
2292
|
-
if (seen.has(key2))
|
|
2293
|
-
return false;
|
|
2294
|
-
seen.add(key2);
|
|
2295
|
-
return true;
|
|
2296
|
-
});
|
|
2297
|
-
setSceneContext(scene, ctx);
|
|
2298
|
-
for (const action of asList(scene["actions"])) {
|
|
2299
|
-
if (fromKind === "actor" && strOf(action["actor_id"]) === fromId)
|
|
2300
|
-
action["actor_id"] = intoId;
|
|
2301
|
-
for (const change of asList(action["state_changes"])) {
|
|
2302
|
-
if (strOf(change["target_kind"]) === fromKind && strOf(change["target_id"]) === fromId) {
|
|
2303
|
-
change["target_id"] = intoId;
|
|
2304
|
-
}
|
|
2305
|
-
}
|
|
2306
|
-
const transition = action["transition_prompt"];
|
|
2307
|
-
if (isDict(transition) && strOf(transition["target_kind"]) === fromKind && strOf(transition["target_id"]) === fromId) {
|
|
2308
|
-
transition["target_id"] = intoId;
|
|
2309
|
-
}
|
|
2310
|
-
}
|
|
2311
|
-
}
|
|
2312
|
-
}
|
|
2313
|
-
// Speakers anchored to the source actor get reattached to the target.
|
|
2314
|
-
for (const sp of asList(script["speakers"])) {
|
|
2315
|
-
if (strOf(sp["source_kind"]) === fromKind && strOf(sp["source_id"]) === fromId) {
|
|
2316
|
-
sp["source_id"] = intoId;
|
|
2317
|
-
}
|
|
2318
|
-
}
|
|
2319
|
-
applied.push(kind);
|
|
2320
|
-
}
|
|
2321
|
-
else if (kind === "asset.delete") {
|
|
2322
|
-
// Refs are walked via collectAssetRefs. Default: refuse if any refs exist.
|
|
2323
|
-
// strategy=replace → rewrite refs to a replacement asset, then delete.
|
|
2324
|
-
// strategy=remove → rewrite refs to null/clean and delete (leaves dangling
|
|
2325
|
-
// pointers cleaned by per-edge logic; validation will still surface anything
|
|
2326
|
-
// still wrong).
|
|
2327
|
-
const [assetKind, assetId] = parseAssetTarget(op["target"]);
|
|
2328
|
-
resolveAssetByTarget(script, assetKind, assetId);
|
|
2329
|
-
const refs = collectAssetRefs(script, assetKind, assetId);
|
|
2330
|
-
const strategy = strOf(op["strategy"]).trim();
|
|
2331
|
-
if (refs.length > 0 && !strategy) {
|
|
2332
|
-
throw opErr("SCRIPT OP BLOCKED: Asset has refs", `Asset is referenced ${refs.length} time(s).`, {
|
|
2333
|
-
required: ["--strategy replace|remove"],
|
|
2334
|
-
received: [`refs=${refs.length}`],
|
|
2335
|
-
nextSteps: ["Use --strategy replace --replacement <addr> to remap, or --strategy remove to scrub refs."],
|
|
2336
|
-
op: kind,
|
|
2337
|
-
errorCode: "ASSET_HAS_REFS",
|
|
2338
|
-
});
|
|
2339
|
-
}
|
|
2340
|
-
if (strategy === "replace") {
|
|
2341
|
-
const replacementRaw = op["replacement"] ?? op["into"];
|
|
2342
|
-
const [replacementKind, replacementId] = parseAssetTarget(replacementRaw);
|
|
2343
|
-
if (replacementKind !== assetKind) {
|
|
2344
|
-
throw opErr("SCRIPT OP BLOCKED: Replacement kind mismatch", "Replacement must be same kind.", {
|
|
2345
|
-
required: ["replacement same kind as target"],
|
|
2346
|
-
received: [`${assetKind} vs ${replacementKind}`],
|
|
2347
|
-
nextSteps: ["Pass a same-kind replacement."],
|
|
2348
|
-
op: kind,
|
|
2349
|
-
errorCode: "REPLACEMENT_KIND_MISMATCH",
|
|
2350
|
-
});
|
|
2351
|
-
}
|
|
2352
|
-
resolveAssetByTarget(script, replacementKind, replacementId);
|
|
2353
|
-
// Delegate ref rewrite to asset.merge logic by reusing the same traversal.
|
|
2354
|
-
applyPatchOperations(script, sourceText, [
|
|
2355
|
-
{ op: "asset.merge", from: op["target"], into: replacementRaw },
|
|
2356
|
-
]);
|
|
2357
|
-
applied.push(kind);
|
|
2358
|
-
}
|
|
2359
|
-
else if (strategy === "remove" || refs.length === 0) {
|
|
2360
|
-
// Scrub refs (actor_id → undefined, state_changes/transition refs removed, scene.context entries removed, speakers' source_id cleared).
|
|
2361
|
-
const [key, idKey] = assetKeys(assetKind);
|
|
2362
|
-
script[key] = asList(script[key]).filter((it) => strOf(it[idKey]) !== assetId);
|
|
2363
|
-
for (const ep of asList(script["episodes"])) {
|
|
2364
|
-
for (const scene of asList(ep["scenes"])) {
|
|
2365
|
-
const ctx = sceneContext(scene);
|
|
2366
|
-
ctx[pluralForKind(assetKind)] = contextRefsForKind(ctx, assetKind).filter((ref) => strOf(ref[idKey]) !== assetId);
|
|
2367
|
-
setSceneContext(scene, ctx);
|
|
2368
|
-
for (const action of asList(scene["actions"])) {
|
|
2369
|
-
if (assetKind === "actor" && strOf(action["actor_id"]) === assetId)
|
|
2370
|
-
delete action["actor_id"];
|
|
2371
|
-
const changes = asList(action["state_changes"]).filter((c) => !(strOf(c["target_kind"]) === assetKind && strOf(c["target_id"]) === assetId));
|
|
2372
|
-
if (changes.length > 0)
|
|
2373
|
-
action["state_changes"] = changes;
|
|
2374
|
-
else
|
|
2375
|
-
delete action["state_changes"];
|
|
2376
|
-
const transition = action["transition_prompt"];
|
|
2377
|
-
if (isDict(transition) && strOf(transition["target_kind"]) === assetKind && strOf(transition["target_id"]) === assetId) {
|
|
2378
|
-
delete action["transition_prompt"];
|
|
2379
|
-
}
|
|
2380
|
-
}
|
|
2381
|
-
}
|
|
2382
|
-
}
|
|
2383
|
-
for (const sp of asList(script["speakers"])) {
|
|
2384
|
-
if (strOf(sp["source_kind"]) === assetKind && strOf(sp["source_id"]) === assetId) {
|
|
2385
|
-
sp["source_id"] = null;
|
|
2386
|
-
}
|
|
2387
|
-
}
|
|
2388
|
-
applied.push(kind);
|
|
2389
|
-
}
|
|
2390
|
-
else {
|
|
2391
|
-
throw opErr("SCRIPT OP BLOCKED: Strategy invalid", "Strategy invalid.", {
|
|
2392
|
-
required: ["strategy: replace or remove"],
|
|
2393
|
-
received: [strategy],
|
|
2394
|
-
nextSteps: ["Use replace (with --replacement) or remove."],
|
|
2395
|
-
op: kind,
|
|
2396
|
-
errorCode: "DELETE_STRATEGY_INVALID",
|
|
2397
|
-
});
|
|
2398
|
-
}
|
|
2399
|
-
}
|
|
2400
|
-
else if (kind === "action.type.set") {
|
|
2401
|
-
const [epId, sceneId, actionIndex] = parseActionRef(op["at"]);
|
|
2402
|
-
const actionType = strOf(op["type"] ?? op["action_type"]).trim();
|
|
2403
|
-
if (!ACTION_TYPE_VALUES.has(actionType)) {
|
|
2404
|
-
throw opErr("SCRIPT OP BLOCKED: Action type invalid", "Action type invalid.", {
|
|
2405
|
-
required: ["type: dialogue, inner_thought, or action"],
|
|
2406
|
-
received: [actionType || "<empty>"],
|
|
2407
|
-
nextSteps: ["Use a supported action type."],
|
|
2408
|
-
op: kind,
|
|
2409
|
-
errorCode: "ACTION_TYPE_INVALID",
|
|
2410
|
-
});
|
|
2411
|
-
}
|
|
2412
|
-
const action = findAction(script, epId, sceneId, actionIndex);
|
|
2413
|
-
action["type"] = actionType;
|
|
2414
|
-
applied.push(kind);
|
|
2415
|
-
}
|
|
2416
|
-
else if (kind === "action.actor.set") {
|
|
2417
|
-
const [epId, sceneId, actionIndex] = parseActionRef(op["at"]);
|
|
2418
|
-
const rawActor = op["actor_id"] ?? op["actor"];
|
|
2419
|
-
const isNone = rawActor === null || strOf(rawActor).trim() === "" || strOf(rawActor).trim() === "none";
|
|
2420
|
-
const actorId = isNone ? null : ensureRefId(script, "actor", rawActor);
|
|
2421
|
-
const action = findAction(script, epId, sceneId, actionIndex);
|
|
2422
|
-
if (actorId === null)
|
|
2423
|
-
delete action["actor_id"];
|
|
2424
|
-
else
|
|
2425
|
-
action["actor_id"] = actorId;
|
|
2426
|
-
applied.push(kind);
|
|
2427
|
-
}
|
|
2428
|
-
else if (kind === "action.delete") {
|
|
2429
|
-
const [epId, sceneId, actionIndex] = parseActionRef(op["at"]);
|
|
2430
|
-
const scene = findScene(script, epId, sceneId);
|
|
2431
|
-
const actions = asList(scene["actions"]);
|
|
2432
|
-
if (actionIndex < 0 || actionIndex >= actions.length) {
|
|
2433
|
-
throw opErr("SCRIPT OP BLOCKED: Action index out of range", "Action index out of range.", {
|
|
2434
|
-
required: [`0 <= action_index < ${actions.length}`],
|
|
2435
|
-
received: [String(actionIndex)],
|
|
2436
|
-
nextSteps: ["Inspect actions in the scene and use a valid index."],
|
|
2437
|
-
op: kind,
|
|
2438
|
-
errorCode: "ACTION_INDEX_OUT_OF_RANGE",
|
|
2439
|
-
});
|
|
2440
|
-
}
|
|
2441
|
-
actions.splice(actionIndex, 1);
|
|
2442
|
-
scene["actions"] = actions;
|
|
2443
|
-
applied.push(kind);
|
|
2444
|
-
}
|
|
2445
|
-
else if (kind === "action.insert") {
|
|
2446
|
-
// <at> is a scene ref (ep_xxx/scn_xxx). Position resolved from
|
|
2447
|
-
// --at <idx> | --before <action-ref> | --after <action-ref> | default-append.
|
|
2448
|
-
const [epId, sceneId] = parseSceneRef(op["at"]);
|
|
2449
|
-
const scene = findScene(script, epId, sceneId);
|
|
2450
|
-
const actions = asList(scene["actions"]);
|
|
2451
|
-
const actionType = strOf(op["type"]).trim();
|
|
2452
|
-
if (!ACTION_TYPE_VALUES.has(actionType)) {
|
|
2453
|
-
throw opErr("SCRIPT OP BLOCKED: Action type invalid", "Action type invalid.", {
|
|
2454
|
-
required: ["type: dialogue, inner_thought, or action"],
|
|
2455
|
-
received: [actionType || "<empty>"],
|
|
2456
|
-
nextSteps: ["Use a supported action type."],
|
|
2457
|
-
op: kind,
|
|
2458
|
-
errorCode: "ACTION_TYPE_INVALID",
|
|
2459
|
-
});
|
|
2460
|
-
}
|
|
2461
|
-
const content = strOf(op["content"]);
|
|
2462
|
-
let insertIndex;
|
|
2463
|
-
if (op["before"]) {
|
|
2464
|
-
const [bEp, bScn, bIdx] = parseActionRef(op["before"]);
|
|
2465
|
-
if (bEp !== epId || bScn !== sceneId) {
|
|
2466
|
-
throw opErr("SCRIPT OP BLOCKED: before address mismatch", "--before must point at the same scene.", {
|
|
2467
|
-
required: [`${epId}/${sceneId}#<idx>`],
|
|
2468
|
-
received: [strOf(op["before"])],
|
|
2469
|
-
nextSteps: ["Use a sibling action address."],
|
|
2470
|
-
op: kind,
|
|
2471
|
-
errorCode: "INSERT_REF_MISMATCH",
|
|
2472
|
-
});
|
|
2473
|
-
}
|
|
2474
|
-
insertIndex = bIdx;
|
|
2475
|
-
}
|
|
2476
|
-
else if (op["after"]) {
|
|
2477
|
-
const [aEp, aScn, aIdx] = parseActionRef(op["after"]);
|
|
2478
|
-
if (aEp !== epId || aScn !== sceneId) {
|
|
2479
|
-
throw opErr("SCRIPT OP BLOCKED: after address mismatch", "--after must point at the same scene.", {
|
|
2480
|
-
required: [`${epId}/${sceneId}#<idx>`],
|
|
2481
|
-
received: [strOf(op["after"])],
|
|
2482
|
-
nextSteps: ["Use a sibling action address."],
|
|
2483
|
-
op: kind,
|
|
2484
|
-
errorCode: "INSERT_REF_MISMATCH",
|
|
2485
|
-
});
|
|
2486
|
-
}
|
|
2487
|
-
insertIndex = aIdx + 1;
|
|
2488
|
-
}
|
|
2489
|
-
else if (op["at_index"] !== undefined && op["at_index"] !== null) {
|
|
2490
|
-
const n = Number(op["at_index"]);
|
|
2491
|
-
if (!Number.isInteger(n) || n < 0 || n > actions.length) {
|
|
2492
|
-
throw opErr("SCRIPT OP BLOCKED: at_index out of range", "at_index out of range.", {
|
|
2493
|
-
required: [`0..${actions.length}`],
|
|
2494
|
-
received: [String(op["at_index"])],
|
|
2495
|
-
nextSteps: ["Use a valid integer index."],
|
|
2496
|
-
op: kind,
|
|
2497
|
-
errorCode: "AT_INDEX_OUT_OF_RANGE",
|
|
2498
|
-
});
|
|
2499
|
-
}
|
|
2500
|
-
insertIndex = n;
|
|
2501
|
-
}
|
|
2502
|
-
else {
|
|
2503
|
-
// Default: append at end.
|
|
2504
|
-
insertIndex = actions.length;
|
|
2505
|
-
}
|
|
2506
|
-
const newAction = { type: actionType, content };
|
|
2507
|
-
if (op["actor_id"])
|
|
2508
|
-
newAction["actor_id"] = ensureRefId(script, "actor", op["actor_id"]);
|
|
2509
|
-
if (op["speaker_id"])
|
|
2510
|
-
newAction["speaker_id"] = ensureSpeakerId(script, strOf(op["speaker_id"]));
|
|
2511
|
-
actions.splice(insertIndex, 0, newAction);
|
|
2512
|
-
scene["actions"] = actions;
|
|
2513
|
-
applied.push(kind);
|
|
2514
|
-
}
|
|
2515
|
-
else if (kind === "action.move") {
|
|
2516
|
-
const [fromEp, fromScn, fromIdx] = parseActionRef(op["at"] ?? op["from"]);
|
|
2517
|
-
const [toEp, toScn, toIdx] = parseActionRef(op["to"]);
|
|
2518
|
-
const fromScene = findScene(script, fromEp, fromScn);
|
|
2519
|
-
const fromActions = asList(fromScene["actions"]);
|
|
2520
|
-
if (fromIdx < 0 || fromIdx >= fromActions.length) {
|
|
2521
|
-
throw opErr("SCRIPT OP BLOCKED: Action index out of range", "Source action index out of range.", {
|
|
2522
|
-
required: [`0 <= idx < ${fromActions.length}`],
|
|
2523
|
-
received: [String(fromIdx)],
|
|
2524
|
-
nextSteps: ["Inspect actions and use a valid index."],
|
|
2525
|
-
op: kind,
|
|
2526
|
-
errorCode: "ACTION_INDEX_OUT_OF_RANGE",
|
|
2527
|
-
});
|
|
2528
|
-
}
|
|
2529
|
-
const [moved] = fromActions.splice(fromIdx, 1);
|
|
2530
|
-
fromScene["actions"] = fromActions;
|
|
2531
|
-
const toScene = (fromEp === toEp && fromScn === toScn) ? fromScene : findScene(script, toEp, toScn);
|
|
2532
|
-
const toActions = asList(toScene["actions"]);
|
|
2533
|
-
// After removing the source, when target is the same scene and idx is past
|
|
2534
|
-
// the removed slot, the agent's intended index is already correct (no
|
|
2535
|
-
// shift needed in the destination); otherwise clamp.
|
|
2536
|
-
let dest = toIdx;
|
|
2537
|
-
if (dest < 0)
|
|
2538
|
-
dest = 0;
|
|
2539
|
-
if (dest > toActions.length)
|
|
2540
|
-
dest = toActions.length;
|
|
2541
|
-
toActions.splice(dest, 0, moved);
|
|
2542
|
-
toScene["actions"] = toActions;
|
|
2543
|
-
applied.push(kind);
|
|
2544
|
-
}
|
|
2545
|
-
else if (kind === "scene.insert") {
|
|
2546
|
-
// Insert a new empty scene into an episode. Position resolved from
|
|
2547
|
-
// --at <idx> | --before <ep/scn> | --after <ep/scn> | default-append.
|
|
2548
|
-
// Requires --location to anchor the scene's locations[] context (matches
|
|
2549
|
-
// direct-init's assumption that every scene names at least one location).
|
|
2550
|
-
const epId = strOf(op["at"] ?? op["episode_id"]);
|
|
2551
|
-
const ep = findEpisode(script, epId);
|
|
2552
|
-
const scenes = asList(ep["scenes"]);
|
|
2553
|
-
const locationId = strOf(op["location"] ?? op["location_id"]).trim();
|
|
2554
|
-
if (!locationId) {
|
|
2555
|
-
throw opErr("SCRIPT OP BLOCKED: location required", "Scene insert requires --location.", {
|
|
2556
|
-
required: ["location: existing location_id"],
|
|
2557
|
-
received: ["<empty>"],
|
|
2558
|
-
nextSteps: ["Pass --location <loc_id>."],
|
|
2559
|
-
op: kind,
|
|
2560
|
-
errorCode: "SCENE_LOCATION_MISSING",
|
|
2561
|
-
});
|
|
2562
|
-
}
|
|
2563
|
-
ensureRefId(script, "location", locationId);
|
|
2564
|
-
let insertIndex;
|
|
2565
|
-
if (op["before"]) {
|
|
2566
|
-
const [bEp, bScn] = parseSceneRef(op["before"]);
|
|
2567
|
-
if (bEp !== epId) {
|
|
2568
|
-
throw opErr("SCRIPT OP BLOCKED: before address mismatch", "--before must point at the same episode.", {
|
|
2569
|
-
required: [`${epId}/<scn>`],
|
|
2570
|
-
received: [strOf(op["before"])],
|
|
2571
|
-
nextSteps: ["Use a sibling scene address."],
|
|
2572
|
-
op: kind,
|
|
2573
|
-
errorCode: "INSERT_REF_MISMATCH",
|
|
2574
|
-
});
|
|
2575
|
-
}
|
|
2576
|
-
insertIndex = scenes.findIndex((s) => strOf(s["scene_id"]) === bScn);
|
|
2577
|
-
if (insertIndex < 0) {
|
|
2578
|
-
throw opErr("SCRIPT OP BLOCKED: before scene not found", "before scene not found.", {
|
|
2579
|
-
required: ["existing scene_id"],
|
|
2580
|
-
received: [bScn],
|
|
2581
|
-
nextSteps: ["Inspect scenes."],
|
|
2582
|
-
op: kind,
|
|
2583
|
-
errorCode: "SCENE_NOT_FOUND",
|
|
2584
|
-
});
|
|
2585
|
-
}
|
|
2586
|
-
}
|
|
2587
|
-
else if (op["after"]) {
|
|
2588
|
-
const [aEp, aScn] = parseSceneRef(op["after"]);
|
|
2589
|
-
if (aEp !== epId) {
|
|
2590
|
-
throw opErr("SCRIPT OP BLOCKED: after address mismatch", "--after must point at the same episode.", {
|
|
2591
|
-
required: [`${epId}/<scn>`],
|
|
2592
|
-
received: [strOf(op["after"])],
|
|
2593
|
-
nextSteps: ["Use a sibling scene address."],
|
|
2594
|
-
op: kind,
|
|
2595
|
-
errorCode: "INSERT_REF_MISMATCH",
|
|
2596
|
-
});
|
|
2597
|
-
}
|
|
2598
|
-
const found = scenes.findIndex((s) => strOf(s["scene_id"]) === aScn);
|
|
2599
|
-
if (found < 0) {
|
|
2600
|
-
throw opErr("SCRIPT OP BLOCKED: after scene not found", "after scene not found.", {
|
|
2601
|
-
required: ["existing scene_id"],
|
|
2602
|
-
received: [aScn],
|
|
2603
|
-
nextSteps: ["Inspect scenes."],
|
|
2604
|
-
op: kind,
|
|
2605
|
-
errorCode: "SCENE_NOT_FOUND",
|
|
2606
|
-
});
|
|
2607
|
-
}
|
|
2608
|
-
insertIndex = found + 1;
|
|
2609
|
-
}
|
|
2610
|
-
else if (op["at_index"] !== undefined && op["at_index"] !== null) {
|
|
2611
|
-
const n = Number(op["at_index"]);
|
|
2612
|
-
if (!Number.isInteger(n) || n < 0 || n > scenes.length) {
|
|
2613
|
-
throw opErr("SCRIPT OP BLOCKED: at_index out of range", "at_index out of range.", {
|
|
2614
|
-
required: [`0..${scenes.length}`],
|
|
2615
|
-
received: [String(op["at_index"])],
|
|
2616
|
-
nextSteps: ["Use a valid integer index."],
|
|
2617
|
-
op: kind,
|
|
2618
|
-
errorCode: "AT_INDEX_OUT_OF_RANGE",
|
|
2619
|
-
});
|
|
2620
|
-
}
|
|
2621
|
-
insertIndex = n;
|
|
2622
|
-
}
|
|
2623
|
-
else {
|
|
2624
|
-
insertIndex = scenes.length;
|
|
2625
|
-
}
|
|
2626
|
-
const newSceneId = strOf(op["scene_id"]).trim() || nextSceneId(script);
|
|
2627
|
-
if (sceneIdExists(script, newSceneId)) {
|
|
2628
|
-
throw opErr("SCRIPT OP BLOCKED: New scene id exists", "New scene id exists.", {
|
|
2629
|
-
required: ["unused scene_id"],
|
|
2630
|
-
received: [newSceneId],
|
|
2631
|
-
nextSteps: ["Pass --scene-id <unused> or omit."],
|
|
2632
|
-
op: kind,
|
|
2633
|
-
errorCode: "NEW_SCENE_ID_EXISTS",
|
|
2634
|
-
});
|
|
2635
|
-
}
|
|
2636
|
-
const time = strOf(op["time"]).trim() || "day";
|
|
2637
|
-
const space = strOf(op["space"]).trim() || "interior";
|
|
2638
|
-
const newScene = {
|
|
2639
|
-
scene_id: newSceneId,
|
|
2640
|
-
environment: { time, space },
|
|
2641
|
-
context: {
|
|
2642
|
-
locations: [{ location_id: locationId, state_id: null }],
|
|
2643
|
-
actors: [],
|
|
2644
|
-
props: [],
|
|
2645
|
-
},
|
|
2646
|
-
locations: [{ location_id: locationId, state_id: null }],
|
|
2647
|
-
actors: [],
|
|
2648
|
-
props: [],
|
|
2649
|
-
actions: [],
|
|
2650
|
-
};
|
|
2651
|
-
scenes.splice(insertIndex, 0, newScene);
|
|
2652
|
-
ep["scenes"] = scenes;
|
|
2653
|
-
applied.push(kind);
|
|
2654
|
-
}
|
|
2655
|
-
else if (kind === "scene.split") {
|
|
2656
|
-
const [epId, sceneId] = parseSceneRef(op["at"]);
|
|
2657
|
-
const splitAt = op["at_index"] ?? op["action_index"];
|
|
2658
|
-
if (typeof splitAt !== "number" || !Number.isInteger(splitAt)) {
|
|
2659
|
-
throw opErr("SCRIPT OP BLOCKED: Split index invalid", "Split index invalid.", {
|
|
2660
|
-
required: ["at_index: integer between existing actions"],
|
|
2661
|
-
received: [JSON.stringify(op)],
|
|
2662
|
-
nextSteps: ["Inspect actions and use a valid index."],
|
|
2663
|
-
op: kind,
|
|
2664
|
-
errorCode: "SPLIT_INDEX_INVALID",
|
|
2665
|
-
});
|
|
2666
|
-
}
|
|
2667
|
-
const ep = findEpisode(script, epId);
|
|
2668
|
-
const scenes = asList(ep["scenes"]);
|
|
2669
|
-
const index = scenes.findIndex((s) => s["scene_id"] === sceneId);
|
|
2670
|
-
if (index < 0) {
|
|
2671
|
-
throw opErr("SCRIPT OP BLOCKED: Scene not found", "Scene not found.", {
|
|
2672
|
-
required: ["existing scene_id"],
|
|
2673
|
-
received: [sceneId],
|
|
2674
|
-
nextSteps: ["Inspect scenes and fix the address."],
|
|
2675
|
-
op: kind,
|
|
2676
|
-
errorCode: "SCENE_NOT_FOUND",
|
|
2677
|
-
});
|
|
2678
|
-
}
|
|
2679
|
-
const scene = scenes[index];
|
|
2680
|
-
const actions = asList(scene["actions"]);
|
|
2681
|
-
if (splitAt <= 0 || splitAt >= actions.length) {
|
|
2682
|
-
throw opErr("SCRIPT OP BLOCKED: Split index invalid", "Split index out of bounds.", {
|
|
2683
|
-
required: [`0 < at_index < ${actions.length}`],
|
|
2684
|
-
received: [String(splitAt)],
|
|
2685
|
-
nextSteps: ["Choose an action boundary inside the scene."],
|
|
2686
|
-
op: kind,
|
|
2687
|
-
errorCode: "SPLIT_INDEX_INVALID",
|
|
2688
|
-
});
|
|
2689
|
-
}
|
|
2690
|
-
const newSceneId = strOf(op["new_scene_id"]) || nextSceneId(script);
|
|
2691
|
-
if (sceneIdExists(script, newSceneId)) {
|
|
2692
|
-
throw opErr("SCRIPT OP BLOCKED: New scene id exists", "New scene id exists.", {
|
|
2693
|
-
required: ["unused new_scene_id"],
|
|
2694
|
-
received: [newSceneId],
|
|
2695
|
-
nextSteps: ["Choose an unused scene id or omit new_scene_id."],
|
|
2696
|
-
op: kind,
|
|
2697
|
-
errorCode: "NEW_SCENE_ID_EXISTS",
|
|
2698
|
-
});
|
|
2699
|
-
}
|
|
2700
|
-
const ctx = sceneContext(scene);
|
|
2701
|
-
const newScene = {
|
|
2702
|
-
scene_id: newSceneId,
|
|
2703
|
-
environment: { ...(isDict(scene["environment"]) ? scene["environment"] : {}) },
|
|
2704
|
-
context: ctx,
|
|
2705
|
-
locations: [...asList(ctx["locations"])],
|
|
2706
|
-
actors: [...asList(ctx["actors"])],
|
|
2707
|
-
props: [...asList(ctx["props"])],
|
|
2708
|
-
actions: actions.slice(splitAt),
|
|
2709
|
-
};
|
|
2710
|
-
scene["actions"] = actions.slice(0, splitAt);
|
|
2711
|
-
scenes.splice(index + 1, 0, newScene);
|
|
2712
|
-
applied.push(kind);
|
|
2713
|
-
}
|
|
2714
|
-
else if (kind === "scene.merge") {
|
|
2715
|
-
// 0.6.0 uses {from, into} (source disappears, into keeps). The legacy op
|
|
2716
|
-
// used {keep_scene_id, remove_scene_id}; payload still accepted for back-
|
|
2717
|
-
// compat during transition.
|
|
2718
|
-
const fromRaw = op["from"] ?? op["remove_scene_id"];
|
|
2719
|
-
const intoRaw = op["into"] ?? op["keep_scene_id"];
|
|
2720
|
-
// from can be either ep/scn or just scn (legacy). Normalize.
|
|
2721
|
-
let fromEp, fromScn, intoEp, intoScn;
|
|
2722
|
-
if (typeof fromRaw === "string" && fromRaw.includes("/")) {
|
|
2723
|
-
[fromEp, fromScn] = parseSceneRef(fromRaw);
|
|
2724
|
-
}
|
|
2725
|
-
else {
|
|
2726
|
-
// Legacy: needs episode_id from op.
|
|
2727
|
-
fromEp = strOf(op["episode_id"]);
|
|
2728
|
-
fromScn = strOf(fromRaw);
|
|
2729
|
-
}
|
|
2730
|
-
if (typeof intoRaw === "string" && intoRaw.includes("/")) {
|
|
2731
|
-
[intoEp, intoScn] = parseSceneRef(intoRaw);
|
|
2732
|
-
}
|
|
2733
|
-
else {
|
|
2734
|
-
intoEp = strOf(op["episode_id"]);
|
|
2735
|
-
intoScn = strOf(intoRaw);
|
|
2736
|
-
}
|
|
2737
|
-
if (fromEp !== intoEp) {
|
|
2738
|
-
throw opErr("SCRIPT OP BLOCKED: Merge cross-episode", "Scene merge must be within one episode.", {
|
|
2739
|
-
required: ["same episode_id on both sides"],
|
|
2740
|
-
received: [`${fromEp}/${fromScn} → ${intoEp}/${intoScn}`],
|
|
2741
|
-
nextSteps: ["Move one scene first, or merge same-episode scenes."],
|
|
2742
|
-
op: kind,
|
|
2743
|
-
errorCode: "MERGE_CROSS_EPISODE",
|
|
2744
|
-
});
|
|
2745
|
-
}
|
|
2746
|
-
const ep = findEpisode(script, fromEp);
|
|
2747
|
-
const scenes = asList(ep["scenes"]);
|
|
2748
|
-
const intoScene = scenes.find((s) => s["scene_id"] === intoScn);
|
|
2749
|
-
const fromScene = scenes.find((s) => s["scene_id"] === fromScn);
|
|
2750
|
-
if (!intoScene || !fromScene) {
|
|
2751
|
-
throw opErr("SCRIPT OP BLOCKED: Scene not found", "Scene not found.", {
|
|
2752
|
-
required: ["existing scene_id on both sides"],
|
|
2753
|
-
received: [`from=${fromScn}, into=${intoScn}`],
|
|
2754
|
-
nextSteps: ["Inspect scenes and fix the address."],
|
|
2755
|
-
op: kind,
|
|
2756
|
-
errorCode: "SCENE_NOT_FOUND",
|
|
2757
|
-
});
|
|
2758
|
-
}
|
|
2759
|
-
intoScene["actions"].push(...asList(fromScene["actions"]));
|
|
2760
|
-
const intoCtx = sceneContext(intoScene);
|
|
2761
|
-
const fromCtx = sceneContext(fromScene);
|
|
2762
|
-
for (const [field, idKey] of [["actors", "actor_id"], ["locations", "location_id"], ["props", "prop_id"]]) {
|
|
2763
|
-
const existingIds = new Set();
|
|
2764
|
-
for (const ref of asList(intoCtx[field]))
|
|
2765
|
-
existingIds.add(ref[idKey]);
|
|
2766
|
-
for (const ref of asList(fromCtx[field])) {
|
|
2767
|
-
if (!existingIds.has(ref[idKey])) {
|
|
2768
|
-
if (!isList(intoCtx[field]))
|
|
2769
|
-
intoCtx[field] = [];
|
|
2770
|
-
intoCtx[field].push(ref);
|
|
2771
|
-
}
|
|
2772
|
-
}
|
|
2773
|
-
}
|
|
2774
|
-
setSceneContext(intoScene, intoCtx);
|
|
2775
|
-
ep["scenes"] = scenes.filter((s) => s !== fromScene);
|
|
2776
|
-
applied.push(kind);
|
|
2777
|
-
}
|
|
2778
|
-
else if (kind === "scene.delete") {
|
|
2779
|
-
const [epId, sceneId] = parseSceneRef(op["at"]);
|
|
2780
|
-
const ep = findEpisode(script, epId);
|
|
2781
|
-
const scenes = asList(ep["scenes"]);
|
|
2782
|
-
const scene = scenes.find((s) => s["scene_id"] === sceneId);
|
|
2783
|
-
if (!scene) {
|
|
2784
|
-
throw opErr("SCRIPT OP BLOCKED: Scene not found", "Scene not found.", {
|
|
2785
|
-
required: ["existing scene_id"],
|
|
2786
|
-
received: [`${epId}/${sceneId}`],
|
|
2787
|
-
nextSteps: ["Inspect scenes."],
|
|
2788
|
-
op: kind,
|
|
2789
|
-
errorCode: "SCENE_NOT_FOUND",
|
|
2790
|
-
});
|
|
2791
|
-
}
|
|
2792
|
-
const actions = asList(scene["actions"]);
|
|
2793
|
-
const force = Boolean(op["force"]);
|
|
2794
|
-
if (actions.length > 0 && !force) {
|
|
2795
|
-
throw opErr("SCRIPT OP BLOCKED: Scene non-empty", `Scene has ${actions.length} action(s).`, {
|
|
2796
|
-
required: ["empty scene, or force=true"],
|
|
2797
|
-
received: [`actions=${actions.length}`],
|
|
2798
|
-
nextSteps: ["Pass --force to delete with actions, or move/delete actions first."],
|
|
2799
|
-
op: kind,
|
|
2800
|
-
errorCode: "SCENE_NON_EMPTY",
|
|
2801
|
-
});
|
|
2802
|
-
}
|
|
2803
|
-
ep["scenes"] = scenes.filter((s) => s !== scene);
|
|
2804
|
-
applied.push(kind);
|
|
2805
|
-
}
|
|
2806
|
-
else if (kind === "scene.move") {
|
|
2807
|
-
const [fromEp, fromScn] = parseSceneRef(op["at"] ?? op["from"]);
|
|
2808
|
-
const toEpRaw = strOf(op["to"]);
|
|
2809
|
-
// `to` accepts either bare `ep_NNN` (append) or `ep_NNN/scn_NNN` (insert before that scene).
|
|
2810
|
-
// `--at <idx>` overrides positioning.
|
|
2811
|
-
let toEp;
|
|
2812
|
-
let toAnchorScn = null;
|
|
2813
|
-
if (toEpRaw.includes("/")) {
|
|
2814
|
-
const [a, b] = parseSceneRef(toEpRaw);
|
|
2815
|
-
toEp = a;
|
|
2816
|
-
toAnchorScn = b;
|
|
2817
|
-
}
|
|
2818
|
-
else {
|
|
2819
|
-
toEp = toEpRaw;
|
|
2820
|
-
}
|
|
2821
|
-
if (!toEp) {
|
|
2822
|
-
throw opErr("SCRIPT OP BLOCKED: to address invalid", "to address invalid.", {
|
|
2823
|
-
required: ["to: ep_NNN or ep_NNN/scn_NNN"],
|
|
2824
|
-
received: [toEpRaw],
|
|
2825
|
-
nextSteps: ["Pass a target episode id."],
|
|
2826
|
-
op: kind,
|
|
2827
|
-
errorCode: "SCENE_MOVE_TO_INVALID",
|
|
2828
|
-
});
|
|
2829
|
-
}
|
|
2830
|
-
const fromEpisode = findEpisode(script, fromEp);
|
|
2831
|
-
const fromScenes = asList(fromEpisode["scenes"]);
|
|
2832
|
-
const sourceIdx = fromScenes.findIndex((s) => s["scene_id"] === fromScn);
|
|
2833
|
-
if (sourceIdx < 0) {
|
|
2834
|
-
throw opErr("SCRIPT OP BLOCKED: Scene not found", "Source scene not found.", {
|
|
2835
|
-
required: ["existing scene_id"],
|
|
2836
|
-
received: [`${fromEp}/${fromScn}`],
|
|
2837
|
-
nextSteps: ["Inspect scenes."],
|
|
2838
|
-
op: kind,
|
|
2839
|
-
errorCode: "SCENE_NOT_FOUND",
|
|
2840
|
-
});
|
|
2841
|
-
}
|
|
2842
|
-
const [moved] = fromScenes.splice(sourceIdx, 1);
|
|
2843
|
-
fromEpisode["scenes"] = fromScenes;
|
|
2844
|
-
const toEpisode = findEpisode(script, toEp);
|
|
2845
|
-
const toScenes = asList(toEpisode["scenes"]);
|
|
2846
|
-
let destIdx;
|
|
2847
|
-
if (op["at_index"] !== undefined && op["at_index"] !== null) {
|
|
2848
|
-
const n = Number(op["at_index"]);
|
|
2849
|
-
if (!Number.isInteger(n) || n < 0 || n > toScenes.length) {
|
|
2850
|
-
throw opErr("SCRIPT OP BLOCKED: at_index out of range", "at_index out of range.", {
|
|
2851
|
-
required: [`0..${toScenes.length}`],
|
|
2852
|
-
received: [String(op["at_index"])],
|
|
2853
|
-
nextSteps: ["Use a valid integer index."],
|
|
2854
|
-
op: kind,
|
|
2855
|
-
errorCode: "AT_INDEX_OUT_OF_RANGE",
|
|
2856
|
-
});
|
|
2857
|
-
}
|
|
2858
|
-
destIdx = n;
|
|
2859
|
-
}
|
|
2860
|
-
else if (toAnchorScn) {
|
|
2861
|
-
const found = toScenes.findIndex((s) => s["scene_id"] === toAnchorScn);
|
|
2862
|
-
if (found < 0) {
|
|
2863
|
-
throw opErr("SCRIPT OP BLOCKED: Target scene not found", "Target scene not found.", {
|
|
2864
|
-
required: ["existing scene_id in target episode"],
|
|
2865
|
-
received: [`${toEp}/${toAnchorScn}`],
|
|
2866
|
-
nextSteps: ["Inspect the target episode."],
|
|
2867
|
-
op: kind,
|
|
2868
|
-
errorCode: "SCENE_NOT_FOUND",
|
|
2869
|
-
});
|
|
2870
|
-
}
|
|
2871
|
-
destIdx = found;
|
|
2872
|
-
}
|
|
2873
|
-
else {
|
|
2874
|
-
destIdx = toScenes.length;
|
|
2875
|
-
}
|
|
2876
|
-
toScenes.splice(destIdx, 0, moved);
|
|
2877
|
-
toEpisode["scenes"] = toScenes;
|
|
2878
|
-
applied.push(kind);
|
|
2879
|
-
}
|
|
2880
|
-
else if (kind === "speaker.delete") {
|
|
2881
|
-
// Mirror of asset.delete for speakers: refs scrub or replace via
|
|
2882
|
-
// --strategy. Speaker refs live in action.speaker_id /
|
|
2883
|
-
// action.speakers[] / action.lines[].speaker_id only — no scene
|
|
2884
|
-
// context entries to handle.
|
|
2885
|
-
const speakerId = strOf(op["target"] ?? op["speaker_id"]).trim();
|
|
2886
|
-
if (!speakerId) {
|
|
2887
|
-
throw opErr("SCRIPT OP BLOCKED: Speaker id missing", "Speaker id missing.", {
|
|
2888
|
-
required: ["target speaker id"],
|
|
2889
|
-
received: [JSON.stringify(op)],
|
|
2890
|
-
nextSteps: ["Pass a spk_id."],
|
|
2891
|
-
op: kind,
|
|
2892
|
-
errorCode: "SPEAKER_ID_MISSING",
|
|
2893
|
-
});
|
|
2894
|
-
}
|
|
2895
|
-
const refs = collectAssetRefs(script, "speaker", speakerId);
|
|
2896
|
-
const strategy = strOf(op["strategy"]).trim();
|
|
2897
|
-
if (refs.length > 0 && !strategy) {
|
|
2898
|
-
throw opErr("SCRIPT OP BLOCKED: Speaker has refs", `Speaker is referenced ${refs.length} time(s).`, {
|
|
2899
|
-
required: ["--strategy replace|remove"],
|
|
2900
|
-
received: [`refs=${refs.length}`],
|
|
2901
|
-
nextSteps: ["Use --strategy replace --replacement <spk_id> to remap, or --strategy remove to scrub refs."],
|
|
2902
|
-
op: kind,
|
|
2903
|
-
errorCode: "SPEAKER_HAS_REFS",
|
|
2904
|
-
});
|
|
2905
|
-
}
|
|
2906
|
-
const replacement = strOf(op["replacement"]).trim();
|
|
2907
|
-
if (strategy === "replace") {
|
|
2908
|
-
if (!replacement) {
|
|
2909
|
-
throw opErr("SCRIPT OP BLOCKED: Replacement missing", "Replacement missing.", {
|
|
2910
|
-
required: ["replacement speaker id"],
|
|
2911
|
-
received: ["<empty>"],
|
|
2912
|
-
nextSteps: ["Pass --replacement <spk_id>."],
|
|
2913
|
-
op: kind,
|
|
2914
|
-
errorCode: "REPLACEMENT_MISSING",
|
|
2915
|
-
});
|
|
2916
|
-
}
|
|
2917
|
-
// Verify replacement exists.
|
|
2918
|
-
if (!asList(script["speakers"]).some((s) => strOf(s["speaker_id"]) === replacement)) {
|
|
2919
|
-
throw opErr("SCRIPT OP BLOCKED: Replacement speaker not found", "Replacement speaker not found.", {
|
|
2920
|
-
required: ["existing speaker id"],
|
|
2921
|
-
received: [replacement],
|
|
2922
|
-
nextSteps: ["Add the speaker first or use a different replacement."],
|
|
2923
|
-
op: kind,
|
|
2924
|
-
errorCode: "SPEAKER_NOT_FOUND",
|
|
2925
|
-
});
|
|
2926
|
-
}
|
|
2927
|
-
for (const ep of asList(script["episodes"])) {
|
|
2928
|
-
for (const scene of asList(ep["scenes"])) {
|
|
2929
|
-
for (const action of asList(scene["actions"])) {
|
|
2930
|
-
if (strOf(action["speaker_id"]) === speakerId)
|
|
2931
|
-
action["speaker_id"] = replacement;
|
|
2932
|
-
const speakers = asList(action["speakers"]);
|
|
2933
|
-
for (const sp of speakers) {
|
|
2934
|
-
if (strOf(sp["speaker_id"]) === speakerId)
|
|
2935
|
-
sp["speaker_id"] = replacement;
|
|
2936
|
-
}
|
|
2937
|
-
// Dedupe action.speakers[] in case the action already referenced
|
|
2938
|
-
// the replacement: an unconditional rewrite + no dedupe would
|
|
2939
|
-
// leave [spk_replacement, spk_replacement], breaking overlap/
|
|
2940
|
-
// simultaneous delivery counts downstream.
|
|
2941
|
-
if (action["speakers"] !== undefined) {
|
|
2942
|
-
const seen = new Set();
|
|
2943
|
-
action["speakers"] = speakers.filter((s) => {
|
|
2944
|
-
const id = strOf(s["speaker_id"]);
|
|
2945
|
-
if (seen.has(id))
|
|
2946
|
-
return false;
|
|
2947
|
-
seen.add(id);
|
|
2948
|
-
return true;
|
|
2949
|
-
});
|
|
2950
|
-
}
|
|
2951
|
-
const lines = asList(action["lines"]);
|
|
2952
|
-
for (const line of lines) {
|
|
2953
|
-
if (strOf(line["speaker_id"]) === speakerId)
|
|
2954
|
-
line["speaker_id"] = replacement;
|
|
2955
|
-
}
|
|
2956
|
-
// lines[] semantically represents distinct overlapping lines
|
|
2957
|
-
// (a speaker can legitimately overlap with themselves), so we do
|
|
2958
|
-
// NOT dedupe lines — preserve overlap-with-self if it existed.
|
|
2959
|
-
}
|
|
2960
|
-
}
|
|
2961
|
-
}
|
|
2962
|
-
}
|
|
2963
|
-
else if (strategy === "remove" || refs.length === 0) {
|
|
2964
|
-
for (const ep of asList(script["episodes"])) {
|
|
2965
|
-
for (const scene of asList(ep["scenes"])) {
|
|
2966
|
-
for (const action of asList(scene["actions"])) {
|
|
2967
|
-
if (strOf(action["speaker_id"]) === speakerId)
|
|
2968
|
-
delete action["speaker_id"];
|
|
2969
|
-
const speakers = asList(action["speakers"]).filter((s) => strOf(s["speaker_id"]) !== speakerId);
|
|
2970
|
-
if (action["speakers"] !== undefined) {
|
|
2971
|
-
if (speakers.length > 0)
|
|
2972
|
-
action["speakers"] = speakers;
|
|
2973
|
-
else
|
|
2974
|
-
delete action["speakers"];
|
|
2975
|
-
}
|
|
2976
|
-
const lines = asList(action["lines"]).filter((l) => strOf(l["speaker_id"]) !== speakerId);
|
|
2977
|
-
if (action["lines"] !== undefined) {
|
|
2978
|
-
if (lines.length > 0)
|
|
2979
|
-
action["lines"] = lines;
|
|
2980
|
-
else
|
|
2981
|
-
delete action["lines"];
|
|
2982
|
-
}
|
|
2983
|
-
}
|
|
2984
|
-
}
|
|
2985
|
-
}
|
|
2986
|
-
}
|
|
2987
|
-
else {
|
|
2988
|
-
throw opErr("SCRIPT OP BLOCKED: Strategy invalid", "Strategy invalid.", {
|
|
2989
|
-
required: ["strategy: replace or remove"],
|
|
2990
|
-
received: [strategy],
|
|
2991
|
-
nextSteps: ["Use replace (with --replacement) or remove."],
|
|
2992
|
-
op: kind,
|
|
2993
|
-
errorCode: "DELETE_STRATEGY_INVALID",
|
|
2994
|
-
});
|
|
2995
|
-
}
|
|
2996
|
-
script["speakers"] = asList(script["speakers"]).filter((s) => strOf(s["speaker_id"]) !== speakerId);
|
|
2997
|
-
applied.push(kind);
|
|
2998
|
-
}
|
|
2999
|
-
else if (kind === "context.ref.remove") {
|
|
3000
|
-
const [epId, sceneId] = parseSceneRef(op["at"]);
|
|
3001
|
-
const [targetKind, targetId] = parseAssetTarget(op["target"]);
|
|
3002
|
-
const scene = findScene(script, epId, sceneId);
|
|
3003
|
-
const ctx = sceneContext(scene);
|
|
3004
|
-
const idKey = idKeyForKind(targetKind);
|
|
3005
|
-
ctx[pluralForKind(targetKind)] = contextRefsForKind(ctx, targetKind).filter((ref) => strOf(ref[idKey]) !== targetId);
|
|
3006
|
-
setSceneContext(scene, ctx);
|
|
3007
|
-
applied.push(kind);
|
|
3008
|
-
}
|
|
3009
|
-
else {
|
|
3010
|
-
throw opErr("PATCH BLOCKED: Unsupported operation", "Unsupported patch operation.", {
|
|
3011
|
-
required: [
|
|
3012
|
-
"dot-style op: meta.worldview.set / asset.{rename,describe,alias.set,alias.add,alias.remove,states.set,role.set,merge,delete} / state.{add,rename,describe,delete} / context.{set,clear,ref.remove} / action.{type.set,actor.set,delete,insert,move,state.change,state.remove,transition.set,transition.clear,content.replace} / scene.{split,merge,delete,move} / dialogue.{speakers,overlap} / speaker.add",
|
|
3013
|
-
],
|
|
3014
|
-
received: [kind],
|
|
3015
|
-
nextSteps: [
|
|
3016
|
-
"Use a supported dot-style patch operation.",
|
|
3017
|
-
"Legacy snake-style ops (set_worldview / rename_actor / set_action_type / merge_scenes / split_scene / set_scene_*_ref / set_action_actor / set_asset_aliases / set_asset_states / set_action_content_from_span etc.) were removed in 0.6.0; use the new dot-style equivalents.",
|
|
3018
|
-
],
|
|
3019
|
-
errorCode: "OP_UNSUPPORTED",
|
|
3020
|
-
});
|
|
3021
|
-
}
|
|
3022
|
-
}
|
|
3023
|
-
renumberSceneIds(script);
|
|
3024
|
-
return applied;
|
|
3025
|
-
}
|
|
3026
|
-
//# sourceMappingURL=script-core.js.map
|