@lingjingai/scriptctl 0.32.0 → 0.33.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.
@@ -3927,7 +3927,7 @@ export function validateAssetGroupingPlan(script, kind, rawPlan) {
3927
3927
  retainedByName.set(key, list);
3928
3928
  }
3929
3929
  const duplicateMerges = [];
3930
- const omittedInvalid = [];
3930
+ const omittedSingletons = [];
3931
3931
  for (const id of inputIds) {
3932
3932
  if (mentioned.has(id))
3933
3933
  continue;
@@ -3937,7 +3937,13 @@ export function validateAssetGroupingPlan(script, kind, rawPlan) {
3937
3937
  const key = assetGroupingNameKey(kind, asset[assetNameKey(kind)]);
3938
3938
  const targets = key ? retainedByName.get(key) ?? [] : [];
3939
3939
  if (targets.length === 0) {
3940
- omittedInvalid.push(`${kind}:${id}:${strOf(asset[assetNameKey(kind)])}`);
3940
+ groups.push({
3941
+ kind,
3942
+ ids: [id],
3943
+ reason: "omitted by grouping; review as standalone asset",
3944
+ });
3945
+ mentioned.add(id);
3946
+ omittedSingletons.push(id);
3941
3947
  continue;
3942
3948
  }
3943
3949
  duplicateMerges.push({
@@ -3949,9 +3955,6 @@ export function validateAssetGroupingPlan(script, kind, rawPlan) {
3949
3955
  reason: "name-level duplicate omitted by asset grouping",
3950
3956
  });
3951
3957
  }
3952
- if (omittedInvalid.length > 0) {
3953
- curationBlocked("Asset grouping omitted non-duplicate asset ids.", omittedInvalid.slice(0, 40));
3954
- }
3955
3958
  return {
3956
3959
  version: 1,
3957
3960
  format: "asset-grouping-v1",
@@ -3966,6 +3969,7 @@ export function validateAssetGroupingPlan(script, kind, rawPlan) {
3966
3969
  group_count: groups.length,
3967
3970
  mentioned_count: mentioned.size,
3968
3971
  omitted_duplicate_count: duplicateMerges.length,
3972
+ omitted_singleton_count: omittedSingletons.length,
3969
3973
  normalization_count: normalizations.length,
3970
3974
  },
3971
3975
  };
@@ -5049,6 +5053,14 @@ function parseCurationExpression(expression) {
5049
5053
  match = /^PROP\s+(actor|location|prop)\s+(\S+)\s+->\s+(\S+)\s+"((?:\\.|[^"\\])*)"$/i.exec(command);
5050
5054
  if (match) {
5051
5055
  const target = match[3];
5056
+ const normalizedTarget = target.toLowerCase();
5057
+ const targetPrefix = /^([A-Za-z]+):(.+)$/.exec(target);
5058
+ if ((targetPrefix && targetPrefix[1].toLowerCase() !== "prop") ||
5059
+ normalizedTarget === "actor" ||
5060
+ normalizedTarget === "location" ||
5061
+ normalizedTarget === "prop") {
5062
+ curationBlocked("PROP target must be NEW or an existing prop id.", [expression]);
5063
+ }
5052
5064
  const decision = {
5053
5065
  kind: match[1].toLowerCase(),
5054
5066
  source_id: match[2],
@@ -5058,7 +5070,7 @@ function parseCurationExpression(expression) {
5058
5070
  reason,
5059
5071
  };
5060
5072
  if (target.toUpperCase() !== "NEW")
5061
- decision["target_id"] = target;
5073
+ decision["target_id"] = targetPrefix ? targetPrefix[2] : target;
5062
5074
  return decision;
5063
5075
  }
5064
5076
  curationBlocked("Unknown or malformed curation expression.", [expression]);
@@ -6171,6 +6183,650 @@ export function curateScriptAssets(script, rawCuration = null) {
6171
6183
  };
6172
6184
  }
6173
6185
  // ---------------------------------------------------------------------------
