narraeon 0.5.7 → 0.6.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 (36) hide show
  1. package/README.md +2 -0
  2. package/README.zh-CN.md +2 -0
  3. package/dist/node/protocol/gameplay.js +69 -0
  4. package/dist/node/protocol/resolution.js +274 -0
  5. package/dist/node/protocol/v1.js +15 -0
  6. package/dist/node/runtime/RuntimeErrorLog.js +95 -0
  7. package/dist/node/runtime/V1Runtime.js +57 -2
  8. package/dist/node/runtime/content/ContentWorkspace.js +10 -2
  9. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +43 -2
  10. package/dist/node/runtime/content/FileNativeContentTree.js +8 -1
  11. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +37 -1
  12. package/dist/node/runtime/play/FileNativePlayPresetStore.js +48 -3
  13. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +7 -0
  14. package/dist/node/runtime/play/OrderedPlayPrompts.js +68 -6
  15. package/dist/node/runtime/play/PlayCallChain.js +33 -5
  16. package/dist/node/runtime/play/TabletopResolution.js +372 -0
  17. package/dist/node/runtime/play/TabletopRules.js +141 -0
  18. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +45 -10
  19. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +35 -2
  20. package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +4 -1
  21. package/dist/node/runtime/setting/SettingAuthoringTransaction.js +56 -13
  22. package/dist/node/runtime/setting/SettingImprovementSession.js +6 -4
  23. package/dist/node/runtime/world/FileNativeWorldStore.js +36 -2
  24. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -0
  25. package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +35 -6
  26. package/dist/node/runtime/world-revision/WorldRevisionSession.js +9 -5
  27. package/dist/node/server/ConversationStream.js +2 -0
  28. package/dist/node/shared/ordered-author-prompts.js +8 -0
  29. package/dist/node/shared/ordered-play-prompts.js +23 -0
  30. package/dist/node/shared/tabletop-guidance.js +100 -0
  31. package/dist/web/assets/index-BJ-1Mb0e.css +1 -0
  32. package/dist/web/assets/index-BgBL8G-A.js +1323 -0
  33. package/dist/web/index.html +2 -2
  34. package/package.json +1 -1
  35. package/dist/web/assets/index-DxNtxzLR.js +0 -1255
  36. package/dist/web/assets/index-DzOnu9uv.css +0 -1
@@ -4,6 +4,7 @@ import { readFileSync, unlinkSync } from "node:fs";
4
4
  import { link, open, mkdir, readFile, readdir, rename, rm, stat, unlink, } from "node:fs/promises";
5
5
  import { basename, dirname, isAbsolute, join, posix, win32 } from "node:path";
6
6
  import { portableContentTreePathKey } from "../../protocol/contentTree.js";
7
+ import { assertSameGameplayMode, gameplayFile, gameplayPath, readGameplay, } from "../../protocol/gameplay.js";
7
8
  import { defaultContentWorkspaceLimits, } from "./ContentLimits.js";
8
9
  import { inspectContentPackageCurrentTree, isContentPackageWorldDocumentPath, worldDocumentTextRequirement, } from "./FileNativeContentTree.js";
9
10
  const localIdPattern = /^package-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
@@ -228,6 +229,29 @@ export class CurrentTreeContentLibrary {
228
229
  }
229
230
  });
230
231
  }
