narraeon 0.7.2 → 0.7.5
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/dist/node/protocol/v1.js +8 -2
- package/dist/node/runtime/V1Runtime.js +12 -3
- package/dist/node/runtime/archive/WorldArchives.js +17 -5
- package/dist/node/runtime/content/ContentWorkspace.js +2 -2
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +98 -165
- package/dist/node/runtime/content/FileNativeContentTree.js +16 -13
- package/dist/node/runtime/model/FileNativeModelAdapters.js +106 -18
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +104 -21
- package/dist/node/runtime/play/PlayAdvanceLifecycle.js +211 -0
- package/dist/node/runtime/play/PlayCallChain.js +200 -374
- package/dist/node/runtime/setting/SettingAuthoringTransaction.js +312 -167
- package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +5 -0
- package/dist/node/runtime/world/FileNativeWorldStore.js +151 -108
- package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -1
- package/dist/node/runtime/world/WorldDocumentStore.js +1 -1
- package/dist/node/runtime/world/WorldLedgerAuthority.js +37 -3
- package/dist/node/runtime/world/WorldTurnLedger.js +17 -4
- package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -4
- package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +19 -241
- package/dist/node/runtime/world-revision/WorldRevisionWorkspace.js +8 -27
- package/dist/node/server/createServer.js +44 -20
- package/dist/web/assets/index-9N9kO8kY.js +1408 -0
- package/dist/web/assets/index-BjxBYYQi.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BUCslLEK.css +0 -1
- package/dist/web/assets/index-DkamBH6c.js +0 -1408
|
@@ -1,89 +1,46 @@
|
|
|
1
1
|
import { isPackageFollowupControlPath } from "../content/PackageFollowups.js";
|
|
2
|
-
import { defaultAppLocale } from "../../protocol/appPreferences.js";
|
|
3
2
|
import { assertSameGameplay, gameplayPath, readGameplay, } from "../../protocol/gameplay.js";
|
|
4
3
|
import { inspectContentPackageCurrentTree } from "../content/FileNativeContentTree.js";
|
|
5
|
-
import { SettingAuthoringTransaction,
|
|
4
|
+
import { authoringToolDefinitions, SettingAuthoringTransaction, worldRevisionAuthoringSurface, } from "../setting/SettingAuthoringTransaction.js";
|
|
6
5
|
import { WorldDocumentStore } from "../world/WorldDocumentStore.js";
|
|
7
|
-
const syntheticOpening = {
|
|
8
|
-
path: "opening.md",
|
|
9
|
-
contents: "# Existing world\n\nThe genesis opening is immutable in a world revision.\n",
|
|
10
|
-
};
|
|
11
|
-
const settingToRevisionTool = new Map([
|
|
12
|
-
["setting_list", "world_revision_list"],
|
|
13
|
-
["setting_search", "world_revision_search"],
|
|
14
|
-
["setting_read", "world_revision_read"],
|
|
15
|
-
["setting_create", "world_revision_create"],
|
|
16
|
-
["setting_write_file", "world_revision_write_file"],
|
|
17
|
-
["setting_patch", "world_revision_patch"],
|
|
18
|
-
["setting_move", "world_revision_move"],
|
|
19
|
-
["setting_delete", "world_revision_delete"],
|
|
20
|
-
]);
|
|
21
|
-
const revisionToSettingTool = new Map([...settingToRevisionTool].map(([setting, revision]) => [revision, setting]));
|
|
22
6
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
7
|
+
* The authoring transaction over a playing world's state/control worktree.
|
|
8
|
+
* Its namespace, tool names and wording come from the world-revision surface,
|
|
9
|
+
* so nothing it returns is rewritten after the transaction renders it.
|
|
25
10
|
*/
|
|
26
11
|
export class WorldRevisionAuthoringTransaction {
|
|
27
12
|
#inner;
|
|
28
|
-
#locale;
|
|
29
13
|
constructor(input) {
|
|
30
|
-
this.#locale = input.locale ?? defaultAppLocale;
|
|
31
14
|
this.#inner = new SettingAuthoringTransaction({
|
|
32
|
-
baseFiles:
|
|
33
|
-
|
|
15
|
+
baseFiles: input.baseFiles,
|
|
16
|
+
surface: worldRevisionAuthoringSurface,
|
|
17
|
+
...(input.locale === undefined ? {} : { locale: input.locale }),
|
|
34
18
|
...(input.revision === undefined ? {} : { revision: input.revision }),
|
|
35
19
|
...(input.authorization === undefined
|
|
36
20
|
? {}
|
|
37
21
|
: { authorization: input.authorization }),
|
|
38
|
-
validateFiles: (files) =>
|
|
39
|
-
const candidate = fromProjectedFiles(files);
|
|
40
|
-
assertUsableWorldRevisionFiles(candidate, input.immutableBaseFiles);
|
|
41
|
-
},
|
|
22
|
+
validateFiles: (files) => assertUsableWorldRevisionFiles(files, input.immutableBaseFiles),
|
|
42
23
|
preview: (snapshot) => input.preview(WorldDocumentStore.open({
|
|
43
24
|
layout: "world_state",
|
|
44
|
-
files:
|
|
25
|
+
files: [...snapshot.files].sort((left, right) => left.path.localeCompare(right.path)),
|
|
45
26
|
})),
|
|
46
27
|
});
|
|
47
28
|
}
|
|
48
29
|
files() {
|
|
49
|
-
return
|
|
30
|
+
return this.#inner.files();
|
|
50
31
|
}
|
|
51
32
|
review() {
|
|
52
|
-
return
|
|
33
|
+
return this.#inner.review();
|
|
53
34
|
}
|
|
54
35
|
authorization(revision) {
|
|
55
36
|
return this.#inner.authorization(revision);
|
|
56
37
|
}
|
|
57
38
|
execute(calls) {
|
|
58
|
-
|
|
59
|
-
const executed = this.#inner.execute(mapped.flatMap((item) => ("call" in item ? [item.call] : [])));
|
|
60
|
-
let executedIndex = 0;
|
|
61
|
-
return mapped.map((item, index) => {
|
|
62
|
-
if ("error" in item)
|
|
63
|
-
return rejectedToolResult(calls[index].id, item.error, this.#locale);
|
|
64
|
-
const result = executed[executedIndex++];
|
|
65
|
-
if (result === undefined)
|
|
66
|
-
throw new Error("World-revision authoring returned no tool result");
|
|
67
|
-
return {
|
|
68
|
-
...result,
|
|
69
|
-
markdown: publicWorldRevisionMarkdown(result.markdown),
|
|
70
|
-
changes: result.changes.map(mapDiff),
|
|
71
|
-
};
|
|
72
|
-
});
|
|
39
|
+
return this.#inner.execute(calls);
|
|
73
40
|
}
|
|
74
41
|
}
|
|
75
42
|
export function worldRevisionToolDefinitions(locale, gameplay) {
|
|
76
|
-
return
|
|
77
|
-
const name = settingToRevisionTool.get(definition.name);
|
|
78
|
-
if (name === undefined)
|
|
79
|
-
throw new Error(`Unsupported setting authoring tool: ${definition.name}`);
|
|
80
|
-
return {
|
|
81
|
-
...structuredClone(definition),
|
|
82
|
-
name,
|
|
83
|
-
description: publicWorldRevisionMarkdown(definition.description),
|
|
84
|
-
inputSchema: mapSchema(definition.inputSchema),
|
|
85
|
-
};
|
|
86
|
-
});
|
|
43
|
+
return authoringToolDefinitions(worldRevisionAuthoringSurface, locale, gameplay);
|
|
87
44
|
}
|
|
88
45
|
export function worldRevisionRuntimeContract(locale, gameplay) {
|
|
89
46
|
// A world's mode and scheme are fixed, so only tabletop worlds mention the file.
|
|
@@ -170,21 +127,17 @@ export function inspectWorldRevisionFiles(files, immutableBaseFiles) {
|
|
|
170
127
|
message: "An existing world-state document must keep its document ID and short reference",
|
|
171
128
|
});
|
|
172
129
|
}
|
|
173
|
-
const
|
|
174
|
-
...structuredClone(file),
|
|
175
|
-
path: file.path.startsWith("state/")
|
|
176
|
-
? `world/${file.path.slice("state/".length)}`
|
|
177
|
-
: file.path,
|
|
178
|
-
}));
|
|
179
|
-
const inspection = inspectContentPackageCurrentTree(projected, {
|
|
130
|
+
const inspection = inspectContentPackageCurrentTree(files, {
|
|
180
131
|
requireOpening: false,
|
|
132
|
+
worldDocumentSnapshot: WorldDocumentStore.open({
|
|
133
|
+
layout: "world_revision",
|
|
134
|
+
files,
|
|
135
|
+
}),
|
|
181
136
|
});
|
|
182
137
|
if (inspection.status !== "usable")
|
|
183
138
|
diagnostics.push(...inspection.issues.map(({ code, path, message }) => ({
|
|
184
139
|
code,
|
|
185
|
-
path
|
|
186
|
-
? `state/${path.slice("world/".length)}`
|
|
187
|
-
: path,
|
|
140
|
+
path,
|
|
188
141
|
message,
|
|
189
142
|
})));
|
|
190
143
|
return {
|
|
@@ -223,178 +176,6 @@ export function assertUsableWorldRevisionFiles(files, immutableBaseFiles) {
|
|
|
223
176
|
.map(({ path, message }) => `${path}: ${message}`)
|
|
224
177
|
.join("; "));
|
|
225
178
|
}
|
|
226
|
-
function toProjectedFiles(files) {
|
|
227
|
-
return [
|
|
228
|
-
syntheticOpening,
|
|
229
|
-
...files.map((file) => ({
|
|
230
|
-
...structuredClone(file),
|
|
231
|
-
path: file.path.startsWith("state/")
|
|
232
|
-
? `world/${file.path.slice("state/".length)}`
|
|
233
|
-
: file.path,
|
|
234
|
-
})),
|
|
235
|
-
];
|
|
236
|
-
}
|
|
237
|
-
function fromProjectedFiles(files) {
|
|
238
|
-
const opening = files.find(({ path }) => path === "opening.md");
|
|
239
|
-
if (opening?.contents !== syntheticOpening.contents ||
|
|
240
|
-
opening.encoding !== undefined)
|
|
241
|
-
throw new Error("opening.md is immutable in a world revision");
|
|
242
|
-
return files
|
|
243
|
-
.filter(({ path }) => path !== "opening.md")
|
|
244
|
-
.map((file) => ({
|
|
245
|
-
...structuredClone(file),
|
|
246
|
-
path: file.path.startsWith("world/")
|
|
247
|
-
? `state/${file.path.slice("world/".length)}`
|
|
248
|
-
: file.path,
|
|
249
|
-
}))
|
|
250
|
-
.sort((left, right) => left.path.localeCompare(right.path));
|
|
251
|
-
}
|
|
252
|
-
function mapToolCall(call) {
|
|
253
|
-
const name = revisionToSettingTool.get(call.name);
|
|
254
|
-
if (name === undefined)
|
|
255
|
-
return {
|
|
256
|
-
call: { ...structuredClone(call), name: `unsupported:${call.name}` },
|
|
257
|
-
};
|
|
258
|
-
const args = isRecord(call.arguments)
|
|
259
|
-
? structuredClone(call.arguments)
|
|
260
|
-
: call.arguments;
|
|
261
|
-
if (isRecord(args)) {
|
|
262
|
-
for (const field of [
|
|
263
|
-
"path",
|
|
264
|
-
"directory",
|
|
265
|
-
"within",
|
|
266
|
-
"from",
|
|
267
|
-
"to",
|
|
268
|
-
"document",
|
|
269
|
-
])
|
|
270
|
-
if (typeof args[field] === "string") {
|
|
271
|
-
if (args[field] === "opening.md")
|
|
272
|
-
return { error: "opening" };
|
|
273
|
-
if (args[field] === "world" || args[field].startsWith("world/"))
|
|
274
|
-
return { error: "namespace" };
|
|
275
|
-
args[field] = toProjectedPath(args[field]);
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
return {
|
|
279
|
-
call: { ...structuredClone(call), name, arguments: args },
|
|
280
|
-
};
|
|
281
|
-
}
|
|
282
|
-
function toProjectedPath(path) {
|
|
283
|
-
if (path === "state")
|
|
284
|
-
return "world";
|
|
285
|
-
return path.startsWith("state/")
|
|
286
|
-
? `world/${path.slice("state/".length)}`
|
|
287
|
-
: path;
|
|
288
|
-
}
|
|
289
|
-
function mapDiff(diff) {
|
|
290
|
-
return {
|
|
291
|
-
...structuredClone(diff),
|
|
292
|
-
path: diff.path.startsWith("world/")
|
|
293
|
-
? `state/${diff.path.slice("world/".length)}`
|
|
294
|
-
: diff.path,
|
|
295
|
-
};
|
|
296
|
-
}
|
|
297
|
-
function mapReview(review) {
|
|
298
|
-
return {
|
|
299
|
-
...structuredClone(review),
|
|
300
|
-
diff: review.diff.filter(({ path }) => path !== "opening.md").map(mapDiff),
|
|
301
|
-
diagnostics: review.diagnostics.map((diagnostic) => ({
|
|
302
|
-
...diagnostic,
|
|
303
|
-
path: diagnostic.path.startsWith("world/")
|
|
304
|
-
? `state/${diagnostic.path.slice("world/".length)}`
|
|
305
|
-
: diagnostic.path,
|
|
306
|
-
})),
|
|
307
|
-
playCoverage: review.playCoverage === null
|
|
308
|
-
? null
|
|
309
|
-
: {
|
|
310
|
-
...structuredClone(review.playCoverage),
|
|
311
|
-
changed: review.playCoverage.changed
|
|
312
|
-
.filter(({ path }) => path !== "opening.md")
|
|
313
|
-
.map((item) => ({ ...item, path: mapPublicPath(item.path) })),
|
|
314
|
-
},
|
|
315
|
-
};
|
|
316
|
-
}
|
|
317
|
-
function mapPublicPath(path) {
|
|
318
|
-
return path.startsWith("world/")
|
|
319
|
-
? `state/${path.slice("world/".length)}`
|
|
320
|
-
: path;
|
|
321
|
-
}
|
|
322
|
-
function publicWorldRevisionMarkdown(markdown) {
|
|
323
|
-
let fenced = false;
|
|
324
|
-
let documentBody = false;
|
|
325
|
-
let opaqueBody = false;
|
|
326
|
-
return markdown
|
|
327
|
-
.split("\n")
|
|
328
|
-
.flatMap((line) => {
|
|
329
|
-
if (line.trim() === "[body]") {
|
|
330
|
-
documentBody = true;
|
|
331
|
-
return [line];
|
|
332
|
-
}
|
|
333
|
-
if (line.trim() === "[/body]") {
|
|
334
|
-
documentBody = false;
|
|
335
|
-
return [line];
|
|
336
|
-
}
|
|
337
|
-
if (line.trimStart().startsWith("```")) {
|
|
338
|
-
fenced = !fenced;
|
|
339
|
-
return [line];
|
|
340
|
-
}
|
|
341
|
-
if (line.startsWith("# Special-file source ") ||
|
|
342
|
-
line.startsWith("# 专用文件原文 "))
|
|
343
|
-
opaqueBody = true;
|
|
344
|
-
else if (opaqueBody && line === "---")
|
|
345
|
-
opaqueBody = false;
|
|
346
|
-
if (fenced ||
|
|
347
|
-
documentBody ||
|
|
348
|
-
(opaqueBody &&
|
|
349
|
-
!line.startsWith("# Special-file source ") &&
|
|
350
|
-
!line.startsWith("# 专用文件原文 ")) ||
|
|
351
|
-
/^\s+"/u.test(line))
|
|
352
|
-
return [line];
|
|
353
|
-
const mapped = line
|
|
354
|
-
.replaceAll("setting_", "world_revision_")
|
|
355
|
-
.replaceAll("content package", "world revision")
|
|
356
|
-
.replaceAll("Content package", "World revision")
|
|
357
|
-
.replaceAll("内容包当前树", "世界修订工作树")
|
|
358
|
-
.replaceAll("内容包", "世界修订")
|
|
359
|
-
.replaceAll("opening and control", "control")
|
|
360
|
-
.replaceAll("opening/control", "control")
|
|
361
|
-
.replaceAll("opening.md, ", "")
|
|
362
|
-
.replaceAll("opening 和 control", "control")
|
|
363
|
-
.replaceAll("opening.md、", "")
|
|
364
|
-
.replaceAll("world/", "state/");
|
|
365
|
-
return /^- \[(?:special|binary)\] opening\.md$/u.test(mapped.trim())
|
|
366
|
-
? []
|
|
367
|
-
: [mapped];
|
|
368
|
-
})
|
|
369
|
-
.join("\n");
|
|
370
|
-
}
|
|
371
|
-
function rejectedToolResult(toolCallId, error, locale) {
|
|
372
|
-
const message = error === "opening"
|
|
373
|
-
? locale === "zh-CN"
|
|
374
|
-
? "世界创建后 opening.md 已成为不可变创世叙事。请改写 state/* 或 control/*。"
|
|
375
|
-
: "opening.md is immutable after a world has been created. Edit state/* or control/* instead."
|
|
376
|
-
: locale === "zh-CN"
|
|
377
|
-
? "世界修订只公开 state/* 与 control/* 逻辑路径;不能使用内容包的 world/* 命名空间。"
|
|
378
|
-
: "World revision exposes only state/* and control/* logical paths; the content-package world/* namespace is unavailable.";
|
|
379
|
-
return {
|
|
380
|
-
toolCallId,
|
|
381
|
-
markdown: `# Runtime tool rejected\n\n${message}`,
|
|
382
|
-
isError: true,
|
|
383
|
-
changes: [],
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
function mapSchema(schema) {
|
|
387
|
-
const map = (value) => {
|
|
388
|
-
if (typeof value === "string")
|
|
389
|
-
return value.replaceAll("world", "state");
|
|
390
|
-
if (Array.isArray(value))
|
|
391
|
-
return value.map(map);
|
|
392
|
-
if (!isRecord(value))
|
|
393
|
-
return value;
|
|
394
|
-
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, map(child)]));
|
|
395
|
-
};
|
|
396
|
-
return map(schema);
|
|
397
|
-
}
|
|
398
179
|
function isWritableWorldRevisionPath(path) {
|
|
399
180
|
if (/^state\/.+\.(?:ya?ml|md)$/u.test(path))
|
|
400
181
|
return true;
|
|
@@ -404,6 +185,3 @@ function isWritableWorldRevisionPath(path) {
|
|
|
404
185
|
path === "control/player-views.yaml" ||
|
|
405
186
|
/^control\/blocks\/.+\.md$/u.test(path));
|
|
406
187
|
}
|
|
407
|
-
function isRecord(value) {
|
|
408
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
409
|
-
}
|
|
@@ -190,25 +190,18 @@ export class WorldRevisionWorkspace {
|
|
|
190
190
|
layout: "world_state",
|
|
191
191
|
files: epoch.files,
|
|
192
192
|
}));
|
|
193
|
-
|
|
193
|
+
// state 与 control 的改动是同一轮 revision_apply:要么整轮成立,要么整轮不存在(#102 的账本恢复)。
|
|
194
|
+
const outcome = await this.#worlds.commitWorldRevisionApply({
|
|
194
195
|
...(worldClock === undefined ? {} : { worldClock }),
|
|
195
196
|
operationId: epoch.apply.operationId,
|
|
196
197
|
worldId: epoch.worldId,
|
|
197
198
|
parentHead: epoch.baseHead,
|
|
198
199
|
nextMaterials: binding.additionalMaterials,
|
|
199
200
|
stateChanges: stateChanges(epoch.baseFiles, epoch.files),
|
|
201
|
+
control: controlFiles(epoch.files),
|
|
200
202
|
worldRevisionLock: handle,
|
|
201
|
-
validationControl: controlFiles(epoch.files),
|
|
202
203
|
});
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
if (epoch.apply?.phase === "state_committed") {
|
|
206
|
-
await this.#worlds.replaceWorldRevisionControl({
|
|
207
|
-
handle,
|
|
208
|
-
expectedFingerprint: epoch.baseControlFingerprint,
|
|
209
|
-
files: controlFiles(epoch.files),
|
|
210
|
-
});
|
|
211
|
-
await this.#recordControlPublication(handle);
|
|
204
|
+
await this.#recordCommit(handle, outcome);
|
|
212
205
|
}
|
|
213
206
|
epoch = await this.#finishApply(handle);
|
|
214
207
|
await this.#worlds.releaseWorldRevisionLock(handle);
|
|
@@ -309,7 +302,7 @@ export class WorldRevisionWorkspace {
|
|
|
309
302
|
if (epoch.lifecycle !== "applying" || epoch.apply === null)
|
|
310
303
|
throw new WorldRevisionWorkspaceError("Apply recovery state is damaged");
|
|
311
304
|
epoch.lifecycle = "applied";
|
|
312
|
-
epoch.apply.phase = "
|
|
305
|
+
epoch.apply.phase = "committed";
|
|
313
306
|
epoch.apply.committedHead = epoch.baseHead;
|
|
314
307
|
epoch.finishedAt = Date.now();
|
|
315
308
|
epoch.updatedAt = epoch.finishedAt;
|
|
@@ -317,34 +310,22 @@ export class WorldRevisionWorkspace {
|
|
|
317
310
|
return epoch;
|
|
318
311
|
});
|
|
319
312
|
}
|
|
320
|
-
async #
|
|
313
|
+
async #recordCommit(handle, outcome) {
|
|
321
314
|
return this.#withRevisionOwnership(handle, async () => {
|
|
322
315
|
const epoch = await this.#store.readEpoch(handle.epochId);
|
|
323
316
|
if (epoch.lifecycle !== "applying" || epoch.apply === null)
|
|
324
317
|
throw new WorldRevisionWorkspaceError("Apply recovery state is damaged");
|
|
325
|
-
epoch.apply.phase = "
|
|
318
|
+
epoch.apply.phase = "committed";
|
|
326
319
|
epoch.apply.committedHead = outcome.head;
|
|
327
320
|
epoch.updatedAt = Date.now();
|
|
328
321
|
await this.#store.saveEpoch(epoch);
|
|
329
322
|
return epoch;
|
|
330
323
|
});
|
|
331
324
|
}
|
|
332
|
-
async #recordControlPublication(handle) {
|
|
333
|
-
return this.#withRevisionOwnership(handle, async () => {
|
|
334
|
-
const epoch = await this.#store.readEpoch(handle.epochId);
|
|
335
|
-
if (epoch.lifecycle !== "applying" || epoch.apply === null)
|
|
336
|
-
throw new WorldRevisionWorkspaceError("Apply recovery state is damaged");
|
|
337
|
-
epoch.apply.phase = "control_published";
|
|
338
|
-
epoch.updatedAt = Date.now();
|
|
339
|
-
await this.#store.saveEpoch(epoch);
|
|
340
|
-
return epoch;
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
325
|
async #finishApply(handle) {
|
|
344
326
|
return this.#withRevisionOwnership(handle, async () => {
|
|
345
327
|
const epoch = await this.#store.readEpoch(handle.epochId);
|
|
346
|
-
if (epoch.lifecycle !== "applying" ||
|
|
347
|
-
epoch.apply?.phase !== "control_published")
|
|
328
|
+
if (epoch.lifecycle !== "applying" || epoch.apply?.phase !== "committed")
|
|
348
329
|
throw new WorldRevisionWorkspaceError("Apply recovery state is damaged");
|
|
349
330
|
epoch.lifecycle = "applied";
|
|
350
331
|
epoch.finishedAt = Date.now();
|
|
@@ -91,6 +91,7 @@ export async function createServer(input) {
|
|
|
91
91
|
return await input.runtime.worldArchives.upload(request.body);
|
|
92
92
|
}
|
|
93
93
|
catch (error) {
|
|
94
|
+
await input.runtime.recordArchiveFailure({ type: "world.archive.upload" }, error);
|
|
94
95
|
if (error instanceof WorldArchiveError)
|
|
95
96
|
return reply
|
|
96
97
|
.status(error.code === "limit_exceeded" ? 413 : 400)
|
|
@@ -98,6 +99,21 @@ export async function createServer(input) {
|
|
|
98
99
|
throw error;
|
|
99
100
|
}
|
|
100
101
|
});
|
|
102
|
+
// The browser checks this before it starts the one-time download, so a
|
|
103
|
+
// missing or failing export is reported on the page instead of a blank file.
|
|
104
|
+
archives.get("/api/runtime/v1/world-archives/:archiveId/check", async (request, reply) => {
|
|
105
|
+
try {
|
|
106
|
+
await input.runtime.worldArchives.checkDownload(request.params.archiveId);
|
|
107
|
+
return reply.status(204).send();
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
await input.runtime.recordArchiveFailure({
|
|
111
|
+
type: "world.archive.download",
|
|
112
|
+
archiveId: request.params.archiveId,
|
|
113
|
+
}, error);
|
|
114
|
+
return archiveDownloadFailure(reply, error);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
101
117
|
archives.get("/api/runtime/v1/world-archives/:archiveId", async (request, reply) => {
|
|
102
118
|
try {
|
|
103
119
|
const archive = await input.runtime.worldArchives.download(request.params.archiveId);
|
|
@@ -111,20 +127,11 @@ export async function createServer(input) {
|
|
|
111
127
|
.send(archive.stream);
|
|
112
128
|
}
|
|
113
129
|
catch (error) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
"code" in error &&
|
|
120
|
-
error.code === "ENOENT")
|
|
121
|
-
return reply.status(404).send({
|
|
122
|
-
error: {
|
|
123
|
-
code: "invalid_archive",
|
|
124
|
-
message: "The archive download is unavailable",
|
|
125
|
-
},
|
|
126
|
-
});
|
|
127
|
-
throw error;
|
|
130
|
+
await input.runtime.recordArchiveFailure({
|
|
131
|
+
type: "world.archive.download",
|
|
132
|
+
archiveId: request.params.archiveId,
|
|
133
|
+
}, error);
|
|
134
|
+
return archiveDownloadFailure(reply, error);
|
|
128
135
|
}
|
|
129
136
|
});
|
|
130
137
|
done();
|
|
@@ -160,13 +167,19 @@ export async function createServer(input) {
|
|
|
160
167
|
}
|
|
161
168
|
catch (error) {
|
|
162
169
|
if (error instanceof V1ProtocolError) {
|
|
163
|
-
//
|
|
164
|
-
const status = error.code === "endpoint_discarded"
|
|
170
|
+
// 端点已被回退丢弃或内容包已被其他操作修改:客户端应重新读取,不是服务端错误。
|
|
171
|
+
const status = error.code === "endpoint_discarded" ||
|
|
172
|
+
error.code === "content_conflict"
|
|
173
|
+
? 409
|
|
174
|
+
: 400;
|
|
165
175
|
return reply.status(status).send({
|
|
166
176
|
protocol: "narraeon.runtime/v1",
|
|
167
177
|
error: {
|
|
168
178
|
code: error.code,
|
|
169
179
|
message: error.message,
|
|
180
|
+
...(error.details === undefined
|
|
181
|
+
? {}
|
|
182
|
+
: { details: error.details }),
|
|
170
183
|
},
|
|
171
184
|
});
|
|
172
185
|
}
|
|
@@ -182,6 +195,20 @@ export async function createServer(input) {
|
|
|
182
195
|
server.get("/index.html", index);
|
|
183
196
|
return server;
|
|
184
197
|
}
|
|
198
|
+
function archiveDownloadFailure(reply, error) {
|
|
199
|
+
if (error instanceof WorldArchiveError)
|
|
200
|
+
return reply
|
|
201
|
+
.status(400)
|
|
202
|
+
.send({ error: { code: error.code, message: error.message } });
|
|
203
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
204
|
+
return reply.status(404).send({
|
|
205
|
+
error: {
|
|
206
|
+
code: "invalid_archive",
|
|
207
|
+
message: "The archive download is unavailable",
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
185
212
|
function acceptsPlayCallChainStream(request) {
|
|
186
213
|
const accept = request.headers.accept;
|
|
187
214
|
return (typeof accept === "string" &&
|
|
@@ -246,8 +273,5 @@ function isPlayCallChainView(value) {
|
|
|
246
273
|
value.status === "interrupted"));
|
|
247
274
|
}
|
|
248
275
|
function hasPlayCancellation(value) {
|
|
249
|
-
|
|
250
|
-
return false;
|
|
251
|
-
const last = value.events.at(-1);
|
|
252
|
-
return isRecord(last) && last.kind === "cancellation";
|
|
276
|
+
return isRecord(value) && value.lastCancelled === true;
|
|
253
277
|
}
|