narraeon 0.5.7 → 0.6.0

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 (36) hide show
  1. package/README.md +2 -0
  2. package/README.zh-CN.md +2 -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 -0
  6. package/dist/node/runtime/RuntimeErrorLog.js +95 -0
  7. package/dist/node/runtime/V1Runtime.js +57 -2
  8. package/dist/node/runtime/content/ContentWorkspace.js +10 -2
  9. package/dist/node/runtime/content/CurrentTreeContentLibrary.js +43 -2
  10. package/dist/node/runtime/content/FileNativeContentTree.js +8 -1
  11. package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +37 -1
  12. package/dist/node/runtime/play/FileNativePlayPresetStore.js +48 -3
  13. package/dist/node/runtime/play/FileNativePlayTimelineStore.js +7 -0
  14. package/dist/node/runtime/play/OrderedPlayPrompts.js +68 -6
  15. package/dist/node/runtime/play/PlayCallChain.js +33 -5
  16. package/dist/node/runtime/play/TabletopResolution.js +372 -0
  17. package/dist/node/runtime/play/TabletopRules.js +141 -0
  18. package/dist/node/runtime/prompt/FileNativePromptCompiler.js +45 -10
  19. package/dist/node/runtime/prompt/FileNativeToolRegistry.js +35 -2
  20. package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +4 -1
  21. package/dist/node/runtime/setting/SettingAuthoringTransaction.js +56 -13
  22. package/dist/node/runtime/setting/SettingImprovementSession.js +6 -4
  23. package/dist/node/runtime/world/FileNativeWorldStore.js +36 -2
  24. package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -0
  25. package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +35 -6
  26. package/dist/node/runtime/world-revision/WorldRevisionSession.js +9 -5
  27. package/dist/node/server/ConversationStream.js +2 -0
  28. package/dist/node/shared/ordered-author-prompts.js +8 -0
  29. package/dist/node/shared/ordered-play-prompts.js +23 -0
  30. package/dist/node/shared/tabletop-guidance.js +100 -0
  31. package/dist/web/assets/index-BJ-1Mb0e.css +1 -0
  32. package/dist/web/assets/index-BgBL8G-A.js +1323 -0
  33. package/dist/web/index.html +2 -2
  34. package/package.json +1 -1
  35. package/dist/web/assets/index-DxNtxzLR.js +0 -1255
  36. package/dist/web/assets/index-DzOnu9uv.css +0 -1
