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.
- package/README.md +13 -0
- package/README.zh-CN.md +10 -0
- package/dist/node/protocol/gameplay.js +69 -0
- package/dist/node/protocol/resolution.js +274 -0
- package/dist/node/protocol/v1.js +15 -2
- package/dist/node/runtime/FileNativeExclusiveLink.js +39 -0
- package/dist/node/runtime/RuntimeErrorLog.js +95 -0
- package/dist/node/runtime/V1Runtime.js +58 -7
- package/dist/node/runtime/archive/WorldArchives.js +14 -46
- package/dist/node/runtime/artifact/ArtifactStoreFiles.js +330 -0
- package/dist/node/runtime/artifact/FileNativeArtifactStore.js +495 -1535
- package/dist/node/runtime/artifact/LegacyArtifactStoreMigration.js +260 -0
- package/dist/node/runtime/config/AppPreferencesStore.js +0 -1
- package/dist/node/runtime/config/ProtagonistTemplateStore.js +0 -1
- package/dist/node/runtime/config/dataRootLock.js +108 -0
- package/dist/node/runtime/content/ContentWorkspace.js +10 -12
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +50 -46
- package/dist/node/runtime/content/FileNativeContentTree.js +8 -1
- package/dist/node/runtime/extension/PackageScriptPermissions.js +0 -8
- package/dist/node/runtime/extension/WorldExtensionControls.js +0 -8
- package/dist/node/runtime/model/ModelConnectionStore.js +0 -1
- package/dist/node/runtime/play/FileNativeContinuityCorrection.js +3 -14
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +31 -170
- package/dist/node/runtime/play/FileNativePlayPresetStore.js +48 -3
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +36 -239
- package/dist/node/runtime/play/OrderedPlayPrompts.js +68 -6
- package/dist/node/runtime/play/PlayCallChain.js +359 -359
- package/dist/node/runtime/play/PlayDocumentTools.js +8 -125
- package/dist/node/runtime/play/PlayFollowupRequests.js +2 -2
- package/dist/node/runtime/play/PlayTraceRetention.js +8 -36
- package/dist/node/runtime/play/TabletopResolution.js +372 -0
- package/dist/node/runtime/play/TabletopRules.js +141 -0
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +45 -10
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +35 -2
- package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +7 -16
- package/dist/node/runtime/setting/SettingAuthoringTransaction.js +56 -13
- package/dist/node/runtime/setting/SettingImprovementSession.js +6 -4
- package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +213 -1033
- package/dist/node/runtime/world/FileNativeWorldStore.js +266 -783
- package/dist/node/runtime/world/{FileNativeAuthorityV3.js → LegacyAuthorityV3Reader.js} +13 -404
- package/dist/node/runtime/world/WorldActivity.js +14 -22
- package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -2
- package/dist/node/runtime/world/WorldLedgerAuthority.js +340 -0
- package/dist/node/runtime/world/WorldOperationCoordinator.js +59 -401
- package/dist/node/runtime/world/WorldQueue.js +46 -0
- package/dist/node/runtime/world/WorldTurnLedger.js +353 -0
- package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -12
- package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +35 -6
- package/dist/node/runtime/world-revision/WorldRevisionSession.js +9 -5
- package/dist/node/server/ConversationStream.js +2 -0
- package/dist/node/server/mobile.js +20 -0
- package/dist/node/server/startWebServer.js +15 -8
- package/dist/node/shared/ordered-author-prompts.js +8 -0
- package/dist/node/shared/ordered-play-prompts.js +23 -0
- package/dist/node/shared/tabletop-guidance.js +100 -0
- package/dist/web/assets/index-7GzLSUpU.js +1323 -0
- package/dist/web/assets/index-BJ-1Mb0e.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/dist/node/runtime/FileNativePhysicalClone.js +0 -86
- package/dist/web/assets/index-DxNtxzLR.js +0 -1255
- 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.
|
|
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.
|
|
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.
|
|
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,
|
|
1
|
+
import { isNarrativeOrigin, } from "../history/HistoryBackground.js";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
-
import {
|
|
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
|
-
*
|
|
7
|
+
* The single in-progress play advance of a call chain, if any.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
22
|
-
if (!sameAdvanceBase(current
|
|
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
|
-
|
|
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
|
|
73
|
-
if (
|
|
32
|
+
const value = await readOptionalJson(this.#markerPath(worldId, chainId));
|
|
33
|
+
if (value === null)
|
|
74
34
|
return null;
|
|
75
|
-
|
|
76
|
-
if (
|
|
77
|
-
throw new Error("Play advance
|
|
78
|
-
|
|
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
|
|
40
|
+
async markSettled(base) {
|
|
96
41
|
const current = await this.readCurrent(base.worldId, base.chainId);
|
|
97
|
-
if (current === null
|
|
42
|
+
if (current === null)
|
|
43
|
+
return;
|
|
44
|
+
if (!sameAdvanceBase(current, base))
|
|
98
45
|
throw new Error("Play advance is no longer current");
|
|
99
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
#
|
|
108
|
-
return join(this.#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|