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
package/README.md CHANGED
@@ -163,6 +163,8 @@ By default, the browser talks only to the same-machine Runtime. Local-first does
163
163
 
164
164
  When a provider, response-format, Runtime-tool, or candidate-check error occurs, Narraeon creates a JSONL incident under `NARRAEON_LOG_ROOT/ai-failures`. It preserves the raw provider exchange and later recovery attempts, including reasoning or thinking actually returned by the provider. It does not invent hidden reasoning or record API keys and request headers. The files can still contain private prompts, world content, tool arguments, and provider-returned reasoning, so treat the log directory as sensitive.
165
165
 
166
+ Every Runtime request that fails, every play call that ends interrupted, and every failed live conversation sync is also appended to `NARRAEON_LOG_ROOT/runtime-errors/<UTC date>.jsonl` with the request type, its identifiers (world, context, event, and operation IDs), and the error message, code, stack, and cause chain. Player and world text from the request is not recorded. Include the matching line when reporting a bug.
167
+
166
168
  ## Development
167
169
 
168
170
  ```bash
package/README.zh-CN.md CHANGED
@@ -153,6 +153,8 @@ Narraeon 默认使用当前操作系统的标准用户应用目录。如果要
153
153
 
154
154
  发生 Provider、响应格式、Runtime 工具或候选检查错误时,Narraeon 会在 `NARRAEON_LOG_ROOT/ai-failures` 下创建 JSONL 事故记录。它会保存原始 Provider 交换和后续恢复尝试,包括 Provider 真实返回的 reasoning/thinking。它不会伪造隐藏思考,也不会记录 API Key 和请求 header。不过日志仍可能包含私密提示词、世界内容、工具参数和 Provider 返回的思考,因此应当把该目录视为敏感数据。
155
155
 
156
+ 每个失败的 Runtime 请求、每次以中断结束的游玩调用、每次失败的对话实时同步,也会追加到 `NARRAEON_LOG_ROOT/runtime-errors/<UTC 日期>.jsonl`,记录请求类型、请求中的标识(世界、上下文、事件和操作 ID)以及错误信息、错误码、调用栈和 cause 链,不记录请求里的玩家原文和世界内容。反馈问题时附上对应的那一行即可。
157
+
156
158
  ## 仓库开发
157
159
 
158
160
  ```bash
