chore: bootstrap nana-story M0
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.rs]
|
||||||
|
indent_size = 4
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: verify
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main", "integration/**", "agent/**"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
path: workspace/nana-story
|
||||||
|
- name: Checkout pinned lapp-rs
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: openlapp/lapp-rs
|
||||||
|
ref: 5ba3c659e1536ec4bee16340faca603940a5cb17
|
||||||
|
path: workspace/lapp-rs
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10.29.2
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: workspace/nana-story/pnpm-lock.yaml
|
||||||
|
- uses: dtolnay/rust-toolchain@1.96.0
|
||||||
|
with:
|
||||||
|
components: clippy,rustfmt
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: workspace/nana-story
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
- name: Verify
|
||||||
|
working-directory: workspace/nana-story
|
||||||
|
run: pnpm verify
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
target/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.log
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"crates/nana-contracts",
|
||||||
|
"crates/nana-domain",
|
||||||
|
"crates/nana-engine",
|
||||||
|
"crates/nana-runtime",
|
||||||
|
"crates/nana-store",
|
||||||
|
"src-tauri",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.96"
|
||||||
|
license = "MIT"
|
||||||
|
authors = ["nana-story contributors"]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
nana-domain = { path = "crates/nana-domain" }
|
||||||
|
nana-engine = { path = "crates/nana-engine" }
|
||||||
|
nana-runtime = { path = "crates/nana-runtime" }
|
||||||
|
nana-store = { path = "crates/nana-store" }
|
||||||
|
openlapp = { path = "../lapp-rs", default-features = false, features = ["client-rustls"] }
|
||||||
|
schemars = "1.0.4"
|
||||||
|
semver = { version = "1.0.27", features = ["serde"] }
|
||||||
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
serde_json = "1.0.145"
|
||||||
|
sha2 = "0.10.9"
|
||||||
|
thiserror = "2.0.17"
|
||||||
|
ts-rs = { version = "11.1.0", features = ["serde-compat"] }
|
||||||
|
|
||||||
|
[workspace.lints.rust]
|
||||||
|
unsafe_code = "deny"
|
||||||
|
|
||||||
|
[workspace.lints.clippy]
|
||||||
|
all = { level = "warn", priority = -1 }
|
||||||
|
pedantic = { level = "warn", priority = -1 }
|
||||||
|
missing_errors_doc = "allow"
|
||||||
|
module_name_repetitions = "allow"
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 听娜娜讲故事
|
||||||
|
|
||||||
|
`nana-story` 是一个本地优先、纯 BYOK 的 AI 对话游戏。V1 聚焦一段可回溯、可分支、状态可重放的单角色纵切。
|
||||||
|
|
||||||
|
## 当前阶段
|
||||||
|
|
||||||
|
M0:工程骨架与领域契约冻结。
|
||||||
|
|
||||||
|
已建立:
|
||||||
|
|
||||||
|
- Tauri 2 + Vue 3 + TypeScript 工程;
|
||||||
|
- Rust workspace 与模块所有权边界;
|
||||||
|
- Rust 领域类型作为契约唯一事实来源;
|
||||||
|
- 生成的 JSON Schema 与 TypeScript DTO;
|
||||||
|
- canonical `PlayerView` fixture;
|
||||||
|
- 最小娜娜内容包;
|
||||||
|
- 静态主演出屏与窄 Tauri 命令。
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- Node.js 24+
|
||||||
|
- pnpm 10.29.2
|
||||||
|
- Rust 1.96
|
||||||
|
- 与本仓库同级的 `lapp-rs`,固定到 `lapp-rs.lock` 中的提交
|
||||||
|
|
||||||
|
```text
|
||||||
|
workspace/
|
||||||
|
├─ lapp-rs/
|
||||||
|
└─ nana-story/
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm verify
|
||||||
|
pnpm tauri dev
|
||||||
|
```
|
||||||
|
|
||||||
|
只验证前端和已提交契约时可运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm verify:web
|
||||||
|
```
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 模型供应商协议与凭据处理属于 `lapp-rs`。
|
||||||
|
- 前端只接收 `PlayerView`,不会得到完整 `RuntimeState`。
|
||||||
|
- V1 不使用向量记忆库、导演 Agent 或多个完整角色。
|
||||||
|
- 模型与前端都不能直接修改运行时状态。
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
{
|
||||||
|
"id": "nana.demo.station",
|
||||||
|
"schema_version": 1,
|
||||||
|
"revision": "0.1.0",
|
||||||
|
"display_name": "天亮之前",
|
||||||
|
"entry_character": "nana.character.nana",
|
||||||
|
"entry_persona": "nana.persona.traveler",
|
||||||
|
"entry_plot_module": "nana.plot.before_dawn",
|
||||||
|
"characters": [
|
||||||
|
{
|
||||||
|
"header": {
|
||||||
|
"id": "nana.character.nana",
|
||||||
|
"kind": "character",
|
||||||
|
"schema_version": 1,
|
||||||
|
"revision": "0.1.0",
|
||||||
|
"content_hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||||
|
"dependencies": [
|
||||||
|
{
|
||||||
|
"id": "nana.world.station",
|
||||||
|
"kind": "world_book",
|
||||||
|
"revision": "0.1.0",
|
||||||
|
"content_hash": "sha256:2222222222222222222222222222222222222222222222222222222222222222"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"name": "娜娜",
|
||||||
|
"identity": "独自守在废弃青川站的年轻女孩,正在等待失踪的妹妹留下的讯息。",
|
||||||
|
"personality": ["克制", "敏锐", "不轻易求助", "会用反问掩饰不安"],
|
||||||
|
"values": ["兑现承诺", "保护家人", "不轻率触碰他人的秘密"],
|
||||||
|
"boundaries": ["不会因一次说服就交出妹妹的遗物", "不会替玩家解释玩家的内心"],
|
||||||
|
"style": {
|
||||||
|
"speech": "短句,少用修辞;紧张时会先停顿再追问。",
|
||||||
|
"mannerisms": ["不安时攥住外套袖口", "提到妹妹时避开视线"],
|
||||||
|
"forbidden_player_assumptions": ["替玩家许诺", "替玩家主动触碰娜娜", "替玩家决定是否相信她"]
|
||||||
|
},
|
||||||
|
"initial_relationship": {
|
||||||
|
"affinity": 48,
|
||||||
|
"trust": 34,
|
||||||
|
"hope": 31,
|
||||||
|
"respect": 45,
|
||||||
|
"intimacy": 26,
|
||||||
|
"attachment": 42
|
||||||
|
},
|
||||||
|
"judgment_rules": [
|
||||||
|
{
|
||||||
|
"tags": ["kept_promise", "accepted_risk"],
|
||||||
|
"meaning": "比口头保证更重视为许诺实际承担风险。",
|
||||||
|
"relationship_effects": [
|
||||||
|
{
|
||||||
|
"dimension": "trust",
|
||||||
|
"delta": 4,
|
||||||
|
"cause": "玩家以行动证明许诺",
|
||||||
|
"judgment_rule": "nana.promises.proven_by_action"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"skills": [
|
||||||
|
{
|
||||||
|
"skill": "psychology",
|
||||||
|
"value": 58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"skill": "listen",
|
||||||
|
"value": 64
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default_expression": "guarded"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"world_books": [
|
||||||
|
{
|
||||||
|
"header": {
|
||||||
|
"id": "nana.world.station",
|
||||||
|
"kind": "world_book",
|
||||||
|
"schema_version": 1,
|
||||||
|
"revision": "0.1.0",
|
||||||
|
"content_hash": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
|
||||||
|
"dependencies": []
|
||||||
|
},
|
||||||
|
"name": "青川站",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"id": "station_clock",
|
||||||
|
"title": "停摆的站钟",
|
||||||
|
"content": "青川站的钟停在四点十七分。十二年前最后一班列车也在这个时间失联。",
|
||||||
|
"keywords": ["站钟", "四点十七分", "最后一班列车"],
|
||||||
|
"tags": ["station_history", "missing_train"],
|
||||||
|
"required_flags": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"personas": [
|
||||||
|
{
|
||||||
|
"header": {
|
||||||
|
"id": "nana.persona.traveler",
|
||||||
|
"kind": "persona",
|
||||||
|
"schema_version": 1,
|
||||||
|
"revision": "0.1.0",
|
||||||
|
"content_hash": "sha256:3333333333333333333333333333333333333333333333333333333333333333",
|
||||||
|
"dependencies": [
|
||||||
|
{
|
||||||
|
"id": "nana.item.old_flashlight",
|
||||||
|
"kind": "item_spec",
|
||||||
|
"revision": "0.1.0",
|
||||||
|
"content_hash": "sha256:5555555555555555555555555555555555555555555555555555555555555555"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"name": "旅人",
|
||||||
|
"identity": "因暴雨临时进入废弃车站避雨的普通旅人。",
|
||||||
|
"traits": ["谨慎", "对异常细节敏感"],
|
||||||
|
"skills": [
|
||||||
|
{
|
||||||
|
"skill": "spot_hidden",
|
||||||
|
"value": 55
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"skill": "persuade",
|
||||||
|
"value": 50
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"initial_items": ["nana.item.old_flashlight"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"plot_modules": [
|
||||||
|
{
|
||||||
|
"header": {
|
||||||
|
"id": "nana.plot.before_dawn",
|
||||||
|
"kind": "plot_module",
|
||||||
|
"schema_version": 1,
|
||||||
|
"revision": "0.1.0",
|
||||||
|
"content_hash": "sha256:4444444444444444444444444444444444444444444444444444444444444444",
|
||||||
|
"dependencies": [
|
||||||
|
{
|
||||||
|
"id": "nana.world.station",
|
||||||
|
"kind": "world_book",
|
||||||
|
"revision": "0.1.0",
|
||||||
|
"content_hash": "sha256:2222222222222222222222222222222222222222222222222222222222222222"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"name": "天亮之前",
|
||||||
|
"opening_event": "rainy_platform",
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"id": "rainy_platform",
|
||||||
|
"title": "雨夜站台",
|
||||||
|
"tags": ["stranger_arrives", "promise_possible"],
|
||||||
|
"situation": ["玩家进入废弃青川站避雨", "娜娜正在等待一封不可能寄到的信"],
|
||||||
|
"required_flags": [],
|
||||||
|
"pressures": [
|
||||||
|
{
|
||||||
|
"id": "dawn_approaches",
|
||||||
|
"description": "天亮前仍未找到线索,娜娜会独自进入封锁隧道。",
|
||||||
|
"clock_id": "clock_dawn",
|
||||||
|
"clock_delta": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outcomes": [
|
||||||
|
{
|
||||||
|
"id": "promise_accepted",
|
||||||
|
"description": "娜娜接受玩家天亮前归来的许诺。",
|
||||||
|
"effects": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"item_specs": [
|
||||||
|
{
|
||||||
|
"header": {
|
||||||
|
"id": "nana.item.old_flashlight",
|
||||||
|
"kind": "item_spec",
|
||||||
|
"schema_version": 1,
|
||||||
|
"revision": "0.1.0",
|
||||||
|
"content_hash": "sha256:5555555555555555555555555555555555555555555555555555555555555555",
|
||||||
|
"dependencies": []
|
||||||
|
},
|
||||||
|
"name": "旧手电筒",
|
||||||
|
"description": "一支表面磨损严重的手电筒。",
|
||||||
|
"tags": ["tool", "light"],
|
||||||
|
"lore_refs": [],
|
||||||
|
"mechanics": {
|
||||||
|
"usable": true,
|
||||||
|
"grants_tags": ["has_light"],
|
||||||
|
"check_modifier": 1
|
||||||
|
},
|
||||||
|
"hidden_facts": ["电池仓里刻着青川站工作人员的编号"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
374a325cf1198df128248876fe7f115b41b899777a89609f87cb4ba9e2958239
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "AppInfo",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name", "version", "contractVersion"],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"contractVersion": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "CharacterCard",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"header",
|
||||||
|
"name",
|
||||||
|
"identity",
|
||||||
|
"personality",
|
||||||
|
"values",
|
||||||
|
"boundaries",
|
||||||
|
"style",
|
||||||
|
"initial_relationship",
|
||||||
|
"judgment_rules",
|
||||||
|
"skills",
|
||||||
|
"default_expression"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"header": {
|
||||||
|
"$ref": "resource-header.schema.json"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"personality": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"boundaries": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"initial_relationship": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"judgment_rules": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"skills": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"default_expression": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "DemoPackSummary",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"displayName",
|
||||||
|
"revision",
|
||||||
|
"characters",
|
||||||
|
"worldBooks",
|
||||||
|
"personas",
|
||||||
|
"plotModules",
|
||||||
|
"itemSpecs"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"displayName": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"revision": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"characters": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"worldBooks": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"personas": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"plotModules": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"itemSpecs": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "ItemSpec",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"header",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"tags",
|
||||||
|
"lore_refs",
|
||||||
|
"mechanics",
|
||||||
|
"hidden_facts"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"header": {
|
||||||
|
"$ref": "resource-header.schema.json"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"lore_refs": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"mechanics": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"hidden_facts": {
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "Persona",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["header", "name", "identity", "traits", "skills", "initial_items"],
|
||||||
|
"properties": {
|
||||||
|
"header": {
|
||||||
|
"$ref": "resource-header.schema.json"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"identity": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"traits": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"skills": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"initial_items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "PlayerView",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"storyId",
|
||||||
|
"nodeId",
|
||||||
|
"branchId",
|
||||||
|
"sceneId",
|
||||||
|
"sceneTitle",
|
||||||
|
"characterName",
|
||||||
|
"beats",
|
||||||
|
"suggestions",
|
||||||
|
"inventory",
|
||||||
|
"knowledge",
|
||||||
|
"promises",
|
||||||
|
"relationship",
|
||||||
|
"history",
|
||||||
|
"canContinue"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"storyId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"nodeId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"branchId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sceneId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sceneTitle": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"characterName": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"beats": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "kind", "speaker", "text", "visual"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"suggestions": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"inventory": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"knowledge": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"promises": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"relationship": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"canContinue": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "PlotModule",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["header", "name", "opening_event", "events"],
|
||||||
|
"properties": {
|
||||||
|
"header": {
|
||||||
|
"$ref": "resource-header.schema.json"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"opening_event": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "ResourceBundle",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"schema_version",
|
||||||
|
"revision",
|
||||||
|
"display_name",
|
||||||
|
"entry_character",
|
||||||
|
"entry_persona",
|
||||||
|
"entry_plot_module",
|
||||||
|
"characters",
|
||||||
|
"world_books",
|
||||||
|
"personas",
|
||||||
|
"plot_modules",
|
||||||
|
"item_specs"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9_]+(?:\\.[a-z0-9_]+)+$"
|
||||||
|
},
|
||||||
|
"schema_version": {
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"revision": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
|
||||||
|
},
|
||||||
|
"display_name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"entry_character": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"entry_persona": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"entry_plot_module": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"characters": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "character-card.schema.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"world_books": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "world-book.schema.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"personas": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "persona.schema.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plot_modules": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "plot-module.schema.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"item_specs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "item-spec.schema.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "ResourceHeader",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "kind", "schema_version", "revision", "content_hash", "dependencies"],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9_]+(?:\\.[a-z0-9_]+)+$"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"enum": ["character", "world_book", "persona", "plot_module", "item_spec"]
|
||||||
|
},
|
||||||
|
"schema_version": {
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"revision": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
|
||||||
|
},
|
||||||
|
"content_hash": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^sha256:[0-9a-f]{64}$"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "RuntimeState",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"story_id",
|
||||||
|
"current_node",
|
||||||
|
"current_branch",
|
||||||
|
"world_flags",
|
||||||
|
"relationships",
|
||||||
|
"relationship_states",
|
||||||
|
"promises",
|
||||||
|
"knowledge",
|
||||||
|
"items",
|
||||||
|
"clocks",
|
||||||
|
"checks"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"story_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"current_node": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"current_branch": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"world_flags": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"relationships": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"relationship_states": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"promises": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"knowledge": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"clocks": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"checks": {
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "StoryNode",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"story_id",
|
||||||
|
"branch_id",
|
||||||
|
"parent_id",
|
||||||
|
"action_id",
|
||||||
|
"user_input",
|
||||||
|
"beats",
|
||||||
|
"delta",
|
||||||
|
"state_hash"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"story_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"branch_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"parent_id": {
|
||||||
|
"type": ["string", "null"]
|
||||||
|
},
|
||||||
|
"action_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"user_input": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"beats": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"delta": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"state_hash": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "TurnFailure",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["code", "message", "retryable"],
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"enum": [
|
||||||
|
"stale_node",
|
||||||
|
"invalid_input",
|
||||||
|
"invalid_model_output",
|
||||||
|
"provider_unavailable",
|
||||||
|
"cancelled",
|
||||||
|
"timed_out",
|
||||||
|
"internal"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"retryable": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "TurnRequest",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"storyId",
|
||||||
|
"branchId",
|
||||||
|
"expectedNodeId",
|
||||||
|
"actionId",
|
||||||
|
"intent",
|
||||||
|
"input"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"storyId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"branchId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"expectedNodeId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"actionId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"intent": {
|
||||||
|
"enum": ["speak_or_act", "continue", "regenerate", "push_check"]
|
||||||
|
},
|
||||||
|
"input": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "TurnResult",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["committedNodeId", "playerView"],
|
||||||
|
"properties": {
|
||||||
|
"committedNodeId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"playerView": {
|
||||||
|
"$ref": "player-view.schema.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "WorldBook",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["header", "name", "entries"],
|
||||||
|
"properties": {
|
||||||
|
"header": {
|
||||||
|
"$ref": "resource-header.schema.json"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
// @generated by crates/nana-contracts; do not edit.
|
||||||
|
|
||||||
|
export type ResourceId = string;
|
||||||
|
export type ResourceKind =
|
||||||
|
| "character"
|
||||||
|
| "world_book"
|
||||||
|
| "persona"
|
||||||
|
| "plot_module"
|
||||||
|
| "item_spec";
|
||||||
|
|
||||||
|
export interface ResourceRef {
|
||||||
|
id: ResourceId;
|
||||||
|
kind: ResourceKind;
|
||||||
|
revision: string;
|
||||||
|
content_hash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceHeader {
|
||||||
|
id: ResourceId;
|
||||||
|
kind: ResourceKind;
|
||||||
|
schema_version: number;
|
||||||
|
revision: string;
|
||||||
|
content_hash: string;
|
||||||
|
dependencies: ResourceRef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RelationshipDimension =
|
||||||
|
| "affinity"
|
||||||
|
| "trust"
|
||||||
|
| "hope"
|
||||||
|
| "respect"
|
||||||
|
| "intimacy"
|
||||||
|
| "attachment";
|
||||||
|
|
||||||
|
export interface RelationshipAxes {
|
||||||
|
affinity: number;
|
||||||
|
trust: number;
|
||||||
|
hope: number;
|
||||||
|
respect: number;
|
||||||
|
intimacy: number;
|
||||||
|
attachment: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelationshipAdjustment {
|
||||||
|
dimension: RelationshipDimension;
|
||||||
|
delta: number;
|
||||||
|
cause: string;
|
||||||
|
judgment_rule: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillValue {
|
||||||
|
skill: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterCard {
|
||||||
|
header: ResourceHeader;
|
||||||
|
name: string;
|
||||||
|
identity: string;
|
||||||
|
personality: string[];
|
||||||
|
values: string[];
|
||||||
|
boundaries: string[];
|
||||||
|
style: {
|
||||||
|
speech: string;
|
||||||
|
mannerisms: string[];
|
||||||
|
forbidden_player_assumptions: string[];
|
||||||
|
};
|
||||||
|
initial_relationship: RelationshipAxes;
|
||||||
|
judgment_rules: Array<{
|
||||||
|
tags: string[];
|
||||||
|
meaning: string;
|
||||||
|
relationship_effects: RelationshipAdjustment[];
|
||||||
|
}>;
|
||||||
|
skills: SkillValue[];
|
||||||
|
default_expression: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorldBook {
|
||||||
|
header: ResourceHeader;
|
||||||
|
name: string;
|
||||||
|
entries: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
keywords: string[];
|
||||||
|
tags: string[];
|
||||||
|
required_flags: string[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Persona {
|
||||||
|
header: ResourceHeader;
|
||||||
|
name: string;
|
||||||
|
identity: string;
|
||||||
|
traits: string[];
|
||||||
|
skills: SkillValue[];
|
||||||
|
initial_items: ResourceId[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlotModule {
|
||||||
|
header: ResourceHeader;
|
||||||
|
name: string;
|
||||||
|
opening_event: string;
|
||||||
|
events: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
tags: string[];
|
||||||
|
situation: string[];
|
||||||
|
required_flags: string[];
|
||||||
|
pressures: Array<{
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
clock_id: string | null;
|
||||||
|
clock_delta: number | null;
|
||||||
|
}>;
|
||||||
|
outcomes: Array<{
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
effects: StateOp[];
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemSpec {
|
||||||
|
header: ResourceHeader;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
lore_refs: string[];
|
||||||
|
mechanics: {
|
||||||
|
usable: boolean;
|
||||||
|
grants_tags: string[];
|
||||||
|
check_modifier: number | null;
|
||||||
|
};
|
||||||
|
hidden_facts: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceBundle {
|
||||||
|
id: string;
|
||||||
|
schema_version: number;
|
||||||
|
revision: string;
|
||||||
|
display_name: string;
|
||||||
|
entry_character: ResourceId;
|
||||||
|
entry_persona: ResourceId;
|
||||||
|
entry_plot_module: ResourceId | null;
|
||||||
|
characters: CharacterCard[];
|
||||||
|
world_books: WorldBook[];
|
||||||
|
personas: Persona[];
|
||||||
|
plot_modules: PlotModule[];
|
||||||
|
item_specs: ItemSpec[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromiseStatus =
|
||||||
|
| "proposed"
|
||||||
|
| "accepted"
|
||||||
|
| "fulfilled"
|
||||||
|
| "broken"
|
||||||
|
| "released"
|
||||||
|
| "impossible";
|
||||||
|
export type PromiseWeight = "minor" | "major";
|
||||||
|
export type KnowledgeCertainty = "suspected" | "reported" | "confirmed";
|
||||||
|
export type ItemPlacement = "bag" | "worn" | "hand" | "scene" | "hidden";
|
||||||
|
export type AcquisitionMode = "initial" | "found" | "given" | "borrowed" | "stolen" | "returned";
|
||||||
|
|
||||||
|
export interface Promise {
|
||||||
|
id: string;
|
||||||
|
promiser: string;
|
||||||
|
promisee: string;
|
||||||
|
content: string;
|
||||||
|
status: PromiseStatus;
|
||||||
|
weight: PromiseWeight;
|
||||||
|
created_at: string;
|
||||||
|
accepted_at: string | null;
|
||||||
|
resolved_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeRecord {
|
||||||
|
id: string;
|
||||||
|
observer: string;
|
||||||
|
subject: string | null;
|
||||||
|
fact: string;
|
||||||
|
certainty: KnowledgeCertainty;
|
||||||
|
source: string;
|
||||||
|
learned_at: string;
|
||||||
|
last_verified_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemInstance {
|
||||||
|
id: string;
|
||||||
|
spec_ref: ResourceId;
|
||||||
|
owner: string;
|
||||||
|
holder: string;
|
||||||
|
placement: ItemPlacement;
|
||||||
|
quantity: number;
|
||||||
|
condition: string;
|
||||||
|
state_tags: string[];
|
||||||
|
acquisition: {
|
||||||
|
mode: AcquisitionMode;
|
||||||
|
from: string | null;
|
||||||
|
at_node: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeState {
|
||||||
|
story_id: string;
|
||||||
|
current_node: string;
|
||||||
|
current_branch: string;
|
||||||
|
world_flags: Record<string, boolean>;
|
||||||
|
relationships: Record<string, RelationshipAxes>;
|
||||||
|
relationship_states: unknown[];
|
||||||
|
promises: Promise[];
|
||||||
|
knowledge: KnowledgeRecord[];
|
||||||
|
items: ItemInstance[];
|
||||||
|
clocks: unknown[];
|
||||||
|
checks: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StateOp =
|
||||||
|
| { op: "set_world_flag"; key: string; value: boolean }
|
||||||
|
| { op: "adjust_relationship"; from: string; to: string; adjustment: RelationshipAdjustment }
|
||||||
|
| { op: "create_promise"; promise: Promise }
|
||||||
|
| { op: "update_promise"; promise_id: string; status: PromiseStatus; resolved_at: string | null }
|
||||||
|
| { op: "add_knowledge"; record: KnowledgeRecord }
|
||||||
|
| { op: "add_item"; item: ItemInstance }
|
||||||
|
| {
|
||||||
|
op: "transfer_item";
|
||||||
|
item_id: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
placement: ItemPlacement;
|
||||||
|
mode: AcquisitionMode;
|
||||||
|
}
|
||||||
|
| { op: "record_check"; check: unknown }
|
||||||
|
| { op: "advance_clock"; clock_id: string; delta: number };
|
||||||
|
|
||||||
|
export type BeatKind = "narration" | "dialogue" | "action" | "system";
|
||||||
|
|
||||||
|
export interface PresentationBeat {
|
||||||
|
id: string;
|
||||||
|
kind: BeatKind;
|
||||||
|
speaker: string | null;
|
||||||
|
text: string;
|
||||||
|
visual: {
|
||||||
|
character: string | null;
|
||||||
|
expression: string | null;
|
||||||
|
pose: string | null;
|
||||||
|
scene: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RelationshipBand = "distant" | "guarded" | "warming" | "close" | "bonded";
|
||||||
|
|
||||||
|
export interface RelationshipView {
|
||||||
|
affinity: RelationshipBand;
|
||||||
|
trust: RelationshipBand;
|
||||||
|
hope: RelationshipBand;
|
||||||
|
respect: RelationshipBand;
|
||||||
|
intimacy: RelationshipBand;
|
||||||
|
attachment: RelationshipBand;
|
||||||
|
updatedAtNode: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerView {
|
||||||
|
storyId: string;
|
||||||
|
nodeId: string;
|
||||||
|
branchId: string;
|
||||||
|
sceneId: string;
|
||||||
|
sceneTitle: string;
|
||||||
|
characterName: string;
|
||||||
|
beats: PresentationBeat[];
|
||||||
|
suggestions: Array<{ id: string; label: string; draft: string }>;
|
||||||
|
inventory: Array<{
|
||||||
|
instanceId: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
placement: ItemPlacement;
|
||||||
|
condition: string;
|
||||||
|
}>;
|
||||||
|
knowledge: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
certainty: KnowledgeCertainty;
|
||||||
|
}>;
|
||||||
|
promises: Array<{
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
status: PromiseStatus;
|
||||||
|
weight: PromiseWeight;
|
||||||
|
}>;
|
||||||
|
relationship: RelationshipView;
|
||||||
|
history: Array<{
|
||||||
|
id: string;
|
||||||
|
parentId: string | null;
|
||||||
|
branchId: string;
|
||||||
|
label: string;
|
||||||
|
isCurrent: boolean;
|
||||||
|
}>;
|
||||||
|
canContinue: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TurnIntent = "speak_or_act" | "continue" | "regenerate" | "push_check";
|
||||||
|
|
||||||
|
export interface TurnRequest {
|
||||||
|
storyId: string;
|
||||||
|
branchId: string;
|
||||||
|
expectedNodeId: string;
|
||||||
|
actionId: string;
|
||||||
|
intent: TurnIntent;
|
||||||
|
input: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnResult {
|
||||||
|
committedNodeId: string;
|
||||||
|
playerView: PlayerView;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TurnFailureCode =
|
||||||
|
| "stale_node"
|
||||||
|
| "invalid_input"
|
||||||
|
| "invalid_model_output"
|
||||||
|
| "provider_unavailable"
|
||||||
|
| "cancelled"
|
||||||
|
| "timed_out"
|
||||||
|
| "internal";
|
||||||
|
|
||||||
|
export interface TurnFailure {
|
||||||
|
code: TurnFailureCode;
|
||||||
|
message: string;
|
||||||
|
retryable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppInfo {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
contractVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DemoPackSummary {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
revision: string;
|
||||||
|
characters: number;
|
||||||
|
worldBooks: number;
|
||||||
|
personas: number;
|
||||||
|
plotModules: number;
|
||||||
|
itemSpecs: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "nana-contracts"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
nana-domain.workspace = true
|
||||||
|
schemars.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
ts-rs.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
use std::{
|
||||||
|
env, fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
use nana_domain::{
|
||||||
|
AcquisitionMode, ActionSuggestion, AppInfo, BeatKind, CharacterCard, CharacterJudgmentRule,
|
||||||
|
CharacterStyle, CheckDifficulty, CheckRecord, CheckResult, ClockState, DemoPackSummary,
|
||||||
|
HistoryNodeView, ItemAcquisition, ItemInstance, ItemMechanics, ItemPlacement, ItemSpec,
|
||||||
|
KnowledgeCertainty, KnowledgeRecord, Persona, PlayerItemView, PlayerKnowledgeView,
|
||||||
|
PlayerPromiseView, PlayerView, PlotEvent, PlotModule, PlotOutcome, PlotPressure, Promise,
|
||||||
|
PromiseStatus, PromiseWeight, RelationshipAdjustment, RelationshipAxes, RelationshipBand,
|
||||||
|
RelationshipDimension, RelationshipState, RelationshipView, ResourceBundle, ResourceHeader,
|
||||||
|
ResourceId, ResourceKind, ResourceRef, RuntimeState, SkillValue, StateDelta, StateOp, Story,
|
||||||
|
StoryBinding, StoryNode, TurnFailure, TurnFailureCode, TurnIntent, TurnRequest, TurnResult,
|
||||||
|
ValidationCode, ValidationIssue, ValidationReport, VisualDirective, WorldBook, WorldBookEntry,
|
||||||
|
};
|
||||||
|
use schemars::{JsonSchema, schema_for};
|
||||||
|
use serde::Serialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let check = env::args().any(|argument| argument == "--check");
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
let outputs = generated_outputs(&root)?;
|
||||||
|
|
||||||
|
if check {
|
||||||
|
let mut stale = Vec::new();
|
||||||
|
for (path, expected) in outputs {
|
||||||
|
if fs::read(&path).ok().as_deref() != Some(expected.as_slice()) {
|
||||||
|
stale.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stale.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
for path in stale {
|
||||||
|
eprintln!("stale generated contract: {}", path.display());
|
||||||
|
}
|
||||||
|
return Err("generated contracts are stale".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (path, bytes) in outputs {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
fs::write(path, bytes)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generated_outputs(
|
||||||
|
root: &Path,
|
||||||
|
) -> Result<Vec<(PathBuf, Vec<u8>)>, Box<dyn std::error::Error>> {
|
||||||
|
let mut outputs = Vec::new();
|
||||||
|
let schema_dir = root.join("contracts/schema");
|
||||||
|
|
||||||
|
add_schema::<ResourceHeader>(&mut outputs, &schema_dir, "resource-header")?;
|
||||||
|
add_schema::<CharacterCard>(&mut outputs, &schema_dir, "character-card")?;
|
||||||
|
add_schema::<WorldBook>(&mut outputs, &schema_dir, "world-book")?;
|
||||||
|
add_schema::<Persona>(&mut outputs, &schema_dir, "persona")?;
|
||||||
|
add_schema::<PlotModule>(&mut outputs, &schema_dir, "plot-module")?;
|
||||||
|
add_schema::<ItemSpec>(&mut outputs, &schema_dir, "item-spec")?;
|
||||||
|
add_schema::<ResourceBundle>(&mut outputs, &schema_dir, "resource-bundle")?;
|
||||||
|
add_schema::<StoryNode>(&mut outputs, &schema_dir, "story-node")?;
|
||||||
|
add_schema::<RuntimeState>(&mut outputs, &schema_dir, "runtime-state")?;
|
||||||
|
add_schema::<PlayerView>(&mut outputs, &schema_dir, "player-view")?;
|
||||||
|
add_schema::<TurnRequest>(&mut outputs, &schema_dir, "turn-request")?;
|
||||||
|
add_schema::<TurnResult>(&mut outputs, &schema_dir, "turn-result")?;
|
||||||
|
add_schema::<TurnFailure>(&mut outputs, &schema_dir, "turn-failure")?;
|
||||||
|
add_schema::<AppInfo>(&mut outputs, &schema_dir, "app-info")?;
|
||||||
|
add_schema::<DemoPackSummary>(&mut outputs, &schema_dir, "demo-pack-summary")?;
|
||||||
|
|
||||||
|
let declarations = [
|
||||||
|
ResourceId::decl(),
|
||||||
|
ResourceKind::decl(),
|
||||||
|
ResourceRef::decl(),
|
||||||
|
ResourceHeader::decl(),
|
||||||
|
CharacterStyle::decl(),
|
||||||
|
CharacterJudgmentRule::decl(),
|
||||||
|
SkillValue::decl(),
|
||||||
|
CharacterCard::decl(),
|
||||||
|
WorldBookEntry::decl(),
|
||||||
|
WorldBook::decl(),
|
||||||
|
Persona::decl(),
|
||||||
|
PlotPressure::decl(),
|
||||||
|
PlotOutcome::decl(),
|
||||||
|
PlotEvent::decl(),
|
||||||
|
PlotModule::decl(),
|
||||||
|
ItemMechanics::decl(),
|
||||||
|
ItemSpec::decl(),
|
||||||
|
ResourceBundle::decl(),
|
||||||
|
RelationshipAxes::decl(),
|
||||||
|
RelationshipAdjustment::decl(),
|
||||||
|
RelationshipDimension::decl(),
|
||||||
|
RelationshipBand::decl(),
|
||||||
|
RelationshipView::decl(),
|
||||||
|
PromiseStatus::decl(),
|
||||||
|
PromiseWeight::decl(),
|
||||||
|
Promise::decl(),
|
||||||
|
RelationshipState::decl(),
|
||||||
|
KnowledgeCertainty::decl(),
|
||||||
|
KnowledgeRecord::decl(),
|
||||||
|
ItemPlacement::decl(),
|
||||||
|
AcquisitionMode::decl(),
|
||||||
|
ItemAcquisition::decl(),
|
||||||
|
ItemInstance::decl(),
|
||||||
|
CheckDifficulty::decl(),
|
||||||
|
CheckResult::decl(),
|
||||||
|
CheckRecord::decl(),
|
||||||
|
ClockState::decl(),
|
||||||
|
StoryBinding::decl(),
|
||||||
|
Story::decl(),
|
||||||
|
StateDelta::decl(),
|
||||||
|
StateOp::decl(),
|
||||||
|
StoryNode::decl(),
|
||||||
|
RuntimeState::decl(),
|
||||||
|
BeatKind::decl(),
|
||||||
|
VisualDirective::decl(),
|
||||||
|
PresentationBeat::decl(),
|
||||||
|
ActionSuggestion::decl(),
|
||||||
|
PlayerItemView::decl(),
|
||||||
|
PlayerKnowledgeView::decl(),
|
||||||
|
PlayerPromiseView::decl(),
|
||||||
|
HistoryNodeView::decl(),
|
||||||
|
PlayerView::decl(),
|
||||||
|
TurnIntent::decl(),
|
||||||
|
TurnRequest::decl(),
|
||||||
|
TurnResult::decl(),
|
||||||
|
TurnFailureCode::decl(),
|
||||||
|
TurnFailure::decl(),
|
||||||
|
AppInfo::decl(),
|
||||||
|
DemoPackSummary::decl(),
|
||||||
|
ValidationCode::decl(),
|
||||||
|
ValidationIssue::decl(),
|
||||||
|
ValidationReport::decl(),
|
||||||
|
]
|
||||||
|
.join("\n\n");
|
||||||
|
let ts = format!(
|
||||||
|
"// @generated by crates/nana-contracts; do not edit.\n\n{declarations}\n"
|
||||||
|
);
|
||||||
|
outputs.push((
|
||||||
|
root.join("contracts/ts/index.ts"),
|
||||||
|
ts.into_bytes(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let domain_source = fs::read(root.join("crates/nana-domain/src/lib.rs"))?;
|
||||||
|
let source_hash = format!("{:x}\n", Sha256::digest(domain_source));
|
||||||
|
outputs.push((
|
||||||
|
root.join("contracts/.source.sha256"),
|
||||||
|
source_hash.into_bytes(),
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(outputs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_schema<T: JsonSchema + Serialize>(
|
||||||
|
outputs: &mut Vec<(PathBuf, Vec<u8>)>,
|
||||||
|
schema_dir: &Path,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<(), serde_json::Error> {
|
||||||
|
let mut bytes = serde_json::to_vec_pretty(&schema_for!(T))?;
|
||||||
|
bytes.push(b'\n');
|
||||||
|
outputs.push((schema_dir.join(format!("{name}.schema.json")), bytes));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "nana-domain"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
schemars.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
ts-rs.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "nana-engine"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
nana-domain.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
use nana_domain::{RuntimeState, StateDelta};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum ReduceError {
|
||||||
|
#[error("state delta is not implemented yet: {0} operation(s)")]
|
||||||
|
NotImplemented(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// M0 contract seam. M1 replaces this placeholder with the only authoritative
|
||||||
|
/// state mutation path in the application.
|
||||||
|
pub fn apply_delta(state: &RuntimeState, delta: &StateDelta) -> Result<RuntimeState, ReduceError> {
|
||||||
|
if delta.ops.is_empty() {
|
||||||
|
Ok(state.clone())
|
||||||
|
} else {
|
||||||
|
Err(ReduceError::NotImplemented(delta.ops.len()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use nana_domain::{RuntimeState, StateDelta};
|
||||||
|
|
||||||
|
use super::apply_delta;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_delta_is_identity() {
|
||||||
|
let state = RuntimeState {
|
||||||
|
story_id: "story_demo".to_owned(),
|
||||||
|
current_node: "node_001".to_owned(),
|
||||||
|
current_branch: "branch_main".to_owned(),
|
||||||
|
world_flags: BTreeMap::new(),
|
||||||
|
relationships: BTreeMap::new(),
|
||||||
|
relationship_states: Vec::new(),
|
||||||
|
promises: Vec::new(),
|
||||||
|
knowledge: Vec::new(),
|
||||||
|
items: Vec::new(),
|
||||||
|
clocks: Vec::new(),
|
||||||
|
checks: Vec::new(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
apply_delta(&state, &StateDelta { ops: Vec::new() }),
|
||||||
|
Ok(state)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "nana-runtime"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
nana-domain.workspace = true
|
||||||
|
openlapp.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use nana_domain::{TurnFailure, TurnFailureCode, TurnRequest, TurnResult};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub const LAPP_BASELINE_COMMIT: &str = "5ba3c659e1536ec4bee16340faca603940a5cb17";
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ProviderError {
|
||||||
|
#[error("no recorded response remains")]
|
||||||
|
FixtureExhausted,
|
||||||
|
#[error("LAPP profile could not be loaded: {0}")]
|
||||||
|
Profile(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait TurnProvider {
|
||||||
|
fn complete_turn(&mut self, request: &TurnRequest) -> Result<TurnResult, ProviderError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct FakeProvider {
|
||||||
|
responses: VecDeque<TurnResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeProvider {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(responses: impl IntoIterator<Item = TurnResult>) -> Self {
|
||||||
|
Self {
|
||||||
|
responses: responses.into_iter().collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TurnProvider for FakeProvider {
|
||||||
|
fn complete_turn(&mut self, _request: &TurnRequest) -> Result<TurnResult, ProviderError> {
|
||||||
|
self.responses
|
||||||
|
.pop_front()
|
||||||
|
.ok_or(ProviderError::FixtureExhausted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_default_lapp_profile() -> Result<openlapp::Profile, ProviderError> {
|
||||||
|
openlapp::load_default_profile().map_err(|error| ProviderError::Profile(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn provider_failure(message: impl Into<String>) -> TurnFailure {
|
||||||
|
TurnFailure {
|
||||||
|
code: TurnFailureCode::ProviderUnavailable,
|
||||||
|
message: message.into(),
|
||||||
|
retryable: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "nana-store"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
nana-domain.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
use nana_domain::{RuntimeState, StoryNode};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum StoreError {
|
||||||
|
#[error("story not found: {0}")]
|
||||||
|
StoryNotFound(String),
|
||||||
|
#[error("storage backend is not initialized")]
|
||||||
|
NotInitialized,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait StoryStore: Send + Sync {
|
||||||
|
fn append_node(&self, node: &StoryNode, state: &RuntimeState) -> Result<(), StoreError>;
|
||||||
|
|
||||||
|
fn load_state(
|
||||||
|
&self,
|
||||||
|
story_id: &str,
|
||||||
|
branch_id: &str,
|
||||||
|
) -> Result<RuntimeState, StoreError>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 并行开发所有权
|
||||||
|
|
||||||
|
契约冻结提交之后,目录即所有权边界。
|
||||||
|
|
||||||
|
| 角色 | 独占目录 | 允许工作 |
|
||||||
|
|---|---|---|
|
||||||
|
| 集成人 | 根配置、`contracts/`、`docs/adr/`、`src-tauri/`、入口与 CI | 契约、生成物、接线、验收 |
|
||||||
|
| Core | `crates/nana-domain/`、`crates/nana-engine/`、`crates/nana-store/` | 类型、reducer、规则、SQLite、重放 |
|
||||||
|
| Runtime | `crates/nana-runtime/`、`fixtures/turns/` | LAPP、上下文、工具循环、Turn Engine |
|
||||||
|
| Experience | `src/`、`content/nana-demo/` | Vue、演出、试玩内容 |
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
1. 根 `Cargo.toml`、`package.json`、锁文件、生成代码和公共契约仅由集成人修改。
|
||||||
|
2. 新依赖和公共契约变化先提交短变更请求,不跨目录顺手修改。
|
||||||
|
3. 禁止无关重构、全仓格式化和依赖大版本升级。
|
||||||
|
4. 每个分支必须从同一份契约冻结提交开始。
|
||||||
|
5. 合并顺序固定为契约/fixtures → Core → Runtime → Experience → E2E。
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# ADR 0001:Rust 是公共契约的唯一事实来源
|
||||||
|
|
||||||
|
- 状态:已接受
|
||||||
|
- 日期:2026-07-28
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
公共数据模型只在 `nana-domain` 中定义。JSON Schema 与 TypeScript DTO 由
|
||||||
|
`nana-contracts` 生成并提交到 `contracts/`。CI 同时检查源文件哈希和生成结果。
|
||||||
|
|
||||||
|
禁止手工维护第二套同名模型。前端只能导入 `contracts/ts`。
|
||||||
|
|
||||||
|
## 原因
|
||||||
|
|
||||||
|
资源校验、状态 reducer、存档和 Tauri 命令都运行在 Rust 侧。让 Rust 成为唯一事实来源
|
||||||
|
可以避免多个子智能体分别发明不兼容的字段和枚举。
|
||||||
|
|
||||||
|
## 后果
|
||||||
|
|
||||||
|
- 任何契约变更必须重新生成 Schema 与 TypeScript。
|
||||||
|
- 契约变更必须连同 canonical fixtures 一起评审。
|
||||||
|
- 生成物漂移会使 `pnpm verify` 失败。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# ADR 0002:RuntimeState 与 PlayerView 是安全边界
|
||||||
|
|
||||||
|
- 状态:已接受
|
||||||
|
- 日期:2026-07-28
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
前端永远不接收完整 `RuntimeState`。所有界面通过 Rust 生成的 `PlayerView` 或同等
|
||||||
|
脱敏 DTO 读取数据。
|
||||||
|
|
||||||
|
`PlayerView` 只包含:
|
||||||
|
|
||||||
|
- 当前演出节拍和建议;
|
||||||
|
- 玩家当前可访问的持有物;
|
||||||
|
- 玩家已经获得的知识;
|
||||||
|
- 已接受且允许展示的许诺;
|
||||||
|
- 模糊化关系阶段;
|
||||||
|
- 可回溯节点摘要。
|
||||||
|
|
||||||
|
## 原因
|
||||||
|
|
||||||
|
这条边界从结构上阻止 NPC 隐藏物品、未揭示事实、精确关系数值、骰点和剧情节点泄漏。
|
||||||
|
仅靠前端“不要显示”不足以形成安全保证。
|
||||||
|
|
||||||
|
## 后果
|
||||||
|
|
||||||
|
- Vue 不允许导入或缓存 `RuntimeState`。
|
||||||
|
- PlayerView 泄密测试属于跨模块硬门槛。
|
||||||
|
- 新界面字段必须先证明玩家有权知道。
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# ADR 0003:供应商能力只通过 lapp-rs
|
||||||
|
|
||||||
|
- 状态:已接受
|
||||||
|
- 日期:2026-07-28
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
供应商协议、模型选择、凭据解析与网络调用全部属于 `lapp-rs`。`nana-story` 只在
|
||||||
|
`nana-runtime` 中保留窄适配层,不引入供应商 SDK。
|
||||||
|
|
||||||
|
开发基线固定为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
openlapp/lapp-rs@5ba3c659e1536ec4bee16340faca603940a5cb17
|
||||||
|
```
|
||||||
|
|
||||||
|
本地模型仅视为 HTTP 服务;应用不安装、下载、启动或管理 Ollama/LM Studio。
|
||||||
|
|
||||||
|
## 后果
|
||||||
|
|
||||||
|
- 普通 CI 使用确定性 Fake Provider。
|
||||||
|
- 真实模型只参与独立冒烟测试。
|
||||||
|
- `lapp-rs` 升级必须先通过协议夹具回归。
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,669 @@
|
|||||||
|
# `nana-story` V1 开发计划
|
||||||
|
|
||||||
|
> 中文名:《听娜娜讲故事》
|
||||||
|
> 计划版本:0.1
|
||||||
|
> 制定日期:2026-07-28
|
||||||
|
> 目标平台:Windows 桌面端
|
||||||
|
> 执行模型:1 个集成人 + 最多 3 个并行子智能体
|
||||||
|
|
||||||
|
## 1. 交付目标
|
||||||
|
|
||||||
|
V1 不是“通用 AI Galgame 平台”的完整形态,而是一段能够证明整个产品闭环的可玩纵切:
|
||||||
|
|
||||||
|
1. 用户通过 LAPP 选择并调用聊天模型;
|
||||||
|
2. 导入或使用内置的娜娜试玩资源包;
|
||||||
|
3. 创建故事并进行 20~30 分钟的自由输入式角色扮演;
|
||||||
|
4. 经历至少一次隐藏的简化 CoC7 判定;
|
||||||
|
5. 建立一项重要许诺,并让行为影响信赖、希望等关系维度;
|
||||||
|
6. 获得、使用或转移一件带隐藏背景的持有物;
|
||||||
|
7. 随时查看自己的持有物、已知线索和已接受许诺;
|
||||||
|
8. 查看模糊化的六维关系雷达图;
|
||||||
|
9. 每轮自动保存,退出后能够恢复;
|
||||||
|
10. 回溯到旧节点继续时创建新分支,并保留原线路。
|
||||||
|
|
||||||
|
V1 的成功标准不是资源数量,而是:
|
||||||
|
|
||||||
|
> 从创建故事到完成纵切,全程不需要开发者干预;状态可重放、隐藏信息不泄漏、回溯不破坏旧分支、异常不会留下半轮存档。
|
||||||
|
|
||||||
|
## 2. 已冻结的产品范围
|
||||||
|
|
||||||
|
### 2.1 技术与平台
|
||||||
|
|
||||||
|
- 仓库名:`nana-story`。
|
||||||
|
- 首发 Windows 桌面端;安卓以后再考虑。
|
||||||
|
- 技术栈:
|
||||||
|
- Tauri 2
|
||||||
|
- Vue 3
|
||||||
|
- TypeScript
|
||||||
|
- Vite
|
||||||
|
- Naive UI
|
||||||
|
- Pinia
|
||||||
|
- Vue Router
|
||||||
|
- 管理界面可以使用 Naive UI,游玩界面采用自定义视觉实现。
|
||||||
|
- 管理端、游玩端和运行时位于同一个 Tauri 桌面进程,不引入 daemon、sidecar 或本地网关。
|
||||||
|
|
||||||
|
### 2.2 模型与 LAPP 边界
|
||||||
|
|
||||||
|
- 供应商协议、模型选择、凭据解析和网络调用由 `lapp-rs` 负责。
|
||||||
|
- `nana-story` 不直接依赖任何供应商 SDK。
|
||||||
|
- `nana-story` 只提供必要的 LAPP 查看、选择、连接测试和简单管理能力。
|
||||||
|
- 本地模型只作为 HTTP 服务接入。
|
||||||
|
- 不负责安装、下载、启动、停止、更新或管理 Ollama、LM Studio 等本地模型运行器。
|
||||||
|
- 开发时可使用相邻仓库的 path dependency;发布构建必须固定到明确的 `lapp-rs` 提交,不能复制其协议语义。
|
||||||
|
|
||||||
|
### 2.3 游戏交互
|
||||||
|
|
||||||
|
- 自由输入为主。
|
||||||
|
- 每轮可提供 2~3 个可编辑行动建议;点击后只填入输入框,不直接提交。
|
||||||
|
- 提供“继续”按钮,表示沉默、观察、等待或允许 NPC 继续行动。
|
||||||
|
- AI 不得替玩家决定说话、主动行动或内心想法。
|
||||||
|
- 时间按照实际行动推进,不按消息轮数推进。
|
||||||
|
- 高风险行动需要先给出叙事风险提示,再执行隐藏的简化 CoC7 判定。
|
||||||
|
- 玩家默认看不到骰点、技能数值和精确关系变化。
|
||||||
|
- 回复由若干短演出节拍构成,而不是一整块聊天气泡。
|
||||||
|
|
||||||
|
### 2.4 角色、关系与剧情
|
||||||
|
|
||||||
|
- V1 只有一个完整角色:娜娜。
|
||||||
|
- 剧情模块可以包含会说话的轻量配角,但不为其建立完整关系系统、长期记忆或自主调度。
|
||||||
|
- 六维关系:
|
||||||
|
- 好感 `affinity`
|
||||||
|
- 信赖 `trust`
|
||||||
|
- 希望 `hope`
|
||||||
|
- 尊重 `respect`
|
||||||
|
- 亲密 `intimacy`
|
||||||
|
- 依恋 `attachment`
|
||||||
|
- 信赖主要来自已经发生的行为。
|
||||||
|
- 希望主要来自被接受的许诺和共同未来计划。
|
||||||
|
- 重要许诺必须保存为独立对象,不能简化成一次关系数值变化。
|
||||||
|
- 怨恨、恐惧、愧疚、嫉妒等保存为带来源的关系状态,不占六维。
|
||||||
|
- 创建故事时绑定剧情模块;V1 不支持中途增删模块。
|
||||||
|
|
||||||
|
### 2.5 持有物、知识与分支
|
||||||
|
|
||||||
|
- 物品定义、物品实例和角色认知必须分离。
|
||||||
|
- 所有权 `owner` 与当前持有者 `holder` 必须分离。
|
||||||
|
- 玩家可以随时读取自己当前可访问的持有物。
|
||||||
|
- NPC 的真实持有物不能直接暴露;只能显示公开可见或玩家已经知道的内容。
|
||||||
|
- V1 不处理负重、体积、格子或背包容量。
|
||||||
|
- 每轮自动保存。
|
||||||
|
- 从旧节点继续时创建新分支。
|
||||||
|
- V1 不做分支合并,也不建立跨分支记忆。
|
||||||
|
|
||||||
|
## 3. V1 明确不做
|
||||||
|
|
||||||
|
- 多个完整角色及多角色发言调度。
|
||||||
|
- 导演 Agent、自动总结 Agent 或运行时多 Agent 编排。
|
||||||
|
- 完整 CoC7 职业、成长、战斗、理智、追逐和幸运消费。
|
||||||
|
- Live2D、语音、口型、动态镜头和复杂动画。
|
||||||
|
- 分支合并和跨分支记忆。
|
||||||
|
- 中途增删剧情模块。
|
||||||
|
- 负重、体积、容量、制作和物品经济。
|
||||||
|
- 内置资源编辑器或可视化剧情编辑器。
|
||||||
|
- Steam Workshop 正式接入、DLC 支付、社区付费内容抽成。
|
||||||
|
- 云同步、账户、多人或跨设备存档。
|
||||||
|
- Android、Web 和多语言发布。
|
||||||
|
- 模型下载器、模型启动器和本地模型进程管理。
|
||||||
|
- 任意脚本、任意插件或任意外部工具执行。
|
||||||
|
|
||||||
|
V1 只保证资源包格式未来可以承接 Workshop/DLC,不实现市场本身。
|
||||||
|
|
||||||
|
## 4. 总体架构
|
||||||
|
|
||||||
|
### 4.1 三类事实
|
||||||
|
|
||||||
|
| 层级 | 内容 | 权威来源 |
|
||||||
|
|---|---|---|
|
||||||
|
| 创作资源 | CharacterCard、WorldBook、Persona、PlotModule、ItemSpec 和素材引用 | 不可变的版本化资源 |
|
||||||
|
| 分支状态 | 世界状态、关系、许诺、知识、持有物、时钟、判定记录 | 根状态/检查点 + 节点 delta |
|
||||||
|
| 上下文投影 | 当前意图、命中的世界书、最近演出、提示词 | 每轮临时编译,可重建 |
|
||||||
|
|
||||||
|
任何模型推测、检索结果和临时提示词都不是持久事实。需要持续存在的变化必须经过类型化操作,由状态 reducer 验证后写入当前分支。
|
||||||
|
|
||||||
|
### 4.2 单一权威写入路径
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["玩家输入"] --> B["上下文编译"]
|
||||||
|
B --> C["单模型工具循环"]
|
||||||
|
C --> D["暂存工具结果与状态操作"]
|
||||||
|
D --> E["规则校验与 reducer"]
|
||||||
|
E --> F["原子提交 StoryNode"]
|
||||||
|
F --> G["生成 PlayerView"]
|
||||||
|
G --> H["Vue 演出"]
|
||||||
|
```
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 模型和前端都不能直接修改 `RuntimeState`。
|
||||||
|
- reducer 是状态变化的唯一权威入口。
|
||||||
|
- 一轮生成要么完整提交,要么完全不提交。
|
||||||
|
- 模型失败、取消、超时或畸形输出不得留下半轮状态。
|
||||||
|
- 同一玩家行动的重新生成复用原判定记录;推骰属于新行动和新节点。
|
||||||
|
|
||||||
|
### 4.3 前后端安全边界
|
||||||
|
|
||||||
|
前端不得得到完整 `RuntimeState`,只能得到经过投影的 `PlayerView`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PlayerView
|
||||||
|
├─ 当前演出节拍
|
||||||
|
├─ 可编辑行动建议
|
||||||
|
├─ 玩家可访问的持有物
|
||||||
|
├─ 已知线索
|
||||||
|
├─ 已接受许诺
|
||||||
|
├─ 模糊化六维关系
|
||||||
|
└─ 可回溯节点摘要
|
||||||
|
```
|
||||||
|
|
||||||
|
这条边界用于结构性防止:
|
||||||
|
|
||||||
|
- NPC 隐藏物品泄漏;
|
||||||
|
- 未揭示世界事实泄漏;
|
||||||
|
- 精确关系数值泄漏;
|
||||||
|
- 内部骰点和难度泄漏;
|
||||||
|
- 未触发的剧情节点泄漏。
|
||||||
|
|
||||||
|
### 4.4 推荐持久化
|
||||||
|
|
||||||
|
V1 默认采用嵌入式 SQLite,由 Rust 侧独占访问。
|
||||||
|
|
||||||
|
- 内容包以经过校验的不可变文件形式安装到应用数据目录。
|
||||||
|
- SQLite 保存故事、资源绑定、节点、分支、检查点和迁移版本。
|
||||||
|
- 节点中的状态操作使用版本化、类型化 JSON。
|
||||||
|
- 每隔固定节点数创建检查点,恢复时使用“最近检查点 + 后续 delta”。
|
||||||
|
- SQLite 事务同时提交用户输入、模型演出、工具结果、判定记录和状态 delta。
|
||||||
|
|
||||||
|
建议最小表:
|
||||||
|
|
||||||
|
```text
|
||||||
|
stories
|
||||||
|
story_bindings
|
||||||
|
story_nodes
|
||||||
|
story_branches
|
||||||
|
state_checkpoints
|
||||||
|
installed_packs
|
||||||
|
schema_migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
不要在 V1 过早拆出几十张关系、物品、许诺明细表。分支事件流才是事实来源,当前状态是可重建投影。
|
||||||
|
|
||||||
|
### 4.5 模型运行时
|
||||||
|
|
||||||
|
V1 游戏运行时仍然只有一个模型,不引入导演 Agent。
|
||||||
|
|
||||||
|
每轮流程:
|
||||||
|
|
||||||
|
1. 读取当前节点、资源绑定和重建后的运行时状态;
|
||||||
|
2. 按优先级编译上下文;
|
||||||
|
3. 调用 `lapp-rs` 选中的聊天模型;
|
||||||
|
4. 模型按需调用判定、关系、许诺、知识、持有物、时钟等领域工具;
|
||||||
|
5. Rust 验证工具参数并写入暂存事务;
|
||||||
|
6. 模型根据工具返回继续生成短演出节拍和行动建议;
|
||||||
|
7. 校验演出协议与全部状态操作;
|
||||||
|
8. 原子提交新节点;
|
||||||
|
9. 生成脱敏后的 `PlayerView` 给前端。
|
||||||
|
|
||||||
|
上下文优先级:
|
||||||
|
|
||||||
|
1. 玩家自主权和输出协议;
|
||||||
|
2. 当前角色卡、Persona 与场景;
|
||||||
|
3. 当前关系、许诺、知识、持有物和剧情压力;
|
||||||
|
4. 命中的世界书条目;
|
||||||
|
5. 当前分支最近演出;
|
||||||
|
6. 非关键风格参考。
|
||||||
|
|
||||||
|
V1 不做向量记忆库。世界书先采用确定性的标签、关键词和条件触发;长上下文压缩只能作为可丢弃缓存,不能成为新的事实来源。
|
||||||
|
|
||||||
|
## 5. 仓库结构与代码所有权
|
||||||
|
|
||||||
|
建议从第一天就使用 Rust workspace,把并行边界变成目录边界:
|
||||||
|
|
||||||
|
```text
|
||||||
|
nana-story/
|
||||||
|
├─ src/ # Vue 应用
|
||||||
|
│ ├─ app/ # 路由、全局状态、真实/Mock adapter
|
||||||
|
│ ├─ features/play/ # 主演出屏
|
||||||
|
│ ├─ features/inventory/ # 持有物、线索、许诺
|
||||||
|
│ ├─ features/relationship/ # 关系档案
|
||||||
|
│ └─ features/history/ # 回溯与分支
|
||||||
|
├─ src-tauri/ # Tauri 外壳与窄命令
|
||||||
|
├─ crates/
|
||||||
|
│ ├─ nana-domain/ # 资源、状态、delta、PlayerView
|
||||||
|
│ ├─ nana-engine/ # reducer、规则、简化 CoC7
|
||||||
|
│ ├─ nana-store/ # SQLite、节点、检查点、恢复
|
||||||
|
│ └─ nana-runtime/ # LAPP、上下文、工具循环、Turn Engine
|
||||||
|
├─ contracts/ # 生成的 JSON Schema 与 TS 类型
|
||||||
|
├─ content/nana-demo/ # 试玩纵切资源包
|
||||||
|
├─ fixtures/ # 稳定的契约和模型响应样例
|
||||||
|
├─ tests/e2e/ # 跨模块验收
|
||||||
|
└─ docs/adr/ # 已接受架构决策
|
||||||
|
```
|
||||||
|
|
||||||
|
单一契约源:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Rust serde 类型
|
||||||
|
→ 生成 JSON Schema
|
||||||
|
→ 生成 TypeScript DTO
|
||||||
|
→ CI 检查生成物是否漂移
|
||||||
|
```
|
||||||
|
|
||||||
|
禁止同时手写 Rust、Schema 和 TypeScript 三套同名模型。
|
||||||
|
|
||||||
|
## 6. 多子智能体并行方案
|
||||||
|
|
||||||
|
### 6.1 推荐拓扑
|
||||||
|
|
||||||
|
并行度维持在 1 个集成人 + 3 个子智能体。
|
||||||
|
|
||||||
|
| 角色 | 独占范围 | 主要职责 |
|
||||||
|
|---|---|---|
|
||||||
|
| 集成人 | 公共契约、根配置、入口、生成物、跨模块接线、最终验收 | 拆任务、冻结契约、日常合并、拒绝范围扩张 |
|
||||||
|
| 子智能体 A:Core | `nana-domain`、`nana-engine`、`nana-store` | 类型、reducer、规则、SQLite、分支重放 |
|
||||||
|
| 子智能体 B:Runtime | `nana-runtime` | LAPP、上下文编译、工具循环、Turn Engine |
|
||||||
|
| 子智能体 C:Experience | `src/`、`content/nana-demo/` | Vue 界面、演出、官方 Fixture、娜娜纵切内容 |
|
||||||
|
|
||||||
|
测试靠近各自模块编写;跨模块 E2E 和契约生成由集成人负责。
|
||||||
|
|
||||||
|
不建议同时开启 5~8 个编码子智能体。这个项目的瓶颈是共享语义而不是代码量,过高并行度会把收益变成 Schema、状态提交和 UI DTO 的返工。
|
||||||
|
|
||||||
|
### 6.2 必须先冻结的契约
|
||||||
|
|
||||||
|
进入正式并行前必须合并:
|
||||||
|
|
||||||
|
- `ResourceHeader`
|
||||||
|
- `CharacterCard`
|
||||||
|
- `WorldBook`
|
||||||
|
- `Persona`
|
||||||
|
- `PlotModule`
|
||||||
|
- `ItemSpec` 与 `ItemInstance`
|
||||||
|
- `Story`、`StoryBinding`、`StoryNode`
|
||||||
|
- `RuntimeState`
|
||||||
|
- 所有状态操作的判别联合
|
||||||
|
- `PresentationBeat`
|
||||||
|
- `PlayerView`
|
||||||
|
- `TurnRequest`、`TurnResult`、`TurnFailure`
|
||||||
|
- 五组 canonical fixtures:
|
||||||
|
- 普通对话;
|
||||||
|
- 隐藏判定;
|
||||||
|
- 许诺建立与履行;
|
||||||
|
- 物品转移且 NPC 尚未知情;
|
||||||
|
- 回溯后分叉。
|
||||||
|
|
||||||
|
### 6.3 Worktree 与分支
|
||||||
|
|
||||||
|
每个子智能体必须使用独立 Git worktree:
|
||||||
|
|
||||||
|
```text
|
||||||
|
integration/v1
|
||||||
|
agent/core
|
||||||
|
agent/runtime
|
||||||
|
agent/experience
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 所有分支从同一份已冻结契约提交开始。
|
||||||
|
- 每个子智能体只修改分配的目录。
|
||||||
|
- 根 `Cargo.toml`、`package.json`、锁文件、CI、Tauri 入口、Vue 入口和生成代码只由集成人修改。
|
||||||
|
- 子智能体需要新增依赖或修改公共契约时,提交短小的变更请求,不直接跨目录修改。
|
||||||
|
- 禁止无关重构、全仓格式化和顺手升级依赖。
|
||||||
|
- 每天至少合并一次小的可验证增量,不在阶段末进行巨型合并。
|
||||||
|
- 只有集成人解决跨模块冲突。
|
||||||
|
|
||||||
|
### 6.4 子智能体任务包模板
|
||||||
|
|
||||||
|
每个任务必须包含:
|
||||||
|
|
||||||
|
```text
|
||||||
|
目标:
|
||||||
|
基线提交:
|
||||||
|
允许修改的目录:
|
||||||
|
输入契约版本:
|
||||||
|
必须新增的测试:
|
||||||
|
验收命令:
|
||||||
|
明确不做:
|
||||||
|
交付时需要说明的风险:
|
||||||
|
```
|
||||||
|
|
||||||
|
没有这些信息的任务不应分派给子智能体。
|
||||||
|
|
||||||
|
## 7. 里程碑与并行波次
|
||||||
|
|
||||||
|
基准工期为 25 个有效开发日,约 5 周。AI 子智能体可以压缩编码时间,但不能取消契约、纵切联调和 Windows 发布验证。
|
||||||
|
|
||||||
|
### M0:工程骨架与契约冻结(第 1~2 日)
|
||||||
|
|
||||||
|
交付:
|
||||||
|
|
||||||
|
- 初始化 Tauri 2 + Vue 3 工程和 Rust workspace;
|
||||||
|
- 接入相邻 `lapp-rs`,固定开发基线;
|
||||||
|
- 建立 `pnpm verify` 或等价的一键校验;
|
||||||
|
- 定义 Rust 领域类型、生成 JSON Schema 和 TS DTO;
|
||||||
|
- 建立 canonical fixtures;
|
||||||
|
- 内置最小娜娜资源包;
|
||||||
|
- 写入首批 ADR。
|
||||||
|
|
||||||
|
演示:
|
||||||
|
|
||||||
|
- Windows 桌面窗口可以启动;
|
||||||
|
- 前端通过窄命令读取应用版本和一份静态 `PlayerView`;
|
||||||
|
- 最小娜娜资源包能够加载。
|
||||||
|
|
||||||
|
硬门槛:
|
||||||
|
|
||||||
|
- CI 可重复运行;
|
||||||
|
- 非法资源返回稳定错误;
|
||||||
|
- ID、版本、哈希、依赖和引用可校验;
|
||||||
|
- Rust、Schema、TS 不存在多个事实来源。
|
||||||
|
|
||||||
|
并行:
|
||||||
|
|
||||||
|
- 集成人:契约与仓库骨架;
|
||||||
|
- A:CI 和 Rust workspace;
|
||||||
|
- B:Fake LAPP Provider/录制模型响应夹具;
|
||||||
|
- C:按四张原型搭静态 UI 外壳。
|
||||||
|
|
||||||
|
### M1:确定性状态内核(第 3~6 日)
|
||||||
|
|
||||||
|
交付:
|
||||||
|
|
||||||
|
- `RuntimeState`;
|
||||||
|
- 纯 reducer;
|
||||||
|
- 六维关系与单轮限幅;
|
||||||
|
- 许诺生命周期;
|
||||||
|
- 知识与可见性;
|
||||||
|
- 物品 owner/holder、位置、取得方式;
|
||||||
|
- 时钟;
|
||||||
|
- 简化 CoC7 判定记录;
|
||||||
|
- `PlayerView` 投影;
|
||||||
|
- SQLite 节点、delta 和检查点。
|
||||||
|
|
||||||
|
演示:
|
||||||
|
|
||||||
|
- 不调用模型,仅通过固定命令完成:
|
||||||
|
- 获得物品;
|
||||||
|
- 作出许诺;
|
||||||
|
- 改变关系;
|
||||||
|
- 推进时钟;
|
||||||
|
- 执行一次判定;
|
||||||
|
- 保存、关闭、恢复。
|
||||||
|
|
||||||
|
硬门槛:
|
||||||
|
|
||||||
|
- 同一根状态 + 同一组 delta 必须产生完全相同的 `RuntimeState` 和 `PlayerView`;
|
||||||
|
- delta 重放不能重复结算;
|
||||||
|
- 关系变化会被规则限幅;
|
||||||
|
- owner/holder 语义正确;
|
||||||
|
- 玩家无法读取 NPC 未知持有物;
|
||||||
|
- 同一行动重新生成时可以复用判定记录。
|
||||||
|
|
||||||
|
并行:
|
||||||
|
|
||||||
|
- A:domain、engine、store;
|
||||||
|
- B:基于 fixture 实现 runtime 接口和 Fake Provider;
|
||||||
|
- C:前端使用同一份 `PlayerView` fixture 完成完整假数据交互。
|
||||||
|
|
||||||
|
### M2:单轮模型运行循环(第 5~10 日,与 M1 后半段重叠)
|
||||||
|
|
||||||
|
交付:
|
||||||
|
|
||||||
|
- LAPP profile 读取、模型选择和连接测试;
|
||||||
|
- 非流式与流式聊天适配;
|
||||||
|
- 上下文编译器;
|
||||||
|
- 领域工具目录;
|
||||||
|
- 工具循环和最大轮次保护;
|
||||||
|
- `PresentationBeat`;
|
||||||
|
- 暂存事务、校验、提交和回滚;
|
||||||
|
- 取消、超时、畸形输出和一次安全重试。
|
||||||
|
|
||||||
|
演示:
|
||||||
|
|
||||||
|
- 用户输入一句自由文本;
|
||||||
|
- 娜娜以短节拍回应;
|
||||||
|
- 表情发生一次可选变化;
|
||||||
|
- 生成 2~3 个可编辑建议;
|
||||||
|
- 一次需要判定的行动经过工具返回后继续叙述;
|
||||||
|
- 整轮状态一次性提交。
|
||||||
|
|
||||||
|
硬门槛:
|
||||||
|
|
||||||
|
- 模型和 UI 都不能直接写 `RuntimeState`;
|
||||||
|
- 工具失败、取消和断流不产生半轮节点;
|
||||||
|
- 模型不能替玩家决定主动言行和内心;
|
||||||
|
- 高风险行动先有风险提示;
|
||||||
|
- 凭据、完整上游错误体和隐藏状态不进入日志或前端 DTO;
|
||||||
|
- CI 使用 Fake Provider,不依赖真实付费模型。
|
||||||
|
|
||||||
|
并行:
|
||||||
|
|
||||||
|
- A:补齐 reducer 和 store 的运行时接口;
|
||||||
|
- B:实现 LAPP adapter、上下文和工具循环;
|
||||||
|
- C:把主演出屏从 fixture adapter 切到真实 Turn API。
|
||||||
|
|
||||||
|
### M3:自动存档、回溯和分支(第 8~14 日)
|
||||||
|
|
||||||
|
交付:
|
||||||
|
|
||||||
|
- 每轮原子自动保存;
|
||||||
|
- 分支头和节点父子关系;
|
||||||
|
- 从任意旧节点创建新分支;
|
||||||
|
- 旧线路保留;
|
||||||
|
- 故事恢复;
|
||||||
|
- 同输入重新生成与推骰的不同语义;
|
||||||
|
- 简洁故事时间线和完整回溯页。
|
||||||
|
|
||||||
|
演示:
|
||||||
|
|
||||||
|
- 连续玩 5 轮后退出;
|
||||||
|
- 重启恢复;
|
||||||
|
- 回到第 2 轮并继续;
|
||||||
|
- 原分支和新分支同时存在;
|
||||||
|
- 两条分支的持有物、许诺和关系状态互不污染。
|
||||||
|
|
||||||
|
硬门槛:
|
||||||
|
|
||||||
|
- 每轮只出现一次有效提交;
|
||||||
|
- 重启后的状态与提交前一致;
|
||||||
|
- 回溯不会修改旧节点;
|
||||||
|
- 重新生成复用原判定;
|
||||||
|
- 推骰创建新行动和新判定记录;
|
||||||
|
- 500 节点以内的分支恢复不需要扫描无关分支。
|
||||||
|
|
||||||
|
并行:
|
||||||
|
|
||||||
|
- A:分支、检查点、恢复和迁移;
|
||||||
|
- B:重新生成、推骰与运行时事务;
|
||||||
|
- C:故事回溯 UI 和错误恢复交互。
|
||||||
|
|
||||||
|
### M4:完整玩法系统与四个界面(第 11~18 日)
|
||||||
|
|
||||||
|
交付:
|
||||||
|
|
||||||
|
- 主游戏界面;
|
||||||
|
- 持有物/线索/许诺侧栏;
|
||||||
|
- 六维关系档案;
|
||||||
|
- 故事回溯页;
|
||||||
|
- 角色卡、世界书、Persona、剧情模块和 ItemSpec 的导入校验;
|
||||||
|
- 轻量配角;
|
||||||
|
- 场景、立绘、表情和姿态保持;
|
||||||
|
- “自由输入 / 建议 / 继续”三种操作。
|
||||||
|
|
||||||
|
演示:
|
||||||
|
|
||||||
|
- 四张界面原型对应的真实交互全部接入运行状态;
|
||||||
|
- 玩家查看自己的持有物;
|
||||||
|
- NPC 未知物品不会出现;
|
||||||
|
- 重要许诺能从 accepted 发展到 fulfilled 或 broken;
|
||||||
|
- 关系雷达变化但不显示精确数字;
|
||||||
|
- 轻量配角可以参与一段事件但不会获得完整关系系统。
|
||||||
|
|
||||||
|
硬门槛:
|
||||||
|
|
||||||
|
- 所有 UI 数据只来自 `PlayerView` 或其他脱敏 DTO;
|
||||||
|
- 不显示骰点、检定难度和精确关系增量;
|
||||||
|
- UI 不把消息和演出节拍混为一套结构;
|
||||||
|
- 静态资源加载失败有占位和明确错误;
|
||||||
|
- 导入包不能通过路径穿越写出目标目录。
|
||||||
|
|
||||||
|
并行:
|
||||||
|
|
||||||
|
- A:资源导入、规则系统和 PlayerView;
|
||||||
|
- B:世界书触发、剧情工具和轻量配角上下文;
|
||||||
|
- C:四个界面和演出状态机。
|
||||||
|
|
||||||
|
### M5:娜娜 20~30 分钟纵切(第 17~21 日)
|
||||||
|
|
||||||
|
纵切至少包含:
|
||||||
|
|
||||||
|
- 一个明确开场目标;
|
||||||
|
- 一次普通自由对话;
|
||||||
|
- 一次隐藏判定;
|
||||||
|
- 一次玩家可感知的风险提示;
|
||||||
|
- 一个被接受的重要许诺;
|
||||||
|
- 一次用行动证明或破坏许诺;
|
||||||
|
- 一件具有隐藏背景的物品;
|
||||||
|
- 一次 owner/holder 不同的物品状态;
|
||||||
|
- 一次关系路线分化;
|
||||||
|
- 一个剧情时钟;
|
||||||
|
- 一次轻量配角发言;
|
||||||
|
- 一次回溯;
|
||||||
|
- 至少两个结果方向。
|
||||||
|
|
||||||
|
硬门槛:
|
||||||
|
|
||||||
|
- 无开发者操作可以从开场玩到结果;
|
||||||
|
- 条件触发没有死路;
|
||||||
|
- 角色不泄露玩家尚未获得的知识;
|
||||||
|
- 不同选择产生可回放的不同状态;
|
||||||
|
- 引擎规则中不得硬编码 `nana` 或纵切事件 ID;
|
||||||
|
- 使用另一个最小反例资源夹具证明引擎仍然通用。
|
||||||
|
|
||||||
|
本阶段停止增加系统功能。所有子智能体只处理纵切暴露的问题。
|
||||||
|
|
||||||
|
### M6:Windows 发布前加固(第 22~25 日)
|
||||||
|
|
||||||
|
交付:
|
||||||
|
|
||||||
|
- Windows 安装包;
|
||||||
|
- 崩溃和断电恢复;
|
||||||
|
- 存档迁移夹具;
|
||||||
|
- 模型超时、断网、限流、拒绝、畸形工具调用处理;
|
||||||
|
- 长对话上下文预算测试;
|
||||||
|
- 日志脱敏;
|
||||||
|
- 内容包边界和尺寸限制;
|
||||||
|
- 用户可理解的错误页;
|
||||||
|
- 最小发布说明和已知限制。
|
||||||
|
|
||||||
|
硬门槛:
|
||||||
|
|
||||||
|
- 全新 Windows 环境能够安装、启动和卸载;
|
||||||
|
- 配置一个 LAPP 模型后能够完成纵切;
|
||||||
|
- 进程被强制关闭后不会产生半轮节点;
|
||||||
|
- 旧 Schema 存档夹具能够迁移或给出明确的不兼容提示;
|
||||||
|
- 凭据不进入前端状态、日志、崩溃报告或存档;
|
||||||
|
- 所有 canonical fixtures、单元测试和跨模块验收通过。
|
||||||
|
|
||||||
|
## 8. 合并顺序与质量门
|
||||||
|
|
||||||
|
每个并行波次按以下顺序合并:
|
||||||
|
|
||||||
|
1. 契约与 fixtures;
|
||||||
|
2. domain/engine/store;
|
||||||
|
3. runtime;
|
||||||
|
4. client adapter;
|
||||||
|
5. 跨模块 E2E;
|
||||||
|
6. 纵切内容更新。
|
||||||
|
|
||||||
|
任何合并都必须跑同一条最小跨模块场景:
|
||||||
|
|
||||||
|
```text
|
||||||
|
创建故事
|
||||||
|
→ 三轮交互
|
||||||
|
→ 一次隐藏判定
|
||||||
|
→ 获得物品
|
||||||
|
→ 建立许诺
|
||||||
|
→ 关闭并重启
|
||||||
|
→ 回溯
|
||||||
|
→ 从旧节点继续
|
||||||
|
→ 校验两个分支互不污染
|
||||||
|
```
|
||||||
|
|
||||||
|
阶段门禁:
|
||||||
|
|
||||||
|
- M1 未通过确定性重放,不接正式存档 UI。
|
||||||
|
- M2 未实现“校验后一次性提交”,不允许模型修改运行时状态。
|
||||||
|
- M3 未通过分支隔离,不开放回溯入口。
|
||||||
|
- M5 未跑通完整纵切,不扩展第二完整角色、编辑器、Workshop 或 Live2D。
|
||||||
|
|
||||||
|
## 9. 测试矩阵
|
||||||
|
|
||||||
|
| 层级 | 主要测试 |
|
||||||
|
|---|---|
|
||||||
|
| Domain | Schema、ID/引用、版本/哈希、资源依赖、反序列化错误 |
|
||||||
|
| Engine | reducer、限幅、许诺生命周期、知识可见性、物品 owner/holder、CoC7 |
|
||||||
|
| Store | 原子提交、重放、检查点、分支隔离、迁移、损坏恢复 |
|
||||||
|
| Runtime | Fake Provider、工具循环、上下文预算、取消、超时、畸形输出 |
|
||||||
|
| Security | PlayerView 泄密、日志脱敏、内容包路径穿越、凭据边界 |
|
||||||
|
| Frontend | 演出状态机、建议编辑、继续、侧栏、雷达、回溯 |
|
||||||
|
| Cross-module | 完整最小场景、重启恢复、回溯继续、真实 LAPP 冒烟 |
|
||||||
|
| Content | 条件可达性、无死路、隐藏事实、不同结果、角色一致性 |
|
||||||
|
|
||||||
|
真实模型测试不作为普通 CI 的前提。CI 使用确定性 Fake Provider;真实模型只运行独立冒烟和录制回归。
|
||||||
|
|
||||||
|
## 10. 主要风险与控制
|
||||||
|
|
||||||
|
| 风险 | 控制 |
|
||||||
|
|---|---|
|
||||||
|
| 多个子智能体各自发明 Schema | Rust 类型为唯一事实来源;契约目录独占;变更必须更新 fixtures |
|
||||||
|
| UI 或模型直接修改状态 | 所有修改必须经过类型化工具和唯一 reducer |
|
||||||
|
| 模型输出不稳定 | 工具调用、结构校验、最大循环次数、一次安全重试、失败不提交 |
|
||||||
|
| 假数据与真实接口分裂 | UI 和运行时共用 canonical `PlayerView` fixtures |
|
||||||
|
| 分支恢复不确定 | 纯 reducer;固定 delta 顺序;每次重放做哈希校验 |
|
||||||
|
| 重生成刷骰 | 判定绑定玩家行动;重新生成复用,推骰创建新行动 |
|
||||||
|
| 隐藏信息泄漏 | 后端生成 `PlayerView`;前端永远不获得完整状态 |
|
||||||
|
| `lapp-rs` 仍处于 0.1 阶段 | 固定提交;包一层窄 adapter;用三种协议夹具做回归 |
|
||||||
|
| 本地模型不支持工具调用 | V1 启动前做能力检测并明确提示;不在 V1 增加第二套自由文本解析运行时 |
|
||||||
|
| 子智能体大范围重构 | 目录所有权、短任务包、禁止无关改动、每日小合并 |
|
||||||
|
| Windows 问题最后才出现 | M0 即建立 Windows 构建;每个阶段做一次安装/启动冒烟 |
|
||||||
|
| 纵切内容被写成娜娜专用代码 | 引擎只识别通用标签和资源引用;增加非娜娜反例夹具 |
|
||||||
|
|
||||||
|
## 11. V1 Definition of Done
|
||||||
|
|
||||||
|
满足以下全部条件才称为 V1:
|
||||||
|
|
||||||
|
- Windows 安装包可用;
|
||||||
|
- 使用 LAPP 可以选择云端或本地 HTTP 模型并通过连接测试;
|
||||||
|
- 内置娜娜试玩包可直接创建故事;
|
||||||
|
- 20~30 分钟纵切可从头玩到至少两个结果方向;
|
||||||
|
- 自由输入、可编辑建议和“继续”都能稳定完成一轮;
|
||||||
|
- 六维关系、许诺、知识、持有物、时钟和隐藏判定都在纵切中真实生效;
|
||||||
|
- 每轮原子自动保存;
|
||||||
|
- 重启恢复一致;
|
||||||
|
- 回溯后建立新分支并保留旧线;
|
||||||
|
- 同一行动重新生成不重新掷骰;
|
||||||
|
- NPC 隐藏状态、精确关系值、骰点和凭据不泄漏;
|
||||||
|
- 模型取消、超时和畸形输出不产生半轮状态;
|
||||||
|
- canonical fixtures、自动测试和 Windows 冒烟全部通过;
|
||||||
|
- 没有引入 V1 明确不做的功能。
|
||||||
|
|
||||||
|
## 12. 开工顺序
|
||||||
|
|
||||||
|
第一批实际开发任务按下面顺序创建:
|
||||||
|
|
||||||
|
1. 初始化 `nana-story` 仓库、Tauri/Vue 工程和 Rust workspace;
|
||||||
|
2. 写入架构 ADR 与目录所有权;
|
||||||
|
3. 定义 `ResourceHeader`、资源绑定、`StoryNode`、`RuntimeState`、状态操作、`PresentationBeat` 和 `PlayerView`;
|
||||||
|
4. 生成 JSON Schema、TypeScript DTO 和 canonical fixtures;
|
||||||
|
5. 同时启动三个独立 worktree:
|
||||||
|
- Core:reducer + SQLite;
|
||||||
|
- Runtime:Fake Provider + Turn API;
|
||||||
|
- Experience:静态主演出屏 + fixture adapter;
|
||||||
|
6. 在 M1 确定性重放通过后,接入真实 `lapp-rs` 和第一条完整游戏回合。
|
||||||
|
|
||||||
|
第一条集成目标应当非常小:
|
||||||
|
|
||||||
|
> 玩家输入“我答应天亮前回来”,娜娜回应并接受许诺;引擎创建 Promise、提高希望、保存节点;关闭应用后恢复;回溯到许诺之前继续,旧分支仍然存在。
|
||||||
|
|
||||||
|
它同时覆盖角色演出、工具调用、许诺、关系、存档、恢复和分支,是最适合作为 `nana-story` 第一条端到端主线的开发样例。
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# M0 开工状态
|
||||||
|
|
||||||
|
日期:2026-07-28
|
||||||
|
|
||||||
|
## 已完成
|
||||||
|
|
||||||
|
- 初始化 Git、Tauri 2、Vue 3、TypeScript 与 Rust workspace。
|
||||||
|
- 固定 `lapp-rs` 基线提交。
|
||||||
|
- 建立 `nana-domain`、`nana-engine`、`nana-store`、`nana-runtime` 和
|
||||||
|
`nana-contracts` 边界。
|
||||||
|
- 定义第一批公共领域契约。
|
||||||
|
- 提交 15 份 JSON Schema、TypeScript DTO 和源文件漂移检查。
|
||||||
|
- 建立普通对话、隐藏判定、许诺、NPC 未知物品转移和回溯分叉 fixtures。
|
||||||
|
- 建立最小娜娜内容包“天亮之前”。
|
||||||
|
- 建立静态 `PlayerView` 主演出屏。
|
||||||
|
- 建立版本、试玩包摘要和静态 `PlayerView` 三个窄 Tauri 命令。
|
||||||
|
- 写入契约、安全边界与 LAPP 边界 ADR。
|
||||||
|
- 建立 Windows CI。
|
||||||
|
|
||||||
|
## 已通过
|
||||||
|
|
||||||
|
```text
|
||||||
|
pnpm contracts:check
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm test # 4 tests passed
|
||||||
|
pnpm build
|
||||||
|
pnpm verify:web
|
||||||
|
```
|
||||||
|
|
||||||
|
## 尚未通过
|
||||||
|
|
||||||
|
当前 Work 执行环境没有 `cargo`/Rust 1.96,因此以下门禁尚未执行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --workspace
|
||||||
|
cargo run -p nana-contracts -- --check
|
||||||
|
pnpm tauri dev
|
||||||
|
```
|
||||||
|
|
||||||
|
这意味着 M0 已经开工并形成第一份可合并基线,但在 Rust/Tauri 与 Windows 冒烟完成前,
|
||||||
|
不标记为正式完成。
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"sourceBranch": {
|
||||||
|
"id": "branch_main",
|
||||||
|
"head": "node_006"
|
||||||
|
},
|
||||||
|
"fork": {
|
||||||
|
"fromNode": "node_002",
|
||||||
|
"newBranch": "branch_002",
|
||||||
|
"newHead": "node_007"
|
||||||
|
},
|
||||||
|
"assertions": {
|
||||||
|
"sourceHeadUnchanged": "node_006",
|
||||||
|
"newParent": "node_002",
|
||||||
|
"crossBranchStateLeak": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"ops": [
|
||||||
|
{
|
||||||
|
"op": "transfer_item",
|
||||||
|
"item_id": "item_red_hairpin_001",
|
||||||
|
"from": "nana",
|
||||||
|
"to": "player",
|
||||||
|
"placement": "bag",
|
||||||
|
"mode": "stolen"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"assertions": {
|
||||||
|
"owner": "nana",
|
||||||
|
"holder": "player",
|
||||||
|
"nanaKnowsTransfer": false,
|
||||||
|
"visibleInPlayerInventory": true,
|
||||||
|
"hiddenFactsVisible": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"ops": [
|
||||||
|
{
|
||||||
|
"op": "create_promise",
|
||||||
|
"promise": {
|
||||||
|
"id": "promise_return_before_dawn",
|
||||||
|
"promiser": "player",
|
||||||
|
"promisee": "nana",
|
||||||
|
"content": "天亮前一定回来",
|
||||||
|
"status": "accepted",
|
||||||
|
"weight": "major",
|
||||||
|
"created_at": "node_003",
|
||||||
|
"accepted_at": "node_003",
|
||||||
|
"resolved_at": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"op": "adjust_relationship",
|
||||||
|
"from": "nana",
|
||||||
|
"to": "player",
|
||||||
|
"adjustment": {
|
||||||
|
"dimension": "hope",
|
||||||
|
"delta": 4,
|
||||||
|
"cause": "娜娜接受了玩家关于共同未来的明确许诺",
|
||||||
|
"judgment_rule": "nana.promises.accepted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"storyId": "story_nana_demo",
|
||||||
|
"nodeId": "node_001",
|
||||||
|
"branchId": "branch_main",
|
||||||
|
"sceneId": "old_station_platform",
|
||||||
|
"sceneTitle": "旧青川站",
|
||||||
|
"characterName": "娜娜",
|
||||||
|
"beats": [
|
||||||
|
{
|
||||||
|
"id": "beat_001",
|
||||||
|
"kind": "narration",
|
||||||
|
"speaker": null,
|
||||||
|
"text": "雨水沿着废弃站台的铁棚落下。远处的钟停在四点十七分。",
|
||||||
|
"visual": {
|
||||||
|
"character": "nana",
|
||||||
|
"expression": "uneasy",
|
||||||
|
"pose": "holding_coat",
|
||||||
|
"scene": "old_station_platform_rain"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "beat_002",
|
||||||
|
"kind": "dialogue",
|
||||||
|
"speaker": "娜娜",
|
||||||
|
"text": "你真的会在天亮前回来吗?",
|
||||||
|
"visual": {
|
||||||
|
"character": "nana",
|
||||||
|
"expression": "guarded",
|
||||||
|
"pose": null,
|
||||||
|
"scene": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"suggestions": [
|
||||||
|
{
|
||||||
|
"id": "suggestion_promise",
|
||||||
|
"label": "认真答应她",
|
||||||
|
"draft": "我答应你,天亮前一定回来。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "suggestion_ask",
|
||||||
|
"label": "追问原因",
|
||||||
|
"draft": "你为什么这么在意天亮之前?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "suggestion_observe",
|
||||||
|
"label": "先观察她",
|
||||||
|
"draft": "我没有立刻回答,先看了看她攥紧外套的手。"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"inventory": [
|
||||||
|
{
|
||||||
|
"instanceId": "item_flashlight_001",
|
||||||
|
"name": "旧手电筒",
|
||||||
|
"description": "光线不算亮,但足以照清站台下方。",
|
||||||
|
"quantity": 1,
|
||||||
|
"placement": "bag",
|
||||||
|
"condition": "worn"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"knowledge": [
|
||||||
|
{
|
||||||
|
"id": "knowledge_station_clock",
|
||||||
|
"title": "停摆的站钟",
|
||||||
|
"summary": "站钟停在四点十七分,似乎并非自然损坏。",
|
||||||
|
"certainty": "confirmed"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"promises": [],
|
||||||
|
"relationship": {
|
||||||
|
"affinity": "warming",
|
||||||
|
"trust": "guarded",
|
||||||
|
"hope": "guarded",
|
||||||
|
"respect": "warming",
|
||||||
|
"intimacy": "guarded",
|
||||||
|
"attachment": "warming",
|
||||||
|
"updatedAtNode": null
|
||||||
|
},
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"id": "node_001",
|
||||||
|
"parentId": null,
|
||||||
|
"branchId": "branch_main",
|
||||||
|
"label": "雨夜车站",
|
||||||
|
"isCurrent": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"canContinue": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"storyId": "story_nana_demo",
|
||||||
|
"branchId": "branch_main",
|
||||||
|
"expectedNodeId": "node_004",
|
||||||
|
"actionId": "action_search_locker",
|
||||||
|
"intent": "speak_or_act",
|
||||||
|
"input": "我趁娜娜看向铁轨时,检查她刚才挡住的储物柜。"
|
||||||
|
},
|
||||||
|
"recordedCheck": {
|
||||||
|
"id": "check_001",
|
||||||
|
"action_id": "action_search_locker",
|
||||||
|
"actor": "player",
|
||||||
|
"skill": "spot_hidden",
|
||||||
|
"target": 55,
|
||||||
|
"difficulty": "hard",
|
||||||
|
"bonus_dice": 0,
|
||||||
|
"roll": 24,
|
||||||
|
"result": "hard_success",
|
||||||
|
"pushed_from": null,
|
||||||
|
"node_id": "node_005"
|
||||||
|
},
|
||||||
|
"playerVisible": {
|
||||||
|
"riskWasNarratedBeforeCheck": true,
|
||||||
|
"containsRoll": false,
|
||||||
|
"containsTarget": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"request": {
|
||||||
|
"storyId": "story_nana_demo",
|
||||||
|
"branchId": "branch_main",
|
||||||
|
"expectedNodeId": "node_001",
|
||||||
|
"actionId": "action_002",
|
||||||
|
"intent": "speak_or_act",
|
||||||
|
"input": "这里以前发生过什么?"
|
||||||
|
},
|
||||||
|
"expected": {
|
||||||
|
"committedNodeId": "node_002",
|
||||||
|
"stateOps": [],
|
||||||
|
"beatKinds": ["action", "dialogue"]
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#10131c" />
|
||||||
|
<title>听娜娜讲故事</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
repository = "https://github.com/openlapp/lapp-rs"
|
||||||
|
commit = "5ba3c659e1536ec4bee16340faca603940a5cb17"
|
||||||
|
development_path = "../lapp-rs"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "nana-story",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@10.29.2",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"tauri": "tauri",
|
||||||
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"contracts:generate": "cargo run -p nana-contracts",
|
||||||
|
"contracts:check": "node scripts/verify-contracts.mjs",
|
||||||
|
"fmt:check": "cargo fmt --all -- --check",
|
||||||
|
"clippy": "cargo clippy --workspace --all-targets -- -D warnings",
|
||||||
|
"test:rust": "cargo test --workspace",
|
||||||
|
"verify:web": "pnpm contracts:check && pnpm typecheck && pnpm test && pnpm build",
|
||||||
|
"verify:rust": "pnpm fmt:check && pnpm clippy && pnpm test:rust && cargo run -p nana-contracts -- --check",
|
||||||
|
"verify": "pnpm verify:web && pnpm verify:rust"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.11.1",
|
||||||
|
"naive-ui": "^2.44.1",
|
||||||
|
"pinia": "^3.0.3",
|
||||||
|
"vue": "^3.5.40",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.11.4",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
|
"@vue/test-utils": "^2.4.11",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^8.1.5",
|
||||||
|
"vitest": "^4.1.10",
|
||||||
|
"vue-tsc": "^3.3.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2138
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "1.96.0"
|
||||||
|
components = ["clippy", "rustfmt"]
|
||||||
|
profile = "minimal"
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFile, readdir } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const root = resolve(import.meta.dirname, "..");
|
||||||
|
const sourcePath = resolve(root, "crates/nana-domain/src/lib.rs");
|
||||||
|
const hashPath = resolve(root, "contracts/.source.sha256");
|
||||||
|
const schemaDirectory = resolve(root, "contracts/schema");
|
||||||
|
|
||||||
|
const source = await readFile(sourcePath);
|
||||||
|
const expectedHash = (await readFile(hashPath, "utf8")).trim();
|
||||||
|
const actualHash = createHash("sha256").update(source).digest("hex");
|
||||||
|
|
||||||
|
if (actualHash !== expectedHash) {
|
||||||
|
throw new Error(
|
||||||
|
"Generated contracts are stale. Install Rust and run `pnpm contracts:generate`."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemaFiles = (await readdir(schemaDirectory))
|
||||||
|
.filter((name) => name.endsWith(".schema.json"))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
const required = [
|
||||||
|
"app-info.schema.json",
|
||||||
|
"character-card.schema.json",
|
||||||
|
"demo-pack-summary.schema.json",
|
||||||
|
"item-spec.schema.json",
|
||||||
|
"persona.schema.json",
|
||||||
|
"player-view.schema.json",
|
||||||
|
"plot-module.schema.json",
|
||||||
|
"resource-bundle.schema.json",
|
||||||
|
"resource-header.schema.json",
|
||||||
|
"runtime-state.schema.json",
|
||||||
|
"story-node.schema.json",
|
||||||
|
"turn-failure.schema.json",
|
||||||
|
"turn-request.schema.json",
|
||||||
|
"turn-result.schema.json",
|
||||||
|
"world-book.schema.json"
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const name of required) {
|
||||||
|
if (!schemaFiles.includes(name)) {
|
||||||
|
throw new Error(`Missing generated schema: contracts/schema/${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of schemaFiles) {
|
||||||
|
const value = JSON.parse(await readFile(resolve(schemaDirectory, name), "utf8"));
|
||||||
|
if (typeof value !== "object" || value === null || value.$schema === undefined) {
|
||||||
|
throw new Error(`Invalid generated schema: contracts/schema/${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeScript = await readFile(resolve(root, "contracts/ts/index.ts"), "utf8");
|
||||||
|
if (!typeScript.startsWith("// @generated by crates/nana-contracts")) {
|
||||||
|
throw new Error("contracts/ts/index.ts is not marked as generated");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Verified ${schemaFiles.length} schemas and TypeScript DTO source hash.`);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "nana-story-app"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description = "Desktop shell for 听娜娜讲故事"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "nana_story_app_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
nana-domain.workspace = true
|
||||||
|
nana-runtime.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build();
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default capability for the main window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
use nana_domain::{
|
||||||
|
AppInfo, DemoPackSummary, DOMAIN_SCHEMA_VERSION, PlayerView, ResourceBundle, ValidationIssue,
|
||||||
|
validate_bundle,
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
const DEMO_PLAYER_VIEW: &str = include_str!("../../fixtures/player-view/initial.json");
|
||||||
|
const DEMO_BUNDLE: &str = include_str!("../../content/nana-demo/bundle.json");
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CommandError {
|
||||||
|
code: &'static str,
|
||||||
|
message: String,
|
||||||
|
issues: Vec<ValidationIssue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandError {
|
||||||
|
fn parse(target: &str, error: &serde_json::Error) -> Self {
|
||||||
|
Self {
|
||||||
|
code: "invalid_embedded_json",
|
||||||
|
message: format!("failed to parse embedded {target}: {error}"),
|
||||||
|
issues: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_app_info() -> AppInfo {
|
||||||
|
AppInfo {
|
||||||
|
name: "听娜娜讲故事".to_owned(),
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_owned(),
|
||||||
|
contract_version: DOMAIN_SCHEMA_VERSION,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_demo_player_view() -> Result<PlayerView, CommandError> {
|
||||||
|
serde_json::from_str(DEMO_PLAYER_VIEW)
|
||||||
|
.map_err(|error| CommandError::parse("PlayerView fixture", &error))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_demo_pack_summary() -> Result<DemoPackSummary, CommandError> {
|
||||||
|
let bundle: ResourceBundle = serde_json::from_str(DEMO_BUNDLE)
|
||||||
|
.map_err(|error| CommandError::parse("content bundle", &error))?;
|
||||||
|
let report = validate_bundle(&bundle);
|
||||||
|
if !report.is_valid() {
|
||||||
|
return Err(CommandError {
|
||||||
|
code: "invalid_content_bundle",
|
||||||
|
message: "the embedded content bundle failed domain validation".to_owned(),
|
||||||
|
issues: report.issues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(DemoPackSummary {
|
||||||
|
id: bundle.id,
|
||||||
|
display_name: bundle.display_name,
|
||||||
|
revision: bundle.revision,
|
||||||
|
characters: bundle.characters.len(),
|
||||||
|
world_books: bundle.world_books.len(),
|
||||||
|
personas: bundle.personas.len(),
|
||||||
|
plot_modules: bundle.plot_modules.len(),
|
||||||
|
item_specs: bundle.item_specs.len(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
get_app_info,
|
||||||
|
get_demo_pack_summary,
|
||||||
|
get_demo_player_view
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("failed to run nana-story");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{get_demo_pack_summary, get_demo_player_view};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_player_view_is_valid() {
|
||||||
|
let view = get_demo_player_view().expect("valid PlayerView fixture");
|
||||||
|
assert_eq!(view.character_name, "娜娜");
|
||||||
|
assert!(!view.beats.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_content_bundle_is_valid() {
|
||||||
|
let summary = get_demo_pack_summary().expect("valid demo bundle");
|
||||||
|
assert_eq!(summary.characters, 1);
|
||||||
|
assert_eq!(summary.plot_modules, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
nana_story_app_lib::run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "听娜娜讲故事",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "dev.nanastory.app",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeBuildCommand": "pnpm build",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "听娜娜讲故事",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"minWidth": 960,
|
||||||
|
"minHeight": 640,
|
||||||
|
"resizable": true,
|
||||||
|
"fullscreen": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": "default-src 'self'; img-src 'self' asset: data:; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"shortDescription": "本地优先的 AI 对话游戏"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { flushPromises, mount } from "@vue/test-utils";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import App from "./App.vue";
|
||||||
|
|
||||||
|
describe("App", () => {
|
||||||
|
it("renders the canonical demo PlayerView", async () => {
|
||||||
|
const wrapper = mount(App);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain("听娜娜讲故事");
|
||||||
|
expect(wrapper.text()).toContain("娜娜");
|
||||||
|
expect(wrapper.text()).toContain("你真的会在天亮前回来吗?");
|
||||||
|
expect(wrapper.get('textarea[aria-label="自由输入"]')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies an editable suggestion into the composer", async () => {
|
||||||
|
const wrapper = mount(App);
|
||||||
|
await flushPromises();
|
||||||
|
await wrapper.get(".suggestions button").trigger("click");
|
||||||
|
|
||||||
|
const composer = wrapper.get<HTMLTextAreaElement>("textarea");
|
||||||
|
expect(composer.element.value.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
|
import { useDemo } from "./app/useDemo";
|
||||||
|
|
||||||
|
const { appInfo, error, latestBeat, loading, pack, playerView } = useDemo();
|
||||||
|
const draft = ref("");
|
||||||
|
|
||||||
|
const sceneLabel = computed(() => {
|
||||||
|
if (!playerView.value) return "载入场景";
|
||||||
|
return `${playerView.value.sceneTitle} · ${pack.value?.displayName ?? ""}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function useSuggestion(suggestion: { draft: string }) {
|
||||||
|
draft.value = suggestion.draft;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="app-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-mark" aria-hidden="true">∞</span>
|
||||||
|
<span>{{ appInfo?.name ?? "听娜娜讲故事" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="scene-label">{{ sceneLabel }}</div>
|
||||||
|
<nav class="top-actions" aria-label="故事工具">
|
||||||
|
<button type="button">持有物</button>
|
||||||
|
<button type="button">关系</button>
|
||||||
|
<button type="button">回溯</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section v-if="loading" class="state-screen">正在载入故事……</section>
|
||||||
|
<section v-else-if="error" class="state-screen state-screen--error">
|
||||||
|
<strong>内容载入失败</strong>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="playerView" class="stage">
|
||||||
|
<div class="stage-sky" />
|
||||||
|
<div class="station-sign">旧青川站</div>
|
||||||
|
<div class="character" aria-label="娜娜">
|
||||||
|
<div class="character-hair" />
|
||||||
|
<div class="character-face">
|
||||||
|
<i class="eye eye--left" />
|
||||||
|
<i class="eye eye--right" />
|
||||||
|
<i class="mouth" />
|
||||||
|
</div>
|
||||||
|
<div class="character-coat" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chapter-chip">第一幕 · 雨夜车站</div>
|
||||||
|
|
||||||
|
<article class="dialogue-panel">
|
||||||
|
<div class="speaker-row">
|
||||||
|
<strong>{{ latestBeat?.speaker ?? playerView.characterName }}</strong>
|
||||||
|
<span>雨声很近,钟声还没有响。</span>
|
||||||
|
</div>
|
||||||
|
<p>{{ latestBeat?.text }}</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div class="suggestions" aria-label="行动建议">
|
||||||
|
<button
|
||||||
|
v-for="suggestion in playerView.suggestions"
|
||||||
|
:key="suggestion.id"
|
||||||
|
type="button"
|
||||||
|
@click="useSuggestion(suggestion)"
|
||||||
|
>
|
||||||
|
{{ suggestion.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="composer" @submit.prevent>
|
||||||
|
<textarea
|
||||||
|
v-model="draft"
|
||||||
|
aria-label="自由输入"
|
||||||
|
placeholder="说些什么,或者描述你的行动……"
|
||||||
|
rows="2"
|
||||||
|
/>
|
||||||
|
<button class="continue-button" type="button">继续</button>
|
||||||
|
<button class="send-button" type="submit" :disabled="draft.trim().length === 0">
|
||||||
|
发送
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<footer class="statusline">
|
||||||
|
<span>{{ playerView.nodeId }}</span>
|
||||||
|
<span>{{ playerView.branchId }}</span>
|
||||||
|
<span>关系与判定已隐藏</span>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
import initialView from "../../fixtures/player-view/initial.json";
|
||||||
|
import type { AppInfo, DemoPackSummary, PlayerView } from "@contracts";
|
||||||
|
|
||||||
|
function isTauri(): boolean {
|
||||||
|
return typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAppInfo(): Promise<AppInfo> {
|
||||||
|
if (!isTauri()) {
|
||||||
|
return {
|
||||||
|
name: "听娜娜讲故事",
|
||||||
|
version: "0.1.0",
|
||||||
|
contractVersion: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return invoke<AppInfo>("get_app_info");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDemoPlayerView(): Promise<PlayerView> {
|
||||||
|
if (!isTauri()) {
|
||||||
|
return initialView as PlayerView;
|
||||||
|
}
|
||||||
|
return invoke<PlayerView>("get_demo_player_view");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDemoPackSummary(): Promise<DemoPackSummary> {
|
||||||
|
if (!isTauri()) {
|
||||||
|
return {
|
||||||
|
id: "nana.demo.station",
|
||||||
|
displayName: "天亮之前",
|
||||||
|
revision: "0.1.0",
|
||||||
|
characters: 1,
|
||||||
|
worldBooks: 1,
|
||||||
|
personas: 1,
|
||||||
|
plotModules: 1,
|
||||||
|
itemSpecs: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return invoke<DemoPackSummary>("get_demo_pack_summary");
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import initialView from "../../fixtures/player-view/initial.json";
|
||||||
|
|
||||||
|
describe("PlayerView security boundary", () => {
|
||||||
|
it("does not expose hidden runtime fields", () => {
|
||||||
|
const serialized = JSON.stringify(initialView);
|
||||||
|
|
||||||
|
for (const forbidden of [
|
||||||
|
'"roll"',
|
||||||
|
'"target"',
|
||||||
|
'"hidden_facts"',
|
||||||
|
'"world_flags"',
|
||||||
|
'"checks"',
|
||||||
|
'"relationship_states"'
|
||||||
|
]) {
|
||||||
|
expect(serialized).not.toContain(forbidden);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows only qualitative relationship bands", () => {
|
||||||
|
for (const [key, value] of Object.entries(initialView.relationship)) {
|
||||||
|
if (key === "updatedAtNode") continue;
|
||||||
|
expect(typeof value).toBe("string");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
|
||||||
|
import type { AppInfo, DemoPackSummary, PlayerView } from "@contracts";
|
||||||
|
|
||||||
|
import { getAppInfo, getDemoPackSummary, getDemoPlayerView } from "./bridge";
|
||||||
|
|
||||||
|
export function useDemo() {
|
||||||
|
const appInfo = ref<AppInfo | null>(null);
|
||||||
|
const pack = ref<DemoPackSummary | null>(null);
|
||||||
|
const playerView = ref<PlayerView | null>(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
const latestBeat = computed(() => playerView.value?.beats.at(-1) ?? null);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
[appInfo.value, pack.value, playerView.value] = await Promise.all([
|
||||||
|
getAppInfo(),
|
||||||
|
getDemoPackSummary(),
|
||||||
|
getDemoPlayerView()
|
||||||
|
]);
|
||||||
|
} catch (reason) {
|
||||||
|
error.value = reason instanceof Error ? reason.message : String(reason);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
appInfo,
|
||||||
|
error,
|
||||||
|
latestBeat,
|
||||||
|
loading,
|
||||||
|
pack,
|
||||||
|
playerView
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
__TAURI_INTERNALS__?: unknown;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createPinia } from "pinia";
|
||||||
|
import { createApp } from "vue";
|
||||||
|
|
||||||
|
import App from "./App.vue";
|
||||||
|
import "./styles/main.css";
|
||||||
|
|
||||||
|
createApp(App).use(createPinia()).mount("#app");
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
:root {
|
||||||
|
color: #f3f2ef;
|
||||||
|
background: #0b0e16;
|
||||||
|
font-family:
|
||||||
|
Inter, "Noto Sans SC", "Microsoft YaHei", system-ui, -apple-system, BlinkMacSystemFont,
|
||||||
|
sans-serif;
|
||||||
|
font-synthesis: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 960px;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 640px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #111522;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
height: 58px;
|
||||||
|
padding: 0 22px;
|
||||||
|
color: rgb(245 241 234 / 88%);
|
||||||
|
background: rgb(11 14 22 / 86%);
|
||||||
|
border-bottom: 1px solid rgb(255 255 255 / 8%);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand,
|
||||||
|
.top-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-family: "Noto Serif SC", "Songti SC", serif;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
display: grid;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
place-items: center;
|
||||||
|
color: #e5b7a5;
|
||||||
|
border: 1px solid rgb(229 183 165 / 48%);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-label {
|
||||||
|
color: rgb(244 239 232 / 62%);
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-actions button,
|
||||||
|
.suggestions button {
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgb(255 255 255 / 13%);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-actions button {
|
||||||
|
padding: 7px 13px;
|
||||||
|
color: rgb(245 241 234 / 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
position: relative;
|
||||||
|
height: calc(100% - 58px);
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgb(12 17 31 / 4%), rgb(8 11 19 / 88%)),
|
||||||
|
linear-gradient(115deg, #18283c 0%, #26364b 46%, #111722 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage::before,
|
||||||
|
.stage::after {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage::before {
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0.35;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
104deg,
|
||||||
|
transparent 0 28px,
|
||||||
|
rgb(210 230 244 / 16%) 29px,
|
||||||
|
transparent 31px 52px
|
||||||
|
);
|
||||||
|
transform: translateY(-15%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage::after {
|
||||||
|
right: -10%;
|
||||||
|
bottom: 31%;
|
||||||
|
width: 64%;
|
||||||
|
height: 28%;
|
||||||
|
background: linear-gradient(90deg, transparent, rgb(233 172 124 / 20%));
|
||||||
|
filter: blur(40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-sky {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 44%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 68% 30%, rgb(247 194 145 / 18%), transparent 24%),
|
||||||
|
linear-gradient(180deg, #15243b, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-sign {
|
||||||
|
position: absolute;
|
||||||
|
top: 16%;
|
||||||
|
left: 10%;
|
||||||
|
padding: 14px 24px;
|
||||||
|
color: rgb(229 220 204 / 65%);
|
||||||
|
font-family: "Noto Serif SC", serif;
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: 0.35em;
|
||||||
|
background: rgb(26 31 40 / 70%);
|
||||||
|
border: 1px solid rgb(255 255 255 / 12%);
|
||||||
|
transform: rotate(-1deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.character {
|
||||||
|
position: absolute;
|
||||||
|
right: 16%;
|
||||||
|
bottom: 22%;
|
||||||
|
width: 280px;
|
||||||
|
height: 510px;
|
||||||
|
filter: drop-shadow(0 34px 36px rgb(0 0 0 / 40%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-hair,
|
||||||
|
.character-face,
|
||||||
|
.character-coat {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-hair {
|
||||||
|
top: 20px;
|
||||||
|
width: 204px;
|
||||||
|
height: 250px;
|
||||||
|
background: #28242d;
|
||||||
|
border-radius: 52% 48% 44% 48% / 36% 40% 60% 64%;
|
||||||
|
box-shadow:
|
||||||
|
-42px 80px 0 -25px #28242d,
|
||||||
|
48px 90px 0 -28px #211f27;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-face {
|
||||||
|
top: 62px;
|
||||||
|
z-index: 2;
|
||||||
|
width: 142px;
|
||||||
|
height: 176px;
|
||||||
|
background: linear-gradient(145deg, #f4d5c4, #dfb39f);
|
||||||
|
border-radius: 48% 48% 46% 46% / 40% 42% 58% 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eye,
|
||||||
|
.mouth {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
background: #4c3838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eye {
|
||||||
|
top: 82px;
|
||||||
|
width: 18px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eye--left {
|
||||||
|
left: 31px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eye--right {
|
||||||
|
right: 31px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mouth {
|
||||||
|
bottom: 35px;
|
||||||
|
left: 50%;
|
||||||
|
width: 24px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 0 0 50% 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-coat {
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1;
|
||||||
|
width: 260px;
|
||||||
|
height: 320px;
|
||||||
|
background: linear-gradient(115deg, #c9baa6, #817a72);
|
||||||
|
border-radius: 46% 46% 7% 7% / 20% 20% 5% 5%;
|
||||||
|
clip-path: polygon(20% 0, 80% 0, 100% 100%, 0 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-chip {
|
||||||
|
position: absolute;
|
||||||
|
top: 7%;
|
||||||
|
left: 50%;
|
||||||
|
padding: 6px 14px;
|
||||||
|
color: rgb(245 239 231 / 62%);
|
||||||
|
font-family: "Noto Serif SC", serif;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
border: 1px solid rgb(255 255 255 / 10%);
|
||||||
|
border-radius: 999px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogue-panel {
|
||||||
|
position: absolute;
|
||||||
|
right: 7%;
|
||||||
|
bottom: 116px;
|
||||||
|
left: 7%;
|
||||||
|
z-index: 10;
|
||||||
|
min-height: 126px;
|
||||||
|
padding: 20px 26px;
|
||||||
|
background: linear-gradient(100deg, rgb(13 17 27 / 94%), rgb(24 26 34 / 84%));
|
||||||
|
border: 1px solid rgb(255 255 255 / 13%);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 22px 56px rgb(0 0 0 / 38%);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-row strong {
|
||||||
|
color: #f0c4b2;
|
||||||
|
font-family: "Noto Serif SC", serif;
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-row span {
|
||||||
|
color: rgb(241 237 231 / 40%);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialogue-panel p {
|
||||||
|
max-width: 72ch;
|
||||||
|
margin: 14px 0 0;
|
||||||
|
color: rgb(250 247 242 / 90%);
|
||||||
|
font-family: "Noto Serif SC", "Songti SC", serif;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions {
|
||||||
|
position: absolute;
|
||||||
|
right: 8%;
|
||||||
|
bottom: 255px;
|
||||||
|
z-index: 12;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions button {
|
||||||
|
padding: 8px 13px;
|
||||||
|
color: rgb(245 241 234 / 72%);
|
||||||
|
background: rgb(15 19 29 / 64%);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
position: absolute;
|
||||||
|
right: 7%;
|
||||||
|
bottom: 42px;
|
||||||
|
left: 7%;
|
||||||
|
z-index: 12;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer textarea {
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 13px 16px;
|
||||||
|
resize: none;
|
||||||
|
color: rgb(250 247 242 / 92%);
|
||||||
|
outline: none;
|
||||||
|
background: rgb(12 15 24 / 86%);
|
||||||
|
border: 1px solid rgb(255 255 255 / 13%);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer button {
|
||||||
|
min-width: 72px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continue-button {
|
||||||
|
color: rgb(245 241 234 / 75%);
|
||||||
|
background: rgb(24 28 39 / 88%);
|
||||||
|
border: 1px solid rgb(255 255 255 / 13%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button {
|
||||||
|
color: #201918;
|
||||||
|
background: #e7b8a4;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusline {
|
||||||
|
position: absolute;
|
||||||
|
right: 8%;
|
||||||
|
bottom: 15px;
|
||||||
|
left: 8%;
|
||||||
|
z-index: 12;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: rgb(242 238 231 / 28%);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-screen {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100% - 58px);
|
||||||
|
place-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: rgb(245 241 234 / 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-screen--error {
|
||||||
|
color: #f1b5ad;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["node", "vite/client", "vitest/globals"],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"],
|
||||||
|
"@contracts": ["contracts/ts/index.ts"]
|
||||||
|
},
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.vue",
|
||||||
|
"src/**/*.d.ts",
|
||||||
|
"contracts/ts/**/*.ts",
|
||||||
|
"vite.config.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
|
"@contracts": fileURLToPath(new URL("./contracts/ts", import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
strictPort: true,
|
||||||
|
port: 1420
|
||||||
|
},
|
||||||
|
envPrefix: ["VITE_", "TAURI_"],
|
||||||
|
build: {
|
||||||
|
target: process.env.TAURI_ENV_PLATFORM === "windows" ? "chrome105" : "safari13",
|
||||||
|
minify: process.env.TAURI_ENV_DEBUG ? false : "oxc",
|
||||||
|
sourcemap: Boolean(process.env.TAURI_ENV_DEBUG)
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "jsdom"
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user