narraeon 0.5.7 → 0.6.1
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 +13 -0
- package/README.zh-CN.md +10 -0
- package/dist/node/protocol/gameplay.js +69 -0
- package/dist/node/protocol/resolution.js +274 -0
- package/dist/node/protocol/v1.js +15 -2
- package/dist/node/runtime/FileNativeExclusiveLink.js +39 -0
- package/dist/node/runtime/RuntimeErrorLog.js +95 -0
- package/dist/node/runtime/V1Runtime.js +58 -7
- package/dist/node/runtime/archive/WorldArchives.js +14 -46
- package/dist/node/runtime/artifact/ArtifactStoreFiles.js +330 -0
- package/dist/node/runtime/artifact/FileNativeArtifactStore.js +495 -1535
- package/dist/node/runtime/artifact/LegacyArtifactStoreMigration.js +260 -0
- package/dist/node/runtime/config/AppPreferencesStore.js +0 -1
- package/dist/node/runtime/config/ProtagonistTemplateStore.js +0 -1
- package/dist/node/runtime/config/dataRootLock.js +108 -0
- package/dist/node/runtime/content/ContentWorkspace.js +10 -12
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +50 -46
- package/dist/node/runtime/content/FileNativeContentTree.js +8 -1
- package/dist/node/runtime/extension/PackageScriptPermissions.js +0 -8
- package/dist/node/runtime/extension/WorldExtensionControls.js +0 -8
- package/dist/node/runtime/model/ModelConnectionStore.js +0 -1
- package/dist/node/runtime/play/FileNativeContinuityCorrection.js +3 -14
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +31 -170
- package/dist/node/runtime/play/FileNativePlayPresetStore.js +48 -3
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +36 -239
- package/dist/node/runtime/play/OrderedPlayPrompts.js +68 -6
- package/dist/node/runtime/play/PlayCallChain.js +359 -359
- package/dist/node/runtime/play/PlayDocumentTools.js +8 -125
- package/dist/node/runtime/play/PlayFollowupRequests.js +2 -2
- package/dist/node/runtime/play/PlayTraceRetention.js +8 -36
- 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 +45 -10
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +35 -2
- package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +7 -16
- package/dist/node/runtime/setting/SettingAuthoringTransaction.js +56 -13
- package/dist/node/runtime/setting/SettingImprovementSession.js +6 -4
- package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +213 -1033
- package/dist/node/runtime/world/FileNativeWorldStore.js +266 -783
- package/dist/node/runtime/world/{FileNativeAuthorityV3.js → LegacyAuthorityV3Reader.js} +13 -404
- package/dist/node/runtime/world/WorldActivity.js +14 -22
- package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -2
- package/dist/node/runtime/world/WorldLedgerAuthority.js +340 -0
- package/dist/node/runtime/world/WorldOperationCoordinator.js +59 -401
- package/dist/node/runtime/world/WorldQueue.js +46 -0
- package/dist/node/runtime/world/WorldTurnLedger.js +353 -0
- package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -12
- 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/mobile.js +20 -0
- package/dist/node/server/startWebServer.js +15 -8
- package/dist/node/shared/ordered-author-prompts.js +8 -0
- package/dist/node/shared/ordered-play-prompts.js +23 -0
- package/dist/node/shared/tabletop-guidance.js +100 -0
- package/dist/web/assets/index-7GzLSUpU.js +1323 -0
- package/dist/web/assets/index-BJ-1Mb0e.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/dist/node/runtime/FileNativePhysicalClone.js +0 -86
- package/dist/web/assets/index-DxNtxzLR.js +0 -1255
- package/dist/web/assets/index-DzOnu9uv.css +0 -1
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { captureNarrativeOrigin, snapshotBackground, isHistoryBackground, } from "../history/HistoryBackground.js";
|
|
2
|
-
import { FileNativeArtifactStore } from "../artifact/FileNativeArtifactStore.js";
|
|
2
|
+
import { FileNativeArtifactStore, forkArtifactClosure, } from "../artifact/FileNativeArtifactStore.js";
|
|
3
|
+
import { migrateLegacyArtifactStore } from "../artifact/LegacyArtifactStoreMigration.js";
|
|
3
4
|
import { WorldExtensionRequests } from "../extension/WorldExtensionRequests.js";
|
|
4
5
|
import { WorldExtensionControls, extensionDefinitions, } from "../extension/WorldExtensionControls.js";
|
|
6
|
+
import { assertSameGameplay, readGameplay, } from "../../protocol/gameplay.js";
|
|
5
7
|
import { HistoryQuery } from "../history/HistoryQuery.js";
|
|
6
8
|
import { exportStory } from "../history/StoryExport.js";
|
|
7
9
|
import { PackageScriptPermissions } from "../extension/PackageScriptPermissions.js";
|
|
8
10
|
import { createHash, randomUUID } from "node:crypto";
|
|
9
|
-
import {
|
|
11
|
+
import { cp, mkdir, readFile, readdir, rename, rm, stat, writeFile, } from "node:fs/promises";
|
|
10
12
|
import { dirname, join, resolve } from "node:path";
|
|
11
13
|
import { isDeepStrictEqual } from "node:util";
|
|
12
14
|
import { inspectContentPackageCurrentTree, isContentPackageWorldDocumentPath, } from "../content/FileNativeContentTree.js";
|
|
@@ -16,10 +18,11 @@ import { WorldDocumentStore } from "./WorldDocumentStore.js";
|
|
|
16
18
|
import { readWorldActivity, updateWorldActivity, } from "./WorldActivity.js";
|
|
17
19
|
import { FileNativePlayTimelineStore } from "../play/FileNativePlayTimelineStore.js";
|
|
18
20
|
import { FileNativePlayAdvanceStore } from "../play/FileNativePlayAdvanceStore.js";
|
|
19
|
-
import { FileNativeWorldStorageMigrator } from "./FileNativeWorldStorageMigrator.js";
|
|
20
|
-
import {
|
|
21
|
+
import { FileNativeWorldStorageMigrator, UnsupportedLegacyStorageError, } from "./FileNativeWorldStorageMigrator.js";
|
|
22
|
+
import { WorldAuthorityError, WorldLedgerAuthority, } from "./WorldLedgerAuthority.js";
|
|
23
|
+
import { forgetLedger, rewindTo } from "./WorldTurnLedger.js";
|
|
21
24
|
import { FileNativeWorldOperationCoordinator, WorldOperationBusyError, } from "./WorldOperationCoordinator.js";
|
|
22
|
-
import {
|
|
25
|
+
import { projectFileNativeHistorySurface, projectHistoryBackgrounds, historyBackgroundRecord, } from "./FileNativeHistoryProjection.js";
|
|
23
26
|
const publicationFile = "publication.json";
|
|
24
27
|
const localMetadataFile = "local.json";
|
|
25
28
|
export class FileNativeWorldCreationError extends Error {
|
|
@@ -36,11 +39,21 @@ export class FileNativeWorldNotFoundError extends Error {
|
|
|
36
39
|
this.name = "FileNativeWorldNotFoundError";
|
|
37
40
|
}
|
|
38
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Completed operations kept per world for same-process duplicate guards. A
|
|
44
|
+
* pending player-revision intent resumes against its own world's entry, so
|
|
45
|
+
* retention is per world: another world's traffic cannot evict it.
|
|
46
|
+
*/
|
|
47
|
+
const RETAINED_OPERATION_OUTCOMES_PER_WORLD = 256;
|
|
39
48
|
export class FileNativeWorldStore {
|
|
40
49
|
extensionRequests;
|
|
41
50
|
#documentMaintenanceCache = new Map();
|
|
42
51
|
#worldsRoot;
|
|
43
|
-
|
|
52
|
+
// Process-local duplicate guard keyed by operation ID (ADR-0044): entries are
|
|
53
|
+
// not persisted, so a restart forgets them and a repeated operation redoes.
|
|
54
|
+
// Completed entries are bounded per world; in-progress entries are not.
|
|
55
|
+
#operationOutcomes = new Map();
|
|
56
|
+
#retainedOperationIds = new Map();
|
|
44
57
|
#promptCompiler;
|
|
45
58
|
#activeControlUsers = new Map();
|
|
46
59
|
#openingWorldRevisions = new Set();
|
|
@@ -55,23 +68,26 @@ export class FileNativeWorldStore {
|
|
|
55
68
|
const root = resolve(dataRoot);
|
|
56
69
|
this.#worldsRoot = join(root, "worlds-file-native");
|
|
57
70
|
this.extensionRequests = new WorldExtensionRequests(this.#worldsRoot);
|
|
58
|
-
this.#operationsRoot = join(root, "operations");
|
|
59
71
|
this.#promptCompiler =
|
|
60
72
|
options.promptCompiler ?? new FileNativePromptCompiler();
|
|
61
73
|
this.operations = new FileNativeWorldOperationCoordinator(root);
|
|
62
74
|
this.playTimeline = new FileNativePlayTimelineStore(root);
|
|
63
75
|
this.playAdvances = new FileNativePlayAdvanceStore(root);
|
|
64
|
-
this.#storageMigrator = new FileNativeWorldStorageMigrator(root
|
|
76
|
+
this.#storageMigrator = new FileNativeWorldStorageMigrator(root);
|
|
65
77
|
}
|
|
66
78
|
/**
|
|
67
|
-
* Upgrade
|
|
68
|
-
* play-timeline behavior crosses its seam. Concurrent callers share one
|
|
79
|
+
* Upgrade an Authority V3 world to the current ledger layout before current
|
|
80
|
+
* Authority or play-timeline behavior crosses its seam. Concurrent callers share one
|
|
69
81
|
* in-process promise; the durable world lock serializes processes.
|
|
70
82
|
*/
|
|
71
83
|
async ensureCurrentStorage(worldId) {
|
|
72
84
|
assertIdentity(worldId, "World ID");
|
|
73
85
|
if (this.#currentStorage.has(worldId))
|
|
74
86
|
return;
|
|
87
|
+
// A shared migration may be queued behind this caller; when the caller
|
|
88
|
+
// already holds the world, run the migration inline instead of awaiting it.
|
|
89
|
+
if (this.operations.isWorldHeld(worldId))
|
|
90
|
+
return this.#ensureCurrentStorageSerialized(worldId);
|
|
75
91
|
const existing = this.#storageMigrations.get(worldId);
|
|
76
92
|
if (existing !== undefined)
|
|
77
93
|
return existing;
|
|
@@ -88,7 +104,7 @@ export class FileNativeWorldStore {
|
|
|
88
104
|
async #ensureCurrentStorageSerialized(worldId) {
|
|
89
105
|
try {
|
|
90
106
|
const root = join(this.#worldsRoot, worldId);
|
|
91
|
-
const authority = new
|
|
107
|
+
const authority = new WorldLedgerAuthority(root);
|
|
92
108
|
if (await authority.exists()) {
|
|
93
109
|
await authority.readHead();
|
|
94
110
|
this.#currentStorage.add(worldId);
|
|
@@ -96,6 +112,7 @@ export class FileNativeWorldStore {
|
|
|
96
112
|
}
|
|
97
113
|
await this.operations.withWorldAuthorityLock(worldId, async () => {
|
|
98
114
|
await this.#storageMigrator.migrate(worldId);
|
|
115
|
+
await migrateLegacyArtifactStore(dirname(this.#worldsRoot), worldId);
|
|
99
116
|
});
|
|
100
117
|
this.#currentStorage.add(worldId);
|
|
101
118
|
}
|
|
@@ -113,9 +130,18 @@ export class FileNativeWorldStore {
|
|
|
113
130
|
}
|
|
114
131
|
if (error instanceof WorldOperationBusyError)
|
|
115
132
|
throw new FileNativeWorldCreationError("operation_conflict", "World storage is being upgraded by another durable operation", { cause: error });
|
|
116
|
-
|
|
133
|
+
if (error instanceof UnsupportedLegacyStorageError)
|
|
134
|
+
throw new FileNativeWorldCreationError("world_corrupt", "This world was created by v0.2.0 or earlier; open it once with v0.3.0–v0.6.0 before upgrading", { cause: error });
|
|
135
|
+
throw new FileNativeWorldCreationError("world_corrupt", "World storage could not be migrated to the current format", error instanceof Error ? { cause: error } : undefined);
|
|
117
136
|
}
|
|
118
137
|
}
|
|
138
|
+
async gameplay(worldId) {
|
|
139
|
+
assertIdentity(worldId, "World ID");
|
|
140
|
+
await this.ensureCurrentStorage(worldId);
|
|
141
|
+
const root = join(this.#worldsRoot, worldId);
|
|
142
|
+
await readPublicationAt(root);
|
|
143
|
+
return readWorldGameplayAt(root);
|
|
144
|
+
}
|
|
119
145
|
async readWorldSummary(worldId) {
|
|
120
146
|
assertIdentity(worldId, "World ID");
|
|
121
147
|
return readWorldSummaryAt(join(this.#worldsRoot, worldId));
|
|
@@ -142,7 +168,7 @@ export class FileNativeWorldStore {
|
|
|
142
168
|
const root = join(this.#worldsRoot, worldId);
|
|
143
169
|
const publication = await readPublicationAt(root);
|
|
144
170
|
const title = await readWorldTitleAt(root, publication);
|
|
145
|
-
const endpoint = await new
|
|
171
|
+
const endpoint = await new WorldLedgerAuthority(root).recoverHeadResult();
|
|
146
172
|
const history = new HistoryQuery(worldId, endpoint.history.map(({ messageId, role, exactText }) => ({
|
|
147
173
|
id: messageId,
|
|
148
174
|
role,
|
|
@@ -192,8 +218,14 @@ export class FileNativeWorldStore {
|
|
|
192
218
|
throw error;
|
|
193
219
|
}
|
|
194
220
|
return this.operations.withExclusiveWorldStateMutation(worldId, () => this.operations.withWorldAuthorityLock(worldId, async () => {
|
|
221
|
+
forgetLedger(root);
|
|
195
222
|
await rm(root, { recursive: true, force: true });
|
|
223
|
+
// 删除期间的并发读取可能重新填充缓存,删除后再失效一次。
|
|
224
|
+
forgetLedger(root);
|
|
196
225
|
this.#currentStorage.delete(worldId);
|
|
226
|
+
for (const operationId of this.#retainedOperationIds.get(worldId) ?? [])
|
|
227
|
+
this.#operationOutcomes.delete(operationId);
|
|
228
|
+
this.#retainedOperationIds.delete(worldId);
|
|
197
229
|
return { deleted: true };
|
|
198
230
|
}));
|
|
199
231
|
}
|
|
@@ -290,9 +322,7 @@ export class FileNativeWorldStore {
|
|
|
290
322
|
const preview = this.#preview(boundInput, worldId);
|
|
291
323
|
const finalRoot = join(this.#worldsRoot, worldId);
|
|
292
324
|
const stagingRoot = join(this.#worldsRoot, `.staging-${worldId}-${randomUUID()}`);
|
|
293
|
-
const operationPath = this.#operationPath(input.operationId);
|
|
294
325
|
await mkdir(this.#worldsRoot, { recursive: true, mode: 0o700 });
|
|
295
|
-
await mkdir(this.#operationsRoot, { recursive: true, mode: 0o700 });
|
|
296
326
|
await rm(stagingRoot, { recursive: true, force: true });
|
|
297
327
|
try {
|
|
298
328
|
const openingMessage = {
|
|
@@ -326,19 +356,7 @@ export class FileNativeWorldStore {
|
|
|
326
356
|
worldId,
|
|
327
357
|
history: genesis.history,
|
|
328
358
|
});
|
|
329
|
-
await new
|
|
330
|
-
operationId: input.operationId,
|
|
331
|
-
state,
|
|
332
|
-
history: [openingMessage],
|
|
333
|
-
additionalMaterials: initialMaterials,
|
|
334
|
-
});
|
|
335
|
-
await writeJson(join(stagingRoot, "runtime", "materialized-head.json"), {
|
|
336
|
-
schemaVersion: 3,
|
|
337
|
-
head: "genesis",
|
|
338
|
-
sequence: 0,
|
|
339
|
-
commitDigest: null,
|
|
340
|
-
...backgroundCount([openingMessage]),
|
|
341
|
-
});
|
|
359
|
+
await new WorldLedgerAuthority(stagingRoot).initialize();
|
|
342
360
|
await writeJson(join(stagingRoot, "runtime", "additional-materials.json"), { head: "genesis", items: initialMaterials });
|
|
343
361
|
const publication = {
|
|
344
362
|
schemaVersion: 1,
|
|
@@ -350,12 +368,7 @@ export class FileNativeWorldStore {
|
|
|
350
368
|
};
|
|
351
369
|
await writeJson(join(stagingRoot, publicationFile), publication);
|
|
352
370
|
await updateWorldActivity(stagingRoot, "genesis", [openingMessage], Date.now());
|
|
353
|
-
await durableTree(stagingRoot);
|
|
354
|
-
crashAtFileNativeWorldCreationEdge("after_staging");
|
|
355
371
|
await rename(stagingRoot, finalRoot);
|
|
356
|
-
await syncDirectory(this.#worldsRoot);
|
|
357
|
-
crashAtFileNativeWorldCreationEdge("after_atomic_publish");
|
|
358
|
-
await publishOperation(operationPath, publication);
|
|
359
372
|
return {
|
|
360
373
|
outcome: "created",
|
|
361
374
|
world: await this.#currentSummary(publication),
|
|
@@ -410,12 +423,8 @@ export class FileNativeWorldStore {
|
|
|
410
423
|
* must never pair a mutable state tree with a different response head.
|
|
411
424
|
*/
|
|
412
425
|
async renderPlayerViewsAtHead(worldId, head) {
|
|
413
|
-
const root = join(this.#worldsRoot, worldId);
|
|
414
|
-
const materialized = await readOptionalJson(join(root, "runtime", "materialized-head.json"));
|
|
415
426
|
const [state, control] = await Promise.all([
|
|
416
|
-
|
|
417
|
-
? readTree(join(root, "state"))
|
|
418
|
-
: this.recoverEndpoint(worldId, head).then(({ state }) => state),
|
|
427
|
+
this.recoverEndpoint(worldId, head).then(({ state }) => state),
|
|
419
428
|
this.readSurface(worldId, "control"),
|
|
420
429
|
]);
|
|
421
430
|
const source = control.find(({ path }) => path === "player-views.yaml");
|
|
@@ -429,7 +438,7 @@ export class FileNativeWorldStore {
|
|
|
429
438
|
await this.ensureCurrentStorage(worldId);
|
|
430
439
|
const root = join(this.#worldsRoot, worldId);
|
|
431
440
|
await readPublicationAt(root);
|
|
432
|
-
return (await new
|
|
441
|
+
return (await new WorldLedgerAuthority(root).readHead()).head;
|
|
433
442
|
}
|
|
434
443
|
async withArchiveExport(worldId, action) {
|
|
435
444
|
assertIdentity(worldId, "World ID");
|
|
@@ -461,7 +470,7 @@ export class FileNativeWorldStore {
|
|
|
461
470
|
await this.ensureCurrentStorage(worldId);
|
|
462
471
|
const root = join(this.#worldsRoot, worldId);
|
|
463
472
|
await readPublicationAt(root);
|
|
464
|
-
return new
|
|
473
|
+
return new WorldLedgerAuthority(root).currentOperationId();
|
|
465
474
|
}
|
|
466
475
|
async saveControlDraft(worldId, files) {
|
|
467
476
|
assertIdentity(worldId, "World ID");
|
|
@@ -472,6 +481,7 @@ export class FileNativeWorldStore {
|
|
|
472
481
|
await this.ensureCurrentStorage(worldId);
|
|
473
482
|
const root = join(this.#worldsRoot, worldId);
|
|
474
483
|
await readPublicationAt(root);
|
|
484
|
+
assertControlGameplayUnchanged(await readTree(join(root, "control")), files);
|
|
475
485
|
const staging = join(root, `runtime/.control-draft-${randomUUID()}`);
|
|
476
486
|
await writeSurface(staging, "control", files);
|
|
477
487
|
const final = join(root, "runtime/control-draft");
|
|
@@ -538,7 +548,6 @@ export class FileNativeWorldStore {
|
|
|
538
548
|
if (currentFingerprint === nextFingerprint) {
|
|
539
549
|
await rm(stagingRoot, { recursive: true, force: true });
|
|
540
550
|
await rm(previousPath, { recursive: true, force: true });
|
|
541
|
-
await syncDirectory(runtimeRoot);
|
|
542
551
|
return { fingerprint: nextFingerprint };
|
|
543
552
|
}
|
|
544
553
|
const previous = await readTree(previousPath);
|
|
@@ -547,34 +556,24 @@ export class FileNativeWorldStore {
|
|
|
547
556
|
fingerprint(previous) === input.expectedFingerprint) {
|
|
548
557
|
if (fingerprint(staged) === nextFingerprint) {
|
|
549
558
|
await rename(stagedPath, currentPath);
|
|
550
|
-
await syncDirectory(root);
|
|
551
|
-
await syncDirectory(stagingRoot);
|
|
552
559
|
await rm(previousPath, { recursive: true, force: true });
|
|
553
560
|
await rm(stagingRoot, { recursive: true, force: true });
|
|
554
|
-
await syncDirectory(runtimeRoot);
|
|
555
561
|
return { fingerprint: nextFingerprint };
|
|
556
562
|
}
|
|
557
563
|
await rename(previousPath, currentPath);
|
|
558
|
-
await syncDirectory(root);
|
|
559
|
-
await syncDirectory(runtimeRoot);
|
|
560
564
|
current = await readTree(currentPath);
|
|
561
565
|
currentFingerprint = fingerprint(current);
|
|
562
566
|
}
|
|
563
567
|
if (currentFingerprint !== input.expectedFingerprint)
|
|
564
568
|
throw new FileNativeWorldCreationError("operation_conflict", "World control changed after this revision epoch opened");
|
|
569
|
+
assertControlGameplayUnchanged(current, input.files);
|
|
565
570
|
await rm(stagingRoot, { recursive: true, force: true });
|
|
566
571
|
await rm(previousPath, { recursive: true, force: true });
|
|
567
572
|
await writeSurface(stagingRoot, "control", input.files);
|
|
568
|
-
await durableTree(stagingRoot);
|
|
569
573
|
await rename(currentPath, previousPath);
|
|
570
|
-
await syncDirectory(root);
|
|
571
|
-
await syncDirectory(runtimeRoot);
|
|
572
574
|
await rename(stagedPath, currentPath);
|
|
573
|
-
await syncDirectory(root);
|
|
574
|
-
await syncDirectory(stagingRoot);
|
|
575
575
|
await rm(previousPath, { recursive: true, force: true });
|
|
576
576
|
await rm(stagingRoot, { recursive: true, force: true });
|
|
577
|
-
await syncDirectory(runtimeRoot);
|
|
578
577
|
return { fingerprint: nextFingerprint };
|
|
579
578
|
});
|
|
580
579
|
}
|
|
@@ -618,6 +617,7 @@ export class FileNativeWorldStore {
|
|
|
618
617
|
throw new FileNativeWorldCreationError("operation_conflict", "World control is frozen by a running attempt");
|
|
619
618
|
const preview = await this.previewControlDraft(worldId, prompt);
|
|
620
619
|
const root = join(this.#worldsRoot, worldId);
|
|
620
|
+
assertControlGameplayUnchanged(await readTree(join(root, "control")), await readTree(join(root, "runtime/control-draft")));
|
|
621
621
|
const draft = join(root, "runtime/control-draft");
|
|
622
622
|
const replacement = join(root, `runtime/.control-apply-${randomUUID()}`);
|
|
623
623
|
await rename(draft, replacement);
|
|
@@ -698,52 +698,22 @@ export class FileNativeWorldStore {
|
|
|
698
698
|
}
|
|
699
699
|
try {
|
|
700
700
|
return await this.operations.withWorldAuthorityLock(worldId, async () => {
|
|
701
|
-
const
|
|
702
|
-
const
|
|
703
|
-
const checkpoint = await readMaterializedCheckpoint(root);
|
|
704
|
-
const cachedBackgrounds = await readTree(join(root, "runtime", "history-backgrounds"));
|
|
705
|
-
if (!sameMaterializedEndpoint(checkpoint, authority) ||
|
|
706
|
-
(checkpoint?.historyBackgroundCount ?? 0) !== cachedBackgrounds.length) {
|
|
707
|
-
const recovered = await authorityStore.recover();
|
|
708
|
-
let operation = null;
|
|
709
|
-
if (authority.operationId !== null && authority.sequence > 0) {
|
|
710
|
-
const candidate = await this.getOperationOutcome(authority.operationId);
|
|
711
|
-
if (isCommittedOutcome(candidate)) {
|
|
712
|
-
if (candidate.worldId !== worldId ||
|
|
713
|
-
candidate.head !== authority.head ||
|
|
714
|
-
candidate.commitDigest !== authority.commitDigest)
|
|
715
|
-
throw new FileNativeWorldCreationError("world_corrupt", "Current Authority head is bound to a different operation receipt");
|
|
716
|
-
operation = candidate;
|
|
717
|
-
}
|
|
718
|
-
else if (candidate.outcome !== "not_started") {
|
|
719
|
-
throw new FileNativeWorldCreationError("world_corrupt", "Current Authority head has no recoverable acceptance receipt");
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
await materializeRecoveredEndpoint(root, authority, recovered);
|
|
723
|
-
if (operation !== null)
|
|
724
|
-
await publishJson(this.#operationOutcomePath(authority.operationId), { ...operation, outcome: "committed" });
|
|
725
|
-
}
|
|
726
|
-
const [state, control, history, materials, backgrounds] = await Promise.all([
|
|
727
|
-
readTree(join(root, "state")),
|
|
728
|
-
readTree(join(root, "control")),
|
|
729
|
-
readTree(join(root, "history")),
|
|
730
|
-
readOptionalJson(join(root, "runtime", "additional-materials.json")),
|
|
731
|
-
readTree(join(root, "runtime", "history-backgrounds")),
|
|
732
|
-
]);
|
|
733
|
-
if ((materials?.head ?? "genesis") !== authority.head)
|
|
734
|
-
throw new FileNativeWorldCreationError("inconsistent_materialization", "Materialized additional materials do not match the current Authority endpoint");
|
|
701
|
+
const recovered = await new WorldLedgerAuthority(root).recover();
|
|
702
|
+
const control = await readTree(join(root, "control"));
|
|
735
703
|
return {
|
|
736
704
|
worldId,
|
|
737
|
-
parentHead:
|
|
738
|
-
files: [...state, ...control].reduce((files, { path, contents }, index) => {
|
|
739
|
-
files[`${index < state.length ? "state" : "control"}/${path}`] =
|
|
740
|
-
contents;
|
|
705
|
+
parentHead: recovered.head,
|
|
706
|
+
files: [...recovered.state, ...control].reduce((files, { path, contents }, index) => {
|
|
707
|
+
files[`${index < recovered.state.length ? "state" : "control"}/${path}`] = contents;
|
|
741
708
|
return files;
|
|
742
709
|
}, {}),
|
|
743
|
-
additionalMaterials:
|
|
744
|
-
history:
|
|
745
|
-
|
|
746
|
-
|
|
710
|
+
additionalMaterials: recovered.additionalMaterials,
|
|
711
|
+
history: Object.fromEntries(recovered.history.map(({ messageId, exactText }) => [
|
|
712
|
+
messageId,
|
|
713
|
+
exactText,
|
|
714
|
+
])),
|
|
715
|
+
historyBackgrounds: historyBackgroundRecord(recovered.history),
|
|
716
|
+
narrativeCheckpoint: recovered.narrativeCheckpoint,
|
|
747
717
|
};
|
|
748
718
|
});
|
|
749
719
|
}
|
|
@@ -756,7 +726,7 @@ export class FileNativeWorldStore {
|
|
|
756
726
|
/**
|
|
757
727
|
* Bind an immutable historical endpoint with the world's current control
|
|
758
728
|
* surface. This is the same semantic input as a fresh context started while
|
|
759
|
-
* that endpoint was current, without
|
|
729
|
+
* that endpoint was current, without rewinding today's head.
|
|
760
730
|
*/
|
|
761
731
|
async bindPlayCallChainAt(worldId, head) {
|
|
762
732
|
assertIdentity(worldId, "World ID");
|
|
@@ -780,34 +750,29 @@ export class FileNativeWorldStore {
|
|
|
780
750
|
])),
|
|
781
751
|
};
|
|
782
752
|
}
|
|
783
|
-
|
|
753
|
+
reserveOperation(operationId) {
|
|
784
754
|
assertIdentity(operationId, "operation ID");
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
755
|
+
if (this.#operationOutcomes.has(operationId))
|
|
756
|
+
return Promise.resolve(false);
|
|
757
|
+
this.#operationOutcomes.set(operationId, { phase: "in_progress" });
|
|
758
|
+
return Promise.resolve(true);
|
|
759
|
+
}
|
|
760
|
+
#rememberOperationOutcome(worldId, operationId, outcome) {
|
|
761
|
+
this.#operationOutcomes.set(operationId, { phase: "done", outcome });
|
|
762
|
+
const retained = this.#retainedOperationIds.get(worldId) ?? [];
|
|
763
|
+
retained.push(operationId);
|
|
764
|
+
while (retained.length > RETAINED_OPERATION_OUTCOMES_PER_WORLD) {
|
|
765
|
+
const evicted = retained.shift();
|
|
766
|
+
if (evicted !== undefined)
|
|
767
|
+
this.#operationOutcomes.delete(evicted);
|
|
797
768
|
}
|
|
769
|
+
this.#retainedOperationIds.set(worldId, retained);
|
|
798
770
|
}
|
|
799
|
-
|
|
771
|
+
releaseOperationReservation(operationId) {
|
|
800
772
|
assertIdentity(operationId, "operation ID");
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
return;
|
|
805
|
-
if (!isRecord(current) ||
|
|
806
|
-
!hasExactKeys(current, ["outcome"]) ||
|
|
807
|
-
current.outcome !== "in_progress")
|
|
808
|
-
throw new FileNativeWorldCreationError("world_corrupt", "Operation reservation to release has an invalid shape");
|
|
809
|
-
await rm(path, { force: true });
|
|
810
|
-
await syncDirectory(this.#operationsRoot);
|
|
773
|
+
if (this.#operationOutcomes.get(operationId)?.phase === "in_progress")
|
|
774
|
+
this.#operationOutcomes.delete(operationId);
|
|
775
|
+
return Promise.resolve();
|
|
811
776
|
}
|
|
812
777
|
async recoverEndpoint(worldId, head) {
|
|
813
778
|
assertIdentity(worldId, "World ID");
|
|
@@ -815,7 +780,7 @@ export class FileNativeWorldStore {
|
|
|
815
780
|
const root = join(this.#worldsRoot, worldId);
|
|
816
781
|
await readPublicationAt(root);
|
|
817
782
|
try {
|
|
818
|
-
const recovered = await new
|
|
783
|
+
const recovered = await new WorldLedgerAuthority(root).recover(head);
|
|
819
784
|
return {
|
|
820
785
|
worldId,
|
|
821
786
|
head: recovered.head,
|
|
@@ -831,25 +796,6 @@ export class FileNativeWorldStore {
|
|
|
831
796
|
throw new FileNativeWorldCreationError("world_corrupt", `Cannot recover immutable Authority endpoint${head === undefined ? "" : `: ${head}`}`, error instanceof Error ? { cause: error } : undefined);
|
|
832
797
|
}
|
|
833
798
|
}
|
|
834
|
-
async repairMaterialization(worldId) {
|
|
835
|
-
assertIdentity(worldId, "World ID");
|
|
836
|
-
await this.ensureCurrentStorage(worldId);
|
|
837
|
-
try {
|
|
838
|
-
const operationId = await this.operations.withExclusiveWorldStateMutation(worldId, async () => {
|
|
839
|
-
await this.bindPlayCallChain(worldId);
|
|
840
|
-
const root = join(this.#worldsRoot, worldId);
|
|
841
|
-
return (await new FileNativeAuthorityV3(root).readHead()).operationId;
|
|
842
|
-
});
|
|
843
|
-
return operationId === null
|
|
844
|
-
? { outcome: "not_started" }
|
|
845
|
-
: this.getOperationOutcome(operationId);
|
|
846
|
-
}
|
|
847
|
-
catch (error) {
|
|
848
|
-
if (error instanceof WorldOperationBusyError)
|
|
849
|
-
throw new FileNativeWorldCreationError("operation_conflict", "This world has a durable state operation and cannot repair materialization yet");
|
|
850
|
-
throw error;
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
799
|
async deriveWorld(input) {
|
|
854
800
|
assertIdentity(input.operationId, "Fork operation ID");
|
|
855
801
|
assertIdentity(input.sourceWorldId, "Source world ID");
|
|
@@ -867,37 +813,17 @@ export class FileNativeWorldStore {
|
|
|
867
813
|
const target = input.targetDataRoot === undefined
|
|
868
814
|
? this
|
|
869
815
|
: new FileNativeWorldStore(input.targetDataRoot);
|
|
870
|
-
const operationPath = input.identityPrefix === "import"
|
|
871
|
-
? join(target.#operationsRoot, `import-${operationDigest(input.operationId)}.json`)
|
|
872
|
-
: target.#derivationOperationPath(input.operationId);
|
|
873
816
|
const requestFingerprint = derivationRequestFingerprint(input);
|
|
874
817
|
const worldId = `world-${operationDigest(`${input.identityPrefix ?? "derive"}:${input.operationId}`).slice(0, 24)}`;
|
|
875
818
|
const finalRoot = input.publish === false
|
|
876
819
|
? resolve(input.targetWorldRoot)
|
|
877
820
|
: join(target.#worldsRoot, worldId);
|
|
878
|
-
const previous = input.publish === false
|
|
879
|
-
? null
|
|
880
|
-
: await readOptionalJson(operationPath);
|
|
881
|
-
if (previous !== null) {
|
|
882
|
-
await target.ensureCurrentStorage(previous.worldId);
|
|
883
|
-
if (!(await derivationPublicationMatches(join(target.#worldsRoot, previous.worldId), previous, input, requestFingerprint)))
|
|
884
|
-
throw new FileNativeWorldCreationError("operation_conflict", "The same fork operation ID is bound to a different fork payload");
|
|
885
|
-
return {
|
|
886
|
-
outcome: "derived",
|
|
887
|
-
world: await target.#currentSummary(previous),
|
|
888
|
-
worldRoot: finalRoot,
|
|
889
|
-
};
|
|
890
|
-
}
|
|
891
821
|
const alreadyPublished = await readOptionalJson(join(finalRoot, publicationFile));
|
|
892
822
|
if (alreadyPublished !== null) {
|
|
893
823
|
if (input.publish !== false)
|
|
894
824
|
await target.ensureCurrentStorage(alreadyPublished.worldId);
|
|
895
825
|
if (!(await derivationPublicationMatches(finalRoot, alreadyPublished, input, requestFingerprint)))
|
|
896
826
|
throw new FileNativeWorldCreationError("operation_conflict", "Published fork world does not match the operation payload");
|
|
897
|
-
if (input.publish !== false) {
|
|
898
|
-
await mkdir(target.#operationsRoot, { recursive: true, mode: 0o700 });
|
|
899
|
-
await publishJson(operationPath, alreadyPublished);
|
|
900
|
-
}
|
|
901
827
|
return {
|
|
902
828
|
outcome: "derived",
|
|
903
829
|
world: await readWorldSummaryAt(finalRoot, alreadyPublished),
|
|
@@ -909,43 +835,31 @@ export class FileNativeWorldStore {
|
|
|
909
835
|
const sourceRoot = join(this.#worldsRoot, input.sourceWorldId);
|
|
910
836
|
const sourcePublication = await readPublicationAt(sourceRoot);
|
|
911
837
|
const sourceSummary = await readWorldSummaryAt(sourceRoot, sourcePublication);
|
|
912
|
-
const sourceAuthority = new
|
|
838
|
+
const sourceAuthority = new WorldLedgerAuthority(sourceRoot);
|
|
913
839
|
const sourceHead = input.sourceHead === "current"
|
|
914
840
|
? (await sourceAuthority.readHead()).head
|
|
915
841
|
: input.sourceHead;
|
|
916
|
-
if (input.requireMaterialized) {
|
|
917
|
-
const authorityHead = await sourceAuthority.readHead();
|
|
918
|
-
const materialized = await readMaterializedCheckpoint(sourceRoot);
|
|
919
|
-
if (materialized?.head !== authorityHead.head ||
|
|
920
|
-
materialized.commitDigest !== authorityHead.commitDigest)
|
|
921
|
-
throw new FileNativeWorldCreationError("inconsistent_materialization", "Please repair the world before exporting a backup");
|
|
922
|
-
}
|
|
923
842
|
const [sourceGenesis, control] = await Promise.all([
|
|
924
843
|
readGenesisForFork(sourceRoot, input.sourceWorldId),
|
|
925
844
|
readTree(join(sourceRoot, "control")),
|
|
926
845
|
]);
|
|
927
846
|
const staging = join(dirname(finalRoot), `.staging-${worldId}-${randomUUID()}`);
|
|
928
847
|
await mkdir(dirname(finalRoot), { recursive: true, mode: 0o700 });
|
|
929
|
-
if (input.publish !== false)
|
|
930
|
-
await mkdir(target.#operationsRoot, { recursive: true, mode: 0o700 });
|
|
931
848
|
try {
|
|
932
|
-
await
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
849
|
+
await copyForkClosure(sourceRoot, staging);
|
|
850
|
+
await rewindTo(staging, headPosition(sourceHead));
|
|
851
|
+
await forkArtifactClosure(staging, {
|
|
852
|
+
sourceWorldId: input.sourceWorldId,
|
|
853
|
+
targetWorldId: worldId,
|
|
854
|
+
restoredHead: sourceHead,
|
|
937
855
|
});
|
|
938
|
-
|
|
939
|
-
await
|
|
940
|
-
await
|
|
856
|
+
// 分叉不信任来源的投影文件:当前文件由副本上的账本端点重建。
|
|
857
|
+
await new WorldLedgerAuthority(staging).materializeCurrentFiles();
|
|
858
|
+
const selected = await new WorldLedgerAuthority(staging).recover();
|
|
941
859
|
await PackageScriptPermissions.write(staging, input.resetPackageScriptPermissions
|
|
942
860
|
? []
|
|
943
861
|
: await PackageScriptPermissions.read(sourceRoot));
|
|
944
862
|
await WorldExtensionControls.copy(sourceRoot, staging);
|
|
945
|
-
await mkdir(join(staging, "history"), { recursive: true });
|
|
946
|
-
for (const file of historySurfaceFiles(selected.history))
|
|
947
|
-
await writeIdempotentText(join(staging, "history", file.path), file.contents);
|
|
948
|
-
await replaceMaterializedTree(join(staging, "runtime", "history-backgrounds"), projectHistoryBackgrounds(selected.history));
|
|
949
863
|
const genesis = {
|
|
950
864
|
schemaVersion: 1,
|
|
951
865
|
type: "file_native_genesis",
|
|
@@ -963,17 +877,6 @@ export class FileNativeWorldStore {
|
|
|
963
877
|
worldId,
|
|
964
878
|
history: genesis.history,
|
|
965
879
|
});
|
|
966
|
-
await writeJson(join(staging, "runtime", "additional-materials.json"), {
|
|
967
|
-
head: sourceHead,
|
|
968
|
-
items: selected.additionalMaterials,
|
|
969
|
-
});
|
|
970
|
-
await writeJson(join(staging, "runtime", "materialized-head.json"), {
|
|
971
|
-
schemaVersion: 3,
|
|
972
|
-
head: sourceHead,
|
|
973
|
-
sequence: clonedHead.sequence,
|
|
974
|
-
commitDigest: clonedHead.commitDigest,
|
|
975
|
-
...backgroundCount(selected.history),
|
|
976
|
-
});
|
|
977
880
|
await input.stageTarget?.({
|
|
978
881
|
sourceHead,
|
|
979
882
|
targetWorldRoot: staging,
|
|
@@ -998,7 +901,6 @@ export class FileNativeWorldStore {
|
|
|
998
901
|
await writeJson(join(staging, "runtime", "archive-import.json"), {
|
|
999
902
|
schemaVersion: 1,
|
|
1000
903
|
});
|
|
1001
|
-
crashAtFileNativeAuthorityEdge("derivation_after_target_staging");
|
|
1002
904
|
const publication = {
|
|
1003
905
|
schemaVersion: 1,
|
|
1004
906
|
worldId,
|
|
@@ -1009,13 +911,8 @@ export class FileNativeWorldStore {
|
|
|
1009
911
|
};
|
|
1010
912
|
await writeJson(join(staging, publicationFile), publication);
|
|
1011
913
|
await updateWorldActivity(staging, sourceHead, selected.history, Date.now());
|
|
1012
|
-
|
|
1013
|
-
crashAtFileNativeAuthorityEdge("derivation_before_publish");
|
|
914
|
+
forgetLedger(finalRoot);
|
|
1014
915
|
await rename(staging, finalRoot);
|
|
1015
|
-
await syncDirectory(dirname(finalRoot));
|
|
1016
|
-
crashAtFileNativeAuthorityEdge("derivation_after_publish");
|
|
1017
|
-
if (input.publish !== false)
|
|
1018
|
-
await publishJson(operationPath, publication);
|
|
1019
916
|
return {
|
|
1020
917
|
outcome: "derived",
|
|
1021
918
|
world: await readWorldSummaryAt(finalRoot, publication),
|
|
@@ -1028,8 +925,6 @@ export class FileNativeWorldStore {
|
|
|
1028
925
|
if (published !== null) {
|
|
1029
926
|
if (!(await derivationPublicationMatches(finalRoot, published, input, requestFingerprint)))
|
|
1030
927
|
throw new FileNativeWorldCreationError("operation_conflict", "Published fork world does not match the operation payload");
|
|
1031
|
-
if (input.publish !== false)
|
|
1032
|
-
await publishJson(operationPath, published);
|
|
1033
928
|
return {
|
|
1034
929
|
outcome: "derived",
|
|
1035
930
|
world: await readWorldSummaryAt(finalRoot, published),
|
|
@@ -1048,142 +943,53 @@ export class FileNativeWorldStore {
|
|
|
1048
943
|
throw error;
|
|
1049
944
|
}
|
|
1050
945
|
}
|
|
1051
|
-
async failOperation(operationId, outcome = "failed") {
|
|
1052
|
-
const current = await this.getOperationOutcome(operationId);
|
|
1053
|
-
if (isCommittedOutcome(current))
|
|
1054
|
-
return;
|
|
1055
|
-
await writeJson(this.#operationOutcomePath(operationId), { outcome });
|
|
1056
|
-
}
|
|
1057
946
|
async getOperationOutcome(operationId) {
|
|
1058
|
-
return this.#readOperationOutcome(operationId);
|
|
1059
|
-
}
|
|
1060
|
-
async #readOperationOutcome(operationId) {
|
|
1061
947
|
assertIdentity(operationId, "operation ID");
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
await this.ensureCurrentStorage(value.worldId);
|
|
1071
|
-
value = await readOptionalJson(this.#operationOutcomePath(operationId));
|
|
1072
|
-
}
|
|
1073
|
-
if (value === null)
|
|
1074
|
-
directRecord = null;
|
|
1075
|
-
else {
|
|
1076
|
-
assertOperationOutcomeIntegrity(value);
|
|
1077
|
-
directRecord = value;
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
catch (error) {
|
|
1081
|
-
if (error instanceof FileNativeWorldCreationError)
|
|
1082
|
-
throw error;
|
|
1083
|
-
throw new FileNativeWorldCreationError("world_corrupt", "Authority operation outcome has an invalid structure", error instanceof Error ? { cause: error } : undefined);
|
|
1084
|
-
}
|
|
1085
|
-
if (directRecord?.outcome === "acceptance_prepared")
|
|
1086
|
-
return resolvePreparedOperationOutcome(this.#worldsRoot, operationId, directRecord);
|
|
1087
|
-
const direct = directRecord === null ? null : projectOperationOutcome(directRecord);
|
|
1088
|
-
if (direct?.outcome === "committed" ||
|
|
1089
|
-
direct?.outcome === "committed_materialization_pending") {
|
|
1090
|
-
const worldRoot = join(this.#worldsRoot, direct.worldId);
|
|
1091
|
-
const commit = await assertCommittedAuthorityOutcomeMatches(worldRoot, direct);
|
|
1092
|
-
const materialized = await readMaterializedCheckpoint(worldRoot);
|
|
1093
|
-
if (materialized !== null && materialized.sequence >= commit.sequence)
|
|
1094
|
-
return { ...direct, outcome: "committed" };
|
|
1095
|
-
await assertPendingMaterializationCompatible(worldRoot, direct);
|
|
1096
|
-
return { ...direct, outcome: "committed_materialization_pending" };
|
|
1097
|
-
}
|
|
1098
|
-
return direct?.outcome === "in_progress"
|
|
1099
|
-
? { outcome: "in_progress" }
|
|
1100
|
-
: (direct ?? { outcome: "not_started" });
|
|
948
|
+
const entry = this.#operationOutcomes.get(operationId);
|
|
949
|
+
if (entry === undefined)
|
|
950
|
+
return { outcome: "not_started" };
|
|
951
|
+
if (entry.phase === "in_progress")
|
|
952
|
+
return { outcome: "in_progress" };
|
|
953
|
+
const outcome = structuredClone(entry.outcome);
|
|
954
|
+
await assertCommittedAuthorityOutcomeMatches(join(this.#worldsRoot, outcome.worldId), outcome);
|
|
955
|
+
return outcome;
|
|
1101
956
|
}
|
|
1102
957
|
/**
|
|
1103
|
-
*
|
|
1104
|
-
*
|
|
1105
|
-
*
|
|
1106
|
-
* snapshot and appends the replacement player text in one acceptance step.
|
|
958
|
+
* Roll the world back to a player message's logical parent endpoint before an
|
|
959
|
+
* edited replacement is submitted (ADR-0045). Rewinding to the current head is
|
|
960
|
+
* a no-op, so the edit's steps can repeat safely.
|
|
1107
961
|
*/
|
|
1108
|
-
async
|
|
1109
|
-
assertIdentity(
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
!/^commit:[1-9][0-9]*$/u.test(input.replacesHead))
|
|
1114
|
-
throw new TypeError("Timeline-revision endpoint is invalid");
|
|
1115
|
-
if (input.replacementText.trim() === "")
|
|
1116
|
-
throw new TypeError("Edited player message cannot be empty");
|
|
1117
|
-
if (!/^sha256:[a-f0-9]{64}$/u.test(input.requestFingerprint))
|
|
1118
|
-
throw new TypeError("Timeline-revision request fingerprint is invalid");
|
|
1119
|
-
await this.ensureCurrentStorage(input.worldId);
|
|
962
|
+
async rewindPlayerRevision(worldId, restoresHead) {
|
|
963
|
+
assertIdentity(worldId, "World ID");
|
|
964
|
+
if (!/^(?:genesis|commit:[1-9][0-9]*)$/u.test(restoresHead))
|
|
965
|
+
throw new TypeError("Player-revision endpoint is invalid");
|
|
966
|
+
await this.ensureCurrentStorage(worldId);
|
|
1120
967
|
try {
|
|
1121
|
-
|
|
968
|
+
await this.operations.withExclusiveWorldStateMutation(worldId, () => rewindTo(join(this.#worldsRoot, worldId), headPosition(restoresHead)));
|
|
1122
969
|
}
|
|
1123
970
|
catch (error) {
|
|
1124
971
|
if (error instanceof WorldOperationBusyError)
|
|
1125
|
-
throw new FileNativeWorldCreationError("operation_conflict", "
|
|
972
|
+
throw new FileNativeWorldCreationError("operation_conflict", "Player edits are locked while a world revision is open");
|
|
1126
973
|
throw error;
|
|
1127
974
|
}
|
|
1128
975
|
}
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
return existing;
|
|
1147
|
-
}
|
|
1148
|
-
const root = join(this.#worldsRoot, input.worldId);
|
|
1149
|
-
const authorityStore = new FileNativeAuthorityV3(root);
|
|
1150
|
-
const [replaced, replacementMaterials] = await Promise.all([
|
|
1151
|
-
authorityStore.commitAt(input.replacesHead),
|
|
1152
|
-
authorityStore.recoverMaterials(input.restoresHead),
|
|
1153
|
-
]);
|
|
1154
|
-
const logicalParent = replaced?.timelineParent.head;
|
|
1155
|
-
if (replaced === null ||
|
|
1156
|
-
!replaced.historyAppend.some(({ role }) => role === "player") ||
|
|
1157
|
-
logicalParent !== input.restoresHead)
|
|
1158
|
-
throw new FileNativeWorldCreationError("operation_conflict", "Selected player message does not match the logical parent endpoint to restore");
|
|
1159
|
-
const binding = await this.bindPlayCallChainAt(input.worldId, input.restoresHead);
|
|
1160
|
-
const snapshot = WorldDocumentStore.open({
|
|
1161
|
-
layout: "world_state",
|
|
1162
|
-
files: Object.entries(binding.files).map(([path, contents]) => ({
|
|
1163
|
-
path,
|
|
1164
|
-
contents,
|
|
1165
|
-
})),
|
|
1166
|
-
});
|
|
1167
|
-
const background = snapshotBackground(captureNarrativeOrigin(snapshot, 1).value);
|
|
1168
|
-
return this.#commitHistoryChange({
|
|
1169
|
-
operationId: input.operationId,
|
|
1170
|
-
worldId: input.worldId,
|
|
1171
|
-
parentHead: input.expectedCurrentHead,
|
|
1172
|
-
historyAppend: [
|
|
1173
|
-
{
|
|
1174
|
-
role: "player",
|
|
1175
|
-
exactText: input.replacementText,
|
|
1176
|
-
...backgroundField(background),
|
|
1177
|
-
},
|
|
1178
|
-
],
|
|
1179
|
-
nextMaterials: worldNeutralMaterials(input.worldId, replacementMaterials),
|
|
1180
|
-
stateChanges: [],
|
|
1181
|
-
mode: "timeline_revision",
|
|
1182
|
-
timelineRevision: {
|
|
1183
|
-
restoresHead: input.restoresHead,
|
|
1184
|
-
replacesHead: input.replacesHead,
|
|
1185
|
-
requestFingerprint: input.requestFingerprint,
|
|
1186
|
-
},
|
|
976
|
+
/** The in-progress player edit record; written before the first world change. */
|
|
977
|
+
async readPlayerRevisionIntent(worldId) {
|
|
978
|
+
assertIdentity(worldId, "World ID");
|
|
979
|
+
return readOptionalJsonFile(playerRevisionIntentPath(this.#worldsRoot, worldId));
|
|
980
|
+
}
|
|
981
|
+
async writePlayerRevisionIntent(worldId, intent) {
|
|
982
|
+
assertIdentity(worldId, "World ID");
|
|
983
|
+
const path = playerRevisionIntentPath(this.#worldsRoot, worldId);
|
|
984
|
+
await mkdir(dirname(path), { recursive: true });
|
|
985
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
986
|
+
await writeFile(temporary, `${JSON.stringify(intent)}\n`, "utf8");
|
|
987
|
+
await rename(temporary, path);
|
|
988
|
+
}
|
|
989
|
+
async clearPlayerRevisionIntent(worldId) {
|
|
990
|
+
assertIdentity(worldId, "World ID");
|
|
991
|
+
await rm(playerRevisionIntentPath(this.#worldsRoot, worldId), {
|
|
992
|
+
force: true,
|
|
1187
993
|
});
|
|
1188
994
|
}
|
|
1189
995
|
async commitCorrection(input) {
|
|
@@ -1251,83 +1057,51 @@ export class FileNativeWorldStore {
|
|
|
1251
1057
|
}
|
|
1252
1058
|
}
|
|
1253
1059
|
async #commitHistoryChange(input) {
|
|
1254
|
-
const existing =
|
|
1255
|
-
if (
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
const fact = await
|
|
1259
|
-
if (fact.digest !==
|
|
1060
|
+
const existing = this.#operationOutcomes.get(input.operationId);
|
|
1061
|
+
if (existing?.phase === "done") {
|
|
1062
|
+
const outcome = existing.outcome;
|
|
1063
|
+
assertSameOperationOutcome(outcome, input);
|
|
1064
|
+
const fact = await new WorldLedgerAuthority(join(this.#worldsRoot, outcome.worldId)).authorityFactAt(outcome.head);
|
|
1065
|
+
if (fact.digest !== outcome.commitDigest ||
|
|
1260
1066
|
!authorityCommitMatchesInput(fact.commit, input))
|
|
1261
1067
|
throw new FileNativeWorldCreationError("operation_conflict", "The same play operation ID is bound to a different complete commit payload");
|
|
1262
|
-
return
|
|
1263
|
-
}
|
|
1264
|
-
const prepared = existing.outcome === "in_progress"
|
|
1265
|
-
? await readAcceptancePreparedOutcome(this.#operationOutcomePath(input.operationId))
|
|
1266
|
-
: null;
|
|
1267
|
-
if (prepared !== null) {
|
|
1268
|
-
const comparable = {
|
|
1269
|
-
...structuredClone(prepared),
|
|
1270
|
-
outcome: "committed_materialization_pending",
|
|
1271
|
-
};
|
|
1272
|
-
assertSameOperationOutcome(comparable, input);
|
|
1068
|
+
return outcome;
|
|
1273
1069
|
}
|
|
1274
|
-
if (existing
|
|
1275
|
-
!(existing.outcome === "in_progress" &&
|
|
1276
|
-
(input.operationReserved === true || prepared !== null)))
|
|
1070
|
+
if (existing?.phase === "in_progress" && input.operationReserved !== true)
|
|
1277
1071
|
throw new FileNativeWorldCreationError("operation_conflict", "The same play operation ID is occupied by another durable operation");
|
|
1278
1072
|
const root = join(this.#worldsRoot, input.worldId);
|
|
1279
1073
|
try {
|
|
1280
1074
|
return await this.operations.withWorldAuthorityLock(input.worldId, async () => {
|
|
1281
|
-
const concurrent =
|
|
1282
|
-
if (
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1075
|
+
const concurrent = this.#operationOutcomes.get(input.operationId);
|
|
1076
|
+
if (concurrent?.phase === "done") {
|
|
1077
|
+
const outcome = concurrent.outcome;
|
|
1078
|
+
assertSameOperationOutcome(outcome, input);
|
|
1079
|
+
const known = await new WorldLedgerAuthority(join(this.#worldsRoot, outcome.worldId)).authorityFactAt(outcome.head);
|
|
1080
|
+
if (known.digest !== outcome.commitDigest ||
|
|
1286
1081
|
!authorityCommitMatchesInput(known.commit, input))
|
|
1287
1082
|
throw new FileNativeWorldCreationError("operation_conflict", "The same play operation ID is bound to a different complete commit payload");
|
|
1288
|
-
return
|
|
1083
|
+
return outcome;
|
|
1289
1084
|
}
|
|
1290
|
-
|
|
1291
|
-
? await readAcceptancePreparedOutcome(this.#operationOutcomePath(input.operationId))
|
|
1292
|
-
: null;
|
|
1293
|
-
if (concurrentPrepared !== null)
|
|
1294
|
-
assertSameOperationOutcome({
|
|
1295
|
-
...structuredClone(concurrentPrepared),
|
|
1296
|
-
outcome: "committed_materialization_pending",
|
|
1297
|
-
}, input);
|
|
1298
|
-
else if (concurrent.outcome === "in_progress" &&
|
|
1085
|
+
if (concurrent?.phase === "in_progress" &&
|
|
1299
1086
|
input.operationReserved !== true)
|
|
1300
1087
|
throw new FileNativeWorldCreationError("operation_conflict", "The same play operation ID is occupied by another durable operation");
|
|
1301
|
-
const authorityStore = new
|
|
1088
|
+
const authorityStore = new WorldLedgerAuthority(root);
|
|
1302
1089
|
const authority = await authorityStore.readHead();
|
|
1303
|
-
if (authority.head !== input.parentHead)
|
|
1090
|
+
if (authority.head !== input.parentHead)
|
|
1304
1091
|
throw new FileNativeWorldCreationError("operation_conflict", "The parent endpoint of the play operation has changed");
|
|
1305
|
-
}
|
|
1306
|
-
const materialized = await readOptionalJson(join(root, "runtime", "materialized-head.json"));
|
|
1307
|
-
if ((materialized?.head ?? "genesis") !== authority.head)
|
|
1308
|
-
throw new FileNativeWorldCreationError("operation_conflict", "The world has accepted but unrepaired materialization; new competing writes are forbidden");
|
|
1309
1092
|
const [basisState, storedControl] = await Promise.all([
|
|
1310
|
-
authorityStore.recoverState(
|
|
1093
|
+
authorityStore.recoverState(authority.head),
|
|
1311
1094
|
readTree(join(root, "control")),
|
|
1312
1095
|
]);
|
|
1313
1096
|
const candidateState = new Map(basisState.map((file) => [file.path, file.contents]));
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
for (const change of input.stateChanges) {
|
|
1323
|
-
assertRelativePath(change.relativePath);
|
|
1324
|
-
const previous = candidateState.get(change.relativePath);
|
|
1325
|
-
const previousHash = previous === undefined ? null : sha256(previous);
|
|
1326
|
-
if (previousHash !== change.expectedPreviousHash ||
|
|
1327
|
-
sha256(change.canonicalNextBytes) !== change.nextHash)
|
|
1328
|
-
throw new FileNativeWorldCreationError("operation_conflict", `State-change hash conflicts: ${change.relativePath}`);
|
|
1329
|
-
candidateState.set(change.relativePath, change.canonicalNextBytes);
|
|
1330
|
-
}
|
|
1097
|
+
for (const change of input.stateChanges) {
|
|
1098
|
+
assertRelativePath(change.relativePath);
|
|
1099
|
+
const previous = candidateState.get(change.relativePath);
|
|
1100
|
+
const previousHash = previous === undefined ? null : sha256(previous);
|
|
1101
|
+
if (previousHash !== change.expectedPreviousHash ||
|
|
1102
|
+
sha256(change.canonicalNextBytes) !== change.nextHash)
|
|
1103
|
+
throw new FileNativeWorldCreationError("operation_conflict", `State-change hash conflicts: ${change.relativePath}`);
|
|
1104
|
+
candidateState.set(change.relativePath, change.canonicalNextBytes);
|
|
1331
1105
|
}
|
|
1332
1106
|
const inspection = inspectContentPackageCurrentTree([
|
|
1333
1107
|
...[...candidateState].map(([path, contents]) => ({
|
|
@@ -1341,10 +1115,7 @@ export class FileNativeWorldStore {
|
|
|
1341
1115
|
], { requireOpening: false });
|
|
1342
1116
|
if (inspection.status !== "usable")
|
|
1343
1117
|
throw new FileNativeWorldCreationError("candidate_validation_failed", `Candidate world failed mechanical validation: ${inspection.issues.map(({ message }) => message).join("; ")}`);
|
|
1344
|
-
|
|
1345
|
-
await authorityStore.discardUnacceptedNextEpoch();
|
|
1346
|
-
crashAtFileNativeAuthorityEdge("before_commit_acceptance");
|
|
1347
|
-
const preparedAppend = await authorityStore.prepareAppend({
|
|
1118
|
+
const { commit, commitDigest } = await authorityStore.appendCommit({
|
|
1348
1119
|
...(input.playContext === undefined
|
|
1349
1120
|
? {}
|
|
1350
1121
|
: { playContext: input.playContext }),
|
|
@@ -1353,12 +1124,9 @@ export class FileNativeWorldStore {
|
|
|
1353
1124
|
: { worldClock: input.worldClock }),
|
|
1354
1125
|
operationId: input.operationId,
|
|
1355
1126
|
parentHead: input.parentHead,
|
|
1356
|
-
...(input.timelineRevision === undefined
|
|
1357
|
-
? {}
|
|
1358
|
-
: { timelineParentHead: input.timelineRevision.restoresHead }),
|
|
1359
1127
|
mode: input.mode,
|
|
1360
1128
|
historyAppend: input.historyAppend,
|
|
1361
|
-
stateChanges:
|
|
1129
|
+
stateChanges: input.stateChanges,
|
|
1362
1130
|
nextMaterials: input.nextMaterials,
|
|
1363
1131
|
...(input.narrativeCheckpoint === undefined
|
|
1364
1132
|
? {}
|
|
@@ -1369,57 +1137,27 @@ export class FileNativeWorldStore {
|
|
|
1369
1137
|
corrects: input.parentHead,
|
|
1370
1138
|
}
|
|
1371
1139
|
: {}),
|
|
1372
|
-
...(input.timelineRevision === undefined
|
|
1373
|
-
? {}
|
|
1374
|
-
: { timelineRevision: input.timelineRevision }),
|
|
1375
1140
|
});
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
if (acceptedPreparation !== null &&
|
|
1379
|
-
acceptedPreparation.commitDigest !== preparedAppend.commitDigest)
|
|
1380
|
-
throw new FileNativeWorldCreationError("operation_conflict", "The prepared Authority fact does not match the reserved operation");
|
|
1381
|
-
const { commit } = preparedAppend;
|
|
1382
|
-
const pending = {
|
|
1383
|
-
outcome: "committed_materialization_pending",
|
|
1141
|
+
const outcome = {
|
|
1142
|
+
outcome: "committed",
|
|
1384
1143
|
worldId: input.worldId,
|
|
1385
1144
|
parentHead: input.parentHead,
|
|
1386
1145
|
head: commit.head,
|
|
1387
|
-
commitDigest
|
|
1146
|
+
commitDigest,
|
|
1388
1147
|
historyAppend: structuredClone(input.historyAppend),
|
|
1389
1148
|
nextAdditionalMaterials: structuredClone(input.nextMaterials),
|
|
1390
1149
|
mode: input.mode,
|
|
1391
1150
|
};
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
crashAtFileNativeAuthorityEdge("after_acceptance_prepared");
|
|
1397
|
-
await authorityStore.publishPrepared(preparedAppend);
|
|
1398
|
-
const acceptedAt = Date.now();
|
|
1399
|
-
crashAtFileNativeAuthorityEdge("after_commit_acceptance");
|
|
1400
|
-
await publishJson(this.#operationOutcomePath(input.operationId), pending);
|
|
1401
|
-
try {
|
|
1402
|
-
const recovered = await authorityStore.recover(commit.head);
|
|
1403
|
-
await assertPendingMaterializationCompatible(root, pending);
|
|
1404
|
-
await materializeRecoveredEndpoint(root, preparedAppend.nextHead, recovered, acceptedAt);
|
|
1405
|
-
crashAtFileNativeAuthorityEdge("after_materialization");
|
|
1406
|
-
const outcome = { ...pending, outcome: "committed" };
|
|
1407
|
-
await publishJson(this.#operationOutcomePath(input.operationId), outcome);
|
|
1408
|
-
return outcome;
|
|
1409
|
-
}
|
|
1410
|
-
catch (error) {
|
|
1411
|
-
if (error instanceof FileNativeWorldCreationError &&
|
|
1412
|
-
(error.code === "world_corrupt" ||
|
|
1413
|
-
error.code === "inconsistent_materialization"))
|
|
1414
|
-
throw error;
|
|
1415
|
-
return pending;
|
|
1416
|
-
}
|
|
1151
|
+
this.#rememberOperationOutcome(input.worldId, input.operationId, outcome);
|
|
1152
|
+
const recovered = await authorityStore.recover(commit.head);
|
|
1153
|
+
await updateWorldActivity(root, commit.head, recovered.history, Date.now());
|
|
1154
|
+
return outcome;
|
|
1417
1155
|
});
|
|
1418
1156
|
}
|
|
1419
1157
|
catch (error) {
|
|
1420
1158
|
if (error instanceof WorldOperationBusyError)
|
|
1421
1159
|
throw new FileNativeWorldCreationError("operation_conflict", "World Authority commit is being serialized by another process");
|
|
1422
|
-
if (error instanceof
|
|
1160
|
+
if (error instanceof WorldAuthorityError)
|
|
1423
1161
|
throw new FileNativeWorldCreationError(error.code === "corrupt" ? "world_corrupt" : "operation_conflict", error.message, { cause: error });
|
|
1424
1162
|
throw error;
|
|
1425
1163
|
}
|
|
@@ -1429,7 +1167,7 @@ export class FileNativeWorldStore {
|
|
|
1429
1167
|
await this.ensureCurrentStorage(worldId);
|
|
1430
1168
|
const root = join(this.#worldsRoot, worldId);
|
|
1431
1169
|
await readPublicationAt(root);
|
|
1432
|
-
const authority = await new
|
|
1170
|
+
const authority = await new WorldLedgerAuthority(root).readHistory();
|
|
1433
1171
|
return structuredClone(authority);
|
|
1434
1172
|
}
|
|
1435
1173
|
async inspectDocumentMaintenance(worldId, head) {
|
|
@@ -1452,7 +1190,7 @@ export class FileNativeWorldStore {
|
|
|
1452
1190
|
await this.ensureCurrentStorage(worldId);
|
|
1453
1191
|
const root = join(this.#worldsRoot, worldId);
|
|
1454
1192
|
await readPublicationAt(root);
|
|
1455
|
-
const authority = new
|
|
1193
|
+
const authority = new WorldLedgerAuthority(root);
|
|
1456
1194
|
const selectedHead = head ?? (await authority.readHead()).head;
|
|
1457
1195
|
let cache = this.#documentMaintenanceCache.get(worldId);
|
|
1458
1196
|
if (cache === undefined) {
|
|
@@ -1490,49 +1228,25 @@ export class FileNativeWorldStore {
|
|
|
1490
1228
|
};
|
|
1491
1229
|
}
|
|
1492
1230
|
async #readPublication(operationId) {
|
|
1231
|
+
// The world root's own publication record is the only creation receipt.
|
|
1232
|
+
const worldId = `world-${operationDigest(operationId).slice(0, 24)}`;
|
|
1493
1233
|
let publication;
|
|
1494
1234
|
try {
|
|
1495
|
-
publication = await
|
|
1235
|
+
publication = await readPublicationAt(join(this.#worldsRoot, worldId));
|
|
1496
1236
|
}
|
|
1497
1237
|
catch (error) {
|
|
1498
|
-
if (isNodeError(error) && error.code === "ENOENT")
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
publication = await readPublicationAt(join(this.#worldsRoot, worldId));
|
|
1502
|
-
}
|
|
1503
|
-
catch (worldError) {
|
|
1504
|
-
if (isNodeError(worldError) && worldError.code === "ENOENT")
|
|
1505
|
-
return null;
|
|
1506
|
-
throw worldError;
|
|
1507
|
-
}
|
|
1508
|
-
if (publication.operationId !== operationId) {
|
|
1509
|
-
throw new FileNativeWorldCreationError("operation_conflict", "Deterministic world identity is occupied by another creation operation");
|
|
1510
|
-
}
|
|
1511
|
-
await mkdir(this.#operationsRoot, { recursive: true, mode: 0o700 });
|
|
1512
|
-
await publishOperation(this.#operationPath(operationId), publication);
|
|
1513
|
-
}
|
|
1514
|
-
else {
|
|
1515
|
-
throw error;
|
|
1516
|
-
}
|
|
1238
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
1239
|
+
return null;
|
|
1240
|
+
throw error;
|
|
1517
1241
|
}
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
throw new FileNativeWorldCreationError("world_corrupt", "Creation operation does not match the world publication record");
|
|
1242
|
+
if (publication.operationId !== operationId) {
|
|
1243
|
+
throw new FileNativeWorldCreationError("operation_conflict", "Deterministic world identity is occupied by another creation operation");
|
|
1521
1244
|
}
|
|
1522
1245
|
return publication;
|
|
1523
1246
|
}
|
|
1524
1247
|
#currentSummary(publication) {
|
|
1525
1248
|
return readWorldSummaryAt(join(this.#worldsRoot, publication.worldId), publication);
|
|
1526
1249
|
}
|
|
1527
|
-
#operationPath(operationId) {
|
|
1528
|
-
return join(this.#operationsRoot, `${operationDigest(operationId)}.json`);
|
|
1529
|
-
}
|
|
1530
|
-
#operationOutcomePath(operationId) {
|
|
1531
|
-
return join(this.#operationsRoot, `outcome-${operationDigest(operationId)}.json`);
|
|
1532
|
-
}
|
|
1533
|
-
#derivationOperationPath(operationId) {
|
|
1534
|
-
return join(this.#operationsRoot, `derive-${operationDigest(operationId)}.json`);
|
|
1535
|
-
}
|
|
1536
1250
|
#preview(input, worldId) {
|
|
1537
1251
|
const openingMessage = genesisOpeningMessage(openingText(input.packageFiles));
|
|
1538
1252
|
return this.#promptCompiler.preview({
|
|
@@ -1670,12 +1384,6 @@ function contentPackageCreationFingerprint(title, files) {
|
|
|
1670
1384
|
function operationDigest(operationId) {
|
|
1671
1385
|
return createHash("sha256").update(operationId).digest("hex");
|
|
1672
1386
|
}
|
|
1673
|
-
async function publishOperation(path, value) {
|
|
1674
|
-
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
1675
|
-
await writeJson(temporary, value);
|
|
1676
|
-
await rename(temporary, path);
|
|
1677
|
-
await syncDirectory(dirname(path));
|
|
1678
|
-
}
|
|
1679
1387
|
function isRecord(value) {
|
|
1680
1388
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1681
1389
|
}
|
|
@@ -1688,93 +1396,14 @@ function hasRequiredAndOptionalKeys(value, required, optional) {
|
|
|
1688
1396
|
return (required.every((key) => Object.hasOwn(value, key)) &&
|
|
1689
1397
|
Object.keys(value).every((key) => allowed.has(key)));
|
|
1690
1398
|
}
|
|
1691
|
-
function assertOperationOutcomeIntegrity(value) {
|
|
1692
|
-
if (!isRecord(value) || typeof value.outcome !== "string")
|
|
1693
|
-
throw corruptOperationOutcome();
|
|
1694
|
-
if (value.outcome === "in_progress") {
|
|
1695
|
-
if (!hasExactKeys(value, ["outcome"]))
|
|
1696
|
-
throw corruptOperationOutcome();
|
|
1697
|
-
return;
|
|
1698
|
-
}
|
|
1699
|
-
if (value.outcome === "failed" || value.outcome === "cancelled") {
|
|
1700
|
-
if (!hasExactKeys(value, ["outcome"]))
|
|
1701
|
-
throw corruptOperationOutcome();
|
|
1702
|
-
return;
|
|
1703
|
-
}
|
|
1704
|
-
if (value.outcome === "acceptance_prepared" ||
|
|
1705
|
-
value.outcome === "committed" ||
|
|
1706
|
-
value.outcome === "committed_materialization_pending") {
|
|
1707
|
-
if (!hasExactKeys(value, [
|
|
1708
|
-
"outcome",
|
|
1709
|
-
"worldId",
|
|
1710
|
-
"parentHead",
|
|
1711
|
-
"head",
|
|
1712
|
-
"commitDigest",
|
|
1713
|
-
"historyAppend",
|
|
1714
|
-
"nextAdditionalMaterials",
|
|
1715
|
-
"mode",
|
|
1716
|
-
]) ||
|
|
1717
|
-
typeof value.worldId !== "string" ||
|
|
1718
|
-
value.worldId.length === 0 ||
|
|
1719
|
-
typeof value.parentHead !== "string" ||
|
|
1720
|
-
!/^(?:genesis|commit:[1-9][0-9]*)$/u.test(value.parentHead) ||
|
|
1721
|
-
typeof value.head !== "string" ||
|
|
1722
|
-
!/^commit:[1-9][0-9]*$/u.test(value.head) ||
|
|
1723
|
-
typeof value.commitDigest !== "string" ||
|
|
1724
|
-
!/^[a-f0-9]{64}$/u.test(value.commitDigest) ||
|
|
1725
|
-
!Array.isArray(value.historyAppend) ||
|
|
1726
|
-
!value.historyAppend.every(isPlayHistoryAppendInput) ||
|
|
1727
|
-
!Array.isArray(value.nextAdditionalMaterials) ||
|
|
1728
|
-
!value.nextAdditionalMaterials.every(isAuthorityMaterialSelection) ||
|
|
1729
|
-
!(value.mode === "play" ||
|
|
1730
|
-
value.mode === "correction" ||
|
|
1731
|
-
value.mode === "timeline_revision"))
|
|
1732
|
-
throw corruptOperationOutcome();
|
|
1733
|
-
return;
|
|
1734
|
-
}
|
|
1735
|
-
throw corruptOperationOutcome();
|
|
1736
|
-
}
|
|
1737
|
-
function projectOperationOutcome(value) {
|
|
1738
|
-
return structuredClone(value);
|
|
1739
|
-
}
|
|
1740
|
-
async function readAcceptancePreparedOutcome(path) {
|
|
1741
|
-
const value = await readOptionalJson(path);
|
|
1742
|
-
if (value === null)
|
|
1743
|
-
return null;
|
|
1744
|
-
assertOperationOutcomeIntegrity(value);
|
|
1745
|
-
if (value.outcome !== "acceptance_prepared")
|
|
1746
|
-
return null;
|
|
1747
|
-
return structuredClone(value);
|
|
1748
|
-
}
|
|
1749
|
-
async function resolvePreparedOperationOutcome(worldsRoot, operationId, value) {
|
|
1750
|
-
const prepared = value;
|
|
1751
|
-
const worldRoot = join(worldsRoot, prepared.worldId);
|
|
1752
|
-
const authorityStore = new FileNativeAuthorityV3(worldRoot);
|
|
1753
|
-
const authority = await authorityStore.readHead();
|
|
1754
|
-
if (authority.head === prepared.parentHead)
|
|
1755
|
-
return { outcome: "in_progress" };
|
|
1756
|
-
if (authority.operationId !== operationId)
|
|
1757
|
-
throw corruptOperationOutcome();
|
|
1758
|
-
const commit = await assertCommittedAuthorityOutcomeMatches(worldRoot, prepared);
|
|
1759
|
-
if (authority.sequence < commit.sequence)
|
|
1760
|
-
throw corruptOperationOutcome();
|
|
1761
|
-
const materialized = await readMaterializedCheckpoint(worldRoot);
|
|
1762
|
-
const outcome = materialized !== null && materialized.sequence >= commit.sequence
|
|
1763
|
-
? "committed"
|
|
1764
|
-
: "committed_materialization_pending";
|
|
1765
|
-
const resolved = { ...structuredClone(prepared), outcome };
|
|
1766
|
-
if (outcome === "committed_materialization_pending")
|
|
1767
|
-
await assertPendingMaterializationCompatible(worldRoot, resolved);
|
|
1768
|
-
return resolved;
|
|
1769
|
-
}
|
|
1770
1399
|
function corruptOperationOutcome() {
|
|
1771
1400
|
return new FileNativeWorldCreationError("world_corrupt", "Authority operation outcome structure or operation identity is invalid");
|
|
1772
1401
|
}
|
|
1773
1402
|
async function assertCommittedAuthorityOutcomeMatches(worldRoot, outcome) {
|
|
1774
|
-
const fact = await new
|
|
1403
|
+
const fact = await new WorldLedgerAuthority(worldRoot).authorityFactAt(outcome.head);
|
|
1775
1404
|
const { commit } = fact;
|
|
1776
1405
|
if (fact.digest !== outcome.commitDigest ||
|
|
1777
|
-
commit.
|
|
1406
|
+
commit.parentHead !== outcome.parentHead ||
|
|
1778
1407
|
commit.head !== outcome.head ||
|
|
1779
1408
|
commit.mode !== outcome.mode ||
|
|
1780
1409
|
!isDeepStrictEqual(commit.historyAppend.map(({ role, exactText, background }) => ({
|
|
@@ -1786,143 +1415,6 @@ async function assertCommittedAuthorityOutcomeMatches(worldRoot, outcome) {
|
|
|
1786
1415
|
throw corruptOperationOutcome();
|
|
1787
1416
|
return commit;
|
|
1788
1417
|
}
|
|
1789
|
-
async function assertPendingMaterializationCompatible(worldRoot, outcome) {
|
|
1790
|
-
const authority = new FileNativeAuthorityV3(worldRoot);
|
|
1791
|
-
await assertCommittedAuthorityOutcomeMatches(worldRoot, outcome);
|
|
1792
|
-
const [parent, target, state, history] = await Promise.all([
|
|
1793
|
-
authority.recover(outcome.parentHead),
|
|
1794
|
-
authority.recover(outcome.head),
|
|
1795
|
-
readTree(join(worldRoot, "state")),
|
|
1796
|
-
readTree(join(worldRoot, "history")),
|
|
1797
|
-
]);
|
|
1798
|
-
assertProjectionBetweenEndpoints("state", state, parent.state, target.state);
|
|
1799
|
-
assertHistoryProjectionBetweenEndpoints(history, historySurfaceFiles(parent.history), historySurfaceFiles(target.history));
|
|
1800
|
-
const existingMaterials = await readOptionalJson(join(worldRoot, "runtime", "additional-materials.json"));
|
|
1801
|
-
if (existingMaterials !== null &&
|
|
1802
|
-
!((existingMaterials.head === parent.head &&
|
|
1803
|
-
isDeepStrictEqual(existingMaterials.items, parent.additionalMaterials)) ||
|
|
1804
|
-
(existingMaterials.head === target.head &&
|
|
1805
|
-
isDeepStrictEqual(existingMaterials.items, target.additionalMaterials))))
|
|
1806
|
-
throw new FileNativeWorldCreationError("inconsistent_materialization", "Additional-material list content conflicts at the same endpoint");
|
|
1807
|
-
}
|
|
1808
|
-
function assertProjectionBetweenEndpoints(surface, current, parent, target) {
|
|
1809
|
-
const parentFiles = new Map(parent.map((file) => [file.path, file.contents]));
|
|
1810
|
-
const targetFiles = new Map(target.map((file) => [file.path, file.contents]));
|
|
1811
|
-
for (const file of current) {
|
|
1812
|
-
if (file.contents !== parentFiles.get(file.path) &&
|
|
1813
|
-
file.contents !== targetFiles.get(file.path))
|
|
1814
|
-
throw new FileNativeWorldCreationError("inconsistent_materialization", `World ${surface} projection conflicts with both accepted endpoints: ${file.path}`);
|
|
1815
|
-
}
|
|
1816
|
-
}
|
|
1817
|
-
function assertHistoryProjectionBetweenEndpoints(current, parent, target) {
|
|
1818
|
-
const byMessageId = (files) => new Map(files.map((file) => [
|
|
1819
|
-
fileNativeHistoryMessageIdFromProjectionPath(file.path),
|
|
1820
|
-
file.contents,
|
|
1821
|
-
]));
|
|
1822
|
-
const parentFiles = byMessageId(parent);
|
|
1823
|
-
const targetFiles = byMessageId(target);
|
|
1824
|
-
const seen = new Set();
|
|
1825
|
-
for (const file of current) {
|
|
1826
|
-
const messageId = fileNativeHistoryMessageIdFromProjectionPath(file.path);
|
|
1827
|
-
if (messageId === null ||
|
|
1828
|
-
seen.has(messageId) ||
|
|
1829
|
-
(file.contents !== parentFiles.get(messageId) &&
|
|
1830
|
-
file.contents !== targetFiles.get(messageId)))
|
|
1831
|
-
throw new FileNativeWorldCreationError("inconsistent_materialization", `World history projection conflicts with both accepted endpoints: ${file.path}`);
|
|
1832
|
-
seen.add(messageId);
|
|
1833
|
-
}
|
|
1834
|
-
}
|
|
1835
|
-
function isAuthorityMaterialSelection(value) {
|
|
1836
|
-
if (!isRecord(value))
|
|
1837
|
-
return false;
|
|
1838
|
-
if (value.kind === "document")
|
|
1839
|
-
return (hasExactKeys(value, ["kind", "document"]) &&
|
|
1840
|
-
typeof value.document === "string");
|
|
1841
|
-
if (value.kind === "node")
|
|
1842
|
-
return (hasExactKeys(value, ["kind", "document", "locator"]) &&
|
|
1843
|
-
typeof value.document === "string" &&
|
|
1844
|
-
isAuthorityMaterialLocator(value.locator));
|
|
1845
|
-
if (value.kind === "history_message")
|
|
1846
|
-
return (hasExactKeys(value, ["kind", "message"]) &&
|
|
1847
|
-
typeof value.message === "string");
|
|
1848
|
-
return (value.kind === "history_commit" &&
|
|
1849
|
-
hasExactKeys(value, ["kind", "commit"]) &&
|
|
1850
|
-
typeof value.commit === "string");
|
|
1851
|
-
}
|
|
1852
|
-
function isAuthorityMaterialLocator(value) {
|
|
1853
|
-
return (isRecord(value) &&
|
|
1854
|
-
hasRequiredAndOptionalKeys(value, [], ["yaml", "markdown"]) &&
|
|
1855
|
-
(value.yaml === undefined ||
|
|
1856
|
-
(Array.isArray(value.yaml) &&
|
|
1857
|
-
value.yaml.every((segment) => typeof segment === "string" || Number.isSafeInteger(segment)))) &&
|
|
1858
|
-
(value.markdown === undefined ||
|
|
1859
|
-
(Array.isArray(value.markdown) &&
|
|
1860
|
-
value.markdown.every((segment) => typeof segment === "string"))));
|
|
1861
|
-
}
|
|
1862
|
-
async function publishJson(path, value) {
|
|
1863
|
-
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
1864
|
-
await writeJson(temporary, value);
|
|
1865
|
-
await rename(temporary, path);
|
|
1866
|
-
await syncDirectory(dirname(path));
|
|
1867
|
-
}
|
|
1868
|
-
async function readMaterializedCheckpoint(root) {
|
|
1869
|
-
const value = await readOptionalJson(join(root, "runtime", "materialized-head.json"));
|
|
1870
|
-
if (value === null)
|
|
1871
|
-
return null;
|
|
1872
|
-
if (!isRecord(value) ||
|
|
1873
|
-
!hasRequiredAndOptionalKeys(value, ["schemaVersion", "head", "sequence", "commitDigest"], ["historyBackgroundCount"]) ||
|
|
1874
|
-
(value.historyBackgroundCount !== undefined &&
|
|
1875
|
-
(!Number.isSafeInteger(value.historyBackgroundCount) ||
|
|
1876
|
-
Number(value.historyBackgroundCount) < 1)) ||
|
|
1877
|
-
value.schemaVersion !== 3 ||
|
|
1878
|
-
typeof value.head !== "string" ||
|
|
1879
|
-
!Number.isSafeInteger(value.sequence) ||
|
|
1880
|
-
Number(value.sequence) < 0 ||
|
|
1881
|
-
(value.commitDigest !== null &&
|
|
1882
|
-
(typeof value.commitDigest !== "string" ||
|
|
1883
|
-
!/^[a-f0-9]{64}$/u.test(value.commitDigest))) ||
|
|
1884
|
-
(Number(value.sequence) === 0
|
|
1885
|
-
? value.head !== "genesis" || value.commitDigest !== null
|
|
1886
|
-
: value.head !== `commit:${String(value.sequence)}` ||
|
|
1887
|
-
value.commitDigest === null))
|
|
1888
|
-
throw new FileNativeWorldCreationError("world_corrupt", "Materialized world checkpoint has an invalid shape");
|
|
1889
|
-
return value;
|
|
1890
|
-
}
|
|
1891
|
-
function sameMaterializedEndpoint(checkpoint, authority) {
|
|
1892
|
-
return (checkpoint !== null &&
|
|
1893
|
-
checkpoint.head === authority.head &&
|
|
1894
|
-
checkpoint.sequence === authority.sequence &&
|
|
1895
|
-
checkpoint.commitDigest === authority.commitDigest);
|
|
1896
|
-
}
|
|
1897
|
-
function isPlayHistoryAppendInput(value) {
|
|
1898
|
-
return (isRecord(value) &&
|
|
1899
|
-
hasRequiredAndOptionalKeys(value, ["role", "exactText"], ["background"]) &&
|
|
1900
|
-
(value.background === undefined ||
|
|
1901
|
-
(isHistoryBackground(value.background) &&
|
|
1902
|
-
(value.role !== "player" || value.background.kind === "snapshot"))) &&
|
|
1903
|
-
(value.role === "player" || value.role === "narrator") &&
|
|
1904
|
-
typeof value.exactText === "string");
|
|
1905
|
-
}
|
|
1906
|
-
async function materializeRecoveredEndpoint(root, authority, endpoint, lastActivityAt = null) {
|
|
1907
|
-
if (authority.head !== endpoint.head)
|
|
1908
|
-
throw new FileNativeWorldCreationError("world_corrupt", "Recovered endpoint does not match the Authority head to materialize");
|
|
1909
|
-
await replaceMaterializedTree(join(root, "state"), endpoint.state);
|
|
1910
|
-
crashAtFileNativeAuthorityEdge("after_state_materialization");
|
|
1911
|
-
await replaceMaterializedTree(join(root, "history"), historySurfaceFiles(endpoint.history));
|
|
1912
|
-
await replaceMaterializedTree(join(root, "runtime", "history-backgrounds"), projectHistoryBackgrounds(endpoint.history));
|
|
1913
|
-
await publishJson(join(root, "runtime", "additional-materials.json"), {
|
|
1914
|
-
head: authority.head,
|
|
1915
|
-
items: endpoint.additionalMaterials,
|
|
1916
|
-
});
|
|
1917
|
-
await publishJson(join(root, "runtime", "materialized-head.json"), {
|
|
1918
|
-
schemaVersion: 3,
|
|
1919
|
-
head: authority.head,
|
|
1920
|
-
sequence: authority.sequence,
|
|
1921
|
-
commitDigest: authority.commitDigest,
|
|
1922
|
-
...backgroundCount(endpoint.history),
|
|
1923
|
-
});
|
|
1924
|
-
await updateWorldActivity(root, authority.head, endpoint.history, lastActivityAt);
|
|
1925
|
-
}
|
|
1926
1418
|
async function replaceMaterializedTree(root, files) {
|
|
1927
1419
|
const next = new Map();
|
|
1928
1420
|
for (const file of files) {
|
|
@@ -1942,34 +1434,19 @@ async function replaceMaterializedTree(root, files) {
|
|
|
1942
1434
|
continue;
|
|
1943
1435
|
const path = join(root, file.path);
|
|
1944
1436
|
await rm(path, { force: true });
|
|
1945
|
-
await syncDirectory(dirname(path));
|
|
1946
1437
|
}
|
|
1947
1438
|
}
|
|
1948
1439
|
async function publishText(path, contents) {
|
|
1949
1440
|
await mkdir(dirname(path), { recursive: true });
|
|
1950
1441
|
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
1951
1442
|
await writeFile(temporary, contents, "utf8");
|
|
1952
|
-
await syncFile(temporary);
|
|
1953
1443
|
await rename(temporary, path);
|
|
1954
|
-
await syncDirectory(dirname(path));
|
|
1955
1444
|
}
|
|
1956
1445
|
function historySurfaceFiles(messages) {
|
|
1957
1446
|
return projectFileNativeHistorySurface(messages, (messageId) => {
|
|
1958
1447
|
throw new FileNativeWorldCreationError("world_corrupt", `History-message identity cannot form a materialization path: ${messageId}`);
|
|
1959
1448
|
});
|
|
1960
1449
|
}
|
|
1961
|
-
function materializedHistoryRecord(files) {
|
|
1962
|
-
const result = {};
|
|
1963
|
-
for (const file of files) {
|
|
1964
|
-
const messageId = fileNativeHistoryMessageIdFromProjectionPath(file.path);
|
|
1965
|
-
if (messageId === null)
|
|
1966
|
-
throw new FileNativeWorldCreationError("world_corrupt", `Materialized history path is invalid: ${file.path}`);
|
|
1967
|
-
if (messageId in result)
|
|
1968
|
-
throw new FileNativeWorldCreationError("world_corrupt", `Materialized history contains a duplicate message: ${messageId}`);
|
|
1969
|
-
result[messageId] = file.contents;
|
|
1970
|
-
}
|
|
1971
|
-
return result;
|
|
1972
|
-
}
|
|
1973
1450
|
async function writeIdempotentText(path, contents) {
|
|
1974
1451
|
try {
|
|
1975
1452
|
const existing = await readFile(path, "utf8");
|
|
@@ -1981,15 +1458,9 @@ async function writeIdempotentText(path, contents) {
|
|
|
1981
1458
|
throw error;
|
|
1982
1459
|
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
1983
1460
|
await writeFile(temporary, contents, "utf8");
|
|
1984
|
-
await syncFile(temporary);
|
|
1985
1461
|
await rename(temporary, path);
|
|
1986
|
-
await syncDirectory(dirname(path));
|
|
1987
1462
|
}
|
|
1988
1463
|
}
|
|
1989
|
-
function isCommittedOutcome(outcome) {
|
|
1990
|
-
return (outcome.outcome === "committed" ||
|
|
1991
|
-
outcome.outcome === "committed_materialization_pending");
|
|
1992
|
-
}
|
|
1993
1464
|
function assertSameOperationOutcome(outcome, input) {
|
|
1994
1465
|
if (outcome.worldId !== input.worldId ||
|
|
1995
1466
|
outcome.parentHead !== input.parentHead ||
|
|
@@ -2026,8 +1497,7 @@ function authorityCommitMatchesInput(commit, input) {
|
|
|
2026
1497
|
const candidate = input.stateChanges[index];
|
|
2027
1498
|
if (candidate === undefined)
|
|
2028
1499
|
return false;
|
|
2029
|
-
|
|
2030
|
-
const expected = {
|
|
1500
|
+
return isDeepStrictEqual(stored, {
|
|
2031
1501
|
kind: candidate.kind,
|
|
2032
1502
|
documentId: candidate.documentId,
|
|
2033
1503
|
stableShortRef: candidate.stableShortRef,
|
|
@@ -2035,10 +1505,7 @@ function authorityCommitMatchesInput(commit, input) {
|
|
|
2035
1505
|
codec: candidate.codec,
|
|
2036
1506
|
expectedPreviousHash: candidate.expectedPreviousHash,
|
|
2037
1507
|
nextHash: candidate.nextHash,
|
|
2038
|
-
};
|
|
2039
|
-
return (nextBlob.epoch === commit.sequence &&
|
|
2040
|
-
`sha256:${nextBlob.digest}` === candidate.nextHash &&
|
|
2041
|
-
isDeepStrictEqual(metadata, expected));
|
|
1508
|
+
});
|
|
2042
1509
|
});
|
|
2043
1510
|
const expectedCorrection = input.mode === "correction"
|
|
2044
1511
|
? input.stateChanges.map(({ documentId }) => documentId)
|
|
@@ -2046,9 +1513,7 @@ function authorityCommitMatchesInput(commit, input) {
|
|
|
2046
1513
|
return (commit.mode === input.mode &&
|
|
2047
1514
|
commit.playContext === input.playContext &&
|
|
2048
1515
|
commit.worldClock === input.worldClock &&
|
|
2049
|
-
commit.
|
|
2050
|
-
commit.timelineParent.head ===
|
|
2051
|
-
(input.timelineRevision?.restoresHead ?? input.parentHead) &&
|
|
1516
|
+
commit.parentHead === input.parentHead &&
|
|
2052
1517
|
isDeepStrictEqual(commit.historyAppend.map(({ role, exactText, background }) => ({
|
|
2053
1518
|
role,
|
|
2054
1519
|
exactText,
|
|
@@ -2056,12 +1521,48 @@ function authorityCommitMatchesInput(commit, input) {
|
|
|
2056
1521
|
})), input.historyAppend) &&
|
|
2057
1522
|
stateChangesMatch &&
|
|
2058
1523
|
isDeepStrictEqual(commit.nextAdditionalMaterials, input.nextMaterials) &&
|
|
2059
|
-
isDeepStrictEqual(commit.timelineRevision, input.timelineRevision) &&
|
|
2060
1524
|
isDeepStrictEqual(commit.narrativeCheckpoint, input.narrativeCheckpoint) &&
|
|
2061
1525
|
isDeepStrictEqual(commit.correctionTargets, expectedCorrection) &&
|
|
2062
1526
|
commit.corrects ===
|
|
2063
1527
|
(input.mode === "correction" ? input.parentHead : undefined));
|
|
2064
1528
|
}
|
|
1529
|
+
function headPosition(head) {
|
|
1530
|
+
if (head === "genesis")
|
|
1531
|
+
return 0;
|
|
1532
|
+
const match = /^commit:([1-9][0-9]*)$/u.exec(head);
|
|
1533
|
+
if (match === null)
|
|
1534
|
+
throw new TypeError(`Endpoint is invalid: ${head}`);
|
|
1535
|
+
return Number.parseInt(match[1], 10);
|
|
1536
|
+
}
|
|
1537
|
+
// 分叉只复制一个世界的闭包;回退与身份重分配由调用方在副本上完成。
|
|
1538
|
+
const forkClosureEntries = [
|
|
1539
|
+
"state",
|
|
1540
|
+
"control",
|
|
1541
|
+
"history",
|
|
1542
|
+
join("runtime", "head.json"),
|
|
1543
|
+
join("runtime", "turns"),
|
|
1544
|
+
join("runtime", "history-backgrounds"),
|
|
1545
|
+
join("runtime", "additional-materials.json"),
|
|
1546
|
+
join("runtime", "genesis.json"),
|
|
1547
|
+
join("runtime", "play-genesis-timeline.json"),
|
|
1548
|
+
"artifacts",
|
|
1549
|
+
];
|
|
1550
|
+
async function copyForkClosure(sourceRoot, target) {
|
|
1551
|
+
await mkdir(join(target, "runtime"), { recursive: true, mode: 0o700 });
|
|
1552
|
+
for (const entry of forkClosureEntries) {
|
|
1553
|
+
const source = join(sourceRoot, entry);
|
|
1554
|
+
try {
|
|
1555
|
+
await stat(source);
|
|
1556
|
+
}
|
|
1557
|
+
catch (error) {
|
|
1558
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
1559
|
+
continue;
|
|
1560
|
+
throw error;
|
|
1561
|
+
}
|
|
1562
|
+
await mkdir(dirname(join(target, entry)), { recursive: true, mode: 0o700 });
|
|
1563
|
+
await cp(source, join(target, entry), { recursive: true });
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
2065
1566
|
function openWorldDocumentSnapshot(files) {
|
|
2066
1567
|
return WorldDocumentStore.open({
|
|
2067
1568
|
layout: "world_state",
|
|
@@ -2078,36 +1579,11 @@ async function writeJson(path, value) {
|
|
|
2078
1579
|
encoding: "utf8",
|
|
2079
1580
|
mode: 0o600,
|
|
2080
1581
|
});
|
|
2081
|
-
await syncFile(path);
|
|
2082
1582
|
}
|
|
2083
|
-
async function
|
|
2084
|
-
const
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
}
|
|
2088
|
-
finally {
|
|
2089
|
-
await handle.close();
|
|
2090
|
-
}
|
|
2091
|
-
}
|
|
2092
|
-
async function syncDirectory(path) {
|
|
2093
|
-
const handle = await open(path, "r");
|
|
2094
|
-
try {
|
|
2095
|
-
await handle.sync();
|
|
2096
|
-
}
|
|
2097
|
-
finally {
|
|
2098
|
-
await handle.close();
|
|
2099
|
-
}
|
|
2100
|
-
}
|
|
2101
|
-
async function durableTree(root) {
|
|
2102
|
-
const entries = await readdir(root, { withFileTypes: true });
|
|
2103
|
-
for (const entry of entries) {
|
|
2104
|
-
const path = join(root, entry.name);
|
|
2105
|
-
if (entry.isDirectory())
|
|
2106
|
-
await durableTree(path);
|
|
2107
|
-
else if (entry.isFile())
|
|
2108
|
-
await syncFile(path);
|
|
2109
|
-
}
|
|
2110
|
-
await syncDirectory(root);
|
|
1583
|
+
async function publishJson(path, value) {
|
|
1584
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
1585
|
+
await writeJson(temporary, value);
|
|
1586
|
+
await rename(temporary, path);
|
|
2111
1587
|
}
|
|
2112
1588
|
async function readGenesisForFork(worldRoot, worldId) {
|
|
2113
1589
|
const value = await readJson(join(worldRoot, "runtime", "genesis.json"));
|
|
@@ -2179,15 +1655,16 @@ async function readPublicationAt(root) {
|
|
|
2179
1655
|
}
|
|
2180
1656
|
async function readWorldSummaryAt(root, publication, serializeRebuild) {
|
|
2181
1657
|
const published = publication ?? (await readPublicationAt(root));
|
|
2182
|
-
const
|
|
2183
|
-
const
|
|
2184
|
-
|
|
1658
|
+
const gameplay = await readWorldGameplayAt(root);
|
|
1659
|
+
const activity = await readWorldActivity(root, serializeRebuild);
|
|
1660
|
+
return {
|
|
1661
|
+
...toSummary(published, gameplay),
|
|
2185
1662
|
title: await readWorldTitleAt(root, published),
|
|
2186
|
-
lastActivityAt,
|
|
2187
|
-
excerpt,
|
|
2188
|
-
playerTurns,
|
|
1663
|
+
lastActivityAt: activity.lastActivityAt,
|
|
1664
|
+
excerpt: activity.excerpt,
|
|
1665
|
+
playerTurns: activity.playerTurns,
|
|
1666
|
+
...(activity.pendingUpgrade === true ? { pendingUpgrade: true } : {}),
|
|
2189
1667
|
};
|
|
2190
|
-
return summary;
|
|
2191
1668
|
}
|
|
2192
1669
|
async function readWorldTitleAt(root, published) {
|
|
2193
1670
|
const metadata = await readOptionalJson(join(root, localMetadataFile));
|
|
@@ -2197,6 +1674,15 @@ async function readWorldTitleAt(root, published) {
|
|
|
2197
1674
|
throw new FileNativeWorldCreationError("world_corrupt", "Local world shell is corrupt");
|
|
2198
1675
|
return metadata.name;
|
|
2199
1676
|
}
|
|
1677
|
+
/** Worlds copy the package's gameplay identity with control; its absence means interactive fiction. */
|
|
1678
|
+
async function readWorldGameplayAt(root) {
|
|
1679
|
+
try {
|
|
1680
|
+
return readGameplay(controlGameplayFiles(await readTree(join(root, "control"))));
|
|
1681
|
+
}
|
|
1682
|
+
catch (error) {
|
|
1683
|
+
throw new FileNativeWorldCreationError("world_corrupt", "World gameplay identity is corrupt", { cause: error });
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
2200
1686
|
async function readJson(path) {
|
|
2201
1687
|
return JSON.parse(await readFile(path, "utf8"));
|
|
2202
1688
|
}
|
|
@@ -2210,13 +1696,26 @@ async function readOptionalJson(path) {
|
|
|
2210
1696
|
throw error;
|
|
2211
1697
|
}
|
|
2212
1698
|
}
|
|
2213
|
-
function toSummary(publication) {
|
|
1699
|
+
function toSummary(publication, gameplay) {
|
|
2214
1700
|
return {
|
|
2215
1701
|
worldId: publication.worldId,
|
|
2216
1702
|
title: publication.title,
|
|
2217
1703
|
parentEndpoint: publication.parentEndpoint,
|
|
1704
|
+
gameplay,
|
|
2218
1705
|
};
|
|
2219
1706
|
}
|
|
1707
|
+
/** World control is a fixed mode and scheme: drafts, revisions and applies may not change either. */
|
|
1708
|
+
function assertControlGameplayUnchanged(current, next) {
|
|
1709
|
+
try {
|
|
1710
|
+
assertSameGameplay(controlGameplayFiles(current), controlGameplayFiles(next));
|
|
1711
|
+
}
|
|
1712
|
+
catch (error) {
|
|
1713
|
+
throw new FileNativeWorldCreationError("candidate_validation_failed", error instanceof Error ? error.message : "World gameplay is fixed", { cause: error });
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
function controlGameplayFiles(files) {
|
|
1717
|
+
return files.map((file) => ({ ...file, path: `control/${file.path}` }));
|
|
1718
|
+
}
|
|
2220
1719
|
const worldNamePattern = /^[^\r\n]{1,160}$/u;
|
|
2221
1720
|
function validWorldName(name) {
|
|
2222
1721
|
return worldNamePattern.test(name);
|
|
@@ -2284,36 +1783,20 @@ function assertRelativePath(path) {
|
|
|
2284
1783
|
function isNodeError(error) {
|
|
2285
1784
|
return error instanceof Error && "code" in error;
|
|
2286
1785
|
}
|
|
2287
|
-
function crashAtFileNativeWorldCreationEdge(edge) {
|
|
2288
|
-
if (process.env.NODE_ENV === "test" &&
|
|
2289
|
-
process.env
|
|
2290
|
-
.NARRAEON_INTERNAL_TEST_CRASH_AT_FILE_NATIVE_WORLD_CREATION_EDGE === edge) {
|
|
2291
|
-
process.kill(process.pid, "SIGKILL");
|
|
2292
|
-
}
|
|
2293
|
-
}
|
|
2294
|
-
function crashAtFileNativeAuthorityEdge(edge) {
|
|
2295
|
-
if (process.env.NODE_ENV === "test" &&
|
|
2296
|
-
process.env.NARRAEON_INTERNAL_TEST_CRASH_AT_FILE_NATIVE_AUTHORITY_EDGE ===
|
|
2297
|
-
edge) {
|
|
2298
|
-
process.kill(process.pid, "SIGKILL");
|
|
2299
|
-
}
|
|
2300
|
-
}
|
|
2301
1786
|
import { readDocumentMaintenanceFacts, } from "./WorldDocumentMaintenance.js";
|
|
2302
1787
|
function backgroundField(background) {
|
|
2303
1788
|
return background === undefined ? {} : { background };
|
|
2304
1789
|
}
|
|
2305
|
-
function
|
|
2306
|
-
return
|
|
2307
|
-
const id = path.replace(/\.json$/u, "");
|
|
2308
|
-
const value = JSON.parse(contents);
|
|
2309
|
-
if (path !== `${id}.json` ||
|
|
2310
|
-
!Object.hasOwn(history, id) ||
|
|
2311
|
-
!isHistoryBackground(value))
|
|
2312
|
-
throw new FileNativeWorldCreationError("world_corrupt", "Materialized history background is invalid or unreachable");
|
|
2313
|
-
return [id, value];
|
|
2314
|
-
}));
|
|
1790
|
+
function playerRevisionIntentPath(worldsRoot, worldId) {
|
|
1791
|
+
return join(worldsRoot, worldId, "runtime", "player-revision-intent.json");
|
|
2315
1792
|
}
|
|
2316
|
-
function
|
|
2317
|
-
|
|
2318
|
-
|
|
1793
|
+
async function readOptionalJsonFile(path) {
|
|
1794
|
+
try {
|
|
1795
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
1796
|
+
}
|
|
1797
|
+
catch (error) {
|
|
1798
|
+
if (error.code === "ENOENT")
|
|
1799
|
+
return null;
|
|
1800
|
+
throw error;
|
|
1801
|
+
}
|
|
2319
1802
|
}
|