narraeon 0.5.6 → 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 (59) hide show
  1. package/README.md +3 -1
  2. package/README.zh-CN.md +3 -1
  3. package/dist/node/protocol/appPreferences.js +4 -2
  4. package/dist/node/protocol/gameplay.js +69 -0
  5. package/dist/node/protocol/playFailure.js +13 -0
  6. package/dist/node/protocol/protagonistTemplates.js +13 -0
  7. package/dist/node/protocol/resolution.js +274 -0
  8. package/dist/node/protocol/v1.js +40 -19
  9. package/dist/node/protocol/worldArchives.js +7 -0
  10. package/dist/node/runtime/RuntimeErrorLog.js +95 -0
  11. package/dist/node/runtime/V1Runtime.js +103 -8
  12. package/dist/node/runtime/archive/WorldArchiveZip.js +202 -0
  13. package/dist/node/runtime/archive/WorldArchives.js +389 -0
  14. package/dist/node/runtime/config/AppPreferencesStore.js +23 -10
  15. package/dist/node/runtime/config/ProtagonistTemplateStore.js +134 -0
  16. package/dist/node/runtime/content/ContentWorkspace.js +15 -2
  17. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +43 -2
  18. package/dist/node/runtime/content/FileNativeContentTree.js +8 -1
  19. package/dist/node/runtime/history/StoryExport.js +33 -0
  20. package/dist/node/runtime/model/FileNativeModelAdapters.js +34 -2
  21. package/dist/node/runtime/model/ModelHost.js +12 -1
  22. package/dist/node/runtime/model/ModelHostStream.js +11 -3
  23. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +37 -1
  24. package/dist/node/runtime/play/FileNativePlayPresetStore.js +48 -3
  25. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +87 -4
  26. package/dist/node/runtime/play/LegacyModelBindingFailure.js +10 -0
  27. package/dist/node/runtime/play/OrderedPlayPrompts.js +68 -6
  28. package/dist/node/runtime/play/PlayCallChain.js +177 -57
  29. package/dist/node/runtime/play/PlayCallChainError.js +3 -1
  30. package/dist/node/runtime/play/PlayContinuity.js +3 -20
  31. package/dist/node/runtime/play/PlayFailure.js +20 -0
  32. package/dist/node/runtime/play/PlayTraceRetention.js +3 -11
  33. package/dist/node/runtime/play/TabletopResolution.js +372 -0
  34. package/dist/node/runtime/play/TabletopRules.js +141 -0
  35. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +49 -32
  36. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +37 -4
  37. package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +4 -1
  38. package/dist/node/runtime/setting/SettingAuthoringTransaction.js +56 -13
  39. package/dist/node/runtime/setting/SettingImprovementSession.js +6 -4
  40. package/dist/node/runtime/world/FileNativeWorldStore.js +207 -40
  41. package/dist/node/runtime/world/WorldActivity.js +143 -0
  42. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -0
  43. package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +35 -6
  44. package/dist/node/runtime/world-revision/WorldRevisionSession.js +9 -5
  45. package/dist/node/server/ConversationStream.js +2 -0
  46. package/dist/node/server/createServer.js +48 -0
  47. package/dist/node/shared/default-preset-host.js +10 -6
  48. package/dist/node/shared/ordered-author-prompts.js +8 -0
  49. package/dist/node/shared/ordered-play-prompts.js +25 -2
  50. package/dist/node/shared/sample-packages/index.js +10 -0
  51. package/dist/node/shared/sample-packages/rainy-inn-en.js +194 -0
  52. package/dist/node/shared/sample-packages/rainy-inn-zh.js +194 -0
  53. package/dist/node/shared/tabletop-guidance.js +100 -0
  54. package/dist/web/assets/index-BJ-1Mb0e.css +1 -0
  55. package/dist/web/assets/index-BgBL8G-A.js +1323 -0
  56. package/dist/web/index.html +2 -2
  57. package/package.json +6 -2
  58. package/dist/web/assets/index-BH8NyuE4.js +0 -1233
  59. package/dist/web/assets/index-CVN48xrw.css +0 -1
@@ -2,6 +2,9 @@ import { captureNarrativeOrigin, snapshotBackground, isHistoryBackground, } from
2
2
  import { FileNativeArtifactStore } from "../artifact/FileNativeArtifactStore.js";
3
3
  import { WorldExtensionRequests } from "../extension/WorldExtensionRequests.js";
4
4
  import { WorldExtensionControls, extensionDefinitions, } from "../extension/WorldExtensionControls.js";
5
+ import { assertSameGameplay, readGameplay, } from "../../protocol/gameplay.js";
6
+ import { HistoryQuery } from "../history/HistoryQuery.js";
7
+ import { exportStory } from "../history/StoryExport.js";
5
8
  import { PackageScriptPermissions } from "../extension/PackageScriptPermissions.js";
6
9
  import { createHash, randomUUID } from "node:crypto";
7
10
  import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile, } from "node:fs/promises";
