narraeon 0.5.7 → 0.6.1

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 (62) hide show
  1. package/README.md +13 -0
  2. package/README.zh-CN.md +10 -0
  3. package/dist/node/protocol/gameplay.js +69 -0
  4. package/dist/node/protocol/resolution.js +274 -0
  5. package/dist/node/protocol/v1.js +15 -2
  6. package/dist/node/runtime/FileNativeExclusiveLink.js +39 -0
  7. package/dist/node/runtime/RuntimeErrorLog.js +95 -0
  8. package/dist/node/runtime/V1Runtime.js +58 -7
  9. package/dist/node/runtime/archive/WorldArchives.js +14 -46
  10. package/dist/node/runtime/artifact/ArtifactStoreFiles.js +330 -0
  11. package/dist/node/runtime/artifact/FileNativeArtifactStore.js +495 -1535
  12. package/dist/node/runtime/artifact/LegacyArtifactStoreMigration.js +260 -0
  13. package/dist/node/runtime/config/AppPreferencesStore.js +0 -1
  14. package/dist/node/runtime/config/ProtagonistTemplateStore.js +0 -1
  15. package/dist/node/runtime/config/dataRootLock.js +108 -0
  16. package/dist/node/runtime/content/ContentWorkspace.js +10 -12
  17. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +50 -46
  18. package/dist/node/runtime/content/FileNativeContentTree.js +8 -1
  19. package/dist/node/runtime/extension/PackageScriptPermissions.js +0 -8
  20. package/dist/node/runtime/extension/WorldExtensionControls.js +0 -8
  21. package/dist/node/runtime/model/ModelConnectionStore.js +0 -1
  22. package/dist/node/runtime/play/FileNativeContinuityCorrection.js +3 -14
  23. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +31 -170
  24. package/dist/node/runtime/play/FileNativePlayPresetStore.js +48 -3
  25. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +36 -239
  26. package/dist/node/runtime/play/OrderedPlayPrompts.js +68 -6
  27. package/dist/node/runtime/play/PlayCallChain.js +359 -359
  28. package/dist/node/runtime/play/PlayDocumentTools.js +8 -125
  29. package/dist/node/runtime/play/PlayFollowupRequests.js +2 -2
  30. package/dist/node/runtime/play/PlayTraceRetention.js +8 -36
  31. package/dist/node/runtime/play/TabletopResolution.js +372 -0
  32. package/dist/node/runtime/play/TabletopRules.js +141 -0
  33. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +45 -10
  34. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +35 -2
  35. package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +7 -16
  36. package/dist/node/runtime/setting/SettingAuthoringTransaction.js +56 -13
  37. package/dist/node/runtime/setting/SettingImprovementSession.js +6 -4
  38. package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +213 -1033
  39. package/dist/node/runtime/world/FileNativeWorldStore.js +266 -783
  40. package/dist/node/runtime/world/{FileNativeAuthorityV3.js → LegacyAuthorityV3Reader.js} +13 -404
  41. package/dist/node/runtime/world/WorldActivity.js +14 -22
  42. package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -2
  43. package/dist/node/runtime/world/WorldLedgerAuthority.js +340 -0
  44. package/dist/node/runtime/world/WorldOperationCoordinator.js +59 -401
  45. package/dist/node/runtime/world/WorldQueue.js +46 -0
  46. package/dist/node/runtime/world/WorldTurnLedger.js +353 -0
  47. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -12
  48. package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +35 -6
  49. package/dist/node/runtime/world-revision/WorldRevisionSession.js +9 -5
  50. package/dist/node/server/ConversationStream.js +2 -0
  51. package/dist/node/server/mobile.js +20 -0
  52. package/dist/node/server/startWebServer.js +15 -8
  53. package/dist/node/shared/ordered-author-prompts.js +8 -0
  54. package/dist/node/shared/ordered-play-prompts.js +23 -0
  55. package/dist/node/shared/tabletop-guidance.js +100 -0
  56. package/dist/web/assets/index-7GzLSUpU.js +1323 -0
  57. package/dist/web/assets/index-BJ-1Mb0e.css +1 -0
  58. package/dist/web/index.html +2 -2
  59. package/package.json +2 -1
  60. package/dist/node/runtime/FileNativePhysicalClone.js +0 -86
  61. package/dist/web/assets/index-DxNtxzLR.js +0 -1255
  62. package/dist/web/assets/index-DzOnu9uv.css +0 -1