6186
+ // State curation
6187
+ // ---------------------------------------------------------------------------
6188
+ export const STATE_CURATION_MAX_ASSETS = 4;
6189
+ export const STATE_CURATION_MAX_CONTEXT_CHARS = 28000;
6190
+ function stateCurationBlocked(message, received) {
6191
+ throw new CliError("DIRECT STATE CURATION BLOCKED: invalid provider plan", message, {
6192
+ exitCode: EXIT_NEEDS_AGENT,
6193
+ required: ["valid state curation expressions referencing existing asset/state ids"],
6194
+ received,
6195
+ nextSteps: ["Inspect state_curation.json, adjust the draft manually, or rerun direct init after provider plan fixes."],
6196
+ });
6197
+ }
6198
+ function stateCurationSourceKey(kind, assetId, stateId) {
6199
+ return `${kind}:${assetId}/${stateId}`;
6200
+ }
6201
+ function stateCurationDecisionSourceKey(decision) {
6202
+ return stateCurationSourceKey(strOf(decision["kind"]), strOf(decision["asset_id"]), strOf(decision["state_id"]));
6203
+ }
6204
+ function stateCurationUsageFor(script) {
6205
+ const usage = new Map();
6206
+ const ensure = (kind, assetId, stateId) => {
6207
+ const key = stateCurationSourceKey(kind, assetId, stateId);
6208
+ const current = usage.get(key);
6209
+ if (current)
6210
+ return current;
6211
+ const next = { sceneRefs: new Set(), stateChanges: new Set() };
6212
+ usage.set(key, next);
6213
+ return next;
6214
+ };
6215
+ for (const ep of asList(script["episodes"])) {
6216
+ const episodeId = strOf(ep["episode_id"]);
6217
+ for (const scene of asList(ep["scenes"])) {
6218
+ const sceneId = strOf(scene["scene_id"]);
6219
+ const sceneRef = [episodeId, sceneId].filter((item) => item).join("/");
6220
+ const ctx = sceneContext(scene);
6221
+ for (const kind of ["actor", "location", "prop"]) {
6222
+ const idKey = assetIdKey(kind);
6223
+ const refKey = assetListKey(kind);
6224
+ for (const ref of asList(ctx[refKey])) {
6225
+ const assetId = strOf(ref[idKey]);
6226
+ const stateId = strOf(ref["state_id"]);
6227
+ if (assetId && stateId && sceneRef)
6228
+ ensure(kind, assetId, stateId).sceneRefs.add(sceneRef);
6229
+ }
6230
+ }
6231
+ for (const [actionIndex, action] of asList(scene["actions"]).entries()) {
6232
+ if (!isDict(action))
6233
+ continue;
6234
+ for (const change of asList(action["state_changes"])) {
6235
+ if (!isDict(change))
6236
+ continue;
6237
+ const kind = strOf(change["target_kind"]);
6238
+ if (!isAssetKindValue(kind))
6239
+ continue;
6240
+ const assetId = strOf(change["target_id"]);
6241
+ if (!assetId)
6242
+ continue;
6243
+ const actionRef = `${sceneRef}#${actionIndex}`;
6244
+ const fromStateId = strOf(change["from_state_id"]);
6245
+ const toStateId = strOf(change["to_state_id"]);
6246
+ if (fromStateId)
6247
+ ensure(kind, assetId, fromStateId).stateChanges.add(actionRef);
6248
+ if (toStateId)
6249
+ ensure(kind, assetId, toStateId).stateChanges.add(actionRef);
6250
+ }
6251
+ }
6252
+ }
6253
+ }
6254
+ return usage;
6255
+ }
6256
+ function statefulAssetsForCuration(script) {
6257
+ const refs = [];
6258
+ for (const kind of ["actor", "location", "prop"]) {
6259
+ for (const asset of asList(script[assetListKey(kind)])) {
6260
+ if (!isDict(asset))
6261
+ continue;
6262
+ const assetId = strOf(asset[assetIdKey(kind)]).trim();
6263
+ if (!assetId || asList(asset["states"]).filter(isDict).length === 0)
6264
+ continue;
6265
+ refs.push({ kind, asset_id: assetId });
6266
+ }
6267
+ }
6268
+ return refs;
6269
+ }
6270
+ function stateCurationAssetByRef(script, ref) {
6271
+ return assetMapById(script, ref.kind).get(ref.asset_id) ?? null;
6272
+ }
6273
+ function compactStateCurationText(value, maxChars = 120) {
6274
+ const text = strOf(value).replace(/\s+/g, " ").replace(/\|/g, "/").trim();
6275
+ return text.length > maxChars ? `${text.slice(0, maxChars - 1)}…` : text;
6276
+ }
6277
+ function formatStateCurationUsage(usage) {
6278
+ if (!usage)
6279
+ return "refs=0 changes=0";
6280
+ return `refs=${usage.sceneRefs.size} changes=${usage.stateChanges.size}`;
6281
+ }
6282
+ function stateCurationPrefix(kind) {
6283
+ if (kind === "actor")
6284
+ return "A";
6285
+ if (kind === "location")
6286
+ return "L";
6287
+ return "P";
6288
+ }
6289
+ function formatStateCurationAssetBlock(script, ref, usageByKey) {
6290
+ const asset = stateCurationAssetByRef(script, ref);
6291
+ if (!asset)
6292
+ return [];
6293
+ const name = compactStateCurationText(asset[assetNameKey(ref.kind)], 80);
6294
+ const description = compactStateCurationText(asset["description"], 160);
6295
+ const states = asList(asset["states"]).filter(isDict);
6296
+ const lines = [
6297
+ `${stateCurationPrefix(ref.kind)} | ${ref.kind} | ${ref.asset_id} | ${name || "-"} | states=${states.length} | desc=${description || "-"}`,
6298
+ ];
6299
+ for (const state of states) {
6300
+ const stateId = compactStateCurationText(state["state_id"], 40);
6301
+ const stateName = compactStateCurationText(state["state_name"], 80);
6302
+ if (!stateId || !stateName)
6303
+ continue;
6304
+ const usage = usageByKey.get(stateCurationSourceKey(ref.kind, ref.asset_id, stateId));
6305
+ lines.push(` S | ${stateId} | ${stateName} | ${formatStateCurationUsage(usage)} | desc=${compactStateCurationText(state["description"], 180) || "-"}`);
6306
+ }
6307
+ return lines;
6308
+ }
6309
+ export function buildStateCurationContextText(script, refs) {
6310
+ const selectedRefs = refs && refs.length > 0 ? refs : statefulAssetsForCuration(script);
6311
+ const usage = stateCurationUsageFor(script);
6312
+ const lines = [];
6313
+ lines.push("# State Curation Context");
6314
+ lines.push(`title: ${compactStateCurationText(script["title"], 120) || "-"}`);
6315
+ lines.push("");
6316
+ lines.push("Goal: compress each asset's states to reusable production visual states.");
6317
+ lines.push("");
6318
+ lines.push("Policy:");
6319
+ lines.push("- A state is a large reusable visual asset form, not a plot/action/emotion/pose label.");
6320
+ lines.push("- KEEP only states that require a visibly different reusable asset: major form transformation, Q version, non-human/beast/god/giant form, durable outfit/appearance redesign, major damage/repair condition, or a prop/location physical condition that must recur.");
6321
+ lines.push("- MERGE states that are only degree/progress variants of the same major appearance. Rename one representative to the broad canonical state, then merge the variants into it.");
6322
+ lines.push("- DROP or ACTION_STATE transient states: emotions, poses, unconscious/asleep/lying/sitting, holding/carrying an item, temporary glow/VFX intensity, minor mark brightness/thickness/spread, one-off ordinary clothing, camera effects, or scene-only actions.");
6323
+ lines.push("- For actor states, held objects are not actor states. If the state is just holding/using an item, use ACTION_STATE unless the item must remain a separate prop.");
6324
+ lines.push("- Every S line below must receive exactly one expression.");
6325
+ lines.push("- Use only state_id values listed under the same asset block. Do not invent ids or cross-asset merge targets.");
6326
+ lines.push("- If a broad canonical state name is needed, RENAME_STATE one existing representative state and MERGE_STATE the variants into it.");
6327
+ lines.push("");
6328
+ lines.push("Expression grammar:");
6329
+ lines.push("- <exp>KEEP_STATE <kind> <asset_id> <state_id> # reason</exp>");
6330
+ lines.push("- <exp>MERGE_STATE <kind> <asset_id> <state_id> -> <target_state_id> # reason</exp>");
6331
+ lines.push("- <exp>DROP_STATE <kind> <asset_id> <state_id> # reason</exp>");
6332
+ lines.push("- <exp>ACTION_STATE <kind> <asset_id> <state_id> # reason</exp>");
6333
+ lines.push("- <exp>RENAME_STATE <kind> <asset_id> <state_id> \"<new_name>\" # reason</exp>");
6334
+ lines.push("- <kind> is one of actor, location, prop. Use double quotes for names; escape only \\\" and \\\\.");
6335
+ lines.push("");
6336
+ lines.push("Assets:");
6337
+ for (const ref of selectedRefs) {
6338
+ const block = formatStateCurationAssetBlock(script, ref, usage);
6339
+ if (block.length > 0)
6340
+ lines.push(...block);
6341
+ }
6342
+ lines.push("");
6343
+ lines.push("required_state_ids:");
6344
+ for (const ref of selectedRefs) {
6345
+ const asset = stateCurationAssetByRef(script, ref);
6346
+ if (!asset)
6347
+ continue;
6348
+ for (const state of asList(asset["states"])) {
6349
+ const stateId = strOf(state["state_id"]);
6350
+ if (stateId)
6351
+ lines.push(`- ${stateCurationSourceKey(ref.kind, ref.asset_id, stateId)}`);
6352
+ }
6353
+ }
6354
+ return `${lines.join("\n")}\n`;
6355
+ }
6356
+ export function buildStateCurationChunks(script, options = {}) {
6357
+ const maxAssets = Math.max(1, Math.floor(options.maxAssets ?? STATE_CURATION_MAX_ASSETS));
6358
+ const maxContextChars = Math.max(2000, Math.floor(options.maxContextChars ?? STATE_CURATION_MAX_CONTEXT_CHARS));
6359
+ const refs = statefulAssetsForCuration(script);
6360
+ const chunks = [];
6361
+ let current = [];
6362
+ const flush = () => {
6363
+ if (current.length === 0)
6364
+ return;
6365
+ chunks.push({ part_id: `part.${String(chunks.length + 1).padStart(3, "0")}`, refs: current });
6366
+ current = [];
6367
+ };
6368
+ for (const ref of refs) {
6369
+ const singletonLength = buildStateCurationContextText(script, [ref]).length;
6370
+ if (current.length === 0) {
6371
+ current.push(ref);
6372
+ if (singletonLength > maxContextChars)
6373
+ flush();
6374
+ continue;
6375
+ }
6376
+ const candidate = [...current, ref];
6377
+ if (candidate.length > maxAssets || buildStateCurationContextText(script, candidate).length > maxContextChars) {
6378
+ flush();
6379
+ current.push(ref);
6380
+ if (singletonLength > maxContextChars)
6381
+ flush();
6382
+ }
6383
+ else {
6384
+ current = candidate;
6385
+ }
6386
+ }
6387
+ flush();
6388
+ return chunks;
6389
+ }
6390
+ function splitStateCurationExpressionReason(expression) {
6391
+ let inQuote = false;
6392
+ let escaped = false;
6393
+ for (let index = 0; index < expression.length; index += 1) {
6394
+ const ch = expression[index];
6395
+ if (escaped) {
6396
+ escaped = false;
6397
+ continue;
6398
+ }
6399
+ if (ch === "\\") {
6400
+ escaped = true;
6401
+ continue;
6402
+ }
6403
+ if (ch === "\"") {
6404
+ inQuote = !inQuote;
6405
+ continue;
6406
+ }
6407
+ if (ch === "#" && !inQuote) {
6408
+ const command = expression.slice(0, index).trim();
6409
+ const reason = expression.slice(index + 1).trim();
6410
+ if (!command)
6411
+ stateCurationBlocked("State curation expression is missing an operation before # reason.", [expression]);
6412
+ if (!reason)
6413
+ stateCurationBlocked("State curation expression is missing reason text after #.", [expression]);
6414
+ return { command, reason };
6415
+ }
6416
+ }
6417
+ stateCurationBlocked("State curation expression must include `# reason`.", [expression]);
6418
+ }
6419
+ function unquoteStateCurationExpressionString(value) {
6420
+ let out = "";
6421
+ let escaped = false;
6422
+ for (const ch of value) {
6423
+ if (escaped) {
6424
+ if (ch !== "\\" && ch !== "\"")
6425
+ stateCurationBlocked("Unsupported escape sequence in state curation expression string.", [`\\${ch}`]);
6426
+ out += ch;
6427
+ escaped = false;
6428
+ }
6429
+ else if (ch === "\\") {
6430
+ escaped = true;
6431
+ }
6432
+ else {
6433
+ out += ch;
6434
+ }
6435
+ }
6436
+ if (escaped)
6437
+ stateCurationBlocked("Unterminated escape sequence in state curation expression string.", [value]);
6438
+ return out;
6439
+ }
6440
+ function parseStateCurationExpression(expression) {
6441
+ if (expression.includes("\n") || expression.includes("\r")) {
6442
+ stateCurationBlocked("Each state curation expression must be a single line.", [expression]);
6443
+ }
6444
+ const { command, reason } = splitStateCurationExpressionReason(expression.trim());
6445
+ if (/^NOOP$/i.test(command))
6446
+ return null;
6447
+ let match = /^KEEP_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)$/i.exec(command);
6448
+ if (match) {
6449
+ return { kind: match[1].toLowerCase(), asset_id: match[2], state_id: match[3], decision: "keep", reason };
6450
+ }
6451
+ match = /^MERGE_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)\s+->\s+(\S+)$/i.exec(command);
6452
+ if (match) {
6453
+ return {
6454
+ kind: match[1].toLowerCase(),
6455
+ asset_id: match[2],
6456
+ state_id: match[3],
6457
+ decision: "merge",
6458
+ target_state_id: match[4],
6459
+ reason,
6460
+ };
6461
+ }
6462
+ match = /^DROP_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)$/i.exec(command);
6463
+ if (match) {
6464
+ return { kind: match[1].toLowerCase(), asset_id: match[2], state_id: match[3], decision: "drop", reason };
6465
+ }
6466
+ match = /^ACTION_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)$/i.exec(command);
6467
+ if (match) {
6468
+ return { kind: match[1].toLowerCase(), asset_id: match[2], state_id: match[3], decision: "action_desc", reason };
6469
+ }
6470
+ match = /^RENAME_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)\s+"((?:\\.|[^"\\])*)"$/i.exec(command);
6471
+ if (match) {
6472
+ return {
6473
+ kind: match[1].toLowerCase(),
6474
+ asset_id: match[2],
6475
+ state_id: match[3],
6476
+ decision: "rename",
6477
+ new_name: unquoteStateCurationExpressionString(match[4]),
6478
+ reason,
6479
+ };
6480
+ }
6481
+ stateCurationBlocked("Unknown or malformed state curation expression.", [expression]);
6482
+ }
6483
+ function extractStateCurationExpressions(rawText) {
6484
+ const expressions = [];
6485
+ const re = /<exp>([\s\S]*?)<\/exp>/g;
6486
+ let outside = "";
6487
+ let lastIndex = 0;
6488
+ let match;
6489
+ while ((match = re.exec(rawText)) !== null) {
6490
+ outside += rawText.slice(lastIndex, match.index);
6491
+ expressions.push(strOf(match[1]).trim());
6492
+ lastIndex = match.index + match[0].length;
6493
+ }
6494
+ outside += rawText.slice(lastIndex);
6495
+ if (outside.trim()) {
6496
+ stateCurationBlocked("State curation response must contain only <exp>...</exp> lines.", [outside.trim().slice(0, 200)]);
6497
+ }
6498
+ if (expressions.length === 0) {
6499
+ stateCurationBlocked("State curation response did not contain any <exp> expressions.", [rawText.slice(0, 200)]);
6500
+ }
6501
+ return expressions;
6502
+ }
6503
+ export function parseStateCurationExpressions(rawText) {
6504
+ const decisions = [];
6505
+ const expressions = extractStateCurationExpressions(rawText);
6506
+ let noop = false;
6507
+ for (const expression of expressions) {
6508
+ const decision = parseStateCurationExpression(expression);
6509
+ if (decision === null)
6510
+ noop = true;
6511
+ else
6512
+ decisions.push(decision);
6513
+ }
6514
+ if (noop && decisions.length > 0) {
6515
+ stateCurationBlocked("NOOP cannot be combined with actionable state curation expressions.", [rawText.slice(0, 200)]);
6516
+ }
6517
+ return {
6518
+ version: 1,
6519
+ format: "state-curation-exp-v1",
6520
+ expression_text: rawText,
6521
+ expressions,
6522
+ decisions,
6523
+ };
6524
+ }
6525
+ function renderStateCurationDecisionExpression(decision) {
6526
+ const kind = strOf(decision["kind"]);
6527
+ const assetId = strOf(decision["asset_id"]);
6528
+ const stateId = strOf(decision["state_id"]);
6529
+ const reason = strOf(decision["reason"]).trim() || "state curation decision";
6530
+ const action = strOf(decision["decision"]);
6531
+ if (action === "keep")
6532
+ return `KEEP_STATE ${kind} ${assetId} ${stateId} # ${reason}`;
6533
+ if (action === "merge")
6534
+ return `MERGE_STATE ${kind} ${assetId} ${stateId} -> ${strOf(decision["target_state_id"])} # ${reason}`;
6535
+ if (action === "drop")
6536
+ return `DROP_STATE ${kind} ${assetId} ${stateId} # ${reason}`;
6537
+ if (action === "action_desc")
6538
+ return `ACTION_STATE ${kind} ${assetId} ${stateId} # ${reason}`;
6539
+ if (action === "rename")
6540
+ return `RENAME_STATE ${kind} ${assetId} ${stateId} ${quoteCurationExpressionString(decision["new_name"])} # ${reason}`;
6541
+ stateCurationBlocked("Cannot render unknown state curation decision.", [`decision: ${action || "<empty>"}`]);
6542
+ }
6543
+ export function formatStateCurationDecisionExpressions(plan) {
6544
+ const decisions = asList(plan["decisions"]).filter(isDict);
6545
+ if (decisions.length === 0)
6546
+ return "<exp>NOOP # no state curation changes</exp>";
6547
+ return `${decisions.map((decision) => `<exp>${renderStateCurationDecisionExpression(decision)}</exp>`).join("\n")}\n`;
6548
+ }
6549
+ function stateCurationAllSourceKeys(script) {
6550
+ const keys = [];
6551
+ for (const ref of statefulAssetsForCuration(script)) {
6552
+ const asset = stateCurationAssetByRef(script, ref);
6553
+ if (!asset)
6554
+ continue;
6555
+ for (const state of asList(asset["states"])) {
6556
+ const stateId = strOf(state["state_id"]);
6557
+ if (stateId)
6558
+ keys.push(stateCurationSourceKey(ref.kind, ref.asset_id, stateId));
6559
+ }
6560
+ }
6561
+ return keys;
6562
+ }
6563
+ export function stateCurationSourceKeysForRefs(script, refs) {
6564
+ const keys = [];
6565
+ for (const ref of refs) {
6566
+ const asset = stateCurationAssetByRef(script, ref);
6567
+ if (!asset)
6568
+ continue;
6569
+ for (const state of asList(asset["states"])) {
6570
+ const stateId = strOf(state["state_id"]);
6571
+ if (stateId)
6572
+ keys.push(stateCurationSourceKey(ref.kind, ref.asset_id, stateId));
6573
+ }
6574
+ }
6575
+ return keys;
6576
+ }
6577
+ function stateCurationDecisionMap(decisions) {
6578
+ const map = new Map();
6579
+ for (const decision of decisions) {
6580
+ map.set(stateCurationDecisionSourceKey(decision), decision);
6581
+ }
6582
+ return map;
6583
+ }
6584
+ export function missingStateCurationRequiredSourceKeys(script, plan, requiredKeys) {
6585
+ const keys = requiredKeys && requiredKeys.length > 0 ? [...requiredKeys] : stateCurationAllSourceKeys(script);
6586
+ const decisions = stateCurationDecisionMap(asList(plan["decisions"]).filter(isDict));
6587
+ return keys.filter((key) => !decisions.has(key));
6588
+ }
6589
+ function assertStateExistsForCuration(script, kind, assetId, stateId) {
6590
+ const asset = assertAssetExists(script, kind, assetId);
6591
+ for (const state of asList(asset["states"])) {
6592
+ if (strOf(state["state_id"]) === stateId)
6593
+ return state;
6594
+ }
6595
+ stateCurationBlocked("State curation decision references an unknown state.", [stateCurationSourceKey(kind, assetId, stateId)]);
6596
+ }
6597
+ function validateStateCurationDecision(script, decision) {
6598
+ const kind = strOf(decision["kind"]);
6599
+ if (!isAssetKindValue(kind))
6600
+ stateCurationBlocked("Unknown state curation kind.", [`kind: ${kind || "<empty>"}`]);
6601
+ const assetId = strOf(decision["asset_id"]);
6602
+ const stateId = strOf(decision["state_id"]);
6603
+ const action = strOf(decision["decision"]);
6604
+ const reason = strOf(decision["reason"]);
6605
+ if (!assetId || !stateId)
6606
+ stateCurationBlocked("State curation decision is missing asset_id/state_id.", [`${kind}:${assetId}/${stateId}`]);
6607
+ if (!reason)
6608
+ stateCurationBlocked("State curation decision is missing reason.", [`${kind}:${assetId}/${stateId}`]);
6609
+ assertStateExistsForCuration(script, kind, assetId, stateId);
6610
+ if (action === "merge") {
6611
+ const targetStateId = strOf(decision["target_state_id"]);
6612
+ if (!targetStateId || targetStateId === stateId) {
6613
+ stateCurationBlocked("MERGE_STATE requires a different target_state_id.", [`${kind}:${assetId}/${stateId}`]);
6614
+ }
6615
+ assertStateExistsForCuration(script, kind, assetId, targetStateId);
6616
+ }
6617
+ else if (action === "rename") {
6618
+ const newName = strOf(decision["new_name"]).trim();
6619
+ if (!newName)
6620
+ stateCurationBlocked("RENAME_STATE requires a non-empty new_name.", [`${kind}:${assetId}/${stateId}`]);
6621
+ }
6622
+ else if (action !== "keep" && action !== "drop" && action !== "action_desc") {
6623
+ stateCurationBlocked("Unknown state curation decision.", [`decision: ${action || "<empty>"}`]);
6624
+ }
6625
+ }
6626
+ function normalizeStateCurationMergeTargets(decisions) {
6627
+ const bySource = stateCurationDecisionMap(decisions);
6628
+ for (const decision of decisions) {
6629
+ if (strOf(decision["decision"]) !== "merge")
6630
+ continue;
6631
+ const kind = strOf(decision["kind"]);
6632
+ const assetId = strOf(decision["asset_id"]);
6633
+ let targetStateId = strOf(decision["target_state_id"]);
6634
+ const seen = new Set([stateCurationDecisionSourceKey(decision)]);
6635
+ while (targetStateId) {
6636
+ const targetKey = stateCurationSourceKey(kind, assetId, targetStateId);
6637
+ if (seen.has(targetKey))
6638
+ stateCurationBlocked("State curation merge plan contains a cycle.", [`cycle at ${targetKey}`]);
6639
+ seen.add(targetKey);
6640
+ const targetDecision = bySource.get(targetKey);
6641
+ if (!targetDecision)
6642
+ break;
6643
+ const targetAction = strOf(targetDecision["decision"]);
6644
+ if (targetAction === "merge") {
6645
+ targetStateId = strOf(targetDecision["target_state_id"]);
6646
+ decision["target_state_id"] = targetStateId;
6647
+ continue;
6648
+ }
6649
+ if (targetAction === "drop" || targetAction === "action_desc") {
6650
+ decision["decision"] = targetAction;
6651
+ delete decision["target_state_id"];
6652
+ }
6653
+ break;
6654
+ }
6655
+ }
6656
+ }
6657
+ export function assertStateCurationPlan(script, plan, requiredKeys) {
6658
+ const rawDecisions = asList(plan["decisions"]).filter(isDict);
6659
+ const decisions = [...stateCurationDecisionMap(rawDecisions).values()];
6660
+ for (const decision of decisions)
6661
+ validateStateCurationDecision(script, decision);
6662
+ normalizeStateCurationMergeTargets(decisions);
6663
+ const missing = missingStateCurationRequiredSourceKeys(script, { decisions }, requiredKeys);
6664
+ if (missing.length > 0) {
6665
+ stateCurationBlocked("State curation plan is missing required state decisions.", missing.slice(0, 80));
6666
+ }
6667
+ return {
6668
+ ...plan,
6669
+ decisions,
6670
+ };
6671
+ }
6672
+ function mapStateForCuration(kind, assetId, stateId, replacements, removed) {
6673
+ const id = strOf(stateId);
6674
+ if (!id)
6675
+ return null;
6676
+ const key = stateCurationSourceKey(kind, assetId, id);
6677
+ const replacement = replacements.get(key);
6678
+ if (replacement)
6679
+ return replacement;
6680
+ if (removed.has(key))
6681
+ return null;
6682
+ return id;
6683
+ }
6684
+ function rewriteRefsAfterStateCuration(script, replacements, removed) {
6685
+ let sceneRefsCleared = 0;
6686
+ let sceneRefsMerged = 0;
6687
+ let stateChangesRemoved = 0;
6688
+ let stateChangesRewritten = 0;
6689
+ for (const ep of asList(script["episodes"])) {
6690
+ for (const scene of asList(ep["scenes"])) {
6691
+ const ctx = sceneContext(scene);
6692
+ for (const kind of ["actor", "location", "prop"]) {
6693
+ const idKey = assetIdKey(kind);
6694
+ const refKey = assetListKey(kind);
6695
+ for (const ref of asList(ctx[refKey])) {
6696
+ if (!isDict(ref))
6697
+ continue;
6698
+ const assetId = strOf(ref[idKey]);
6699
+ const prior = strOf(ref["state_id"]);
6700
+ const next = mapStateForCuration(kind, assetId, prior, replacements, removed);
6701
+ if (!next && prior) {
6702
+ ref["state_id"] = null;
6703
+ sceneRefsCleared += 1;
6704
+ }
6705
+ else if (next && next !== prior) {
6706
+ ref["state_id"] = next;
6707
+ sceneRefsMerged += 1;
6708
+ }
6709
+ }
6710
+ }
6711
+ setSceneContext(scene, ctx);
6712
+ for (const action of asList(scene["actions"])) {
6713
+ if (!isDict(action) || action["state_changes"] === undefined || action["state_changes"] === null)
6714
+ continue;
6715
+ const nextChanges = [];
6716
+ for (const change of asList(action["state_changes"])) {
6717
+ if (!isDict(change))
6718
+ continue;
6719
+ const kind = strOf(change["target_kind"]);
6720
+ if (!isAssetKindValue(kind))
6721
+ continue;
6722
+ const assetId = strOf(change["target_id"]);
6723
+ const toState = mapStateForCuration(kind, assetId, change["to_state_id"], replacements, removed);
6724
+ if (!toState) {
6725
+ stateChangesRemoved += 1;
6726
+ continue;
6727
+ }
6728
+ const fromState = mapStateForCuration(kind, assetId, change["from_state_id"], replacements, removed);
6729
+ if (fromState && fromState === toState) {
6730
+ stateChangesRemoved += 1;
6731
+ continue;
6732
+ }
6733
+ const next = { ...change, to_state_id: toState };
6734
+ if (fromState)
6735
+ next["from_state_id"] = fromState;
6736
+ else
6737
+ delete next["from_state_id"];
6738
+ if (toState !== strOf(change["to_state_id"]) || fromState !== strOf(change["from_state_id"]))
6739
+ stateChangesRewritten += 1;
6740
+ nextChanges.push(next);
6741
+ }
6742
+ if (nextChanges.length > 0)
6743
+ action["state_changes"] = nextChanges;
6744
+ else
6745
+ delete action["state_changes"];
6746
+ }
6747
+ }
6748
+ }
6749
+ return {
6750
+ scene_refs_cleared: sceneRefsCleared,
6751
+ scene_refs_merged: sceneRefsMerged,
6752
+ state_changes_removed: stateChangesRemoved,
6753
+ state_changes_rewritten: stateChangesRewritten,
6754
+ };
6755
+ }
6756
+ export function applyStateCurationPlan(script, rawPlan) {
6757
+ const plan = assertStateCurationPlan(script, rawPlan);
6758
+ const decisions = asList(plan["decisions"]).filter(isDict);
6759
+ const beforeByKind = { actor: 0, location: 0, prop: 0 };
6760
+ const afterByKind = { actor: 0, location: 0, prop: 0 };
6761
+ for (const kind of ["actor", "location", "prop"]) {
6762
+ for (const asset of asList(script[assetListKey(kind)]))
6763
+ beforeByKind[kind] += asList(asset["states"]).filter(isDict).length;
6764
+ }
6765
+ const replacements = new Map();
6766
+ const removed = new Set();
6767
+ const decisionSummary = {
6768
+ keep: 0,
6769
+ merge: 0,
6770
+ drop: 0,
6771
+ action_desc: 0,
6772
+ rename: 0,
6773
+ };
6774
+ for (const decision of decisions) {
6775
+ const kind = strOf(decision["kind"]);
6776
+ const assetId = strOf(decision["asset_id"]);
6777
+ const stateId = strOf(decision["state_id"]);
6778
+ const key = stateCurationSourceKey(kind, assetId, stateId);
6779
+ const action = strOf(decision["decision"]);
6780
+ decisionSummary[action] = Number(decisionSummary[action] ?? 0) + 1;
6781
+ if (action === "rename") {
6782
+ const state = assertStateExistsForCuration(script, kind, assetId, stateId);
6783
+ state["state_name"] = strOf(decision["new_name"]).trim();
6784
+ }
6785
+ else if (action === "merge") {
6786
+ replacements.set(key, strOf(decision["target_state_id"]));
6787
+ removed.add(key);
6788
+ }
6789
+ else if (action === "drop" || action === "action_desc") {
6790
+ removed.add(key);
6791
+ }
6792
+ }
6793
+ const rewriteSummary = rewriteRefsAfterStateCuration(script, replacements, removed);
6794
+ for (const kind of ["actor", "location", "prop"]) {
6795
+ for (const asset of asList(script[assetListKey(kind)])) {
6796
+ if (!isDict(asset))
6797
+ continue;
6798
+ const assetId = strOf(asset[assetIdKey(kind)]);
6799
+ asset["states"] = asList(asset["states"]).filter((state) => {
6800
+ if (!isDict(state))
6801
+ return false;
6802
+ const key = stateCurationSourceKey(kind, assetId, strOf(state["state_id"]));
6803
+ return !removed.has(key);
6804
+ });
6805
+ afterByKind[kind] += asList(asset["states"]).filter(isDict).length;
6806
+ }
6807
+ }
6808
+ const statesBefore = beforeByKind.actor + beforeByKind.location + beforeByKind.prop;
6809
+ const statesAfter = afterByKind.actor + afterByKind.location + afterByKind.prop;
6810
+ return {
6811
+ version: 1,
6812
+ format: "state-curation-exp-v1",
6813
+ summary: {
6814
+ states_before: statesBefore,
6815
+ states_after: statesAfter,
6816
+ states_removed: statesBefore - statesAfter,
6817
+ actor_states_before: beforeByKind.actor,
6818
+ actor_states_after: afterByKind.actor,
6819
+ location_states_before: beforeByKind.location,
6820
+ location_states_after: afterByKind.location,
6821
+ prop_states_before: beforeByKind.prop,
6822
+ prop_states_after: afterByKind.prop,
6823
+ decisions: decisionSummary,
6824
+ ...rewriteSummary,
6825
+ },
6826
+ decisions,
6827
+ };
6828
+ }
6829
+ // ---------------------------------------------------------------------------
6174
6830
  // State binding
6175
6831
  // ---------------------------------------------------------------------------
6176
6832
  function stateBindingBlocked(message, received) {