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.
Files changed (62) hide show
  1. package/README.md +13 -0
  2. package/README.zh-CN.md +10 -0
  3. package/dist/node/protocol/gameplay.js +69 -0
  4. package/dist/node/protocol/resolution.js +274 -0
  5. package/dist/node/protocol/v1.js +15 -2
  6. package/dist/node/runtime/FileNativeExclusiveLink.js +39 -0
  7. package/dist/node/runtime/RuntimeErrorLog.js +95 -0
  8. package/dist/node/runtime/V1Runtime.js +58 -7
  9. package/dist/node/runtime/archive/WorldArchives.js +14 -46
  10. package/dist/node/runtime/artifact/ArtifactStoreFiles.js +330 -0
  11. package/dist/node/runtime/artifact/FileNativeArtifactStore.js +495 -1535
  12. package/dist/node/runtime/artifact/LegacyArtifactStoreMigration.js +260 -0
  13. package/dist/node/runtime/config/AppPreferencesStore.js +0 -1
  14. package/dist/node/runtime/config/ProtagonistTemplateStore.js +0 -1
  15. package/dist/node/runtime/config/dataRootLock.js +108 -0
  16. package/dist/node/runtime/content/ContentWorkspace.js +10 -12
  17. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +50 -46
  18. package/dist/node/runtime/content/FileNativeContentTree.js +8 -1
  19. package/dist/node/runtime/extension/PackageScriptPermissions.js +0 -8
  20. package/dist/node/runtime/extension/WorldExtensionControls.js +0 -8
  21. package/dist/node/runtime/model/ModelConnectionStore.js +0 -1
  22. package/dist/node/runtime/play/FileNativeContinuityCorrection.js +3 -14
  23. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +31 -170
  24. package/dist/node/runtime/play/FileNativePlayPresetStore.js +48 -3
  25. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +36 -239
  26. package/dist/node/runtime/play/OrderedPlayPrompts.js +68 -6
  27. package/dist/node/runtime/play/PlayCallChain.js +359 -359
  28. package/dist/node/runtime/play/PlayDocumentTools.js +8 -125
  29. package/dist/node/runtime/play/PlayFollowupRequests.js +2 -2
  30. package/dist/node/runtime/play/PlayTraceRetention.js +8 -36
  31. package/dist/node/runtime/play/TabletopResolution.js +372 -0
  32. package/dist/node/runtime/play/TabletopRules.js +141 -0
  33. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +45 -10
  34. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +35 -2
  35. package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +7 -16
  36. package/dist/node/runtime/setting/SettingAuthoringTransaction.js +56 -13
  37. package/dist/node/runtime/setting/SettingImprovementSession.js +6 -4
  38. package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +213 -1033
  39. package/dist/node/runtime/world/FileNativeWorldStore.js +266 -783
  40. package/dist/node/runtime/world/{FileNativeAuthorityV3.js → LegacyAuthorityV3Reader.js} +13 -404
  41. package/dist/node/runtime/world/WorldActivity.js +14 -22
  42. package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -2
  43. package/dist/node/runtime/world/WorldLedgerAuthority.js +340 -0
  44. package/dist/node/runtime/world/WorldOperationCoordinator.js +59 -401
  45. package/dist/node/runtime/world/WorldQueue.js +46 -0
  46. package/dist/node/runtime/world/WorldTurnLedger.js +353 -0
  47. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -12
  48. package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +35 -6
  49. package/dist/node/runtime/world-revision/WorldRevisionSession.js +9 -5
  50. package/dist/node/server/ConversationStream.js +2 -0
  51. package/dist/node/server/mobile.js +20 -0
  52. package/dist/node/server/startWebServer.js +15 -8
  53. package/dist/node/shared/ordered-author-prompts.js +8 -0
  54. package/dist/node/shared/ordered-play-prompts.js +23 -0
  55. package/dist/node/shared/tabletop-guidance.js +100 -0
  56. package/dist/web/assets/index-7GzLSUpU.js +1323 -0
  57. package/dist/web/assets/index-BJ-1Mb0e.css +1 -0
  58. package/dist/web/index.html +2 -2
  59. package/package.json +2 -1
  60. package/dist/node/runtime/FileNativePhysicalClone.js +0 -86
  61. package/dist/web/assets/index-DxNtxzLR.js +0 -1255
  62. package/dist/web/assets/index-DzOnu9uv.css +0 -1