232
+ /**
233
+ * Change only the resolution scheme of a tabletop package. The edit reads the
234
+ * tree under the same lease as ordinary saves, so it publishes atomically and
235
+ * follows the same conflict rules.
236
+ */
237
+ setGameplayScheme(localId, scheme, options = {}) {
238
+ return this.#mutate(async () => {
239
+ const lease = await this.acquireOperationLease(localId, {
240
+ wait: options.waitForLease !== false,
241
+ });
242
+ try {
243
+ options.signal?.throwIfAborted();
244
+ const current = lease.package;
245
+ if (current.gameplay.mode !== "tabletop")
246
+ throw new InvalidContentTreeError("Only a tabletop content package has a resolution scheme / 只有跑团内容包有判定方案");
247
+ const replacement = gameplayFile({ mode: "tabletop", scheme });
248
+ return await lease.replace(current.files.map((file) => file.path === gameplayPath ? replacement : file), options);
249
+ }
250
+ finally {
251
+ lease.release();
252
+ }
253
+ });
254
+ }
231
255
  async acquireOperationLease(localId, options = {}) {
232
256
  this.#packageRoot(localId);
233
257
  await mkdir(this.#locksRoot, { recursive: true, mode: 0o700 });
@@ -281,6 +305,7 @@ export class CurrentTreeContentLibrary {
281
305
  throw new CurrentTreeContentPackageBusyError();
282
306
  options.signal?.throwIfAborted();
283
307
  const normalized = normalizeCurrentContentTreeFiles(files, this.#limits);
308
+ assertGameplayModeUnchanged(package_.files, normalized);
284
309
  return this.#replaceNormalizedCurrentTree(localId, package_.title, normalized, options.signal);
285
310
  },
286
311
  release: () => {
@@ -476,7 +501,7 @@ export class CurrentTreeContentLibrary {
476
501
  }
477
502
  function detail(localId, title, files, inspection) {
478
503
  return {
479
- ...summary(localId, title, inspection),
504
+ ...summary(localId, title, readGameplay(files), inspection),
480
505
  files: files.map((file) => ({ ...file })),
481
506
  documents: structuredClone(inspection.documents),
482
507
  issues: structuredClone(inspection.issues),
@@ -484,8 +509,9 @@ function detail(localId, title, files, inspection) {
484
509
  control: structuredClone(inspection.control),
485
510
  };
486
511
  }
487
- function summary(localId, title, inspection) {
512
+ function summary(localId, title, gameplay, inspection) {
488
513
  return {
514
+ gameplay: { ...gameplay },
489
515
  localId,
490
516
  title,
491
517
  status: inspection.status,
@@ -495,6 +521,7 @@ function summary(localId, title, inspection) {
495
521
  }
496
522
  function packageSummary(package_) {
497
523
  return {
524
+ gameplay: { ...package_.gameplay },
498
525
  localId: package_.localId,
499
526
  title: package_.title,
500
527
  status: package_.status,
@@ -547,8 +574,22 @@ export function normalizeCurrentContentTreeFiles(files, limits = defaultLimits)
547
574
  ...(file.encoding === undefined ? {} : { encoding: file.encoding }),
548
575
  });
549
576
  }
577
+ try {
578
+ readGameplay(normalized);
579
+ }
580
+ catch (error) {
581
+ throw new InvalidContentTreeError(error instanceof Error ? error.message : "Invalid gameplay identity");
582
+ }
550
583
  return normalized.sort((left, right) => compareStrings(left.path, right.path));
551
584
  }
585
+ function assertGameplayModeUnchanged(before, after) {
586
+ try {
587
+ assertSameGameplayMode(before, after);
588
+ }
589
+ catch (error) {
590
+ throw new InvalidContentTreeError(error instanceof Error ? error.message : "Gameplay mode is fixed");
591
+ }
592
+ }
552
593
  function decodeCanonicalBase64(contents, path) {
553
594
  if (contents.length % 4 !== 0 ||
554
595
  !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(contents)) {
@@ -1,3 +1,4 @@
1
+ import { gameplayPath, readGameplay } from "../../protocol/gameplay.js";
1
2
  import { readPackageFollowups } from "./PackageFollowups.js";
2
3
  import { parseDocument } from "yaml";
3
4
  import { WorldDocumentStore, } from "../world/WorldDocumentStore.js";
@@ -45,7 +46,7 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
45
46
  }
46
47
  const unsupportedPaths = new Set();
47
48
  for (const file of snapshotFiles) {
48
- if (/\.json$/iu.test(file.path)) {
49
+ if (/\.json$/iu.test(file.path) && file.path !== gameplayPath) {
49
50
  unsupportedPaths.add(file.path);
50
51
  issue(issues, "unsupported_content_file", file.path, "The current content package does not accept manifest, seven-field JSON, schema, record, or revision files");
51
52
  }
@@ -116,6 +117,12 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
116
117
  issue(issues, "invalid_player_view", "control/player-views.yaml", `${formatProblems.length - maxReportedPlayerViewProblems} more player-view issues were omitted`);
117
118
  }
118
119
  }
120
+ try {
121
+ readGameplay(snapshotFiles);
122
+ }
123
+ catch (error) {
124
+ issue(issues, "invalid_gameplay", gameplayPath, error instanceof Error ? error.message : "Invalid gameplay");
125
+ }
119
126
  try {
120
127
  readPackageFollowups(snapshotFiles);
121
128
  }
@@ -1,5 +1,5 @@
1
1
  import { isNarrativeOrigin, isHistoryBackground, } from "../history/HistoryBackground.js";
2
- import { createHash, randomUUID } from "node:crypto";
2
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
3
3
  import { link, mkdir, open, readFile, rename, rm, writeFile, } from "node:fs/promises";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { isDeepStrictEqual } from "node:util";
@@ -57,6 +57,33 @@ export class FileNativePlayAdvanceStore {
57
57
  recordedAt: durableBase.createdAt,
58
58
  });
59
59
  }
60
+ /**
61
+ * Return the advance's secret resolution seed, creating it on first need.
62
+ * Recovery reads the same fact, so every tool call in the advance draws from
63
+ * one persisted stream and a crashed batch cannot resample.
64
+ */
65
+ async resolutionSeed(base) {
66
+ const durableBase = await this.#assertCurrent(base);
67
+ const path = this.#factPath(base, "resolution-seed.json");
68
+ if ((await readOptionalJson(path)) === null) {
69
+ try {
70
+ await publishImmutableJson(path, {
71
+ schemaVersion: 1,
72
+ stage: "resolution_seed",
73
+ seed: randomBytes(32).toString("hex"),
74
+ recordedAt: durableBase.createdAt,
75
+ });
76
+ }
77
+ catch (error) {
78
+ // A concurrent writer may have published first; the stored fact wins.
79
+ if ((await readOptionalJson(path)) === null)
80
+ throw error;
81
+ }
82
+ }
83
+ const fact = await readJson(path);
84
+ assertResolutionSeedFact(fact);
85
+ return fact.seed;
86
+ }
60
87
  async markSettled(base, head) {
61
88
  const durableBase = await this.#assertCurrent(base);
62
89
  await publishImmutableJson(this.#factPath(base, "settled.json"), {
@@ -115,6 +142,15 @@ function sameAdvanceBase(left, right) {
115
142
  void rightCreatedAt;
116
143
  return isDeepStrictEqual(leftIdentity, rightIdentity);
117
144
  }
145
+ function assertResolutionSeedFact(value) {
146
+ if (!isRecord(value) ||
147
+ value.schemaVersion !== 1 ||
148
+ value.stage !== "resolution_seed" ||
149
+ typeof value.seed !== "string" ||
150
+ !/^[a-f0-9]{64}$/.test(value.seed) ||
151
+ typeof value.recordedAt !== "number")
152
+ throw new Error("Invalid persisted resolution seed");
153
+ }
118
154
  function assertBase(value) {
119
155
  if (!isRecord(value) ||
120
156
  value.schemaVersion !== 1 ||
@@ -3,7 +3,7 @@ export { validatePlayPresetArtifactPayload } from "../../shared/artifact-payload
3
3
  import { defaultFollowupItems, } from "../../shared/ordered-followups.js";
4
4
  import { parseFollowupItems } from "./OrderedFollowups.js";
5
5
  import { builtinAuthorPrompts, defaultOrderedAuthorPrompts, } from "../../shared/ordered-author-prompts.js";
6
- import { parseOrderedPlayPrompts, migrateLegacyPlayPrompts, } from "./OrderedPlayPrompts.js";
6
+ import { parseOrderedPlayPrompts, migrateLegacyPlayPrompts, withTabletopPromptBuiltins, } from "./OrderedPlayPrompts.js";
7
7
  import { defaultOrderedPlayPrompts, } from "../../shared/ordered-play-prompts.js";
8
8
  import { createHash, randomUUID } from "node:crypto";
9
9
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
@@ -203,12 +203,17 @@ export class FileNativePlayPresetStore {
203
203
  };
204
204
  this.#locale = options.locale ?? (() => defaultAppLocale);
205
205
  }
206
+ /**
207
+ * Startup is the only place that upgrades stored presets. Reads never write:
208
+ * list, bind and freeze observe the library as it was last saved.
209
+ */
206
210
  async initialize() {
207
211
  await this.#change(async () => {
208
212
  const document = await this.#read();
209
213
  const migrated = this.#markLegacyBuiltinDefault(document);
210
214
  const synchronized = this.#synchronizeBuiltinDefault(document);
211
- if (migrated || synchronized)
215
+ const tabletop = this.#migrateTabletopPromptBuiltins(document);
216
+ if (migrated || synchronized || tabletop)
212
217
  await this.#write(document);
213
218
  });
214
219
  }
@@ -452,7 +457,8 @@ export class FileNativePlayPresetStore {
452
457
  .map((path) => ({ path, contents: binding.files[path] }));
453
458
  }
454
459
  async importPortable(input) {
455
- const files = toFileMap(input.files, this.#limits);
460
+ // A released preset gains the tabletop builtins at import, as at upgrade.
461
+ const files = withTabletopPromptBuiltins(toFileMap(input.files, this.#limits));
456
462
  const parsed = parsePlayPresetFiles(files, this.#limits);
457
463
  if (parsed.kind === "invalid")
458
464
  throw parsed.error;
@@ -561,6 +567,45 @@ export class FileNativePlayPresetStore {
561
567
  preset.builtinDefault = true;
562
568
  return true;
563
569
  }
570
+ /**
571
+ * One-time upgrade for ordered presets saved before the tabletop builtins
572
+ * existed. An affected revision is replaced by a new revision that inserts
573
+ * them; the old revision stays for frozen requests. A pending draft gets the
574
+ * same insertion, because a draft that kept the old list would fail the
575
+ * builtin-retention rule on save. The library is one atomically written file,
576
+ * so an interrupted upgrade leaves the released bytes and reruns cleanly.
577
+ */
578
+ #migrateTabletopPromptBuiltins(document) {
579
+ let changed = false;
580
+ for (const stored of document.presets) {
581
+ const current = stored.revisions[stored.currentRevision];
582
+ if (current !== undefined) {
583
+ const migrated = withTabletopPromptBuiltins(current);
584
+ if (migrated !== current) {
585
+ const revision = revisionForFiles(migrated, this.#limits);
586
+ stored.revisions[revision] = cloneFiles(migrated);
587
+ stored.currentRevision = revision;
588
+ changed = true;
589
+ }
590
+ }
591
+ const draft = stored.draftRevision === undefined
592
+ ? undefined
593
+ : stored.revisions[stored.draftRevision];
594
+ if (draft !== undefined) {
595
+ const migrated = withTabletopPromptBuiltins(draft);
596
+ if (migrated !== draft) {
597
+ const revision = revisionForFiles(migrated, this.#limits);
598
+ stored.revisions[revision] = cloneFiles(migrated);
599
+ if (revision === stored.currentRevision)
600
+ delete stored.draftRevision;
601
+ else
602
+ stored.draftRevision = revision;
603
+ changed = true;
604
+ }
605
+ }
606
+ }
607
+ return changed;
608
+ }
564
609
  #synchronizeBuiltinDefault(document) {
565
610
  const preset = document.presets.find(({ builtinDefault }) => builtinDefault === true);
566
611
  if (preset === undefined || preset.draftRevision !== undefined)
@@ -1,3 +1,4 @@
1
+ import { isResolutionReceipt } from "../../protocol/resolution.js";
1
2
  import { legacyModelBindingFailure, projectLegacyModelBindingFailure, } from "./LegacyModelBindingFailure.js";
2
3
  import { isPlayFailureKind, } from "../../protocol/playFailure.js";
3
4
  import { isNarrativeOrigin, } from "../history/HistoryBackground.js";
@@ -1028,6 +1029,9 @@ function assertPlayEvent(value) {
1028
1029
  return;
1029
1030
  }
1030
1031
  if (value.kind === "tool_result") {
1032
+ if (value.resolution !== undefined &&
1033
+ !isResolutionReceipt(value.resolution))
1034
+ throw new Error("Invalid resolution receipt");
1031
1035
  if (typeof value.callId !== "string" ||
1032
1036
  typeof value.name !== "string" ||
1033
1037
  typeof value.ok !== "boolean" ||
@@ -1091,6 +1095,9 @@ function assertTimelineEventSummary(value) {
1091
1095
  return;
1092
1096
  }
1093
1097
  if (value.kind === "tool_result") {
1098
+ if (value.resolution !== undefined &&
1099
+ !isResolutionReceipt(value.resolution))
1100
+ throw new Error("Invalid resolution receipt");
1094
1101
  if (typeof value.callId !== "string" ||
1095
1102
  typeof value.name !== "string" ||
1096
1103
  typeof value.ok !== "boolean" ||
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { parseDocument } from "yaml";
2
+ import { isMap, isSeq, parseDocument } from "yaml";
3
3
  import { builtinPlayPrompts, isPromptMessageRole, } from "../../shared/ordered-play-prompts.js";
4
4
  /** The same strict permission gate is used by portable imports and structured saves. */
5
5
  export function parseOrderedPlayPrompts(value, catalog = builtinPlayPrompts("en"), worldRequired = true) {
@@ -24,20 +24,23 @@ export function parseOrderedPlayPrompts(value, catalog = builtinPlayPrompts("en"
24
24
  !isPromptMessageRole(item.messageRole))
25
25
  return fail();
26
26
  const messageRole = item.messageRole === undefined ? {} : { messageRole: item.messageRole };
27
- const exact = (keys) => Object.keys(item).length ===
28
- keys.length + (item.messageRole === undefined ? 0 : 1) &&
29
- keys.every((key) => Object.hasOwn(item, key));
27
+ const exact = (keys, optional = []) => Object.keys(item).every((key) => keys.includes(key) ||
28
+ optional.includes(key) ||
29
+ key === "messageRole" ||
30
+ key === "id" ||
31
+ key === "kind") && keys.every((key) => Object.hasOwn(item, key));
30
32
  if (item.kind === "world" && exact(["id", "kind"])) {
31
33
  worlds++;
32
34
  return { id: item.id, kind: "world", ...messageRole };
33
35
  }
34
36
  if (item.kind === "user" &&
35
- exact(["id", "kind", "name", "enabled", "body"]) &&
37
+ exact(["id", "kind", "name", "enabled", "body"], ["gameplay"]) &&
36
38
  typeof item.name === "string" &&
37
39
  item.name.trim().length > 0 &&
38
40
  /^[^\r\n\0]{1,160}$/u.test(item.name) &&
39
41
  typeof item.body === "string" &&
40
- typeof item.enabled === "boolean")
42
+ typeof item.enabled === "boolean" &&
43
+ (item.gameplay === undefined || item.gameplay === "tabletop"))
41
44
  return {
42
45
  id: item.id,
43
46
  kind: "user",
@@ -45,6 +48,9 @@ export function parseOrderedPlayPrompts(value, catalog = builtinPlayPrompts("en"
45
48
  enabled: item.enabled,
46
49
  body: item.body,
47
50
  ...messageRole,
51
+ ...(item.gameplay === "tabletop"
52
+ ? { gameplay: "tabletop" }
53
+ : {}),
48
54
  };
49
55
  const builtin = catalog.find((entry) => entry.id === item.builtin);
50
56
  if (item.kind !== "builtin" ||
@@ -70,6 +76,54 @@ export function parseOrderedPlayPrompts(value, catalog = builtinPlayPrompts("en"
70
76
  return fail();
71
77
  return entries;
72
78
  }
79
+ /**
80
+ * Insert the tabletop builtins that an ordered preset does not list yet.
81
+ * play.tabletop goes just before the world placeholder and author.tabletop just
82
+ * after author.guidance. Files that are not ordered v2 or do not parse come back
83
+ * unchanged, and nothing else in them is rewritten. The same object is returned
84
+ * when nothing needs inserting, so callers can detect a change by identity.
85
+ */
86
+ export function withTabletopPromptBuiltins(files) {
87
+ const source = files["preset.yaml"];
88
+ if (source === undefined)
89
+ return files;
90
+ const document = parseDocument(source, { uniqueKeys: true });
91
+ if (document.errors.length > 0 ||
92
+ document.warnings.length > 0 ||
93
+ !isMap(document.contents) ||
94
+ document.get("format") !== "narraeon.play-preset/v2")
95
+ return files;
96
+ let changed = false;
97
+ const playPrompts = document.get("playPrompts", true);
98
+ if (isSeq(playPrompts) && !hasEntry(playPrompts, "play.tabletop")) {
99
+ const world = playPrompts.items.findIndex((item) => entryField(item, "kind") === "world");
100
+ playPrompts.items.splice(world < 0 ? playPrompts.items.length : world, 0, document.createNode({
101
+ id: "play.tabletop",
102
+ kind: "builtin",
103
+ builtin: "play.tabletop",
104
+ enabled: true,
105
+ }));
106
+ changed = true;
107
+ }
108
+ const authorPrompts = document.get("authorPrompts", true);
109
+ if (isSeq(authorPrompts) && !hasEntry(authorPrompts, "author.tabletop")) {
110
+ const guidance = authorPrompts.items.findIndex((item) => entryField(item, "id") === "author.guidance");
111
+ authorPrompts.items.splice(guidance < 0 ? authorPrompts.items.length : guidance + 1, 0, document.createNode({
112
+ id: "author.tabletop",
113
+ kind: "builtin",
114
+ builtin: "author.tabletop",
115
+ enabled: true,
116
+ }));
117
+ changed = true;
118
+ }
119
+ return changed ? { ...files, "preset.yaml": document.toString() } : files;
120
+ }
121
+ function hasEntry(sequence, id) {
122
+ return sequence.items.some((item) => entryField(item, "id") === id);
123
+ }
124
+ function entryField(item, key) {
125
+ return isMap(item) ? item.get(key) : undefined;
126
+ }
73
127
  /** Recognized v1 content becomes user text, never a locked builtin inferred from a path. */
74
128
  export function migrateLegacyPlayPrompts(files, narrative, excludedPaths = []) {
75
129
  const document = parseDocument(files["frame.yaml"] ?? "", {
@@ -185,5 +239,13 @@ export function migrateLegacyPlayPrompts(files, narrative, excludedPaths = []) {
185
239
  if ((path.startsWith("blocks/") || path.startsWith("prompts/")) &&
186
240
  path.endsWith(".md"))
187
241
  add(path, false);
242
+ // The tabletop builtin joins converted presets just before the world placeholder.
243
+ const worldIndex = entries.findIndex((entry) => entry.kind === "world");
244
+ entries.splice(worldIndex, 0, {
245
+ id: "play.tabletop",
246
+ kind: "builtin",
247
+ builtin: "play.tabletop",
248
+ enabled: true,
249
+ });
188
250
  return entries;
189
251
  }
@@ -1,5 +1,6 @@
1
1
  import { projectLegacyModelBindingFailure } from "./LegacyModelBindingFailure.js";
2
2
  import { classifyPlayFailure } from "./PlayFailure.js";
3
+ import { isResolutionTool, resolutionMarkdown, resolutionToolNames, resolveTabletop, seededRandom, } from "./TabletopResolution.js";
3
4
  import { captureNarrativeOrigin, snapshotBackground, narrativeBackground, } from "../history/HistoryBackground.js";
4
5
  import { isDeepStrictEqual } from "node:util";
5
6
  import { hasLegacyHistoryTools } from "../prompt/FileNativeToolRegistry.js";
@@ -18,6 +19,7 @@ import { parsePlayPresetFiles, } from "./FileNativePlayPresetStore.js";
18
19
  import { runPlayFollowupRequests, } from "./PlayFollowupRequests.js";
19
20
  import { FileNativePlayDocuments, fingerprintControl, } from "./PlayDocumentTools.js";
20
21
  const callChainToolNames = new Set([
22
+ ...resolutionToolNames,
21
23
  "state_list",
22
24
  "history_list",
23
25
  "history_search",
@@ -660,10 +662,15 @@ export class PlayCallChain {
660
662
  const timelineGeneration = playerRevisionTimelineGeneration(input.source.timelineGeneration, request.operationId, request.worldId, request.continuation);
661
663
  const requestFingerprint = playerRevisionRequestFingerprint(request, input.selectedEvent.committedHead, restoresHead);
662
664
  const traceRetention = preparePlayerRevisionTrace(sourceContext, input.selectedEventIndex);
665
+ // A timeline revision appends on the current audit head and restores the
666
+ // selected message's logical parent, so a chain that lags the world (an
667
+ // out-of-story world revision, or an earlier revision whose chain was not
668
+ // published) must not block it. The commit still rechecks this head under
669
+ // the world lock.
663
670
  const outcome = await this.#worlds.reviseTimeline({
664
671
  operationId: request.operationId,
665
672
  worldId: request.worldId,
666
- expectedCurrentHead: input.source.parentHead,
673
+ expectedCurrentHead: await this.#worlds.currentHead(request.worldId),
667
674
  restoresHead,
668
675
  replacesHead: input.selectedEvent.committedHead,
669
676
  replacementText: request.replacementText,
@@ -1152,8 +1159,15 @@ export class PlayCallChain {
1152
1159
  const prepared = [];
1153
1160
  const trailingEvents = [];
1154
1161
  let nextEventId = advance.eventId + 1;
1162
+ // One persisted seed per advance: every resolution call draws from it by
1163
+ // call identity, so recovery reproduces the same receipts.
1164
+ const resolutionSeed = calls.some(({ name }) => isResolutionTool(name))
1165
+ ? await this.#worlds.playAdvances.resolutionSeed(advance)
1166
+ : undefined;
1155
1167
  for (const call of calls) {
1156
- const item = prepareTool(session, workingTools, advance.exchange, call, this.#compiler.locale);
1168
+ const item = prepareTool(session, workingTools, advance.exchange, call, this.#compiler.locale, resolutionSeed);
1169
+ if (item.result.resolution !== undefined)
1170
+ crashAtPlayAdvanceEdge("after_resolution");
1157
1171
  prepared.push(item);
1158
1172
  trailingEvents.push({
1159
1173
  id: nextEventId++,
@@ -2068,7 +2082,7 @@ function appendCommittedHistory(session, head, messages) {
2068
2082
  });
2069
2083
  }
2070
2084
  }
2071
- function prepareTool(session, working, exchange, call, locale) {
2085
+ function prepareTool(session, working, exchange, call, locale, resolutionSeed) {
2072
2086
  const key = `${exchange}:${call.id}`;
2073
2087
  const signature = JSON.stringify(call.arguments);
2074
2088
  const completed = working.get(key);
@@ -2095,8 +2109,9 @@ function prepareTool(session, working, exchange, call, locale) {
2095
2109
  }
2096
2110
  else {
2097
2111
  try {
2098
- result =
2099
- call.name === "world_checkpoint"
2112
+ result = isResolutionTool(call.name)
2113
+ ? resolutionToolResult(call.name, call.arguments, resolutionSeed, key, locale)
2114
+ : call.name === "world_checkpoint"
2100
2115
  ? typeof call.arguments === "object" &&
2101
2116
  call.arguments !== null &&
2102
2117
  !Array.isArray(call.arguments) &&
@@ -2139,6 +2154,9 @@ function prepareTool(session, working, exchange, call, locale) {
2139
2154
  name: call.name,
2140
2155
  ok: result.ok,
2141
2156
  markdown: result.markdown,
2157
+ ...(result.resolution === undefined
2158
+ ? {}
2159
+ : { resolution: structuredClone(result.resolution) }),
2142
2160
  replayed,
2143
2161
  };
2144
2162
  return {
@@ -2409,6 +2427,16 @@ function notifyAssistantDelta(observer, delta) {
2409
2427
  // Like snapshot projection, the Provider exchange outlives its browser stream.
2410
2428
  }
2411
2429
  }
2430
+ function resolutionToolResult(name, args, seed, identity, locale) {
2431
+ if (seed === undefined)
2432
+ throw new Error("The resolution seed for this advance is unavailable.");
2433
+ const resolution = resolveTabletop(name, args, seededRandom(seed, identity));
2434
+ return {
2435
+ ok: true,
2436
+ markdown: resolutionMarkdown(resolution, locale),
2437
+ resolution,
2438
+ };
2439
+ }
2412
2440
  function crashAtPlayAdvanceEdge(edge) {
2413
2441
  if (process.env.NODE_ENV === "test" &&
2414
2442
  process.env.NARRAEON_INTERNAL_TEST_CRASH_AT_PLAY_ADVANCE_EDGE === edge)