@@ -11,6 +14,7 @@ import { inspectContentPackageCurrentTree, isContentPackageWorldDocumentPath, }
11
14
  import { FileNativePromptCompiler, } from "../prompt/FileNativePromptCompiler.js";
12
15
  import { PlayerViewRenderer } from "./PlayerViewRenderer.js";
13
16
  import { WorldDocumentStore } from "./WorldDocumentStore.js";
17
+ import { readWorldActivity, updateWorldActivity, } from "./WorldActivity.js";
14
18
  import { FileNativePlayTimelineStore } from "../play/FileNativePlayTimelineStore.js";
15
19
  import { FileNativePlayAdvanceStore } from "../play/FileNativePlayAdvanceStore.js";
16
20
  import { FileNativeWorldStorageMigrator } from "./FileNativeWorldStorageMigrator.js";
@@ -41,6 +45,7 @@ export class FileNativeWorldStore {
41
45
  #promptCompiler;
42
46
  #activeControlUsers = new Map();
43
47
  #openingWorldRevisions = new Set();
48
+ #archiveExports = new Set();
44
49
  operations;
45
50
  playTimeline;
46
51
  playAdvances;
@@ -112,16 +117,47 @@ export class FileNativeWorldStore {
112
117
  throw new FileNativeWorldCreationError("world_corrupt", "Released world storage could not be migrated to the current format", error instanceof Error ? { cause: error } : undefined);
113
118
  }
114
119
  }
