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
@@ -4,10 +4,12 @@ import { maxPortableContentArchiveBase64Characters } from "./contentTree.js";
4
4
  export const v1Protocol = "narraeon.runtime/v1";
5
5
  export class V1ProtocolError extends Error {
6
6
  code;
7
- constructor(code, message, options) {
7
+ details;
8
+ constructor(code, message, options = {}) {
8
9
  super(message, options);
9
10
  this.name = "V1ProtocolError";
10
11
  this.code = code;
12
+ this.details = options.details;
11
13
  }
12
14
  }
13
15
  export function parseV1Envelope(value) {
@@ -37,7 +39,11 @@ const requiredFields = {
37
39
  "world.package-scripts.set": { worldId: "string", enabled: "boolean" },
38
40
  "content.gameplay.set": { packageId: "string", scheme: "string" },
39
41
  "content.read": { packageId: "string" },
40
- "content.replace": { packageId: "string", files: "array" },
42
+ "content.replace": {
43
+ packageId: "string",
44
+ files: "array",
45
+ baseTreeRevision: "string",
46
+ },
41
47
  "content.copy": { packageId: "string" },
42
48
  "content.delete": { packageId: "string" },
43
49
  "content.rename": { packageId: "string", name: "string" },
@@ -7,7 +7,7 @@ import { join } from "node:path";
7
7
  import { maxPortableContentArchiveBase64Characters, maxPortableContentArchiveBytes, } from "../protocol/contentTree.js";
8
8
  import { V1ProtocolError, v1Protocol, } from "../protocol/v1.js";
9
9
  import { defaultAppLocale, } from "../protocol/appPreferences.js";
10
- import { ContentPackageImportError, ContentWorkspace, InvalidContentTreeError, } from "./content/ContentWorkspace.js";
10
+ import { ContentPackageImportError, ContentTreeConflictError, ContentWorkspace, InvalidContentTreeError, } from "./content/ContentWorkspace.js";
11
11
  import { contentTreeFingerprint } from "./content/ContentTreeFingerprint.js";
12
12
  import { inspectContentPackageCurrentTree } from "./content/FileNativeContentTree.js";
13
13
  import { FileNativeModelHost } from "./model/FileNativeModelAdapters.js";
@@ -197,7 +197,7 @@ export class V1Runtime {
197
197
  const chain = result;
198
198
  if (chain?.status !== "interrupted" ||
199
199
  typeof chain.lastFailure !== "string" ||
200
- chain.events?.at(-1)?.kind === "cancellation")
200
+ chain.lastCancelled === true)
201
201
  return;
202
202
  await this.#errorLog.recordPlayInterruption(request, {
203
203
  message: chain.lastFailure,
@@ -215,6 +215,10 @@ export class V1Runtime {
215
215
  : {}),
216
216
  }, error);
217
217
  }
218
+ /** Archive routes sit outside `handle`, so they record their own failures. */
219
+ async recordArchiveFailure(target, error) {
220
+ await this.#errorLog?.recordRequestFailure(target, error);
221
+ }
218
222
  subscribeConversation(target, changed) {
219
223
  return this.#conversationChanges.subscribe(`${target.kind}:${target.id}`, changed);
220
224
  }
@@ -324,13 +328,18 @@ export class V1Runtime {
324
328
  return this.#content.readCurrentTreeContentPackage(request.packageId);
325
329
  case "content.replace":
326
330
  try {
327
- return await this.#content.replaceCurrentTreeContentPackage(request.packageId, request.files);
331
+ return await this.#content.replaceCurrentTreeContentPackage(request.packageId, request.files, { baseTreeRevision: request.baseTreeRevision });
328
332
  }
329
333
  catch (error) {
330
334
  if (error instanceof InvalidContentTreeError)
331
335
  throw new V1ProtocolError("invalid_request", error.message, {
332
336
  cause: error,
333
337
  });
338
+ if (error instanceof ContentTreeConflictError)
339
+ throw new V1ProtocolError("content_conflict", error.message, {
340
+ cause: error,
341
+ details: { current: error.current },
342
+ });
334
343
  throw error;
335
344
  }
336
345
  case "content.copy":
@@ -108,7 +108,23 @@ export class WorldArchives {
108
108
  }
109
109
  });
