narraeon 0.5.6 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/README.zh-CN.md +3 -1
- package/dist/node/protocol/appPreferences.js +4 -2
- package/dist/node/protocol/gameplay.js +69 -0
- package/dist/node/protocol/playFailure.js +13 -0
- package/dist/node/protocol/protagonistTemplates.js +13 -0
- package/dist/node/protocol/resolution.js +274 -0
- package/dist/node/protocol/v1.js +40 -19
- package/dist/node/protocol/worldArchives.js +7 -0
- package/dist/node/runtime/RuntimeErrorLog.js +95 -0
- package/dist/node/runtime/V1Runtime.js +103 -8
- package/dist/node/runtime/archive/WorldArchiveZip.js +202 -0
- package/dist/node/runtime/archive/WorldArchives.js +389 -0
- package/dist/node/runtime/config/AppPreferencesStore.js +23 -10
- package/dist/node/runtime/config/ProtagonistTemplateStore.js +134 -0
- package/dist/node/runtime/content/ContentWorkspace.js +15 -2
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +43 -2
- package/dist/node/runtime/content/FileNativeContentTree.js +8 -1
- package/dist/node/runtime/history/StoryExport.js +33 -0
- package/dist/node/runtime/model/FileNativeModelAdapters.js +34 -2
- package/dist/node/runtime/model/ModelHost.js +12 -1
- package/dist/node/runtime/model/ModelHostStream.js +11 -3
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +37 -1
- package/dist/node/runtime/play/FileNativePlayPresetStore.js +48 -3
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +87 -4
- package/dist/node/runtime/play/LegacyModelBindingFailure.js +10 -0
- package/dist/node/runtime/play/OrderedPlayPrompts.js +68 -6
- package/dist/node/runtime/play/PlayCallChain.js +177 -57
- package/dist/node/runtime/play/PlayCallChainError.js +3 -1
- package/dist/node/runtime/play/PlayContinuity.js +3 -20
- package/dist/node/runtime/play/PlayFailure.js +20 -0
- package/dist/node/runtime/play/PlayTraceRetention.js +3 -11
- package/dist/node/runtime/play/TabletopResolution.js +372 -0
- package/dist/node/runtime/play/TabletopRules.js +141 -0
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +49 -32
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +37 -4
- package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +4 -1
- package/dist/node/runtime/setting/SettingAuthoringTransaction.js +56 -13
- package/dist/node/runtime/setting/SettingImprovementSession.js +6 -4
- package/dist/node/runtime/world/FileNativeWorldStore.js +207 -40
- package/dist/node/runtime/world/WorldActivity.js +143 -0
- package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -0
- package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +35 -6
- package/dist/node/runtime/world-revision/WorldRevisionSession.js +9 -5
- package/dist/node/server/ConversationStream.js +2 -0
- package/dist/node/server/createServer.js +48 -0
- package/dist/node/shared/default-preset-host.js +10 -6
- package/dist/node/shared/ordered-author-prompts.js +8 -0
- package/dist/node/shared/ordered-play-prompts.js +25 -2
- package/dist/node/shared/sample-packages/index.js +10 -0
- package/dist/node/shared/sample-packages/rainy-inn-en.js +194 -0
- package/dist/node/shared/sample-packages/rainy-inn-zh.js +194 -0
- package/dist/node/shared/tabletop-guidance.js +100 -0
- package/dist/web/assets/index-BJ-1Mb0e.css +1 -0
- package/dist/web/assets/index-BgBL8G-A.js +1323 -0
- package/dist/web/index.html +2 -2
- package/package.json +6 -2
- package/dist/web/assets/index-BH8NyuE4.js +0 -1233
- package/dist/web/assets/index-CVN48xrw.css +0 -1
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { mkdir, open, readFile, readdir, rename, rm, stat, } from "node:fs/promises";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { worldArchiveFormat, worldArchiveLimits, } from "../../protocol/worldArchives.js";
|
|
6
|
+
import { FileNativeArtifactStore } from "../artifact/FileNativeArtifactStore.js";
|
|
7
|
+
import { PlayCallChain } from "../play/PlayCallChain.js";
|
|
8
|
+
import { FileNativePromptCompiler } from "../prompt/FileNativePromptCompiler.js";
|
|
9
|
+
import { FileNativeAuthorityV3 } from "../world/FileNativeAuthorityV3.js";
|
|
10
|
+
import { FileNativeWorldStore, } from "../world/FileNativeWorldStore.js";
|
|
11
|
+
import { archivePath, extractArchive, inventory, streamFile, writeArchive, WorldArchiveError, } from "./WorldArchiveZip.js";
|
|
12
|
+
export class WorldArchives {
|
|
13
|
+
#dataRoot;
|
|
14
|
+
#root;
|
|
15
|
+
#limits;
|
|
16
|
+
#worlds;
|
|
17
|
+
#chains;
|
|
18
|
+
#appVersion = "";
|
|
19
|
+
constructor(input) {
|
|
20
|
+
this.#dataRoot = resolve(input.dataRoot);
|
|
21
|
+
this.#root = join(this.#dataRoot, "world-archives");
|
|
22
|
+
this.#worlds = input.worlds;
|
|
23
|
+
this.#chains = input.chains;
|
|
24
|
+
this.#limits = input.limits ?? worldArchiveLimits;
|
|
25
|
+
}
|
|
26
|
+
async initialize() {
|
|
27
|
+
this.#appVersion = await appVersion();
|
|
28
|
+
const worldsRoot = join(this.#dataRoot, "worlds-file-native");
|
|
29
|
+
const staged = await readdir(worldsRoot).catch(() => []);
|
|
30
|
+
for (const operationId of await readdir(join(this.#root, "quarantine")).catch(() => [])) {
|
|
31
|
+
const worldId = `world-${createHash("sha256").update(`import:${operationId}`).digest("hex").slice(0, 24)}`;
|
|
32
|
+
for (const entry of staged)
|
|
33
|
+
if (entry.startsWith(`.staging-${worldId}-`))
|
|
34
|
+
await rm(join(worldsRoot, entry), { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
for (const name of ["uploads", "quarantine", "exports"]) {
|
|
37
|
+
await rm(join(this.#root, name), { recursive: true, force: true });
|
|
38
|
+
await mkdir(join(this.#root, name), { recursive: true, mode: 0o700 });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async upload(source) {
|
|
42
|
+
const uploadId = randomUUID();
|
|
43
|
+
const path = this.#uploadPath(uploadId);
|
|
44
|
+
try {
|
|
45
|
+
await streamFile(source, path, this.#limits.archiveBytes);
|
|
46
|
+
await syncFile(path);
|
|
47
|
+
crashAt("upload");
|
|
48
|
+
return { uploadId };
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
await rm(path, { force: true });
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async exportWorld(input) {
|
|
56
|
+
identity(input.operationId);
|
|
57
|
+
return this.#worlds.withArchiveExport(input.worldId, async () => {
|
|
58
|
+
const root = join(this.#root, "exports", input.operationId);
|
|
59
|
+
await rm(root, { recursive: true, force: true });
|
|
60
|
+
try {
|
|
61
|
+
const source = await this.#worlds.readWorldSummary(input.worldId);
|
|
62
|
+
if (!source)
|
|
63
|
+
throw new WorldArchiveError("invalid_archive", "World does not exist");
|
|
64
|
+
const fork = await this.#chains.deriveWorld({
|
|
65
|
+
operationId: input.operationId,
|
|
66
|
+
sourceWorldId: input.worldId,
|
|
67
|
+
sourceHead: "current",
|
|
68
|
+
hostPresetId: "world-archive",
|
|
69
|
+
targetName: source.title,
|
|
70
|
+
publish: false,
|
|
71
|
+
targetWorldRoot: join(root, "world"),
|
|
72
|
+
requireMaterialized: true,
|
|
73
|
+
resetPackageScriptPermissions: true,
|
|
74
|
+
retainCompleteTimeline: true,
|
|
75
|
+
});
|
|
76
|
+
// A fork may create a local display cache; it is outside the portable closure.
|
|
77
|
+
await rm(join(fork.worldRoot, "runtime", "activity.json"), {
|
|
78
|
+
force: true,
|
|
79
|
+
});
|
|
80
|
+
const files = await inventory(fork.worldRoot);
|
|
81
|
+
const manifest = {
|
|
82
|
+
format: worldArchiveFormat,
|
|
83
|
+
appVersion: this.#appVersion || (await appVersion()),
|
|
84
|
+
exportedAt: new Date().toISOString(),
|
|
85
|
+
worldName: source.title,
|
|
86
|
+
archivedWorldId: fork.world.worldId,
|
|
87
|
+
head: (await new FileNativeAuthorityV3(fork.worldRoot).readHead())
|
|
88
|
+
.head,
|
|
89
|
+
files,
|
|
90
|
+
};
|
|
91
|
+
await durableJson(join(root, "narraeon-world.json"), manifest);
|
|
92
|
+
const bytes = await writeArchive(root, files, join(root, "archive.zip"), this.#limits);
|
|
93
|
+
const fileName = `${archiveBaseName(source.title)}.narraeon-world.zip`;
|
|
94
|
+
const result = { archiveId: input.operationId, fileName, bytes };
|
|
95
|
+
await durableJson(join(root, "download.json"), result);
|
|
96
|
+
const expiry = setTimeout(() => {
|
|
97
|
+
void stat(join(root, "download.json"))
|
|
98
|
+
.then((value) => {
|
|
99
|
+
if (Date.now() - value.mtimeMs >= 60 * 60 * 1000)
|
|
100
|
+
return rm(root, { recursive: true, force: true });
|
|
101
|
+
})
|
|
102
|
+
.catch(() => undefined);
|
|
103
|
+
}, 60 * 60 * 1000);
|
|
104
|
+
expiry.unref();
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
await rm(root, { recursive: true, force: true });
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
async download(archiveId) {
|
|
114
|
+
identity(archiveId);
|
|
115
|
+
const root = join(this.#root, "exports", archiveId);
|
|
116
|
+
const metadata = JSON.parse(await readFile(join(root, "download.json"), "utf8"));
|
|
117
|
+
if (Date.now() - (await stat(join(root, "download.json"))).mtimeMs >=
|
|
118
|
+
60 * 60 * 1000) {
|
|
119
|
+
await rm(root, { recursive: true, force: true });
|
|
120
|
+
throw new WorldArchiveError("invalid_archive", "The download has expired; export the world again");
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
metadata,
|
|
124
|
+
stream: createReadStream(join(root, "archive.zip")),
|
|
125
|
+
cleanup: () => rm(root, { recursive: true, force: true }),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
async importWorld(input) {
|
|
129
|
+
identity(input.operationId);
|
|
130
|
+
identity(input.uploadId);
|
|
131
|
+
const digest = createHash("sha256")
|
|
132
|
+
.update(`import:${input.operationId}`)
|
|
133
|
+
.digest("hex")
|
|
134
|
+
.slice(0, 24);
|
|
135
|
+
return this.#worlds.operations.withExclusiveWorldStateMutation(`archive-import-${digest}`, async () => {
|
|
136
|
+
const worldId = `world-${digest}`;
|
|
137
|
+
const targetRoot = join(this.#dataRoot, "worlds-file-native", worldId);
|
|
138
|
+
const quarantine = join(this.#root, "quarantine", input.operationId);
|
|
139
|
+
const upload = this.#uploadPath(input.uploadId);
|
|
140
|
+
try {
|
|
141
|
+
// Publication is the durable commit point. A process may exit before writing its receipt or cleaning the upload.
|
|
142
|
+
let published = null;
|
|
143
|
+
try {
|
|
144
|
+
published = JSON.parse(await readFile(join(targetRoot, "publication.json"), "utf8"));
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (!missing(error))
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
if (published) {
|
|
151
|
+
if (published.operationId !== input.operationId ||
|
|
152
|
+
published.worldId !== worldId)
|
|
153
|
+
throw new WorldArchiveError("integrity_failed", "Import publication does not match its operation");
|
|
154
|
+
await this.#worlds.ensureCurrentStorage(worldId);
|
|
155
|
+
await durableJson(join(this.#dataRoot, "operations", `import-${createHash("sha256").update(input.operationId).digest("hex")}.json`), JSON.parse(await readFile(join(targetRoot, "publication.json"), "utf8")));
|
|
156
|
+
const world = await this.#worlds.readWorldSummary(worldId);
|
|
157
|
+
return { outcome: "imported", world };
|
|
158
|
+
}
|
|
159
|
+
await rm(quarantine, { recursive: true, force: true });
|
|
160
|
+
const worldsRoot = join(this.#dataRoot, "worlds-file-native");
|
|
161
|
+
for (const entry of await readdir(worldsRoot).catch(() => []))
|
|
162
|
+
if (entry.startsWith(`.staging-${worldId}-`))
|
|
163
|
+
await rm(join(worldsRoot, entry), {
|
|
164
|
+
recursive: true,
|
|
165
|
+
force: true,
|
|
166
|
+
});
|
|
167
|
+
const extracted = join(quarantine, "extracted");
|
|
168
|
+
await mkdir(extracted, { recursive: true, mode: 0o700 });
|
|
169
|
+
crashAt("quarantine");
|
|
170
|
+
let actual;
|
|
171
|
+
try {
|
|
172
|
+
actual = await extractArchive(upload, extracted, this.#limits);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (missing(error))
|
|
176
|
+
throw new WorldArchiveError("invalid_archive", "Upload is missing; upload the ZIP again");
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
crashAt("unpack");
|
|
180
|
+
if (!actual.has("narraeon-world.json"))
|
|
181
|
+
throw new WorldArchiveError("invalid_archive", "Archive manifest is missing");
|
|
182
|
+
let value;
|
|
183
|
+
try {
|
|
184
|
+
value = JSON.parse(await readFile(join(extracted, "narraeon-world.json"), "utf8"));
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
throw new WorldArchiveError("invalid_archive", "Archive manifest is invalid", { cause: error });
|
|
188
|
+
}
|
|
189
|
+
const manifest = validateManifest(value, this.#limits);
|
|
190
|
+
if (actual.size !== manifest.files.length + 1)
|
|
191
|
+
throw new WorldArchiveError("integrity_failed", "Archive contains extra or missing files");
|
|
192
|
+
for (const file of manifest.files) {
|
|
193
|
+
const found = actual.get(file.path);
|
|
194
|
+
if (file.bytes !== found?.bytes || file.sha256 !== found?.sha256)
|
|
195
|
+
throw new WorldArchiveError("integrity_failed", "Archive file digest does not match its manifest");
|
|
196
|
+
}
|
|
197
|
+
const sourceRoot = join(quarantine, "worlds-file-native", manifest.archivedWorldId);
|
|
198
|
+
await mkdir(dirname(sourceRoot), { recursive: true });
|
|
199
|
+
try {
|
|
200
|
+
await rename(join(extracted, "world"), sourceRoot);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
if (missing(error))
|
|
204
|
+
throw new WorldArchiveError("invalid_archive", "Archive world directory is missing");
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
// Discard any incoming machine-local display cache before storage migration or reads.
|
|
208
|
+
await rm(join(sourceRoot, "runtime", "activity.json"), {
|
|
209
|
+
force: true,
|
|
210
|
+
});
|
|
211
|
+
const sourceWorlds = new FileNativeWorldStore(quarantine);
|
|
212
|
+
try {
|
|
213
|
+
const publication = JSON.parse(await readFile(join(sourceRoot, "publication.json"), "utf8"));
|
|
214
|
+
if (publication?.worldId !== manifest.archivedWorldId)
|
|
215
|
+
throw new Error("Archive world identity mismatch");
|
|
216
|
+
await sourceWorlds.ensureCurrentStorage(manifest.archivedWorldId);
|
|
217
|
+
const authority = new FileNativeAuthorityV3(sourceRoot);
|
|
218
|
+
const history = await authority.readHistory();
|
|
219
|
+
if (history.head !== manifest.head)
|
|
220
|
+
throw new Error("Archive head mismatch");
|
|
221
|
+
await authority.recover("genesis");
|
|
222
|
+
for (const commit of history.commits)
|
|
223
|
+
await authority.recover(commit.head);
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
throw new WorldArchiveError("integrity_failed", "Archive Authority failed its complete audit", { cause: error });
|
|
227
|
+
}
|
|
228
|
+
crashAt("audit");
|
|
229
|
+
const sourceChains = new PlayCallChain(sourceWorlds, new FileNativePromptCompiler(), new FileNativeArtifactStore(quarantine));
|
|
230
|
+
let result;
|
|
231
|
+
try {
|
|
232
|
+
result = await sourceChains.deriveWorld({
|
|
233
|
+
operationId: input.operationId,
|
|
234
|
+
sourceWorldId: manifest.archivedWorldId,
|
|
235
|
+
sourceHead: manifest.head,
|
|
236
|
+
hostPresetId: "world-archive",
|
|
237
|
+
targetDataRoot: this.#dataRoot,
|
|
238
|
+
targetName: manifest.worldName,
|
|
239
|
+
identityPrefix: "import",
|
|
240
|
+
resetPackageScriptPermissions: true,
|
|
241
|
+
retainCompleteTimeline: true,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
throw new WorldArchiveError("integrity_failed", "Archive world closure cannot be restored", { cause: error });
|
|
246
|
+
}
|
|
247
|
+
crashAt("publish");
|
|
248
|
+
return { outcome: "imported", world: result.world };
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
await rm(quarantine, { recursive: true, force: true });
|
|
252
|
+
await rm(upload, { force: true });
|
|
253
|
+
const worldsRoot = join(this.#dataRoot, "worlds-file-native");
|
|
254
|
+
for (const entry of await readdir(worldsRoot).catch(() => []))
|
|
255
|
+
if (entry.startsWith(`.staging-${worldId}-`))
|
|
256
|
+
await rm(join(worldsRoot, entry), {
|
|
257
|
+
recursive: true,
|
|
258
|
+
force: true,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
#uploadPath(uploadId) {
|
|
264
|
+
return join(this.#root, "uploads", `${uploadId}.zip`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function validateManifest(value, limits) {
|
|
268
|
+
if (!record(value))
|
|
269
|
+
throw new WorldArchiveError("invalid_archive", "Archive manifest must be an object");
|
|
270
|
+
if (value.format !== worldArchiveFormat)
|
|
271
|
+
throw new WorldArchiveError("unsupported_format", "Unsupported world archive format");
|
|
272
|
+
if (Object.keys(value).sort().join() !==
|
|
273
|
+
[
|
|
274
|
+
"format",
|
|
275
|
+
"appVersion",
|
|
276
|
+
"exportedAt",
|
|
277
|
+
"worldName",
|
|
278
|
+
"archivedWorldId",
|
|
279
|
+
"head",
|
|
280
|
+
"files",
|
|
281
|
+
]
|
|
282
|
+
.sort()
|
|
283
|
+
.join() ||
|
|
284
|
+
typeof value.appVersion !== "string" ||
|
|
285
|
+
typeof value.exportedAt !== "string" ||
|
|
286
|
+
!Number.isFinite(Date.parse(value.exportedAt)) ||
|
|
287
|
+
typeof value.worldName !== "string" ||
|
|
288
|
+
value.worldName.trim() === "" ||
|
|
289
|
+
Array.from(value.worldName).length > 160 ||
|
|
290
|
+
value.worldName.includes("\r") ||
|
|
291
|
+
value.worldName.includes("\n") ||
|
|
292
|
+
value.worldName.includes(String.fromCharCode(0)) ||
|
|
293
|
+
typeof value.archivedWorldId !== "string" ||
|
|
294
|
+
!/^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u.test(value.archivedWorldId) ||
|
|
295
|
+
typeof value.head !== "string" ||
|
|
296
|
+
!/^(genesis|commit:[1-9][0-9]*)$/u.test(value.head) ||
|
|
297
|
+
!Array.isArray(value.files))
|
|
298
|
+
throw new WorldArchiveError("invalid_archive", "Archive manifest has invalid fields");
|
|
299
|
+
const seen = new Set();
|
|
300
|
+
if (value.files.length + 1 > limits.entries)
|
|
301
|
+
throw new WorldArchiveError("limit_exceeded", "Too many archive files");
|
|
302
|
+
for (const file of value.files) {
|
|
303
|
+
if (!record(file) ||
|
|
304
|
+
Object.keys(file).sort().join() !== "bytes,path,sha256" ||
|
|
305
|
+
typeof file.path !== "string" ||
|
|
306
|
+
!file.path.startsWith("world/") ||
|
|
307
|
+
typeof file.bytes !== "number" ||
|
|
308
|
+
!Number.isSafeInteger(file.bytes) ||
|
|
309
|
+
file.bytes < 0 ||
|
|
310
|
+
typeof file.sha256 !== "string" ||
|
|
311
|
+
!/^[a-f0-9]{64}$/u.test(file.sha256))
|
|
312
|
+
throw new WorldArchiveError("invalid_archive", "Invalid archive file manifest");
|
|
313
|
+
archivePath(file.path);
|
|
314
|
+
const key = file.path.normalize("NFKC").toLowerCase();
|
|
315
|
+
if (seen.has(key))
|
|
316
|
+
throw new WorldArchiveError("unsafe_path", "Duplicate manifest path");
|
|
317
|
+
seen.add(key);
|
|
318
|
+
}
|
|
319
|
+
return value;
|
|
320
|
+
}
|
|
321
|
+
function record(value) {
|
|
322
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
323
|
+
}
|
|
324
|
+
function identity(value) {
|
|
325
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u.test(value))
|
|
326
|
+
throw new WorldArchiveError("unsafe_path", "Invalid archive operation identity");
|
|
327
|
+
}
|
|
328
|
+
function missing(error) {
|
|
329
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
330
|
+
}
|
|
331
|
+
function archiveBaseName(name) {
|
|
332
|
+
return (name
|
|
333
|
+
.normalize("NFKC")
|
|
334
|
+
.replaceAll(/[^\p{L}\p{N}._-]+/gu, "-")
|
|
335
|
+
.replaceAll(/^[.-]+|[.-]+$/gu, "")
|
|
336
|
+
.slice(0, 80) || "world");
|
|
337
|
+
}
|
|
338
|
+
async function syncFile(path) {
|
|
339
|
+
const file = await open(path, "r");
|
|
340
|
+
try {
|
|
341
|
+
await file.sync();
|
|
342
|
+
}
|
|
343
|
+
finally {
|
|
344
|
+
await file.close();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async function durableJson(path, value) {
|
|
348
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
349
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
350
|
+
const file = await open(temporary, "wx", 0o600);
|
|
351
|
+
try {
|
|
352
|
+
await file.writeFile(JSON.stringify(value));
|
|
353
|
+
await file.sync();
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
await file.close();
|
|
357
|
+
}
|
|
358
|
+
await rename(temporary, path);
|
|
359
|
+
const directory = await open(dirname(path), "r");
|
|
360
|
+
try {
|
|
361
|
+
await directory.sync();
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
await directory.close();
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
async function appVersion() {
|
|
368
|
+
let directory = import.meta.dirname;
|
|
369
|
+
for (;;) {
|
|
370
|
+
try {
|
|
371
|
+
const value = JSON.parse(await readFile(join(directory, "package.json"), "utf8"));
|
|
372
|
+
if (value.name === "narraeon")
|
|
373
|
+
return value.version;
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
if (!missing(error))
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
const parent = dirname(directory);
|
|
380
|
+
if (parent === directory)
|
|
381
|
+
throw new Error("Application version is unavailable");
|
|
382
|
+
directory = parent;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
function crashAt(edge) {
|
|
386
|
+
if (process.env.NODE_ENV === "test" &&
|
|
387
|
+
process.env.NARRAEON_INTERNAL_TEST_CRASH_AT_WORLD_ARCHIVE_EDGE === edge)
|
|
388
|
+
process.exit(89);
|
|
389
|
+
}
|
|
@@ -25,7 +25,7 @@ export class AppPreferencesStore {
|
|
|
25
25
|
throw new Error("Unsupported app preferences update");
|
|
26
26
|
const current = toView(await this.#readDocument());
|
|
27
27
|
const document = {
|
|
28
|
-
schemaVersion:
|
|
28
|
+
schemaVersion: 3,
|
|
29
29
|
locale: update.locale ?? current.locale,
|
|
30
30
|
reading: structuredClone(update.reading ?? current.reading),
|
|
31
31
|
};
|
|
@@ -59,7 +59,7 @@ export class AppPreferencesStore {
|
|
|
59
59
|
throw error;
|
|
60
60
|
}
|
|
61
61
|
return {
|
|
62
|
-
schemaVersion:
|
|
62
|
+
schemaVersion: 3,
|
|
63
63
|
locale: defaultAppLocale,
|
|
64
64
|
reading: structuredClone(defaultAppReadingPreferences),
|
|
65
65
|
};
|
|
@@ -95,19 +95,32 @@ function validateDocument(value) {
|
|
|
95
95
|
Object.keys(value).every((key) => key === "schemaVersion" || key === "locale"))
|
|
96
96
|
return { schemaVersion: 1, locale: value.locale };
|
|
97
97
|
if ("schemaVersion" in value &&
|
|
98
|
-
value.schemaVersion === 2 &&
|
|
98
|
+
(value.schemaVersion === 2 || value.schemaVersion === 3) &&
|
|
99
99
|
"locale" in value &&
|
|
100
100
|
isAppLocale(value.locale) &&
|
|
101
101
|
"reading" in value &&
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
102
|
+
Object.keys(value).every((key) => key === "schemaVersion" || key === "locale" || key === "reading")) {
|
|
103
|
+
const reading = value.schemaVersion === 2
|
|
104
|
+
? isLegacyReadingPreferences(value.reading)
|
|
105
|
+
? { ...value.reading, showTechnicalInfo: false }
|
|
106
|
+
: null
|
|
107
|
+
: value.reading;
|
|
108
|
+
if (isAppReadingPreferences(reading))
|
|
109
|
+
return {
|
|
110
|
+
schemaVersion: 3,
|
|
111
|
+
locale: value.locale,
|
|
112
|
+
reading: structuredClone(reading),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
109
115
|
throw new Error("App preferences file does not match a supported schema");
|
|
110
116
|
}
|
|
117
|
+
function isLegacyReadingPreferences(value) {
|
|
118
|
+
return (typeof value === "object" &&
|
|
119
|
+
value !== null &&
|
|
120
|
+
!Array.isArray(value) &&
|
|
121
|
+
Object.keys(value).length === 5 &&
|
|
122
|
+
isAppReadingPreferences({ ...value, showTechnicalInfo: false }));
|
|
123
|
+
}
|
|
111
124
|
function toView(document) {
|
|
112
125
|
return {
|
|
113
126
|
locale: document.locale,
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { isProtagonistTemplateContent, isProtagonistTemplateId, } from "../../protocol/protagonistTemplates.js";
|
|
5
|
+
const fileName = "protagonist-templates-v1.json";
|
|
6
|
+
export class ProtagonistTemplateStore {
|
|
7
|
+
#root;
|
|
8
|
+
#path;
|
|
9
|
+
#mutationTail = Promise.resolve();
|
|
10
|
+
constructor(configRoot) {
|
|
11
|
+
this.#root = configRoot;
|
|
12
|
+
this.#path = join(configRoot, fileName);
|
|
13
|
+
}
|
|
14
|
+
async view() {
|
|
15
|
+
await this.#mutationTail;
|
|
16
|
+
return this.#read();
|
|
17
|
+
}
|
|
18
|
+
save(input) {
|
|
19
|
+
return this.#mutate((templates) => {
|
|
20
|
+
if (!isProtagonistTemplateContent(input.name, input.body) ||
|
|
21
|
+
(input.id !== undefined && !isProtagonistTemplateId(input.id)))
|
|
22
|
+
throw new Error("Protagonist template requires a single-line name of 1–80 characters and a Markdown body of 1–16 KiB");
|
|
23
|
+
const template = {
|
|
24
|
+
id: input.id ?? `template-${randomUUID()}`,
|
|
25
|
+
name: input.name,
|
|
26
|
+
body: input.body,
|
|
27
|
+
updatedAt: Date.now(),
|
|
28
|
+
};
|
|
29
|
+
if (input.id === undefined)
|
|
30
|
+
templates.push(template);
|
|
31
|
+
else
|
|
32
|
+
templates[findTemplate(templates, input.id)] = template;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
copy(id) {
|
|
36
|
+
return this.#mutate((templates) => {
|
|
37
|
+
const source = templates[findTemplate(templates, id)];
|
|
38
|
+
templates.push({
|
|
39
|
+
...source,
|
|
40
|
+
id: `template-${randomUUID()}`,
|
|
41
|
+
updatedAt: Date.now(),
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
delete(id) {
|
|
46
|
+
return this.#mutate((templates) => {
|
|
47
|
+
templates.splice(findTemplate(templates, id), 1);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
async #mutate(operation) {
|
|
51
|
+
const previous = this.#mutationTail;
|
|
52
|
+
let release = () => undefined;
|
|
53
|
+
this.#mutationTail = new Promise((resolve) => (release = resolve));
|
|
54
|
+
await previous;
|
|
55
|
+
try {
|
|
56
|
+
const view = await this.#read();
|
|
57
|
+
operation(view.templates);
|
|
58
|
+
await this.#write(view);
|
|
59
|
+
return view;
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
release();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async #read() {
|
|
66
|
+
let contents;
|
|
67
|
+
try {
|
|
68
|
+
contents = await readFile(this.#path, "utf8");
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error.code === "ENOENT")
|
|
72
|
+
return { templates: [] };
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
return validateDocument(JSON.parse(contents));
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
throw new Error(`${fileName} is invalid: expected schemaVersion 1 with valid protagonist templates`, { cause: error });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async #write(view) {
|
|
83
|
+
await mkdir(this.#root, { recursive: true, mode: 0o700 });
|
|
84
|
+
const temporary = join(this.#root, `.${fileName}.${randomUUID()}.tmp`);
|
|
85
|
+
let handle;
|
|
86
|
+
try {
|
|
87
|
+
handle = await open(temporary, "wx", 0o600);
|
|
88
|
+
await handle.writeFile(`${JSON.stringify({ schemaVersion: 1, ...view }, null, 2)}\n`, "utf8");
|
|
89
|
+
await handle.sync();
|
|
90
|
+
await handle.close();
|
|
91
|
+
handle = undefined;
|
|
92
|
+
await rename(temporary, this.#path);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
await handle?.close().catch(() => undefined);
|
|
96
|
+
await unlink(temporary).catch(() => undefined);
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function findTemplate(templates, id) {
|
|
102
|
+
if (!isProtagonistTemplateId(id))
|
|
103
|
+
throw new Error("Invalid protagonist template id");
|
|
104
|
+
const index = templates.findIndex((template) => template.id === id);
|
|
105
|
+
if (index < 0)
|
|
106
|
+
throw new Error("Protagonist template was not found");
|
|
107
|
+
return index;
|
|
108
|
+
}
|
|
109
|
+
function validateDocument(value) {
|
|
110
|
+
if (!isRecord(value) ||
|
|
111
|
+
value.schemaVersion !== 1 ||
|
|
112
|
+
Object.keys(value).length !== 2 ||
|
|
113
|
+
!Array.isArray(value.templates))
|
|
114
|
+
throw new Error("Invalid template document");
|
|
115
|
+
const ids = new Set();
|
|
116
|
+
for (const template of value.templates) {
|
|
117
|
+
if (!isRecord(template) ||
|
|
118
|
+
Object.keys(template).length !== 4 ||
|
|
119
|
+
!isProtagonistTemplateId(template.id) ||
|
|
120
|
+
ids.has(template.id) ||
|
|
121
|
+
!isProtagonistTemplateContent(template.name, template.body) ||
|
|
122
|
+
typeof template.updatedAt !== "number" ||
|
|
123
|
+
!Number.isSafeInteger(template.updatedAt) ||
|
|
124
|
+
template.updatedAt < 0)
|
|
125
|
+
throw new Error("Invalid template");
|
|
126
|
+
ids.add(template.id);
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
templates: structuredClone(value.templates),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function isRecord(value) {
|
|
133
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
134
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
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";
|
|
8
|
+
import { sampleContentPackageForLocale } from "../../shared/sample-packages/index.js";
|
|
7
9
|
import { inspectContentPackageCurrentTree, isContentPackageWorldDocumentPath, worldDocumentTextRequirement, } from "./FileNativeContentTree.js";
|
|
8
10
|
export { CurrentTreeContentPackageBusyError, CurrentTreeContentLibraryError, CurrentTreeContentPackageNotFoundError, InvalidContentTreeError, } from "./CurrentTreeContentLibrary.js";
|
|
9
11
|
const portableTreeUtf8Decoder = new TextDecoder("utf-8", {
|
|
@@ -35,13 +37,21 @@ export class ContentWorkspace {
|
|
|
35
37
|
...options.currentTree,
|
|
36
38
|
});
|
|
37
39
|
}
|
|
38
|
-
createCurrentTreeContentPackage() {
|
|
40
|
+
createCurrentTreeContentPackage(gameplay = fictionGameplay) {
|
|
39
41
|
const locale = this.#locale();
|
|
42
|
+
const chosen = parseGameplay(gameplay);
|
|
40
43
|
return this.#currentTreeLibrary.createPackage({
|
|
41
44
|
title: defaultContentPackageTitle(locale, "new"),
|
|
42
|
-
files:
|
|
45
|
+
files: [
|
|
46
|
+
...minimalFileNativeContentScaffold(locale),
|
|
47
|
+
...(chosen.mode === "tabletop" ? [gameplayFile(chosen)] : []),
|
|
48
|
+
],
|
|
43
49
|
});
|
|
44
50
|
}
|
|
51
|
+
createSampleContentPackage(locale) {
|
|
52
|
+
const sample = sampleContentPackageForLocale(locale);
|
|
53
|
+
return this.#currentTreeLibrary.createPackage(sample);
|
|
54
|
+
}
|
|
45
55
|
listCurrentTreeContentPackages() {
|
|
46
56
|
return this.#currentTreeLibrary.listPackages();
|
|
47
57
|
}
|
|
@@ -71,6 +81,9 @@ export class ContentWorkspace {
|
|
|
71
81
|
replaceCurrentTreeContentPackage(localId, files, options = {}) {
|
|
72
82
|
return this.#currentTreeLibrary.replaceCurrentTree(localId, files, options);
|
|
73
83
|
}
|
|
84
|
+
setCurrentTreeContentGameplayScheme(localId, scheme, options = {}) {
|
|
85
|
+
return this.#currentTreeLibrary.setGameplayScheme(localId, scheme, options);
|
|
86
|
+
}
|
|
74
87
|
importPortableContentPackageArchive(archive, options = {}) {
|
|
75
88
|
return this.#mutate(async () => {
|
|
76
89
|
const files = readPortableContentArchive(archive, this.#limits, options.signal);
|