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.
Files changed (27) hide show
  1. package/dist/node/protocol/v1.js +8 -2
  2. package/dist/node/runtime/V1Runtime.js +12 -3
  3. package/dist/node/runtime/archive/WorldArchives.js +17 -5
  4. package/dist/node/runtime/content/ContentWorkspace.js +2 -2
  5. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +98 -165
  6. package/dist/node/runtime/content/FileNativeContentTree.js +16 -13
  7. package/dist/node/runtime/model/FileNativeModelAdapters.js +106 -18
  8. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +104 -21
  9. package/dist/node/runtime/play/PlayAdvanceLifecycle.js +211 -0
  10. package/dist/node/runtime/play/PlayCallChain.js +200 -374
  11. package/dist/node/runtime/setting/SettingAuthoringTransaction.js +312 -167
  12. package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +5 -0
  13. package/dist/node/runtime/world/FileNativeWorldStore.js +151 -108
  14. package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -1
  15. package/dist/node/runtime/world/WorldDocumentStore.js +1 -1
  16. package/dist/node/runtime/world/WorldLedgerAuthority.js +37 -3
  17. package/dist/node/runtime/world/WorldTurnLedger.js +17 -4
  18. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -4
  19. package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +19 -241
  20. package/dist/node/runtime/world-revision/WorldRevisionWorkspace.js +8 -27
  21. package/dist/node/server/createServer.js +44 -20
  22. package/dist/web/assets/index-9N9kO8kY.js +1408 -0
  23. package/dist/web/assets/index-BjxBYYQi.css +1 -0
  24. package/dist/web/index.html +2 -2
  25. package/package.json +1 -1
  26. package/dist/web/assets/index-BUCslLEK.css +0 -1
  27. 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, settingImprovementToolDefinitions, } from "../setting/SettingAuthoringTransaction.js";
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
- * Adapter that reuses the content-package authoring mechanics while exposing
24
- * the current world's state/control worktree as its own logical target.
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: toProjectedFiles(input.baseFiles),
33
- locale: this.#locale,
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: fromProjectedFiles(snapshot.files),
25
+ files: [...snapshot.files].sort((left, right) => left.path.localeCompare(right.path)),
45
26
  })),
46
27
  });
47
28
  }
48
29
  files() {
49
- return fromProjectedFiles(this.#inner.files());
30
+ return this.#inner.files();
50
31
  }
51
32
  review() {
52
- return mapReview(this.#inner.review());
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
- const mapped = calls.map(mapToolCall);
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 settingImprovementToolDefinitions(locale, gameplay).map((definition) => {
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 projected = files.map((file) => ({
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: path.startsWith("world/")
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
- const outcome = await this.#worlds.commitCorrection({
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
- epoch = await this.#recordStateCommit(handle, outcome);
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 = "control_published";
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 #recordStateCommit(handle, outcome) {
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 = "state_committed";
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
- if (error instanceof WorldArchiveError)
115
- return reply
116
- .status(400)
117
- .send({ error: { code: error.code, message: error.message } });
118
- if (error instanceof Error &&
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" ? 409 : 400;
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
- if (!isRecord(value) || !Array.isArray(value.events))
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
  }