@@ -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
+ }
@@ -0,0 +1,141 @@
1
+ import { gameplayLabel } from "../../protocol/gameplay.js";
2
+ import { resolutionCheckToolForScheme, resolutionToolDefinition, } from "./TabletopResolution.js";
3
+ /**
4
+ * The scheme's fixed rules, taken from the same description the check tool
5
+ * sends to the model, so the prompt and the tool cannot drift apart.
6
+ */
7
+ export function tabletopSchemeRules(gameplay, locale) {
8
+ return resolutionToolDefinition(resolutionCheckToolForScheme(gameplay.scheme), locale).description;
9
+ }
10
+ /**
11
+ * Runtime-owned rules at the world placeholder of a tabletop world. The block
12
+ * is world-derived and carries no preset state, so a scheme never requires a
13
+ * preset change.
14
+ */
15
+ export function tabletopWorldRulesBlock(gameplay, locale) {
16
+ const label = gameplayLabel(gameplay, locale);
17
+ const rules = tabletopSchemeRules(gameplay, locale);
18
+ // Only the bag scheme has a world-defined token list; other schemes never mention one.
19
+ const bagLine = gameplay.scheme !== "bag"
20
+ ? ""
21
+ : locale === "zh-CN"
22
+ ? "- 世界文档中定义的混乱袋必须读取,并原样提供其当前标记清单;袋的变化由你在世界文档中维护。\n"
23
+ : "- A chaos bag defined in the world documents must be read and supplied exactly as its current token list. You maintain the bag's changes in the world documents.\n";
24
+ const markdown = locale === "zh-CN"
25
+ ? `# 跑团判定规则
26
+
27
+ 本世界是跑团世界,判定方案为 ${label}。以下规则由 Runtime 固定,随本世界的玩法身份生效。
28
+
29
+ ## 判定工具
30
+
31
+ ${rules}
32
+
33
+ ## 辅助工具
34
+
35
+ - roll_dice:辅助数值掷骰,用于伤害、数量等,不重新裁决已确定的行动成败。
36
+ - calculate:对两个数执行加减乘除,不产生随机数,不修改世界。
37
+
38
+ ## 绑定契约
39
+
40
+ - 调用判定工具前,先声明行动目标、所用数值与难度;只有有成败不确定性或对抗的行动才需要判定。
41
+ - 必须先调用判定工具并取得回执,再叙述结果。
42
+ - 判定成功必须实现已声明的目标,失败则不实现,不论情节是否看似合理。
43
+ - 大成功与大失败按工具的固定规则兑现,不能由叙述改写。
44
+ - 掷骰之后不得更改该行动的目标或难度,也不得为同一行动重掷。
45
+ - 辅助掷骰与算术只决定伤害、数量等数值,不能推翻已经结算的判定。
46
+ ${bagLine}- 参数不合法属于工具错误,不等于行动失败;修正参数后重新调用即可。`
47
+ : `# Tabletop resolution rules
48
+
49
+ This world is tabletop, with resolution scheme ${label}. The Runtime-fixed rules below apply because of this world's gameplay identity.
50
+
51
+ ## Check tool
52
+
53
+ ${rules}
54
+
55
+ ## Auxiliary tools
56
+
57
+ - roll_dice: auxiliary numeric dice for damage, quantities and similar values. It never re-decides a resolved action.
58
+ - calculate: add, subtract, multiply or divide two numbers. It draws no randomness and changes no world state.
59
+
60
+ ## Binding contract
61
+
62
+ - Before calling the check tool, declare the action's goal, the inputs and the difficulty. Only actions with uncertain success or opposition need a check.
63
+ - Call the check tool and receive its receipt before narrating the outcome.
64
+ - A success must fulfil the declared goal and a failure must not, regardless of whether the story seems plausible.
65
+ - Critical outcomes follow the tool's fixed rules and cannot be rewritten by narration.
66
+ - After a draw, never change that action's goal or difficulty, and never reroll the same action.
67
+ - Auxiliary dice and arithmetic decide numeric values such as damage or quantities; they never reverse a resolved check.
68
+ ${bagLine}- Invalid arguments are tool errors, not failed actions; correct the arguments and call the tool again.`;
69
+ return { source: "runtime:world-placeholder/gameplay", markdown };
70
+ }
71
+ /**
72
+ * Author-side statement of a tabletop package or world revision: the mode and
73
+ * scheme, what a write may change, and the scheme's rules.
74
+ */
75
+ export function tabletopAuthoringRulesBlock(gameplay, target, locale) {
76
+ const label = gameplayLabel(gameplay, locale);
77
+ const rules = tabletopSchemeRules(gameplay, locale);
78
+ const source = target === "setting"
79
+ ? "runtime:content-package/gameplay"
80
+ : "runtime:world-revision/gameplay";
81
+ const markdown = target === "setting"
82
+ ? locale === "zh-CN"
83
+ ? `# 玩法模式与判定方案
84
+
85
+ 当前内容包是跑团内容包,当前判定方案为 ${label}。玩法模式固定为跑团,不能改为互动式小说。判定方案可通过写入 control/gameplay.json 改为 d20、d100 或 bag;这只影响之后创建的世界,已有世界不随源内容包变化。改变方案时,应同步调整人物数值、难度与规则,使其符合新方案的数值尺度。
86
+
87
+ 当前方案的判定工具规则:
88
+
89
+ ${rules}`
90
+ : `# Gameplay mode and resolution scheme
91
+
92
+ This content package is tabletop, with current resolution scheme ${label}. The gameplay mode is fixed as tabletop and cannot become interactive fiction. The scheme may be changed by writing control/gameplay.json to d20, d100 or bag. The change affects only worlds created later; existing worlds never follow the source package. When changing the scheme, adjust character values, difficulty and rules so they match the new scheme's numeric scale.
93
+
94
+ Check-tool rules for the current scheme:
95
+
96
+ ${rules}`
97
+ : locale === "zh-CN"
98
+ ? `# 玩法模式与判定方案(固定)
99
+
100
+ 当前修订的世界是跑团世界,判定方案为 ${label}。玩法模式与判定方案在世界创建后固定,修订不能修改 control/gameplay.json。数值与难度须遵循该方案。
101
+
102
+ 当前方案的判定工具规则:
103
+
104
+ ${rules}`
105
+ : `# Gameplay mode and resolution scheme (fixed)
106
+
107
+ The world under revision is tabletop, with resolution scheme ${label}. Its gameplay mode and scheme were fixed at creation; a revision cannot change control/gameplay.json. Numeric values and difficulty must follow this scheme.
108
+
109
+ Check-tool rules for the current scheme:
110
+
111
+ ${rules}`;
112
+ const bag = gameplay.scheme === "bag" ? `\n\n${bagAuthoringGuidance(locale)}` : "";
113
+ return { source, markdown: `${markdown}${bag}` };
114
+ }
115
+ /**
116
+ * Authoring guidance for a chaos-bag scheme: the default bag, or a bag the world
117
+ * documents, and who maintains its contents. Only the bag scheme carries it.
118
+ */
119
+ export function bagAuthoringGuidance(locale) {
120
+ return locale === "zh-CN"
121
+ ? `## 混乱袋内容
122
+
123
+ - 默认袋:世界文档未定义标记时,判定使用默认袋:+1、0×2、-1×2、-2、大成功、大失败。
124
+ - 世界自定义袋:在世界文档中写明每种标记的名称与数量,并且每种标记恰好写明修正值或大成功/大失败之一。判定时必须读取该清单,并原样提供。
125
+ - 袋内容的变化由你在世界文档中维护。每次抽取后标记放回,抽取本身不改变袋。`
126
+ : `## Chaos-bag contents
127
+
128
+ - Default bag: when the world documents define no tokens, the check uses the default bag: +1, 0 ×2, -1 ×2, -2, critical success, critical failure.
129
+ - World-documented bag: write each token's label and count in the world documents, and give each token exactly one of a modifier or a critical outcome. Read that list and supply it exactly when checking.
130
+ - You maintain any change to the bag's contents in the world documents. Each drawn token returns to the bag, so drawing never changes the bag itself.`;
131
+ }
132
+ /** The receipt sent after a successful gameplay write; it teaches the new scheme's rules mid-conversation. */
133
+ export function tabletopSchemeReceipt(gameplay, locale) {
134
+ const rules = tabletopSchemeRules(gameplay, locale);
135
+ const receipt = locale === "zh-CN"
136
+ ? `新判定方案规则(之后请按此调整数值与难度):\n\n${rules}`
137
+ : `New resolution-scheme rules (adjust numbers and difficulty to match from now on):\n\n${rules}`;
138
+ return gameplay.scheme === "bag"
139
+ ? `${receipt}\n\n${bagAuthoringGuidance(locale)}`
140
+ : receipt;
141
+ }