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,8 +1,8 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { HistoryQuery, historyInputs } from "../history/HistoryQuery.js";
|
|
2
3
|
import { renderHistoryResult } from "../history/HistoryRendering.js";
|
|
3
4
|
import { stateDirectoryHandle, parseStateDirectoryHandle, } from "../world/StateDirectoryHandles.js";
|
|
4
5
|
import { renderDocumentWritePosition } from "../prompt/WorldMaintenanceReport.js";
|
|
5
|
-
import { createHash } from "node:crypto";
|
|
6
6
|
import { stringify } from "yaml";
|
|
7
7
|
import { renderPlayerViewBindings } from "../world/PlayerViewBindings.js";
|
|
8
8
|
import { PromptCompilationError, } from "../prompt/FileNativePromptCompiler.js";
|
|
@@ -56,66 +56,16 @@ export class FileNativePlayDocuments {
|
|
|
56
56
|
get committedSnapshot() {
|
|
57
57
|
return this.#committedSnapshot;
|
|
58
58
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
this.restoreAuthorizationCheckpoint(previousAuthorization);
|
|
67
|
-
for (const [ref, locators] of injected.documents) {
|
|
68
|
-
for (const locator of locators ?? [null])
|
|
69
|
-
authorizeRead(this.#reads, this.#candidate.snapshot.id, ref, locator);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
59
|
+
/**
|
|
60
|
+
* Read authorizations live only in memory (ADR-0044). A fresh binding starts
|
|
61
|
+
* from the bootstrap's own reads; writes are guarded at commit time by the
|
|
62
|
+
* previous-content hash of each changed file.
|
|
63
|
+
*/
|
|
64
|
+
bindBootstrap(bootstrap) {
|
|
65
|
+
this.#reads = bootstrapAuthorizations(bootstrap, this.#candidate.snapshot);
|
|
72
66
|
// Creation capabilities come from this prefix, not past directory slots.
|
|
73
67
|
this.#declaredDirectories = declaredStateDirectories(bootstrap);
|
|
74
68
|
}
|
|
75
|
-
authorizationCheckpoint() {
|
|
76
|
-
return {
|
|
77
|
-
schemaVersion: 1,
|
|
78
|
-
kind: "play_document_authorizations",
|
|
79
|
-
stateFingerprint: fingerprintStateFiles(this.#candidate.files),
|
|
80
|
-
documents: [...this.#reads.documents.entries()]
|
|
81
|
-
.sort(([left], [right]) => left.localeCompare(right))
|
|
82
|
-
.map(([shortRef, locators]) => ({
|
|
83
|
-
shortRef,
|
|
84
|
-
locators: locators === null
|
|
85
|
-
? null
|
|
86
|
-
: locators
|
|
87
|
-
.filter((locator, index, all) => all.findIndex((candidate) => sameLocator(candidate, locator)) === index)
|
|
88
|
-
.map((locator) => structuredClone(locator)),
|
|
89
|
-
})),
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
restoreAuthorizationCheckpoint(checkpoint) {
|
|
93
|
-
if (!isPlayDocumentAuthorizationCheckpoint(checkpoint))
|
|
94
|
-
throw new TypeError("The play-document authorization checkpoint has an invalid format");
|
|
95
|
-
if (checkpoint.stateFingerprint !==
|
|
96
|
-
fingerprintStateFiles(this.#candidate.files))
|
|
97
|
-
throw new TypeError("The play-document authorization checkpoint does not match the current state");
|
|
98
|
-
const documents = new Map();
|
|
99
|
-
for (const { shortRef, locators } of checkpoint.documents) {
|
|
100
|
-
if (documentDescriptorByRef(this.#candidate.snapshot, shortRef) === null)
|
|
101
|
-
throw new TypeError(`The play-document authorization checkpoint refers to missing @${shortRef}`);
|
|
102
|
-
const restoredLocators = locators?.map((locator) => {
|
|
103
|
-
const selected = this.#candidate.snapshot.query({
|
|
104
|
-
kind: "select_node",
|
|
105
|
-
document: { shortRef },
|
|
106
|
-
locator,
|
|
107
|
-
});
|
|
108
|
-
if (selected.kind !== "select_node" || !selected.ok)
|
|
109
|
-
throw new TypeError(`The play-document authorization checkpoint refers to a missing node in @${shortRef}`);
|
|
110
|
-
return structuredClone(selected.node.locator);
|
|
111
|
-
});
|
|
112
|
-
documents.set(shortRef, restoredLocators ?? null);
|
|
113
|
-
}
|
|
114
|
-
this.#reads = {
|
|
115
|
-
snapshotId: this.#candidate.snapshot.id,
|
|
116
|
-
documents,
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
69
|
execute(call, history, options = {}) {
|
|
120
70
|
let historyQuery;
|
|
121
71
|
const query = () => (historyQuery ??= new HistoryQuery(options.historyScope ?? "local-document-context", historyInputs(history)));
|
|
@@ -254,15 +204,6 @@ function authorizeToolRead(authorizations, snapshot, result) {
|
|
|
254
204
|
return;
|
|
255
205
|
authorizeRead(authorizations, snapshot.id, authorization.shortRef, authorization.locator);
|
|
256
206
|
}
|
|
257
|
-
function sameLocator(left, right) {
|
|
258
|
-
if (left === null || right === null)
|
|
259
|
-
return left === right;
|
|
260
|
-
const leftPath = "yaml" in left ? left.yaml : left.markdown;
|
|
261
|
-
const rightPath = "yaml" in right ? right.yaml : right.markdown;
|
|
262
|
-
return ("yaml" in left === "yaml" in right &&
|
|
263
|
-
leftPath.length === rightPath.length &&
|
|
264
|
-
leftPath.every((segment, index) => segment === rightPath[index]));
|
|
265
|
-
}
|
|
266
207
|
function parseYamlHandleSegment(segment) {
|
|
267
208
|
return /^(?:0|[1-9][0-9]*)$/u.test(segment) ? Number(segment) : segment;
|
|
268
209
|
}
|
|
@@ -914,64 +855,6 @@ export function fingerprintControl(files) {
|
|
|
914
855
|
hash.update(path).update("\0").update(contents).update("\0");
|
|
915
856
|
return `sha256:${hash.digest("hex")}`;
|
|
916
857
|
}
|
|
917
|
-
export function isPlayDocumentAuthorizationCheckpoint(value) {
|
|
918
|
-
if (!record(value) ||
|
|
919
|
-
!hasExactKeys(value, [
|
|
920
|
-
"schemaVersion",
|
|
921
|
-
"kind",
|
|
922
|
-
"stateFingerprint",
|
|
923
|
-
"documents",
|
|
924
|
-
]) ||
|
|
925
|
-
value.schemaVersion !== 1 ||
|
|
926
|
-
value.kind !== "play_document_authorizations" ||
|
|
927
|
-
typeof value.stateFingerprint !== "string" ||
|
|
928
|
-
!/^sha256:[0-9a-f]{64}$/u.test(value.stateFingerprint) ||
|
|
929
|
-
!Array.isArray(value.documents) ||
|
|
930
|
-
value.documents.length > 100_000)
|
|
931
|
-
return false;
|
|
932
|
-
const refs = new Set();
|
|
933
|
-
for (const document of value.documents) {
|
|
934
|
-
if (!record(document) ||
|
|
935
|
-
!hasExactKeys(document, ["shortRef", "locators"]) ||
|
|
936
|
-
typeof document.shortRef !== "string" ||
|
|
937
|
-
!/^[a-z][a-z0-9-]*$/u.test(document.shortRef) ||
|
|
938
|
-
refs.has(document.shortRef) ||
|
|
939
|
-
(document.locators !== null &&
|
|
940
|
-
(!Array.isArray(document.locators) ||
|
|
941
|
-
document.locators.length > 100_000 ||
|
|
942
|
-
!document.locators.every(isPersistedAuthorizedLocator))))
|
|
943
|
-
return false;
|
|
944
|
-
refs.add(document.shortRef);
|
|
945
|
-
}
|
|
946
|
-
return true;
|
|
947
|
-
}
|
|
948
|
-
function fingerprintStateFiles(files) {
|
|
949
|
-
const hash = createHash("sha256");
|
|
950
|
-
for (const [path, contents] of Object.entries(files)
|
|
951
|
-
.filter(([path]) => path.startsWith("state/"))
|
|
952
|
-
.sort(([left], [right]) => left.localeCompare(right)))
|
|
953
|
-
hash.update(path).update("\0").update(contents).update("\0");
|
|
954
|
-
return `sha256:${hash.digest("hex")}`;
|
|
955
|
-
}
|
|
956
|
-
function isPersistedAuthorizedLocator(value) {
|
|
957
|
-
if (!record(value))
|
|
958
|
-
return false;
|
|
959
|
-
if (hasExactKeys(value, ["yaml"]))
|
|
960
|
-
return (Array.isArray(value.yaml) &&
|
|
961
|
-
value.yaml.length <= 64 &&
|
|
962
|
-
value.yaml.every((segment) => typeof segment === "string" ||
|
|
963
|
-
(Number.isSafeInteger(segment) && Number(segment) >= 0)));
|
|
964
|
-
if (hasExactKeys(value, ["markdown"]))
|
|
965
|
-
return (Array.isArray(value.markdown) &&
|
|
966
|
-
value.markdown.length <= 64 &&
|
|
967
|
-
value.markdown.every((segment) => typeof segment === "string"));
|
|
968
|
-
return false;
|
|
969
|
-
}
|
|
970
|
-
function hasExactKeys(value, expected) {
|
|
971
|
-
const keys = Object.keys(value);
|
|
972
|
-
return (keys.length === expected.length &&
|
|
973
|
-
expected.every((key) => Object.hasOwn(value, key)));
|
|
974
|
-
}
|
|
975
858
|
function record(value) {
|
|
976
859
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
977
860
|
}
|
|
@@ -59,8 +59,8 @@ export async function runPlayFollowupRequests(input) {
|
|
|
59
59
|
failure ??= outcome.failure;
|
|
60
60
|
}
|
|
61
61
|
const extension = failure === undefined
|
|
62
|
-
? await input.artifacts.completeExtension(input.context.operationId, completed)
|
|
63
|
-
: await input.artifacts.failExtension(input.context.operationId, "recovery_required", failure, completed);
|
|
62
|
+
? await input.artifacts.completeExtension(input.context.worldId, input.context.operationId, completed)
|
|
63
|
+
: await input.artifacts.failExtension(input.context.worldId, input.context.operationId, "recovery_required", failure, completed);
|
|
64
64
|
return { outcomes, extension };
|
|
65
65
|
}
|
|
66
66
|
async function runOne(input, followup, prefix, requestSignal) {
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { captureNarrativeOrigin } from "../history/HistoryBackground.js";
|
|
2
|
-
import { FileNativePlayDocuments
|
|
2
|
+
import { FileNativePlayDocuments } from "./PlayDocumentTools.js";
|
|
3
3
|
import { currentPlayPrompt, playPromptRunsThroughEvents, } from "./PlayPromptRun.js";
|
|
4
4
|
import { isPlayerRoundMarker, playerInputAppend } from "./PlayContinuity.js";
|
|
5
5
|
import { PlayCallChainError } from "./PlayCallChainError.js";
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Split a context at a selected player message for an edit: the prefix before
|
|
8
|
+
* it is kept, and the replacement is appended once the world has rewound and
|
|
9
|
+
* committed it (PlayCallChain.revisePlayer).
|
|
9
10
|
*/
|
|
10
11
|
export function preparePlayerRevisionTrace(source, eventIndex) {
|
|
11
12
|
const prefix = structuredClone(source.events.slice(0, eventIndex));
|
|
12
13
|
const transcript = transcriptThroughEvents(source.transcript, prefix);
|
|
13
14
|
const player = source.events[eventIndex];
|
|
14
15
|
return {
|
|
15
|
-
// This append precedes the operation's materialization check, as before.
|
|
16
16
|
withReplacement(replacement) {
|
|
17
17
|
const events = [
|
|
18
18
|
...prefix,
|
|
@@ -46,7 +46,7 @@ export function preparePlayerRevisionTrace(source, eventIndex) {
|
|
|
46
46
|
pagePrefixAtEndpoint(endpoint) {
|
|
47
47
|
if (prefix.length === 0)
|
|
48
48
|
return null;
|
|
49
|
-
//
|
|
49
|
+
// A fresh context keeps the page before the edit as a detached prefix.
|
|
50
50
|
const prefixTranscript = transcriptThroughEvents(source.transcript, prefix);
|
|
51
51
|
return assembleRetainedTrace(source, endpoint, {
|
|
52
52
|
events: prefix,
|
|
@@ -119,7 +119,6 @@ function assembleRetainedTrace(source, endpoint, selection) {
|
|
|
119
119
|
completedTools: source.completedTools
|
|
120
120
|
.filter(({ key }) => completedKeys === null || completedKeys.has(key))
|
|
121
121
|
.map((item) => structuredClone(item)),
|
|
122
|
-
documentAuthorizationCheckpoints: authorizationCheckpointsThroughEvents(source, selection.events, documents.authorizationCheckpoint()),
|
|
123
122
|
changedDocuments: changedDocumentsAtHead(source.changedDocuments, endpoint.baseline.state, endpoint.selected.state),
|
|
124
123
|
nextMaterials: structuredClone(selection.materials),
|
|
125
124
|
nextEventId: Math.max(0, ...selection.events.map(({ id }) => id)) + 1,
|
|
@@ -132,29 +131,16 @@ function assembleRetainedTrace(source, endpoint, selection) {
|
|
|
132
131
|
export function restorePlayDocuments(files, context, events) {
|
|
133
132
|
const documents = new FileNativePlayDocuments(files);
|
|
134
133
|
try {
|
|
135
|
-
|
|
134
|
+
// Read authorizations are memory-only: a restored prefix always starts from
|
|
135
|
+
// its bootstrap, and stale write authority is caught by the commit's hash.
|
|
136
136
|
const runs = playPromptRunsThroughEvents(context, events);
|
|
137
|
-
|
|
138
|
-
documents.bindBootstrap(currentPlayPrompt({ ...context, promptRuns: runs }).bootstrap);
|
|
139
|
-
if (checkpoint !== undefined)
|
|
140
|
-
documents.restoreAuthorizationCheckpoint(checkpoint.authorization);
|
|
137
|
+
documents.bindBootstrap(currentPlayPrompt({ ...context, promptRuns: runs }).bootstrap);
|
|
141
138
|
}
|
|
142
139
|
catch (error) {
|
|
143
140
|
throw new PlayCallChainError(`Call-chain document authorization could not be restored: ${error instanceof Error ? error.message : "invalid checkpoint"}。`);
|
|
144
141
|
}
|
|
145
142
|
return documents;
|
|
146
143
|
}
|
|
147
|
-
function documentAuthorizationThroughEvents(context, events) {
|
|
148
|
-
const checkpoints = context.documentAuthorizationCheckpoints;
|
|
149
|
-
if (checkpoints === undefined || checkpoints.length === 0)
|
|
150
|
-
return undefined;
|
|
151
|
-
const selectedEventIds = new Set(events.map(({ id }) => id));
|
|
152
|
-
const selected = checkpoints.findLast(({ afterEventId }) => afterEventId === 0 || selectedEventIds.has(afterEventId));
|
|
153
|
-
// Legacy V1 records had no durable dynamic authorization. If the selected
|
|
154
|
-
// prefix predates their first lazily-written checkpoint, retain the exact
|
|
155
|
-
// old recovery behavior and rebuild only bootstrap authorization.
|
|
156
|
-
return selected === undefined ? undefined : structuredClone(selected);
|
|
157
|
-
}
|
|
158
144
|
export function independentContextCopy(source) {
|
|
159
145
|
const context = structuredClone(source);
|
|
160
146
|
context.continuityContextId ??= source.chainId;
|
|
@@ -162,20 +148,6 @@ export function independentContextCopy(source) {
|
|
|
162
148
|
delete context.branchedBeforePlayer;
|
|
163
149
|
return context;
|
|
164
150
|
}
|
|
165
|
-
function authorizationCheckpointsThroughEvents(context, events, fallback) {
|
|
166
|
-
const eventIds = new Set(events.map(({ id }) => id));
|
|
167
|
-
const selected = (context.documentAuthorizationCheckpoints ?? [])
|
|
168
|
-
.filter(({ afterEventId }) => afterEventId === 0 || eventIds.has(afterEventId))
|
|
169
|
-
.map((checkpoint) => structuredClone(checkpoint));
|
|
170
|
-
if (selected.length > 0)
|
|
171
|
-
return selected;
|
|
172
|
-
return [
|
|
173
|
-
{
|
|
174
|
-
afterEventId: Math.max(0, ...events.map(({ id }) => id)),
|
|
175
|
-
authorization: structuredClone(fallback),
|
|
176
|
-
},
|
|
177
|
-
];
|
|
178
|
-
}
|
|
179
151
|
function transcriptThroughEvents(transcript, events) {
|
|
180
152
|
const result = [];
|
|
181
153
|
let cursor = 0;
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { Ajv } from "ajv";
|
|
3
|
+
import { arithmeticTotal, d100Outcome, d20Outcome, isResolutionReceipt, percentileCandidate, } from "../../protocol/resolution.js";
|
|
4
|
+
export const resolutionToolNames = [
|
|
5
|
+
"d20_check",
|
|
6
|
+
"percentile_check",
|
|
7
|
+
"bag_check",
|
|
8
|
+
"roll_dice",
|
|
9
|
+
"calculate",
|
|
10
|
+
];
|
|
11
|
+
export function isResolutionTool(name) {
|
|
12
|
+
return resolutionToolNames.some((item) => item === name);
|
|
13
|
+
}
|
|
14
|
+
const checkToolByScheme = {
|
|
15
|
+
d20: "d20_check",
|
|
16
|
+
d100: "percentile_check",
|
|
17
|
+
bag: "bag_check",
|
|
18
|
+
};
|
|
19
|
+
/** The one check tool that embodies a tabletop scheme. Auxiliary tools never decide success. */
|
|
20
|
+
export function resolutionCheckToolForScheme(scheme) {
|
|
21
|
+
return checkToolByScheme[scheme];
|
|
22
|
+
}
|
|
23
|
+
const integer = (minimum, maximum) => ({
|
|
24
|
+
type: "integer",
|
|
25
|
+
minimum,
|
|
26
|
+
maximum,
|
|
27
|
+
});
|
|
28
|
+
const number = { type: "number", minimum: -1_000_000, maximum: 1_000_000 };
|
|
29
|
+
const goal = { type: "string", minLength: 1, maxLength: 1000, pattern: "\\S" };
|
|
30
|
+
const object = (properties, required) => ({
|
|
31
|
+
type: "object",
|
|
32
|
+
additionalProperties: false,
|
|
33
|
+
properties,
|
|
34
|
+
required,
|
|
35
|
+
});
|
|
36
|
+
const resolutionSchemas = {
|
|
37
|
+
d20_check: object({
|
|
38
|
+
goal,
|
|
39
|
+
modifier: integer(-1_000_000, 1_000_000),
|
|
40
|
+
difficulty: integer(-1_000_000, 1_000_000),
|
|
41
|
+
mode: { enum: ["normal", "advantage", "disadvantage"] },
|
|
42
|
+
}, ["goal", "modifier", "difficulty"]),
|
|
43
|
+
percentile_check: object({
|
|
44
|
+
goal,
|
|
45
|
+
target: integer(0, 100),
|
|
46
|
+
bonusDice: integer(0, 2),
|
|
47
|
+
penaltyDice: integer(0, 2),
|
|
48
|
+
}, ["goal", "target"]),
|
|
49
|
+
bag_check: object({
|
|
50
|
+
goal,
|
|
51
|
+
value: number,
|
|
52
|
+
difficulty: number,
|
|
53
|
+
tokens: {
|
|
54
|
+
type: "array",
|
|
55
|
+
minItems: 1,
|
|
56
|
+
maxItems: 100,
|
|
57
|
+
items: object({
|
|
58
|
+
label: { type: "string", minLength: 1, maxLength: 80 },
|
|
59
|
+
count: integer(1, 100),
|
|
60
|
+
modifier: number,
|
|
61
|
+
outcome: { enum: ["critical_success", "critical_failure"] },
|
|
62
|
+
}, ["label", "count"]),
|
|
63
|
+
},
|
|
64
|
+
}, ["goal", "value", "difficulty"]),
|
|
65
|
+
roll_dice: object({ goal, count: integer(1, 100), sides: integer(2, 1000), modifier: number }, ["goal", "count", "sides"]),
|
|
66
|
+
calculate: object({
|
|
67
|
+
goal,
|
|
68
|
+
left: number,
|
|
69
|
+
right: number,
|
|
70
|
+
operation: { enum: ["add", "subtract", "multiply", "divide"] },
|
|
71
|
+
}, ["goal", "left", "right", "operation"]),
|
|
72
|
+
};
|
|
73
|
+
const descriptions = {
|
|
74
|
+
d20_check: [
|
|
75
|
+
"掷 d20 判定。原始 20 大成功、1 大失败;其余取值加 modifier >= difficulty 成功。优势两骰取高,劣势取低。先确定目标和数值,结果必须兑现。",
|
|
76
|
+
"Resolve a d20 check. Selected raw 20 critically succeeds, 1 critically fails; otherwise roll + modifier >= difficulty succeeds. Advantage keeps the higher of two dice, disadvantage the lower. Declare the goal and values before rolling; honour the result.",
|
|
77
|
+
],
|
|
78
|
+
percentile_check: [
|
|
79
|
+
"百分骰判定。共享个位,奖励/惩罚额外十位骰相消后取较低/较高的完整结果,00 与个位0表示100。最终1大成功、100大失败,其余 <= target 成功。",
|
|
80
|
+
"Resolve a percentile check. Bonus/penalty tens dice cancel, share one units die, and select the lowest/highest complete result; 00 with units 0 means 100. Selected 1 critically succeeds, 100 critically fails; otherwise <= target succeeds.",
|
|
81
|
+
],
|
|
82
|
+
bag_check: [
|
|
83
|
+
"从当前标记清单按数量抽一枚并放回。数值 value + 标记修正 >= difficulty 成功;特殊标记覆盖成败。省略 tokens 使用默认袋(+1、0×2、-1×2、-2、大成功、大失败)。自定义袋先从世界文档读取,原样提供;每种标记必须恰好有 modifier 或 outcome。",
|
|
84
|
+
"Draw one token by quantity, with replacement. value + token modifier >= difficulty succeeds; a special outcome overrides comparison. Omit tokens for the default bag (+1, 0 x2, -1 x2, -2, critical success, critical failure). Read a custom bag from world documents and supply it exactly; every token has exactly one modifier or outcome.",
|
|
85
|
+
],
|
|
86
|
+
roll_dice: [
|
|
87
|
+
"辅助数值掷骰:count 枚 sides 面骰求和加 modifier。用于伤害、数量等,不重新裁决已确定的行动成败。",
|
|
88
|
+
"Roll auxiliary numeric dice: sum count dice of sides faces and add modifier. Use for damage or quantities, not to reverse a resolved action.",
|
|
89
|
+
],
|
|
90
|
+
calculate: [
|
|
91
|
+
"对两个数执行加减乘除,不产生随机数,不修改世界。",
|
|
92
|
+
"Add, subtract, multiply or divide two numbers without randomness or world changes.",
|
|
93
|
+
],
|
|
94
|
+
};
|
|
95
|
+
export function resolutionToolDefinition(name, locale) {
|
|
96
|
+
return {
|
|
97
|
+
name,
|
|
98
|
+
description: descriptions[name][locale === "zh-CN" ? 0 : 1],
|
|
99
|
+
inputSchema: structuredClone(resolutionSchemas[name]),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const ajv = new Ajv({ allErrors: true, strict: true });
|
|
103
|
+
const validators = Object.fromEntries(resolutionToolNames.map((name) => [
|
|
104
|
+
name,
|
|
105
|
+
ajv.compile(resolutionSchemas[name]),
|
|
106
|
+
]));
|
|
107
|
+
const defaultBagTokens = [
|
|
108
|
+
{ label: "+1", count: 1, modifier: 1 },
|
|
109
|
+
{ label: "0", count: 2, modifier: 0 },
|
|
110
|
+
{ label: "-1", count: 2, modifier: -1 },
|
|
111
|
+
{ label: "-2", count: 1, modifier: -2 },
|
|
112
|
+
{ label: "critical_success", count: 1, outcome: "critical_success" },
|
|
113
|
+
{ label: "critical_failure", count: 1, outcome: "critical_failure" },
|
|
114
|
+
];
|
|
115
|
+
/** Rejection sampling over a secret persisted seed; independent per tool call. */
|
|
116
|
+
export function seededRandom(seed, identity) {
|
|
117
|
+
let counter = 0;
|
|
118
|
+
return (maximum) => {
|
|
119
|
+
if (!Number.isSafeInteger(maximum) || maximum < 1 || maximum > 1_000_000)
|
|
120
|
+
throw new Error("Invalid random bound");
|
|
121
|
+
const limit = Math.floor(0x1_0000_0000 / maximum) * maximum;
|
|
122
|
+
for (;;) {
|
|
123
|
+
const value = createHmac("sha256", seed)
|
|
124
|
+
.update(JSON.stringify([identity, counter++]))
|
|
125
|
+
.digest()
|
|
126
|
+
.readUInt32BE();
|
|
127
|
+
if (value < limit)
|
|
128
|
+
return value % maximum;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** Resolve one validated tool call. Invalid arguments throw before any draw. */
|
|
133
|
+
export function resolveTabletop(name, args, random) {
|
|
134
|
+
if (!validators[name](args))
|
|
135
|
+
throw new Error(`Invalid ${name} parameters: ${ajv.errorsText(validators[name].errors)}`);
|
|
136
|
+
const input = args;
|
|
137
|
+
const receipt = name === "d20_check"
|
|
138
|
+
? resolveD20(input, random)
|
|
139
|
+
: name === "percentile_check"
|
|
140
|
+
? resolveD100(input, random)
|
|
141
|
+
: name === "bag_check"
|
|
142
|
+
? resolveBag(input, random)
|
|
143
|
+
: name === "roll_dice"
|
|
144
|
+
? resolveDice(input, random)
|
|
145
|
+
: resolveArithmetic(input);
|
|
146
|
+
if (!isResolutionReceipt(receipt))
|
|
147
|
+
throw new Error("Calculation produced an invalid resolution receipt");
|
|
148
|
+
return receipt;
|
|
149
|
+
}
|
|
150
|
+
function roll(random, sides) {
|
|
151
|
+
return random(sides) + 1;
|
|
152
|
+
}
|
|
153
|
+
function resolveD20(input, random) {
|
|
154
|
+
const mode = input.mode ?? "normal";
|
|
155
|
+
const modifier = input.modifier;
|
|
156
|
+
const difficulty = input.difficulty;
|
|
157
|
+
const dice = [roll(random, 20)];
|
|
158
|
+
if (mode !== "normal")
|
|
159
|
+
dice.push(roll(random, 20));
|
|
160
|
+
const selected = mode === "disadvantage" ? Math.min(...dice) : Math.max(...dice);
|
|
161
|
+
const total = selected + modifier;
|
|
162
|
+
return {
|
|
163
|
+
kind: "d20",
|
|
164
|
+
goal: input.goal,
|
|
165
|
+
modifier,
|
|
166
|
+
difficulty,
|
|
167
|
+
mode,
|
|
168
|
+
dice,
|
|
169
|
+
selected,
|
|
170
|
+
total,
|
|
171
|
+
margin: total - difficulty,
|
|
172
|
+
outcome: d20Outcome(selected, total, difficulty),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function resolveD100(input, random) {
|
|
176
|
+
const bonusDice = input.bonusDice ?? 0;
|
|
177
|
+
const penaltyDice = input.penaltyDice ?? 0;
|
|
178
|
+
const target = input.target;
|
|
179
|
+
const units = random(10);
|
|
180
|
+
const net = bonusDice - penaltyDice;
|
|
181
|
+
const tens = Array.from({ length: Math.abs(net) + 1 }, () => random(10));
|
|
182
|
+
const candidates = tens.map((ten) => percentileCandidate(ten, units));
|
|
183
|
+
const selected = net < 0 ? Math.max(...candidates) : Math.min(...candidates);
|
|
184
|
+
return {
|
|
185
|
+
kind: "d100",
|
|
186
|
+
goal: input.goal,
|
|
187
|
+
target,
|
|
188
|
+
bonusDice,
|
|
189
|
+
penaltyDice,
|
|
190
|
+
units,
|
|
191
|
+
tens,
|
|
192
|
+
candidates,
|
|
193
|
+
selected,
|
|
194
|
+
margin: target - selected,
|
|
195
|
+
outcome: d100Outcome(selected, target),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function resolveBag(input, random) {
|
|
199
|
+
const defaultBag = input.tokens === undefined;
|
|
200
|
+
const tokens = structuredClone(input.tokens ?? defaultBagTokens);
|
|
201
|
+
if (new Set(tokens.map((token) => token.label)).size !== tokens.length ||
|
|
202
|
+
tokens.some((token) => (token.modifier !== undefined) === (token.outcome !== undefined)))
|
|
203
|
+
throw new Error("Bag labels must be unique, with exactly one modifier or outcome per token");
|
|
204
|
+
const weight = tokens.reduce((sum, token) => sum + token.count, 0);
|
|
205
|
+
let index = random(weight);
|
|
206
|
+
const token = tokens.find((item) => {
|
|
207
|
+
index -= item.count;
|
|
208
|
+
return index < 0;
|
|
209
|
+
});
|
|
210
|
+
const value = input.value;
|
|
211
|
+
const difficulty = input.difficulty;
|
|
212
|
+
const base = {
|
|
213
|
+
kind: "bag",
|
|
214
|
+
goal: input.goal,
|
|
215
|
+
value,
|
|
216
|
+
difficulty,
|
|
217
|
+
defaultBag,
|
|
218
|
+
tokens,
|
|
219
|
+
};
|
|
220
|
+
if (token.outcome !== undefined)
|
|
221
|
+
return {
|
|
222
|
+
...base,
|
|
223
|
+
drawn: { label: token.label, outcome: token.outcome },
|
|
224
|
+
outcome: token.outcome,
|
|
225
|
+
};
|
|
226
|
+
const drawn = { label: token.label, modifier: token.modifier };
|
|
227
|
+
const total = value + token.modifier;
|
|
228
|
+
return {
|
|
229
|
+
...base,
|
|
230
|
+
drawn,
|
|
231
|
+
total,
|
|
232
|
+
margin: total - difficulty,
|
|
233
|
+
outcome: total >= difficulty ? "success" : "failure",
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function resolveDice(input, random) {
|
|
237
|
+
const count = input.count;
|
|
238
|
+
const sides = input.sides;
|
|
239
|
+
const modifier = input.modifier ?? 0;
|
|
240
|
+
const dice = Array.from({ length: count }, () => roll(random, sides));
|
|
241
|
+
return {
|
|
242
|
+
kind: "dice",
|
|
243
|
+
goal: input.goal,
|
|
244
|
+
count,
|
|
245
|
+
sides,
|
|
246
|
+
modifier,
|
|
247
|
+
dice,
|
|
248
|
+
total: dice.reduce((sum, die) => sum + die, modifier),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
function resolveArithmetic(input) {
|
|
252
|
+
const left = input.left;
|
|
253
|
+
const right = input.right;
|
|
254
|
+
const operation = input.operation;
|
|
255
|
+
if (operation === "divide" && right === 0)
|
|
256
|
+
throw new Error("Cannot divide by zero");
|
|
257
|
+
const total = arithmeticTotal(operation, left, right);
|
|
258
|
+
if (!Number.isFinite(total))
|
|
259
|
+
throw new Error("Calculation is not finite");
|
|
260
|
+
return {
|
|
261
|
+
kind: "arithmetic",
|
|
262
|
+
goal: input.goal,
|
|
263
|
+
left,
|
|
264
|
+
right,
|
|
265
|
+
operation,
|
|
266
|
+
total,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
const outcomeLabels = {
|
|
270
|
+
"zh-CN": {
|
|
271
|
+
success: "成功",
|
|
272
|
+
failure: "失败",
|
|
273
|
+
critical_success: "大成功",
|
|
274
|
+
critical_failure: "大失败",
|
|
275
|
+
},
|
|
276
|
+
en: {
|
|
277
|
+
success: "success",
|
|
278
|
+
failure: "failure",
|
|
279
|
+
critical_success: "critical success",
|
|
280
|
+
critical_failure: "critical failure",
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
/** Localized text the model receives as the tool result. */
|
|
284
|
+
export function resolutionMarkdown(receipt, locale) {
|
|
285
|
+
const zh = locale === "zh-CN";
|
|
286
|
+
const outcome = (value) => outcomeLabels[zh ? "zh-CN" : "en"][value];
|
|
287
|
+
const signed = (value) => (value >= 0 ? `+${value}` : `${value}`);
|
|
288
|
+
const list = (values) => values.join(", ");
|
|
289
|
+
const lines = [`# ${receipt.goal}`, ""];
|
|
290
|
+
if (receipt.kind === "d20") {
|
|
291
|
+
const modes = zh
|
|
292
|
+
? { normal: "普通", advantage: "优势", disadvantage: "劣势" }
|
|
293
|
+
: {
|
|
294
|
+
normal: "normal",
|
|
295
|
+
advantage: "advantage",
|
|
296
|
+
disadvantage: "disadvantage",
|
|
297
|
+
};
|
|
298
|
+
const compared = receipt.total >= receipt.difficulty ? ">=" : "<";
|
|
299
|
+
lines.push(zh
|
|
300
|
+
? `判定:d20,${modes[receipt.mode]}`
|
|
301
|
+
: `Check: d20, ${modes[receipt.mode]}`, zh
|
|
302
|
+
? `修正:${signed(receipt.modifier)};难度:${receipt.difficulty}`
|
|
303
|
+
: `Modifier: ${signed(receipt.modifier)}; difficulty: ${receipt.difficulty}`, zh
|
|
304
|
+
? `掷骰:${list(receipt.dice)};取值:${receipt.selected}`
|
|
305
|
+
: `Dice: ${list(receipt.dice)}; selected: ${receipt.selected}`, zh
|
|
306
|
+
? `计算:${receipt.selected} ${signed(receipt.modifier)} = ${receipt.total};${receipt.total} ${compared} ${receipt.difficulty};差值 ${signed(receipt.margin)}`
|
|
307
|
+
: `Calculation: ${receipt.selected} ${signed(receipt.modifier)} = ${receipt.total}; ${receipt.total} ${compared} ${receipt.difficulty}; margin ${signed(receipt.margin)}`);
|
|
308
|
+
}
|
|
309
|
+
else if (receipt.kind === "d100") {
|
|
310
|
+
const net = receipt.bonusDice - receipt.penaltyDice;
|
|
311
|
+
const pool = net === 0
|
|
312
|
+
? zh
|
|
313
|
+
? "无额外十位骰"
|
|
314
|
+
: "no extra tens die"
|
|
315
|
+
: net > 0
|
|
316
|
+
? zh
|
|
317
|
+
? `奖励骰 ${receipt.bonusDice}、惩罚骰 ${receipt.penaltyDice},相消后 ${net} 枚奖励骰,取低`
|
|
318
|
+
: `${receipt.bonusDice} bonus and ${receipt.penaltyDice} penalty dice, net ${net} bonus die; lowest kept`
|
|
319
|
+
: zh
|
|
320
|
+
? `奖励骰 ${receipt.bonusDice}、惩罚骰 ${receipt.penaltyDice},相消后 ${-net} 枚惩罚骰,取高`
|
|
321
|
+
: `${receipt.bonusDice} bonus and ${receipt.penaltyDice} penalty dice, net ${-net} penalty die; highest kept`;
|
|
322
|
+
lines.push(zh ? `目标:${receipt.target}` : `Target: ${receipt.target}`, zh ? `判定:d100,${pool}` : `Check: d100, ${pool}`, zh
|
|
323
|
+
? `个位:${receipt.units};十位:${list(receipt.tens)}`
|
|
324
|
+
: `Units: ${receipt.units}; tens: ${list(receipt.tens)}`, zh
|
|
325
|
+
? `完整结果:${list(receipt.candidates)};取值:${receipt.selected}`
|
|
326
|
+
: `Complete results: ${list(receipt.candidates)}; selected: ${receipt.selected}`, zh
|
|
327
|
+
? `计算:${receipt.selected} <= ${receipt.target};差值 ${signed(receipt.margin)}`
|
|
328
|
+
: `Calculation: ${receipt.selected} <= ${receipt.target}; margin ${signed(receipt.margin)}`);
|
|
329
|
+
}
|
|
330
|
+
else if (receipt.kind === "bag") {
|
|
331
|
+
const tokens = receipt.tokens
|
|
332
|
+
.map((token) => `${token.label} ×${token.count}`)
|
|
333
|
+
.join(zh ? "、" : ", ");
|
|
334
|
+
lines.push(zh
|
|
335
|
+
? `标记袋:${receipt.defaultBag ? "默认袋" : "自定义袋"}(${tokens})`
|
|
336
|
+
: `Bag: ${receipt.defaultBag ? "default" : "custom"} (${tokens})`, zh
|
|
337
|
+
? `抽取:${receipt.drawn.label}(有放回)`
|
|
338
|
+
: `Drawn: ${receipt.drawn.label} (with replacement)`);
|
|
339
|
+
if (receipt.drawn.outcome !== undefined)
|
|
340
|
+
lines.push(zh
|
|
341
|
+
? `特殊标记直接决定结果:${outcome(receipt.drawn.outcome)}`
|
|
342
|
+
: `Special token decides the result: ${outcome(receipt.drawn.outcome)}`);
|
|
343
|
+
else {
|
|
344
|
+
const compared = receipt.total >= receipt.difficulty ? ">=" : "<";
|
|
345
|
+
lines.push(zh
|
|
346
|
+
? `数值:${receipt.value};标记修正:${signed(receipt.drawn.modifier)};难度:${receipt.difficulty}`
|
|
347
|
+
: `Value: ${receipt.value}; token modifier: ${signed(receipt.drawn.modifier)}; difficulty: ${receipt.difficulty}`, zh
|
|
348
|
+
? `计算:${receipt.value} ${signed(receipt.drawn.modifier)} = ${receipt.total};${receipt.total} ${compared} ${receipt.difficulty};差值 ${signed(receipt.margin)}`
|
|
349
|
+
: `Calculation: ${receipt.value} ${signed(receipt.drawn.modifier)} = ${receipt.total}; ${receipt.total} ${compared} ${receipt.difficulty}; margin ${signed(receipt.margin)}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
else if (receipt.kind === "dice") {
|
|
353
|
+
lines.push(zh
|
|
354
|
+
? `掷骰:${receipt.count}d${receipt.sides},结果 ${list(receipt.dice)},修正 ${signed(receipt.modifier)}`
|
|
355
|
+
: `Dice: ${receipt.count}d${receipt.sides}, results ${list(receipt.dice)}, modifier ${signed(receipt.modifier)}`, zh ? `合计:${receipt.total}` : `Total: ${receipt.total}`);
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
lines.push(zh
|
|
359
|
+
? `计算:${receipt.left} ${arithmeticSymbol(receipt.operation)} ${receipt.right} = ${receipt.total}`
|
|
360
|
+
: `Calculation: ${receipt.left} ${arithmeticSymbol(receipt.operation)} ${receipt.right} = ${receipt.total}`);
|
|
361
|
+
}
|
|
362
|
+
if (receipt.kind === "d20" ||
|
|
363
|
+
receipt.kind === "d100" ||
|
|
364
|
+
receipt.kind === "bag")
|
|
365
|
+
lines.push("", zh
|
|
366
|
+
? `结果:${outcome(receipt.outcome)}`
|
|
367
|
+
: `Outcome: ${outcome(receipt.outcome)}`);
|
|
368
|
+
return lines.join("\n");
|
|
369
|
+
}
|
|
370
|
+
function arithmeticSymbol(operation) {
|
|
371
|
+
return { add: "+", subtract: "-", multiply: "×", divide: "÷" }[operation];
|
|
372
|
+
}
|