110
110
  }
111
+ /**
112
+ * Confirms that `download` can serve this export without consuming it, so the
113
+ * browser can check before it starts the one-time download.
114
+ */
115
+ async checkDownload(archiveId) {
116
+ const { root } = await this.#readExport(archiveId);
117
+ await stat(join(root, "archive.zip"));
118
+ }
111
119
  async download(archiveId) {
120
+ const { root, metadata } = await this.#readExport(archiveId);
121
+ return {
122
+ metadata,
123
+ stream: createReadStream(join(root, "archive.zip")),
124
+ cleanup: () => rm(root, { recursive: true, force: true }),
125
+ };
126
+ }
127
+ async #readExport(archiveId) {
112
128
  identity(archiveId);
113
129
  const root = join(this.#root, "exports", archiveId);
114
130
  const metadata = JSON.parse(await readFile(join(root, "download.json"), "utf8"));
@@ -117,11 +133,7 @@ export class WorldArchives {
117
133
  await rm(root, { recursive: true, force: true });
118
134
  throw new WorldArchiveError("invalid_archive", "The download has expired; export the world again");
119
135
  }
120
- return {
121
- metadata,
122
- stream: createReadStream(join(root, "archive.zip")),
123
- cleanup: () => rm(root, { recursive: true, force: true }),
124
- };
136
+ return { root, metadata };
125
137
  }
