narraeon 0.5.6 → 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.
- package/README.md +3 -1
- package/README.zh-CN.md +3 -1
- package/dist/node/protocol/appPreferences.js +4 -2
- package/dist/node/protocol/gameplay.js +69 -0
- package/dist/node/protocol/playFailure.js +13 -0
- package/dist/node/protocol/protagonistTemplates.js +13 -0
- package/dist/node/protocol/resolution.js +274 -0
- package/dist/node/protocol/v1.js +40 -19
- package/dist/node/protocol/worldArchives.js +7 -0
- package/dist/node/runtime/RuntimeErrorLog.js +95 -0
- package/dist/node/runtime/V1Runtime.js +103 -8
- package/dist/node/runtime/archive/WorldArchiveZip.js +202 -0
- package/dist/node/runtime/archive/WorldArchives.js +389 -0
- package/dist/node/runtime/config/AppPreferencesStore.js +23 -10
- package/dist/node/runtime/config/ProtagonistTemplateStore.js +134 -0
- package/dist/node/runtime/content/ContentWorkspace.js +15 -2
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +43 -2
- package/dist/node/runtime/content/FileNativeContentTree.js +8 -1
- package/dist/node/runtime/history/StoryExport.js +33 -0
- package/dist/node/runtime/model/FileNativeModelAdapters.js +34 -2
- package/dist/node/runtime/model/ModelHost.js +12 -1
- package/dist/node/runtime/model/ModelHostStream.js +11 -3
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +37 -1
- package/dist/node/runtime/play/FileNativePlayPresetStore.js +48 -3
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +87 -4
- package/dist/node/runtime/play/LegacyModelBindingFailure.js +10 -0
- package/dist/node/runtime/play/OrderedPlayPrompts.js +68 -6
- package/dist/node/runtime/play/PlayCallChain.js +177 -57
- package/dist/node/runtime/play/PlayCallChainError.js +3 -1
- package/dist/node/runtime/play/PlayContinuity.js +3 -20
- package/dist/node/runtime/play/PlayFailure.js +20 -0
- package/dist/node/runtime/play/PlayTraceRetention.js +3 -11
- 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 +49 -32
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +37 -4
- package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +4 -1
- package/dist/node/runtime/setting/SettingAuthoringTransaction.js +56 -13
- package/dist/node/runtime/setting/SettingImprovementSession.js +6 -4
- package/dist/node/runtime/world/FileNativeWorldStore.js +207 -40
- package/dist/node/runtime/world/WorldActivity.js +143 -0
- package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -0
- 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/createServer.js +48 -0
- package/dist/node/shared/default-preset-host.js +10 -6
- package/dist/node/shared/ordered-author-prompts.js +8 -0
- package/dist/node/shared/ordered-play-prompts.js +25 -2
- package/dist/node/shared/sample-packages/index.js +10 -0
- package/dist/node/shared/sample-packages/rainy-inn-en.js +194 -0
- package/dist/node/shared/sample-packages/rainy-inn-zh.js +194 -0
- package/dist/node/shared/tabletop-guidance.js +100 -0
- package/dist/web/assets/index-BJ-1Mb0e.css +1 -0
- package/dist/web/assets/index-BgBL8G-A.js +1323 -0
- package/dist/web/index.html +2 -2
- package/package.json +6 -2
- package/dist/web/assets/index-BH8NyuE4.js +0 -1233
- package/dist/web/assets/index-CVN48xrw.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
|
|
@@ -178,7 +180,7 @@ TMPDIR=/tmp npm run test:package
|
|
|
178
180
|
|
|
179
181
|
Project contracts and architecture:
|
|
180
182
|
|
|
181
|
-
- [
|
|
183
|
+
- [GLOSSARY.md](GLOSSARY.md) — domain vocabulary
|
|
182
184
|
- [docs/product-foundation.md](docs/product-foundation.md) — current V1 product contract
|
|
183
185
|
- [docs/adr/](docs/adr/) — architectural decisions
|
|
184
186
|
|
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
|
|
@@ -168,7 +170,7 @@ TMPDIR=/tmp npm run test:package
|
|
|
168
170
|
|
|
169
171
|
项目契约与架构文档:
|
|
170
172
|
|
|
171
|
-
- [
|
|
173
|
+
- [GLOSSARY.md](GLOSSARY.md) —— 领域词汇
|
|
172
174
|
- [docs/product-foundation.md](docs/product-foundation.md) —— 当前 V1 产品契约
|
|
173
175
|
- [docs/adr/](docs/adr/) —— 架构决策
|
|
174
176
|
|
|
@@ -7,6 +7,7 @@ export const defaultAppReadingPreferences = {
|
|
|
7
7
|
lineHeight: 1.7,
|
|
8
8
|
letterSpacing: 0.01,
|
|
9
9
|
measure: 48,
|
|
10
|
+
showTechnicalInfo: false,
|
|
10
11
|
};
|
|
11
12
|
export function isAppLocale(value) {
|
|
12
13
|
return appLocales.includes(value);
|
|
@@ -15,12 +16,13 @@ export function isAppReadingPreferences(value) {
|
|
|
15
16
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
16
17
|
return false;
|
|
17
18
|
const candidate = value;
|
|
18
|
-
return (Object.keys(candidate).length ===
|
|
19
|
+
return (Object.keys(candidate).length === 6 &&
|
|
19
20
|
readingDensities.includes(candidate.density) &&
|
|
20
21
|
isNumberInRange(candidate.fontSize, 15, 24) &&
|
|
21
22
|
isNumberInRange(candidate.lineHeight, 1.4, 2.4) &&
|
|
22
23
|
isNumberInRange(candidate.letterSpacing, 0, 0.12) &&
|
|
23
|
-
isNumberInRange(candidate.measure, 32, 72)
|
|
24
|
+
isNumberInRange(candidate.measure, 32, 72) &&
|
|
25
|
+
typeof candidate.showTechnicalInfo === "boolean");
|
|
24
26
|
}
|
|
25
27
|
function isNumberInRange(value, minimum, maximum) {
|
|
26
28
|
return (typeof value === "number" &&
|
|
@@ -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,13 @@
|
|
|
1
|
+
export const playFailureKinds = [
|
|
2
|
+
"outcome_unknown",
|
|
3
|
+
"context_limit",
|
|
4
|
+
"output_limit",
|
|
5
|
+
"context_unavailable",
|
|
6
|
+
"retryable",
|
|
7
|
+
"provider_error",
|
|
8
|
+
"other",
|
|
9
|
+
"configuration_mismatch",
|
|
10
|
+
];
|
|
11
|
+
export function isPlayFailureKind(value) {
|
|
12
|
+
return playFailureKinds.some((kind) => kind === value);
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function isProtagonistTemplateId(value) {
|
|
2
|
+
return (typeof value === "string" &&
|
|
3
|
+
/^template-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(value));
|
|
4
|
+
}
|
|
5
|
+
export function isProtagonistTemplateContent(name, body) {
|
|
6
|
+
return (typeof name === "string" &&
|
|
7
|
+
name.trim().length > 0 &&
|
|
8
|
+
[...name].length <= 80 &&
|
|
9
|
+
!/[\r\n\u0085\u2028\u2029]/u.test(name) &&
|
|
10
|
+
typeof body === "string" &&
|
|
11
|
+
body.trim().length > 0 &&
|
|
12
|
+
new TextEncoder().encode(body).byteLength <= 16 * 1024);
|
|
13
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/node/protocol/v1.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { parseGameplay, resolutionSchemes, } from "./gameplay.js";
|
|
2
|
+
import { isAppReadingPreferences, } from "./appPreferences.js";
|
|
1
3
|
import { maxPortableContentArchiveBase64Characters } from "./contentTree.js";
|
|
2
4
|
export const v1Protocol = "narraeon.runtime/v1";
|
|
3
5
|
export class V1ProtocolError extends Error {
|
|
@@ -20,15 +22,20 @@ export function parseV1Envelope(value) {
|
|
|
20
22
|
}
|
|
21
23
|
const requiredFields = {
|
|
22
24
|
"preferences.save": {},
|
|
25
|
+
"protagonist-templates.save": { name: "string", body: "string" },
|
|
26
|
+
"protagonist-templates.copy": { id: "string" },
|
|
27
|
+
"protagonist-templates.delete": { id: "string" },
|
|
23
28
|
"model.save": { connection: "object" },
|
|
24
29
|
"model.copy": { connectionId: "string", name: "string" },
|
|
25
30
|
"model.select": { connectionId: "string" },
|
|
26
31
|
"model.delete": { connectionId: "string" },
|
|
27
32
|
"model.models": { provider: "string", baseUrl: "string" },
|
|
33
|
+
"content.create-sample": { locale: "string" },
|
|
28
34
|
"content.scripts.read": { packageId: "string" },
|
|
29
35
|
"content.scripts.set": { packageId: "string", enabled: "boolean" },
|
|
30
36
|
"world.package-scripts.read": { worldId: "string" },
|
|
31
37
|
"world.package-scripts.set": { worldId: "string", enabled: "boolean" },
|
|
38
|
+
"content.gameplay.set": { packageId: "string", scheme: "string" },
|
|
32
39
|
"content.read": { packageId: "string" },
|
|
33
40
|
"content.replace": { packageId: "string", files: "array" },
|
|
34
41
|
"content.copy": { packageId: "string" },
|
|
@@ -82,8 +89,11 @@ const requiredFields = {
|
|
|
82
89
|
packageId: "string",
|
|
83
90
|
model: "object",
|
|
84
91
|
},
|
|
92
|
+
"world.archive.export": { worldId: "string", operationId: "string" },
|
|
93
|
+
"world.archive.import": { operationId: "string", uploadId: "string" },
|
|
85
94
|
"world.read": { worldId: "string" },
|
|
86
95
|
"world.rename": { worldId: "string", name: "string" },
|
|
96
|
+
"world.story.export": { worldId: "string", format: "string" },
|
|
87
97
|
"world.delete": { worldId: "string" },
|
|
88
98
|
"artifacts.read": { worldId: "string" },
|
|
89
99
|
"artifacts.debug": { worldId: "string" },
|
|
@@ -204,6 +214,14 @@ function validateRequestFields(request) {
|
|
|
204
214
|
if (!valid || (kind === "string" && value === "" && !allowsEmptyString))
|
|
205
215
|
throw new V1ProtocolError("invalid_request", `${String(request.type)}.${field} is invalid`);
|
|
206
216
|
}
|
|
217
|
+
if (request.type === "world.story.export" &&
|
|
218
|
+
request.format !== "markdown" &&
|
|
219
|
+
request.format !== "text")
|
|
220
|
+
throw new V1ProtocolError("invalid_request", "world.story.export.format must be markdown or text");
|
|
221
|
+
if (request.type === "content.create-sample" &&
|
|
222
|
+
(Object.keys(request).some((key) => key !== "type" && key !== "locale") ||
|
|
223
|
+
(request.locale !== "en" && request.locale !== "zh-CN")))
|
|
224
|
+
throw new V1ProtocolError("invalid_request", "content.create-sample.locale must be en or zh-CN, with no extra fields");
|
|
207
225
|
if (request.type === "world.extensions.set" &&
|
|
208
226
|
!["on", "off", "default"].includes(String(request.value)))
|
|
209
227
|
throw new V1ProtocolError("invalid_request", "Invalid world extension choice");
|
|
@@ -216,9 +234,21 @@ function validateRequestFields(request) {
|
|
|
216
234
|
request.locale !== "zh-CN")
|
|
217
235
|
throw new V1ProtocolError("invalid_request", "preferences.save.locale must be en or zh-CN");
|
|
218
236
|
if (request.reading !== undefined &&
|
|
219
|
-
!
|
|
237
|
+
!isAppReadingPreferences(request.reading))
|
|
220
238
|
throw new V1ProtocolError("invalid_request", "preferences.save.reading is invalid");
|
|
221
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
|
+
}
|
|
222
252
|
if (request.type === "content.import") {
|
|
223
253
|
const archiveBase64 = request.archiveBase64;
|
|
224
254
|
if (archiveBase64.length > maxPortableContentArchiveBase64Characters ||
|
|
@@ -359,6 +389,10 @@ function validateRequestFields(request) {
|
|
|
359
389
|
const requestTypes = new Set([
|
|
360
390
|
"workspace.read",
|
|
361
391
|
"preferences.read",
|
|
392
|
+
"protagonist-templates.read",
|
|
393
|
+
"protagonist-templates.save",
|
|
394
|
+
"protagonist-templates.copy",
|
|
395
|
+
"protagonist-templates.delete",
|
|
362
396
|
"preferences.save",
|
|
363
397
|
"model.read",
|
|
364
398
|
"model.save",
|
|
@@ -367,6 +401,8 @@ const requestTypes = new Set([
|
|
|
367
401
|
"model.delete",
|
|
368
402
|
"model.models",
|
|
369
403
|
"content.create",
|
|
404
|
+
"content.gameplay.set",
|
|
405
|
+
"content.create-sample",
|
|
370
406
|
"content.scripts.read",
|
|
371
407
|
"content.scripts.set",
|
|
372
408
|
"world.package-scripts.read",
|
|
@@ -401,8 +437,11 @@ const requestTypes = new Set([
|
|
|
401
437
|
"play.workbench.read",
|
|
402
438
|
"prompt.preview",
|
|
403
439
|
"world.create",
|
|
440
|
+
"world.archive.export",
|
|
441
|
+
"world.archive.import",
|
|
404
442
|
"world.read",
|
|
405
443
|
"world.rename",
|
|
444
|
+
"world.story.export",
|
|
406
445
|
"world.delete",
|
|
407
446
|
"artifacts.read",
|
|
408
447
|
"artifacts.debug",
|
|
@@ -447,21 +486,3 @@ const requestTypes = new Set([
|
|
|
447
486
|
function isRecord(value) {
|
|
448
487
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
449
488
|
}
|
|
450
|
-
function isValidReadingPreferences(value) {
|
|
451
|
-
if (!isRecord(value))
|
|
452
|
-
return false;
|
|
453
|
-
return (Object.keys(value).length === 5 &&
|
|
454
|
-
(value.density === "compact" ||
|
|
455
|
-
value.density === "standard" ||
|
|
456
|
-
value.density === "relaxed") &&
|
|
457
|
-
isFiniteRange(value.fontSize, 15, 24) &&
|
|
458
|
-
isFiniteRange(value.lineHeight, 1.4, 2.4) &&
|
|
459
|
-
isFiniteRange(value.letterSpacing, 0, 0.12) &&
|
|
460
|
-
isFiniteRange(value.measure, 32, 72));
|
|
461
|
-
}
|
|
462
|
-
function isFiniteRange(value, minimum, maximum) {
|
|
463
|
-
return (typeof value === "number" &&
|
|
464
|
-
Number.isFinite(value) &&
|
|
465
|
-
value >= minimum &&
|
|
466
|
-
value <= maximum);
|
|
467
|
-
}
|
|
@@ -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
|
+
}
|