narraeon 0.7.4 → 0.7.6

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 (30) hide show
  1. package/dist/node/protocol/v1.js +4 -0
  2. package/dist/node/runtime/V1Runtime.js +13 -1
  3. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +64 -163
  4. package/dist/node/runtime/content/FileNativeContentTree.js +16 -13
  5. package/dist/node/runtime/model/ToolArgumentCoercion.js +138 -0
  6. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +34 -18
  7. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +6 -2
  8. package/dist/node/runtime/play/OrderedPlayPrompts.js +3 -6
  9. package/dist/node/runtime/play/PlayAdvanceLifecycle.js +211 -0
  10. package/dist/node/runtime/play/PlayCallChain.js +276 -398
  11. package/dist/node/runtime/play/PlayDocumentTools.js +19 -0
  12. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +3 -3
  13. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +15 -0
  14. package/dist/node/runtime/prompt/ToolNameAlias.js +14 -0
  15. package/dist/node/runtime/setting/SettingAuthoringTransaction.js +370 -169
  16. package/dist/node/runtime/world/FileNativeWorldStore.js +151 -109
  17. package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -1
  18. package/dist/node/runtime/world/WorldDocumentStore.js +1 -1
  19. package/dist/node/runtime/world/WorldLedgerAuthority.js +37 -3
  20. package/dist/node/runtime/world/WorldTurnLedger.js +17 -4
  21. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -4
  22. package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +19 -241
  23. package/dist/node/runtime/world-revision/WorldRevisionWorkspace.js +8 -27
  24. package/dist/node/server/createServer.js +1 -4
  25. package/dist/node/shared/ordered-play-prompts.js +5 -0
  26. package/dist/node/shared/turn-guide.js +27 -2
  27. package/dist/web/assets/index-Cj2JYCia.js +1408 -0
  28. package/dist/web/index.html +1 -1
  29. package/package.json +1 -1
  30. package/dist/web/assets/index-9N9kO8kY.js +0 -1408
@@ -353,6 +353,10 @@ function validateRequestFields(request) {
353
353
  if (request.type === "play.timeline.detail" &&
354
354
  (!Number.isSafeInteger(request.eventId) || Number(request.eventId) < 1))
355
355
  throw new V1ProtocolError("invalid_request", "play.timeline.detail.eventId is invalid");
356
+ if ("turnNote" in request &&
357
+ request.turnNote !== undefined &&
358
+ typeof request.turnNote !== "string")
359
+ throw new V1ProtocolError("invalid_request", "turnNote is invalid");
356
360
  if (request.type === "play.chain.revise-player" &&
357
361
  request.continuation !== "continue_context" &&
358
362
  request.continuation !== "fresh_context")
@@ -197,7 +197,7 @@ export class V1Runtime {
197
197
  const chain = result;
198
198
  if (chain?.status !== "interrupted" ||
199
199
  typeof chain.lastFailure !== "string" ||
200
- chain.events?.at(-1)?.kind === "cancellation")
200
+ chain.lastCancelled === true)
201
201
  return;
202
202
  await this.#errorLog.recordPlayInterruption(request, {
203
203
  message: chain.lastFailure,
@@ -458,6 +458,9 @@ export class V1Runtime {
458
458
  }), `content:${request.packageId}.message.genesis.narrator`, "content-preview"),
459
459
  playerInputPlacement: "append",
460
460
  playerInput: request.playerInput,
461
+ ...(request.turnNote === undefined
462
+ ? {}
463
+ : { turnNote: request.turnNote }),
461
464
  modelBinding: modelHost.binding(),
462
465
  }, playPreset);
463
466
  const tools = structuredClone(preview.compilation.toolUniverse ?? preview.compilation.tools);
@@ -669,6 +672,9 @@ export class V1Runtime {
669
672
  eventId: request.eventId,
670
673
  replacementExchangeId: request.replacementExchangeId,
671
674
  replacementText: request.replacementText,
675
+ ...(request.turnNote === undefined
676
+ ? {}
677
+ : { turnNote: request.turnNote }),
672
678
  };