126
138
  async importWorld(input) {
127
139
  identity(input.operationId);
@@ -7,7 +7,7 @@ import { defaultContentWorkspaceLimits, maximumPortableContentArchiveBytes, } fr
7
7
  import { CurrentTreeContentLibrary, InvalidContentTreeError, normalizeCurrentContentTreeFiles, } from "./CurrentTreeContentLibrary.js";
8
8
  import { sampleContentPackageForLocale } from "../../shared/sample-packages/index.js";
9
9
  import { inspectContentPackageCurrentTree, isContentPackageWorldDocumentPath, worldDocumentTextRequirement, } from "./FileNativeContentTree.js";
10
- export { CurrentTreeContentPackageBusyError, CurrentTreeContentLibraryError, CurrentTreeContentPackageNotFoundError, InvalidContentTreeError, } from "./CurrentTreeContentLibrary.js";
10
+ export { ContentTreeConflictError, CurrentTreeContentPackageBusyError, CurrentTreeContentLibraryError, CurrentTreeContentPackageNotFoundError, InvalidContentTreeError, } from "./CurrentTreeContentLibrary.js";
11
11
  const portableTreeUtf8Decoder = new TextDecoder("utf-8", {
12
12
  fatal: true,
13
13
  ignoreBOM: true,
@@ -78,7 +78,7 @@ export class ContentWorkspace {
78
78
  validateCurrentTreeContentPackage(files) {
79
79
  normalizeCurrentContentTreeFiles(files, this.#limits);
80
80
  }
81
- replaceCurrentTreeContentPackage(localId, files, options = {}) {
81
+ replaceCurrentTreeContentPackage(localId, files, options) {
82
82
  return this.#currentTreeLibrary.replaceCurrentTree(localId, files, options);
83
83
  }
84
84
  setCurrentTreeContentGameplayScheme(localId, scheme, options = {}) {
@@ -1,6 +1,5 @@
1
1
  import { PackageScriptPermissions } from "../extension/PackageScriptPermissions.js";
2
- import { randomUUID } from "node:crypto";
3
- import { readFileSync, unlinkSync } from "node:fs";
2
+ import { createHash, randomUUID } from "node:crypto";
4
3
  import { open, mkdir, readFile, readdir, rename, rm, stat, unlink, } from "node:fs/promises";
5
4
  import { linkExclusive } from "../FileNativeExclusiveLink.js";
6
5
  import { basename, dirname, isAbsolute, join, posix, win32 } from "node:path";
@@ -31,6 +30,18 @@ export class CurrentTreeContentPackageBusyError extends CurrentTreeContentLibrar
31
30
  this.name = "CurrentTreeContentPackageBusyError";
32
31
  }
33
32
  }
33
+ /**
34
+ * The current tree changed after the caller's base revision. Nothing was
35
+ * written; `current` is the tree the caller must reload before saving again.
36
+ */
37
+ export class ContentTreeConflictError extends CurrentTreeContentLibraryError {
38
+ current;
39
+ constructor(current) {
40
+ super("The content package changed after this draft was opened; load the latest tree before saving");
41
+ this.name = "ContentTreeConflictError";
42
+ this.current = current;
43
+ }
44
+ }
34
45
  export class InvalidContentTreeError extends Error {
35
46
  constructor(message) {
36
47
  super(message);
@@ -42,15 +53,16 @@ export class InvalidContentTreeError extends Error {
42
53
  * single replaceable envelope so saving cannot expose half of two file sets.
43
54
  */
44
55
  export class CurrentTreeContentLibrary {
45
- #locksRoot;
56
+ #legacyLocksRoot;
46
57
  #packagesRoot;
47
58
  #stagingRoot;
48
59
  #limits;
49
60
  #beforePackagePublish;
50
61
  #beforeCurrentTreeReplace;
51
62
  #mutationTail = Promise.resolve();
63
+ #legacyLocksRemoval;
52
64
  constructor(dataRoot, options = {}) {
53
- this.#locksRoot = join(dataRoot, "content", ".package-locks");
65
+ this.#legacyLocksRoot = join(dataRoot, "content", ".package-locks");
54
66
  this.#packagesRoot = join(dataRoot, "content", "packages");
55
67
  this.#stagingRoot = join(dataRoot, "content", ".package-staging");
56
68
  this.#limits = normalizeLimits(options.limits);
@@ -216,12 +228,19 @@ export class CurrentTreeContentLibrary {
216
228
  }
217
229
  });
218
230
  }
219
- replaceCurrentTree(localId, files, options = {}) {
231
+ /**
232
+ * Replace the whole tree only if it is still the revision the draft was
233
+ * based on. The comparison runs under the package lease, so a publish that
234
+ * landed after the draft opened cannot be overwritten.
235
+ */
236
+ replaceCurrentTree(localId, files, options) {
220
237
  return this.#mutate(async () => {
221
238
  const lease = await this.acquireOperationLease(localId, {
222
239
  wait: options.waitForLease !== false,
223
240
  });
224
241
  try {
242
+ if (lease.package.treeRevision !== options.baseTreeRevision)
243
+ throw new ContentTreeConflictError(lease.package);
225
244
  return await lease.replace(files, options);
226
245
  }
227
246
  finally {
@@ -253,48 +272,16 @@ export class CurrentTreeContentLibrary {
253
272
  });
254
273
  }
255
274
  async acquireOperationLease(localId, options = {}) {
256
- this.#packageRoot(localId);
257
- await mkdir(this.#locksRoot, { recursive: true, mode: 0o700 });
258
- const lockPath = this.#packageLockPath(localId);
259
- const reclaimPath = this.#packageReclaimPath(localId);
260
- await clearDeadPackageLockMarker(reclaimPath);
261
- if (await pathExists(reclaimPath)) {
262
- throw new CurrentTreeContentPackageBusyError();
263
- }
264
- const marker = createPackageLockMarker();
265
- const temporaryPath = join(this.#locksRoot, `.${localId}.${marker.token}.tmp`);
266
- let handle;
267
- try {
268
- handle = await open(temporaryPath, "wx", 0o600);
269
- await handle.writeFile(`${JSON.stringify(marker)}\n`, "utf8");
270
- await handle.close();
271
- handle = undefined;
272
- try {
273
- await linkExclusive(temporaryPath, lockPath);
274
- }
275
- catch (error) {
276
- if (!isNodeErrorCode(error, "EEXIST"))
277
- throw error;
278
- if (options.wait === true) {
279
- await this.#waitForPackageLock(lockPath, reclaimPath, temporaryPath);
280
- }
281
- else if (!(await this.#tryReplaceDeadPackageLock(lockPath, reclaimPath, temporaryPath))) {
282
- throw new CurrentTreeContentPackageBusyError();
283
- }
284
- }
285
- }
286
- catch (error) {
287
- await handle?.close().catch(() => undefined);
288
- if (error instanceof CurrentTreeContentPackageBusyError)
289
- throw error;
290
- throw new CurrentTreeContentLibraryError("Unable to acquire the source-package save owner", {
291
- cause: error instanceof Error ? error : undefined,
292
- });
293
- }
294
- finally {
295
- await unlink(temporaryPath).catch(() => undefined);
296
- }
275
+ const packageRoot = this.#packageRoot(localId);
276
+ await this.#removeLegacyPackageLocks();
277
+ await acquireInProcessPackageLease(packageRoot, options.wait === true);
297
278
  let released = false;
279
+ const release = () => {
280
+ if (released)
281
+ return;
282
+ released = true;
283
+ releaseInProcessPackageLease(packageRoot);
284
+ };
298
285
  try {
299
286
  const package_ = (await this.#readPackageSnapshot(localId)).package;
300
287
  return {
@@ -307,19 +294,25 @@ export class CurrentTreeContentLibrary {
307
294
  assertGameplayModeUnchanged(package_.files, normalized);
308
295
  return this.#replaceNormalizedCurrentTree(localId, package_.title, normalized, options.signal);
309
296
  },
310
- release: () => {
311
- if (released)
312
- return;
313
- released = true;
314
- releasePackageOperationLockSync(lockPath, marker.token);
315
- },
297
+ release,
316
298
  };
317
299
  }
318
300
  catch (error) {
319
- releasePackageOperationLockSync(lockPath, marker.token);
301
+ release();
320
302
  throw error;
321
303
  }
322
304
  }
305
+ /**
306
+ * Lock files written by versions that leased packages across processes. No
307
+ * code reads them any more, so the first lease in a library removes them.
308
+ */
309
+ #removeLegacyPackageLocks() {
310
+ this.#legacyLocksRemoval ??= rm(this.#legacyLocksRoot, {
311
+ recursive: true,
312
+ force: true,
313
+ }).catch(() => undefined);
314
+ return this.#legacyLocksRemoval;
315
+ }
323
316
  async #replaceNormalizedCurrentTree(localId, title, normalized, signal) {
324
317
  const packageRoot = this.#packageRoot(localId);
325
318
  const temporaryPath = join(packageRoot, `.current-tree-${randomUUID()}.tmp`);
@@ -408,70 +401,6 @@ export class CurrentTreeContentLibrary {
408
401
  }
409
402
  return join(this.#packagesRoot, localId);
410
403
  }
411
- #packageLockPath(localId) {
412
- return join(this.#locksRoot, `${localId}.mutation`);
413
- }
414
- #packageReclaimPath(localId) {
415
- return join(this.#locksRoot, `${localId}.reclaim`);
416
- }
417
- async #tryReplaceDeadPackageLock(lockPath, reclaimPath, replacementPath) {
418
- const current = await readPackageLockMarker(lockPath);
419
- if (current === null || isProcessAlive(current.pid))
420
- return false;
421
- const reclaim = createPackageLockMarker();
422
- const temporaryPath = join(this.#locksRoot, `.reclaim.${reclaim.token}.tmp`);
423
- let handle;
424
- try {
425
- handle = await open(temporaryPath, "wx", 0o600);
426
- await handle.writeFile(`${JSON.stringify(reclaim)}\n`, "utf8");
427
- await handle.close();
428
- handle = undefined;
429
- await linkExclusive(temporaryPath, reclaimPath);
430
- }
431
- catch (error) {
432
- await handle?.close().catch(() => undefined);
433
- if (isNodeErrorCode(error, "EEXIST"))
434
- return false;
435
- throw error;
436
- }
437
- finally {
438
- await unlink(temporaryPath).catch(() => undefined);
439
- }
440
- try {
441
- const verified = await readPackageLockMarker(lockPath);
442
- if (verified?.token !== current.token || isProcessAlive(current.pid)) {
443
- return false;
444
- }
445
- await unlink(lockPath).catch((error) => {
446
- if (!isNodeErrorCode(error, "ENOENT"))
447
- throw error;
448
- });
449
- await linkExclusive(replacementPath, lockPath);
450
- return true;
451
- }
452
- finally {
453
- const owner = await readPackageLockMarker(reclaimPath);
454
- if (owner?.token === reclaim.token) {
455
- await unlink(reclaimPath).catch(() => undefined);
456
- }
457
- }
458
- }
459
- async #waitForPackageLock(lockPath, reclaimPath, replacementPath) {
460
- for (let attempt = 0; attempt < 2_000; attempt += 1) {
461
- if ((await this.#tryReplaceDeadPackageLock(lockPath, reclaimPath, replacementPath)) ||
462
- (await linkExclusive(replacementPath, lockPath)
463
- .then(() => true)
464
- .catch((error) => {
465
- if (isNodeErrorCode(error, "EEXIST"))
466
- return false;
467
- throw error;
468
- }))) {
469
- return;
470
- }
471
- await new Promise((resolve) => setTimeout(resolve, 5));
472
- }
473
- throw new CurrentTreeContentPackageBusyError();
474
- }
475
404
  async #mutate(operation) {
476
405
  const previous = this.#mutationTail;
477
406
  let release = () => undefined;
@@ -490,6 +419,7 @@ export class CurrentTreeContentLibrary {
490
419
  function detail(localId, title, files, inspection) {
491
420
  return {
492
421
  ...summary(localId, title, readGameplay(files), inspection),
422
+ treeRevision: treeRevisionOf(files),
493
423
  files: files.map((file) => ({ ...file })),
494
424
  documents: structuredClone(inspection.documents),
495
425
  issues: structuredClone(inspection.issues),
@@ -497,6 +427,18 @@ function detail(localId, title, files, inspection) {
497
427
  control: structuredClone(inspection.control),
498
428
  };
499
429
  }
430
+ /**
431
+ * Digest of every file's path, encoding and contents, independent of file
432
+ * order. Equal trees always share a revision; a tree that returns to earlier
433
+ * content is equal to it, so a draft based on that content loses nothing.
434
+ */
435
+ function treeRevisionOf(files) {
436
+ const hash = createHash("sha256");
437
+ for (const file of [...files].sort((left, right) => compareStrings(left.path, right.path))) {
438
+ hash.update(JSON.stringify([file.path, file.encoding ?? null, file.contents]));
439
+ }
440
+ return hash.digest("hex");
441
+ }
500
442
  function summary(localId, title, gameplay, inspection) {
501
443
  return {
502
444
  gameplay: { ...gameplay },
@@ -731,47 +673,47 @@ function isPlainObject(value) {
731
673
  function serialize(value) {
732
674
  return `${JSON.stringify(value, null, 2)}\n`;
733
675
  }
734
- function createPackageLockMarker() {
735
- return { schemaVersion: 1, pid: process.pid, token: randomUUID() };
736
- }
737
- async function readPackageLockMarker(path) {
738
- try {
739
- const value = JSON.parse(await readFile(path, "utf8"));
740
- return parsePackageLockMarker(value);
741
- }
742
- catch {
743
- return null;
744
- }
745
- }
746
- function parsePackageLockMarker(value) {
747
- if (isPlainObject(value) &&
748
- value.schemaVersion === 1 &&
749
- typeof value.pid === "number" &&
750
- Number.isSafeInteger(value.pid) &&
751
- value.pid > 0 &&
752
- typeof value.token === "string" &&
753
- /^[0-9a-f-]{36}$/u.test(value.token)) {
754
- return { schemaVersion: 1, pid: value.pid, token: value.token };
755
- }
756
- return null;
757
- }
758
- function releasePackageOperationLockSync(path, token) {
759
- try {
760
- const current = parsePackageLockMarker(JSON.parse(readFileSync(path, "utf8")));
761
- if (current?.token === token)
762
- unlinkSync(path);
763
- }
764
- catch (error) {
765
- if (!isNodeErrorCode(error, "ENOENT"))
766
- throw error;
676
+ const packageLeaseWaitMs = 10_000;
677
+ /**
678
+ * Package leases held by this process, keyed by package root. A key is present
679
+ * while its lease is held; its array queues waiters in arrival order, and a
680
+ * release hands the lease straight to the first of them.
681
+ */
682
+ const heldPackageLeases = new Map();
683
+ async function acquireInProcessPackageLease(packageRoot, wait) {
684
+ const queue = heldPackageLeases.get(packageRoot);
685
+ if (queue === undefined) {
686
+ heldPackageLeases.set(packageRoot, []);
687
+ return;
767
688
  }
689
+ if (!wait)
690
+ throw new CurrentTreeContentPackageBusyError();
691
+ await new Promise((resolve, reject) => {
692
+ const waiter = {
693
+ grant: () => {
694
+ clearTimeout(timer);
695
+ resolve();
696
+ },
697
+ };
698
+ const timer = setTimeout(() => {
699
+ const index = queue.indexOf(waiter);
700
+ if (index === -1)
701
+ return;
702
+ queue.splice(index, 1);
703
+ reject(new CurrentTreeContentPackageBusyError());
704
+ }, packageLeaseWaitMs);
705
+ queue.push(waiter);
706
+ });
768
707
  }
769
- async function clearDeadPackageLockMarker(path) {
770
- const marker = await readPackageLockMarker(path);
771
- if ((marker !== null && !isProcessAlive(marker.pid)) ||
772
- (marker === null && (await pathExists(path)))) {
773
- await unlink(path).catch(() => undefined);
774
- }
708
+ function releaseInProcessPackageLease(packageRoot) {
709
+ const queue = heldPackageLeases.get(packageRoot);
710
+ if (queue === undefined)
711
+ return;
712
+ const next = queue.shift();
713
+ if (next === undefined)
714
+ heldPackageLeases.delete(packageRoot);
715
+ else
716
+ next.grant();
775
717
  }
776
718
  async function pathExists(path) {
777
719
  try {
@@ -784,15 +726,6 @@ async function pathExists(path) {
784
726
  throw error;
785
727
  }
786
728
  }
787
- function isProcessAlive(pid) {
788
- try {
789
- process.kill(pid, 0);
790
- return true;
791
- }
792
- catch (error) {
793
- return !isNodeErrorCode(error, "ESRCH");
794
- }
795
- }
796
729
  function isNodeErrorCode(error, code) {
797
730
  return (typeof error === "object" &&
798
731
  error !== null &&
@@ -14,8 +14,11 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
14
14
  layout: "content_package",
15
15
  files,
16
16
  });
17
- if (worldDocumentSnapshot.layout !== "content_package")
18
- throw new Error("Content-package inspection accepts only content_package document snapshots");
17
+ if (worldDocumentSnapshot.layout === "world_state")
18
+ throw new Error("Authoring inspection accepts only content_package or world_revision document snapshots");
19
+ const subject = worldDocumentSnapshot.layout === "world_revision"
20
+ ? "world revision"
21
+ : "content package";
19
22
  const snapshotFiles = worldDocumentSnapshot.files;
20
23
  const issues = [];
21
24
  const documents = worldDocumentSnapshotDocuments(worldDocumentSnapshot);
@@ -26,11 +29,11 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
26
29
  let opening = "missing";
27
30
  if (openingFiles.length === 0) {
28
31
  if (options.requireOpening !== false)
29
- issue(issues, "missing_opening", "opening.md", "The content package is missing a root-level opening.md introduction");
32
+ issue(issues, "missing_opening", "opening.md", `The ${subject} is missing a root-level opening.md introduction`);
30
33
  }
31
34
  else if (openingFiles.length > 1) {
32
35
  opening = "invalid";
33
- issue(issues, "invalid_opening", "opening.md", "A content package may contain only one root-level opening.md file");
36
+ issue(issues, "invalid_opening", "opening.md", `A ${subject} may contain only one root-level opening.md file`);
34
37
  }
35
38
  else {
36
39
  const openingFile = openingFiles[0];
@@ -48,19 +51,19 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
48
51
  for (const file of snapshotFiles) {
49
52
  if (/\.json$/iu.test(file.path) && file.path !== gameplayPath) {
50
53
  unsupportedPaths.add(file.path);
51
- issue(issues, "unsupported_content_file", file.path, "The current content package does not accept manifest, seven-field JSON, schema, record, or revision files");
54
+ issue(issues, "unsupported_content_file", file.path, `The current ${subject} does not accept manifest, seven-field JSON, schema, record, or revision files`);
52
55
  }
53
56
  }
54
57
  issues.push(...worldDocumentSnapshot.diagnostics
55
58
  .filter(({ logicalPath }) => logicalPath === undefined || !unsupportedPaths.has(logicalPath))
56
- .map(worldDocumentIssue));
59
+ .map((diagnostic) => worldDocumentIssue(diagnostic, worldDocumentSnapshot.logicalRoot)));
57
60
  const index = documentIndex(documents);
58
61
  const frameSource = textFiles.get("control/frame.yaml");
59
62
  let worldFrame = "missing";
60
63
  let currentSituation = "missing";
61
64
  if (frameSource === undefined) {
62
- issue(issues, "missing_world_frame", "control/frame.yaml", "The content package is missing its world prompt frame");
63
- issue(issues, "missing_current_situation", "control/frame.yaml", "The content package has not bound a current-situation document");
65
+ issue(issues, "missing_world_frame", "control/frame.yaml", `The ${subject} is missing its world prompt frame`);
66
+ issue(issues, "missing_current_situation", "control/frame.yaml", `The ${subject} has not bound a current-situation document`);
64
67
  }
65
68
  else {
66
69
  const frame = parseRestrictedYaml("control/frame.yaml", frameSource, issues);
@@ -97,7 +100,7 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
97
100
  const playerSource = textFiles.get("control/player-views.yaml");
98
101
  let playerView = "missing";
99
102
  if (playerSource === undefined) {
100
- issue(issues, "missing_player_view", "control/player-views.yaml", "The content package is missing its player-view control file");
103
+ issue(issues, "missing_player_view", "control/player-views.yaml", `The ${subject} is missing its player-view control file`);
101
104
  }
102
105
  else {
103
106
  const player = parseRestrictedYaml("control/player-views.yaml", playerSource, issues);
@@ -218,10 +221,10 @@ function contentDocument(document) {
218
221
  codec: document.codec,
219
222
  };
220
223
  }
221
- function worldDocumentIssue(diagnostic) {
224
+ function worldDocumentIssue(diagnostic, root) {
222
225
  return {
223
226
  code: contentIssueCode(diagnostic),
224
- path: diagnostic.logicalPath ?? "world",
227
+ path: diagnostic.logicalPath ?? root,
225
228
  message: diagnostic.code === "document_reference_invalid"
226
229
  ? "An explicit $ref points to a missing document, or the target identity is not unique in the snapshot"
227
230
  : diagnostic.message,
@@ -430,9 +433,9 @@ function validateFrameContext(value, index, snapshot, issues) {
430
433
  }
431
434
  if (entry.slot.kind === "catalog" &&
432
435
  entry.slot.required !== false &&
433
- catalogDocuments(entry.slot.directory, index.documents(), "world").length === 0) {
436
+ catalogDocuments(entry.slot.directory, index.documents(), snapshot.logicalRoot).length === 0) {
434
437
  const directory = entry.slot.directory;
435
- issue(issues, "invalid_world_frame", "control/frame.yaml", `Required catalog ${directory} has no associated documents; it matches only direct children named world/${directory}/<file>.yaml, .yml, or .md, and dots in file names do not create directories`);
438
+ issue(issues, "invalid_world_frame", "control/frame.yaml", `Required catalog ${directory} has no associated documents; it matches only direct children named ${snapshot.logicalRoot}/${directory}/<file>.yaml, .yml, or .md, and dots in file names do not create directories`);
436
439
  continue;
437
440
  }
438
441
  kinds.push(entry.slot.kind);