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.
- package/README.md +3 -1
- package/README.zh-CN.md +3 -1
- package/dist/node/protocol/appPreferences.js +4 -2
- package/dist/node/protocol/gameplay.js +69 -0
- package/dist/node/protocol/playFailure.js +13 -0
- package/dist/node/protocol/protagonistTemplates.js +13 -0
- package/dist/node/protocol/resolution.js +274 -0
- package/dist/node/protocol/v1.js +40 -19
- package/dist/node/protocol/worldArchives.js +7 -0
- package/dist/node/runtime/RuntimeErrorLog.js +95 -0
- package/dist/node/runtime/V1Runtime.js +103 -8
- package/dist/node/runtime/archive/WorldArchiveZip.js +202 -0
- package/dist/node/runtime/archive/WorldArchives.js +389 -0
- package/dist/node/runtime/config/AppPreferencesStore.js +23 -10
- package/dist/node/runtime/config/ProtagonistTemplateStore.js +134 -0
- package/dist/node/runtime/content/ContentWorkspace.js +15 -2
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +43 -2
- package/dist/node/runtime/content/FileNativeContentTree.js +8 -1
- package/dist/node/runtime/history/StoryExport.js +33 -0
- package/dist/node/runtime/model/FileNativeModelAdapters.js +34 -2
- package/dist/node/runtime/model/ModelHost.js +12 -1
- package/dist/node/runtime/model/ModelHostStream.js +11 -3
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +37 -1
- package/dist/node/runtime/play/FileNativePlayPresetStore.js +48 -3
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +87 -4
- package/dist/node/runtime/play/LegacyModelBindingFailure.js +10 -0
- package/dist/node/runtime/play/OrderedPlayPrompts.js +68 -6
- package/dist/node/runtime/play/PlayCallChain.js +177 -57
- package/dist/node/runtime/play/PlayCallChainError.js +3 -1
- package/dist/node/runtime/play/PlayContinuity.js +3 -20
- package/dist/node/runtime/play/PlayFailure.js +20 -0
- package/dist/node/runtime/play/PlayTraceRetention.js +3 -11
- package/dist/node/runtime/play/TabletopResolution.js +372 -0
- package/dist/node/runtime/play/TabletopRules.js +141 -0
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +49 -32
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +37 -4
- package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +4 -1
- package/dist/node/runtime/setting/SettingAuthoringTransaction.js +56 -13
- package/dist/node/runtime/setting/SettingImprovementSession.js +6 -4
- package/dist/node/runtime/world/FileNativeWorldStore.js +207 -40
- package/dist/node/runtime/world/WorldActivity.js +143 -0
- package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -0
- package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +35 -6
- package/dist/node/runtime/world-revision/WorldRevisionSession.js +9 -5
- package/dist/node/server/ConversationStream.js +2 -0
- package/dist/node/server/createServer.js +48 -0
- package/dist/node/shared/default-preset-host.js +10 -6
- package/dist/node/shared/ordered-author-prompts.js +8 -0
- package/dist/node/shared/ordered-play-prompts.js +25 -2
- package/dist/node/shared/sample-packages/index.js +10 -0
- package/dist/node/shared/sample-packages/rainy-inn-en.js +194 -0
- package/dist/node/shared/sample-packages/rainy-inn-zh.js +194 -0
- package/dist/node/shared/tabletop-guidance.js +100 -0
- package/dist/web/assets/index-BJ-1Mb0e.css +1 -0
- package/dist/web/assets/index-BgBL8G-A.js +1323 -0
- package/dist/web/index.html +2 -2
- package/package.json +6 -2
- package/dist/web/assets/index-BH8NyuE4.js +0 -1233
- 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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
804
|
-
const finalRoot =
|
|
805
|
-
|
|
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
|
|
808
|
-
if (!(await derivationPublicationMatches(join(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
821
|
-
|
|
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
|
|
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(
|
|
838
|
-
await mkdir(
|
|
839
|
-
|
|
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:
|
|
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,
|
|
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:
|
|
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:
|
|
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:
|
|
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(
|
|
1026
|
+
await syncDirectory(dirname(finalRoot));
|
|
916
1027
|
crashAtFileNativeAuthorityEdge("derivation_after_publish");
|
|
917
|
-
|
|
918
|
-
|
|
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
|
-
|
|
927
|
-
|
|
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
|
|
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
|
|
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)));
|