@@ -0,0 +1,69 @@
1
+ export const resolutionSchemes = ["d20", "d100", "bag"];
2
+ export const fictionGameplay = { mode: "interactive-fiction" };
3
+ export const gameplayPath = "control/gameplay.json";
4
+ const gameplayFormat = "narraeon.gameplay/v1";
5
+ export function parseGameplay(value) {
6
+ if (typeof value !== "object" || value === null || Array.isArray(value))
7
+ throw new Error("Invalid gameplay choice / 玩法选择无效");
8
+ const record = value;
9
+ if (record.mode === "interactive-fiction" && Object.keys(record).length === 1)
10
+ return { mode: "interactive-fiction" };
11
+ if (record.mode === "tabletop" &&
12
+ Object.keys(record).every((key) => key === "mode" || key === "scheme") &&
13
+ resolutionSchemes.some((scheme) => scheme === record.scheme))
14
+ return { mode: "tabletop", scheme: record.scheme };
15
+ throw new Error("Unknown gameplay type or resolution scheme / 未知的玩法类型或判定方案");
16
+ }
17
+ /** A missing file is interactive fiction, so existing packages need no migration. */
18
+ export function readGameplay(files) {
19
+ const matches = files.filter((file) => file.path.toLowerCase() === gameplayPath);
20
+ if (matches.length === 0)
21
+ return { ...fictionGameplay };
22
+ const file = matches[0];
23
+ if (matches.length !== 1 ||
24
+ file.path !== gameplayPath ||
25
+ file.encoding !== undefined)
26
+ throw new Error("Gameplay identity must be canonical UTF-8 control/gameplay.json / 玩法身份必须是规范的 UTF-8 control/gameplay.json");
27
+ let value;
28
+ try {
29
+ value = JSON.parse(file.contents);
30
+ }
31
+ catch (error) {
32
+ throw new Error("Gameplay identity must be valid JSON / 玩法文件必须是合法 JSON", { cause: error });
33
+ }
34
+ if (typeof value !== "object" || value === null || Array.isArray(value))
35
+ throw new Error("Invalid gameplay identity / 玩法文件无效");
36
+ const { format, ...gameplay } = value;
37
+ if (format !== gameplayFormat)
38
+ throw new Error("Unknown gameplay format / 未知的玩法文件格式");
39
+ return parseGameplay(gameplay);
40
+ }
41
+ export function gameplayFile(gameplay) {
42
+ return {
43
+ path: gameplayPath,
44
+ contents: `${JSON.stringify({ format: gameplayFormat, ...parseGameplay(gameplay) }, null, 2)}\n`,
45
+ };
46
+ }
47
+ /** Content authoring may change the resolution scheme but never the gameplay mode. */
48
+ export function assertSameGameplayMode(before, after) {
49
+ if (readGameplay(before).mode !== readGameplay(after).mode)
50
+ throw new Error("Gameplay mode is fixed / 玩法模式不能在互动式小说与跑团之间切换");
51
+ }
52
+ /** A world keeps both its gameplay mode and its resolution scheme. */
53
+ export function assertSameGameplay(before, after) {
54
+ if (gameplayKey(readGameplay(before)) !== gameplayKey(readGameplay(after)))
55
+ throw new Error("Gameplay mode and resolution scheme are fixed / 玩法模式与判定方案固定,不能修改");
56
+ }
57
+ export function gameplayLabel(gameplay, locale) {
58
+ if (gameplay.mode !== "tabletop")
59
+ return locale === "zh-CN" ? "互动式小说" : "Interactive fiction";
60
+ const scheme = gameplay.scheme === "bag"
61
+ ? locale === "zh-CN"
62
+ ? "混乱袋"
63
+ : "Chaos bag"
64
+ : gameplay.scheme;
65
+ return locale === "zh-CN" ? `跑团 · ${scheme}` : `Tabletop · ${scheme}`;
66
+ }
67
+ function gameplayKey(gameplay) {
68
+ return gameplay.mode === "tabletop" ? gameplay.scheme : gameplay.mode;
69
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Structured mechanical receipts for tabletop resolution tools.
3
+ *
4
+ * Receipts store numbers and identifiers only. Localized prose is rendered by
5
+ * Runtime for the model and by the UI from these fields, never stored as text.
6
+ */
7
+ /** Shared Narraeon v1 rules; the engine and the receipt validator both use them. */
8
+ export function d20Outcome(selected, total, difficulty) {
9
+ if (selected === 20)
10
+ return "critical_success";
11
+ if (selected === 1)
12
+ return "critical_failure";
13
+ return total >= difficulty ? "success" : "failure";
14
+ }
15
+ export function arithmeticTotal(operation, left, right) {
16
+ switch (operation) {
17
+ case "add":
18
+ return left + right;
19
+ case "subtract":
20
+ return left - right;
21
+ case "multiply":
22
+ return left * right;
23
+ case "divide":
24
+ return left / right;
25
+ }
26
+ }
27
+ export function percentileCandidate(tens, units) {
28
+ return tens * 10 + units || 100;
29
+ }
30
+ export function d100Outcome(selected, target) {
31
+ if (selected === 1)
32
+ return "critical_success";
33
+ if (selected === 100)
34
+ return "critical_failure";
35
+ return selected <= target ? "success" : "failure";
36
+ }
37
+ const maximumMagnitude = 1_000_000;
38
+ const maximumGoalLength = 1000;
39
+ const maximumLabelLength = 80;
40
+ const maximumBagTokens = 100;
41
+ const outcomes = [
42
+ "success",
43
+ "failure",
44
+ "critical_success",
45
+ "critical_failure",
46
+ ];
47
+ const criticalOutcomes = [
48
+ "critical_success",
49
+ "critical_failure",
50
+ ];
51
+ const operations = ["add", "subtract", "multiply", "divide"];
52
+ const modes = ["normal", "advantage", "disadvantage"];
53
+ /** Strict structural and arithmetic validation for a persisted or received receipt. */
54
+ export function isResolutionReceipt(value) {
55
+ if (!isRecord(value))
56
+ return false;
57
+ switch (value.kind) {
58
+ case "d20":
59
+ return isD20Receipt(value);
60
+ case "d100":
61
+ return isD100Receipt(value);
62
+ case "bag":
63
+ return isBagReceipt(value);
64
+ case "dice":
65
+ return isDiceReceipt(value);
66
+ case "arithmetic":
67
+ return isArithmeticReceipt(value);
68
+ default:
69
+ return false;
70
+ }
71
+ }
72
+ function isD20Receipt(value) {
73
+ if (!hasShape(value, [
74
+ "kind",
75
+ "goal",
76
+ "modifier",
77
+ "difficulty",
78
+ "mode",
79
+ "dice",
80
+ "selected",
81
+ "total",
82
+ "margin",
83
+ "outcome",
84
+ ], []) ||
85
+ !isGoal(value.goal) ||
86
+ !isInteger(value.modifier, -maximumMagnitude, maximumMagnitude) ||
87
+ !isInteger(value.difficulty, -maximumMagnitude, maximumMagnitude) ||
88
+ typeof value.mode !== "string" ||
89
+ !modes.includes(value.mode) ||
90
+ !Array.isArray(value.dice) ||
91
+ !value.dice.every((die) => isInteger(die, 1, 20)) ||
92
+ value.dice.length !== (value.mode === "normal" ? 1 : 2) ||
93
+ !isOutcome(value.outcome))
94
+ return false;
95
+ const dice = value.dice;
96
+ const selected = value.mode === "disadvantage" ? Math.min(...dice) : Math.max(...dice);
97
+ const modifier = value.modifier;
98
+ const difficulty = value.difficulty;
99
+ const total = selected + modifier;
100
+ return (value.selected === selected &&
101
+ value.total === total &&
102
+ value.margin === total - difficulty &&
103
+ value.outcome === d20Outcome(selected, total, difficulty));
104
+ }
105
+ function isD100Receipt(value) {
106
+ if (!hasShape(value, [
107
+ "kind",
108
+ "goal",
109
+ "target",
110
+ "bonusDice",
111
+ "penaltyDice",
112
+ "units",
113
+ "tens",
114
+ "candidates",
115
+ "selected",
116
+ "margin",
117
+ "outcome",
118
+ ], []) ||
119
+ !isGoal(value.goal) ||
120
+ !isInteger(value.target, 0, 100) ||
121
+ !isInteger(value.bonusDice, 0, 2) ||
122
+ !isInteger(value.penaltyDice, 0, 2) ||
123
+ !isInteger(value.units, 0, 9) ||
124
+ !Array.isArray(value.tens) ||
125
+ !value.tens.every((ten) => isInteger(ten, 0, 9)) ||
126
+ !Array.isArray(value.candidates) ||
127
+ !isOutcome(value.outcome))
128
+ return false;
129
+ const bonus = value.bonusDice;
130
+ const penalty = value.penaltyDice;
131
+ const units = value.units;
132
+ const tens = value.tens;
133
+ const candidates = value.candidates;
134
+ if (tens.length !== Math.abs(bonus - penalty) + 1)
135
+ return false;
136
+ if (candidates.length !== tens.length)
137
+ return false;
138
+ const expected = tens.map((ten) => percentileCandidate(ten, units));
139
+ if (!candidates.every((item, index) => item === expected[index]))
140
+ return false;
141
+ const selected = bonus < penalty ? Math.max(...expected) : Math.min(...expected);
142
+ const target = value.target;
143
+ return (value.selected === selected &&
144
+ value.margin === target - selected &&
145
+ value.outcome === d100Outcome(selected, target));
146
+ }
147
+ function isBagReceipt(value) {
148
+ if (!hasShape(value, [
149
+ "kind",
150
+ "goal",
151
+ "value",
152
+ "difficulty",
153
+ "defaultBag",
154
+ "tokens",
155
+ "drawn",
156
+ "outcome",
157
+ ], ["total", "margin"]) ||
158
+ !isGoal(value.goal) ||
159
+ !isNumber(value.value) ||
160
+ !isNumber(value.difficulty) ||
161
+ typeof value.defaultBag !== "boolean" ||
162
+ !Array.isArray(value.tokens) ||
163
+ value.tokens.length < 1 ||
164
+ value.tokens.length > maximumBagTokens ||
165
+ !value.tokens.every(isBagToken) ||
166
+ !isOutcome(value.outcome))
167
+ return false;
168
+ const tokens = value.tokens;
169
+ if (new Set(tokens.map((token) => token.label)).size !== tokens.length)
170
+ return false;
171
+ if (!isBagDraw(value.drawn))
172
+ return false;
173
+ const drawn = value.drawn;
174
+ const token = tokens.find((item) => item.label === drawn.label);
175
+ if (token === undefined ||
176
+ token.modifier !== drawn.modifier ||
177
+ token.outcome !== drawn.outcome)
178
+ return false;
179
+ if (drawn.outcome !== undefined)
180
+ return (value.total === undefined &&
181
+ value.margin === undefined &&
182
+ value.outcome === drawn.outcome);
183
+ if (drawn.modifier === undefined ||
184
+ !isNumber(value.total) ||
185
+ !isNumber(value.margin))
186
+ return false;
187
+ const difficulty = value.difficulty;
188
+ const total = value.value + drawn.modifier;
189
+ return (value.total === total &&
190
+ value.margin === total - difficulty &&
191
+ value.outcome === (total >= difficulty ? "success" : "failure"));
192
+ }
193
+ function isDiceReceipt(value) {
194
+ if (!hasShape(value, ["kind", "goal", "count", "sides", "modifier", "dice", "total"], []) ||
195
+ !isGoal(value.goal) ||
196
+ !isInteger(value.count, 1, 100) ||
197
+ !isInteger(value.sides, 2, 1000) ||
198
+ !isNumber(value.modifier) ||
199
+ !Array.isArray(value.dice) ||
200
+ value.dice.length !== value.count ||
201
+ !value.dice.every((die) => isInteger(die, 1, value.sides)))
202
+ return false;
203
+ const dice = value.dice;
204
+ return value.total === dice.reduce((sum, die) => sum + die, value.modifier);
205
+ }
206
+ function isArithmeticReceipt(value) {
207
+ if (!hasShape(value, ["kind", "goal", "left", "right", "operation", "total"], []) ||
208
+ !isGoal(value.goal) ||
209
+ !isNumber(value.left) ||
210
+ !isNumber(value.right) ||
211
+ typeof value.operation !== "string" ||
212
+ !operations.includes(value.operation))
213
+ return false;
214
+ const left = value.left;
215
+ const right = value.right;
216
+ if (value.operation === "divide" && right === 0)
217
+ return false;
218
+ const total = arithmeticTotal(value.operation, left, right);
219
+ return Number.isFinite(total) && value.total === total;
220
+ }
221
+ function isBagToken(value) {
222
+ return (isRecord(value) &&
223
+ hasShape(value, ["label", "count"], ["modifier", "outcome"]) &&
224
+ isLabel(value.label) &&
225
+ isInteger(value.count, 1, 100) &&
226
+ exactlyOneOf(value) &&
227
+ (value.modifier === undefined || isNumber(value.modifier)) &&
228
+ (value.outcome === undefined || isCriticalOutcome(value.outcome)));
229
+ }
230
+ function isBagDraw(value) {
231
+ return (isRecord(value) &&
232
+ hasShape(value, ["label"], ["modifier", "outcome"]) &&
233
+ isLabel(value.label) &&
234
+ exactlyOneOf(value) &&
235
+ (value.modifier === undefined || isNumber(value.modifier)) &&
236
+ (value.outcome === undefined || isCriticalOutcome(value.outcome)));
237
+ }
238
+ function exactlyOneOf(value) {
239
+ return (value.modifier === undefined) !== (value.outcome === undefined);
240
+ }
241
+ function hasShape(value, required, optional) {
242
+ return (required.every((key) => value[key] !== undefined) &&
243
+ Object.entries(value).every(([key, item]) => item !== undefined &&
244
+ (required.includes(key) || optional.includes(key))));
245
+ }
246
+ function isRecord(value) {
247
+ return typeof value === "object" && value !== null && !Array.isArray(value);
248
+ }
249
+ function isNumber(value) {
250
+ return (typeof value === "number" &&
251
+ Number.isFinite(value) &&
252
+ Math.abs(value) <= maximumMagnitude);
253
+ }
254
+ function isInteger(value, minimum, maximum) {
255
+ return (Number.isSafeInteger(value) &&
256
+ value >= minimum &&
257
+ value <= maximum);
258
+ }
259
+ function isGoal(value) {
260
+ return (typeof value === "string" &&
261
+ /\S/.test(value) &&
262
+ Array.from(value).length <= maximumGoalLength);
263
+ }
264
+ function isLabel(value) {
265
+ return (typeof value === "string" &&
266
+ value.length > 0 &&
267
+ Array.from(value).length <= maximumLabelLength);
268
+ }
269
+ function isOutcome(value) {
270
+ return typeof value === "string" && outcomes.includes(value);
271
+ }
272
+ function isCriticalOutcome(value) {
273
+ return typeof value === "string" && criticalOutcomes.includes(value);
274
+ }
@@ -1,3 +1,4 @@
1
+ import { parseGameplay, resolutionSchemes, } from "./gameplay.js";
1
2
  import { isAppReadingPreferences, } from "./appPreferences.js";
2
3
  import { maxPortableContentArchiveBase64Characters } from "./contentTree.js";
3
4
  export const v1Protocol = "narraeon.runtime/v1";
@@ -34,6 +35,7 @@ const requiredFields = {
34
35
  "content.scripts.set": { packageId: "string", enabled: "boolean" },
35
36
  "world.package-scripts.read": { worldId: "string" },
36
37
  "world.package-scripts.set": { worldId: "string", enabled: "boolean" },
38
+ "content.gameplay.set": { packageId: "string", scheme: "string" },
37
39
  "content.read": { packageId: "string" },
38
40
  "content.replace": { packageId: "string", files: "array" },
39
41
  "content.copy": { packageId: "string" },
@@ -235,6 +237,18 @@ function validateRequestFields(request) {
235
237
  !isAppReadingPreferences(request.reading))
236
238
  throw new V1ProtocolError("invalid_request", "preferences.save.reading is invalid");
237
239
  }
240
+ if (request.type === "content.create" && request.gameplay !== undefined) {
241
+ try {
242
+ parseGameplay(request.gameplay);
243
+ }
244
+ catch (error) {
245
+ throw new V1ProtocolError("invalid_request", "content.create.gameplay is invalid", { cause: error });
246
+ }
247
+ }
248
+ if (request.type === "content.gameplay.set") {
249
+ if (!resolutionSchemes.some((scheme) => scheme === request.scheme))
250
+ throw new V1ProtocolError("invalid_request", "content.gameplay.set.scheme is invalid");
251
+ }
238
252
  if (request.type === "content.import") {
239
253
  const archiveBase64 = request.archiveBase64;
240
254
  if (archiveBase64.length > maxPortableContentArchiveBase64Characters ||
@@ -387,6 +401,7 @@ const requestTypes = new Set([
387
401
  "model.delete",
388
402
  "model.models",
389
403
  "content.create",
404
+ "content.gameplay.set",
390
405
  "content.create-sample",
391
406
  "content.scripts.read",
392
407
  "content.scripts.set",
@@ -0,0 +1,95 @@
1
+ import { appendFile, chmod, mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ export const runtimeErrorLogFormat = "narraeon.runtime-error-log/v1";
4
+ /**
5
+ * Append-only diagnostics for Runtime requests that fail or leave a play call
6
+ * interrupted. One JSONL file per UTC day; recording never changes the
7
+ * request's own outcome.
8
+ */
9
+ export class FileNativeRuntimeErrorLog {
10
+ #root;
11
+ constructor(root) {
12
+ this.#root = root;
13
+ }
14
+ async recordRequestFailure(request, error) {
15
+ const [outer, ...causes] = describeErrorChain(error);
16
+ await this.#append({
17
+ kind: "request_failed",
18
+ request,
19
+ error: outer,
20
+ ...(causes.length === 0 ? {} : { causes }),
21
+ });
22
+ }
23
+ async recordPlayInterruption(request, failure) {
24
+ await this.#append({
25
+ kind: "play_interrupted",
26
+ request,
27
+ error: {
28
+ name: "PlayCallInterrupted",
29
+ message: failure.message,
30
+ ...(failure.kind === undefined ? {} : { code: failure.kind }),
31
+ },
32
+ });
33
+ }
34
+ async #append(input) {
35
+ try {
36
+ const recordedAt = new Date().toISOString();
37
+ const record = {
38
+ format: runtimeErrorLogFormat,
39
+ recordedAt,
40
+ kind: input.kind,
41
+ requestType: requestType(input.request),
42
+ request: requestIdentifiers(input.request),
43
+ error: input.error,
44
+ ...(input.causes === undefined ? {} : { causes: input.causes }),
45
+ };
46
+ await mkdir(this.#root, { recursive: true, mode: 0o700 });
47
+ await chmod(this.#root, 0o700);
48
+ const path = join(this.#root, `${recordedAt.slice(0, 10)}.jsonl`);
49
+ await appendFile(path, `${JSON.stringify(record)}\n`, {
50
+ encoding: "utf8",
51
+ mode: 0o600,
52
+ });
53
+ await chmod(path, 0o600);
54
+ }
55
+ catch {
56
+ // Diagnostics must never change Runtime request semantics.
57
+ }
58
+ }
59
+ }
60
+ function requestType(request) {
61
+ const type = request.type;
62
+ return typeof type === "string" ? type : "unknown";
63
+ }
64
+ function requestIdentifiers(request) {
65
+ const identifiers = {};
66
+ for (const [key, value] of Object.entries(request)) {
67
+ if (!/(?:Id|Head|^continuation|^context|^format|^limit)$/u.test(key))
68
+ continue;
69
+ if (typeof value === "string" || typeof value === "number")
70
+ identifiers[key] = value;
71
+ }
72
+ return identifiers;
73
+ }
74
+ function describeErrorChain(error) {
75
+ const chain = [];
76
+ const seen = new Set();
77
+ let current = error;
78
+ while (current !== undefined && !seen.has(current) && chain.length < 8) {
79
+ seen.add(current);
80
+ chain.push(describeError(current));
81
+ current = current instanceof Error ? current.cause : undefined;
82
+ }
83
+ return chain;
84
+ }
85
+ function describeError(error) {
86
+ if (!(error instanceof Error))
87
+ return { name: "UnknownError", message: String(error) };
88
+ const code = error.code;
89
+ return {
90
+ name: error.name,
91
+ message: error.message,
92
+ ...(typeof code === "string" ? { code } : {}),
93
+ ...(error.stack === undefined ? {} : { stack: error.stack }),
94
+ };
95
+ }
@@ -27,6 +27,7 @@ import { SettingImprovementRollbackError, SettingImprovementSession, } from "./s
27
27
  import { FileNativeWorldStore } from "./world/FileNativeWorldStore.js";
28
28
  import { WorldDocumentStore } from "./world/WorldDocumentStore.js";
29
29
  import { FileNativeAiFailureLog } from "./model/AiFailureLog.js";
30
+ import { FileNativeRuntimeErrorLog } from "./RuntimeErrorLog.js";
30
31
  import { ProtagonistTemplateStore } from "./config/ProtagonistTemplateStore.js";
31
32
  import { AppPreferencesStore } from "./config/AppPreferencesStore.js";
32
33
  import { FileNativeWorldRevisionStore } from "./world-revision/FileNativeWorldRevisionStore.js";
@@ -44,6 +45,7 @@ export class V1Runtime {
44
45
  #corrections;
45
46
  #playCallChains;
46
47
  #failureLog;
48
+ #errorLog;
47
49
  #preferences;
48
50
  #protagonistTemplates;
49
51
  #settingImprovements;
@@ -65,6 +67,10 @@ export class V1Runtime {
65
67
  input.logRoot === undefined
66
68
  ? undefined
67
69
  : new FileNativeAiFailureLog(join(input.logRoot, "ai-failures"));
70
+ this.#errorLog =
71
+ input.logRoot === undefined
72
+ ? undefined
73
+ : new FileNativeRuntimeErrorLog(join(input.logRoot, "runtime-errors"));
68
74
  this.#playCallChains = new PlayCallChain(this.#worlds, this.#compiler, this.#artifacts, this.#failureLog, (id) => this.#conversationChanges.publish(`play:${id}`));
69
75
  this.worldArchives = new WorldArchives({
70
76
  dataRoot: input.dataRoot,
@@ -150,9 +156,11 @@ export class V1Runtime {
150
156
  async handle(request, playCallChainObserver) {
151
157
  try {
152
158
  const result = await this.#dispatch(request, playCallChainObserver);
159
+ await this.#recordPlayInterruption(request, result);
153
160
  return { protocol: v1Protocol, result };
154
161
  }
155
162
  catch (error) {
163
+ await this.#errorLog?.recordRequestFailure(request, error);
156
164
  if (error instanceof WorldArchiveError)
157
165
  throw new V1ProtocolError(error.code, error.message, { cause: error });
158
166
  if (request.type === "world.archive.export" &&
@@ -176,6 +184,32 @@ export class V1Runtime {
176
184
  }
177
185
  }
178
186
  }
187
+ async #recordPlayInterruption(request, result) {
188
+ if (this.#errorLog === undefined ||
189
+ (request.type !== "play.chain.start" &&
190
+ request.type !== "play.chain.append"))
191
+ return;
192
+ const chain = result;
193
+ if (chain?.status !== "interrupted" ||
194
+ typeof chain.lastFailure !== "string" ||
195
+ chain.events?.at(-1)?.kind === "cancellation")
196
+ return;
197
+ await this.#errorLog.recordPlayInterruption(request, {
198
+ message: chain.lastFailure,
199
+ ...(chain.lastFailureKind === undefined
200
+ ? {}
201
+ : { kind: chain.lastFailureKind }),
202
+ });
203
+ }
204
+ async recordConversationFailure(target, error) {
205
+ await this.#errorLog?.recordRequestFailure({
206
+ type: `${target.kind}.observe`,
207
+ targetId: target.id,
208
+ ...("sessionId" in target && target.sessionId !== undefined
209
+ ? { sessionId: target.sessionId }
210
+ : {}),
211
+ }, error);
212
+ }
179
213
  subscribeConversation(target, changed) {
180
214
  return this.#conversationChanges.subscribe(`${target.kind}:${target.id}`, changed);
181
215
  }
@@ -258,7 +292,19 @@ export class V1Runtime {
258
292
  case "model.models":
259
293
  return this.#models.listModels(request);
260
294
  case "content.create":
261
- return this.#content.createCurrentTreeContentPackage();
295
+ return this.#content.createCurrentTreeContentPackage(request.gameplay);
296
+ case "content.gameplay.set": {
297
+ try {
298
+ return await this.#content.setCurrentTreeContentGameplayScheme(request.packageId, request.scheme);
299
+ }
300
+ catch (error) {
301
+ if (error instanceof InvalidContentTreeError)
302
+ throw new V1ProtocolError("invalid_request", error.message, {
303
+ cause: error,
304
+ });
305
+ throw error;
306
+ }
307
+ }
262
308
  case "content.create-sample":
263
309
  return this.#content.createSampleContentPackage(request.locale);
264
310
  case "content.scripts.read":
@@ -272,7 +318,16 @@ export class V1Runtime {
272
318
  case "content.read":
273
319
  return this.#content.readCurrentTreeContentPackage(request.packageId);
274
320
  case "content.replace":
275
- return this.#content.replaceCurrentTreeContentPackage(request.packageId, request.files);
321
+ try {
322
+ return await this.#content.replaceCurrentTreeContentPackage(request.packageId, request.files);
323
+ }
324
+ catch (error) {
325
+ if (error instanceof InvalidContentTreeError)
326
+ throw new V1ProtocolError("invalid_request", error.message, {
327
+ cause: error,
328
+ });
329
+ throw error;
330
+ }
276
331
  case "content.copy":
277
332
  return this.#content.copyCurrentTreeContentPackage(request.packageId);
278
333
  case "content.delete":
@@ -1,6 +1,7 @@
1
1
  import { isAbsolute, win32 } from "node:path";
2
2
  import { inflateRawSync } from "node:zlib";
3
3
  import { portableContentTreePathKey } from "../../protocol/contentTree.js";
4
+ import { fictionGameplay, gameplayFile, parseGameplay, } from "../../protocol/gameplay.js";
4
5
  import { defaultAppLocale, } from "../../protocol/appPreferences.js";
5
6
  import { defaultContentWorkspaceLimits, maximumPortableContentArchiveBytes, } from "./ContentLimits.js";
6
7
  import { CurrentTreeContentLibrary, InvalidContentTreeError, normalizeCurrentContentTreeFiles, } from "./CurrentTreeContentLibrary.js";
@@ -36,11 +37,15 @@ export class ContentWorkspace {
36
37
  ...options.currentTree,
37
38
  });
38
39
  }
39
- createCurrentTreeContentPackage() {
40
+ createCurrentTreeContentPackage(gameplay = fictionGameplay) {
40
41
  const locale = this.#locale();
42
+ const chosen = parseGameplay(gameplay);
41
43
  return this.#currentTreeLibrary.createPackage({
42
44
  title: defaultContentPackageTitle(locale, "new"),
43
- files: minimalFileNativeContentScaffold(locale),
45
+ files: [
46
+ ...minimalFileNativeContentScaffold(locale),
47
+ ...(chosen.mode === "tabletop" ? [gameplayFile(chosen)] : []),
48
+ ],
44
49
  });
45
50
  }
46
51
  createSampleContentPackage(locale) {
@@ -76,6 +81,9 @@ export class ContentWorkspace {
76
81
  replaceCurrentTreeContentPackage(localId, files, options = {}) {
77
82
  return this.#currentTreeLibrary.replaceCurrentTree(localId, files, options);
78
83
  }
84
+ setCurrentTreeContentGameplayScheme(localId, scheme, options = {}) {
85
+ return this.#currentTreeLibrary.setGameplayScheme(localId, scheme, options);
86
+ }
79
87
  importPortableContentPackageArchive(archive, options = {}) {
80
88
  return this.#mutate(async () => {
81
89
  const files = readPortableContentArchive(archive, this.#limits, options.signal);