chore: bootstrap nana-story M0

This commit is contained in:
Codex
2026-07-28 12:51:00 +08:00
commit 3dacc92423
71 changed files with 6840 additions and 0 deletions
+11
View File
@@ -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
+37
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
target/
.idea/
.vscode/
*.log
*.db
*.db-shm
*.db-wal
+40
View File
@@ -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"
+51
View File
@@ -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 或多个完整角色。
- 模型与前端都不能直接修改运行时状态。
+194
View File
@@ -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": ["电池仓里刻着青川站工作人员的编号"]
}
]
}
+1
View File
@@ -0,0 +1 @@
374a325cf1198df128248876fe7f115b41b899777a89609f87cb4ba9e2958239
+18
View File
@@ -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
}
+38
View File
@@ -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"
}
}
}
+30
View File
@@ -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"
}
}
}
}
+70
View File
@@ -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
}
+25
View File
@@ -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
}
+45
View File
@@ -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"
}
}
}
+26
View File
@@ -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
}
+34
View File
@@ -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
}
+15
View File
@@ -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
}
+21
View File
@@ -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"
}
}
}
}
+349
View File
@@ -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;
}
+17
View File
@@ -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
+167
View File
@@ -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(())
}
+16
View File
@@ -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
+13
View File
@@ -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
+48
View File
@@ -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)
);
}
}
+14
View File
@@ -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
+53
View File
@@ -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,
}
}
+13
View File
@@ -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
+20
View File
@@ -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>;
}
+18
View File
@@ -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。
+22
View File
@@ -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` 失败。
+29
View File
@@ -0,0 +1,29 @@
# ADR 0002RuntimeState 与 PlayerView 是安全边界
- 状态:已接受
- 日期:2026-07-28
## 决策
前端永远不接收完整 `RuntimeState`。所有界面通过 Rust 生成的 `PlayerView` 或同等
脱敏 DTO 读取数据。
`PlayerView` 只包含:
- 当前演出节拍和建议;
- 玩家当前可访问的持有物;
- 玩家已经获得的知识;
- 已接受且允许展示的许诺;
- 模糊化关系阶段;
- 可回溯节点摘要。
## 原因
这条边界从结构上阻止 NPC 隐藏物品、未揭示事实、精确关系数值、骰点和剧情节点泄漏。
仅靠前端“不要显示”不足以形成安全保证。
## 后果
- Vue 不允许导入或缓存 `RuntimeState`
- PlayerView 泄密测试属于跨模块硬门槛。
- 新界面字段必须先证明玩家有权知道。
+23
View File
@@ -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

+669
View File
@@ -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 个子智能体。
| 角色 | 独占范围 | 主要职责 |
|---|---|---|
| 集成人 | 公共契约、根配置、入口、生成物、跨模块接线、最终验收 | 拆任务、冻结契约、日常合并、拒绝范围扩张 |
| 子智能体 ACore | `nana-domain``nana-engine``nana-store` | 类型、reducer、规则、SQLite、分支重放 |
| 子智能体 BRuntime | `nana-runtime` | LAPP、上下文编译、工具循环、Turn Engine |
| 子智能体 CExperience | `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 不存在多个事实来源。
并行:
- 集成人:契约与仓库骨架;
- ACI 和 Rust workspace
- BFake LAPP Provider/录制模型响应夹具;
- C:按四张原型搭静态 UI 外壳。
### M1:确定性状态内核(第 3~6 日)
交付:
- `RuntimeState`
- 纯 reducer
- 六维关系与单轮限幅;
- 许诺生命周期;
- 知识与可见性;
- 物品 owner/holder、位置、取得方式;
- 时钟;
- 简化 CoC7 判定记录;
- `PlayerView` 投影;
- SQLite 节点、delta 和检查点。
演示:
- 不调用模型,仅通过固定命令完成:
- 获得物品;
- 作出许诺;
- 改变关系;
- 推进时钟;
- 执行一次判定;
- 保存、关闭、恢复。
硬门槛:
- 同一根状态 + 同一组 delta 必须产生完全相同的 `RuntimeState``PlayerView`
- delta 重放不能重复结算;
- 关系变化会被规则限幅;
- owner/holder 语义正确;
- 玩家无法读取 NPC 未知持有物;
- 同一行动重新生成时可以复用判定记录。
并行:
- Adomain、engine、store
- B:基于 fixture 实现 runtime 接口和 Fake Provider
- C:前端使用同一份 `PlayerView` fixture 完成完整假数据交互。
### M2:单轮模型运行循环(第 5~10 日,与 M1 后半段重叠)
交付:
- LAPP profile 读取、模型选择和连接测试;
- 非流式与流式聊天适配;
- 上下文编译器;
- 领域工具目录;
- 工具循环和最大轮次保护;
- `PresentationBeat`
- 暂存事务、校验、提交和回滚;
- 取消、超时、畸形输出和一次安全重试。
演示:
- 用户输入一句自由文本;
- 娜娜以短节拍回应;
- 表情发生一次可选变化;
- 生成 23 个可编辑建议;
- 一次需要判定的行动经过工具返回后继续叙述;
- 整轮状态一次性提交。
硬门槛:
- 模型和 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
- 使用另一个最小反例资源夹具证明引擎仍然通用。
本阶段停止增加系统功能。所有子智能体只处理纵切暴露的问题。
### M6Windows 发布前加固(第 2225 日)
交付:
- 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
- Corereducer + SQLite
- RuntimeFake Provider + Turn API
- Experience:静态主演出屏 + fixture adapter
6. 在 M1 确定性重放通过后,接入真实 `lapp-rs` 和第一条完整游戏回合。
第一条集成目标应当非常小:
> 玩家输入“我答应天亮前回来”,娜娜回应并接受许诺;引擎创建 Promise、提高希望、保存节点;关闭应用后恢复;回溯到许诺之前继续,旧分支仍然存在。
它同时覆盖角色演出、工具调用、许诺、关系、存档、恢复和分支,是最适合作为 `nana-story` 第一条端到端主线的开发样例。
+43
View File
@@ -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 冒烟完成前,
不标记为正式完成。
+16
View File
@@ -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
}
}
+29
View File
@@ -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"
}
}
]
}
+89
View File
@@ -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
}
+28
View File
@@ -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
}
}
+15
View File
@@ -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
View File
@@ -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>
+3
View File
@@ -0,0 +1,3 @@
repository = "https://github.com/openlapp/lapp-rs"
commit = "5ba3c659e1536ec4bee16340faca603940a5cb17"
development_path = "../lapp-rs"
+44
View File
@@ -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"
}
}
+2138
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
[toolchain]
channel = "1.96.0"
components = ["clippy", "rustfmt"]
profile = "minimal"
+60
View File
@@ -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.`);
+24
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capability for the main window",
"windows": ["main"],
"permissions": ["core:default"]
}
+96
View File
@@ -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);
}
}
+3
View File
@@ -0,0 +1,3 @@
fn main() {
nana_story_app_lib::run();
}
+33
View File
@@ -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 对话游戏"
}
}
+25
View File
@@ -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
View File
@@ -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>
+42
View File
@@ -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");
}
+27
View File
@@ -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");
}
});
});
+38
View File
@@ -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
};
}
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="vite/client" />
interface Window {
__TAURI_INTERNALS__?: unknown;
}
+7
View File
@@ -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");
+377
View File
@@ -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;
}
+29
View File
@@ -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"
]
}
+28
View File
@@ -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"
}
});