@lingjingai/scriptctl 0.33.0 → 0.35.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.
Files changed (41) hide show
  1. package/README.md +9 -0
  2. package/changes/0.35.0.md +22 -0
  3. package/dist/bin.js +0 -0
  4. package/dist/cli.js +4 -0
  5. package/dist/cli.js.map +1 -1
  6. package/dist/common.js +29 -6
  7. package/dist/common.js.map +1 -1
  8. package/dist/domain/direct-core.d.ts +4 -1
  9. package/dist/domain/direct-core.js +718 -177
  10. package/dist/domain/direct-core.js.map +1 -1
  11. package/dist/domain/script/validate.js +11 -0
  12. package/dist/domain/script/validate.js.map +1 -1
  13. package/dist/help-text.js +31 -7
  14. package/dist/help-text.js.map +1 -1
  15. package/dist/infra/providers.js +76 -24
  16. package/dist/infra/providers.js.map +1 -1
  17. package/dist/usecases/direct.js +25 -9
  18. package/dist/usecases/direct.js.map +1 -1
  19. package/dist/usecases/script/actions.js +1 -1
  20. package/dist/usecases/script/actions.js.map +1 -1
  21. package/dist/usecases/script/actors.js +1 -0
  22. package/dist/usecases/script/actors.js.map +1 -1
  23. package/dist/usecases/script/assets.js +2 -1
  24. package/dist/usecases/script/assets.js.map +1 -1
  25. package/dist/usecases/script/lib.d.ts +11 -0
  26. package/dist/usecases/script/lib.js +93 -3
  27. package/dist/usecases/script/lib.js.map +1 -1
  28. package/dist/usecases/script/locations.js +1 -0
  29. package/dist/usecases/script/locations.js.map +1 -1
  30. package/dist/usecases/script/props.js +1 -0
  31. package/dist/usecases/script/props.js.map +1 -1
  32. package/dist/usecases/script/states.js +58 -13
  33. package/dist/usecases/script/states.js.map +1 -1
  34. package/dist/usecases/script/summary.js +8 -0
  35. package/dist/usecases/script/summary.js.map +1 -1
  36. package/package.json +17 -14
  37. package/scripts/install-skill.mjs +120 -0
  38. package/skills/scriptctl/SKILL.md +273 -0
  39. package/skills/scriptctl/references/atomic-write-workflow.md +114 -0
  40. package/skills/scriptctl/references/direct-workflow.md +72 -0
  41. package/skills/scriptctl/references/state-reference-repair.md +72 -0
@@ -104,6 +104,9 @@ const _MD_ASSET_SUBSECTION_MAP = {
104
104
  "场景状态": "location_states", "地点状态": "location_states",
105
105
  "道具状态": "prop_states",
106
106
  };
107
+ const _MD_STATE_REASON_RE = /^(?:提取缘由|抽取缘由|提取理由|状态缘由|状态理由)\s*[::]\s*(.+)$/;
108
+ const STATE_REASON_MAX_CHARS = 360;
109
+ const MERGED_FROM_MAX_ITEMS = 12;
107
110
  // ---------------------------------------------------------------------------
108
111
  // Generic helpers (Python builtin equivalents)
109
112
  // ---------------------------------------------------------------------------
@@ -411,8 +414,19 @@ export function extractEpisodeTitleCandidate(header) {
411
414
  // ---------------------------------------------------------------------------
412
415
  // Episode plan
413
416
  // ---------------------------------------------------------------------------
417
+ const _SCREENPLAY_BODY_MARKER_RE = /^[\t ]*(?:剧本正文|正文|短剧正文|正片剧本|剧本内容)[\t ]*[::]?[\t ]*$/gim;
418
+ function screenplayBodyStartOffset(sourceText) {
419
+ for (const match of reFindAllMatches(_SCREENPLAY_BODY_MARKER_RE, sourceText)) {
420
+ const start = match.index + match[0].length;
421
+ if (reFindAllMatches(_EP_HEADER_RE, sourceText.slice(start)).length > 0)
422
+ return start;
423
+ }
424
+ return 0;
425
+ }
414
426
  export function buildEpisodePlan(sourceText) {
415
- const matches = reFindAllMatches(_EP_HEADER_RE, sourceText);
427
+ const bodyStart = screenplayBodyStartOffset(sourceText);
428
+ const bodyText = bodyStart > 0 ? sourceText.slice(bodyStart) : sourceText;
429
+ const matches = reFindAllMatches(_EP_HEADER_RE, bodyText);
416
430
  const episodes = [];
417
431
  let confidence;
418
432
  let strategy;
@@ -436,7 +450,8 @@ export function buildEpisodePlan(sourceText) {
436
450
  epNum = next;
437
451
  }
438
452
  usedEpNums.add(epNum);
439
- const end = idx + 1 < matches.length ? matches[idx + 1].index : sourceText.length;
453
+ const start = bodyStart + match.index;
454
+ const end = idx + 1 < matches.length ? bodyStart + matches[idx + 1].index : sourceText.length;
440
455
  const header = match[0].trim();
441
456
  const rawTitle = extractEpisodeTitleCandidate(header);
442
457
  const title = formatEpisodeTitle(epNum, rawTitle);
@@ -448,13 +463,13 @@ export function buildEpisodePlan(sourceText) {
448
463
  raw_title: rawTitle,
449
464
  title_status: titleStatus,
450
465
  title_source: title ? "source" : titleStatus,
451
- source_span: { start: match.index, end },
452
- header_span: { start: match.index, end: match.index + match[0].length },
453
- char_count: end - match.index,
466
+ source_span: { start, end },
467
+ header_span: { start, end: start + match[0].length },
468
+ char_count: end - start,
454
469
  });
455
470
  }
456
471
  confidence = "high";
457
- strategy = "explicit_episode_markers";
472
+ strategy = bodyStart > 0 ? "body_marker_episode_markers" : "explicit_episode_markers";
458
473
  }
