narraeon 0.7.4 → 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.
@@ -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();
@@ -273,8 +273,5 @@ function isPlayCallChainView(value) {
273
273
  value.status === "interrupted"));
274
274
  }
275
275
  function hasPlayCancellation(value) {
276
- if (!isRecord(value) || !Array.isArray(value.events))
277
- return false;
278
- const last = value.events.at(-1);
279
- return isRecord(last) && last.kind === "cancellation";
276
+ return isRecord(value) && value.lastCancelled === true;
280
277
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "narraeon",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
4
4
  "description": "A local-first workspace for living worlds.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",