@lingjingai/scriptctl 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/cli.js +199 -112
- package/dist/cli.js.map +1 -1
- package/dist/common.d.ts +1 -1
- package/dist/common.js +1 -1
- package/dist/common.js.map +1 -1
- package/dist/domain/script-core.d.ts +33 -0
- package/dist/domain/script-core.js +1065 -158
- package/dist/domain/script-core.js.map +1 -1
- package/dist/help-text.js +724 -226
- package/dist/help-text.js.map +1 -1
- package/dist/infra/providers.js +3 -3
- package/dist/infra/providers.js.map +1 -1
- package/dist/usecases/direct.d.ts +1 -0
- package/dist/usecases/direct.js +14 -2
- package/dist/usecases/direct.js.map +1 -1
- package/dist/usecases/doctor.js +1 -1
- package/dist/usecases/doctor.js.map +1 -1
- package/dist/usecases/script.d.ts +33 -7
- package/dist/usecases/script.js +1357 -404
- package/dist/usecases/script.js.map +1 -1
- package/package.json +1 -1
|
@@ -1236,8 +1236,18 @@ function setContextState(script, epId, sceneId, kind, targetId, stateId) {
|
|
|
1236
1236
|
const normalizedState = validateStateForTarget(script, kind, targetId, stateId);
|
|
1237
1237
|
const scene = findScene(script, epId, sceneId);
|
|
1238
1238
|
const ctx = sceneContext(scene);
|
|
1239
|
-
const refs = [...contextRefsForKind(ctx, kind)];
|
|
1240
1239
|
const idKey = idKeyForKind(kind);
|
|
1240
|
+
// location is single-valued per scene by direct-init invariant; the legacy
|
|
1241
|
+
// set_scene_location_ref op replaced the whole locations[] array to preserve
|
|
1242
|
+
// that. Mirror it here: for location kind, context.set REPLACES (one entry
|
|
1243
|
+
// total). actor/prop stay upsert because multi-actor / multi-prop per scene
|
|
1244
|
+
// is intentional.
|
|
1245
|
+
if (kind === "location") {
|
|
1246
|
+
ctx[pluralForKind(kind)] = [{ [idKey]: targetId, state_id: normalizedState }];
|
|
1247
|
+
setSceneContext(scene, ctx);
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
const refs = [...contextRefsForKind(ctx, kind)];
|
|
1241
1251
|
const existing = refs.find((ref) => strOf(ref[idKey]) === targetId);
|
|
1242
1252
|
if (existing) {
|
|
1243
1253
|
existing["state_id"] = normalizedState;
|
|
@@ -1304,6 +1314,167 @@ export function collectStateRefs(script, kind, targetId, stateId) {
|
|
|
1304
1314
|
}
|
|
1305
1315
|
return refs;
|
|
1306
1316
|
}
|
|
1317
|
+
// Try each format in order. The order matters: action (has `#`) is checked
|
|
1318
|
+
// before scene (just `/`), and asset+state (has `:`) is checked before scene.
|
|
1319
|
+
// Episode and speaker are bare ids (no separators) distinguished by prefix.
|
|
1320
|
+
export function parseAnyAddress(raw) {
|
|
1321
|
+
const value = strOf(raw).trim();
|
|
1322
|
+
if (!value) {
|
|
1323
|
+
throw new CliError("ADDRESS BLOCKED: Address empty", "Address empty.", {
|
|
1324
|
+
exitCode: EXIT_USAGE,
|
|
1325
|
+
required: ["one of the supported address formats"],
|
|
1326
|
+
received: ["<empty>"],
|
|
1327
|
+
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."],
|
|
1328
|
+
errorCode: "ADDRESS_EMPTY",
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
if (value.includes("#")) {
|
|
1332
|
+
const [episodeId, sceneId, actionIndex] = parseActionRef(value);
|
|
1333
|
+
return { kind: "action", episodeId, sceneId, actionIndex };
|
|
1334
|
+
}
|
|
1335
|
+
if (value.includes(":")) {
|
|
1336
|
+
if (value.slice(value.indexOf(":") + 1).includes("/")) {
|
|
1337
|
+
const [assetKind, assetId, stateId] = parseStateTarget(value);
|
|
1338
|
+
return { kind: "state", assetKind, assetId, stateId };
|
|
1339
|
+
}
|
|
1340
|
+
const [assetKind, assetId] = parseAssetTarget(value);
|
|
1341
|
+
return { kind: "asset", assetKind, assetId };
|
|
1342
|
+
}
|
|
1343
|
+
if (value.includes("/")) {
|
|
1344
|
+
const [episodeId, sceneId] = parseSceneRef(value);
|
|
1345
|
+
return { kind: "scene", episodeId, sceneId };
|
|
1346
|
+
}
|
|
1347
|
+
if (/^ep_/.test(value)) {
|
|
1348
|
+
return { kind: "episode", episodeId: value };
|
|
1349
|
+
}
|
|
1350
|
+
if (/^spk_/.test(value)) {
|
|
1351
|
+
return { kind: "speaker", speakerId: value };
|
|
1352
|
+
}
|
|
1353
|
+
throw new CliError("ADDRESS BLOCKED: Address invalid", "Address invalid.", {
|
|
1354
|
+
exitCode: EXIT_USAGE,
|
|
1355
|
+
required: [
|
|
1356
|
+
"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)",
|
|
1357
|
+
],
|
|
1358
|
+
received: [value],
|
|
1359
|
+
nextSteps: ["Use a recognized address format."],
|
|
1360
|
+
errorCode: "ADDRESS_INVALID",
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
// Reverse lookup for asset / speaker references. Parallel to collectStateRefs
|
|
1364
|
+
// but tracks where an actor/location/prop/speaker is mentioned rather than
|
|
1365
|
+
// where a specific state is mentioned. Returns one `{target, location, role}`
|
|
1366
|
+
// entry per reference site. Walks the same edges the validator does
|
|
1367
|
+
// (script-core.ts validateScript) so coverage is complete:
|
|
1368
|
+
// - scene.context.{actors|locations|props}[]
|
|
1369
|
+
// - action.actor_id (actor only)
|
|
1370
|
+
// - action.speaker_id + action.speakers[] + action.lines[].speaker_id (speaker only)
|
|
1371
|
+
// - action.state_changes[].target_{kind,id}
|
|
1372
|
+
// - action.transition_prompt.target_{kind,id}
|
|
1373
|
+
// - script.speakers[].source_{kind,id} (for actor/location/prop reverse)
|
|
1374
|
+
export function collectAssetRefs(script, kind, targetId) {
|
|
1375
|
+
const refs = [];
|
|
1376
|
+
const isSpeaker = kind === "speaker";
|
|
1377
|
+
const target = isSpeaker ? `speaker:${targetId}` : `${kind}:${targetId}`;
|
|
1378
|
+
const idKey = isSpeaker ? null : idKeyForKind(kind);
|
|
1379
|
+
for (const ep of asList(script["episodes"])) {
|
|
1380
|
+
const epId = strOf(ep["episode_id"]);
|
|
1381
|
+
for (const scene of asList(ep["scenes"])) {
|
|
1382
|
+
const sceneId = strOf(scene["scene_id"]);
|
|
1383
|
+
if (!isSpeaker) {
|
|
1384
|
+
const ctx = sceneContext(scene);
|
|
1385
|
+
const refsList = contextRefsForKind(ctx, kind);
|
|
1386
|
+
for (let i = 0; i < refsList.length; i++) {
|
|
1387
|
+
if (strOf(refsList[i][idKey]) === targetId) {
|
|
1388
|
+
refs.push({
|
|
1389
|
+
target,
|
|
1390
|
+
location: `${epId}/${sceneId}.context.${pluralForKind(kind)}[${i}]`,
|
|
1391
|
+
role: `scene_${pluralForKind(kind)}`,
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
const actions = asList(scene["actions"]);
|
|
1397
|
+
for (let actionIdx = 0; actionIdx < actions.length; actionIdx++) {
|
|
1398
|
+
const action = actions[actionIdx];
|
|
1399
|
+
if (kind === "actor" && strOf(action["actor_id"]) === targetId) {
|
|
1400
|
+
refs.push({
|
|
1401
|
+
target,
|
|
1402
|
+
location: `${epId}/${sceneId}#${actionIdx}.actor_id`,
|
|
1403
|
+
role: "action_actor",
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
if (isSpeaker) {
|
|
1407
|
+
if (strOf(action["speaker_id"]) === targetId) {
|
|
1408
|
+
refs.push({
|
|
1409
|
+
target,
|
|
1410
|
+
location: `${epId}/${sceneId}#${actionIdx}.speaker_id`,
|
|
1411
|
+
role: "action_speaker",
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
const speakers = asList(action["speakers"]);
|
|
1415
|
+
for (let i = 0; i < speakers.length; i++) {
|
|
1416
|
+
if (strOf(speakers[i]["speaker_id"]) === targetId) {
|
|
1417
|
+
refs.push({
|
|
1418
|
+
target,
|
|
1419
|
+
location: `${epId}/${sceneId}#${actionIdx}.speakers[${i}]`,
|
|
1420
|
+
role: "dialogue_speaker",
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
const lines = asList(action["lines"]);
|
|
1425
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1426
|
+
if (strOf(lines[i]["speaker_id"]) === targetId) {
|
|
1427
|
+
refs.push({
|
|
1428
|
+
target,
|
|
1429
|
+
location: `${epId}/${sceneId}#${actionIdx}.lines[${i}].speaker_id`,
|
|
1430
|
+
role: "overlap_line_speaker",
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
if (!isSpeaker) {
|
|
1436
|
+
const changes = asList(action["state_changes"]);
|
|
1437
|
+
for (let i = 0; i < changes.length; i++) {
|
|
1438
|
+
const change = changes[i];
|
|
1439
|
+
if (strOf(change["target_kind"]) === kind && strOf(change["target_id"]) === targetId) {
|
|
1440
|
+
refs.push({
|
|
1441
|
+
target,
|
|
1442
|
+
location: `${epId}/${sceneId}#${actionIdx}.state_changes[${i}]`,
|
|
1443
|
+
role: "state_change_target",
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
const transition = action["transition_prompt"];
|
|
1448
|
+
if (isDict(transition) && strOf(transition["target_kind"]) === kind && strOf(transition["target_id"]) === targetId) {
|
|
1449
|
+
refs.push({
|
|
1450
|
+
target,
|
|
1451
|
+
location: `${epId}/${sceneId}#${actionIdx}.transition_prompt`,
|
|
1452
|
+
role: "transition_target",
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
// Speaker registrations pointing at this asset (actor/location/prop only).
|
|
1460
|
+
// A speaker.source_id ties an actor/location/prop to a speaker entity; the
|
|
1461
|
+
// speaker entry itself counts as a reference, so deleting / merging the
|
|
1462
|
+
// asset must surface it.
|
|
1463
|
+
if (!isSpeaker) {
|
|
1464
|
+
const speakers = asList(script["speakers"]);
|
|
1465
|
+
for (let i = 0; i < speakers.length; i++) {
|
|
1466
|
+
const s = speakers[i];
|
|
1467
|
+
if (strOf(s["source_kind"]) === kind && strOf(s["source_id"]) === targetId) {
|
|
1468
|
+
refs.push({
|
|
1469
|
+
target,
|
|
1470
|
+
location: `speakers[${i}]`,
|
|
1471
|
+
role: "speaker_source",
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
return refs;
|
|
1477
|
+
}
|
|
1307
1478
|
function nextSceneId(script) {
|
|
1308
1479
|
let maxSeen = 0;
|
|
1309
1480
|
for (const ep of asList(script["episodes"])) {
|
|
@@ -1346,6 +1517,51 @@ function opErr(title, message, opts = {}) {
|
|
|
1346
1517
|
errorCode: opts.errorCode,
|
|
1347
1518
|
});
|
|
1348
1519
|
}
|
|
1520
|
+
export const PATCH_OP_SCHEMA = {
|
|
1521
|
+
// meta
|
|
1522
|
+
"meta.worldview.set": { required: ["worldview"], optional: ["worldview_raw"], description: "Set the script.worldview enum." },
|
|
1523
|
+
// state
|
|
1524
|
+
"state.add": { required: ["target", "name"], optional: ["description", "state_id"], description: "Add a state to an asset's states[]." },
|
|
1525
|
+
"state.rename": { required: ["target", "name"], optional: [], description: "Rename an asset state." },
|
|
1526
|
+
"state.describe": { required: ["target", "description"], optional: [], description: "Set a state's description." },
|
|
1527
|
+
"state.delete": { required: ["target", "strategy"], optional: ["replacement"], description: "Delete a state. strategy: replace|remove." },
|
|
1528
|
+
// asset
|
|
1529
|
+
"asset.rename": { required: ["target", "name"], optional: [], description: "Rename an asset (actor / location / prop)." },
|
|
1530
|
+
"asset.describe": { required: ["target", "description"], optional: [], description: "Set asset description." },
|
|
1531
|
+
"asset.alias.set": { required: ["target", "aliases"], optional: [], description: "Bulk replace aliases[]." },
|
|
1532
|
+
"asset.alias.add": { required: ["target", "alias"], optional: [], description: "Append alias(es)." },
|
|
1533
|
+
"asset.alias.remove": { required: ["target", "alias"], optional: [], description: "Remove alias(es)." },
|
|
1534
|
+
"asset.states.set": { required: ["target", "states"], optional: ["asset_type"], description: "Bulk replace states[]." },
|
|
1535
|
+
"asset.role.set": { required: ["target", "role_type"], optional: [], description: "Set actor role_type (主角|配角)." },
|
|
1536
|
+
"asset.merge": { required: ["from", "into"], optional: [], description: "Merge from asset into another; from disappears." },
|
|
1537
|
+
"asset.delete": { required: ["target"], optional: ["strategy", "replacement"], description: "Delete an asset. strategy: replace|remove (required if refs exist)." },
|
|
1538
|
+
// action
|
|
1539
|
+
"action.type.set": { required: ["at", "type"], optional: [], description: "Set action.type (dialogue|action|inner_thought)." },
|
|
1540
|
+
"action.actor.set": { required: ["at"], optional: ["actor_id"], description: "Set/clear action.actor_id." },
|
|
1541
|
+
"action.content.replace": { required: ["at", "from"], optional: ["to", "all"], description: "Literal substring replace in action.content." },
|
|
1542
|
+
"action.state.change": { required: ["at", "target", "to"], optional: ["from", "effective"], description: "Add/update a state_change on an action." },
|
|
1543
|
+
"action.state.remove": { required: ["at", "target"], optional: [], description: "Remove a state_change from an action." },
|
|
1544
|
+
"action.transition.set": { required: ["at", "target", "process", "contrast"], optional: [], description: "Set transition_prompt." },
|
|
1545
|
+
"action.transition.clear": { required: ["at", "target"], optional: [], description: "Clear transition_prompt." },
|
|
1546
|
+
"action.delete": { required: ["at"], optional: [], description: "Remove an action; later indices shift." },
|
|
1547
|
+
"action.insert": { required: ["at", "type"], optional: ["content", "at_index", "before", "after", "actor_id", "speaker_id"], description: "Insert an action into a scene." },
|
|
1548
|
+
"action.move": { required: ["at", "to"], optional: [], description: "Move an action (within or across scenes)." },
|
|
1549
|
+
// scene
|
|
1550
|
+
"scene.split": { required: ["at", "at_index"], optional: ["new_scene_id"], description: "Split a scene at an action boundary." },
|
|
1551
|
+
"scene.merge": { required: ["from", "into"], optional: [], description: "Merge two scenes within the same episode." },
|
|
1552
|
+
"scene.delete": { required: ["at"], optional: ["force"], description: "Delete a scene; empty by default, force=true for non-empty." },
|
|
1553
|
+
"scene.move": { required: ["at", "to"], optional: ["at_index"], description: "Move a scene to another episode." },
|
|
1554
|
+
"scene.insert": { required: ["at", "location"], optional: ["time", "space", "at_index", "before", "after", "scene_id"], description: "Insert a new empty scene." },
|
|
1555
|
+
// context
|
|
1556
|
+
"context.set": { required: ["at", "target"], optional: ["state", "state_id"], description: "Upsert (actor/prop) or replace (location) scene context ref + state." },
|
|
1557
|
+
"context.clear": { required: ["at", "target"], optional: [], description: "Keep ref, clear state_id." },
|
|
1558
|
+
"context.ref.remove": { required: ["at", "target"], optional: [], description: "Remove the ref entirely from scene context." },
|
|
1559
|
+
// dialogue / speaker
|
|
1560
|
+
"dialogue.speakers": { required: ["at", "speakers"], optional: ["delivery"], description: "Set dialogue speakers; delivery: single|simultaneous|group." },
|
|
1561
|
+
"dialogue.overlap": { required: ["at", "lines"], optional: [], description: "Set overlap dialogue lines[]." },
|
|
1562
|
+
"speaker.add": { required: ["kind", "name"], optional: ["source_id", "voice_desc", "speaker_id"], description: "Register a new speaker." },
|
|
1563
|
+
"speaker.delete": { required: ["target"], optional: ["strategy", "replacement"], description: "Delete a speaker. strategy: replace|remove (required if refs exist)." },
|
|
1564
|
+
};
|
|
1349
1565
|
export function applyPatchOperations(script, sourceText, operations) {
|
|
1350
1566
|
const applied = [];
|
|
1351
1567
|
for (const op of operations) {
|
|
@@ -1707,234 +1923,626 @@ export function applyPatchOperations(script, sourceText, operations) {
|
|
|
1707
1923
|
delete action["speaker_id"];
|
|
1708
1924
|
applied.push(kind);
|
|
1709
1925
|
}
|
|
1710
|
-
else if (kind === "
|
|
1711
|
-
|
|
1926
|
+
else if (kind === "meta.worldview.set") {
|
|
1927
|
+
// 0.6.0 dot-style equivalent of the legacy `set_worldview` op.
|
|
1928
|
+
const worldview = strOf(op["worldview"] ?? op["value"]).trim();
|
|
1712
1929
|
if (!WORLDVIEW_VALUES.includes(worldview)) {
|
|
1713
|
-
throw opErr("
|
|
1930
|
+
throw opErr("SCRIPT OP BLOCKED: Worldview invalid", "Worldview invalid.", {
|
|
1931
|
+
required: [`worldview: one of ${WORLDVIEW_VALUES.join(", ")}`],
|
|
1932
|
+
received: [worldview || "<empty>"],
|
|
1933
|
+
nextSteps: ["Use a supported worldview enum."],
|
|
1934
|
+
op: kind,
|
|
1935
|
+
errorCode: "WORLDVIEW_INVALID",
|
|
1936
|
+
});
|
|
1714
1937
|
}
|
|
1715
1938
|
script["worldview"] = worldview;
|
|
1716
1939
|
if ("worldview_raw" in op)
|
|
1717
1940
|
script["worldview_raw"] = strOf(op["worldview_raw"]).trim();
|
|
1718
1941
|
applied.push(kind);
|
|
1719
1942
|
}
|
|
1720
|
-
else if (kind === "
|
|
1721
|
-
const [
|
|
1722
|
-
|
|
1943
|
+
else if (kind === "asset.role.set") {
|
|
1944
|
+
const [assetKind, assetId] = parseAssetTarget(op["target"]);
|
|
1945
|
+
if (assetKind !== "actor") {
|
|
1946
|
+
throw opErr("SCRIPT OP BLOCKED: Role only on actors", "Role applies to actor only.", {
|
|
1947
|
+
required: ["actor:<id>"],
|
|
1948
|
+
received: [strOf(op["target"])],
|
|
1949
|
+
nextSteps: ["Use an actor target."],
|
|
1950
|
+
op: kind,
|
|
1951
|
+
errorCode: "ROLE_TARGET_INVALID",
|
|
1952
|
+
});
|
|
1953
|
+
}
|
|
1954
|
+
const asset = resolveAssetByTarget(script, "actor", assetId);
|
|
1955
|
+
const roleType = strOf(op["role_type"] ?? op["role"]).trim();
|
|
1723
1956
|
if (!ROLE_TYPE_VALUES.includes(roleType)) {
|
|
1724
|
-
throw opErr("
|
|
1957
|
+
throw opErr("SCRIPT OP BLOCKED: Role type invalid", "Role type invalid.", {
|
|
1958
|
+
required: ["role_type: 主角 or 配角"],
|
|
1959
|
+
received: [roleType || "<empty>"],
|
|
1960
|
+
nextSteps: ["Use a supported actor role_type."],
|
|
1961
|
+
op: kind,
|
|
1962
|
+
errorCode: "ROLE_TYPE_INVALID",
|
|
1963
|
+
});
|
|
1725
1964
|
}
|
|
1726
1965
|
asset["role_type"] = roleType;
|
|
1727
1966
|
applied.push(kind);
|
|
1728
1967
|
}
|
|
1729
|
-
else if (kind === "
|
|
1730
|
-
const [
|
|
1731
|
-
const
|
|
1732
|
-
|
|
1733
|
-
|
|
1968
|
+
else if (kind === "asset.rename") {
|
|
1969
|
+
const [assetKind, assetId] = parseAssetTarget(op["target"]);
|
|
1970
|
+
const asset = resolveAssetByTarget(script, assetKind, assetId);
|
|
1971
|
+
const newName = strOf(op["name"] ?? op["new_name"]).trim();
|
|
1972
|
+
if (!newName) {
|
|
1973
|
+
throw opErr("SCRIPT OP BLOCKED: Name empty", "Name empty.", {
|
|
1974
|
+
required: ["name"],
|
|
1975
|
+
received: [JSON.stringify(op)],
|
|
1976
|
+
nextSteps: ["Provide a non-empty name."],
|
|
1977
|
+
op: kind,
|
|
1978
|
+
errorCode: "NAME_EMPTY",
|
|
1979
|
+
});
|
|
1734
1980
|
}
|
|
1735
|
-
|
|
1981
|
+
const [, , nameKey] = assetKeys(assetKind);
|
|
1982
|
+
asset[nameKey] = newName;
|
|
1736
1983
|
applied.push(kind);
|
|
1737
1984
|
}
|
|
1738
|
-
else if (kind === "
|
|
1739
|
-
const
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1985
|
+
else if (kind === "asset.describe") {
|
|
1986
|
+
const [assetKind, assetId] = parseAssetTarget(op["target"]);
|
|
1987
|
+
const asset = resolveAssetByTarget(script, assetKind, assetId);
|
|
1988
|
+
const description = strOf(op["description"]).trim();
|
|
1989
|
+
if (!description) {
|
|
1990
|
+
throw opErr("SCRIPT OP BLOCKED: Description empty", "Description empty.", {
|
|
1991
|
+
required: ["description"],
|
|
1992
|
+
received: [JSON.stringify(op)],
|
|
1993
|
+
nextSteps: ["Provide a concise grounded description."],
|
|
1994
|
+
op: kind,
|
|
1995
|
+
errorCode: "DESCRIPTION_EMPTY",
|
|
1996
|
+
});
|
|
1746
1997
|
}
|
|
1747
|
-
|
|
1748
|
-
action["type"] = actionType;
|
|
1749
|
-
applied.push(kind);
|
|
1750
|
-
}
|
|
1751
|
-
else if (kind === "rename_actor" || kind === "rename_location" || kind === "rename_prop") {
|
|
1752
|
-
const map = {
|
|
1753
|
-
rename_actor: ["actors", "actor_id", "actor_name"],
|
|
1754
|
-
rename_location: ["locations", "location_id", "location_name"],
|
|
1755
|
-
rename_prop: ["props", "prop_id", "prop_name"],
|
|
1756
|
-
};
|
|
1757
|
-
const [key, idKey, nameKey] = map[kind];
|
|
1758
|
-
const raw = op[idKey] ?? op["id"] ?? op["name"];
|
|
1759
|
-
const newName = op["new_name"] ?? op[nameKey];
|
|
1760
|
-
const asset = resolveAsset(asList(script[key]), idKey, nameKey, strOf(raw));
|
|
1761
|
-
if (!asset || !newName) {
|
|
1762
|
-
throw opErr("PATCH BLOCKED: Rename target invalid", "Rename target invalid.", { required: [`${idKey} or name, and new_name`], received: [JSON.stringify(op)], nextSteps: ["Fix the patch operation and rerun patch."] });
|
|
1763
|
-
}
|
|
1764
|
-
asset[nameKey] = String(newName);
|
|
1998
|
+
asset["description"] = description;
|
|
1765
1999
|
applied.push(kind);
|
|
1766
2000
|
}
|
|
1767
|
-
else if (kind === "
|
|
1768
|
-
|
|
2001
|
+
else if (kind === "asset.alias.set") {
|
|
2002
|
+
// Bulk replace. Rare; primarily for migration tools.
|
|
2003
|
+
const [assetKind, assetId] = parseAssetTarget(op["target"]);
|
|
2004
|
+
const asset = resolveAssetByTarget(script, assetKind, assetId);
|
|
1769
2005
|
const aliases = op["aliases"];
|
|
1770
2006
|
if (!isList(aliases) || aliases.some((it) => typeof it !== "string")) {
|
|
1771
|
-
throw opErr("
|
|
2007
|
+
throw opErr("SCRIPT OP BLOCKED: Aliases invalid", "Aliases invalid.", {
|
|
2008
|
+
required: ["aliases: array of strings"],
|
|
2009
|
+
received: [JSON.stringify(op)],
|
|
2010
|
+
nextSteps: ["Pass a string array."],
|
|
2011
|
+
op: kind,
|
|
2012
|
+
errorCode: "ALIASES_INVALID",
|
|
2013
|
+
});
|
|
1772
2014
|
}
|
|
1773
2015
|
const seen = new Set();
|
|
1774
|
-
const
|
|
2016
|
+
const cleaned = [];
|
|
1775
2017
|
for (const item of aliases) {
|
|
1776
|
-
const
|
|
1777
|
-
if (
|
|
2018
|
+
const trimmed = item.trim();
|
|
2019
|
+
if (trimmed && !seen.has(trimmed)) {
|
|
2020
|
+
seen.add(trimmed);
|
|
2021
|
+
cleaned.push(trimmed);
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
asset["aliases"] = cleaned;
|
|
2025
|
+
applied.push(kind);
|
|
2026
|
+
}
|
|
2027
|
+
else if (kind === "asset.alias.add") {
|
|
2028
|
+
const [assetKind, assetId] = parseAssetTarget(op["target"]);
|
|
2029
|
+
const asset = resolveAssetByTarget(script, assetKind, assetId);
|
|
2030
|
+
const rawAdd = op["alias"] ?? op["aliases"];
|
|
2031
|
+
const toAdd = isList(rawAdd)
|
|
2032
|
+
? rawAdd.map((v) => strOf(v).trim()).filter((v) => v)
|
|
2033
|
+
: strOf(rawAdd).trim() ? [strOf(rawAdd).trim()] : [];
|
|
2034
|
+
if (toAdd.length === 0) {
|
|
2035
|
+
throw opErr("SCRIPT OP BLOCKED: Alias empty", "Alias empty.", {
|
|
2036
|
+
required: ["alias: non-empty string or array"],
|
|
2037
|
+
received: [JSON.stringify(op)],
|
|
2038
|
+
nextSteps: ["Pass --add <alias> at least once."],
|
|
2039
|
+
op: kind,
|
|
2040
|
+
errorCode: "ALIAS_EMPTY",
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
const existing = asList(asset["aliases"]).map((s) => strOf(s));
|
|
2044
|
+
const seen = new Set(existing);
|
|
2045
|
+
for (const alias of toAdd) {
|
|
2046
|
+
if (!seen.has(alias)) {
|
|
2047
|
+
existing.push(alias);
|
|
1778
2048
|
seen.add(alias);
|
|
1779
|
-
cleanedAliases.push(alias);
|
|
1780
2049
|
}
|
|
1781
2050
|
}
|
|
1782
|
-
asset["aliases"] =
|
|
2051
|
+
asset["aliases"] = existing;
|
|
2052
|
+
applied.push(kind);
|
|
2053
|
+
}
|
|
2054
|
+
else if (kind === "asset.alias.remove") {
|
|
2055
|
+
const [assetKind, assetId] = parseAssetTarget(op["target"]);
|
|
2056
|
+
const asset = resolveAssetByTarget(script, assetKind, assetId);
|
|
2057
|
+
const rawRemove = op["alias"] ?? op["aliases"];
|
|
2058
|
+
const toRemove = new Set(isList(rawRemove)
|
|
2059
|
+
? rawRemove.map((v) => strOf(v).trim()).filter((v) => v)
|
|
2060
|
+
: strOf(rawRemove).trim() ? [strOf(rawRemove).trim()] : []);
|
|
2061
|
+
if (toRemove.size === 0) {
|
|
2062
|
+
throw opErr("SCRIPT OP BLOCKED: Alias empty", "Alias empty.", {
|
|
2063
|
+
required: ["alias: non-empty string or array"],
|
|
2064
|
+
received: [JSON.stringify(op)],
|
|
2065
|
+
nextSteps: ["Pass --remove <alias> at least once."],
|
|
2066
|
+
op: kind,
|
|
2067
|
+
errorCode: "ALIAS_EMPTY",
|
|
2068
|
+
});
|
|
2069
|
+
}
|
|
2070
|
+
asset["aliases"] = asList(asset["aliases"]).map((s) => strOf(s)).filter((a) => !toRemove.has(a));
|
|
1783
2071
|
applied.push(kind);
|
|
1784
2072
|
}
|
|
1785
|
-
else if (kind === "
|
|
1786
|
-
const [
|
|
1787
|
-
const
|
|
2073
|
+
else if (kind === "asset.states.set") {
|
|
2074
|
+
const [assetKind, assetId] = parseAssetTarget(op["target"]);
|
|
2075
|
+
const asset = resolveAssetByTarget(script, assetKind, assetId);
|
|
1788
2076
|
asset["states"] = normalizeStates(script, op["states"], assetKind);
|
|
1789
2077
|
applied.push(kind);
|
|
1790
2078
|
}
|
|
1791
|
-
else if (kind === "
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
2079
|
+
else if (kind === "asset.merge") {
|
|
2080
|
+
// Direction is explicit: `from` disappears, `into` keeps everything.
|
|
2081
|
+
const fromRaw = op["from"] ?? op["source"];
|
|
2082
|
+
const intoRaw = op["into"] ?? op["target"];
|
|
2083
|
+
const [fromKind, fromId] = parseAssetTarget(fromRaw);
|
|
2084
|
+
const [intoKind, intoId] = parseAssetTarget(intoRaw);
|
|
2085
|
+
if (fromKind !== intoKind) {
|
|
2086
|
+
throw opErr("SCRIPT OP BLOCKED: Merge kind mismatch", "Merge source and target must be the same kind.", {
|
|
2087
|
+
required: ["same kind on both sides (actor/actor, location/location, prop/prop)"],
|
|
2088
|
+
received: [`${fromKind}:${fromId} → ${intoKind}:${intoId}`],
|
|
2089
|
+
nextSteps: ["Use a same-kind target."],
|
|
2090
|
+
op: kind,
|
|
2091
|
+
errorCode: "MERGE_KIND_MISMATCH",
|
|
2092
|
+
});
|
|
1802
2093
|
}
|
|
1803
|
-
|
|
2094
|
+
const [key, idKey] = assetKeys(fromKind);
|
|
2095
|
+
// Verify both exist.
|
|
2096
|
+
resolveAssetByTarget(script, fromKind, fromId);
|
|
2097
|
+
resolveAssetByTarget(script, intoKind, intoId);
|
|
2098
|
+
// Pre-flight: refuse merge if any scene context already references BOTH
|
|
2099
|
+
// from and into with conflicting state_ids. Silently picking one (the
|
|
2100
|
+
// array-order-first) was a quiet data-loss bug — we'd rather make the
|
|
2101
|
+
// user resolve the state divergence explicitly.
|
|
1804
2102
|
for (const ep of asList(script["episodes"])) {
|
|
1805
2103
|
for (const scene of asList(ep["scenes"])) {
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
2104
|
+
const ctx = sceneContext(scene);
|
|
2105
|
+
const refList = contextRefsForKind(ctx, fromKind);
|
|
2106
|
+
const fromRef = refList.find((ref) => strOf(ref[idKey]) === fromId);
|
|
2107
|
+
const intoRef = refList.find((ref) => strOf(ref[idKey]) === intoId);
|
|
2108
|
+
if (fromRef && intoRef) {
|
|
2109
|
+
const fromState = strOf(fromRef["state_id"]);
|
|
2110
|
+
const intoState = strOf(intoRef["state_id"]);
|
|
2111
|
+
if (fromState !== intoState) {
|
|
2112
|
+
throw opErr("SCRIPT OP BLOCKED: Merge state conflict", "Merge has conflicting state_ids in a shared scene.", {
|
|
2113
|
+
required: ["from and into agree on state_id in every scene where both appear"],
|
|
2114
|
+
received: [`${ep["episode_id"]}/${scene["scene_id"]}: ${fromId}=${fromState || "null"}, ${intoId}=${intoState || "null"}`],
|
|
2115
|
+
nextSteps: ["Resolve the state divergence (run `scriptctl context <scene> <kind>:<into-id> --state <state>`) then retry merge."],
|
|
2116
|
+
op: kind,
|
|
2117
|
+
errorCode: "MERGE_STATE_CONFLICT",
|
|
2118
|
+
});
|
|
2119
|
+
}
|
|
1809
2120
|
}
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
// Drop source from the asset list.
|
|
2124
|
+
script[key] = asList(script[key]).filter((it) => strOf(it[idKey]) !== fromId);
|
|
2125
|
+
// Rewrite refs: scene.context, action.actor_id (actor only), state_changes targets, transition targets, speaker.source_id.
|
|
2126
|
+
for (const ep of asList(script["episodes"])) {
|
|
2127
|
+
for (const scene of asList(ep["scenes"])) {
|
|
2128
|
+
const ctx = sceneContext(scene);
|
|
2129
|
+
const refList = contextRefsForKind(ctx, fromKind);
|
|
2130
|
+
for (const ref of refList) {
|
|
2131
|
+
if (strOf(ref[idKey]) === fromId)
|
|
2132
|
+
ref[idKey] = intoId;
|
|
2133
|
+
}
|
|
2134
|
+
// After rewrite, dedupe (intoId may now appear twice). State_id was
|
|
2135
|
+
// pre-validated to agree above, so first-seen wins is safe.
|
|
2136
|
+
const seen = new Set();
|
|
2137
|
+
ctx[pluralForKind(fromKind)] = refList.filter((ref) => {
|
|
2138
|
+
const key2 = strOf(ref[idKey]);
|
|
2139
|
+
if (seen.has(key2))
|
|
2140
|
+
return false;
|
|
2141
|
+
seen.add(key2);
|
|
2142
|
+
return true;
|
|
2143
|
+
});
|
|
2144
|
+
setSceneContext(scene, ctx);
|
|
1810
2145
|
for (const action of asList(scene["actions"])) {
|
|
1811
|
-
if (
|
|
1812
|
-
action["actor_id"] =
|
|
2146
|
+
if (fromKind === "actor" && strOf(action["actor_id"]) === fromId)
|
|
2147
|
+
action["actor_id"] = intoId;
|
|
2148
|
+
for (const change of asList(action["state_changes"])) {
|
|
2149
|
+
if (strOf(change["target_kind"]) === fromKind && strOf(change["target_id"]) === fromId) {
|
|
2150
|
+
change["target_id"] = intoId;
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
const transition = action["transition_prompt"];
|
|
2154
|
+
if (isDict(transition) && strOf(transition["target_kind"]) === fromKind && strOf(transition["target_id"]) === fromId) {
|
|
2155
|
+
transition["target_id"] = intoId;
|
|
2156
|
+
}
|
|
1813
2157
|
}
|
|
1814
2158
|
}
|
|
1815
2159
|
}
|
|
2160
|
+
// Speakers anchored to the source actor get reattached to the target.
|
|
2161
|
+
for (const sp of asList(script["speakers"])) {
|
|
2162
|
+
if (strOf(sp["source_kind"]) === fromKind && strOf(sp["source_id"]) === fromId) {
|
|
2163
|
+
sp["source_id"] = intoId;
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
1816
2166
|
applied.push(kind);
|
|
1817
2167
|
}
|
|
1818
|
-
else if (kind === "
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
2168
|
+
else if (kind === "asset.delete") {
|
|
2169
|
+
// Refs are walked via collectAssetRefs. Default: refuse if any refs exist.
|
|
2170
|
+
// strategy=replace → rewrite refs to a replacement asset, then delete.
|
|
2171
|
+
// strategy=remove → rewrite refs to null/clean and delete (leaves dangling
|
|
2172
|
+
// pointers cleaned by per-edge logic; validation will still surface anything
|
|
2173
|
+
// still wrong).
|
|
2174
|
+
const [assetKind, assetId] = parseAssetTarget(op["target"]);
|
|
2175
|
+
resolveAssetByTarget(script, assetKind, assetId);
|
|
2176
|
+
const refs = collectAssetRefs(script, assetKind, assetId);
|
|
2177
|
+
const strategy = strOf(op["strategy"]).trim();
|
|
2178
|
+
if (refs.length > 0 && !strategy) {
|
|
2179
|
+
throw opErr("SCRIPT OP BLOCKED: Asset has refs", `Asset is referenced ${refs.length} time(s).`, {
|
|
2180
|
+
required: ["--strategy replace|remove"],
|
|
2181
|
+
received: [`refs=${refs.length}`],
|
|
2182
|
+
nextSteps: ["Use --strategy replace --replacement <addr> to remap, or --strategy remove to scrub refs."],
|
|
2183
|
+
op: kind,
|
|
2184
|
+
errorCode: "ASSET_HAS_REFS",
|
|
2185
|
+
});
|
|
2186
|
+
}
|
|
2187
|
+
if (strategy === "replace") {
|
|
2188
|
+
const replacementRaw = op["replacement"] ?? op["into"];
|
|
2189
|
+
const [replacementKind, replacementId] = parseAssetTarget(replacementRaw);
|
|
2190
|
+
if (replacementKind !== assetKind) {
|
|
2191
|
+
throw opErr("SCRIPT OP BLOCKED: Replacement kind mismatch", "Replacement must be same kind.", {
|
|
2192
|
+
required: ["replacement same kind as target"],
|
|
2193
|
+
received: [`${assetKind} vs ${replacementKind}`],
|
|
2194
|
+
nextSteps: ["Pass a same-kind replacement."],
|
|
2195
|
+
op: kind,
|
|
2196
|
+
errorCode: "REPLACEMENT_KIND_MISMATCH",
|
|
2197
|
+
});
|
|
2198
|
+
}
|
|
2199
|
+
resolveAssetByTarget(script, replacementKind, replacementId);
|
|
2200
|
+
// Delegate ref rewrite to asset.merge logic by reusing the same traversal.
|
|
2201
|
+
applyPatchOperations(script, sourceText, [
|
|
2202
|
+
{ op: "asset.merge", from: op["target"], into: replacementRaw },
|
|
2203
|
+
]);
|
|
2204
|
+
applied.push(kind);
|
|
2205
|
+
}
|
|
2206
|
+
else if (strategy === "remove" || refs.length === 0) {
|
|
2207
|
+
// Scrub refs (actor_id → undefined, state_changes/transition refs removed, scene.context entries removed, speakers' source_id cleared).
|
|
2208
|
+
const [key, idKey] = assetKeys(assetKind);
|
|
2209
|
+
script[key] = asList(script[key]).filter((it) => strOf(it[idKey]) !== assetId);
|
|
2210
|
+
for (const ep of asList(script["episodes"])) {
|
|
2211
|
+
for (const scene of asList(ep["scenes"])) {
|
|
2212
|
+
const ctx = sceneContext(scene);
|
|
2213
|
+
ctx[pluralForKind(assetKind)] = contextRefsForKind(ctx, assetKind).filter((ref) => strOf(ref[idKey]) !== assetId);
|
|
2214
|
+
setSceneContext(scene, ctx);
|
|
2215
|
+
for (const action of asList(scene["actions"])) {
|
|
2216
|
+
if (assetKind === "actor" && strOf(action["actor_id"]) === assetId)
|
|
2217
|
+
delete action["actor_id"];
|
|
2218
|
+
const changes = asList(action["state_changes"]).filter((c) => !(strOf(c["target_kind"]) === assetKind && strOf(c["target_id"]) === assetId));
|
|
2219
|
+
if (changes.length > 0)
|
|
2220
|
+
action["state_changes"] = changes;
|
|
2221
|
+
else
|
|
2222
|
+
delete action["state_changes"];
|
|
2223
|
+
const transition = action["transition_prompt"];
|
|
2224
|
+
if (isDict(transition) && strOf(transition["target_kind"]) === assetKind && strOf(transition["target_id"]) === assetId) {
|
|
2225
|
+
delete action["transition_prompt"];
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
for (const sp of asList(script["speakers"])) {
|
|
2231
|
+
if (strOf(sp["source_kind"]) === assetKind && strOf(sp["source_id"]) === assetId) {
|
|
2232
|
+
sp["source_id"] = null;
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
applied.push(kind);
|
|
1839
2236
|
}
|
|
1840
2237
|
else {
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
existing["state_id"] = stateId;
|
|
1849
|
-
else
|
|
1850
|
-
refs.push({ prop_id: propId, state_id: stateId });
|
|
2238
|
+
throw opErr("SCRIPT OP BLOCKED: Strategy invalid", "Strategy invalid.", {
|
|
2239
|
+
required: ["strategy: replace or remove"],
|
|
2240
|
+
received: [strategy],
|
|
2241
|
+
nextSteps: ["Use replace (with --replacement) or remove."],
|
|
2242
|
+
op: kind,
|
|
2243
|
+
errorCode: "DELETE_STRATEGY_INVALID",
|
|
2244
|
+
});
|
|
1851
2245
|
}
|
|
1852
|
-
setSceneContext(scene, ctx);
|
|
1853
|
-
applied.push(kind);
|
|
1854
2246
|
}
|
|
1855
|
-
else if (kind === "
|
|
1856
|
-
const
|
|
1857
|
-
const
|
|
1858
|
-
if (
|
|
1859
|
-
throw opErr("
|
|
2247
|
+
else if (kind === "action.type.set") {
|
|
2248
|
+
const [epId, sceneId, actionIndex] = parseActionRef(op["at"]);
|
|
2249
|
+
const actionType = strOf(op["type"] ?? op["action_type"]).trim();
|
|
2250
|
+
if (!ACTION_TYPE_VALUES.has(actionType)) {
|
|
2251
|
+
throw opErr("SCRIPT OP BLOCKED: Action type invalid", "Action type invalid.", {
|
|
2252
|
+
required: ["type: dialogue, inner_thought, or action"],
|
|
2253
|
+
received: [actionType || "<empty>"],
|
|
2254
|
+
nextSteps: ["Use a supported action type."],
|
|
2255
|
+
op: kind,
|
|
2256
|
+
errorCode: "ACTION_TYPE_INVALID",
|
|
2257
|
+
});
|
|
1860
2258
|
}
|
|
1861
|
-
const action = findAction(script,
|
|
2259
|
+
const action = findAction(script, epId, sceneId, actionIndex);
|
|
2260
|
+
action["type"] = actionType;
|
|
2261
|
+
applied.push(kind);
|
|
2262
|
+
}
|
|
2263
|
+
else if (kind === "action.actor.set") {
|
|
2264
|
+
const [epId, sceneId, actionIndex] = parseActionRef(op["at"]);
|
|
2265
|
+
const rawActor = op["actor_id"] ?? op["actor"];
|
|
2266
|
+
const isNone = rawActor === null || strOf(rawActor).trim() === "" || strOf(rawActor).trim() === "none";
|
|
2267
|
+
const actorId = isNone ? null : ensureRefId(script, "actor", rawActor);
|
|
2268
|
+
const action = findAction(script, epId, sceneId, actionIndex);
|
|
1862
2269
|
if (actorId === null)
|
|
1863
2270
|
delete action["actor_id"];
|
|
1864
2271
|
else
|
|
1865
2272
|
action["actor_id"] = actorId;
|
|
1866
2273
|
applied.push(kind);
|
|
1867
2274
|
}
|
|
1868
|
-
else if (kind === "
|
|
1869
|
-
const epId = op["
|
|
1870
|
-
const
|
|
1871
|
-
const
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
2275
|
+
else if (kind === "action.delete") {
|
|
2276
|
+
const [epId, sceneId, actionIndex] = parseActionRef(op["at"]);
|
|
2277
|
+
const scene = findScene(script, epId, sceneId);
|
|
2278
|
+
const actions = asList(scene["actions"]);
|
|
2279
|
+
if (actionIndex < 0 || actionIndex >= actions.length) {
|
|
2280
|
+
throw opErr("SCRIPT OP BLOCKED: Action index out of range", "Action index out of range.", {
|
|
2281
|
+
required: [`0 <= action_index < ${actions.length}`],
|
|
2282
|
+
received: [String(actionIndex)],
|
|
2283
|
+
nextSteps: ["Inspect actions in the scene and use a valid index."],
|
|
2284
|
+
op: kind,
|
|
2285
|
+
errorCode: "ACTION_INDEX_OUT_OF_RANGE",
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
actions.splice(actionIndex, 1);
|
|
2289
|
+
scene["actions"] = actions;
|
|
1882
2290
|
applied.push(kind);
|
|
1883
2291
|
}
|
|
1884
|
-
else if (kind === "
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
const
|
|
1888
|
-
const
|
|
1889
|
-
|
|
1890
|
-
|
|
2292
|
+
else if (kind === "action.insert") {
|
|
2293
|
+
// <at> is a scene ref (ep_xxx/scn_xxx). Position resolved from
|
|
2294
|
+
// --at <idx> | --before <action-ref> | --after <action-ref> | default-append.
|
|
2295
|
+
const [epId, sceneId] = parseSceneRef(op["at"]);
|
|
2296
|
+
const scene = findScene(script, epId, sceneId);
|
|
2297
|
+
const actions = asList(scene["actions"]);
|
|
2298
|
+
const actionType = strOf(op["type"]).trim();
|
|
2299
|
+
if (!ACTION_TYPE_VALUES.has(actionType)) {
|
|
2300
|
+
throw opErr("SCRIPT OP BLOCKED: Action type invalid", "Action type invalid.", {
|
|
2301
|
+
required: ["type: dialogue, inner_thought, or action"],
|
|
2302
|
+
received: [actionType || "<empty>"],
|
|
2303
|
+
nextSteps: ["Use a supported action type."],
|
|
2304
|
+
op: kind,
|
|
2305
|
+
errorCode: "ACTION_TYPE_INVALID",
|
|
2306
|
+
});
|
|
2307
|
+
}
|
|
2308
|
+
const content = strOf(op["content"]);
|
|
2309
|
+
let insertIndex;
|
|
2310
|
+
if (op["before"]) {
|
|
2311
|
+
const [bEp, bScn, bIdx] = parseActionRef(op["before"]);
|
|
2312
|
+
if (bEp !== epId || bScn !== sceneId) {
|
|
2313
|
+
throw opErr("SCRIPT OP BLOCKED: before address mismatch", "--before must point at the same scene.", {
|
|
2314
|
+
required: [`${epId}/${sceneId}#<idx>`],
|
|
2315
|
+
received: [strOf(op["before"])],
|
|
2316
|
+
nextSteps: ["Use a sibling action address."],
|
|
2317
|
+
op: kind,
|
|
2318
|
+
errorCode: "INSERT_REF_MISMATCH",
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
insertIndex = bIdx;
|
|
2322
|
+
}
|
|
2323
|
+
else if (op["after"]) {
|
|
2324
|
+
const [aEp, aScn, aIdx] = parseActionRef(op["after"]);
|
|
2325
|
+
if (aEp !== epId || aScn !== sceneId) {
|
|
2326
|
+
throw opErr("SCRIPT OP BLOCKED: after address mismatch", "--after must point at the same scene.", {
|
|
2327
|
+
required: [`${epId}/${sceneId}#<idx>`],
|
|
2328
|
+
received: [strOf(op["after"])],
|
|
2329
|
+
nextSteps: ["Use a sibling action address."],
|
|
2330
|
+
op: kind,
|
|
2331
|
+
errorCode: "INSERT_REF_MISMATCH",
|
|
2332
|
+
});
|
|
2333
|
+
}
|
|
2334
|
+
insertIndex = aIdx + 1;
|
|
2335
|
+
}
|
|
2336
|
+
else if (op["at_index"] !== undefined && op["at_index"] !== null) {
|
|
2337
|
+
const n = Number(op["at_index"]);
|
|
2338
|
+
if (!Number.isInteger(n) || n < 0 || n > actions.length) {
|
|
2339
|
+
throw opErr("SCRIPT OP BLOCKED: at_index out of range", "at_index out of range.", {
|
|
2340
|
+
required: [`0..${actions.length}`],
|
|
2341
|
+
received: [String(op["at_index"])],
|
|
2342
|
+
nextSteps: ["Use a valid integer index."],
|
|
2343
|
+
op: kind,
|
|
2344
|
+
errorCode: "AT_INDEX_OUT_OF_RANGE",
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2347
|
+
insertIndex = n;
|
|
2348
|
+
}
|
|
2349
|
+
else {
|
|
2350
|
+
// Default: append at end.
|
|
2351
|
+
insertIndex = actions.length;
|
|
2352
|
+
}
|
|
2353
|
+
const newAction = { type: actionType, content };
|
|
2354
|
+
if (op["actor_id"])
|
|
2355
|
+
newAction["actor_id"] = ensureRefId(script, "actor", op["actor_id"]);
|
|
2356
|
+
if (op["speaker_id"])
|
|
2357
|
+
newAction["speaker_id"] = ensureSpeakerId(script, strOf(op["speaker_id"]));
|
|
2358
|
+
actions.splice(insertIndex, 0, newAction);
|
|
2359
|
+
scene["actions"] = actions;
|
|
2360
|
+
applied.push(kind);
|
|
2361
|
+
}
|
|
2362
|
+
else if (kind === "action.move") {
|
|
2363
|
+
const [fromEp, fromScn, fromIdx] = parseActionRef(op["at"] ?? op["from"]);
|
|
2364
|
+
const [toEp, toScn, toIdx] = parseActionRef(op["to"]);
|
|
2365
|
+
const fromScene = findScene(script, fromEp, fromScn);
|
|
2366
|
+
const fromActions = asList(fromScene["actions"]);
|
|
2367
|
+
if (fromIdx < 0 || fromIdx >= fromActions.length) {
|
|
2368
|
+
throw opErr("SCRIPT OP BLOCKED: Action index out of range", "Source action index out of range.", {
|
|
2369
|
+
required: [`0 <= idx < ${fromActions.length}`],
|
|
2370
|
+
received: [String(fromIdx)],
|
|
2371
|
+
nextSteps: ["Inspect actions and use a valid index."],
|
|
2372
|
+
op: kind,
|
|
2373
|
+
errorCode: "ACTION_INDEX_OUT_OF_RANGE",
|
|
2374
|
+
});
|
|
1891
2375
|
}
|
|
2376
|
+
const [moved] = fromActions.splice(fromIdx, 1);
|
|
2377
|
+
fromScene["actions"] = fromActions;
|
|
2378
|
+
const toScene = (fromEp === toEp && fromScn === toScn) ? fromScene : findScene(script, toEp, toScn);
|
|
2379
|
+
const toActions = asList(toScene["actions"]);
|
|
2380
|
+
// After removing the source, when target is the same scene and idx is past
|
|
2381
|
+
// the removed slot, the agent's intended index is already correct (no
|
|
2382
|
+
// shift needed in the destination); otherwise clamp.
|
|
2383
|
+
let dest = toIdx;
|
|
2384
|
+
if (dest < 0)
|
|
2385
|
+
dest = 0;
|
|
2386
|
+
if (dest > toActions.length)
|
|
2387
|
+
dest = toActions.length;
|
|
2388
|
+
toActions.splice(dest, 0, moved);
|
|
2389
|
+
toScene["actions"] = toActions;
|
|
2390
|
+
applied.push(kind);
|
|
2391
|
+
}
|
|
2392
|
+
else if (kind === "scene.insert") {
|
|
2393
|
+
// Insert a new empty scene into an episode. Position resolved from
|
|
2394
|
+
// --at <idx> | --before <ep/scn> | --after <ep/scn> | default-append.
|
|
2395
|
+
// Requires --location to anchor the scene's locations[] context (matches
|
|
2396
|
+
// direct-init's assumption that every scene names at least one location).
|
|
2397
|
+
const epId = strOf(op["at"] ?? op["episode_id"]);
|
|
2398
|
+
const ep = findEpisode(script, epId);
|
|
1892
2399
|
const scenes = asList(ep["scenes"]);
|
|
1893
|
-
const
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
2400
|
+
const locationId = strOf(op["location"] ?? op["location_id"]).trim();
|
|
2401
|
+
if (!locationId) {
|
|
2402
|
+
throw opErr("SCRIPT OP BLOCKED: location required", "Scene insert requires --location.", {
|
|
2403
|
+
required: ["location: existing location_id"],
|
|
2404
|
+
received: ["<empty>"],
|
|
2405
|
+
nextSteps: ["Pass --location <loc_id>."],
|
|
2406
|
+
op: kind,
|
|
2407
|
+
errorCode: "SCENE_LOCATION_MISSING",
|
|
2408
|
+
});
|
|
2409
|
+
}
|
|
2410
|
+
ensureRefId(script, "location", locationId);
|
|
2411
|
+
let insertIndex;
|
|
2412
|
+
if (op["before"]) {
|
|
2413
|
+
const [bEp, bScn] = parseSceneRef(op["before"]);
|
|
2414
|
+
if (bEp !== epId) {
|
|
2415
|
+
throw opErr("SCRIPT OP BLOCKED: before address mismatch", "--before must point at the same episode.", {
|
|
2416
|
+
required: [`${epId}/<scn>`],
|
|
2417
|
+
received: [strOf(op["before"])],
|
|
2418
|
+
nextSteps: ["Use a sibling scene address."],
|
|
2419
|
+
op: kind,
|
|
2420
|
+
errorCode: "INSERT_REF_MISMATCH",
|
|
2421
|
+
});
|
|
2422
|
+
}
|
|
2423
|
+
insertIndex = scenes.findIndex((s) => strOf(s["scene_id"]) === bScn);
|
|
2424
|
+
if (insertIndex < 0) {
|
|
2425
|
+
throw opErr("SCRIPT OP BLOCKED: before scene not found", "before scene not found.", {
|
|
2426
|
+
required: ["existing scene_id"],
|
|
2427
|
+
received: [bScn],
|
|
2428
|
+
nextSteps: ["Inspect scenes."],
|
|
2429
|
+
op: kind,
|
|
2430
|
+
errorCode: "SCENE_NOT_FOUND",
|
|
2431
|
+
});
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
else if (op["after"]) {
|
|
2435
|
+
const [aEp, aScn] = parseSceneRef(op["after"]);
|
|
2436
|
+
if (aEp !== epId) {
|
|
2437
|
+
throw opErr("SCRIPT OP BLOCKED: after address mismatch", "--after must point at the same episode.", {
|
|
2438
|
+
required: [`${epId}/<scn>`],
|
|
2439
|
+
received: [strOf(op["after"])],
|
|
2440
|
+
nextSteps: ["Use a sibling scene address."],
|
|
2441
|
+
op: kind,
|
|
2442
|
+
errorCode: "INSERT_REF_MISMATCH",
|
|
2443
|
+
});
|
|
2444
|
+
}
|
|
2445
|
+
const found = scenes.findIndex((s) => strOf(s["scene_id"]) === aScn);
|
|
2446
|
+
if (found < 0) {
|
|
2447
|
+
throw opErr("SCRIPT OP BLOCKED: after scene not found", "after scene not found.", {
|
|
2448
|
+
required: ["existing scene_id"],
|
|
2449
|
+
received: [aScn],
|
|
2450
|
+
nextSteps: ["Inspect scenes."],
|
|
2451
|
+
op: kind,
|
|
2452
|
+
errorCode: "SCENE_NOT_FOUND",
|
|
2453
|
+
});
|
|
2454
|
+
}
|
|
2455
|
+
insertIndex = found + 1;
|
|
2456
|
+
}
|
|
2457
|
+
else if (op["at_index"] !== undefined && op["at_index"] !== null) {
|
|
2458
|
+
const n = Number(op["at_index"]);
|
|
2459
|
+
if (!Number.isInteger(n) || n < 0 || n > scenes.length) {
|
|
2460
|
+
throw opErr("SCRIPT OP BLOCKED: at_index out of range", "at_index out of range.", {
|
|
2461
|
+
required: [`0..${scenes.length}`],
|
|
2462
|
+
received: [String(op["at_index"])],
|
|
2463
|
+
nextSteps: ["Use a valid integer index."],
|
|
2464
|
+
op: kind,
|
|
2465
|
+
errorCode: "AT_INDEX_OUT_OF_RANGE",
|
|
2466
|
+
});
|
|
1911
2467
|
}
|
|
2468
|
+
insertIndex = n;
|
|
1912
2469
|
}
|
|
1913
|
-
|
|
1914
|
-
|
|
2470
|
+
else {
|
|
2471
|
+
insertIndex = scenes.length;
|
|
2472
|
+
}
|
|
2473
|
+
const newSceneId = strOf(op["scene_id"]).trim() || nextSceneId(script);
|
|
2474
|
+
if (sceneIdExists(script, newSceneId)) {
|
|
2475
|
+
throw opErr("SCRIPT OP BLOCKED: New scene id exists", "New scene id exists.", {
|
|
2476
|
+
required: ["unused scene_id"],
|
|
2477
|
+
received: [newSceneId],
|
|
2478
|
+
nextSteps: ["Pass --scene-id <unused> or omit."],
|
|
2479
|
+
op: kind,
|
|
2480
|
+
errorCode: "NEW_SCENE_ID_EXISTS",
|
|
2481
|
+
});
|
|
2482
|
+
}
|
|
2483
|
+
const time = strOf(op["time"]).trim() || "day";
|
|
2484
|
+
const space = strOf(op["space"]).trim() || "interior";
|
|
2485
|
+
const newScene = {
|
|
2486
|
+
scene_id: newSceneId,
|
|
2487
|
+
environment: { time, space },
|
|
2488
|
+
context: {
|
|
2489
|
+
locations: [{ location_id: locationId, state_id: null }],
|
|
2490
|
+
actors: [],
|
|
2491
|
+
props: [],
|
|
2492
|
+
},
|
|
2493
|
+
locations: [{ location_id: locationId, state_id: null }],
|
|
2494
|
+
actors: [],
|
|
2495
|
+
props: [],
|
|
2496
|
+
actions: [],
|
|
2497
|
+
};
|
|
2498
|
+
scenes.splice(insertIndex, 0, newScene);
|
|
2499
|
+
ep["scenes"] = scenes;
|
|
1915
2500
|
applied.push(kind);
|
|
1916
2501
|
}
|
|
1917
|
-
else if (kind === "
|
|
1918
|
-
const epId =
|
|
1919
|
-
const
|
|
1920
|
-
const splitAt = op["action_index"];
|
|
2502
|
+
else if (kind === "scene.split") {
|
|
2503
|
+
const [epId, sceneId] = parseSceneRef(op["at"]);
|
|
2504
|
+
const splitAt = op["at_index"] ?? op["action_index"];
|
|
1921
2505
|
if (typeof splitAt !== "number" || !Number.isInteger(splitAt)) {
|
|
1922
|
-
throw opErr("
|
|
2506
|
+
throw opErr("SCRIPT OP BLOCKED: Split index invalid", "Split index invalid.", {
|
|
2507
|
+
required: ["at_index: integer between existing actions"],
|
|
2508
|
+
received: [JSON.stringify(op)],
|
|
2509
|
+
nextSteps: ["Inspect actions and use a valid index."],
|
|
2510
|
+
op: kind,
|
|
2511
|
+
errorCode: "SPLIT_INDEX_INVALID",
|
|
2512
|
+
});
|
|
1923
2513
|
}
|
|
1924
2514
|
const ep = findEpisode(script, epId);
|
|
1925
2515
|
const scenes = asList(ep["scenes"]);
|
|
1926
2516
|
const index = scenes.findIndex((s) => s["scene_id"] === sceneId);
|
|
1927
2517
|
if (index < 0) {
|
|
1928
|
-
throw opErr("
|
|
2518
|
+
throw opErr("SCRIPT OP BLOCKED: Scene not found", "Scene not found.", {
|
|
2519
|
+
required: ["existing scene_id"],
|
|
2520
|
+
received: [sceneId],
|
|
2521
|
+
nextSteps: ["Inspect scenes and fix the address."],
|
|
2522
|
+
op: kind,
|
|
2523
|
+
errorCode: "SCENE_NOT_FOUND",
|
|
2524
|
+
});
|
|
1929
2525
|
}
|
|
1930
2526
|
const scene = scenes[index];
|
|
1931
2527
|
const actions = asList(scene["actions"]);
|
|
1932
2528
|
if (splitAt <= 0 || splitAt >= actions.length) {
|
|
1933
|
-
throw opErr("
|
|
2529
|
+
throw opErr("SCRIPT OP BLOCKED: Split index invalid", "Split index out of bounds.", {
|
|
2530
|
+
required: [`0 < at_index < ${actions.length}`],
|
|
2531
|
+
received: [String(splitAt)],
|
|
2532
|
+
nextSteps: ["Choose an action boundary inside the scene."],
|
|
2533
|
+
op: kind,
|
|
2534
|
+
errorCode: "SPLIT_INDEX_INVALID",
|
|
2535
|
+
});
|
|
1934
2536
|
}
|
|
1935
2537
|
const newSceneId = strOf(op["new_scene_id"]) || nextSceneId(script);
|
|
1936
2538
|
if (sceneIdExists(script, newSceneId)) {
|
|
1937
|
-
throw opErr("
|
|
2539
|
+
throw opErr("SCRIPT OP BLOCKED: New scene id exists", "New scene id exists.", {
|
|
2540
|
+
required: ["unused new_scene_id"],
|
|
2541
|
+
received: [newSceneId],
|
|
2542
|
+
nextSteps: ["Choose an unused scene id or omit new_scene_id."],
|
|
2543
|
+
op: kind,
|
|
2544
|
+
errorCode: "NEW_SCENE_ID_EXISTS",
|
|
2545
|
+
});
|
|
1938
2546
|
}
|
|
1939
2547
|
const ctx = sceneContext(scene);
|
|
1940
2548
|
const newScene = {
|
|
@@ -1950,13 +2558,312 @@ export function applyPatchOperations(script, sourceText, operations) {
|
|
|
1950
2558
|
scenes.splice(index + 1, 0, newScene);
|
|
1951
2559
|
applied.push(kind);
|
|
1952
2560
|
}
|
|
2561
|
+
else if (kind === "scene.merge") {
|
|
2562
|
+
// 0.6.0 uses {from, into} (source disappears, into keeps). The legacy op
|
|
2563
|
+
// used {keep_scene_id, remove_scene_id}; payload still accepted for back-
|
|
2564
|
+
// compat during transition.
|
|
2565
|
+
const fromRaw = op["from"] ?? op["remove_scene_id"];
|
|
2566
|
+
const intoRaw = op["into"] ?? op["keep_scene_id"];
|
|
2567
|
+
// from can be either ep/scn or just scn (legacy). Normalize.
|
|
2568
|
+
let fromEp, fromScn, intoEp, intoScn;
|
|
2569
|
+
if (typeof fromRaw === "string" && fromRaw.includes("/")) {
|
|
2570
|
+
[fromEp, fromScn] = parseSceneRef(fromRaw);
|
|
2571
|
+
}
|
|
2572
|
+
else {
|
|
2573
|
+
// Legacy: needs episode_id from op.
|
|
2574
|
+
fromEp = strOf(op["episode_id"]);
|
|
2575
|
+
fromScn = strOf(fromRaw);
|
|
2576
|
+
}
|
|
2577
|
+
if (typeof intoRaw === "string" && intoRaw.includes("/")) {
|
|
2578
|
+
[intoEp, intoScn] = parseSceneRef(intoRaw);
|
|
2579
|
+
}
|
|
2580
|
+
else {
|
|
2581
|
+
intoEp = strOf(op["episode_id"]);
|
|
2582
|
+
intoScn = strOf(intoRaw);
|
|
2583
|
+
}
|
|
2584
|
+
if (fromEp !== intoEp) {
|
|
2585
|
+
throw opErr("SCRIPT OP BLOCKED: Merge cross-episode", "Scene merge must be within one episode.", {
|
|
2586
|
+
required: ["same episode_id on both sides"],
|
|
2587
|
+
received: [`${fromEp}/${fromScn} → ${intoEp}/${intoScn}`],
|
|
2588
|
+
nextSteps: ["Move one scene first, or merge same-episode scenes."],
|
|
2589
|
+
op: kind,
|
|
2590
|
+
errorCode: "MERGE_CROSS_EPISODE",
|
|
2591
|
+
});
|
|
2592
|
+
}
|
|
2593
|
+
const ep = findEpisode(script, fromEp);
|
|
2594
|
+
const scenes = asList(ep["scenes"]);
|
|
2595
|
+
const intoScene = scenes.find((s) => s["scene_id"] === intoScn);
|
|
2596
|
+
const fromScene = scenes.find((s) => s["scene_id"] === fromScn);
|
|
2597
|
+
if (!intoScene || !fromScene) {
|
|
2598
|
+
throw opErr("SCRIPT OP BLOCKED: Scene not found", "Scene not found.", {
|
|
2599
|
+
required: ["existing scene_id on both sides"],
|
|
2600
|
+
received: [`from=${fromScn}, into=${intoScn}`],
|
|
2601
|
+
nextSteps: ["Inspect scenes and fix the address."],
|
|
2602
|
+
op: kind,
|
|
2603
|
+
errorCode: "SCENE_NOT_FOUND",
|
|
2604
|
+
});
|
|
2605
|
+
}
|
|
2606
|
+
intoScene["actions"].push(...asList(fromScene["actions"]));
|
|
2607
|
+
const intoCtx = sceneContext(intoScene);
|
|
2608
|
+
const fromCtx = sceneContext(fromScene);
|
|
2609
|
+
for (const [field, idKey] of [["actors", "actor_id"], ["locations", "location_id"], ["props", "prop_id"]]) {
|
|
2610
|
+
const existingIds = new Set();
|
|
2611
|
+
for (const ref of asList(intoCtx[field]))
|
|
2612
|
+
existingIds.add(ref[idKey]);
|
|
2613
|
+
for (const ref of asList(fromCtx[field])) {
|
|
2614
|
+
if (!existingIds.has(ref[idKey])) {
|
|
2615
|
+
if (!isList(intoCtx[field]))
|
|
2616
|
+
intoCtx[field] = [];
|
|
2617
|
+
intoCtx[field].push(ref);
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
setSceneContext(intoScene, intoCtx);
|
|
2622
|
+
ep["scenes"] = scenes.filter((s) => s !== fromScene);
|
|
2623
|
+
applied.push(kind);
|
|
2624
|
+
}
|
|
2625
|
+
else if (kind === "scene.delete") {
|
|
2626
|
+
const [epId, sceneId] = parseSceneRef(op["at"]);
|
|
2627
|
+
const ep = findEpisode(script, epId);
|
|
2628
|
+
const scenes = asList(ep["scenes"]);
|
|
2629
|
+
const scene = scenes.find((s) => s["scene_id"] === sceneId);
|
|
2630
|
+
if (!scene) {
|
|
2631
|
+
throw opErr("SCRIPT OP BLOCKED: Scene not found", "Scene not found.", {
|
|
2632
|
+
required: ["existing scene_id"],
|
|
2633
|
+
received: [`${epId}/${sceneId}`],
|
|
2634
|
+
nextSteps: ["Inspect scenes."],
|
|
2635
|
+
op: kind,
|
|
2636
|
+
errorCode: "SCENE_NOT_FOUND",
|
|
2637
|
+
});
|
|
2638
|
+
}
|
|
2639
|
+
const actions = asList(scene["actions"]);
|
|
2640
|
+
const force = Boolean(op["force"]);
|
|
2641
|
+
if (actions.length > 0 && !force) {
|
|
2642
|
+
throw opErr("SCRIPT OP BLOCKED: Scene non-empty", `Scene has ${actions.length} action(s).`, {
|
|
2643
|
+
required: ["empty scene, or force=true"],
|
|
2644
|
+
received: [`actions=${actions.length}`],
|
|
2645
|
+
nextSteps: ["Pass --force to delete with actions, or move/delete actions first."],
|
|
2646
|
+
op: kind,
|
|
2647
|
+
errorCode: "SCENE_NON_EMPTY",
|
|
2648
|
+
});
|
|
2649
|
+
}
|
|
2650
|
+
ep["scenes"] = scenes.filter((s) => s !== scene);
|
|
2651
|
+
applied.push(kind);
|
|
2652
|
+
}
|
|
2653
|
+
else if (kind === "scene.move") {
|
|
2654
|
+
const [fromEp, fromScn] = parseSceneRef(op["at"] ?? op["from"]);
|
|
2655
|
+
const toEpRaw = strOf(op["to"]);
|
|
2656
|
+
// `to` accepts either bare `ep_NNN` (append) or `ep_NNN/scn_NNN` (insert before that scene).
|
|
2657
|
+
// `--at <idx>` overrides positioning.
|
|
2658
|
+
let toEp;
|
|
2659
|
+
let toAnchorScn = null;
|
|
2660
|
+
if (toEpRaw.includes("/")) {
|
|
2661
|
+
const [a, b] = parseSceneRef(toEpRaw);
|
|
2662
|
+
toEp = a;
|
|
2663
|
+
toAnchorScn = b;
|
|
2664
|
+
}
|
|
2665
|
+
else {
|
|
2666
|
+
toEp = toEpRaw;
|
|
2667
|
+
}
|
|
2668
|
+
if (!toEp) {
|
|
2669
|
+
throw opErr("SCRIPT OP BLOCKED: to address invalid", "to address invalid.", {
|
|
2670
|
+
required: ["to: ep_NNN or ep_NNN/scn_NNN"],
|
|
2671
|
+
received: [toEpRaw],
|
|
2672
|
+
nextSteps: ["Pass a target episode id."],
|
|
2673
|
+
op: kind,
|
|
2674
|
+
errorCode: "SCENE_MOVE_TO_INVALID",
|
|
2675
|
+
});
|
|
2676
|
+
}
|
|
2677
|
+
const fromEpisode = findEpisode(script, fromEp);
|
|
2678
|
+
const fromScenes = asList(fromEpisode["scenes"]);
|
|
2679
|
+
const sourceIdx = fromScenes.findIndex((s) => s["scene_id"] === fromScn);
|
|
2680
|
+
if (sourceIdx < 0) {
|
|
2681
|
+
throw opErr("SCRIPT OP BLOCKED: Scene not found", "Source scene not found.", {
|
|
2682
|
+
required: ["existing scene_id"],
|
|
2683
|
+
received: [`${fromEp}/${fromScn}`],
|
|
2684
|
+
nextSteps: ["Inspect scenes."],
|
|
2685
|
+
op: kind,
|
|
2686
|
+
errorCode: "SCENE_NOT_FOUND",
|
|
2687
|
+
});
|
|
2688
|
+
}
|
|
2689
|
+
const [moved] = fromScenes.splice(sourceIdx, 1);
|
|
2690
|
+
fromEpisode["scenes"] = fromScenes;
|
|
2691
|
+
const toEpisode = findEpisode(script, toEp);
|
|
2692
|
+
const toScenes = asList(toEpisode["scenes"]);
|
|
2693
|
+
let destIdx;
|
|
2694
|
+
if (op["at_index"] !== undefined && op["at_index"] !== null) {
|
|
2695
|
+
const n = Number(op["at_index"]);
|
|
2696
|
+
if (!Number.isInteger(n) || n < 0 || n > toScenes.length) {
|
|
2697
|
+
throw opErr("SCRIPT OP BLOCKED: at_index out of range", "at_index out of range.", {
|
|
2698
|
+
required: [`0..${toScenes.length}`],
|
|
2699
|
+
received: [String(op["at_index"])],
|
|
2700
|
+
nextSteps: ["Use a valid integer index."],
|
|
2701
|
+
op: kind,
|
|
2702
|
+
errorCode: "AT_INDEX_OUT_OF_RANGE",
|
|
2703
|
+
});
|
|
2704
|
+
}
|
|
2705
|
+
destIdx = n;
|
|
2706
|
+
}
|
|
2707
|
+
else if (toAnchorScn) {
|
|
2708
|
+
const found = toScenes.findIndex((s) => s["scene_id"] === toAnchorScn);
|
|
2709
|
+
if (found < 0) {
|
|
2710
|
+
throw opErr("SCRIPT OP BLOCKED: Target scene not found", "Target scene not found.", {
|
|
2711
|
+
required: ["existing scene_id in target episode"],
|
|
2712
|
+
received: [`${toEp}/${toAnchorScn}`],
|
|
2713
|
+
nextSteps: ["Inspect the target episode."],
|
|
2714
|
+
op: kind,
|
|
2715
|
+
errorCode: "SCENE_NOT_FOUND",
|
|
2716
|
+
});
|
|
2717
|
+
}
|
|
2718
|
+
destIdx = found;
|
|
2719
|
+
}
|
|
2720
|
+
else {
|
|
2721
|
+
destIdx = toScenes.length;
|
|
2722
|
+
}
|
|
2723
|
+
toScenes.splice(destIdx, 0, moved);
|
|
2724
|
+
toEpisode["scenes"] = toScenes;
|
|
2725
|
+
applied.push(kind);
|
|
2726
|
+
}
|
|
2727
|
+
else if (kind === "speaker.delete") {
|
|
2728
|
+
// Mirror of asset.delete for speakers: refs scrub or replace via
|
|
2729
|
+
// --strategy. Speaker refs live in action.speaker_id /
|
|
2730
|
+
// action.speakers[] / action.lines[].speaker_id only — no scene
|
|
2731
|
+
// context entries to handle.
|
|
2732
|
+
const speakerId = strOf(op["target"] ?? op["speaker_id"]).trim();
|
|
2733
|
+
if (!speakerId) {
|
|
2734
|
+
throw opErr("SCRIPT OP BLOCKED: Speaker id missing", "Speaker id missing.", {
|
|
2735
|
+
required: ["target speaker id"],
|
|
2736
|
+
received: [JSON.stringify(op)],
|
|
2737
|
+
nextSteps: ["Pass a spk_id."],
|
|
2738
|
+
op: kind,
|
|
2739
|
+
errorCode: "SPEAKER_ID_MISSING",
|
|
2740
|
+
});
|
|
2741
|
+
}
|
|
2742
|
+
const refs = collectAssetRefs(script, "speaker", speakerId);
|
|
2743
|
+
const strategy = strOf(op["strategy"]).trim();
|
|
2744
|
+
if (refs.length > 0 && !strategy) {
|
|
2745
|
+
throw opErr("SCRIPT OP BLOCKED: Speaker has refs", `Speaker is referenced ${refs.length} time(s).`, {
|
|
2746
|
+
required: ["--strategy replace|remove"],
|
|
2747
|
+
received: [`refs=${refs.length}`],
|
|
2748
|
+
nextSteps: ["Use --strategy replace --replacement <spk_id> to remap, or --strategy remove to scrub refs."],
|
|
2749
|
+
op: kind,
|
|
2750
|
+
errorCode: "SPEAKER_HAS_REFS",
|
|
2751
|
+
});
|
|
2752
|
+
}
|
|
2753
|
+
const replacement = strOf(op["replacement"]).trim();
|
|
2754
|
+
if (strategy === "replace") {
|
|
2755
|
+
if (!replacement) {
|
|
2756
|
+
throw opErr("SCRIPT OP BLOCKED: Replacement missing", "Replacement missing.", {
|
|
2757
|
+
required: ["replacement speaker id"],
|
|
2758
|
+
received: ["<empty>"],
|
|
2759
|
+
nextSteps: ["Pass --replacement <spk_id>."],
|
|
2760
|
+
op: kind,
|
|
2761
|
+
errorCode: "REPLACEMENT_MISSING",
|
|
2762
|
+
});
|
|
2763
|
+
}
|
|
2764
|
+
// Verify replacement exists.
|
|
2765
|
+
if (!asList(script["speakers"]).some((s) => strOf(s["speaker_id"]) === replacement)) {
|
|
2766
|
+
throw opErr("SCRIPT OP BLOCKED: Replacement speaker not found", "Replacement speaker not found.", {
|
|
2767
|
+
required: ["existing speaker id"],
|
|
2768
|
+
received: [replacement],
|
|
2769
|
+
nextSteps: ["Add the speaker first or use a different replacement."],
|
|
2770
|
+
op: kind,
|
|
2771
|
+
errorCode: "SPEAKER_NOT_FOUND",
|
|
2772
|
+
});
|
|
2773
|
+
}
|
|
2774
|
+
for (const ep of asList(script["episodes"])) {
|
|
2775
|
+
for (const scene of asList(ep["scenes"])) {
|
|
2776
|
+
for (const action of asList(scene["actions"])) {
|
|
2777
|
+
if (strOf(action["speaker_id"]) === speakerId)
|
|
2778
|
+
action["speaker_id"] = replacement;
|
|
2779
|
+
const speakers = asList(action["speakers"]);
|
|
2780
|
+
for (const sp of speakers) {
|
|
2781
|
+
if (strOf(sp["speaker_id"]) === speakerId)
|
|
2782
|
+
sp["speaker_id"] = replacement;
|
|
2783
|
+
}
|
|
2784
|
+
// Dedupe action.speakers[] in case the action already referenced
|
|
2785
|
+
// the replacement: an unconditional rewrite + no dedupe would
|
|
2786
|
+
// leave [spk_replacement, spk_replacement], breaking overlap/
|
|
2787
|
+
// simultaneous delivery counts downstream.
|
|
2788
|
+
if (action["speakers"] !== undefined) {
|
|
2789
|
+
const seen = new Set();
|
|
2790
|
+
action["speakers"] = speakers.filter((s) => {
|
|
2791
|
+
const id = strOf(s["speaker_id"]);
|
|
2792
|
+
if (seen.has(id))
|
|
2793
|
+
return false;
|
|
2794
|
+
seen.add(id);
|
|
2795
|
+
return true;
|
|
2796
|
+
});
|
|
2797
|
+
}
|
|
2798
|
+
const lines = asList(action["lines"]);
|
|
2799
|
+
for (const line of lines) {
|
|
2800
|
+
if (strOf(line["speaker_id"]) === speakerId)
|
|
2801
|
+
line["speaker_id"] = replacement;
|
|
2802
|
+
}
|
|
2803
|
+
// lines[] semantically represents distinct overlapping lines
|
|
2804
|
+
// (a speaker can legitimately overlap with themselves), so we do
|
|
2805
|
+
// NOT dedupe lines — preserve overlap-with-self if it existed.
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
else if (strategy === "remove" || refs.length === 0) {
|
|
2811
|
+
for (const ep of asList(script["episodes"])) {
|
|
2812
|
+
for (const scene of asList(ep["scenes"])) {
|
|
2813
|
+
for (const action of asList(scene["actions"])) {
|
|
2814
|
+
if (strOf(action["speaker_id"]) === speakerId)
|
|
2815
|
+
delete action["speaker_id"];
|
|
2816
|
+
const speakers = asList(action["speakers"]).filter((s) => strOf(s["speaker_id"]) !== speakerId);
|
|
2817
|
+
if (action["speakers"] !== undefined) {
|
|
2818
|
+
if (speakers.length > 0)
|
|
2819
|
+
action["speakers"] = speakers;
|
|
2820
|
+
else
|
|
2821
|
+
delete action["speakers"];
|
|
2822
|
+
}
|
|
2823
|
+
const lines = asList(action["lines"]).filter((l) => strOf(l["speaker_id"]) !== speakerId);
|
|
2824
|
+
if (action["lines"] !== undefined) {
|
|
2825
|
+
if (lines.length > 0)
|
|
2826
|
+
action["lines"] = lines;
|
|
2827
|
+
else
|
|
2828
|
+
delete action["lines"];
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
else {
|
|
2835
|
+
throw opErr("SCRIPT OP BLOCKED: Strategy invalid", "Strategy invalid.", {
|
|
2836
|
+
required: ["strategy: replace or remove"],
|
|
2837
|
+
received: [strategy],
|
|
2838
|
+
nextSteps: ["Use replace (with --replacement) or remove."],
|
|
2839
|
+
op: kind,
|
|
2840
|
+
errorCode: "DELETE_STRATEGY_INVALID",
|
|
2841
|
+
});
|
|
2842
|
+
}
|
|
2843
|
+
script["speakers"] = asList(script["speakers"]).filter((s) => strOf(s["speaker_id"]) !== speakerId);
|
|
2844
|
+
applied.push(kind);
|
|
2845
|
+
}
|
|
2846
|
+
else if (kind === "context.ref.remove") {
|
|
2847
|
+
const [epId, sceneId] = parseSceneRef(op["at"]);
|
|
2848
|
+
const [targetKind, targetId] = parseAssetTarget(op["target"]);
|
|
2849
|
+
const scene = findScene(script, epId, sceneId);
|
|
2850
|
+
const ctx = sceneContext(scene);
|
|
2851
|
+
const idKey = idKeyForKind(targetKind);
|
|
2852
|
+
ctx[pluralForKind(targetKind)] = contextRefsForKind(ctx, targetKind).filter((ref) => strOf(ref[idKey]) !== targetId);
|
|
2853
|
+
setSceneContext(scene, ctx);
|
|
2854
|
+
applied.push(kind);
|
|
2855
|
+
}
|
|
1953
2856
|
else {
|
|
1954
2857
|
throw opErr("PATCH BLOCKED: Unsupported operation", "Unsupported patch operation.", {
|
|
1955
2858
|
required: [
|
|
1956
|
-
"
|
|
2859
|
+
"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",
|
|
1957
2860
|
],
|
|
1958
2861
|
received: [kind],
|
|
1959
|
-
nextSteps: [
|
|
2862
|
+
nextSteps: [
|
|
2863
|
+
"Use a supported dot-style patch operation.",
|
|
2864
|
+
"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.",
|
|
2865
|
+
],
|
|
2866
|
+
errorCode: "OP_UNSUPPORTED",
|
|
1960
2867
|
});
|
|
1961
2868
|
}
|
|
1962
2869
|
}
|