120
+ async gameplay(worldId) {
121
+ assertIdentity(worldId, "World ID");
122
+ await this.ensureCurrentStorage(worldId);
123
+ const root = join(this.#worldsRoot, worldId);
124
+ await readPublicationAt(root);
125
+ return readWorldGameplayAt(root);
126
+ }
127
+ async readWorldSummary(worldId) {
128
+ assertIdentity(worldId, "World ID");
129
+ return readWorldSummaryAt(join(this.#worldsRoot, worldId));
130
+ }
115
131
  async listWorlds() {
116
132
  await mkdir(this.#worldsRoot, { recursive: true, mode: 0o700 });
117
- const entries = await readdir(this.#worldsRoot, { withFileTypes: true });
133
+ const entries = (await readdir(this.#worldsRoot, { withFileTypes: true })).filter((entry) => entry.isDirectory() && !entry.name.startsWith("."));
118
134
  const worlds = [];
119
- for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
120
- if (!entry.isDirectory() || entry.name.startsWith("."))
121
- continue;
122
- worlds.push(await readWorldSummaryAt(join(this.#worldsRoot, entry.name)));
135
+ // Bound concurrent shell/head/cache reads even in a large workspace.
136
+ for (let offset = 0; offset < entries.length; offset += 16) {
137
+ worlds.push(...(await Promise.all(entries
138
+ .slice(offset, offset + 16)
139
+ .map((entry) => readWorldSummaryAt(join(this.#worldsRoot, entry.name), undefined, (rebuild) => this.operations.withWorldAuthorityLock(entry.name, rebuild))))));
123
140
  }
124
- return worlds;
141
+ return worlds.sort((left, right) => (right.lastActivityAt ?? -1) - (left.lastActivityAt ?? -1) ||
142
+ left.title.localeCompare(right.title) ||
143
+ left.worldId.localeCompare(right.worldId));
144
+ }
145
+ /** Explicit full-history read; independent of page and model projections. */
146
+ async exportStory(worldId, format, locale) {
147
+ assertIdentity(worldId, "World ID");
148
+ await this.ensureCurrentStorage(worldId);
149
+ return this.operations.withWorldAuthorityLock(worldId, async () => {
150
+ const root = join(this.#worldsRoot, worldId);
151
+ const publication = await readPublicationAt(root);
152
+ const title = await readWorldTitleAt(root, publication);
153
+ const endpoint = await new FileNativeAuthorityV3(root).recoverHeadResult();
154
+ const history = new HistoryQuery(worldId, endpoint.history.map(({ messageId, role, exactText }) => ({
155
+ id: messageId,
156
+ role,
157
+ text: exactText,
158
+ })));
159
+ return exportStory(title, history.messages, format, locale);
160
+ });
125
161
  }
126
162
  async renameWorld(worldId, name) {
127
163
  assertIdentity(worldId, "World ID");
@@ -144,7 +180,7 @@ export class FileNativeWorldStore {
144
180
  worldId,
145
181
  name: trimmed,
146
182
  });
147
- return { ...toSummary(publication), title: trimmed };
183
+ return readWorldSummaryAt(root, publication);
148
184
  });
149
185
  }
150
186
  /**
@@ -321,13 +357,18 @@ export class FileNativeWorldStore {
321
357
  sourceFingerprint,
322
358
  };
323
359
  await writeJson(join(stagingRoot, publicationFile), publication);
360
+ await updateWorldActivity(stagingRoot, "genesis", [openingMessage], Date.now());
324
361
  await durableTree(stagingRoot);
325
362
  crashAtFileNativeWorldCreationEdge("after_staging");
326
363
  await rename(stagingRoot, finalRoot);
327
364
  await syncDirectory(this.#worldsRoot);
328
365
  crashAtFileNativeWorldCreationEdge("after_atomic_publish");
329
366
  await publishOperation(operationPath, publication);
330
- return { outcome: "created", world: toSummary(publication), preview };
367
+ return {
368
+ outcome: "created",
369
+ world: await this.#currentSummary(publication),
370
+ preview,
371
+ };
331
372
  }
332
373
  catch (error) {
333
374
  await rm(stagingRoot, { recursive: true, force: true }).catch(() => undefined);
@@ -398,6 +439,31 @@ export class FileNativeWorldStore {
398
439
  await readPublicationAt(root);
399
440
  return (await new FileNativeAuthorityV3(root).readHead()).head;
400
441
  }
442
+ async withArchiveExport(worldId, action) {
443
+ assertIdentity(worldId, "World ID");
444
+ if ((this.#activeControlUsers.get(worldId)?.size ?? 0) > 0 ||
445
+ this.#archiveExports.has(worldId) ||
446
+ this.#openingWorldRevisions.has(worldId))
447
+ throw new FileNativeWorldCreationError("operation_conflict", "Wait for the current generation or world operation before exporting a backup");
448
+ this.#archiveExports.add(worldId);
449
+ try {
450
+ if (await this.readWorldRevisionLock(worldId))
451
+ throw new FileNativeWorldCreationError("operation_conflict", "Please apply or discard the world revision before exporting a backup");
452
+ return await action();
453
+ }
454
+ finally {
455
+ this.#archiveExports.delete(worldId);
456
+ }
457
+ }
458
+ async archiveNeedsFreshContext(worldId) {
459
+ assertIdentity(worldId, "World ID");
460
+ return ((await readOptionalJson(join(this.#worldsRoot, worldId, "runtime", "archive-import.json"))) !== null ||
461
+ (await this.playTimeline.currentContextRequiresFreshContext(worldId)));
462
+ }
463
+ async completeArchiveFreshContext(worldId) {
464
+ assertIdentity(worldId, "World ID");
465
+ await rm(join(this.#worldsRoot, worldId, "runtime", "archive-import.json"), { force: true });
466
+ }
401
467
  async currentHeadOperationId(worldId) {
402
468
  assertIdentity(worldId, "World ID");
403
469
  await this.ensureCurrentStorage(worldId);
@@ -414,6 +480,7 @@ export class FileNativeWorldStore {
414
480
  await this.ensureCurrentStorage(worldId);
415
481
  const root = join(this.#worldsRoot, worldId);
416
482
  await readPublicationAt(root);
483
+ assertControlGameplayUnchanged(await readTree(join(root, "control")), files);
417
484
  const staging = join(root, `runtime/.control-draft-${randomUUID()}`);
418
485
  await writeSurface(staging, "control", files);
419
486
  const final = join(root, "runtime/control-draft");
@@ -431,7 +498,8 @@ export class FileNativeWorldStore {
431
498
  }
432
499
  async acquireWorldRevisionLock(worldId, epochId) {
433
500
  if ((this.#activeControlUsers.get(worldId)?.size ?? 0) > 0 ||
434
- this.#openingWorldRevisions.has(worldId))
501
+ this.#openingWorldRevisions.has(worldId) ||
502
+ this.#archiveExports.has(worldId))
435
503
  return { kind: "busy" };
436
504
  this.#openingWorldRevisions.add(worldId);
437
505
  try {
@@ -503,6 +571,7 @@ export class FileNativeWorldStore {
503
571
  }
504
572
  if (currentFingerprint !== input.expectedFingerprint)
505
573
  throw new FileNativeWorldCreationError("operation_conflict", "World control changed after this revision epoch opened");
574
+ assertControlGameplayUnchanged(current, input.files);
506
575
  await rm(stagingRoot, { recursive: true, force: true });
507
576
  await rm(previousPath, { recursive: true, force: true });
508
577
  await writeSurface(stagingRoot, "control", input.files);
@@ -559,6 +628,7 @@ export class FileNativeWorldStore {
559
628
  throw new FileNativeWorldCreationError("operation_conflict", "World control is frozen by a running attempt");
560
629
  const preview = await this.previewControlDraft(worldId, prompt);
561
630
  const root = join(this.#worldsRoot, worldId);
631
+ assertControlGameplayUnchanged(await readTree(join(root, "control")), await readTree(join(root, "runtime/control-draft")));
562
632
  const draft = join(root, "runtime/control-draft");
563
633
  const replacement = join(root, `runtime/.control-apply-${randomUUID()}`);
564
634
  await rename(draft, replacement);
@@ -587,6 +657,8 @@ export class FileNativeWorldStore {
587
657
  freezeControl(worldId, operationId) {
588
658
  assertIdentity(worldId, "World ID");
589
659
  assertIdentity(operationId, "operation ID");
660
+ if (this.#archiveExports.has(worldId))
661
+ throw new FileNativeWorldCreationError("operation_conflict", "A world backup is being exported; wait for it to finish");
590
662
  if (this.#openingWorldRevisions.has(worldId))
591
663
  throw new FileNativeWorldCreationError("operation_conflict", "World control is locked while a world revision opens");
592
664
  const users = this.#activeControlUsers.get(worldId) ?? new Set();
@@ -796,32 +868,51 @@ export class FileNativeWorldStore {
796
868
  if (input.requestDiscriminator !== undefined)
797
869
  assertIdentity(input.requestDiscriminator, "Fork request discriminator");
798
870
  if (input.sourceHead !== "genesis" &&
871
+ input.sourceHead !== "current" &&
799
872
  !/^commit:[1-9][0-9]*$/u.test(input.sourceHead))
800
873
  throw new TypeError("Source endpoint is invalid");
801
- const operationPath = this.#derivationOperationPath(input.operationId);
874
+ if (input.targetName !== undefined && !validWorldName(input.targetName))
875
+ throw new TypeError("Target world name is invalid");
876
+ if (input.publish === false && input.targetWorldRoot === undefined)
877
+ throw new TypeError("An unpublished closure requires a target directory");
878
+ const target = input.targetDataRoot === undefined
879
+ ? this
880
+ : new FileNativeWorldStore(input.targetDataRoot);
881
+ const operationPath = input.identityPrefix === "import"
882
+ ? join(target.#operationsRoot, `import-${operationDigest(input.operationId)}.json`)
883
+ : target.#derivationOperationPath(input.operationId);
802
884
  const requestFingerprint = derivationRequestFingerprint(input);
803
- const worldId = `world-${operationDigest(`derive:${input.operationId}`).slice(0, 24)}`;
804
- const finalRoot = join(this.#worldsRoot, worldId);
805
- const previous = await readOptionalJson(operationPath);
885
+ const worldId = `world-${operationDigest(`${input.identityPrefix ?? "derive"}:${input.operationId}`).slice(0, 24)}`;
886
+ const finalRoot = input.publish === false
887
+ ? resolve(input.targetWorldRoot)
888
+ : join(target.#worldsRoot, worldId);
889
+ const previous = input.publish === false
890
+ ? null
891
+ : await readOptionalJson(operationPath);
806
892
  if (previous !== null) {
807
- await this.ensureCurrentStorage(previous.worldId);
808
- if (!(await derivationPublicationMatches(join(this.#worldsRoot, previous.worldId), previous, input, requestFingerprint)))
893
+ await target.ensureCurrentStorage(previous.worldId);
894
+ if (!(await derivationPublicationMatches(join(target.#worldsRoot, previous.worldId), previous, input, requestFingerprint)))
809
895
  throw new FileNativeWorldCreationError("operation_conflict", "The same fork operation ID is bound to a different fork payload");
810
896
  return {
811
897
  outcome: "derived",
812
- world: await this.#currentSummary(previous),
898
+ world: await target.#currentSummary(previous),
899
+ worldRoot: finalRoot,
813
900
  };
814
901
  }
815
902
  const alreadyPublished = await readOptionalJson(join(finalRoot, publicationFile));
816
903
  if (alreadyPublished !== null) {
817
- await this.ensureCurrentStorage(alreadyPublished.worldId);
904
+ if (input.publish !== false)
905
+ await target.ensureCurrentStorage(alreadyPublished.worldId);
818
906
  if (!(await derivationPublicationMatches(finalRoot, alreadyPublished, input, requestFingerprint)))
819
907
  throw new FileNativeWorldCreationError("operation_conflict", "Published fork world does not match the operation payload");
820
- await mkdir(this.#operationsRoot, { recursive: true, mode: 0o700 });
821
- await publishJson(operationPath, alreadyPublished);
908
+ if (input.publish !== false) {
909
+ await mkdir(target.#operationsRoot, { recursive: true, mode: 0o700 });
910
+ await publishJson(operationPath, alreadyPublished);
911
+ }
822
912
  return {
823
913
  outcome: "derived",
824
- world: await this.#currentSummary(alreadyPublished),
914
+ world: await readWorldSummaryAt(finalRoot, alreadyPublished),
915
+ worldRoot: finalRoot,
825
916
  };
826
917
  }
827
918
  await this.ensureCurrentStorage(input.sourceWorldId);
@@ -830,24 +921,37 @@ export class FileNativeWorldStore {
830
921
  const sourcePublication = await readPublicationAt(sourceRoot);
831
922
  const sourceSummary = await readWorldSummaryAt(sourceRoot, sourcePublication);
832
923
  const sourceAuthority = new FileNativeAuthorityV3(sourceRoot);
924
+ const sourceHead = input.sourceHead === "current"
925
+ ? (await sourceAuthority.readHead()).head
926
+ : input.sourceHead;
927
+ if (input.requireMaterialized) {
928
+ const authorityHead = await sourceAuthority.readHead();
929
+ const materialized = await readMaterializedCheckpoint(sourceRoot);
930
+ if (materialized?.head !== authorityHead.head ||
931
+ materialized.commitDigest !== authorityHead.commitDigest)
932
+ throw new FileNativeWorldCreationError("inconsistent_materialization", "Please repair the world before exporting a backup");
933
+ }
833
934
  const [sourceGenesis, control] = await Promise.all([
834
935
  readGenesisForFork(sourceRoot, input.sourceWorldId),
835
936
  readTree(join(sourceRoot, "control")),
836
937
  ]);
837
- const staging = join(this.#worldsRoot, `.staging-${worldId}-${randomUUID()}`);
838
- await mkdir(this.#worldsRoot, { recursive: true, mode: 0o700 });
839
- await mkdir(this.#operationsRoot, { recursive: true, mode: 0o700 });
938
+ const staging = join(dirname(finalRoot), `.staging-${worldId}-${randomUUID()}`);
939
+ await mkdir(dirname(finalRoot), { recursive: true, mode: 0o700 });
940
+ if (input.publish !== false)
941
+ await mkdir(target.#operationsRoot, { recursive: true, mode: 0o700 });
840
942
  try {
841
943
  await mkdir(join(staging, "runtime"), { recursive: true });
842
944
  const clonedHead = await sourceAuthority.clonePrefixTo({
843
945
  targetWorldRoot: staging,
844
- selectedHead: input.sourceHead,
946
+ selectedHead: sourceHead,
845
947
  operationId: input.operationId,
846
948
  });
847
949
  const selected = await new FileNativeAuthorityV3(staging).recoverHeadResult();
848
950
  await writeSurface(staging, "state", selected.state);
849
951
  await writeSurface(staging, "control", control);
850
- await PackageScriptPermissions.write(staging, await PackageScriptPermissions.read(sourceRoot));
952
+ await PackageScriptPermissions.write(staging, input.resetPackageScriptPermissions
953
+ ? []
954
+ : await PackageScriptPermissions.read(sourceRoot));
851
955
  await WorldExtensionControls.copy(sourceRoot, staging);
852
956
  await mkdir(join(staging, "history"), { recursive: true });
853
957
  for (const file of historySurfaceFiles(selected.history))
@@ -871,22 +975,23 @@ export class FileNativeWorldStore {
871
975
  history: genesis.history,
872
976
  });
873
977
  await writeJson(join(staging, "runtime", "additional-materials.json"), {
874
- head: input.sourceHead,
978
+ head: sourceHead,
875
979
  items: selected.additionalMaterials,
876
980
  });
877
981
  await writeJson(join(staging, "runtime", "materialized-head.json"), {
878
982
  schemaVersion: 3,
879
- head: input.sourceHead,
983
+ head: sourceHead,
880
984
  sequence: clonedHead.sequence,
881
985
  commitDigest: clonedHead.commitDigest,
882
986
  ...backgroundCount(selected.history),
883
987
  });
884
988
  await input.stageTarget?.({
989
+ sourceHead,
885
990
  targetWorldRoot: staging,
886
991
  targetWorldId: worldId,
887
992
  binding: {
888
993
  worldId,
889
- parentHead: input.sourceHead,
994
+ parentHead: sourceHead,
890
995
  files: [...selected.state, ...control].reduce((files, { path, contents }, index) => {
891
996
  files[`${index < selected.state.length ? "state" : "control"}/${path}`] = contents;
892
997
  return files;
@@ -899,23 +1004,34 @@ export class FileNativeWorldStore {
899
1004
  ])),
900
1005
  },
901
1006
  });
1007
+ if (input.identityPrefix === "import" ||
1008
+ (await this.archiveNeedsFreshContext(input.sourceWorldId)))
1009
+ await writeJson(join(staging, "runtime", "archive-import.json"), {
1010
+ schemaVersion: 1,
1011
+ });
902
1012
  crashAtFileNativeAuthorityEdge("derivation_after_target_staging");
903
1013
  const publication = {
904
1014
  schemaVersion: 1,
905
1015
  worldId,
906
- title: derivedWorldName(sourceSummary.title),
1016
+ title: input.targetName ?? derivedWorldName(sourceSummary.title),
907
1017
  parentEndpoint: "genesis",
908
1018
  operationId: input.operationId,
909
1019
  sourceFingerprint: requestFingerprint,
910
1020
  };
911
1021
  await writeJson(join(staging, publicationFile), publication);
1022
+ await updateWorldActivity(staging, sourceHead, selected.history, Date.now());
912
1023
  await durableTree(staging);
913
1024
  crashAtFileNativeAuthorityEdge("derivation_before_publish");
914
1025
  await rename(staging, finalRoot);
915
- await syncDirectory(this.#worldsRoot);
1026
+ await syncDirectory(dirname(finalRoot));
916
1027
  crashAtFileNativeAuthorityEdge("derivation_after_publish");
917
- await publishJson(operationPath, publication);
918
- return { outcome: "derived", world: toSummary(publication) };
1028
+ if (input.publish !== false)
1029
+ await publishJson(operationPath, publication);
1030
+ return {
1031
+ outcome: "derived",
1032
+ world: await readWorldSummaryAt(finalRoot, publication),
1033
+ worldRoot: finalRoot,
1034
+ };
919
1035
  }
920
1036
  catch (error) {
921
1037
  await rm(staging, { recursive: true, force: true }).catch(() => undefined);
@@ -923,8 +1039,13 @@ export class FileNativeWorldStore {
923
1039
  if (published !== null) {
924
1040
  if (!(await derivationPublicationMatches(finalRoot, published, input, requestFingerprint)))
925
1041
  throw new FileNativeWorldCreationError("operation_conflict", "Published fork world does not match the operation payload");
926
- await publishJson(operationPath, published);
927
- return { outcome: "derived", world: toSummary(published) };
1042
+ if (input.publish !== false)
1043
+ await publishJson(operationPath, published);
1044
+ return {
1045
+ outcome: "derived",
1046
+ world: await readWorldSummaryAt(finalRoot, published),
1047
+ worldRoot: finalRoot,
1048
+ };
928
1049
  }
929
1050
  throw error;
930
1051
  }
@@ -1285,12 +1406,13 @@ export class FileNativeWorldStore {
1285
1406
  });
1286
1407
  crashAtFileNativeAuthorityEdge("after_acceptance_prepared");
1287
1408
  await authorityStore.publishPrepared(preparedAppend);
1409
+ const acceptedAt = Date.now();
1288
1410
  crashAtFileNativeAuthorityEdge("after_commit_acceptance");
1289
1411
  await publishJson(this.#operationOutcomePath(input.operationId), pending);
1290
1412
  try {
1291
1413
  const recovered = await authorityStore.recover(commit.head);
1292
1414
  await assertPendingMaterializationCompatible(root, pending);
1293
- await materializeRecoveredEndpoint(root, preparedAppend.nextHead, recovered);
1415
+ await materializeRecoveredEndpoint(root, preparedAppend.nextHead, recovered, acceptedAt);
1294
1416
  crashAtFileNativeAuthorityEdge("after_materialization");
1295
1417
  const outcome = { ...pending, outcome: "committed" };
1296
1418
  await publishJson(this.#operationOutcomePath(input.operationId), outcome);
@@ -1792,7 +1914,7 @@ function isPlayHistoryAppendInput(value) {
1792
1914
  (value.role === "player" || value.role === "narrator") &&
1793
1915
  typeof value.exactText === "string");
1794
1916
  }
1795
- async function materializeRecoveredEndpoint(root, authority, endpoint) {
1917
+ async function materializeRecoveredEndpoint(root, authority, endpoint, lastActivityAt = null) {
1796
1918
  if (authority.head !== endpoint.head)
1797
1919
  throw new FileNativeWorldCreationError("world_corrupt", "Recovered endpoint does not match the Authority head to materialize");
1798
1920
  await replaceMaterializedTree(join(root, "state"), endpoint.state);
@@ -1810,6 +1932,7 @@ async function materializeRecoveredEndpoint(root, authority, endpoint) {
1810
1932
  commitDigest: authority.commitDigest,
1811
1933
  ...backgroundCount(endpoint.history),
1812
1934
  });
1935
+ await updateWorldActivity(root, authority.head, endpoint.history, lastActivityAt);
1813
1936
  }
1814
1937
  async function replaceMaterializedTree(root, files) {
1815
1938
  const next = new Map();
@@ -2065,14 +2188,35 @@ async function readGenesisForFork(worldRoot, worldId) {
2065
2188
  async function readPublicationAt(root) {
2066
2189
  return readJson(join(root, publicationFile));
2067
2190
  }
2068
- async function readWorldSummaryAt(root, publication) {
2191
+ async function readWorldSummaryAt(root, publication, serializeRebuild) {
2069
2192
  const published = publication ?? (await readPublicationAt(root));
2193
+ const gameplay = await readWorldGameplayAt(root);
2194
+ const { lastActivityAt, excerpt, playerTurns } = await readWorldActivity(root, serializeRebuild);
2195
+ const summary = {
2196
+ ...toSummary(published, gameplay),
2197
+ title: await readWorldTitleAt(root, published),
2198
+ lastActivityAt,
2199
+ excerpt,
2200
+ playerTurns,
2201
+ };
2202
+ return summary;
2203
+ }
2204
+ async function readWorldTitleAt(root, published) {
2070
2205
  const metadata = await readOptionalJson(join(root, localMetadataFile));
2071
2206
  if (metadata === null)
2072
- return toSummary(published);
2207
+ return published.title;
2073
2208
  if (!isWorldLocalMetadata(metadata) || metadata.worldId !== published.worldId)
2074
2209
  throw new FileNativeWorldCreationError("world_corrupt", "Local world shell is corrupt");
2075
- return { ...toSummary(published), title: metadata.name };
2210
+ return metadata.name;
2211
+ }
2212
+ /** Worlds copy the package's gameplay identity with control; its absence means interactive fiction. */
2213
+ async function readWorldGameplayAt(root) {
2214
+ try {
2215
+ return readGameplay(controlGameplayFiles(await readTree(join(root, "control"))));
2216
+ }
2217
+ catch (error) {
2218
+ throw new FileNativeWorldCreationError("world_corrupt", "World gameplay identity is corrupt", { cause: error });
2219
+ }
2076
2220
  }
2077
2221
  async function readJson(path) {
2078
2222
  return JSON.parse(await readFile(path, "utf8"));
@@ -2087,13 +2231,26 @@ async function readOptionalJson(path) {
2087
2231
  throw error;
2088
2232
  }
2089
2233
  }
2090
- function toSummary(publication) {
2234
+ function toSummary(publication, gameplay) {
2091
2235
  return {
2092
2236
  worldId: publication.worldId,
2093
2237
  title: publication.title,
2094
2238
  parentEndpoint: publication.parentEndpoint,
2239
+ gameplay,
2095
2240
  };
2096
2241
  }
2242
+ /** World control is a fixed mode and scheme: drafts, revisions and applies may not change either. */
2243
+ function assertControlGameplayUnchanged(current, next) {
2244
+ try {
2245
+ assertSameGameplay(controlGameplayFiles(current), controlGameplayFiles(next));
2246
+ }
2247
+ catch (error) {
2248
+ throw new FileNativeWorldCreationError("candidate_validation_failed", error instanceof Error ? error.message : "World gameplay is fixed", { cause: error });
2249
+ }
2250
+ }
2251
+ function controlGameplayFiles(files) {
2252
+ return files.map((file) => ({ ...file, path: `control/${file.path}` }));
2253
+ }
2097
2254
  const worldNamePattern = /^[^\r\n]{1,160}$/u;
2098
2255
  function validWorldName(name) {
2099
2256
  return worldNamePattern.test(name);
@@ -2111,6 +2268,16 @@ function derivationRequestFingerprint(input) {
2111
2268
  sourceHead: input.sourceHead,
2112
2269
  hostPresetId: input.hostPresetId,
2113
2270
  requestDiscriminator: input.requestDiscriminator ?? null,
2271
+ ...(input.retainCompleteTimeline ? { retainCompleteTimeline: true } : {}),
2272
+ ...(input.targetName === undefined &&
2273
+ input.identityPrefix === undefined &&
2274
+ !input.resetPackageScriptPermissions
2275
+ ? {}
2276
+ : {
2277
+ targetName: input.targetName ?? null,
2278
+ identityPrefix: input.identityPrefix ?? "derive",
2279
+ resetPackageScriptPermissions: input.resetPackageScriptPermissions ?? false,
2280
+ }),
2114
2281
  }));
2115
2282
  }
2116
2283
  async function derivationPublicationMatches(worldRoot, publication, input, requestFingerprint) {
@@ -0,0 +1,143 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { FileNativeAuthorityV3 } from "./FileNativeAuthorityV3.js";
5
+ import { fileNativeHistoryMessageIdFromProjectionPath } from "./FileNativeHistoryProjection.js";
6
+ /** Read only the small head/cache on a hit; repair misses from the local projection. */
7
+ export async function readWorldActivity(root, serializeRebuild = (rebuild) => rebuild()) {
8
+ const head = await currentHead(root);
9
+ const cached = await readCache(root);
10
+ if (cached?.head === head)
11
+ return cached;
12
+ return serializeRebuild(async () => {
13
+ const latestHead = await currentHead(root);
14
+ const latestCache = await readCache(root);
15
+ if (latestCache?.head === latestHead)
16
+ return latestCache;
17
+ const empty = {
18
+ schemaVersion: 1,
19
+ head: latestHead,
20
+ lastActivityAt: null,
21
+ excerpt: null,
22
+ playerTurns: 0,
23
+ };
24
+ // An accepted endpoint may still be awaiting recovery. Never label its old
25
+ // materialized history as a valid cache for the new endpoint.
26
+ const materialized = await readJson(join(root, "runtime/materialized-head.json"));
27
+ if (!isRecord(materialized) || materialized.head !== latestHead)
28
+ return empty;
29
+ const entries = await readdir(join(root, "history"), {
30
+ withFileTypes: true,
31
+ });
32
+ const messages = entries
33
+ .filter((entry) => entry.isFile())
34
+ .map((entry) => ({
35
+ path: entry.name,
36
+ id: fileNativeHistoryMessageIdFromProjectionPath(entry.name),
37
+ }))
38
+ .filter((message) => message.id !== null)
39
+ .sort((left, right) => {
40
+ const a = left.path.split("-");
41
+ const b = right.path.split("-");
42
+ return Number(a[0]) - Number(b[0]) || Number(a[1]) - Number(b[1]);
43
+ });
44
+ const narrator = messages.findLast(({ id }) => id.endsWith(".narrator"));
45
+ const activity = {
46
+ ...empty,
47
+ excerpt: narrator === undefined
48
+ ? null
49
+ : excerpt(await readFile(join(root, "history", narrator.path), "utf8")),
50
+ playerTurns: messages.filter(({ id }) => id.endsWith(".player")).length,
51
+ };
52
+ await publishCache(root, activity);
53
+ return activity;
54
+ });
55
+ }
56
+ /** Best effort: activity is never part of accepting or recovering Authority. */
57
+ export async function updateWorldActivity(root, head, history, lastActivityAt) {
58
+ const narrator = history.findLast(({ role }) => role === "narrator");
59
+ // Recovery cannot reconstruct a clock time, but can retain a matching cache.
60
+ const previous = lastActivityAt === null ? await readCache(root) : null;
61
+ await publishCache(root, {
62
+ schemaVersion: 1,
63
+ head,
64
+ lastActivityAt: lastActivityAt ??
65
+ (previous?.head === head ? previous.lastActivityAt : null),
66
+ excerpt: narrator === undefined ? null : excerpt(narrator.exactText),
67
+ playerTurns: history.filter(({ role }) => role === "player").length,
68
+ });
69
+ }
70
+ function excerpt(text) {
71
+ const singleLine = text.replace(/[\r\n\u2028\u2029]+/gu, " ").trim();
72
+ return singleLine === ""
73
+ ? null
74
+ : Array.from(singleLine).slice(0, 80).join("");
75
+ }
76
+ async function currentHead(root) {
77
+ try {
78
+ return (await new FileNativeAuthorityV3(root).readHead()).head;
79
+ }
80
+ catch (error) {
81
+ if (!isMissing(error))
82
+ throw error;
83
+ // Listing a released layout must not trigger an Authority migration/replay.
84
+ const checkpoint = await readJson(join(root, "runtime/materialized-head.json"));
85
+ return isRecord(checkpoint) && typeof checkpoint.head === "string"
86
+ ? checkpoint.head
87
+ : "genesis";
88
+ }
89
+ }
90
+ async function readCache(root) {
91
+ const value = await readJson(join(root, "runtime/activity.json")).catch(() => null);
92
+ return isRecord(value) &&
93
+ Object.keys(value).length === 5 &&
94
+ value.schemaVersion === 1 &&
95
+ typeof value.head === "string" &&
96
+ /^(?:genesis|commit:[1-9][0-9]*)$/u.test(value.head) &&
97
+ (value.lastActivityAt === null ||
98
+ (typeof value.lastActivityAt === "number" &&
99
+ Number.isSafeInteger(value.lastActivityAt) &&
100
+ value.lastActivityAt >= 0 &&
101
+ Number.isFinite(new Date(value.lastActivityAt).valueOf()))) &&
102
+ (value.excerpt === null ||
103
+ (typeof value.excerpt === "string" &&
104
+ Array.from(value.excerpt).length <= 80 &&
105
+ !/[\r\n\u2028\u2029]/u.test(value.excerpt))) &&
106
+ typeof value.playerTurns === "number" &&
107
+ Number.isSafeInteger(value.playerTurns) &&
108
+ value.playerTurns >= 0
109
+ ? value
110
+ : null;
111
+ }
112
+ async function readJson(path) {
113
+ try {
114
+ return JSON.parse(await readFile(path, "utf8"));
115
+ }
116
+ catch (error) {
117
+ if (isMissing(error) || error instanceof SyntaxError)
118
+ return null;
119
+ throw error;
120
+ }
121
+ }
122
+ async function publishCache(root, activity) {
123
+ const path = join(root, "runtime/activity.json");
124
+ const temporary = `${path}.${randomUUID()}.tmp`;
125
+ try {
126
+ await writeFile(temporary, `${JSON.stringify(activity)}\n`, {
127
+ mode: 0o600,
128
+ });
129
+ await rename(temporary, path);
130
+ }
131
+ catch (error) {
132
+ console.warn("Could not write world activity cache", root, error);
133
+ }
134
+ finally {
135
+ await rm(temporary, { force: true }).catch(() => undefined);
136
+ }
137
+ }
138
+ function isRecord(value) {
139
+ return typeof value === "object" && value !== null && !Array.isArray(value);
140
+ }
141
+ function isMissing(error) {
142
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
143
+ }
@@ -4,6 +4,7 @@ import { validAuthoringRequests, } from "../authoring/AuthoringRequestSnapshot.j
4
4
  import { createHash, randomUUID } from "node:crypto";
5
5
  import { mkdir, open, readFile, readdir, rename, rm } from "node:fs/promises";
6
6
  import { dirname, join, resolve } from "node:path";
7
+ import { gameplayPath } from "../../protocol/gameplay.js";
7
8
  import { contentTreeFingerprint } from "../content/ContentTreeFingerprint.js";
8
9
  import { isPlayPresetBinding, } from "../play/FileNativePlayPresetStore.js";
9
10
  const uuidV4Pattern = "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}";
@@ -604,6 +605,7 @@ function validRevisionPath(path) {
604
605
  return (safe &&
605
606
  (isPackageFollowupControlPath(path) ||
606
607
  /^state\/.+\.(?:ya?ml|md)$/u.test(path) ||
608
+ path === gameplayPath ||
607
609
  path === "control/frame.yaml" ||
608
610
  path === "control/player-views.yaml" ||
609
611
  /^control\/blocks\/.+\.md$/u.test(path)));