459
474
  else {
460
475
  episodes.push({
@@ -475,6 +490,7 @@ export function buildEpisodePlan(sourceText) {
475
490
  version: 1,
476
491
  strategy,
477
492
  boundary_confidence: confidence,
493
+ body_start_offset: bodyStart,
478
494
  total_episodes: episodes.length,
479
495
  episodes,
480
496
  };
@@ -492,6 +508,11 @@ export function splitEntityState(raw) {
492
508
  if (!value)
493
509
  return ["", null];
494
510
  const states = [];
511
+ for (const item of reFindAll(_LOC_STATE_RE, value)) {
512
+ const v = item.trim();
513
+ if (v)
514
+ states.push(v);
515
+ }
495
516
  for (const item of reFindAll(_STATE_RE, value)) {
496
517
  const v = item.trim();
497
518
  if (v)
@@ -502,7 +523,7 @@ export function splitEntityState(raw) {
502
523
  if (v)
503
524
  states.push(v);
504
525
  }
505
- return [cleanName(value), states.length > 0 ? states[states.length - 1] : null];
526
+ return [cleanName(reReplace(_LOC_STATE_RE, value, "")), states.length > 0 ? states[states.length - 1] : null];
506
527
  }
507
528
  export function splitLocationState(raw) {
508
529
  const value = strOf(raw).trim();
@@ -1189,19 +1210,21 @@ export function isRecoverableBatchCliError(exc) {
1189
1210
  export function isRetryableProviderError(exc) {
1190
1211
  const anyExc = exc;
1191
1212
  const statusCode = normalizeInt(anyExc?.status_code ?? anyExc?.statusCode ?? anyExc?.status, 0);
1192
- if ([408, 409, 429, 500, 502, 503, 504, 529].includes(statusCode))
1193
- return true;
1194
- if ([400, 401, 403, 404].includes(statusCode))
1195
- return false;
1196
1213
  const name = (anyExc?.name ?? "").toLowerCase();
1197
1214
  const message = (typeof exc === "object" && exc !== null && "message" in exc ? String(exc.message) : String(exc)).toLowerCase();
1198
1215
  const retryMarkers = [
1199
1216
  "apierror", "apiconnectionerror", "apitimeouterror", "ratelimiterror",
1200
1217
  "remoteprotocolerror", "timeout", "overloaded", "rate_limit", "temporarily",
1201
1218
  "service unavailable", "bad gateway", "gateway timeout", "connection reset",
1202
- "peer closed", "incomplete chunked read",
1219
+ "peer closed", "incomplete chunked read", "all connection attempts failed",
1203
1220
  ];
1204
- return retryMarkers.some((m) => name.includes(m) || message.includes(m));
1221
+ if (retryMarkers.some((m) => name.includes(m) || message.includes(m)))
1222
+ return true;
1223
+ if ([408, 409, 429, 500, 502, 503, 504, 529].includes(statusCode))
1224
+ return true;
1225
+ if ([400, 401, 403, 404].includes(statusCode))
1226
+ return false;
1227
+ return false;
1205
1228
  }
1206
1229
  export function providerAttempts() {
1207
1230
  const raw = (process.env.SCRIPTCTL_PROVIDER_ATTEMPTS ?? "").trim();
@@ -1276,20 +1299,35 @@ export function _md_push_asset(items, rawName, description) {
1276
1299
  }
1277
1300
  items.push({ name, description: description || null });
1278
1301
  }
1279
- function _md_push_state_def(items, kind, rawName, description) {
1302
+ function cleanStateReason(raw) {
1303
+ const text = strOf(raw).replace(/\s+/g, " ").trim();
1304
+ if (!text)
1305
+ return "";
1306
+ return text.length > STATE_REASON_MAX_CHARS ? `${text.slice(0, STATE_REASON_MAX_CHARS - 1)}…` : text;
1307
+ }
1308
+ function setStateReason(item, reason) {
1309
+ const text = cleanStateReason(reason);
1310
+ if (text && !item["state_reason"])
1311
+ item["state_reason"] = text;
1312
+ }
1313
+ function _md_push_state_def(items, kind, rawName, description, stateReason = "") {
1280
1314
  const [namePart, sep, statePart] = partition(strOf(rawName), "|");
1281
1315
  const assetName = cleanName(namePart);
1282
1316
  const stateName = statePart.trim();
1283
1317
  if (!sep || !assetName || !stateName)
1284
- return;
1318
+ return null;
1285
1319
  for (const item of items) {
1286
1320
  if (item["kind"] === kind && item["asset_name"] === assetName && item["state_name"] === stateName) {
1287
1321
  if (description && !item["description"])
1288
1322
  item["description"] = description;
1289
- return;
1323
+ setStateReason(item, stateReason);
1324
+ return item;
1290
1325
  }
1291
1326
  }
1292
- items.push({ kind, asset_name: assetName, state_name: stateName, description: description || null });
1327
+ const item = { kind, asset_name: assetName, state_name: stateName, description: description || null };
1328
+ setStateReason(item, stateReason);
1329
+ items.push(item);
1330
+ return item;
1293
1331
  }
1294
1332
  export function parseSpeakerRef(raw) {
1295
1333
  const value = strOf(raw).trim();
@@ -1330,7 +1368,7 @@ export function inferNonActorSpeakerKind(name, scene = null) {
1330
1368
  return "system";
1331
1369
  if (reSearch(/广播(?!员)|喇叭|电台|播报|通知|警报/, value))
1332
1370
  return "broadcast";
1333
- if (reSearch(/众人|众|群体|全体|大家|人群/, value))
1371
+ if (reSearch(/众人|众|群体|全体|大家|人群|宾客|观众|群众|围观者/, value))
1334
1372
  return "group";
1335
1373
  if (scene) {
1336
1374
  const propSet = new Set(asList(scene["prop_names"]).map((p) => strOf(p).trim()));
@@ -1500,6 +1538,7 @@ export function parseMarkdownBatch(text, batchPlan, opts = {}) {
1500
1538
  const speakers = [];
1501
1539
  const stateDefs = [];
1502
1540
  let lastAction = null;
1541
+ let lastStateDef = null;
1503
1542
  // synopsis (## 梗概 sub-header → per-episode; # 梗概 top header → whole-script)
1504
1543
  // and the episode title carried as the first non-section top header.
1505
1544
  const synopsisLines = [];
@@ -1515,6 +1554,7 @@ export function parseMarkdownBatch(text, batchPlan, opts = {}) {
1515
1554
  if (subMatch && !sceneMatch && subMatch[1].trim() === "梗概") {
1516
1555
  capturingSynopsis = true;
1517
1556
  lastAction = null;
1557
+ lastStateDef = null;
1518
1558
  continue;
1519
1559
  }
1520
1560
  if (sceneMatch && section === "script") {
@@ -1531,6 +1571,7 @@ export function parseMarkdownBatch(text, batchPlan, opts = {}) {
1531
1571
  actions: [],
1532
1572
  });
1533
1573
  lastAction = null;
1574
+ lastStateDef = null;
1534
1575
  continue;
1535
1576
  }
1536
1577
  if (subMatch && section === "asset") {
@@ -1538,11 +1579,13 @@ export function parseMarkdownBatch(text, batchPlan, opts = {}) {
1538
1579
  const heading = subMatch[1].trim();
1539
1580
  assetSubsection = _MD_ASSET_SUBSECTION_MAP[heading] ?? null;
1540
1581
  lastAction = null;
1582
+ lastStateDef = null;
1541
1583
  continue;
1542
1584
  }
1543
1585
  if (subMatch) {
1544
1586
  capturingSynopsis = false;
1545
1587
  lastAction = null;
1588
+ lastStateDef = null;
1546
1589
  continue;
1547
1590
  }
1548
1591
  const topMatch = reMatch(_MD_TOP_HEADER_RE, stripped);
@@ -1571,6 +1614,7 @@ export function parseMarkdownBatch(text, batchPlan, opts = {}) {
1571
1614
  section = null;
1572
1615
  }
1573
1616
  lastAction = null;
1617
+ lastStateDef = null;
1574
1618
  continue;
1575
1619
  }
1576
1620
  // Inside a synopsis block, capture prose lines until the next header.
@@ -1651,6 +1695,7 @@ export function parseMarkdownBatch(text, batchPlan, opts = {}) {
1651
1695
  }
1652
1696
  }
1653
1697
  lastAction = null;
1698
+ lastStateDef = null;
1654
1699
  continue;
1655
1700
  }
1656
1701
  const actionMatch = reMatch(_MD_ACTION_ANCHOR_RE, stripped);
@@ -1674,10 +1719,12 @@ export function parseMarkdownBatch(text, batchPlan, opts = {}) {
1674
1719
  }
1675
1720
  scenes[scenes.length - 1]["actions"].push(action);
1676
1721
  lastAction = action;
1722
+ lastStateDef = null;
1677
1723
  continue;
1678
1724
  }
1679
1725
  if (!stripped) {
1680
1726
  lastAction = null;
1727
+ lastStateDef = null;
1681
1728
  continue;
1682
1729
  }
1683
1730
  if (lastAction !== null && !stripped.startsWith("-")) {
@@ -1693,8 +1740,14 @@ export function parseMarkdownBatch(text, batchPlan, opts = {}) {
1693
1740
  else if (section === "asset") {
1694
1741
  if (assetSubsection === null || !stripped)
1695
1742
  continue;
1743
+ const reasonMatch = reMatch(_MD_STATE_REASON_RE, stripped);
1744
+ if (reasonMatch && lastStateDef !== null && /_states$/.test(assetSubsection)) {
1745
+ setStateReason(lastStateDef, reasonMatch[1]);
1746
+ continue;
1747
+ }
1696
1748
  const entryMatch = reMatch(_MD_ASSET_ENTRY_RE, stripped);
1697
1749
  if (entryMatch) {
1750
+ lastStateDef = null;
1698
1751
  const name = entryMatch[1].trim();
1699
1752
  const description = (entryMatch[2] || "").trim() || null;
1700
1753
  if (assetSubsection === "actors")
@@ -1709,15 +1762,18 @@ export function parseMarkdownBatch(text, batchPlan, opts = {}) {
1709
1762
  speakers.push({ name: displayName, source_kind: sourceKind, description });
1710
1763
  }
1711
1764
  else if (assetSubsection === "actor_states") {
1712
- _md_push_state_def(stateDefs, "actor", name, description);
1765
+ lastStateDef = _md_push_state_def(stateDefs, "actor", name, description);
1713
1766
  }
1714
1767
  else if (assetSubsection === "location_states") {
1715
- _md_push_state_def(stateDefs, "location", name, description);
1768
+ lastStateDef = _md_push_state_def(stateDefs, "location", name, description);
1716
1769
  }
1717
1770
  else if (assetSubsection === "prop_states") {
1718
- _md_push_state_def(stateDefs, "prop", name, description);
1771
+ lastStateDef = _md_push_state_def(stateDefs, "prop", name, description);
1719
1772
  }
1720
1773
  }
1774
+ else {
1775
+ lastStateDef = null;
1776
+ }
1721
1777
  }
1722
1778
  }
1723
1779
  for (const scene of scenes) {
@@ -1771,15 +1827,23 @@ export function parseAssetDoc(text, kind) {
1771
1827
  const stateDefs = [];
1772
1828
  const stateKind = kind === "actors" ? "actor" : kind === "locations" ? "location" : "prop";
1773
1829
  let currentAsset = null;
1830
+ let currentStateDef = null;
1774
1831
  for (const rawLine of text.split(/\r?\n/)) {
1775
1832
  const stripped = rawLine.trim();
1776
1833
  if (!stripped)
1777
1834
  continue;
1778
1835
  if (reMatch(_MD_SUB_HEADER_RE, stripped) || reMatch(_MD_TOP_HEADER_RE, stripped))
1779
1836
  continue; // headers ignored
1837
+ const reasonMatch = reMatch(_MD_STATE_REASON_RE, stripped);
1838
+ if (reasonMatch && currentStateDef !== null) {
1839
+ setStateReason(currentStateDef, reasonMatch[1]);
1840
+ continue;
1841
+ }
1780
1842
  const m = reMatch(_MD_ASSET_ENTRY_RE, stripped);
1781
- if (!m)
1843
+ if (!m) {
1844
+ currentStateDef = null;
1782
1845
  continue;
1846
+ }
1783
1847
  const name = m[1].trim();
1784
1848
  const description = (m[2] || "").trim() || null;
1785
1849
  if (!name)
@@ -1788,15 +1852,14 @@ export function parseAssetDoc(text, kind) {
1788
1852
  if (kind !== "speakers") {
1789
1853
  // Flat self-contained state: `- name|state: desc`.
1790
1854
  if (name.includes("|")) {
1791
- _md_push_state_def(stateDefs, stateKind, name, description);
1855
+ currentStateDef = _md_push_state_def(stateDefs, stateKind, name, description);
1792
1856
  continue;
1793
1857
  }
1794
1858
  // An indented bullet is always a state sub-bullet — never a top-level
1795
1859
  // asset. Attach to the most recent asset; if there is none yet (malformed
1796
1860
  // input), skip it rather than registering a spurious asset.
1797
1861
  if (indented) {
1798
- if (currentAsset)
1799
- _md_push_state_def(stateDefs, stateKind, `${currentAsset}|${name}`, description);
1862
+ currentStateDef = currentAsset ? _md_push_state_def(stateDefs, stateKind, `${currentAsset}|${name}`, description) : null;
1800
1863
  continue;
1801
1864
  }
1802
1865
  }
@@ -1810,9 +1873,11 @@ export function parseAssetDoc(text, kind) {
1810
1873
  const [sourceKind, displayName] = parseSpeakerAsset(name);
1811
1874
  if (displayName)
1812
1875
  speakers.push({ name: displayName, source_kind: sourceKind, description });
1876
+ currentStateDef = null;
1813
1877
  continue;
1814
1878
  }
1815
1879
  currentAsset = name;
1880
+ currentStateDef = null;
1816
1881
  }
1817
1882
  return { actors, locations, props, speakers, state_definitions: stateDefs };
1818
1883
  }
@@ -2074,6 +2139,24 @@ export function expandCompactEpisodeResult(result, episodePlan) {
2074
2139
  expanded["synopsis"] = synopsis;
2075
2140
  return expanded;
2076
2141
  }
2142
+ const COMPACT_EPISODE_CARRIED_LIST_KEYS = ["actors", "locations", "props", "speakers", "state_definitions"];
2143
+ function carryCompactEpisodeLists(result, compact) {
2144
+ for (const key of COMPACT_EPISODE_CARRIED_LIST_KEYS) {
2145
+ const items = result[key];
2146
+ if (!isList(items) || items.length === 0)
2147
+ continue;
2148
+ compact[key] = items.map((item) => {
2149
+ if (!isDict(item))
2150
+ return item;
2151
+ const cleaned = {};
2152
+ for (const [k, v] of Object.entries(item)) {
2153
+ if (v !== null && v !== undefined)
2154
+ cleaned[k] = v;
2155
+ }
2156
+ return cleaned;
2157
+ });
2158
+ }
2159
+ }
2077
2160
  export function compactEpisodeResult(result) {
2078
2161
  const compactScenes = [];
2079
2162
  for (const scene of asList(result["scenes"])) {
@@ -2174,6 +2257,7 @@ export function compactEpisodeResult(result) {
2174
2257
  compactScenes.push(compactScene);
2175
2258
  }
2176
2259
  const compacted = { sc: compactScenes };
2260
+ carryCompactEpisodeLists(result, compacted);
2177
2261
  // Carry the synopsis through the compact round-trip (short key `syn`). Without
2178
2262
  // this, any synopsis written upstream (## 梗概 → batch result, or the episode
2179
2263
  // reduce) would silently evaporate the moment the result is persisted.
@@ -2198,21 +2282,6 @@ export function compactBatchResult(result) {
2198
2282
  compactActions[j]["r"] = refs.map((r) => strOf(r).trim()).filter((r) => r);
2199
2283
  }
2200
2284
  }
2201
- for (const key of ["actors", "locations", "props", "speakers", "state_definitions"]) {
2202
- const items = result[key];
2203
- if (isList(items) && items.length > 0) {
2204
- compact[key] = items.map((item) => {
2205
- if (!isDict(item))
2206
- return item;
2207
- const cleaned = {};
2208
- for (const [k, v] of Object.entries(item)) {
2209
- if (v !== null && v !== undefined)
2210
- cleaned[k] = v;
2211
- }
2212
- return cleaned;
2213
- });
2214
- }
2215
- }
2216
2285
  return compact;
2217
2286
  }
2218
2287
  /**
@@ -2435,9 +2504,9 @@ export function normalizeEpisodeResult(result, episodePlan) {
2435
2504
  }
2436
2505
  scene["actions"] = actions;
2437
2506
  }
2438
- normalized["actors"] = _normalizeAssetList(normalized["actors"]);
2439
- normalized["locations"] = _normalizeAssetList(normalized["locations"]);
2440
- normalized["props"] = _normalizeAssetList(normalized["props"]);
2507
+ normalized["actors"] = _normalizeAssetList(normalized["actors"], "actor");
2508
+ normalized["locations"] = _normalizeAssetList(normalized["locations"], "location");
2509
+ normalized["props"] = _normalizeAssetList(normalized["props"], "prop");
2441
2510
  normalized["speakers"] = _normalize_speaker_list(normalized["speakers"]);
2442
2511
  normalized["state_definitions"] = _normalizeStateDefinitions(normalized["state_definitions"]);
2443
2512
  for (const scene of scenes) {
@@ -2453,7 +2522,7 @@ export function normalizeEpisodeResult(result, episodePlan) {
2453
2522
  }
2454
2523
  return normalized;
2455
2524
  }
2456
- function _normalizeAssetList(items) {
2525
+ function _normalizeAssetList(items, kind) {
2457
2526
  if (!isList(items))
2458
2527
  return [];
2459
2528
  const out = [];
@@ -2462,11 +2531,22 @@ function _normalizeAssetList(items) {
2462
2531
  let name = "";
2463
2532
  let description = null;
2464
2533
  if (isDict(item)) {
2465
- name = cleanName(strOf(item["name"]));
2534
+ const rawName = strOf(item["name"]);
2535
+ if (kind === "actor")
2536
+ [name] = splitEntityState(rawName);
2537
+ else if (kind === "location" || kind === "prop")
2538
+ [name] = splitLocationState(rawName);
2539
+ else
2540
+ name = cleanName(rawName);
2466
2541
  description = strOf(item["description"]).trim() || null;
2467
2542
  }
2468
2543
  else if (typeof item === "string") {
2469
- name = cleanName(item);
2544
+ if (kind === "actor")
2545
+ [name] = splitEntityState(item);
2546
+ else if (kind === "location" || kind === "prop")
2547
+ [name] = splitLocationState(item);
2548
+ else
2549
+ name = cleanName(item);
2470
2550
  }
2471
2551
  else
2472
2552
  continue;
@@ -2506,7 +2586,13 @@ export function _normalize_speaker_list(items) {
2506
2586
  const [parsedKind, parsedName] = parseSpeakerAsset(rawName);
2507
2587
  let kind = sourceKind || parsedKind;
2508
2588
  kind = _MD_SPEAKER_KIND_NORM[kind.toLowerCase()] ?? _MD_SPEAKER_KIND_NORM[kind] ?? "other";
2509
- const name = cleanName(parsedName);
2589
+ let name = "";
2590
+ if (kind === "actor")
2591
+ [name] = splitEntityState(parsedName);
2592
+ else if (kind === "location" || kind === "prop")
2593
+ [name] = splitLocationState(parsedName);
2594
+ else
2595
+ name = cleanName(parsedName);
2510
2596
  if (!name || seen.has(name))
2511
2597
  continue;
2512
2598
  seen.add(name);
@@ -2525,7 +2611,14 @@ function _normalizeStateDefinitions(items) {
2525
2611
  const kind = strOf(item["kind"] || item["target_kind"]).trim();
2526
2612
  if (!SCRIPT_TARGET_KINDS.has(kind))
2527
2613
  continue;
2528
- const assetName = cleanName(strOf(item["asset_name"] || item["name"]));
2614
+ const rawAssetName = strOf(item["asset_name"] || item["name"]);
2615
+ let assetName = "";
2616
+ if (kind === "actor")
2617
+ [assetName] = splitEntityState(rawAssetName);
2618
+ else if (kind === "location" || kind === "prop")
2619
+ [assetName] = splitLocationState(rawAssetName);
2620
+ else
2621
+ assetName = cleanName(rawAssetName);
2529
2622
  const stateName = strOf(item["state_name"] || item["state"]).trim();
2530
2623
  if (!assetName || !stateName || stateRejectionReason(kind, stateName))
2531
2624
  continue;
@@ -2533,12 +2626,16 @@ function _normalizeStateDefinitions(items) {
2533
2626
  if (seen.has(key))
2534
2627
  continue;
2535
2628
  seen.add(key);
2536
- out.push({
2629
+ const normalized = {
2537
2630
  kind,
2538
2631
  asset_name: assetName,
2539
2632
  state_name: stateName,
2540
2633
  description: strOf(item["description"]).trim() || null,
2541
- });
2634
+ };
2635
+ const stateReason = cleanStateReason(item["state_reason"]);
2636
+ if (stateReason)
2637
+ normalized["state_reason"] = stateReason;
2638
+ out.push(normalized);
2542
2639
  }
2543
2640
  return out;
2544
2641
  }
@@ -3819,17 +3916,31 @@ export function buildPrimaryAssetCurationChunks(script, kind, options = { maxIte
3819
3916
  }
3820
3917
  export function extractAssetGroupingExpressions(rawText) {
3821
3918
  const groups = [];
3919
+ const blocks = [];
3920
+ let currentBlock = [];
3822
3921
  const re = /<group>([\s\S]*?)<\/group>/g;
3823
3922
  let outside = "";
3824
3923
  let lastIndex = 0;
3825
3924
  let match;
3826
3925
  while ((match = re.exec(rawText)) !== null) {
3827
- outside += rawText.slice(lastIndex, match.index);
3828
- groups.push(strOf(match[1]).trim());
3926
+ const gap = rawText.slice(lastIndex, match.index);
3927
+ outside += gap;
3928
+ if (gap.trim() && currentBlock.length > 0) {
3929
+ blocks.push(currentBlock);
3930
+ currentBlock = [];
3931
+ }
3932
+ const expression = strOf(match[1]).trim();
3933
+ groups.push(expression);
3934
+ currentBlock.push(expression);
3829
3935
  lastIndex = match.index + match[0].length;
3830
3936
  }
3831
3937
  outside += rawText.slice(lastIndex);
3938
+ if (currentBlock.length > 0)
3939
+ blocks.push(currentBlock);
3832
3940
  if (outside.trim()) {
3941
+ const lastBlock = blocks[blocks.length - 1] ?? [];
3942
+ if (blocks.length > 1 && lastBlock.length > 0)
3943
+ return lastBlock;
3833
3944
  curationBlocked("Asset grouping response must contain only <group>...</group> lines.", [outside.trim().slice(0, 200)]);
3834
3945
  }
3835
3946
  if (groups.length === 0) {
@@ -4299,8 +4410,9 @@ function auditCandidateReason(script, decision, evidence, relatedById) {
4299
4410
  return "speaking_actor_removed_or_speaker";
4300
4411
  }
4301
4412
  if (action === "keep" && asset) {
4302
- if (hasEnumeratedActorName(strOf(asset["actor_name"])))
4413
+ if (hasEnumeratedActorName(strOf(asset["actor_name"])) && item.dialogueCount === 0 && item.speakingScenes.size === 0) {
4303
4414
  return "kept_enumerated_actor_review";
4415
+ }
4304
4416
  const identity = actorIdentityCandidates(script, sourceId, strOf(asset["actor_name"]), item);
4305
4417
  if (identity.length > 0)
4306
4418
  return "kept_actor_identity_or_form_review";
@@ -4379,7 +4491,8 @@ export function buildAssetCurationAuditContextText(script, primaryPlan, selected
4379
4491
  lines.push("- Output expressions only for listed candidates when the primary decision should be corrected or when the candidate is marked primary=MISSING.");
4380
4492
  lines.push("- Otherwise output exactly <exp>NOOP # no asset curation changes</exp>.");
4381
4493
  lines.push("- Audit expressions must reference source ids listed here. Existing primary sources are overridden; primary=MISSING and primary=IMPLICIT_KEEP sources are added.");
4382
- lines.push("- For primary KEEP actor candidates, output SPEAKER/MERGE/RENAME only when the kept actor is a generic numbered role, anonymous crowd, pure voice label, or duplicate identity.");
4494
+ lines.push("- For primary KEEP actor candidates, output SPEAKER/MERGE/RENAME only when the kept actor is an anonymous crowd, pure voice label, duplicate identity, or a generic numbered role without dialogue.");
4495
+ lines.push("- Do not downgrade a numbered/title-like actor to SPEAKER when it has dialogue or speaking-scene evidence; keep it as an actor unless it is truly a crowd label such as 众人/宾客们/围观者.");
4383
4496
  lines.push("- For primary KEEP location candidates, output RENAME/MERGE only when canonical scope or duplicate/parent-child granularity is wrong; otherwise leave it unchanged.");
4384
4497
  lines.push(`- For primary KEEP prop candidates, output DROP/MERGE/STATE/RENAME when the prop is too broad; output KEEP only to add/replace a valid category reason from ${propKeepCategoryList()}.`);
4385
4498
  lines.push("- For primary DROP/MERGE/STATE prop candidates, output KEEP/RENAME/MERGE only when standalone hero-prop continuity is stronger than the primary decision.");
@@ -4567,7 +4680,7 @@ export function assertLocationCanonicalizationPlan(rawPlan) {
4567
4680
  const invalid = [];
4568
4681
  for (const decision of normalizedCurationDecisions(rawPlan)) {
4569
4682
  const kind = strOf(decision["kind"]);
4570
- const action = strOf(decision["decision"]);
4683
+ let action = strOf(decision["decision"]);
4571
4684
  const sourceId = strOf(decision["source_id"]);
4572
4685
  if (kind !== "location" || (action !== "rename" && action !== "merge")) {
4573
4686
  invalid.push(`${kind || "<empty>"}:${sourceId || "<empty>"} ${action || "<empty>"}`);
@@ -5278,34 +5391,44 @@ export function combinePrimaryAssetCurationPlans(plans) {
5278
5391
  primary_part_expression_text: expressionTextByPlan.join("\n"),
5279
5392
  };
5280
5393
  }
5281
- export function combineAssetCurationPlans(primaryPlan, auditPlan, allowedNewAuditSources = []) {
5394
+ export function combineAssetCurationPlans(primaryPlan, auditPlan, allowedNewAuditSources = [], options = {}) {
5282
5395
  const primaryDecisions = asList(primaryPlan["decisions"]).filter(isDict);
5283
- const auditDecisions = asList(auditPlan["decisions"]).filter(isDict);
5396
+ const rawAuditDecisions = asList(auditPlan["decisions"]).filter(isDict);
5284
5397
  const primaryKeys = curationDecisionSourceSet(primaryDecisions);
5285
5398
  const allowedNewKeys = new Set(allowedNewAuditSources);
5286
5399
  const invalidAuditSources = [];
5287
5400
  let addedAuditDecisions = 0;
5288
- for (const decision of auditDecisions) {
5401
+ const auditDecisions = [];
5402
+ for (const decision of rawAuditDecisions) {
5289
5403
  const key = curationSourceKey(strOf(decision["kind"]), strOf(decision["source_id"]));
5290
- if (primaryKeys.has(key))
5404
+ if (primaryKeys.has(key)) {
5405
+ auditDecisions.push(decision);
5291
5406
  continue;
5407
+ }
5292
5408
  if (allowedNewKeys.has(key)) {
5293
5409
  addedAuditDecisions += 1;
5410
+ auditDecisions.push(decision);
5294
5411
  continue;
5295
5412
  }
5296
5413
  invalidAuditSources.push(key);
5297
5414
  }
5298
- if (invalidAuditSources.length > 0) {
5415
+ if (invalidAuditSources.length > 0 && !options.salvageInvalidAuditSources) {
5299
5416
  curationBlocked("Audit curation expressions may only override primary decision sources or listed missing required sources.", invalidAuditSources);
5300
5417
  }
5301
5418
  const primaryExpressions = asList(primaryPlan["expressions"]);
5302
- const auditExpressions = asList(auditPlan["expressions"]);
5419
+ const auditExpressions = invalidAuditSources.length > 0 && options.salvageInvalidAuditSources
5420
+ ? auditDecisions.map(renderAssetCurationDecisionExpression)
5421
+ : asList(auditPlan["expressions"]);
5422
+ const auditExpressionText = invalidAuditSources.length > 0 && options.salvageInvalidAuditSources
5423
+ ? formatAssetCurationDecisionExpressions({ decisions: auditDecisions })
5424
+ : strOf(auditPlan["expression_text"]);
5303
5425
  return {
5304
5426
  version: 3,
5305
5427
  format: "asset-curation-exp-v1",
5306
5428
  primary_expression_text: strOf(primaryPlan["expression_text"]),
5307
- audit_expression_text: strOf(auditPlan["expression_text"]),
5308
- expression_text: [strOf(primaryPlan["expression_text"]).trim(), strOf(auditPlan["expression_text"]).trim()].filter((text) => text).join("\n"),
5429
+ audit_expression_text: auditExpressionText,
5430
+ audit_raw_expression_text: invalidAuditSources.length > 0 && options.salvageInvalidAuditSources ? strOf(auditPlan["expression_text"]) : undefined,
5431
+ expression_text: [strOf(primaryPlan["expression_text"]).trim(), auditExpressionText.trim()].filter((text) => text).join("\n"),
5309
5432
  primary_expressions: primaryExpressions,
5310
5433
  audit_expressions: auditExpressions,
5311
5434
  expressions: [...primaryExpressions, ...auditExpressions],
@@ -5313,7 +5436,10 @@ export function combineAssetCurationPlans(primaryPlan, auditPlan, allowedNewAudi
5313
5436
  audit_summary: {
5314
5437
  primary_decisions: primaryDecisions.length,
5315
5438
  audit_decisions: auditDecisions.length,
5439
+ audit_raw_decisions: rawAuditDecisions.length,
5316
5440
  audit_added_decisions: addedAuditDecisions,
5441
+ audit_rejected_decisions: invalidAuditSources.length,
5442
+ audit_rejected_sources: invalidAuditSources,
5317
5443
  audit_noop: auditDecisions.length === 0,
5318
5444
  },
5319
5445
  };
@@ -5679,7 +5805,7 @@ function normalizeCurationPlanTargets(script, decisions) {
5679
5805
  normalizeMergeCycles(script, decisions);
5680
5806
  const decisionsBySource = buildCurationDecisionIndex(decisions);
5681
5807
  for (const decision of decisions) {
5682
- const action = strOf(decision["decision"]);
5808
+ let action = strOf(decision["decision"]);
5683
5809
  if (action !== "merge" && action !== "move_to_state" && action !== "move_to_prop")
5684
5810
  continue;
5685
5811
  const targetId = strOf(decision["target_id"]);
@@ -5829,6 +5955,71 @@ function rewriteCurationRefs(script, replacementsByKind, droppedByKind, stateRep
5829
5955
  }
5830
5956
  }
5831
5957
  }
5958
+ function normalizeNonActorSpeakersToActorSpeakers(script) {
5959
+ const actorIdByName = new Map();
5960
+ for (const actor of asList(script["actors"])) {
5961
+ if (!isDict(actor))
5962
+ continue;
5963
+ const name = strOf(actor["actor_name"]).trim();
5964
+ const id = strOf(actor["actor_id"]).trim();
5965
+ if (name && id)
5966
+ actorIdByName.set(name, id);
5967
+ }
5968
+ const actorSpeakerByActorId = new Map();
5969
+ for (const speaker of asList(script["speakers"])) {
5970
+ if (!isDict(speaker))
5971
+ continue;
5972
+ if (strOf(speaker["source_kind"]) !== "actor")
5973
+ continue;
5974
+ const sourceId = strOf(speaker["source_id"]).trim();
5975
+ const speakerId = strOf(speaker["speaker_id"]).trim();
5976
+ if (sourceId && speakerId)
5977
+ actorSpeakerByActorId.set(sourceId, speakerId);
5978
+ }
5979
+ const speakerReplacements = new Map();
5980
+ for (const speaker of asList(script["speakers"])) {
5981
+ if (!isDict(speaker))
5982
+ continue;
5983
+ if (strOf(speaker["source_kind"]) === "actor")
5984
+ continue;
5985
+ const speakerId = strOf(speaker["speaker_id"]).trim();
5986
+ const displayName = strOf(speaker["display_name"]).trim();
5987
+ const actorId = actorIdByName.get(displayName);
5988
+ const actorSpeakerId = actorId ? actorSpeakerByActorId.get(actorId) : "";
5989
+ if (speakerId && actorSpeakerId && speakerId !== actorSpeakerId)
5990
+ speakerReplacements.set(speakerId, actorSpeakerId);
5991
+ }
5992
+ if (speakerReplacements.size === 0)
5993
+ return { speaker_refs_normalized: 0, speakers_removed: 0 };
5994
+ let speakerRefsNormalized = 0;
5995
+ const rewriteSpeakerId = (value) => {
5996
+ const speakerId = strOf(value).trim();
5997
+ const replacement = speakerReplacements.get(speakerId);
5998
+ if (!replacement)
5999
+ return speakerId;
6000
+ speakerRefsNormalized += 1;
6001
+ return replacement;
6002
+ };
6003
+ for (const episode of asList(script["episodes"])) {
6004
+ for (const scene of asList(episode["scenes"])) {
6005
+ for (const action of asList(scene["actions"])) {
6006
+ if (!isDict(action))
6007
+ continue;
6008
+ const rewrittenSpeakerId = rewriteSpeakerId(action["speaker_id"]);
6009
+ if (rewrittenSpeakerId) {
6010
+ action["speaker_id"] = rewrittenSpeakerId;
6011
+ const actorId = rewrittenSpeakerId.startsWith("spk_act_") ? rewrittenSpeakerId.slice(4) : "";
6012
+ if (actorId)
6013
+ action["actor_id"] = actorId;
6014
+ }
6015
+ action["speakers"] = rewriteSpeakerRefs(action["speakers"], speakerReplacements);
6016
+ action["lines"] = rewriteSpeakerRefs(action["lines"], speakerReplacements);
6017
+ }
6018
+ }
6019
+ }
6020
+ script["speakers"] = asList(script["speakers"]).filter((speaker) => isDict(speaker) && !speakerReplacements.has(strOf(speaker["speaker_id"])));
6021
+ return { speaker_refs_normalized: speakerRefsNormalized, speakers_removed: speakerReplacements.size };
6022
+ }
5832
6023
  function copyStatesForMerge(script, kind, source, target, stateReplacements) {
5833
6024
  for (const state of asList(source["states"])) {
5834
6025
  const sourceStateId = strOf(state["state_id"]);
@@ -6019,6 +6210,7 @@ function applyAssetCurationDecisions(script, decisions, coverageSummary, default
6019
6210
  }
6020
6211
  }
6021
6212
  }
6213
+ const speakerNormalizationSummary = normalizeNonActorSpeakersToActorSpeakers(script);
6022
6214
  const actorsAfter = asList(script["actors"]).length;
6023
6215
  const locationsAfter = asList(script["locations"]).length;
6024
6216
  const propsAfter = asList(script["props"]).length;
@@ -6042,6 +6234,8 @@ function applyAssetCurationDecisions(script, decisions, coverageSummary, default
6042
6234
  default_policy: defaultPolicy,
6043
6235
  coverage: coverageSummary,
6044
6236
  actions: summarizeActionableCuration(decisions),
6237
+ speaker_refs_normalized: speakerNormalizationSummary["speaker_refs_normalized"],
6238
+ speakers_normalized_removed: speakerNormalizationSummary["speakers_removed"],
6045
6239
  },
6046
6240
  decisions,
6047
6241
  };
@@ -6201,6 +6395,12 @@ function stateCurationSourceKey(kind, assetId, stateId) {
6201
6395
  function stateCurationDecisionSourceKey(decision) {
6202
6396
  return stateCurationSourceKey(strOf(decision["kind"]), strOf(decision["asset_id"]), strOf(decision["state_id"]));
6203
6397
  }
6398
+ function stateCurationAssetSourceKey(kind, assetId) {
6399
+ return `${kind}:${assetId}`;
6400
+ }
6401
+ function stateCurationDefaultSelectionSourceKey(selection) {
6402
+ return stateCurationAssetSourceKey(strOf(selection["kind"]), strOf(selection["asset_id"]));
6403
+ }
6204
6404
  function stateCurationUsageFor(script) {
6205
6405
  const usage = new Map();
6206
6406
  const ensure = (kind, assetId, stateId) => {
@@ -6279,6 +6479,36 @@ function formatStateCurationUsage(usage) {
6279
6479
  return "refs=0 changes=0";
6280
6480
  return `refs=${usage.sceneRefs.size} changes=${usage.stateChanges.size}`;
6281
6481
  }
6482
+ function stateCurationClusterKey(stateName) {
6483
+ const name = strOf(stateName).replace(/[默认基础普通日常]/g, "").replace(/(?:状态|形态|造型|姿态|阶段|版本|版|态|装)$/g, "").trim();
6484
+ const cjk = /[\u4e00-\u9fff]{2,}/.exec(name);
6485
+ if (cjk)
6486
+ return cjk[0].slice(0, 2);
6487
+ const ascii = /[A-Za-z0-9]{3,}/.exec(name);
6488
+ return ascii ? ascii[0].slice(0, 6).toLowerCase() : "";
6489
+ }
6490
+ function formatStateCurationClusterHints(states) {
6491
+ const buckets = new Map();
6492
+ for (const state of states) {
6493
+ const stateId = compactStateCurationText(state["state_id"], 40);
6494
+ const stateName = compactStateCurationText(state["state_name"], 80);
6495
+ if (!stateId || !stateName || stateName === "默认")
6496
+ continue;
6497
+ const key = stateCurationClusterKey(stateName);
6498
+ if (!key)
6499
+ continue;
6500
+ if (!buckets.has(key))
6501
+ buckets.set(key, []);
6502
+ buckets.get(key).push(`${stateId}:${stateName}`);
6503
+ }
6504
+ const lines = [];
6505
+ for (const [key, members] of buckets) {
6506
+ if (members.length < 2)
6507
+ continue;
6508
+ lines.push(` C | similar_name_cluster=${key} | ${members.slice(0, 12).join(" ~ ")}`);
6509
+ }
6510
+ return lines;
6511
+ }
6282
6512
  function stateCurationPrefix(kind) {
6283
6513
  if (kind === "actor")
6284
6514
  return "A";
@@ -6302,8 +6532,10 @@ function formatStateCurationAssetBlock(script, ref, usageByKey) {
6302
6532
  if (!stateId || !stateName)
6303
6533
  continue;
6304
6534
  const usage = usageByKey.get(stateCurationSourceKey(ref.kind, ref.asset_id, stateId));
6305
- lines.push(` S | ${stateId} | ${stateName} | ${formatStateCurationUsage(usage)} | desc=${compactStateCurationText(state["description"], 180) || "-"}`);
6535
+ const stateReason = compactStateCurationText(state["state_reason"], 220);
6536
+ lines.push(` S | ${stateId} | ${stateName} | ${formatStateCurationUsage(usage)} | desc=${compactStateCurationText(state["description"], 180) || "-"} | reason=${stateReason || "-"}`);
6306
6537
  }
6538
+ lines.push(...formatStateCurationClusterHints(states));
6307
6539
  return lines;
6308
6540
  }
6309
6541
  export function buildStateCurationContextText(script, refs) {
@@ -6317,20 +6549,29 @@ export function buildStateCurationContextText(script, refs) {
6317
6549
  lines.push("");
6318
6550
  lines.push("Policy:");
6319
6551
  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.");
6552
+ lines.push("- KEEP only macro 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, a prop/location physical condition that must recur, or a location time/lighting state from a scene header such as 黄昏/夜晚/清晨.");
6553
+ lines.push("- Actor VFX/power intensity is not a separate state by itself: glowing, flashing, aura, light wall, brightness, totem spreading, transparent/solidifying/recovery frames, and one-action power bursts should MERGE into the nearest broad macro form or become ACTION_STATE.");
6554
+ lines.push("- Actor base human/humanoid shape is not a separate state when the default actor already covers it. Keep human-form states only when they carry a distinct age period, disguise, outfit, or major body redesign.");
6555
+ lines.push("- Prefer one broad macro state over many process frames. MERGE states that are only degree/progress variants of the same major appearance.");
6556
+ lines.push("- DROP or ACTION_STATE transient states: emotions, poses, unconscious/asleep/lying/sitting, holding/carrying an item, temporary glow/VFX intensity, fading/transparent/solidifying progress frames, minor mark brightness/thickness/spread, one-off ordinary clothing, camera effects, or scene-only actions.");
6557
+ lines.push("- Treat each `reason=` field as evidence text, not as a keep decision. Phrases like `needs a separate image`, `reusable`, or `stable` in the extraction reason are not enough to KEEP unless the name/description show a distinct macro form.");
6558
+ lines.push("- For one continuous transformation/recovery process, keep at most one broad representative state for the asset form. MERGE/DROP intermediate frames such as half-transparent body, lower body vanished, only head remains, partial solidifying, brightness increasing, or mark spreading.");
6323
6559
  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.");
6560
+ lines.push("- State descriptions must contain only durable visible appearance. Remove camera/staging/story wording such as back-to-camera, face hidden, close-up, initial pose, entering, taking hands, kissing, or standing in a scene.");
6561
+ lines.push("- When keeping a useful state whose description contains camera/staging/story wording, keep the state and rewrite the description with DESC.");
6324
6562
  lines.push("- Every S line below must receive exactly one expression.");
6563
+ lines.push("- Every asset block with at least one S line must also receive exactly one DEFAULT_STATE expression.");
6564
+ lines.push("- DEFAULT_STATE selects the most common normal appearance for that asset. Keep the selected state's readable state_name; only its state_id will become `default` during deterministic apply.");
6325
6565
  lines.push("- Use only state_id values listed under the same asset block. Do not invent ids or cross-asset merge targets.");
6326
6566
  lines.push("- If a broad canonical state name is needed, RENAME_STATE one existing representative state and MERGE_STATE the variants into it.");
6327
6567
  lines.push("");
6328
6568
  lines.push("Expression grammar:");
6329
- lines.push("- <exp>KEEP_STATE <kind> <asset_id> <state_id> # reason</exp>");
6569
+ lines.push("- <exp>KEEP_STATE <kind> <asset_id> <state_id> [DESC=\"<clean visual description>\"] # reason</exp>");
6330
6570
  lines.push("- <exp>MERGE_STATE <kind> <asset_id> <state_id> -> <target_state_id> # reason</exp>");
6331
6571
  lines.push("- <exp>DROP_STATE <kind> <asset_id> <state_id> # reason</exp>");
6332
6572
  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>");
6573
+ lines.push("- <exp>RENAME_STATE <kind> <asset_id> <state_id> \"<new_name>\" [DESC=\"<clean visual description>\"] # reason</exp>");
6574
+ lines.push("- <exp>DEFAULT_STATE <kind> <asset_id> <state_id> # reason</exp>");
6334
6575
  lines.push("- <kind> is one of actor, location, prop. Use double quotes for names; escape only \\\" and \\\\.");
6335
6576
  lines.push("");
6336
6577
  lines.push("Assets:");
@@ -6351,6 +6592,14 @@ export function buildStateCurationContextText(script, refs) {
6351
6592
  lines.push(`- ${stateCurationSourceKey(ref.kind, ref.asset_id, stateId)}`);
6352
6593
  }
6353
6594
  }
6595
+ lines.push("");
6596
+ lines.push("required_default_assets:");
6597
+ for (const ref of selectedRefs) {
6598
+ const asset = stateCurationAssetByRef(script, ref);
6599
+ if (!asset || asList(asset["states"]).filter(isDict).length === 0)
6600
+ continue;
6601
+ lines.push(`- ${stateCurationAssetSourceKey(ref.kind, ref.asset_id)}`);
6602
+ }
6354
6603
  return `${lines.join("\n")}\n`;
6355
6604
  }
6356
6605
  export function buildStateCurationChunks(script, options = {}) {
@@ -6444,9 +6693,22 @@ function parseStateCurationExpression(expression) {
6444
6693
  const { command, reason } = splitStateCurationExpressionReason(expression.trim());
6445
6694
  if (/^NOOP$/i.test(command))
6446
6695
  return null;
6447
- let match = /^KEEP_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)$/i.exec(command);
6696
+ let match = /^DEFAULT_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)$/i.exec(command);
6697
+ if (match) {
6698
+ return {
6699
+ kind: match[1].toLowerCase(),
6700
+ asset_id: match[2],
6701
+ state_id: match[3],
6702
+ decision: "default",
6703
+ reason,
6704
+ };
6705
+ }
6706
+ match = /^KEEP_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)(?:\s+DESC="(.*)")?$/i.exec(command);
6448
6707
  if (match) {
6449
- return { kind: match[1].toLowerCase(), asset_id: match[2], state_id: match[3], decision: "keep", reason };
6708
+ const decision = { kind: match[1].toLowerCase(), asset_id: match[2], state_id: match[3], decision: "keep", reason };
6709
+ if (match[4] !== undefined)
6710
+ decision["new_description"] = unquoteStateCurationExpressionString(match[4]);
6711
+ return decision;
6450
6712
  }
6451
6713
  match = /^MERGE_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)\s+->\s+(\S+)$/i.exec(command);
6452
6714
  if (match) {
@@ -6459,17 +6721,17 @@ function parseStateCurationExpression(expression) {
6459
6721
  reason,
6460
6722
  };
6461
6723
  }
6462
- match = /^DROP_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)$/i.exec(command);
6724
+ match = /^DROP_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)(?:\s+DESC=".*")?$/i.exec(command);
6463
6725
  if (match) {
6464
6726
  return { kind: match[1].toLowerCase(), asset_id: match[2], state_id: match[3], decision: "drop", reason };
6465
6727
  }
6466
- match = /^ACTION_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)$/i.exec(command);
6728
+ match = /^ACTION_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)(?:\s+DESC=".*")?$/i.exec(command);
6467
6729
  if (match) {
6468
6730
  return { kind: match[1].toLowerCase(), asset_id: match[2], state_id: match[3], decision: "action_desc", reason };
6469
6731
  }
6470
- match = /^RENAME_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)\s+"((?:\\.|[^"\\])*)"$/i.exec(command);
6732
+ match = /^RENAME_STATE\s+(actor|location|prop)\s+(\S+)\s+(\S+)\s+"((?:\\.|[^"\\])*)"(?:\s+DESC="(.*)")?$/i.exec(command);
6471
6733
  if (match) {
6472
- return {
6734
+ const decision = {
6473
6735
  kind: match[1].toLowerCase(),
6474
6736
  asset_id: match[2],
6475
6737
  state_id: match[3],
@@ -6477,23 +6739,18 @@ function parseStateCurationExpression(expression) {
6477
6739
  new_name: unquoteStateCurationExpressionString(match[4]),
6478
6740
  reason,
6479
6741
  };
6742
+ if (match[5] !== undefined)
6743
+ decision["new_description"] = unquoteStateCurationExpressionString(match[5]);
6744
+ return decision;
6480
6745
  }
6481
6746
  stateCurationBlocked("Unknown or malformed state curation expression.", [expression]);
6482
6747
  }
6483
6748
  function extractStateCurationExpressions(rawText) {
6484
6749
  const expressions = [];
6485
6750
  const re = /<exp>([\s\S]*?)<\/exp>/g;
6486
- let outside = "";
6487
- let lastIndex = 0;
6488
6751
  let match;
6489
6752
  while ((match = re.exec(rawText)) !== null) {
6490
- outside += rawText.slice(lastIndex, match.index);
6491
6753
  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
6754
  }
6498
6755
  if (expressions.length === 0) {
6499
6756
  stateCurationBlocked("State curation response did not contain any <exp> expressions.", [rawText.slice(0, 200)]);
@@ -6502,16 +6759,19 @@ function extractStateCurationExpressions(rawText) {
6502
6759
  }
6503
6760
  export function parseStateCurationExpressions(rawText) {
6504
6761
  const decisions = [];
6762
+ const defaultSelections = [];
6505
6763
  const expressions = extractStateCurationExpressions(rawText);
6506
6764
  let noop = false;
6507
6765
  for (const expression of expressions) {
6508
6766
  const decision = parseStateCurationExpression(expression);
6509
6767
  if (decision === null)
6510
6768
  noop = true;
6769
+ else if (strOf(decision["decision"]) === "default")
6770
+ defaultSelections.push(decision);
6511
6771
  else
6512
6772
  decisions.push(decision);
6513
6773
  }
6514
- if (noop && decisions.length > 0) {
6774
+ if (noop && (decisions.length > 0 || defaultSelections.length > 0)) {
6515
6775
  stateCurationBlocked("NOOP cannot be combined with actionable state curation expressions.", [rawText.slice(0, 200)]);
6516
6776
  }
6517
6777
  return {
@@ -6520,6 +6780,7 @@ export function parseStateCurationExpressions(rawText) {
6520
6780
  expression_text: rawText,
6521
6781
  expressions,
6522
6782
  decisions,
6783
+ default_selections: defaultSelections,
6523
6784
  };
6524
6785
  }
6525
6786
  function renderStateCurationDecisionExpression(decision) {
@@ -6528,23 +6789,35 @@ function renderStateCurationDecisionExpression(decision) {
6528
6789
  const stateId = strOf(decision["state_id"]);
6529
6790
  const reason = strOf(decision["reason"]).trim() || "state curation decision";
6530
6791
  const action = strOf(decision["decision"]);
6531
- if (action === "keep")
6532
- return `KEEP_STATE ${kind} ${assetId} ${stateId} # ${reason}`;
6792
+ if (action === "default")
6793
+ return `DEFAULT_STATE ${kind} ${assetId} ${stateId} # ${reason}`;
6533
6794
  if (action === "merge")
6534
6795
  return `MERGE_STATE ${kind} ${assetId} ${stateId} -> ${strOf(decision["target_state_id"])} # ${reason}`;
6535
6796
  if (action === "drop")
6536
6797
  return `DROP_STATE ${kind} ${assetId} ${stateId} # ${reason}`;
6537
6798
  if (action === "action_desc")
6538
6799
  return `ACTION_STATE ${kind} ${assetId} ${stateId} # ${reason}`;
6539
- if (action === "rename")
6540
- return `RENAME_STATE ${kind} ${assetId} ${stateId} ${quoteCurationExpressionString(decision["new_name"])} # ${reason}`;
6800
+ if (action === "keep") {
6801
+ const desc = Object.prototype.hasOwnProperty.call(decision, "new_description")
6802
+ ? ` DESC=${quoteCurationExpressionString(decision["new_description"])}`
6803
+ : "";
6804
+ return `KEEP_STATE ${kind} ${assetId} ${stateId}${desc} # ${reason}`;
6805
+ }
6806
+ if (action === "rename") {
6807
+ const desc = Object.prototype.hasOwnProperty.call(decision, "new_description")
6808
+ ? ` DESC=${quoteCurationExpressionString(decision["new_description"])}`
6809
+ : "";
6810
+ return `RENAME_STATE ${kind} ${assetId} ${stateId} ${quoteCurationExpressionString(decision["new_name"])}${desc} # ${reason}`;
6811
+ }
6541
6812
  stateCurationBlocked("Cannot render unknown state curation decision.", [`decision: ${action || "<empty>"}`]);
6542
6813
  }
6543
6814
  export function formatStateCurationDecisionExpressions(plan) {
6544
6815
  const decisions = asList(plan["decisions"]).filter(isDict);
6545
- if (decisions.length === 0)
6816
+ const defaultSelections = asList(plan["default_selections"]).filter(isDict);
6817
+ const expressions = [...decisions, ...defaultSelections].map((decision) => `<exp>${renderStateCurationDecisionExpression(decision)}</exp>`);
6818
+ if (expressions.length === 0)
6546
6819
  return "<exp>NOOP # no state curation changes</exp>";
6547
- return `${decisions.map((decision) => `<exp>${renderStateCurationDecisionExpression(decision)}</exp>`).join("\n")}\n`;
6820
+ return `${expressions.join("\n")}\n`;
6548
6821
  }
6549
6822
  function stateCurationAllSourceKeys(script) {
6550
6823
  const keys = [];
@@ -6586,6 +6859,28 @@ export function missingStateCurationRequiredSourceKeys(script, plan, requiredKey
6586
6859
  const decisions = stateCurationDecisionMap(asList(plan["decisions"]).filter(isDict));
6587
6860
  return keys.filter((key) => !decisions.has(key));
6588
6861
  }
6862
+ function stateCurationRequiredDefaultAssetKeys(script, requiredKeys) {
6863
+ if (requiredKeys && requiredKeys.length > 0) {
6864
+ const keys = new Set();
6865
+ for (const key of requiredKeys) {
6866
+ const match = /^(actor|location|prop):([^/]+)\//.exec(key);
6867
+ if (match)
6868
+ keys.add(stateCurationAssetSourceKey(match[1], match[2]));
6869
+ }
6870
+ return [...keys];
6871
+ }
6872
+ return statefulAssetsForCuration(script).map((ref) => stateCurationAssetSourceKey(ref.kind, ref.asset_id));
6873
+ }
6874
+ function stateCurationDefaultSelectionMap(selections) {
6875
+ const map = new Map();
6876
+ for (const selection of selections) {
6877
+ const key = stateCurationDefaultSelectionSourceKey(selection);
6878
+ if (map.has(key))
6879
+ stateCurationBlocked("State curation plan selects more than one default state for an asset.", [key]);
6880
+ map.set(key, selection);
6881
+ }
6882
+ return map;
6883
+ }
6589
6884
  function assertStateExistsForCuration(script, kind, assetId, stateId) {
6590
6885
  const asset = assertAssetExists(script, kind, assetId);
6591
6886
  for (const state of asList(asset["states"])) {
@@ -6602,11 +6897,25 @@ function validateStateCurationDecision(script, decision) {
6602
6897
  const stateId = strOf(decision["state_id"]);
6603
6898
  const action = strOf(decision["decision"]);
6604
6899
  const reason = strOf(decision["reason"]);
6900
+ const rewritesDescription = Object.prototype.hasOwnProperty.call(decision, "new_description");
6605
6901
  if (!assetId || !stateId)
6606
6902
  stateCurationBlocked("State curation decision is missing asset_id/state_id.", [`${kind}:${assetId}/${stateId}`]);
6607
6903
  if (!reason)
6608
6904
  stateCurationBlocked("State curation decision is missing reason.", [`${kind}:${assetId}/${stateId}`]);
6609
6905
  assertStateExistsForCuration(script, kind, assetId, stateId);
6906
+ if (action === "default") {
6907
+ if (rewritesDescription)
6908
+ stateCurationBlocked("DEFAULT_STATE cannot rewrite descriptions.", [`${kind}:${assetId}/${stateId}`]);
6909
+ return;
6910
+ }
6911
+ if (rewritesDescription) {
6912
+ const newDescription = strOf(decision["new_description"]).trim();
6913
+ if (!newDescription)
6914
+ stateCurationBlocked("State description rewrite requires a non-empty description.", [`${kind}:${assetId}/${stateId}`]);
6915
+ if (action !== "keep" && action !== "rename") {
6916
+ stateCurationBlocked("State description rewrite is only valid on KEEP_STATE or RENAME_STATE.", [`${kind}:${assetId}/${stateId}:${action}`]);
6917
+ }
6918
+ }
6610
6919
  if (action === "merge") {
6611
6920
  const targetStateId = strOf(decision["target_state_id"]);
6612
6921
  if (!targetStateId || targetStateId === stateId) {
@@ -6657,16 +6966,25 @@ function normalizeStateCurationMergeTargets(decisions) {
6657
6966
  export function assertStateCurationPlan(script, plan, requiredKeys) {
6658
6967
  const rawDecisions = asList(plan["decisions"]).filter(isDict);
6659
6968
  const decisions = [...stateCurationDecisionMap(rawDecisions).values()];
6969
+ const defaultSelections = asList(plan["default_selections"]).filter(isDict);
6660
6970
  for (const decision of decisions)
6661
6971
  validateStateCurationDecision(script, decision);
6972
+ for (const selection of defaultSelections)
6973
+ validateStateCurationDecision(script, selection);
6662
6974
  normalizeStateCurationMergeTargets(decisions);
6663
6975
  const missing = missingStateCurationRequiredSourceKeys(script, { decisions }, requiredKeys);
6664
6976
  if (missing.length > 0) {
6665
6977
  stateCurationBlocked("State curation plan is missing required state decisions.", missing.slice(0, 80));
6666
6978
  }
6979
+ const defaultMap = stateCurationDefaultSelectionMap(defaultSelections);
6980
+ const missingDefaults = stateCurationRequiredDefaultAssetKeys(script, requiredKeys).filter((key) => !defaultMap.has(key));
6981
+ if (missingDefaults.length > 0) {
6982
+ stateCurationBlocked("State curation plan is missing required DEFAULT_STATE selections.", missingDefaults.slice(0, 80));
6983
+ }
6667
6984
  return {
6668
6985
  ...plan,
6669
6986
  decisions,
6987
+ default_selections: [...defaultMap.values()],
6670
6988
  };
6671
6989
  }
6672
6990
  function mapStateForCuration(kind, assetId, stateId, replacements, removed) {
@@ -6753,9 +7071,115 @@ function rewriteRefsAfterStateCuration(script, replacements, removed) {
6753
7071
  state_changes_rewritten: stateChangesRewritten,
6754
7072
  };
6755
7073
  }
7074
+ function appendStateCurationMergedFrom(script, kind, assetId, sourceStateId, targetStateId, reason) {
7075
+ const source = assertStateExistsForCuration(script, kind, assetId, sourceStateId);
7076
+ const target = assertStateExistsForCuration(script, kind, assetId, targetStateId);
7077
+ const sourceName = strOf(source["state_name"]).trim();
7078
+ if (!sourceName)
7079
+ return;
7080
+ const existing = asList(target["merged_from"]).filter(isDict);
7081
+ if (existing.some((item) => item["state_name"] === sourceName))
7082
+ return;
7083
+ const next = [
7084
+ ...existing,
7085
+ {
7086
+ state_name: sourceName,
7087
+ reason: cleanStateReason(reason),
7088
+ },
7089
+ ].slice(0, MERGED_FROM_MAX_ITEMS);
7090
+ target["merged_from"] = next;
7091
+ }
7092
+ function synthesizedDefaultState(asset) {
7093
+ const state = {
7094
+ state_id: "default",
7095
+ state_name: "默认",
7096
+ };
7097
+ const description = strOf(asset["description"]).trim();
7098
+ if (description)
7099
+ state["description"] = description;
7100
+ return state;
7101
+ }
7102
+ function canonicalizeDefaultStatesAfterCuration(script, defaultSelections, replacements, removed) {
7103
+ const selectionByAsset = stateCurationDefaultSelectionMap([...defaultSelections]);
7104
+ const defaultReplacements = new Map();
7105
+ const selectedDefaults = [];
7106
+ let synthesized = 0;
7107
+ let rewritten = 0;
7108
+ for (const kind of ["actor", "location", "prop"]) {
7109
+ for (const asset of asList(script[assetListKey(kind)])) {
7110
+ if (!isDict(asset))
7111
+ continue;
7112
+ const assetId = strOf(asset[assetIdKey(kind)]);
7113
+ if (!assetId)
7114
+ continue;
7115
+ const assetKey = stateCurationAssetSourceKey(kind, assetId);
7116
+ const states = asList(asset["states"]).filter(isDict);
7117
+ if (states.length === 0) {
7118
+ asset["states"] = [synthesizedDefaultState(asset)];
7119
+ synthesized += 1;
7120
+ selectedDefaults.push({
7121
+ kind,
7122
+ asset_id: assetId,
7123
+ input_state_id: null,
7124
+ final_state_id: "default",
7125
+ state_name: "默认",
7126
+ synthesized: true,
7127
+ reason: "asset has no surviving explicit states",
7128
+ });
7129
+ continue;
7130
+ }
7131
+ const selection = selectionByAsset.get(assetKey);
7132
+ if (!selection) {
7133
+ stateCurationBlocked("State curation default canonicalization is missing a DEFAULT_STATE selection.", [assetKey]);
7134
+ }
7135
+ const inputStateId = strOf(selection["state_id"]);
7136
+ const selectedStateId = mapStateForCuration(kind, assetId, inputStateId, replacements, removed);
7137
+ if (!selectedStateId) {
7138
+ stateCurationBlocked("DEFAULT_STATE selection was removed while other states survived.", [
7139
+ `${assetKey}/${inputStateId}`,
7140
+ ]);
7141
+ }
7142
+ const selectedState = states.find((state) => strOf(state["state_id"]) === selectedStateId);
7143
+ if (!selectedState) {
7144
+ stateCurationBlocked("DEFAULT_STATE selection does not resolve to a surviving state.", [
7145
+ `${assetKey}/${inputStateId} -> ${selectedStateId}`,
7146
+ ]);
7147
+ }
7148
+ const existingDefault = states.find((state) => strOf(state["state_id"]) === "default");
7149
+ if (existingDefault && existingDefault !== selectedState) {
7150
+ stateCurationBlocked("State curation would create two `default` states for one asset.", [
7151
+ `${assetKey}: selected=${selectedStateId}`,
7152
+ ]);
7153
+ }
7154
+ if (selectedStateId !== "default") {
7155
+ selectedState["state_id"] = "default";
7156
+ defaultReplacements.set(stateCurationSourceKey(kind, assetId, selectedStateId), "default");
7157
+ rewritten += 1;
7158
+ }
7159
+ selectedDefaults.push({
7160
+ kind,
7161
+ asset_id: assetId,
7162
+ input_state_id: inputStateId,
7163
+ final_state_id: "default",
7164
+ original_final_state_id: selectedStateId,
7165
+ state_name: selectedState["state_name"],
7166
+ synthesized: false,
7167
+ reason: selection["reason"],
7168
+ });
7169
+ }
7170
+ }
7171
+ const rewriteSummary = rewriteRefsAfterStateCuration(script, defaultReplacements, new Set());
7172
+ return {
7173
+ default_states_synthesized: synthesized,
7174
+ default_state_ids_rewritten: rewritten,
7175
+ default_ref_rewrite: rewriteSummary,
7176
+ selected_default_states: selectedDefaults,
7177
+ };
7178
+ }
6756
7179
  export function applyStateCurationPlan(script, rawPlan) {
6757
7180
  const plan = assertStateCurationPlan(script, rawPlan);
6758
7181
  const decisions = asList(plan["decisions"]).filter(isDict);
7182
+ const defaultSelections = asList(plan["default_selections"]).filter(isDict);
6759
7183
  const beforeByKind = { actor: 0, location: 0, prop: 0 };
6760
7184
  const afterByKind = { actor: 0, location: 0, prop: 0 };
6761
7185
  for (const kind of ["actor", "location", "prop"]) {
@@ -6770,6 +7194,7 @@ export function applyStateCurationPlan(script, rawPlan) {
6770
7194
  drop: 0,
6771
7195
  action_desc: 0,
6772
7196
  rename: 0,
7197
+ description_rewrite: 0,
6773
7198
  };
6774
7199
  for (const decision of decisions) {
6775
7200
  const kind = strOf(decision["kind"]);
@@ -6777,13 +7202,29 @@ export function applyStateCurationPlan(script, rawPlan) {
6777
7202
  const stateId = strOf(decision["state_id"]);
6778
7203
  const key = stateCurationSourceKey(kind, assetId, stateId);
6779
7204
  const action = strOf(decision["decision"]);
7205
+ const inputState = assertStateExistsForCuration(script, kind, assetId, stateId);
7206
+ const inputStateName = strOf(inputState["state_name"]).trim();
7207
+ if (inputStateName)
7208
+ decision["input_state_name"] = inputStateName;
7209
+ const inputStateReason = cleanStateReason(inputState["state_reason"]);
7210
+ if (inputStateReason)
7211
+ decision["input_state_reason"] = inputStateReason;
6780
7212
  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();
7213
+ if (action === "keep" || action === "rename") {
7214
+ const state = inputState;
7215
+ if (action === "rename")
7216
+ state["state_name"] = strOf(decision["new_name"]).trim();
7217
+ if (Object.prototype.hasOwnProperty.call(decision, "new_description")) {
7218
+ const newDescription = strOf(decision["new_description"]).trim();
7219
+ if (strOf(state["description"]) !== newDescription)
7220
+ decisionSummary["description_rewrite"] = Number(decisionSummary["description_rewrite"] ?? 0) + 1;
7221
+ state["description"] = newDescription;
7222
+ }
6784
7223
  }
6785
7224
  else if (action === "merge") {
6786
- replacements.set(key, strOf(decision["target_state_id"]));
7225
+ const targetStateId = strOf(decision["target_state_id"]);
7226
+ appendStateCurationMergedFrom(script, kind, assetId, stateId, targetStateId, strOf(decision["reason"]));
7227
+ replacements.set(key, targetStateId);
6787
7228
  removed.add(key);
6788
7229
  }
6789
7230
  else if (action === "drop" || action === "action_desc") {
@@ -6802,18 +7243,23 @@ export function applyStateCurationPlan(script, rawPlan) {
6802
7243
  const key = stateCurationSourceKey(kind, assetId, strOf(state["state_id"]));
6803
7244
  return !removed.has(key);
6804
7245
  });
6805
- afterByKind[kind] += asList(asset["states"]).filter(isDict).length;
6806
7246
  }
6807
7247
  }
7248
+ const defaultSummary = canonicalizeDefaultStatesAfterCuration(script, defaultSelections, replacements, removed);
7249
+ for (const kind of ["actor", "location", "prop"]) {
7250
+ for (const asset of asList(script[assetListKey(kind)]))
7251
+ afterByKind[kind] += asList(asset["states"]).filter(isDict).length;
7252
+ }
6808
7253
  const statesBefore = beforeByKind.actor + beforeByKind.location + beforeByKind.prop;
6809
7254
  const statesAfter = afterByKind.actor + afterByKind.location + afterByKind.prop;
7255
+ const synthesizedDefaults = Number(defaultSummary["default_states_synthesized"] ?? 0);
6810
7256
  return {
6811
7257
  version: 1,
6812
7258
  format: "state-curation-exp-v1",
6813
7259
  summary: {
6814
7260
  states_before: statesBefore,
6815
7261
  states_after: statesAfter,
6816
- states_removed: statesBefore - statesAfter,
7262
+ states_removed: statesBefore - statesAfter + synthesizedDefaults,
6817
7263
  actor_states_before: beforeByKind.actor,
6818
7264
  actor_states_after: afterByKind.actor,
6819
7265
  location_states_before: beforeByKind.location,
@@ -6822,8 +7268,13 @@ export function applyStateCurationPlan(script, rawPlan) {
6822
7268
  prop_states_after: afterByKind.prop,
6823
7269
  decisions: decisionSummary,
6824
7270
  ...rewriteSummary,
7271
+ default_states_synthesized: synthesizedDefaults,
7272
+ default_state_ids_rewritten: defaultSummary["default_state_ids_rewritten"],
7273
+ default_ref_rewrite: defaultSummary["default_ref_rewrite"],
6825
7274
  },
6826
7275
  decisions,
7276
+ default_selections: defaultSelections,
7277
+ selected_default_states: defaultSummary["selected_default_states"],
6827
7278
  };
6828
7279
  }
6829
7280
  // ---------------------------------------------------------------------------
@@ -6879,29 +7330,35 @@ function stateNameById(asset, stateId) {
6879
7330
  }
6880
7331
  function bindableDefaultStateId(asset) {
6881
7332
  const states = asList(asset["states"]);
6882
- if (states.length === 0)
6883
- return "";
6884
- const defaultState = states.find((state) => strOf(state["state_name"]).trim() === "默认");
7333
+ const defaultState = states.find((state) => strOf(state["state_id"]).trim() === "default");
6885
7334
  if (defaultState)
6886
7335
  return strOf(defaultState["state_id"]);
6887
- if (states.length === 1)
6888
- return strOf(states[0]["state_id"]);
6889
7336
  return "";
6890
7337
  }
6891
7338
  export function ensureDefaultStates(script) {
6892
- let added = 0;
7339
+ let synthesized = 0;
7340
+ let missing = 0;
6893
7341
  for (const kind of ["actor", "location", "prop"]) {
6894
7342
  for (const asset of asList(script[assetListKey(kind)])) {
6895
- const states = asList(asset["states"]);
6896
- if (states.length <= 1)
6897
- continue;
6898
- if (states.some((state) => strOf(state["state_name"]).trim() === "默认"))
7343
+ if (!isDict(asset))
6899
7344
  continue;
6900
- appendState(asset, script, "默认", asset["description"]);
6901
- added += 1;
7345
+ const states = asList(asset["states"]).filter(isDict);
7346
+ const defaults = states.filter((state) => strOf(state["state_id"]).trim() === "default");
7347
+ if (states.length === 0) {
7348
+ asset["states"] = [synthesizedDefaultState(asset)];
7349
+ synthesized += 1;
7350
+ }
7351
+ else if (defaults.length === 0) {
7352
+ missing += 1;
7353
+ }
7354
+ else if (defaults.length > 1) {
7355
+ stateBindingBlocked("Asset has more than one `default` state.", [
7356
+ `${kind}:${strOf(asset[assetIdKey(kind)])}`,
7357
+ ]);
7358
+ }
6902
7359
  }
6903
7360
  }
6904
- return { default_states_added: added };
7361
+ return { default_states_synthesized: synthesized, assets_missing_default_state: missing };
6905
7362
  }
6906
7363
  export function bindDefaultStateRefsInScript(script) {
6907
7364
  const assetMaps = {
@@ -6936,7 +7393,7 @@ export function bindDefaultStateRefsInScript(script) {
6936
7393
  setSceneContext(scene, ctx);
6937
7394
  }
6938
7395
  }
6939
- return { default_or_unique_refs_bound: bound, unresolved_stateful_refs: unresolved };
7396
+ return { default_refs_bound: bound, unresolved_stateful_refs: unresolved };
6940
7397
  }
6941
7398
  function compactStateBindingAsset(kind, asset) {
6942
7399
  return {
@@ -7036,7 +7493,8 @@ export function buildStateBindingContexts(script, chunkSize = 10) {
7036
7493
  title: script["title"],
7037
7494
  policy: [
7038
7495
  "Bind each scene asset ref to the best existing state_id when the actions/context show a durable visual state.",
7039
- "Use null only when no existing state fits and no default/unique state should apply.",
7496
+ "Each asset should already have an asset-scoped state_id `default`; keep current_state_id unless actions prove a non-default state.",
7497
+ "Use null only when no existing state fits and current_state_id is already null.",
7040
7498
  "Emit state_changes only for explicit transformation, outfit, damage, breakage, repair, or form-change actions.",
7041
7499
  "Do not invent new states or assets.",
7042
7500
  ],
@@ -7255,18 +7713,27 @@ function splitLocationStatePhrase(rawName) {
7255
7713
  return null;
7256
7714
  return { name: locationName, state };
7257
7715
  }
7258
- function addStateDefinition(stateDefinitions, seen, kind, assetName, stateName, description = null) {
7716
+ function addStateDefinition(stateDefinitions, seen, kind, assetName, stateName, description = null, stateReason = "") {
7259
7717
  const state = strOf(stateName).trim();
7260
7718
  if (!assetName || !state || stateRejectionReason(kind, state))
7261
7719
  return;
7262
7720
  const key = `${kind}::${assetName}::${state}`;
7263
- if (seen.has(key))
7721
+ if (seen.has(key)) {
7722
+ const existing = stateDefinitions.find((item) => item["kind"] === kind && item["asset_name"] === assetName && item["state_name"] === state);
7723
+ if (existing) {
7724
+ const desc = strOf(description).trim();
7725
+ if (desc && !existing["description"])
7726
+ existing["description"] = desc;
7727
+ setStateReason(existing, stateReason);
7728
+ }
7264
7729
  return;
7730
+ }
7265
7731
  seen.add(key);
7266
7732
  const item = { kind, asset_name: assetName, state_name: state };
7267
7733
  const desc = strOf(description).trim();
7268
7734
  if (desc)
7269
7735
  item["description"] = desc;
7736
+ setStateReason(item, stateReason);
7270
7737
  stateDefinitions.push(item);
7271
7738
  }
7272
7739
  function rawStringMap(value) {
@@ -7280,31 +7747,78 @@ function rawStringMap(value) {
7280
7747
  }
7281
7748
  return out;
7282
7749
  }
7750
+ const ACTOR_VOICE_SUFFIX_RE = /\s*(?:VO|V\.O\.|OS|O\.S\.|旁白|画外音|内心独白|内心)\s*$/i;
7751
+ const LEADING_ACTION_ACTOR_PREFIX_RE = /^\s*([^::]{1,24})[::]\s*(\S[\s\S]*)$/;
7752
+ function actorVoiceDisplayName(speaker, actorRegistry, report) {
7753
+ const raw = cleanName(speaker);
7754
+ if (!raw || !ACTOR_VOICE_SUFFIX_RE.test(raw))
7755
+ return null;
7756
+ const base = cleanName(raw.replace(ACTOR_VOICE_SUFFIX_RE, ""));
7757
+ if (!base || base === raw)
7758
+ return null;
7759
+ if (inferNonActorSpeakerKind(base) !== "actor")
7760
+ return null;
7761
+ return canonicalAssetDisplay(actorRegistry, base, report);
7762
+ }
7763
+ function actionPrefixActorDisplayName(prefix, actorRegistry) {
7764
+ const raw = cleanName(prefix);
7765
+ if (!raw)
7766
+ return "";
7767
+ const direct = actorRegistry.displayByKey.get(canonicalAssetNameKey("actor", raw));
7768
+ if (direct)
7769
+ return direct;
7770
+ if (!hasEnumeratedActorName(raw))
7771
+ return "";
7772
+ const base = cleanName(raw.replace(/(?:[甲乙丙丁戊己庚辛壬癸]|[A-Z]|[0-9]+)$/u, ""));
7773
+ if (!base || base === raw)
7774
+ return "";
7775
+ return actorRegistry.displayByKey.get(canonicalAssetNameKey("actor", base)) || "";
7776
+ }
7283
7777
  function normalizeSpeakerNameInAction(action, actorRegistry, locationRegistry, propRegistry, report) {
7284
7778
  const item = { ...action };
7285
7779
  const normalizeByKind = (speaker, speakerKind) => {
7780
+ const actorVoice = actorVoiceDisplayName(speaker, actorRegistry, report);
7781
+ if (actorVoice)
7782
+ return { speaker: actorVoice, speaker_kind: "actor" };
7286
7783
  const kind = strOf(speakerKind || "actor").trim() || "actor";
7287
7784
  if (kind === "actor")
7288
- return canonicalAssetDisplay(actorRegistry, speaker, report);
7785
+ return { speaker: canonicalAssetDisplay(actorRegistry, speaker, report), speaker_kind: "actor" };
7289
7786
  if (kind === "location")
7290
- return canonicalAssetDisplay(locationRegistry, speaker, report);
7787
+ return { speaker: canonicalAssetDisplay(locationRegistry, speaker, report), speaker_kind: "location" };
7291
7788
  if (kind === "prop")
7292
- return canonicalAssetDisplay(propRegistry, speaker, report);
7293
- return cleanName(speaker);
7789
+ return { speaker: canonicalAssetDisplay(propRegistry, speaker, report), speaker_kind: "prop" };
7790
+ return { speaker: cleanName(speaker), speaker_kind: kind };
7294
7791
  };
7295
- if (item["speaker"])
7296
- item["speaker"] = normalizeByKind(item["speaker"], item["speaker_kind"]);
7792
+ if (item["speaker"]) {
7793
+ const normalized = normalizeByKind(item["speaker"], item["speaker_kind"]);
7794
+ item["speaker"] = normalized.speaker;
7795
+ if (normalized.speaker_kind === "actor")
7796
+ delete item["speaker_kind"];
7797
+ else
7798
+ item["speaker_kind"] = normalized.speaker_kind;
7799
+ }
7297
7800
  if (isList(item["speakers"])) {
7298
- item["speakers"] = asList(item["speakers"]).map((speaker) => ({
7299
- ...speaker,
7300
- speaker: normalizeByKind(speaker["speaker"], speaker["speaker_kind"]),
7301
- }));
7801
+ item["speakers"] = asList(item["speakers"]).map((speaker) => {
7802
+ const normalized = normalizeByKind(speaker["speaker"], speaker["speaker_kind"]);
7803
+ return { ...speaker, speaker: normalized.speaker, speaker_kind: normalized.speaker_kind };
7804
+ });
7302
7805
  }
7303
7806
  if (isList(item["lines"])) {
7304
- item["lines"] = asList(item["lines"]).map((line) => ({
7305
- ...line,
7306
- speaker: normalizeByKind(line["speaker"], line["speaker_kind"]),
7307
- }));
7807
+ item["lines"] = asList(item["lines"]).map((line) => {
7808
+ const normalized = normalizeByKind(line["speaker"], line["speaker_kind"]);
7809
+ return { ...line, speaker: normalized.speaker, speaker_kind: normalized.speaker_kind };
7810
+ });
7811
+ }
7812
+ if (strOf(item["type"]) === "action") {
7813
+ const content = strOf(item["content"]).trim();
7814
+ const prefixed = LEADING_ACTION_ACTOR_PREFIX_RE.exec(content);
7815
+ if (prefixed) {
7816
+ const rawPrefix = cleanName(prefixed[1]);
7817
+ const rest = strOf(prefixed[2]).trim();
7818
+ const actorName = actionPrefixActorDisplayName(rawPrefix, actorRegistry);
7819
+ if (actorName && rest)
7820
+ item["content"] = `${actorName}${rest}`;
7821
+ }
7308
7822
  }
7309
7823
  return item;
7310
7824
  }
@@ -7340,10 +7854,13 @@ function normalizeEpisodeResultsForAssets(results, opts) {
7340
7854
  for (const asset of asList(result["actors"])) {
7341
7855
  if (!isDict(asset))
7342
7856
  continue;
7343
- const rawName = cleanName(asset["name"]);
7857
+ const [rawName, stateFromName] = splitEntityState(asset["name"]);
7344
7858
  const name = canonicalAssetDisplay(actorRegistry, rawName, report);
7345
7859
  if (name)
7346
7860
  normalized["actors"].push({ ...asset, name });
7861
+ if (name && stateFromName) {
7862
+ addStateDefinition(globalStateDefinitions, seenStateDefinitions, "actor", name, stateFromName, asset["description"]);
7863
+ }
7347
7864
  }
7348
7865
  for (const asset of asList(result["locations"])) {
7349
7866
  if (!isDict(asset))
@@ -7375,7 +7892,11 @@ function normalizeEpisodeResultsForAssets(results, opts) {
7375
7892
  if (!isDict(speaker))
7376
7893
  continue;
7377
7894
  const sourceKind = strOf(speaker["source_kind"] || "other").trim();
7378
- if (sourceKind === "prop") {
7895
+ const actorVoice = actorVoiceDisplayName(speaker["name"], actorRegistry, report);
7896
+ if (actorVoice) {
7897
+ normalized["actors"].push({ name: actorVoice });
7898
+ }
7899
+ else if (sourceKind === "prop") {
7379
7900
  normalized["speakers"].push({
7380
7901
  ...speaker,
7381
7902
  name: canonicalAssetDisplay(propRegistry, speaker["name"], report),
@@ -7388,9 +7909,10 @@ function normalizeEpisodeResultsForAssets(results, opts) {
7388
7909
  });
7389
7910
  }
7390
7911
  else if (sourceKind === "actor") {
7912
+ const [speakerName] = splitEntityState(speaker["name"]);
7391
7913
  normalized["speakers"].push({
7392
7914
  ...speaker,
7393
- name: canonicalAssetDisplay(actorRegistry, speaker["name"], report),
7915
+ name: canonicalAssetDisplay(actorRegistry, speakerName, report),
7394
7916
  });
7395
7917
  }
7396
7918
  else {
@@ -7404,14 +7926,18 @@ function normalizeEpisodeResultsForAssets(results, opts) {
7404
7926
  if (kind !== "actor" && kind !== "location" && kind !== "prop")
7405
7927
  continue;
7406
7928
  if (kind === "actor") {
7407
- const assetName = canonicalAssetDisplay(actorRegistry, stateDef["asset_name"], report);
7408
- addStateDefinition(globalStateDefinitions, seenStateDefinitions, "actor", assetName, stateDef["state_name"], stateDef["description"]);
7929
+ const [assetBaseName, stateFromAssetName] = splitEntityState(stateDef["asset_name"]);
7930
+ const assetName = canonicalAssetDisplay(actorRegistry, assetBaseName, report);
7931
+ if (stateFromAssetName) {
7932
+ addStateDefinition(globalStateDefinitions, seenStateDefinitions, "actor", assetName, stateFromAssetName, stateDef["description"], stateDef["state_reason"]);
7933
+ }
7934
+ addStateDefinition(globalStateDefinitions, seenStateDefinitions, "actor", assetName, stateDef["state_name"], stateDef["description"], stateDef["state_reason"]);
7409
7935
  }
7410
7936
  else if (kind === "location") {
7411
7937
  const split = splitLocationStatePhrase(stateDef["asset_name"]);
7412
7938
  const assetName = canonicalAssetDisplay(locationRegistry, split ? split.name : stateDef["asset_name"], report);
7413
7939
  if (split) {
7414
- addStateDefinition(globalStateDefinitions, seenStateDefinitions, "location", assetName, split.state, stateDef["description"]);
7940
+ addStateDefinition(globalStateDefinitions, seenStateDefinitions, "location", assetName, split.state, stateDef["description"], stateDef["state_reason"]);
7415
7941
  addNormalizationDecision(report, {
7416
7942
  action: "location_state_from_name",
7417
7943
  kind: "location",
@@ -7421,11 +7947,11 @@ function normalizeEpisodeResultsForAssets(results, opts) {
7421
7947
  reason: "地点状态定义的资产名含时间状态,拆成稳定地点 + 状态。",
7422
7948
  });
7423
7949
  }
7424
- addStateDefinition(globalStateDefinitions, seenStateDefinitions, "location", assetName, stateDef["state_name"], stateDef["description"]);
7950
+ addStateDefinition(globalStateDefinitions, seenStateDefinitions, "location", assetName, stateDef["state_name"], stateDef["description"], stateDef["state_reason"]);
7425
7951
  }
7426
7952
  else {
7427
7953
  const assetName = canonicalAssetDisplay(propRegistry, stateDef["asset_name"], report);
7428
- addStateDefinition(globalStateDefinitions, seenStateDefinitions, "prop", assetName, stateDef["state_name"], stateDef["description"]);
7954
+ addStateDefinition(globalStateDefinitions, seenStateDefinitions, "prop", assetName, stateDef["state_name"], stateDef["description"], stateDef["state_reason"]);
7429
7955
  }
7430
7956
  }
7431
7957
  const scenes = [];
@@ -7469,12 +7995,12 @@ function normalizeEpisodeResultsForAssets(results, opts) {
7469
7995
  const propNames = [];
7470
7996
  const normalizedPropStates = {};
7471
7997
  for (const rawActor of asList(scene["actor_names"])) {
7472
- const cleanActor = cleanName(rawActor);
7998
+ const [cleanActor, stateFromName] = splitEntityState(rawActor);
7473
7999
  const actorName = canonicalAssetDisplay(actorRegistry, cleanActor, report);
7474
8000
  if (!actorName)
7475
8001
  continue;
7476
8002
  uniqueAdd(actorNames, actorName);
7477
- const state = actorStates[cleanActor] || actorStates[actorName] || "";
8003
+ const state = actorStates[cleanActor] || actorStates[actorName] || stateFromName || "";
7478
8004
  if (!state)
7479
8005
  continue;
7480
8006
  normalizedActorStates[actorName] = state;
@@ -7689,6 +8215,12 @@ function mergeEpisodeResultsInternal(results, title, opts = {}) {
7689
8215
  for (const name of speakerOrder) {
7690
8216
  const record = speakerRecords.get(name) ?? { source_kind: "other" };
7691
8217
  let sourceKind = record.source_kind || "other";
8218
+ if ((sourceKind === "system" || sourceKind === "broadcast" || sourceKind === "other") && actorIds.has(name)) {
8219
+ const speakerId = `spk_${actorIds.get(name)}`;
8220
+ speakerIdsByName.set(`${sourceKind}::${name}`, speakerId);
8221
+ speakerIdsByName.set(`${record.source_kind}::${name}`, speakerId);
8222
+ continue;
8223
+ }
7692
8224
  let sourceId = null;
7693
8225
  if (sourceKind === "prop")
7694
8226
  sourceId = propIds.get(name) ?? null;
@@ -7719,27 +8251,58 @@ function mergeEpisodeResultsInternal(results, title, opts = {}) {
7719
8251
  if (!names.includes(stateName))
7720
8252
  names.push(stateName);
7721
8253
  };
7722
- const actors = [];
7723
- for (const name of actorNames) {
7724
- const entry = { actor_id: actorIds.get(name), actor_name: name };
7725
- if (actorDescriptions.has(name))
7726
- entry["description"] = actorDescriptions.get(name);
7727
- const aliases = actorAliases.get(name) ?? [];
7728
- if (aliases.length > 0)
7729
- entry["aliases"] = aliases;
8254
+ // The asset's canonical default state name — the one named 默认, else the
8255
+ // first. Empty when the asset has no named states.
8256
+ const canonicalDefaultStateName = (defs) => {
8257
+ const names = defs.map((d) => strOf(d["state_name"]).trim()).filter((n) => n);
8258
+ if (names.length === 0)
8259
+ return "";
8260
+ return names.includes("默认") ? "默认" : names[0];
8261
+ };
8262
+ // Assemble an asset's states[] with sequential st_NNN ids, recording each
8263
+ // name→id in stateIdsByTarget so context / state_change refs resolve through
8264
+ // it. In the deterministic terminal-assembly path (bindDefaultStateRefs), the
8265
+ // canonical default state takes the asset-scoped id `default` (its readable
8266
+ // state_name preserved) so the assembled script satisfies the
8267
+ // DEFAULT_STATE_MISSING invariant without a curation pass. Ref resolution is
8268
+ // unchanged: it still keys off the state name, now mapped to the canonical id.
8269
+ const buildAssetStates = (kind, assetName, defs) => {
8270
+ const defaultName = opts.bindDefaultStateRefs ? canonicalDefaultStateName(defs) : "";
8271
+ let defaultAssigned = false;
7730
8272
  const states = [];
7731
- for (const stateDef of stateDefsByTarget.get(`actor::${name}`) ?? []) {
7732
- stateCounter += 1;
7733
- const sid = fmtId("st", stateCounter);
8273
+ for (const stateDef of defs) {
7734
8274
  const stateName = strOf(stateDef["state_name"]).trim();
7735
- stateIdsByTarget.set(`actor::${name}::${stateName}`, sid);
7736
- rememberStateName("actor", name, stateName);
8275
+ let sid;
8276
+ if (!defaultAssigned && defaultName && stateName === defaultName) {
8277
+ sid = "default";
8278
+ defaultAssigned = true;
8279
+ }
8280
+ else {
8281
+ stateCounter += 1;
8282
+ sid = fmtId("st", stateCounter);
8283
+ }
8284
+ stateIdsByTarget.set(`${kind}::${assetName}::${stateName}`, sid);
8285
+ rememberStateName(kind, assetName, stateName);
7737
8286
  const stateEntry = { state_id: sid, state_name: stateName };
7738
8287
  const description = strOf(stateDef["description"]).trim();
7739
8288
  if (description)
7740
8289
  stateEntry["description"] = description;
8290
+ const stateReason = cleanStateReason(stateDef["state_reason"]);
8291
+ if (stateReason)
8292
+ stateEntry["state_reason"] = stateReason;
7741
8293
  states.push(stateEntry);
7742
8294
  }
8295
+ return states;
8296
+ };
8297
+ const actors = [];
8298
+ for (const name of actorNames) {
8299
+ const entry = { actor_id: actorIds.get(name), actor_name: name };
8300
+ if (actorDescriptions.has(name))
8301
+ entry["description"] = actorDescriptions.get(name);
8302
+ const aliases = actorAliases.get(name) ?? [];
8303
+ if (aliases.length > 0)
8304
+ entry["aliases"] = aliases;
8305
+ const states = buildAssetStates("actor", name, stateDefsByTarget.get(`actor::${name}`) ?? []);
7743
8306
  if (states.length > 0)
7744
8307
  entry["states"] = states;
7745
8308
  actors.push(entry);
@@ -7752,19 +8315,7 @@ function mergeEpisodeResultsInternal(results, title, opts = {}) {
7752
8315
  const aliases = locationAliases.get(name) ?? [];
7753
8316
  if (aliases.length > 0)
7754
8317
  entry["aliases"] = aliases;
7755
- const states = [];
7756
- for (const stateDef of stateDefsByTarget.get(`location::${name}`) ?? []) {
7757
- stateCounter += 1;
7758
- const sid = fmtId("st", stateCounter);
7759
- const stateName = strOf(stateDef["state_name"]).trim();
7760
- stateIdsByTarget.set(`location::${name}::${stateName}`, sid);
7761
- rememberStateName("location", name, stateName);
7762
- const stateEntry = { state_id: sid, state_name: stateName };
7763
- const description = strOf(stateDef["description"]).trim();
7764
- if (description)
7765
- stateEntry["description"] = description;
7766
- states.push(stateEntry);
7767
- }
8318
+ const states = buildAssetStates("location", name, stateDefsByTarget.get(`location::${name}`) ?? []);
7768
8319
  if (states.length > 0)
7769
8320
  entry["states"] = states;
7770
8321
  locations.push(entry);
@@ -7777,19 +8328,7 @@ function mergeEpisodeResultsInternal(results, title, opts = {}) {
7777
8328
  const aliases = propAliases.get(name) ?? [];
7778
8329
  if (aliases.length > 0)
7779
8330
  entry["aliases"] = aliases;
7780
- const states = [];
7781
- for (const stateDef of stateDefsByTarget.get(`prop::${name}`) ?? []) {
7782
- stateCounter += 1;
7783
- const sid = fmtId("st", stateCounter);
7784
- const stateName = strOf(stateDef["state_name"]).trim();
7785
- stateIdsByTarget.set(`prop::${name}::${stateName}`, sid);
7786
- rememberStateName("prop", name, stateName);
7787
- const stateEntry = { state_id: sid, state_name: stateName };
7788
- const description = strOf(stateDef["description"]).trim();
7789
- if (description)
7790
- stateEntry["description"] = description;
7791
- states.push(stateEntry);
7792
- }
8331
+ const states = buildAssetStates("prop", name, stateDefsByTarget.get(`prop::${name}`) ?? []);
7793
8332
  if (states.length > 0)
7794
8333
  entry["states"] = states;
7795
8334
  props.push(entry);
@@ -7802,6 +8341,8 @@ function mergeEpisodeResultsInternal(results, title, opts = {}) {
7802
8341
  if (speakerKind === "actor" && actorIds.has(speaker))
7803
8342
  return `spk_${actorIds.get(speaker)}`;
7804
8343
  const normKind = _MD_SPEAKER_KIND_NORM[speakerKind.toLowerCase()] ?? _MD_SPEAKER_KIND_NORM[speakerKind] ?? "other";
8344
+ if ((normKind === "system" || normKind === "broadcast" || normKind === "other") && actorIds.has(speaker))
8345
+ return `spk_${actorIds.get(speaker)}`;
7805
8346
  return speakerIdsByName.get(`${normKind}::${speaker}`) ?? speakerIdsByName.get(`other::${speaker}`) ?? null;
7806
8347
  };
7807
8348
  const stateIdForRef = (kind, assetName, explicitState, unresolved) => {