@@ -0,0 +1,260 @@
1
+ import { readdir, readFile, rm, stat, mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { ArtifactStoreCorruptionError, artifactSequenceFileName, assertOperation, assertRawRecord, identityHash, isMissing, isNonEmptyString, isRecord, parseArtifactJson, payloadFingerprint, rawRecordFingerprint, writeArtifactJson, } from "./ArtifactStoreFiles.js";
4
+ /** Whether this world still has artifact state in the released layout. */
5
+ export async function hasLegacyArtifactState(dataRoot, worldId) {
6
+ if (await exists(legacyWorldRoot(dataRoot, worldId)))
7
+ return true;
8
+ return ((await exists(forkSnapshotPath(dataRoot, worldId))) &&
9
+ !(await exists(forkRestoredMarker(dataRoot, worldId))));
10
+ }
11
+ /** Folds one world's released artifact state into `artifacts/`, then deletes the released copies. */
12
+ export async function migrateLegacyArtifactStore(dataRoot, worldId) {
13
+ if (!(await hasLegacyArtifactState(dataRoot, worldId)))
14
+ return;
15
+ const source = await readLegacySource(dataRoot, worldId);
16
+ const folded = foldLegacy(source);
17
+ await writeFolded(dataRoot, worldId, folded);
18
+ await rm(forkSnapshotPath(dataRoot, worldId), { force: true });
19
+ await rm(forkRestoredMarker(dataRoot, worldId), { force: true });
20
+ await rm(legacyWorldRoot(dataRoot, worldId), {
21
+ recursive: true,
22
+ force: true,
23
+ });
24
+ for (const path of source.legacyOperationPaths)
25
+ await rm(path, { force: true });
26
+ await removeEmptyLegacyRoot(dataRoot);
27
+ }
28
+ async function readLegacySource(dataRoot, worldId) {
29
+ const legacyOperationPaths = await legacyOperationPathsFor(dataRoot, worldId);
30
+ const pending = await readPendingFork(dataRoot, worldId);
31
+ if (pending !== null)
32
+ return {
33
+ records: pending.records,
34
+ events: pending.events,
35
+ operations: pending.operations,
36
+ legacyOperationPaths,
37
+ };
38
+ const worldRoot = legacyWorldRoot(dataRoot, worldId);
39
+ const operations = [];
40
+ for (const path of legacyOperationPaths)
41
+ operations.push(parseArtifactJson(await readFile(path, "utf8"), "legacy artifact operation"));
42
+ return {
43
+ records: await readJsonDirectory(join(worldRoot, "records"), "legacy artifact raw record"),
44
+ events: await readJsonDirectory(join(worldRoot, "events"), "legacy artifact projection event"),
45
+ operations,
46
+ legacyOperationPaths,
47
+ };
48
+ }
49
+ async function readPendingFork(dataRoot, worldId) {
50
+ if (await exists(forkRestoredMarker(dataRoot, worldId)))
51
+ return null;
52
+ let bytes;
53
+ try {
54
+ bytes = await readFile(forkSnapshotPath(dataRoot, worldId), "utf8");
55
+ }
56
+ catch (error) {
57
+ if (isMissing(error))
58
+ return null;
59
+ throw error;
60
+ }
61
+ const snapshot = parseArtifactJson(bytes, "legacy artifact fork");
62
+ if (!isRecord(snapshot) ||
63
+ snapshot.schemaVersion !== 1 ||
64
+ snapshot.worldId !== worldId ||
65
+ !Array.isArray(snapshot.operations) ||
66
+ !Array.isArray(snapshot.records) ||
67
+ !Array.isArray(snapshot.events))
68
+ throw new ArtifactStoreCorruptionError("Artifact fork snapshot is invalid");
69
+ for (const operation of snapshot.operations) {
70
+ assertLegacyOperation(operation);
71
+ if (operation.context.worldId !== worldId)
72
+ throw new ArtifactStoreCorruptionError("Artifact fork world mismatch");
73
+ }
74
+ return snapshot;
75
+ }
76
+ function assertLegacyOperation(operation) {
77
+ if (operation.status === "core_materialization_pending")
78
+ throw new ArtifactStoreCorruptionError("Artifact operation awaits a materialization that no longer exists");
79
+ assertOperation(operation);
80
+ }
81
+ /**
82
+ * Replays the released event log exactly as the released reader did, then
83
+ * writes each record's resulting status and head. A record's own fingerprint
84
+ * must still match its released content, so a damaged source is rejected.
85
+ */
86
+ function foldLegacy(source) {
87
+ const operations = new Map(source.operations.map((operation) => {
88
+ assertLegacyOperation(operation);
89
+ return [operation.context.operationId, operation];
90
+ }));
91
+ const raw = [...source.records].sort((a, b) => a.sequence - b.sequence);
92
+ const sequences = new Set();
93
+ const recordIds = new Set();
94
+ for (const record of raw) {
95
+ if (record.newHead !== null ||
96
+ record.status !== "pending" ||
97
+ record.recordFingerprint !== rawRecordFingerprint(record) ||
98
+ record.payloadFingerprint !==
99
+ payloadFingerprint(record.output, record.payload) ||
100
+ recordIds.has(record.recordId) ||
101
+ sequences.has(record.sequence) ||
102
+ !isNonEmptyString(record.recordId))
103
+ throw new ArtifactStoreCorruptionError("Legacy artifact raw record is corrupt");
104
+ recordIds.add(record.recordId);
105
+ sequences.add(record.sequence);
106
+ const operation = operations.get(record.operationId);
107
+ if (operation?.context.worldId !== record.worldId ||
108
+ operation.context.parentHead !== record.parentHead ||
109
+ operation.context.playPresetId !== record.playPresetId ||
110
+ operation.context.playPresetRevision !== record.playPresetRevision ||
111
+ operation.context.playPresetScriptsEnabled !==
112
+ record.playPresetScriptsEnabled)
113
+ throw new ArtifactStoreCorruptionError("Legacy artifact raw record does not match the operation identity");
114
+ }
115
+ const events = [...source.events].sort((a, b) => a.sequence - b.sequence);
116
+ for (const event of events) {
117
+ if (sequences.has(event.sequence))
118
+ throw new ArtifactStoreCorruptionError("Artifact world sequence is duplicated");
119
+ sequences.add(event.sequence);
120
+ }
121
+ const state = new Map();
122
+ for (const record of raw)
123
+ state.set(record.recordId, { status: "pending", head: null });
124
+ for (const event of events) {
125
+ const eventOperation = operations.get(event.operationId);
126
+ if (eventOperation === undefined)
127
+ throw new ArtifactStoreCorruptionError("Artifact projection-event operation does not exist");
128
+ if (event.kind === "ignored_call")
129
+ continue;
130
+ if (event.kind === "clear") {
131
+ if (eventOperation.artifactProjectionStatus === "superseded" ||
132
+ (eventOperation.requestAttempts[event.requestId] !== undefined &&
133
+ eventOperation.requestAttempts[event.requestId] !==
134
+ event.requestAttempt))
135
+ continue;
136
+ for (const record of raw) {
137
+ const current = state.get(record.recordId);
138
+ if (current.status !== "superseded" &&
139
+ current.status !== "cleared" &&
140
+ record.sequence < event.sequence &&
141
+ record.channel === event.channel &&
142
+ record.output === event.output &&
143
+ (event.key === undefined || record.key === event.key))
144
+ current.status = "cleared";
145
+ }
146
+ continue;
147
+ }
148
+ const record = raw.find(({ recordId }) => recordId === event.recordId);
149
+ if (record?.operationId !== event.operationId)
150
+ throw new ArtifactStoreCorruptionError("Artifact projection event does not match the record identity");
151
+ const current = state.get(record.recordId);
152
+ if (event.kind === "activate") {
153
+ if (current.status === "pending")
154
+ current.status = "active";
155
+ }
156
+ else if (event.kind === "supersede") {
157
+ current.status = "superseded";
158
+ }
159
+ else {
160
+ if ((eventOperation.status !== "core_committed" &&
161
+ eventOperation.status !== "completed") ||
162
+ eventOperation.head !== event.head)
163
+ throw new ArtifactStoreCorruptionError("Artifact bind_head event does not match the operation core head");
164
+ current.head = event.head;
165
+ }
166
+ }
167
+ const records = raw.map((record) => {
168
+ const current = state.get(record.recordId);
169
+ const operation = operations.get(record.operationId);
170
+ const currentAttempt = operation?.requestAttempts[record.requestId];
171
+ const superseded = (currentAttempt !== undefined &&
172
+ currentAttempt !== record.requestAttempt) ||
173
+ operation?.artifactProjectionStatus === "superseded";
174
+ const next = {
175
+ ...record,
176
+ status: superseded ? "superseded" : current.status,
177
+ head: current.head,
178
+ };
179
+ delete next.newHead;
180
+ const folded = { ...next, recordFingerprint: rawRecordFingerprint(next) };
181
+ assertRawRecord(folded);
182
+ return folded;
183
+ });
184
+ return {
185
+ records,
186
+ operations: [...operations.values()],
187
+ };
188
+ }
189
+ async function writeFolded(dataRoot, worldId, folded) {
190
+ const artifacts = join(worldsRoot(dataRoot), worldId, "artifacts");
191
+ await rm(join(artifacts, "records"), { recursive: true, force: true });
192
+ await rm(join(artifacts, "operations"), { recursive: true, force: true });
193
+ await mkdir(artifacts, { recursive: true, mode: 0o700 });
194
+ for (const record of folded.records)
195
+ await writeArtifactJson(join(artifacts, "records", artifactSequenceFileName(record.sequence)), record);
196
+ for (const operation of folded.operations) {
197
+ assertOperation(operation);
198
+ await writeArtifactJson(join(artifacts, "operations", `${identityHash(operation.context.operationId)}.json`), operation);
199
+ }
200
+ }
201
+ async function removeEmptyLegacyRoot(dataRoot) {
202
+ const root = join(dataRoot, "artifact-store");
203
+ if ((await entries(join(root, "worlds"))).length === 0 &&
204
+ (await entries(join(root, "operations"))).length === 0)
205
+ await rm(root, { recursive: true, force: true });
206
+ }
207
+ async function legacyOperationPathsFor(dataRoot, worldId) {
208
+ const directory = join(dataRoot, "artifact-store", "operations");
209
+ const paths = [];
210
+ for (const name of await entries(directory)) {
211
+ if (!name.endsWith(".json"))
212
+ continue;
213
+ const path = join(directory, name);
214
+ const operation = parseArtifactJson(await readFile(path, "utf8"), "legacy artifact operation");
215
+ if (operation.context?.worldId === worldId)
216
+ paths.push(path);
217
+ }
218
+ return paths;
219
+ }
220
+ async function readJsonDirectory(directory, label) {
221
+ const values = [];
222
+ for (const name of (await entries(directory))
223
+ .filter((item) => item.endsWith(".json"))
224
+ .sort())
225
+ values.push(parseArtifactJson(await readFile(join(directory, name), "utf8"), label));
226
+ return values;
227
+ }
228
+ async function entries(directory) {
229
+ try {
230
+ return await readdir(directory);
231
+ }
232
+ catch (error) {
233
+ if (isMissing(error))
234
+ return [];
235
+ throw error;
236
+ }
237
+ }
238
+ async function exists(path) {
239
+ try {
240
+ await stat(path);
241
+ return true;
242
+ }
243
+ catch (error) {
244
+ if (isMissing(error))
245
+ return false;
246
+ throw error;
247
+ }
248
+ }
249
+ function worldsRoot(dataRoot) {
250
+ return join(dataRoot, "worlds-file-native");
251
+ }
252
+ function legacyWorldRoot(dataRoot, worldId) {
253
+ return join(dataRoot, "artifact-store", "worlds", identityHash(worldId));
254
+ }
255
+ function forkSnapshotPath(dataRoot, worldId) {
256
+ return join(worldsRoot(dataRoot), worldId, "runtime", "artifact-fork.json");
257
+ }
258
+ function forkRestoredMarker(dataRoot, worldId) {
259
+ return join(worldsRoot(dataRoot), worldId, "fork-restored.json");
260
+ }
@@ -71,7 +71,6 @@ export class AppPreferencesStore {
71
71
  try {
72
72
  handle = await open(temporary, "wx", 0o600);
73
73
  await handle.writeFile(`${JSON.stringify(document, null, 2)}\n`, "utf8");
74
- await handle.sync();
75
74
  await handle.close();
76
75
  handle = undefined;
77
76
  await chmod(temporary, 0o600);
@@ -86,7 +86,6 @@ export class ProtagonistTemplateStore {
86
86
  try {
87
87
  handle = await open(temporary, "wx", 0o600);
88
88
  await handle.writeFile(`${JSON.stringify({ schemaVersion: 1, ...view }, null, 2)}\n`, "utf8");
89
- await handle.sync();
90
89
  await handle.close();
91
90
  handle = undefined;
92
91
  await rename(temporary, this.#path);
@@ -0,0 +1,108 @@
1
+ import { mkdir, open, readdir, readFile, rm, rmdir } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ /**
4
+ * One service process owns a data directory (ADR-0044). The lock is a pid
5
+ * file: a live owner refuses startup, and a dead owner's file is replaced.
6
+ */
7
+ const lockFileName = "server.pid";
8
+ /** Persistent lock directories left by the retired cross-process world locks. */
9
+ const legacyLockPaths = [
10
+ "runtime-locks",
11
+ join("operations", "world-locks"),
12
+ join("operations", "authority-locks"),
13
+ join("operations", "correction-claims"),
14
+ ];
15
+ export class DataRootBusyError extends Error {
16
+ ownerPid;
17
+ constructor(ownerPid) {
18
+ super(`Another Narraeon server (pid ${ownerPid}) is already using this data directory`);
19
+ this.name = "DataRootBusyError";
20
+ this.ownerPid = ownerPid;
21
+ }
22
+ }
23
+ export async function acquireDataRootLock(dataRoot) {
24
+ const root = resolve(dataRoot);
25
+ await mkdir(root, { recursive: true, mode: 0o700 });
26
+ const path = join(root, lockFileName);
27
+ for (let attempt = 0; attempt < 3; attempt += 1) {
28
+ try {
29
+ const handle = await open(path, "wx", 0o600);
30
+ try {
31
+ await handle.writeFile(`${process.pid}\n`, "utf8");
32
+ }
33
+ finally {
34
+ await handle.close();
35
+ }
36
+ await removeLegacyLocks(root);
37
+ return {
38
+ release: async () => {
39
+ if ((await readOwnerPid(path)) === process.pid)
40
+ await rm(path, { force: true });
41
+ },
42
+ };
43
+ }
44
+ catch (error) {
45
+ if (!isNodeError(error) || error.code !== "EEXIST")
46
+ throw error;
47
+ const owner = await readOwnerPid(path);
48
+ if (owner !== null && processIsAlive(owner))
49
+ throw new DataRootBusyError(owner);
50
+ await rm(path, { force: true });
51
+ }
52
+ }
53
+ throw new Error("Could not acquire the data-directory startup lock");
54
+ }
55
+ async function readOwnerPid(path) {
56
+ let text;
57
+ try {
58
+ text = await readFile(path, "utf8");
59
+ }
60
+ catch (error) {
61
+ if (isNodeError(error) && error.code === "ENOENT")
62
+ return null;
63
+ throw error;
64
+ }
65
+ const pid = Number(text.trim());
66
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
67
+ }
68
+ function processIsAlive(pid) {
69
+ try {
70
+ process.kill(pid, 0);
71
+ return true;
72
+ }
73
+ catch (error) {
74
+ return !(isNodeError(error) && error.code === "ESRCH");
75
+ }
76
+ }
77
+ async function removeLegacyLocks(root) {
78
+ for (const relative of legacyLockPaths)
79
+ await rm(join(root, relative), { recursive: true, force: true });
80
+ await removeLegacyOperationReceipts(root);
81
+ }
82
+ /**
83
+ * Operation receipts written before #76 (including archive import receipts) have
84
+ * no readers. The world-revision epoch markers in the same directory are kept.
85
+ */
86
+ async function removeLegacyOperationReceipts(root) {
87
+ const directory = join(root, "operations");
88
+ const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
89
+ if (isNodeError(error) && error.code === "ENOENT")
90
+ return null;
91
+ throw error;
92
+ });
93
+ if (entries === null)
94
+ return;
95
+ for (const entry of entries)
96
+ if (entry.isFile() && entry.name.endsWith(".json"))
97
+ await rm(join(directory, entry.name), { force: true });
98
+ try {
99
+ await rmdir(directory);
100
+ }
101
+ catch (error) {
102
+ if (!isNodeError(error) || error.code !== "ENOTEMPTY")
103
+ throw error;
104
+ }
105
+ }
106
+ function isNodeError(error) {
107
+ return error instanceof Error && "code" in error;
108
+ }
@@ -1,6 +1,7 @@
1
1
  import { isAbsolute, win32 } from "node:path";
2
2
  import { inflateRawSync } from "node:zlib";
3
3
  import { portableContentTreePathKey } from "../../protocol/contentTree.js";
4
+ import { fictionGameplay, gameplayFile, parseGameplay, } from "../../protocol/gameplay.js";
4
5
  import { defaultAppLocale, } from "../../protocol/appPreferences.js";
5
6
  import { defaultContentWorkspaceLimits, maximumPortableContentArchiveBytes, } from "./ContentLimits.js";
6
7
  import { CurrentTreeContentLibrary, InvalidContentTreeError, normalizeCurrentContentTreeFiles, } from "./CurrentTreeContentLibrary.js";
@@ -36,11 +37,15 @@ export class ContentWorkspace {
36
37
  ...options.currentTree,
37
38
  });
38
39
  }
39
- createCurrentTreeContentPackage() {
40
+ createCurrentTreeContentPackage(gameplay = fictionGameplay) {
40
41
  const locale = this.#locale();
42
+ const chosen = parseGameplay(gameplay);
41
43
  return this.#currentTreeLibrary.createPackage({
42
44
  title: defaultContentPackageTitle(locale, "new"),
43
- files: minimalFileNativeContentScaffold(locale),
45
+ files: [
46
+ ...minimalFileNativeContentScaffold(locale),
47
+ ...(chosen.mode === "tabletop" ? [gameplayFile(chosen)] : []),
48
+ ],
44
49
  });
45
50
  }
46
51
  createSampleContentPackage(locale) {
@@ -76,6 +81,9 @@ export class ContentWorkspace {
76
81
  replaceCurrentTreeContentPackage(localId, files, options = {}) {
77
82
  return this.#currentTreeLibrary.replaceCurrentTree(localId, files, options);
78
83
  }
84
+ setCurrentTreeContentGameplayScheme(localId, scheme, options = {}) {
85
+ return this.#currentTreeLibrary.setGameplayScheme(localId, scheme, options);
86
+ }
79
87
  importPortableContentPackageArchive(archive, options = {}) {
80
88
  return this.#mutate(async () => {
81
89
  const files = readPortableContentArchive(archive, this.#limits, options.signal);
@@ -107,7 +115,6 @@ export class ContentWorkspace {
107
115
  await this.#beforePortableContentExport?.();
108
116
  options.signal?.throwIfAborted();
109
117
  const archive = createPortableContentArchive(package_.files);
110
- await crashProcessAtPortableContentExportEdge("after_archive_created");
111
118
  options.signal?.throwIfAborted();
112
119
  return {
113
120
  fileName: `${portableArchiveBaseName(package_.title)}.zip`,
@@ -625,12 +632,3 @@ function isAbortError(error) {
625
632
  function compareStrings(left, right) {
626
633
  return left < right ? -1 : left > right ? 1 : 0;
627
634
  }
628
- async function crashProcessAtPortableContentExportEdge(edge) {
629
- if (process.env.NODE_ENV !== "test" ||
630
- process.env.NARRAEON_INTERNAL_TEST_CRASH_AT_PORTABLE_CONTENT_EXPORT_EDGE !==
631
- edge) {
632
- return;
633
- }
634
- process.kill(process.pid, "SIGKILL");
635
- await new Promise(() => undefined);
636
- }
@@ -1,9 +1,11 @@
1
1
  import { PackageScriptPermissions } from "../extension/PackageScriptPermissions.js";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { readFileSync, unlinkSync } from "node:fs";
4
- import { link, open, mkdir, readFile, readdir, rename, rm, stat, unlink, } from "node:fs/promises";
4
+ import { open, mkdir, readFile, readdir, rename, rm, stat, unlink, } from "node:fs/promises";
5
+ import { linkExclusive } from "../FileNativeExclusiveLink.js";
5
6
  import { basename, dirname, isAbsolute, join, posix, win32 } from "node:path";
6
7
  import { portableContentTreePathKey } from "../../protocol/contentTree.js";
8
+ import { assertSameGameplayMode, gameplayFile, gameplayPath, readGameplay, } from "../../protocol/gameplay.js";
7
9
  import { defaultContentWorkspaceLimits, } from "./ContentLimits.js";
8
10
  import { inspectContentPackageCurrentTree, isContentPackageWorldDocumentPath, worldDocumentTextRequirement, } from "./FileNativeContentTree.js";
9
11
  const localIdPattern = /^package-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
@@ -91,7 +93,6 @@ export class CurrentTreeContentLibrary {
91
93
  try {
92
94
  options.signal?.throwIfAborted();
93
95
  await rename(this.#packageRoot(localId), tombstonePath);
94
- await syncDirectory(this.#packagesRoot);
95
96
  }
96
97
  catch (error) {
97
98
  if (isAbortError(error))
@@ -228,6 +229,29 @@ export class CurrentTreeContentLibrary {
228
229
  }
229
230
  });
230
231
  }
232
+ /**
233
+ * Change only the resolution scheme of a tabletop package. The edit reads the
234
+ * tree under the same lease as ordinary saves, so it publishes atomically and
235
+ * follows the same conflict rules.
236
+ */
237
+ setGameplayScheme(localId, scheme, options = {}) {
238
+ return this.#mutate(async () => {
239
+ const lease = await this.acquireOperationLease(localId, {
240
+ wait: options.waitForLease !== false,
241
+ });
242
+ try {
243
+ options.signal?.throwIfAborted();
244
+ const current = lease.package;
245
+ if (current.gameplay.mode !== "tabletop")
246
+ throw new InvalidContentTreeError("Only a tabletop content package has a resolution scheme / 只有跑团内容包有判定方案");
247
+ const replacement = gameplayFile({ mode: "tabletop", scheme });
248
+ return await lease.replace(current.files.map((file) => file.path === gameplayPath ? replacement : file), options);
249
+ }
250
+ finally {
251
+ lease.release();
252
+ }
253
+ });
254
+ }
231
255
  async acquireOperationLease(localId, options = {}) {
232
256
  this.#packageRoot(localId);
233
257
  await mkdir(this.#locksRoot, { recursive: true, mode: 0o700 });
@@ -243,11 +267,10 @@ export class CurrentTreeContentLibrary {
243
267
  try {
244
268
  handle = await open(temporaryPath, "wx", 0o600);
245
269
  await handle.writeFile(`${JSON.stringify(marker)}\n`, "utf8");
246
- await handle.sync();
247
270
  await handle.close();
248
271
  handle = undefined;
249
272
  try {
250
- await link(temporaryPath, lockPath);
273
+ await linkExclusive(temporaryPath, lockPath);
251
274
  }
252
275
  catch (error) {
253
276
  if (!isNodeErrorCode(error, "EEXIST"))
@@ -281,6 +304,7 @@ export class CurrentTreeContentLibrary {
281
304
  throw new CurrentTreeContentPackageBusyError();
282
305
  options.signal?.throwIfAborted();
283
306
  const normalized = normalizeCurrentContentTreeFiles(files, this.#limits);
307
+ assertGameplayModeUnchanged(package_.files, normalized);
284
308
  return this.#replaceNormalizedCurrentTree(localId, package_.title, normalized, options.signal);
285
309
  },
286
310
  release: () => {
@@ -307,7 +331,6 @@ export class CurrentTreeContentLibrary {
307
331
  schemaVersion: 1,
308
332
  files: normalized,
309
333
  }));
310
- await crashProcessAtCurrentTreeEdge("after_current_tree_staging");
311
334
  signal?.throwIfAborted();
312
335
  await this.#beforeCurrentTreeReplace?.();
313
336
  signal?.throwIfAborted();
@@ -315,12 +338,8 @@ export class CurrentTreeContentLibrary {
315
338
  if (!isNodeErrorCode(error, "ENOENT"))
316
339
  throw error;
317
340
  });
318
- await link(join(packageRoot, currentTreeFileName), previousPath);
319
- await syncDirectory(packageRoot);
341
+ await linkExclusive(join(packageRoot, currentTreeFileName), previousPath);
320
342
  await rename(temporaryPath, join(packageRoot, currentTreeFileName));
321
- await syncDirectory(packageRoot);
322
- await crashProcessAtCurrentTreeEdge("after_current_tree_replace");
323
- failCurrentTreeAfterReplaceForTest();
324
343
  await unlink(previousPath).catch(() => undefined);
325
344
  }
326
345
  catch (error) {
@@ -329,7 +348,6 @@ export class CurrentTreeContentLibrary {
329
348
  if (await pathExists(previousPath).catch(() => false)) {
330
349
  try {
331
350
  await rename(previousPath, join(packageRoot, currentTreeFileName));
332
- await syncDirectory(packageRoot);
333
351
  }
334
352
  catch (rollbackError) {
335
353
  rollbackFailure = rollbackError;
@@ -369,14 +387,10 @@ export class CurrentTreeContentLibrary {
369
387
  schemaVersion: 1,
370
388
  files: normalized,
371
389
  }));
372
- await syncDirectory(stagingPath);
373
- await crashProcessAtCurrentTreeEdge("after_package_staging");
374
390
  signal?.throwIfAborted();
375
391
  await this.#beforePackagePublish?.();
376
392
  signal?.throwIfAborted();
377
393
  await rename(stagingPath, finalPath);
378
- await syncDirectory(this.#packagesRoot);
379
- await crashProcessAtCurrentTreeEdge("after_package_publish");
380
394
  }
381
395
  catch (error) {
382
396
  await rm(stagingPath, { force: true, recursive: true }).catch(() => undefined);
@@ -410,10 +424,9 @@ export class CurrentTreeContentLibrary {
410
424
  try {
411
425
  handle = await open(temporaryPath, "wx", 0o600);
412
426
  await handle.writeFile(`${JSON.stringify(reclaim)}\n`, "utf8");
413
- await handle.sync();
414
427
  await handle.close();
415
428
  handle = undefined;
416
- await link(temporaryPath, reclaimPath);
429
+ await linkExclusive(temporaryPath, reclaimPath);
417
430
  }
418
431
  catch (error) {
419
432
  await handle?.close().catch(() => undefined);
@@ -433,7 +446,7 @@ export class CurrentTreeContentLibrary {
433
446
  if (!isNodeErrorCode(error, "ENOENT"))
434
447
  throw error;
435
448
  });
436
- await link(replacementPath, lockPath);
449
+ await linkExclusive(replacementPath, lockPath);
437
450
  return true;
438
451
  }
439
452
  finally {
@@ -446,7 +459,7 @@ export class CurrentTreeContentLibrary {
446
459
  async #waitForPackageLock(lockPath, reclaimPath, replacementPath) {
447
460
  for (let attempt = 0; attempt < 2_000; attempt += 1) {
448
461
  if ((await this.#tryReplaceDeadPackageLock(lockPath, reclaimPath, replacementPath)) ||
449
- (await link(replacementPath, lockPath)
462
+ (await linkExclusive(replacementPath, lockPath)
450
463
  .then(() => true)
451
464
  .catch((error) => {
452
465
  if (isNodeErrorCode(error, "EEXIST"))
@@ -476,7 +489,7 @@ export class CurrentTreeContentLibrary {
476
489
  }
477
490
  function detail(localId, title, files, inspection) {
478
491
  return {
479
- ...summary(localId, title, inspection),
492
+ ...summary(localId, title, readGameplay(files), inspection),
480
493
  files: files.map((file) => ({ ...file })),
481
494
  documents: structuredClone(inspection.documents),
482
495
  issues: structuredClone(inspection.issues),
@@ -484,8 +497,9 @@ function detail(localId, title, files, inspection) {
484
497
  control: structuredClone(inspection.control),
485
498
  };
486
499
  }
487
- function summary(localId, title, inspection) {
500
+ function summary(localId, title, gameplay, inspection) {
488
501
  return {
502
+ gameplay: { ...gameplay },
489
503
  localId,
490
504
  title,
491
505
  status: inspection.status,
@@ -495,6 +509,7 @@ function summary(localId, title, inspection) {
495
509
  }
496
510
  function packageSummary(package_) {
497
511
  return {
512
+ gameplay: { ...package_.gameplay },
498
513
  localId: package_.localId,
499
514
  title: package_.title,
500
515
  status: package_.status,
@@ -547,8 +562,22 @@ export function normalizeCurrentContentTreeFiles(files, limits = defaultLimits)
547
562
  ...(file.encoding === undefined ? {} : { encoding: file.encoding }),
548
563
  });
549
564
  }
565
+ try {
566
+ readGameplay(normalized);
567
+ }
568
+ catch (error) {
569
+ throw new InvalidContentTreeError(error instanceof Error ? error.message : "Invalid gameplay identity");
570
+ }
550
571
  return normalized.sort((left, right) => compareStrings(left.path, right.path));
551
572
  }
573
+ function assertGameplayModeUnchanged(before, after) {
574
+ try {
575
+ assertSameGameplayMode(before, after);
576
+ }
577
+ catch (error) {
578
+ throw new InvalidContentTreeError(error instanceof Error ? error.message : "Gameplay mode is fixed");
579
+ }
580
+ }
552
581
  function decodeCanonicalBase64(contents, path) {
553
582
  if (contents.length % 4 !== 0 ||
554
583
  !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(contents)) {
@@ -622,7 +651,6 @@ async function writeStoredLocalMetadata(packageRoot, metadata) {
622
651
  try {
623
652
  await writeDurableNewFile(temporaryPath, serialize(metadata));
624
653
  await rename(temporaryPath, join(packageRoot, localMetadataFileName));
625
- await syncDirectory(packageRoot);
626
654
  }
627
655
  catch (error) {
628
656
  await rm(temporaryPath, { force: true }).catch(() => undefined);
@@ -634,16 +662,6 @@ async function writeDurableNewFile(path, contents) {
634
662
  const handle = await open(path, "wx", 0o600);
635
663
  try {
636
664
  await handle.writeFile(contents, "utf8");
637
- await handle.sync();
638
- }
639
- finally {
640
- await handle.close();
641
- }
642
- }
643
- async function syncDirectory(path) {
644
- const handle = await open(path, "r");
645
- try {
646
- await handle.sync();
647
665
  }
648
666
  finally {
649
667
  await handle.close();
@@ -784,20 +802,6 @@ function isNodeErrorCode(error, code) {
784
802
  function isAbortError(error) {
785
803
  return error instanceof Error && error.name === "AbortError";
786
804
  }
787
- async function crashProcessAtCurrentTreeEdge(edge) {
788
- if (process.env.NODE_ENV !== "test" ||
789
- process.env.NARRAEON_INTERNAL_TEST_CRASH_AT_CURRENT_TREE_EDGE !== edge) {
790
- return;
791
- }
792
- process.kill(process.pid, "SIGKILL");
793
- await new Promise(() => undefined);
794
- }
795
- function failCurrentTreeAfterReplaceForTest() {
796
- if (process.env.NODE_ENV === "test" &&
797
- process.env.NARRAEON_INTERNAL_TEST_FAIL_AFTER_CURRENT_TREE_REPLACE === "1") {
798
- throw new Error("simulated failure after current tree replace");
799
- }
800
- }
801
805
  function compareStrings(left, right) {
802
806
  return left < right ? -1 : left > right ? 1 : 0;
803
807
  }