@@ -1,3 +1,4 @@
1
+ import { gameplayPath, readGameplay } from "../../protocol/gameplay.js";
1
2
  import { readPackageFollowups } from "./PackageFollowups.js";
2
3
  import { parseDocument } from "yaml";
3
4
  import { WorldDocumentStore, } from "../world/WorldDocumentStore.js";
@@ -45,7 +46,7 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
45
46
  }
46
47
  const unsupportedPaths = new Set();
47
48
  for (const file of snapshotFiles) {
48
- if (/\.json$/iu.test(file.path)) {
49
+ if (/\.json$/iu.test(file.path) && file.path !== gameplayPath) {
49
50
  unsupportedPaths.add(file.path);
50
51
  issue(issues, "unsupported_content_file", file.path, "The current content package does not accept manifest, seven-field JSON, schema, record, or revision files");
51
52
  }
@@ -116,6 +117,12 @@ export function inspectContentPackageCurrentTree(files, options = {}) {
116
117
  issue(issues, "invalid_player_view", "control/player-views.yaml", `${formatProblems.length - maxReportedPlayerViewProblems} more player-view issues were omitted`);
117
118
  }
118
119
  }
120
+ try {
121
+ readGameplay(snapshotFiles);
122
+ }
123
+ catch (error) {
124
+ issue(issues, "invalid_gameplay", gameplayPath, error instanceof Error ? error.message : "Invalid gameplay");
125
+ }
119
126
  try {
120
127
  readPackageFollowups(snapshotFiles);
121
128
  }
@@ -32,19 +32,11 @@ export class PackageScriptPermissions {
32
32
  const file = await open(temporary, "wx", 0o600);
33
33
  try {
34
34
  await file.writeFile(JSON.stringify({ schemaVersion: 1, grants }));
35
- await file.sync();
36
35
  }
37
36
  finally {
38
37
  await file.close();
39
38
  }
40
39
  await rename(temporary, path);
41
- const directory = await open(ownerRoot, "r");
42
- try {
43
- await directory.sync();
44
- }
45
- finally {
46
- await directory.close();
47
- }
48
40
  }
49
41
  finally {
50
42
  await rm(temporary, { force: true });
@@ -27,19 +27,11 @@ export class WorldExtensionControls {
27
27
  const file = await open(temporary, "wx", 0o600);
28
28
  try {
29
29
  await file.writeFile(JSON.stringify(state));
30
- await file.sync();
31
30
  }
32
31
  finally {
33
32
  await file.close();
34
33
  }
35
34
  await rename(temporary, path);
36
- const directory = await open(root, "r");
37
- try {
38
- await directory.sync();
39
- }
40
- finally {
41
- await directory.close();
42
- }
43
35
  }
44
36
  finally {
45
37
  await rm(temporary, { force: true });
@@ -220,7 +220,6 @@ export class ModelConnectionStore {
220
220
  try {
221
221
  handle = await open(temporary, "wx", 0o600);
222
222
  await handle.writeFile(`${JSON.stringify(document, null, 2)}\n`, "utf8");
223
- await handle.sync();
224
223
  await handle.close();
225
224
  handle = undefined;
226
225
  await chmod(temporary, 0o600);
@@ -5,7 +5,6 @@ import { validateMaterialList } from "../prompt/MaterialSelection.js";
5
5
  import { FileNativeWorldCreationError, } from "../world/FileNativeWorldStore.js";
6
6
  import { WorldOperationBusyError, } from "../world/WorldOperationCoordinator.js";
7
7
  import { beginWorldStateRevision, fileNativeStateChanges, reviseWorldState, worldDocumentRevisionFailureMessage, worldStateRevisionChanges, } from "../world/WorldStateRevision.js";
8
- const claimHeartbeatIntervalMilliseconds = 20_000;
9
8
  export class FileNativeContinuityCorrection {
10
9
  #worlds;
11
10
  #compiler;
@@ -59,16 +58,10 @@ export class FileNativeContinuityCorrection {
59
58
  }
60
59
  if (revision.snapshot.status !== "usable") {
61
60
  this.#worlds.releaseControl(input.worldId, input.operationId);
62
- await this.#worlds.failOperation(input.operationId, "failed");
61
+ await this.#worlds.releaseOperationReservation(input.operationId);
63
62
  await this.#worlds.operations.releaseCorrectionWorld(claimed.handle);
64
63
  throw new Error("The correction candidate's world-state snapshot is unavailable");
65
64
  }
66
- const claimHeartbeat = setInterval(() => {
67
- void this.#worlds.operations
68
- .renewCorrectionWorld(claimed.handle)
69
- .catch(() => undefined);
70
- }, claimHeartbeatIntervalMilliseconds);
71
- claimHeartbeat.unref();
72
65
  try {
73
66
  const candidate = {
74
67
  ...revision,
@@ -87,7 +80,6 @@ export class FileNativeContinuityCorrection {
87
80
  reads: new Map(),
88
81
  previewedVersion: null,
89
82
  stateOperationClaim: claimed.handle,
90
- claimHeartbeat,
91
83
  };
92
84
  this.#candidates.set(candidate.id, candidate);
93
85
  return {
@@ -97,7 +89,6 @@ export class FileNativeContinuityCorrection {
97
89
  };
98
90
  }
99
91
  catch (error) {
100
- clearInterval(claimHeartbeat);
101
92
  this.#worlds.releaseControl(input.worldId, input.operationId);
102
93
  await Promise.allSettled([
103
94
  this.#worlds.releaseOperationReservation(input.operationId),
@@ -200,11 +191,10 @@ export class FileNativeContinuityCorrection {
200
191
  });
201
192
  }
202
193
  catch (error) {
203
- await this.#worlds.failOperation(candidate.operationId, "failed");
194
+ await this.#worlds.releaseOperationReservation(candidate.operationId);
204
195
  throw error;
205
196
  }
206
197
  finally {
207
- clearInterval(candidate.claimHeartbeat);
208
198
  this.#worlds.releaseControl(candidate.worldId, candidate.operationId);
209
199
  this.#candidates.delete(candidate.id);
210
200
  await this.#worlds.operations
@@ -214,11 +204,10 @@ export class FileNativeContinuityCorrection {
214
204
  }
215
205
  async cancel(candidateId, expectedVersion) {
216
206
  const candidate = this.#atVersion(candidateId, expectedVersion);
217
- clearInterval(candidate.claimHeartbeat);
218
207
  this.#candidates.delete(candidateId);
219
208
  this.#worlds.releaseControl(candidate.worldId, candidate.operationId);
220
209
  try {
221
- await this.#worlds.failOperation(candidate.operationId, "cancelled");
210
+ await this.#worlds.releaseOperationReservation(candidate.operationId);
222
211
  }
223
212
  finally {
224
213
  await this.#worlds.operations.releaseCorrectionWorld(candidate.stateOperationClaim);
@@ -1,14 +1,15 @@
1
- import { isNarrativeOrigin, isHistoryBackground, } from "../history/HistoryBackground.js";
1
+ import { isNarrativeOrigin, } from "../history/HistoryBackground.js";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
- import { link, mkdir, open, readFile, rename, rm, writeFile, } from "node:fs/promises";
3
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { isDeepStrictEqual } from "node:util";
6
6
  /**
7
- * Durable progress facts for the one play advance currently being settled.
7
+ * The single in-progress play advance of a call chain, if any.
8
8
  *
9
- * Every stage is immutable. `current.json` is only a small lookup projection;
10
- * deleting it cannot change Authority and a repeated user operation keeps the
11
- * same deterministic advance identity.
9
+ * It exists only between `begin` and `markSettled`. A marker that survives a
10
+ * restart means the advance was interrupted; the runtime marks the chain
11
+ * interrupted and the player retries. Legacy records from earlier layouts are
12
+ * ignored, so they never block a world from opening.
12
13
  */
13
14
  export class FileNativePlayAdvanceStore {
14
15
  #worldsRoot;
@@ -18,94 +19,41 @@ export class FileNativePlayAdvanceStore {
18
19
  async begin(base) {
19
20
  assertBase(base);
20
21
  const current = await this.readCurrent(base.worldId, base.chainId);
21
- if (current !== null && current.settled === null) {
22
- if (!sameAdvanceBase(current.base, base))
22
+ if (current !== null) {
23
+ if (!sameAdvanceBase(current, base))
23
24
  throw new Error("Another play advance is still awaiting settlement");
24
25
  return;
25
26
  }
26
- const basePath = this.#factPath(base, "base.json");
27
- const existing = await readOptionalJson(basePath);
28
- if (existing === null)
29
- await publishImmutableJson(basePath, base);
30
- else {
31
- assertBase(existing);
32
- if (!sameAdvanceBase(existing, base))
33
- throw new Error("A play advance identity is bound to different data");
34
- }
35
- await publishJson(this.#currentPath(base.worldId, base.chainId), {
36
- schemaVersion: 1,
37
- worldId: base.worldId,
38
- chainId: base.chainId,
39
- advanceId: base.advanceId,
40
- });
41
- }
42
- async recordProviderCompleted(base, response) {
43
- const durableBase = await this.#assertCurrent(base);
44
- await publishImmutableJson(this.#factPath(base, "provider-completed.json"), {
45
- schemaVersion: 1,
46
- stage: "provider_completed",
47
- response: structuredClone(response),
48
- recordedAt: durableBase.createdAt,
49
- });
50
- }
51
- async recordSettlementPrepared(base, settlement) {
52
- const durableBase = await this.#assertCurrent(base);
53
- await publishImmutableJson(this.#factPath(base, "settlement-prepared.json"), {
54
- schemaVersion: 1,
55
- stage: "settlement_prepared",
56
- settlement: structuredClone(settlement),
57
- recordedAt: durableBase.createdAt,
58
- });
59
- }
60
- async markSettled(base, head) {
61
- const durableBase = await this.#assertCurrent(base);
62
- await publishImmutableJson(this.#factPath(base, "settled.json"), {
63
- schemaVersion: 1,
64
- stage: "settled",
65
- head,
66
- recordedAt: durableBase.createdAt,
67
- });
27
+ await publishJson(this.#markerPath(base.worldId, base.chainId), base);
68
28
  }
69
29
  async readCurrent(worldId, chainId) {
70
30
  assertIdentity(worldId, "World ID");
71
31
  assertIdentity(chainId, "Call-chain ID");
72
- const pointer = await readOptionalJson(this.#currentPath(worldId, chainId));
73
- if (pointer === null)
32
+ const value = await readOptionalJson(this.#markerPath(worldId, chainId));
33
+ if (value === null)
74
34
  return null;
75
- assertPointer(pointer);
76
- if (pointer.worldId !== worldId || pointer.chainId !== chainId)
77
- throw new Error("Play advance pointer belongs to another context");
78
- const recordRoot = this.#recordRoot(worldId, chainId, pointer.advanceId);
79
- const [base, providerCompleted, settlementPrepared, settled] = await Promise.all([
80
- readJson(join(recordRoot, "base.json")),
81
- readOptionalJson(join(recordRoot, "provider-completed.json")),
82
- readOptionalJson(join(recordRoot, "settlement-prepared.json")),
83
- readOptionalJson(join(recordRoot, "settled.json")),
84
- ]);
85
- assertBase(base);
86
- if (base.worldId !== worldId ||
87
- base.chainId !== chainId ||
88
- base.advanceId !== pointer.advanceId)
89
- throw new Error("Play advance durable identity is inconsistent");
90
- assertProviderCompleted(providerCompleted);
91
- assertSettlementPrepared(settlementPrepared);
92
- assertSettled(settled);
93
- return { base, providerCompleted, settlementPrepared, settled };
35
+ assertBase(value);
36
+ if (value.worldId !== worldId || value.chainId !== chainId)
37
+ throw new Error("Play advance marker belongs to another context");
38
+ return value;
94
39
  }
95
- async #assertCurrent(base) {
40
+ async markSettled(base) {
96
41
  const current = await this.readCurrent(base.worldId, base.chainId);
97
- if (current === null || !sameAdvanceBase(current.base, base))
42
+ if (current === null)
43
+ return;
44
+ if (!sameAdvanceBase(current, base))
98
45
  throw new Error("Play advance is no longer current");
99
- return current.base;
100
- }
101
- #currentPath(worldId, chainId) {
102
- return join(this.#worldsRoot, worldId, "runtime", "play-advances", digest(chainId), "current.json");
46
+ await rm(this.#markerPath(base.worldId, base.chainId), { force: true });
103
47
  }
104
- #recordRoot(worldId, chainId, advanceId) {
105
- return join(this.#worldsRoot, worldId, "runtime", "play-advances", digest(chainId), "records", digest(advanceId));
48
+ /** Remove the in-progress marker and all advance records of a discarded chain. */
49
+ async discardChain(worldId, chainId) {
50
+ await rm(dirname(this.#markerPath(worldId, chainId)), {
51
+ recursive: true,
52
+ force: true,
53
+ });
106
54
  }
107
- #factPath(base, name) {
108
- return join(this.#recordRoot(base.worldId, base.chainId, base.advanceId), name);
55
+ #markerPath(worldId, chainId) {
56
+ return join(this.#worldsRoot, worldId, "runtime", "play-advances", digest(chainId), "in-progress.json");
109
57
  }
110
58
  }
111
59
  function sameAdvanceBase(left, right) {
@@ -152,40 +100,6 @@ function assertBase(value) {
152
100
  Number(value.attempt) < 1)
153
101
  throw new Error("Response advance has an invalid shape");
154
102
  }
155
- function assertPointer(value) {
156
- if (!isRecord(value) ||
157
- value.schemaVersion !== 1 ||
158
- typeof value.worldId !== "string" ||
159
- typeof value.chainId !== "string" ||
160
- typeof value.advanceId !== "string")
161
- throw new Error("Play advance pointer has an invalid shape");
162
- }
163
- function assertProviderCompleted(value) {
164
- if (value !== null &&
165
- (value.schemaVersion !== 1 ||
166
- value.stage !== "provider_completed" ||
167
- !isRecord(value.response) ||
168
- typeof value.recordedAt !== "number"))
169
- throw new Error("Provider-completed play fact has an invalid shape");
170
- }
171
- function assertSettlementPrepared(value) {
172
- if (value !== null &&
173
- (value.schemaVersion !== 1 ||
174
- value.stage !== "settlement_prepared" ||
175
- !isRecord(value.settlement) ||
176
- (value.settlement.background !== undefined &&
177
- !isHistoryBackground(value.settlement.background)) ||
178
- typeof value.recordedAt !== "number"))
179
- throw new Error("Settlement-prepared play fact has an invalid shape");
180
- }
181
- function assertSettled(value) {
182
- if (value !== null &&
183
- (value.schemaVersion !== 1 ||
184
- value.stage !== "settled" ||
185
- typeof value.head !== "string" ||
186
- typeof value.recordedAt !== "number"))
187
- throw new Error("Settled play fact has an invalid shape");
188
- }
189
103
  function digest(value) {
190
104
  return createHash("sha256").update(value).digest("hex");
191
105
  }
@@ -196,12 +110,9 @@ function assertIdentity(value, label) {
196
110
  function isRecord(value) {
197
111
  return typeof value === "object" && value !== null && !Array.isArray(value);
198
112
  }
199
- async function readJson(path) {
200
- return JSON.parse(await readFile(path, "utf8"));
201
- }
202
113
  async function readOptionalJson(path) {
203
114
  try {
204
- return await readJson(path);
115
+ return JSON.parse(await readFile(path, "utf8"));
205
116
  }
206
117
  catch (error) {
207
118
  if (isNodeError(error) && error.code === "ENOENT")
@@ -209,6 +120,7 @@ async function readOptionalJson(path) {
209
120
  throw error;
210
121
  }
211
122
  }
123
+ /** Atomic publish: write a temporary file, then rename over the target. */
212
124
  async function publishJson(path, value) {
213
125
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
214
126
  const temporary = `${path}.${randomUUID()}.tmp`;
@@ -216,58 +128,7 @@ async function publishJson(path, value) {
216
128
  encoding: "utf8",
217
129
  mode: 0o600,
218
130
  });
219
- await syncFile(temporary);
220
131
  await rename(temporary, path);
221
- await syncDirectory(dirname(path));
222
- }
223
- async function publishImmutableJson(path, value) {
224
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
225
- const existing = await readOptionalJson(path);
226
- if (existing !== null) {
227
- if (!isDeepStrictEqual(existing, value))
228
- throw new Error("Immutable play-advance fact already has different data");
229
- return;
230
- }
231
- const temporary = `${path}.${randomUUID()}.tmp`;
232
- await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, {
233
- encoding: "utf8",
234
- mode: 0o600,
235
- });
236
- await syncFile(temporary);
237
- try {
238
- await link(temporary, path);
239
- }
240
- catch (error) {
241
- if (!isNodeError(error) || error.code !== "EEXIST")
242
- throw error;
243
- const published = await readJson(path);
244
- if (!isDeepStrictEqual(published, value))
245
- throw new Error("Concurrent immutable play-advance fact conflicts", {
246
- cause: error,
247
- });
248
- }
249
- finally {
250
- await rm(temporary, { force: true });
251
- }
252
- await syncDirectory(dirname(path));
253
- }
254
- async function syncFile(path) {
255
- const handle = await open(path, "r");
256
- try {
257
- await handle.sync();
258
- }
259
- finally {
260
- await handle.close();
261
- }
262
- }
263
- async function syncDirectory(path) {
264
- const handle = await open(path, "r");
265
- try {
266
- await handle.sync();
267
- }
268
- finally {
269
- await handle.close();
270
- }
271
132
  }
272
133
  function isNodeError(error) {
273
134
  return error instanceof Error && "code" in error;
@@ -3,7 +3,7 @@ export { validatePlayPresetArtifactPayload } from "../../shared/artifact-payload
3
3
  import { defaultFollowupItems, } from "../../shared/ordered-followups.js";
4
4
  import { parseFollowupItems } from "./OrderedFollowups.js";
5
5
  import { builtinAuthorPrompts, defaultOrderedAuthorPrompts, } from "../../shared/ordered-author-prompts.js";
6
- import { parseOrderedPlayPrompts, migrateLegacyPlayPrompts, } from "./OrderedPlayPrompts.js";
6
+ import { parseOrderedPlayPrompts, migrateLegacyPlayPrompts, withTabletopPromptBuiltins, } from "./OrderedPlayPrompts.js";
7
7
  import { defaultOrderedPlayPrompts, } from "../../shared/ordered-play-prompts.js";
8
8
  import { createHash, randomUUID } from "node:crypto";
9
9
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
@@ -203,12 +203,17 @@ export class FileNativePlayPresetStore {
203
203
  };
204
204
  this.#locale = options.locale ?? (() => defaultAppLocale);
205
205
  }
206
+ /**
207
+ * Startup is the only place that upgrades stored presets. Reads never write:
208
+ * list, bind and freeze observe the library as it was last saved.
209
+ */
206
210
  async initialize() {
207
211
  await this.#change(async () => {
208
212
  const document = await this.#read();
209
213
  const migrated = this.#markLegacyBuiltinDefault(document);
210
214
  const synchronized = this.#synchronizeBuiltinDefault(document);
211
- if (migrated || synchronized)
215
+ const tabletop = this.#migrateTabletopPromptBuiltins(document);
216
+ if (migrated || synchronized || tabletop)
212
217
  await this.#write(document);
213
218
  });
214
219
  }
@@ -452,7 +457,8 @@ export class FileNativePlayPresetStore {
452
457
  .map((path) => ({ path, contents: binding.files[path] }));
453
458
  }
454
459
  async importPortable(input) {
455
- const files = toFileMap(input.files, this.#limits);
460
+ // A released preset gains the tabletop builtins at import, as at upgrade.
461
+ const files = withTabletopPromptBuiltins(toFileMap(input.files, this.#limits));
456
462
  const parsed = parsePlayPresetFiles(files, this.#limits);
457
463
  if (parsed.kind === "invalid")
458
464
  throw parsed.error;
@@ -561,6 +567,45 @@ export class FileNativePlayPresetStore {
561
567
  preset.builtinDefault = true;
562
568
  return true;
563
569
  }
570
+ /**
571
+ * One-time upgrade for ordered presets saved before the tabletop builtins
572
+ * existed. An affected revision is replaced by a new revision that inserts
573
+ * them; the old revision stays for frozen requests. A pending draft gets the
574
+ * same insertion, because a draft that kept the old list would fail the
575
+ * builtin-retention rule on save. The library is one atomically written file,
576
+ * so an interrupted upgrade leaves the released bytes and reruns cleanly.
577
+ */
578
+ #migrateTabletopPromptBuiltins(document) {
579
+ let changed = false;
580
+ for (const stored of document.presets) {
581
+ const current = stored.revisions[stored.currentRevision];
582
+ if (current !== undefined) {
583
+ const migrated = withTabletopPromptBuiltins(current);
584
+ if (migrated !== current) {
585
+ const revision = revisionForFiles(migrated, this.#limits);
586
+ stored.revisions[revision] = cloneFiles(migrated);
587
+ stored.currentRevision = revision;
588
+ changed = true;
589
+ }
590
+ }
591
+ const draft = stored.draftRevision === undefined
592
+ ? undefined
593
+ : stored.revisions[stored.draftRevision];
594
+ if (draft !== undefined) {
595
+ const migrated = withTabletopPromptBuiltins(draft);
596
+ if (migrated !== draft) {
597
+ const revision = revisionForFiles(migrated, this.#limits);
598
+ stored.revisions[revision] = cloneFiles(migrated);
599
+ if (revision === stored.currentRevision)
600
+ delete stored.draftRevision;
601
+ else
602
+ stored.draftRevision = revision;
603
+ changed = true;
604
+ }
605
+ }
606
+ }
607
+ return changed;
608
+ }
564
609
  #synchronizeBuiltinDefault(document) {
565
610
  const preset = document.presets.find(({ builtinDefault }) => builtinDefault === true);
566
611
  if (preset === undefined || preset.draftRevision !== undefined)