673
679
  const result = await playCall(async () => {
674
680
  await this.#worlds.assertWorldRevisionUnlocked(request.worldId);
@@ -695,6 +701,9 @@ export class V1Runtime {
695
701
  chainId: request.chainId,
696
702
  exchangeId: request.exchangeId,
697
703
  playerText: request.playerText,
704
+ ...(request.turnNote === undefined
705
+ ? {}
706
+ : { turnNote: request.turnNote }),
698
707
  hostBinding,
699
708
  playPreset,
700
709
  modelBinding,
@@ -712,6 +721,9 @@ export class V1Runtime {
712
721
  chainId: request.chainId,
713
722
  exchangeId: request.exchangeId,
714
723
  playerText: request.playerText,
724
+ ...(request.turnNote === undefined
725
+ ? {}
726
+ : { turnNote: request.turnNote }),
715
727
  modelHost: await this.#modelHost(),
716
728
  resolvePrompt: () => this.#continuousBinding(),
717
729
  ...(playCallChainObserver === undefined
@@ -1,6 +1,5 @@
1
1
  import { PackageScriptPermissions } from "../extension/PackageScriptPermissions.js";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
- import { readFileSync, unlinkSync } from "node:fs";
4
3
  import { open, mkdir, readFile, readdir, rename, rm, stat, unlink, } from "node:fs/promises";
5
4
  import { linkExclusive } from "../FileNativeExclusiveLink.js";
6
5
  import { basename, dirname, isAbsolute, join, posix, win32 } from "node:path";
@@ -54,15 +53,16 @@ export class InvalidContentTreeError extends Error {
54
53
  * single replaceable envelope so saving cannot expose half of two file sets.
55
54
  */
56
55
  export class CurrentTreeContentLibrary {
57
- #locksRoot;
56
+ #legacyLocksRoot;
58
57
  #packagesRoot;
59
58
  #stagingRoot;
60
59
  #limits;
61
60
  #beforePackagePublish;
62
61
  #beforeCurrentTreeReplace;
63
62
  #mutationTail = Promise.resolve();
63
+ #legacyLocksRemoval;
64
64
  constructor(dataRoot, options = {}) {
65
- this.#locksRoot = join(dataRoot, "content", ".package-locks");
65
+ this.#legacyLocksRoot = join(dataRoot, "content", ".package-locks");
66
66
  this.#packagesRoot = join(dataRoot, "content", "packages");
67
67
  this.#stagingRoot = join(dataRoot, "content", ".package-staging");
68
68
  this.#limits = normalizeLimits(options.limits);
@@ -272,48 +272,16 @@ export class CurrentTreeContentLibrary {
272
272
  });
273
273
  }
274
274
  async acquireOperationLease(localId, options = {}) {
275
- this.#packageRoot(localId);
276
- await mkdir(this.#locksRoot, { recursive: true, mode: 0o700 });
277
- const lockPath = this.#packageLockPath(localId);
278
- const reclaimPath = this.#packageReclaimPath(localId);
279
- await clearDeadPackageLockMarker(reclaimPath);
280
- if (await pathExists(reclaimPath)) {
281
- throw new CurrentTreeContentPackageBusyError();
282
- }
283
- const marker = createPackageLockMarker();
284
- const temporaryPath = join(this.#locksRoot, `.${localId}.${marker.token}.tmp`);
285
- let handle;
286
- try {
287
- handle = await open(temporaryPath, "wx", 0o600);
288
- await handle.writeFile(`${JSON.stringify(marker)}\n`, "utf8");
289
- await handle.close();
290
- handle = undefined;
291
- try {
292
- await linkExclusive(temporaryPath, lockPath);
293
- }
294
- catch (error) {
295
- if (!isNodeErrorCode(error, "EEXIST"))
296
- throw error;
297
- if (options.wait === true) {
298
- await this.#waitForPackageLock(lockPath, reclaimPath, temporaryPath);
299
- }
300
- else if (!(await this.#tryReplaceDeadPackageLock(lockPath, reclaimPath, temporaryPath))) {
301
- throw new CurrentTreeContentPackageBusyError();
302
- }
303
- }
304
- }
305
- catch (error) {
306
- await handle?.close().catch(() => undefined);
307
- if (error instanceof CurrentTreeContentPackageBusyError)
308
- throw error;
309
- throw new CurrentTreeContentLibraryError("Unable to acquire the source-package save owner", {
310
- cause: error instanceof Error ? error : undefined,
311
- });
312
- }
313
- finally {
314
- await unlink(temporaryPath).catch(() => undefined);
315
- }
275
+ const packageRoot = this.#packageRoot(localId);
276
+ await this.#removeLegacyPackageLocks();
277
+ await acquireInProcessPackageLease(packageRoot, options.wait === true);
316
278
  let released = false;
279
+ const release = () => {
280
+ if (released)
281
+ return;
282
+ released = true;
283
+ releaseInProcessPackageLease(packageRoot);
284
+ };
317
285
  try {
318
286
  const package_ = (await this.#readPackageSnapshot(localId)).package;
319
287
  return {
@@ -326,19 +294,25 @@ export class CurrentTreeContentLibrary {
326
294
  assertGameplayModeUnchanged(package_.files, normalized);
327
295
  return this.#replaceNormalizedCurrentTree(localId, package_.title, normalized, options.signal);
328
296
  },
329
- release: () => {
330
- if (released)
331
- return;
332
- released = true;
333
- releasePackageOperationLockSync(lockPath, marker.token);
334
- },
297
+ release,
335
298
  };
336
299
  }
337
300
  catch (error) {
338
- releasePackageOperationLockSync(lockPath, marker.token);
301
+ release();
339
302
  throw error;
340
303
  }
341
304
  }
305
+ /**
306
+ * Lock files written by versions that leased packages across processes. No
307
+ * code reads them any more, so the first lease in a library removes them.
308
+ */
309
+ #removeLegacyPackageLocks() {
310
+ this.#legacyLocksRemoval ??= rm(this.#legacyLocksRoot, {
311
+ recursive: true,
312
+ force: true,
313
+ }).catch(() => undefined);
314
+ return this.#legacyLocksRemoval;
315
+ }
342
316
  async #replaceNormalizedCurrentTree(localId, title, normalized, signal) {
343
317
  const packageRoot = this.#packageRoot(localId);
344
318
  const temporaryPath = join(packageRoot, `.current-tree-${randomUUID()}.tmp`);
@@ -427,70 +401,6 @@ export class CurrentTreeContentLibrary {
427
401
  }
428
402
  return join(this.#packagesRoot, localId);
429
403
  }
430
- #packageLockPath(localId) {
431
- return join(this.#locksRoot, `${localId}.mutation`);
432
- }
433
- #packageReclaimPath(localId) {
434
- return join(this.#locksRoot, `${localId}.reclaim`);
435
- }
436
- async #tryReplaceDeadPackageLock(lockPath, reclaimPath, replacementPath) {
437
- const current = await readPackageLockMarker(lockPath);
438
- if (current === null || isProcessAlive(current.pid))
439
- return false;
440
- const reclaim = createPackageLockMarker();
441
- const temporaryPath = join(this.#locksRoot, `.reclaim.${reclaim.token}.tmp`);
442
- let handle;
443
- try {
444
- handle = await open(temporaryPath, "wx", 0o600);
445
- await handle.writeFile(`${JSON.stringify(reclaim)}\n`, "utf8");
446
- await handle.close();
447
- handle = undefined;
448
- await linkExclusive(temporaryPath, reclaimPath);
449
- }
450
- catch (error) {
451
- await handle?.close().catch(() => undefined);
452
- if (isNodeErrorCode(error, "EEXIST"))
453
- return false;
454
- throw error;
455
- }
456
- finally {
457
- await unlink(temporaryPath).catch(() => undefined);
458
- }
459
- try {
460
- const verified = await readPackageLockMarker(lockPath);
461
- if (verified?.token !== current.token || isProcessAlive(current.pid)) {
462
- return false;
463
- }
464
- await unlink(lockPath).catch((error) => {
465
- if (!isNodeErrorCode(error, "ENOENT"))
466
- throw error;
467
- });
468
- await linkExclusive(replacementPath, lockPath);
469
- return true;
470
- }
471
- finally {
472
- const owner = await readPackageLockMarker(reclaimPath);
473
- if (owner?.token === reclaim.token) {
474
- await unlink(reclaimPath).catch(() => undefined);
475
- }
476
- }
477
- }
478
- async #waitForPackageLock(lockPath, reclaimPath, replacementPath) {
479
- for (let attempt = 0; attempt < 2_000; attempt += 1) {
480
- if ((await this.#tryReplaceDeadPackageLock(lockPath, reclaimPath, replacementPath)) ||
481
- (await linkExclusive(replacementPath, lockPath)
482
- .then(() => true)
483
- .catch((error) => {
484
- if (isNodeErrorCode(error, "EEXIST"))
485
- return false;
486
- throw error;
487
- }))) {
488
- return;
489
- }
490
- await new Promise((resolve) => setTimeout(resolve, 5));
491
- }
492
- throw new CurrentTreeContentPackageBusyError();
493
- }
494
404
  async #mutate(operation) {
495
405
  const previous = this.#mutationTail;
496
406
  let release = () => undefined;
@@ -763,47 +673,47 @@ function isPlainObject(value) {
763
673
  function serialize(value) {
764
674
  return `${JSON.stringify(value, null, 2)}\n`;
765
675
  }
766
- function createPackageLockMarker() {
767
- return { schemaVersion: 1, pid: process.pid, token: randomUUID() };
768
- }
769
- async function readPackageLockMarker(path) {
770
- try {
771
- const value = JSON.parse(await readFile(path, "utf8"));
772
- return parsePackageLockMarker(value);
773
- }
774
- catch {
775
- return null;
776
- }
777
- }
778
- function parsePackageLockMarker(value) {
779
- if (isPlainObject(value) &&
780
- value.schemaVersion === 1 &&
781
- typeof value.pid === "number" &&
782
- Number.isSafeInteger(value.pid) &&
783
- value.pid > 0 &&
784
- typeof value.token === "string" &&
785
- /^[0-9a-f-]{36}$/u.test(value.token)) {
786
- return { schemaVersion: 1, pid: value.pid, token: value.token };
787
- }
788
- return null;
789
- }
790
- function releasePackageOperationLockSync(path, token) {
791
- try {
792
- const current = parsePackageLockMarker(JSON.parse(readFileSync(path, "utf8")));
793
- if (current?.token === token)
794
- unlinkSync(path);
795
- }
796
- catch (error) {
797
- if (!isNodeErrorCode(error, "ENOENT"))
798
- throw error;
676
+ const packageLeaseWaitMs = 10_000;
677
+ /**
678
+ * Package leases held by this process, keyed by package root. A key is present
679
+ * while its lease is held; its array queues waiters in arrival order, and a
680
+ * release hands the lease straight to the first of them.
681
+ */
682
+ const heldPackageLeases = new Map();
683
+ async function acquireInProcessPackageLease(packageRoot, wait) {
684
+ const queue = heldPackageLeases.get(packageRoot);
685
+ if (queue === undefined) {
686
+ heldPackageLeases.set(packageRoot, []);
687
+ return;
799
688
  }
689
+ if (!wait)
690
+ throw new CurrentTreeContentPackageBusyError();
691
+ await new Promise((resolve, reject) => {
692
+ const waiter = {
693
+ grant: () => {
694
+ clearTimeout(timer);
695
+ resolve();
696
+ },
697
+ };
698
+ const timer = setTimeout(() => {
699
+ const index = queue.indexOf(waiter);
700
+ if (index === -1)
701
+ return;
702
+ queue.splice(index, 1);
703
+ reject(new CurrentTreeContentPackageBusyError());
704
+ }, packageLeaseWaitMs);
705
+ queue.push(waiter);
706
+ });
800
707
  }
801
- async function clearDeadPackageLockMarker(path) {
802
- const marker = await readPackageLockMarker(path);
803
- if ((marker !== null && !isProcessAlive(marker.pid)) ||
804
- (marker === null && (await pathExists(path)))) {
805
- await unlink(path).catch(() => undefined);
806
- }
708
+ function releaseInProcessPackageLease(packageRoot) {
709
+ const queue = heldPackageLeases.get(packageRoot);
710
+ if (queue === undefined)
711
+ return;
712
+ const next = queue.shift();
713
+ if (next === undefined)
714
+ heldPackageLeases.delete(packageRoot);
715
+ else
716
+ next.grant();
807
717
  }
808
718
  async function pathExists(path) {
809
719
  try {
@@ -816,15 +726,6 @@ async function pathExists(path) {
816
726
  throw error;
817
727
  }
818
728
  }
819
- function isProcessAlive(pid) {
820
- try {
821
- process.kill(pid, 0);
822
- return true;
823
- }
824
- catch (error) {
825
- return !isNodeErrorCode(error, "ESRCH");
826
- }
827
- }
828
729
  function isNodeErrorCode(error, code) {
829
730
  return (typeof error === "object" &&
830
731
  error !== null &&
@@ -14,8 +14,11 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
14
14
  layout: "content_package",
15
15
  files,
16
16
  });
17
- if (worldDocumentSnapshot.layout !== "content_package")
18
- throw new Error("Content-package inspection accepts only content_package document snapshots");
17
+ if (worldDocumentSnapshot.layout === "world_state")
18
+ throw new Error("Authoring inspection accepts only content_package or world_revision document snapshots");
19
+ const subject = worldDocumentSnapshot.layout === "world_revision"
20
+ ? "world revision"
21
+ : "content package";
19
22
  const snapshotFiles = worldDocumentSnapshot.files;
20
23
  const issues = [];
21
24
  const documents = worldDocumentSnapshotDocuments(worldDocumentSnapshot);
@@ -26,11 +29,11 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
26
29
  let opening = "missing";
27
30
  if (openingFiles.length === 0) {
28
31
  if (options.requireOpening !== false)
29
- issue(issues, "missing_opening", "opening.md", "The content package is missing a root-level opening.md introduction");
32
+ issue(issues, "missing_opening", "opening.md", `The ${subject} is missing a root-level opening.md introduction`);
30
33
  }
31
34
  else if (openingFiles.length > 1) {
32
35
  opening = "invalid";
33
- issue(issues, "invalid_opening", "opening.md", "A content package may contain only one root-level opening.md file");
36
+ issue(issues, "invalid_opening", "opening.md", `A ${subject} may contain only one root-level opening.md file`);
34
37
  }
35
38
  else {
36
39
  const openingFile = openingFiles[0];
@@ -48,19 +51,19 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
48
51
  for (const file of snapshotFiles) {
49
52
  if (/\.json$/iu.test(file.path) && file.path !== gameplayPath) {
50
53
  unsupportedPaths.add(file.path);
51
- issue(issues, "unsupported_content_file", file.path, "The current content package does not accept manifest, seven-field JSON, schema, record, or revision files");
54
+ issue(issues, "unsupported_content_file", file.path, `The current ${subject} does not accept manifest, seven-field JSON, schema, record, or revision files`);
52
55
  }
53
56
  }
54
57
  issues.push(...worldDocumentSnapshot.diagnostics
55
58
  .filter(({ logicalPath }) => logicalPath === undefined || !unsupportedPaths.has(logicalPath))
56
- .map(worldDocumentIssue));
59
+ .map((diagnostic) => worldDocumentIssue(diagnostic, worldDocumentSnapshot.logicalRoot)));
57
60
  const index = documentIndex(documents);
58
61
  const frameSource = textFiles.get("control/frame.yaml");
59
62
  let worldFrame = "missing";
60
63
  let currentSituation = "missing";
61
64
  if (frameSource === undefined) {
62
- issue(issues, "missing_world_frame", "control/frame.yaml", "The content package is missing its world prompt frame");
63
- issue(issues, "missing_current_situation", "control/frame.yaml", "The content package has not bound a current-situation document");
65
+ issue(issues, "missing_world_frame", "control/frame.yaml", `The ${subject} is missing its world prompt frame`);
66
+ issue(issues, "missing_current_situation", "control/frame.yaml", `The ${subject} has not bound a current-situation document`);
64
67
  }
65
68
  else {
66
69
  const frame = parseRestrictedYaml("control/frame.yaml", frameSource, issues);
@@ -97,7 +100,7 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
97
100
  const playerSource = textFiles.get("control/player-views.yaml");
98
101
  let playerView = "missing";
99
102
  if (playerSource === undefined) {
100
- issue(issues, "missing_player_view", "control/player-views.yaml", "The content package is missing its player-view control file");
103
+ issue(issues, "missing_player_view", "control/player-views.yaml", `The ${subject} is missing its player-view control file`);
101
104
  }
102
105
  else {
103
106
  const player = parseRestrictedYaml("control/player-views.yaml", playerSource, issues);
@@ -218,10 +221,10 @@ function contentDocument(document) {
218
221
  codec: document.codec,
219
222
  };
220
223
  }
221
- function worldDocumentIssue(diagnostic) {
224
+ function worldDocumentIssue(diagnostic, root) {
222
225
  return {
223
226
  code: contentIssueCode(diagnostic),
224
- path: diagnostic.logicalPath ?? "world",
227
+ path: diagnostic.logicalPath ?? root,
225
228
  message: diagnostic.code === "document_reference_invalid"
226
229
  ? "An explicit $ref points to a missing document, or the target identity is not unique in the snapshot"
227
230
  : diagnostic.message,
@@ -430,9 +433,9 @@ function validateFrameContext(value, index, snapshot, issues) {
430
433
  }
431
434
  if (entry.slot.kind === "catalog" &&
432
435
  entry.slot.required !== false &&
433
- catalogDocuments(entry.slot.directory, index.documents(), "world").length === 0) {
436
+ catalogDocuments(entry.slot.directory, index.documents(), snapshot.logicalRoot).length === 0) {
434
437
  const directory = entry.slot.directory;
435
- issue(issues, "invalid_world_frame", "control/frame.yaml", `Required catalog ${directory} has no associated documents; it matches only direct children named world/${directory}/<file>.yaml, .yml, or .md, and dots in file names do not create directories`);
438
+ issue(issues, "invalid_world_frame", "control/frame.yaml", `Required catalog ${directory} has no associated documents; it matches only direct children named ${snapshot.logicalRoot}/${directory}/<file>.yaml, .yml, or .md, and dots in file names do not create directories`);
436
439
  continue;
437
440
  }
438
441
  kinds.push(entry.slot.kind);
@@ -0,0 +1,138 @@
1
+ /** Root arguments are depth 0; parsing and descent stop after two levels. */
2
+ const maxCoercionDepth = 2;
3
+ export function coerceToolArguments(schema, args) {
4
+ const coerced = [];
5
+ const value = coerceValue(schema, args, "", 0, coerced);
6
+ return { arguments: value, coerced };
7
+ }
8
+ /**
9
+ * Renders the Runtime note appended to a tool result whose arguments were
10
+ * parsed. It tells the model its arguments were repaired, so the next call
11
+ * can send native JSON values.
12
+ */
13
+ export function renderCoercionNote(paths, locale) {
14
+ const names = paths.map((path) => `"${path}"`).join(", ");
15
+ return locale === "zh-CN"
16
+ ? `Runtime 说明:以下参数以 JSON 字符串到达,本次已解析后执行:${names}。请以 JSON 数组或对象的原生值发送,不要用字符串。`
17
+ : `Runtime note: these arguments arrived as JSON strings and were parsed for this call: ${names}. Send array and object arguments as native JSON values, not strings.`;
18
+ }
19
+ function coerceValue(schema, value, path, depth, coerced) {
20
+ const type = definiteType(schema);
21
+ let current = value;
22
+ if ((type === "array" || type === "object") &&
23
+ typeof current === "string" &&
24
+ depth > 0 &&
25
+ depth <= maxCoercionDepth) {
26
+ const parsed = parseJson(current);
27
+ if (matchesType(type, parsed)) {
28
+ current = parsed;
29
+ coerced.push(path);
30
+ }
31
+ }
32
+ if (depth >= maxCoercionDepth)
33
+ return current;
34
+ if (type === "array" && isArray(current)) {
35
+ const items = itemsSchema(schema);
36
+ if (items === undefined)
37
+ return current;
38
+ let next = current;
39
+ current.forEach((element, index) => {
40
+ const repaired = coerceValue(items, element, `${path}[${index}]`, depth + 1, coerced);
41
+ if (repaired !== element) {
42
+ if (next === current)
43
+ next = [...current];
44
+ next[index] = repaired;
45
+ }
46
+ });
47
+ return next;
48
+ }
49
+ if (type === "object" && isRecord(current)) {
50
+ let next = current;
51
+ for (const key of Object.keys(current)) {
52
+ const property = propertySchema(schema, key);
53
+ if (property === undefined)
54
+ continue;
55
+ const repaired = coerceValue(property, current[key], path === "" ? key : `${path}.${key}`, depth + 1, coerced);
56
+ if (repaired !== current[key]) {
57
+ if (next === current)
58
+ next = { ...current };
59
+ next[key] = repaired;
60
+ }
61
+ }
62
+ return next;
63
+ }
64
+ return current;
65
+ }
66
+ /** The single type a schema position admits, or undefined when it is not definite. */
67
+ function definiteType(schema) {
68
+ if (!isRecord(schema))
69
+ return undefined;
70
+ if (schema.type !== undefined) {
71
+ if (typeof schema.type === "string")
72
+ return schema.type;
73
+ if (Array.isArray(schema.type) &&
74
+ schema.type.length === 1 &&
75
+ typeof schema.type[0] === "string")
76
+ return schema.type[0];
77
+ return undefined;
78
+ }
79
+ const branches = Array.isArray(schema.oneOf)
80
+ ? schema.oneOf
81
+ : Array.isArray(schema.anyOf)
82
+ ? schema.anyOf
83
+ : undefined;
84
+ if (branches === undefined || branches.length === 0)
85
+ return undefined;
86
+ const types = branches.map(definiteType);
87
+ const first = types[0];
88
+ return first !== undefined && types.every((type) => type === first)
89
+ ? first
90
+ : undefined;
91
+ }
92
+ function itemsSchema(schema) {
93
+ return isRecord(schema.items) && !Array.isArray(schema.items)
94
+ ? schema.items
95
+ : undefined;
96
+ }
97
+ /**
98
+ * A property schema is taken directly from `properties`, or from oneOf/anyOf
99
+ * branches only when every branch that declares the key declares it the same.
100
+ */
101
+ function propertySchema(schema, key) {
102
+ if (isRecord(schema.properties) && isRecord(schema.properties[key]))
103
+ return schema.properties[key];
104
+ const branches = Array.isArray(schema.oneOf)
105
+ ? schema.oneOf
106
+ : Array.isArray(schema.anyOf)
107
+ ? schema.anyOf
108
+ : [];
109
+ const declared = branches
110
+ .map((branch) => isRecord(branch) && isRecord(branch.properties)
111
+ ? branch.properties[key]
112
+ : undefined)
113
+ .filter((property) => property !== undefined);
114
+ if (declared.length === 0)
115
+ return undefined;
116
+ const serialized = declared.map((property) => JSON.stringify(property));
117
+ return serialized.every((text) => text === serialized[0]) &&
118
+ isRecord(declared[0])
119
+ ? declared[0]
120
+ : undefined;
121
+ }
122
+ function parseJson(text) {
123
+ try {
124
+ return JSON.parse(text);
125
+ }
126
+ catch {
127
+ return undefined;
128
+ }
129
+ }
130
+ function matchesType(type, value) {
131
+ return type === "array" ? Array.isArray(value) : isRecord(value);
132
+ }
133
+ function isArray(value) {
134
+ return Array.isArray(value);
135
+ }
136
+ function isRecord(value) {
137
+ return typeof value === "object" && value !== null && !Array.isArray(value);
138
+ }
@@ -2,7 +2,6 @@ import { isNarrativeOrigin, } from "../history/HistoryBackground.js";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { mkdir, readdir, readFile, rename, rm, stat, writeFile, } from "node:fs/promises";
4
4
  import { dirname, join, resolve } from "node:path";
5
- import { isDeepStrictEqual } from "node:util";
6
5
  /** Written last by the released-record cleanup (ADR-0034); its absence means "not yet run". */
7
6
  const LAYOUT_MARKER = "play-advance-layout.json";
8
7
  /**
@@ -19,15 +18,16 @@ export class FileNativePlayAdvanceStore {
19
18
  constructor(dataRoot) {
20
19
  this.#worldsRoot = join(resolve(dataRoot), "worlds-file-native");
21
20
  }
22
- async begin(base) {
23
- assertBase(base);
24
- const current = await this.readCurrent(base.worldId, base.chainId);
21
+ async begin(advance) {
22
+ assertBase(advance);
23
+ const marker = persistedAdvance(advance);
24
+ const current = await this.readCurrent(marker.worldId, marker.chainId);
25
25
  if (current !== null) {
26
- if (!sameAdvanceBase(current, base))
26
+ if (!sameAdvance(current, marker))
27
27
  throw new Error("Another play advance is still awaiting settlement");
28
28
  return;
29
29
  }
30
- await publishJson(this.#markerPath(base.worldId, base.chainId), base);
30
+ await publishJson(this.#markerPath(marker.worldId, marker.chainId), marker);
31
31
  }
32
32
  async readCurrent(worldId, chainId) {
33
33
  assertIdentity(worldId, "World ID");
@@ -40,13 +40,15 @@ export class FileNativePlayAdvanceStore {
40
40
  throw new Error("Play advance marker belongs to another context");
41
41
  return value;
42
42
  }
43
- async markSettled(base) {
44
- const current = await this.readCurrent(base.worldId, base.chainId);
43
+ async markSettled(advance) {
44
+ const current = await this.readCurrent(advance.worldId, advance.chainId);
45
45
  if (current === null)
46
46
  return;
47
- if (!sameAdvanceBase(current, base))
47
+ if (!sameAdvance(current, advance))
48
48
  throw new Error("Play advance is no longer current");
49
- await rm(this.#markerPath(base.worldId, base.chainId), { force: true });
49
+ await rm(this.#markerPath(advance.worldId, advance.chainId), {
50
+ force: true,
51
+ });
50
52
  }
51
53
  /** Remove the in-progress marker and all advance records of a discarded chain. */
52
54
  async discardChain(worldId, chainId) {
@@ -103,12 +105,25 @@ export class FileNativePlayAdvanceStore {
103
105
  return join(this.#worldsRoot, worldId, "runtime", "play-advances", digest(chainId), "in-progress.json");
104
106
  }
105
107
  }
106
- function sameAdvanceBase(left, right) {
107
- const { createdAt: leftCreatedAt, ...leftIdentity } = left;
108
- const { createdAt: rightCreatedAt, ...rightIdentity } = right;
109
- void leftCreatedAt;
110
- void rightCreatedAt;
111
- return isDeepStrictEqual(leftIdentity, rightIdentity);
108
+ /**
109
+ * Whether two advances are the same one. The marker is confirmed by these
110
+ * identity fields alone; the frozen request is never compared (ADR-0044).
111
+ */
112
+ function sameAdvance(left, right) {
113
+ return (left.advanceKind === right.advanceKind &&
114
+ left.worldId === right.worldId &&
115
+ left.chainId === right.chainId &&
116
+ left.advanceId === right.advanceId &&
117
+ left.operationId === right.operationId &&
118
+ left.eventId === right.eventId);
119
+ }
120
+ /** The marker form of an advance: a player advance's frozen request stays in memory. */
121
+ function persistedAdvance(advance) {
122
+ if (!("nextRequest" in advance))
123
+ return advance;
124
+ const { nextRequest: _nextRequest, ...marker } = advance;
125
+ void _nextRequest;
126
+ return marker;
112
127
  }
113
128
  function assertBase(value) {
114
129
  if (!isRecord(value) ||
@@ -137,9 +152,10 @@ function assertBase(value) {
137
152
  !Number.isSafeInteger(value.transcriptStart) ||
138
153
  Number(value.transcriptStart) < 0 ||
139
154
  !Number.isSafeInteger(value.exchange) ||
140
- Number(value.exchange) < 1 ||
141
- !isRecord(value.nextRequest))
155
+ Number(value.exchange) < 1)
142
156
  throw new Error("Player advance has an invalid shape");
157
+ // A marker released before ADR-0044 may still carry `nextRequest`. Recovery
158
+ // never reads it, so the field is tolerated and not validated.
143
159
  }
144
160
  else if (!Number.isSafeInteger(value.exchange) ||
145
161
  Number(value.exchange) < 0 ||