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
|
@@ -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:
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|