narraeon 0.5.7 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/README.zh-CN.md +10 -0
- package/dist/node/protocol/gameplay.js +69 -0
- package/dist/node/protocol/resolution.js +274 -0
- package/dist/node/protocol/v1.js +15 -2
- package/dist/node/runtime/FileNativeExclusiveLink.js +39 -0
- package/dist/node/runtime/RuntimeErrorLog.js +95 -0
- package/dist/node/runtime/V1Runtime.js +58 -7
- package/dist/node/runtime/archive/WorldArchives.js +14 -46
- package/dist/node/runtime/artifact/ArtifactStoreFiles.js +330 -0
- package/dist/node/runtime/artifact/FileNativeArtifactStore.js +495 -1535
- package/dist/node/runtime/artifact/LegacyArtifactStoreMigration.js +260 -0
- package/dist/node/runtime/config/AppPreferencesStore.js +0 -1
- package/dist/node/runtime/config/ProtagonistTemplateStore.js +0 -1
- package/dist/node/runtime/config/dataRootLock.js +108 -0
- package/dist/node/runtime/content/ContentWorkspace.js +10 -12
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +50 -46
- package/dist/node/runtime/content/FileNativeContentTree.js +8 -1
- package/dist/node/runtime/extension/PackageScriptPermissions.js +0 -8
- package/dist/node/runtime/extension/WorldExtensionControls.js +0 -8
- package/dist/node/runtime/model/ModelConnectionStore.js +0 -1
- package/dist/node/runtime/play/FileNativeContinuityCorrection.js +3 -14
- package/dist/node/runtime/play/FileNativePlayAdvanceStore.js +31 -170
- package/dist/node/runtime/play/FileNativePlayPresetStore.js +48 -3
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +36 -239
- package/dist/node/runtime/play/OrderedPlayPrompts.js +68 -6
- package/dist/node/runtime/play/PlayCallChain.js +359 -359
- package/dist/node/runtime/play/PlayDocumentTools.js +8 -125
- package/dist/node/runtime/play/PlayFollowupRequests.js +2 -2
- package/dist/node/runtime/play/PlayTraceRetention.js +8 -36
- package/dist/node/runtime/play/TabletopResolution.js +372 -0
- package/dist/node/runtime/play/TabletopRules.js +141 -0
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +45 -10
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +35 -2
- package/dist/node/runtime/setting/FileNativeSettingImprovementStore.js +7 -16
- package/dist/node/runtime/setting/SettingAuthoringTransaction.js +56 -13
- package/dist/node/runtime/setting/SettingImprovementSession.js +6 -4
- package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +213 -1033
- package/dist/node/runtime/world/FileNativeWorldStore.js +266 -783
- package/dist/node/runtime/world/{FileNativeAuthorityV3.js → LegacyAuthorityV3Reader.js} +13 -404
- package/dist/node/runtime/world/WorldActivity.js +14 -22
- package/dist/node/runtime/world/WorldDocumentMaintenance.js +2 -2
- package/dist/node/runtime/world/WorldLedgerAuthority.js +340 -0
- package/dist/node/runtime/world/WorldOperationCoordinator.js +59 -401
- package/dist/node/runtime/world/WorldQueue.js +46 -0
- package/dist/node/runtime/world/WorldTurnLedger.js +353 -0
- package/dist/node/runtime/world-revision/FileNativeWorldRevisionStore.js +2 -12
- package/dist/node/runtime/world-revision/WorldRevisionAuthoringTransaction.js +35 -6
- package/dist/node/runtime/world-revision/WorldRevisionSession.js +9 -5
- package/dist/node/server/ConversationStream.js +2 -0
- package/dist/node/server/mobile.js +20 -0
- package/dist/node/server/startWebServer.js +15 -8
- package/dist/node/shared/ordered-author-prompts.js +8 -0
- package/dist/node/shared/ordered-play-prompts.js +23 -0
- package/dist/node/shared/tabletop-guidance.js +100 -0
- package/dist/web/assets/index-7GzLSUpU.js +1323 -0
- package/dist/web/assets/index-BJ-1Mb0e.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/dist/node/runtime/FileNativePhysicalClone.js +0 -86
- package/dist/web/assets/index-DxNtxzLR.js +0 -1255
- package/dist/web/assets/index-DzOnu9uv.css +0 -1
package/README.md
CHANGED
|
@@ -144,6 +144,13 @@ English is the default interface language. Switching to Simplified Chinese updat
|
|
|
144
144
|
|
|
145
145
|
Existing content packages, worlds, imported presets, copied presets, and user-edited prompt files are not translated or rewritten automatically.
|
|
146
146
|
|
|
147
|
+
## Android
|
|
148
|
+
|
|
149
|
+
An experimental Android app runs the same backend inside the app process: a
|
|
150
|
+
foreground service starts the embedded Node runtime, and a WebView opens
|
|
151
|
+
`127.0.0.1:4317`. It supports `arm64-v8a` devices only. Build steps and the
|
|
152
|
+
runtime layout are in [android/README.md](android/README.md).
|
|
153
|
+
|
|
147
154
|
## Local data and privacy
|
|
148
155
|
|
|
149
156
|
Narraeon uses the operating system's standard per-user application directories. To isolate an instance or choose explicit locations, set these variables before startup:
|
|
@@ -163,6 +170,12 @@ By default, the browser talks only to the same-machine Runtime. Local-first does
|
|
|
163
170
|
|
|
164
171
|
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
172
|
|
|
173
|
+
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.
|
|
174
|
+
|
|
175
|
+
## Upgrading worlds
|
|
176
|
+
|
|
177
|
+
Worlds written by v0.3.0 through v0.6.0 upgrade automatically the first time a current version opens them. Worlds written by v0.2.0 or earlier are not upgraded directly: open each one once with v0.3.0–v0.6.0, then open it with the current version. A world that cannot be upgraded is shown as unavailable and is not modified.
|
|
178
|
+
|
|
166
179
|
## Development
|
|
167
180
|
|
|
168
181
|
```bash
|
package/README.zh-CN.md
CHANGED
|
@@ -136,6 +136,10 @@ AI 设定完善只作用于内容包候选。在“先生成计划”的路径
|
|
|
136
136
|
|
|
137
137
|
已有内容包、世界、导入预设、复制预设和用户编辑过的提示文件不会被自动翻译或重写。
|
|
138
138
|
|
|
139
|
+
## Android
|
|
140
|
+
|
|
141
|
+
实验性的 Android 应用在 App 进程内运行同一套后端:前台服务启动内嵌的 Node 运行时,WebView 打开 `127.0.0.1:4317`。目前只支持 `arm64-v8a` 设备。构建步骤与运行时目录结构见 [android/README.md](android/README.md)。
|
|
142
|
+
|
|
139
143
|
## 本地数据与隐私
|
|
140
144
|
|
|
141
145
|
Narraeon 默认使用当前操作系统的标准用户应用目录。如果要隔离一份独立实例,或者自行指定存放位置,可在启动前设置:
|
|
@@ -153,6 +157,12 @@ Narraeon 默认使用当前操作系统的标准用户应用目录。如果要
|
|
|
153
157
|
|
|
154
158
|
发生 Provider、响应格式、Runtime 工具或候选检查错误时,Narraeon 会在 `NARRAEON_LOG_ROOT/ai-failures` 下创建 JSONL 事故记录。它会保存原始 Provider 交换和后续恢复尝试,包括 Provider 真实返回的 reasoning/thinking。它不会伪造隐藏思考,也不会记录 API Key 和请求 header。不过日志仍可能包含私密提示词、世界内容、工具参数和 Provider 返回的思考,因此应当把该目录视为敏感数据。
|
|
155
159
|
|
|
160
|
+
每个失败的 Runtime 请求、每次以中断结束的游玩调用、每次失败的对话实时同步,也会追加到 `NARRAEON_LOG_ROOT/runtime-errors/<UTC 日期>.jsonl`,记录请求类型、请求中的标识(世界、上下文、事件和操作 ID)以及错误信息、错误码、调用栈和 cause 链,不记录请求里的玩家原文和世界内容。反馈问题时附上对应的那一行即可。
|
|
161
|
+
|
|
162
|
+
## 升级世界
|
|
163
|
+
|
|
164
|
+
由 v0.3.0 至 v0.6.0 创建的世界会在当前版本第一次打开时自动升级。由 v0.2.0 及更早版本创建的世界不再直接升级:先用 v0.3.0–v0.6.0 各打开一次,再用当前版本打开。无法升级的世界会显示为不可打开,并且不会被修改。
|
|
165
|
+
|
|
156
166
|
## 仓库开发
|
|
157
167
|
|
|
158
168
|
```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
|
+
}
|
package/dist/node/protocol/v1.js
CHANGED
|
@@ -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" },
|
|
@@ -96,7 +98,6 @@ const requiredFields = {
|
|
|
96
98
|
"artifacts.read": { worldId: "string" },
|
|
97
99
|
"artifacts.debug": { worldId: "string" },
|
|
98
100
|
"world.creation-outcome": { operationId: "string" },
|
|
99
|
-
"world.repair-materialization": { worldId: "string" },
|
|
100
101
|
"world.control-draft.save": { worldId: "string", files: "array" },
|
|
101
102
|
"world.control-draft.preview": { worldId: "string" },
|
|
102
103
|
"world.control-draft.apply": { worldId: "string" },
|
|
@@ -235,6 +236,18 @@ function validateRequestFields(request) {
|
|
|
235
236
|
!isAppReadingPreferences(request.reading))
|
|
236
237
|
throw new V1ProtocolError("invalid_request", "preferences.save.reading is invalid");
|
|
237
238
|
}
|
|
239
|
+
if (request.type === "content.create" && request.gameplay !== undefined) {
|
|
240
|
+
try {
|
|
241
|
+
parseGameplay(request.gameplay);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
throw new V1ProtocolError("invalid_request", "content.create.gameplay is invalid", { cause: error });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (request.type === "content.gameplay.set") {
|
|
248
|
+
if (!resolutionSchemes.some((scheme) => scheme === request.scheme))
|
|
249
|
+
throw new V1ProtocolError("invalid_request", "content.gameplay.set.scheme is invalid");
|
|
250
|
+
}
|
|
238
251
|
if (request.type === "content.import") {
|
|
239
252
|
const archiveBase64 = request.archiveBase64;
|
|
240
253
|
if (archiveBase64.length > maxPortableContentArchiveBase64Characters ||
|
|
@@ -387,6 +400,7 @@ const requestTypes = new Set([
|
|
|
387
400
|
"model.delete",
|
|
388
401
|
"model.models",
|
|
389
402
|
"content.create",
|
|
403
|
+
"content.gameplay.set",
|
|
390
404
|
"content.create-sample",
|
|
391
405
|
"content.scripts.read",
|
|
392
406
|
"content.scripts.set",
|
|
@@ -431,7 +445,6 @@ const requestTypes = new Set([
|
|
|
431
445
|
"artifacts.read",
|
|
432
446
|
"artifacts.debug",
|
|
433
447
|
"world.creation-outcome",
|
|
434
|
-
"world.repair-materialization",
|
|
435
448
|
"world.control-draft.save",
|
|
436
449
|
"world.control-draft.preview",
|
|
437
450
|
"world.control-draft.apply",
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { copyFile, link } from "node:fs/promises";
|
|
3
|
+
// Error codes that mean "hard links are unavailable here". Android app directories
|
|
4
|
+
// return EACCES for link(), so that code is included.
|
|
5
|
+
const linkFallbackCodes = new Set(["EPERM", "EACCES", "ENOTSUP", "EXDEV"]);
|
|
6
|
+
/**
|
|
7
|
+
* Creates target as a new name for source, failing with EEXIST when target already
|
|
8
|
+
* exists. Uses a hard link where the filesystem allows it. Otherwise falls back to an
|
|
9
|
+
* exclusive byte copy, which also fails with EEXIST.
|
|
10
|
+
*
|
|
11
|
+
* Creation is exclusive in both paths. Content visibility is atomic only on the
|
|
12
|
+
* hard-link path: on the copy path a concurrent reader could in principle see a
|
|
13
|
+
* partially written target. This is acceptable under ADR-0044's single-process and
|
|
14
|
+
* per-world serial assumptions, because callers hold a process-local lock and
|
|
15
|
+
* readers only open targets after the creating call returns.
|
|
16
|
+
*/
|
|
17
|
+
export async function linkExclusive(source, target) {
|
|
18
|
+
const injectedErrorCode = process.env.NARRAEON_INTERNAL_TEST_LINK_ERROR_CODE;
|
|
19
|
+
try {
|
|
20
|
+
if (injectedErrorCode !== undefined) {
|
|
21
|
+
const error = new Error(`${injectedErrorCode}: injected hard link failure`);
|
|
22
|
+
error.code = injectedErrorCode;
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
await link(source, target);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (!isFallbackError(error))
|
|
29
|
+
throw error;
|
|
30
|
+
await copyFile(source, target, constants.COPYFILE_EXCL);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function isFallbackError(error) {
|
|
34
|
+
return (typeof error === "object" &&
|
|
35
|
+
error !== null &&
|
|
36
|
+
"code" in error &&
|
|
37
|
+
typeof error.code === "string" &&
|
|
38
|
+
linkFallbackCodes.has(error.code));
|
|
39
|
+
}
|
|
@@ -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
|
+
}
|