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,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
- bindBootstrap(bootstrap, previousAuthorization) {
60
- const injected = bootstrapAuthorizations(bootstrap, this.#candidate.snapshot);
61
- this.#reads = injected;
62
- // Refreshing a prefix retains the native conversation. Its exact reads
63
- // remain valid only on the identical state tree, including our own writes.
64
- if (previousAuthorization?.stateFingerprint ===
65
- fingerprintStateFiles(this.#candidate.files)) {
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, } from "./PlayDocumentTools.js";
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
- * Prepare the selected player's prefix at the existing pre-commit seam.
8
- * Endpoint reads, revision submission and publication belong to the operation.
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
- // Preserve the fresh path's post-commit prefix reconstruction seam.
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
- const checkpoint = documentAuthorizationThroughEvents(context, events);
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
- if (checkpoint === undefined || runs.length > 0)
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
+ }