feat(app): persist demo turns through Tauri
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
## 当前阶段
|
||||
|
||||
M0 契约基线已建立;M1/M2 第一批可并行能力已集成。
|
||||
M0 契约基线已建立;M1 状态、投影与持久化主链已经接通,M2 正在进入真实模型循环。
|
||||
|
||||
目前包括:
|
||||
|
||||
@@ -15,12 +15,20 @@ M0 契约基线已建立;M1/M2 第一批可并行能力已集成。
|
||||
- canonical `PlayerView` fixture;
|
||||
- 最小娜娜内容包;
|
||||
- 原子化状态 reducer,覆盖关系、许诺、知识、持有物、时钟与判定;
|
||||
- 可验证追加、恢复和回溯分叉语义的内存 StoryStore;
|
||||
- 保持同一语义的内存与 SQLite StoryStore,支持事务追加、恢复和回溯分叉;
|
||||
- 写入与恢复时的状态哈希校验;
|
||||
- `RuntimeState → PlayerView` 脱敏投影与隐藏信息 canary 测试;
|
||||
- `TurnPlan → reducer → SQLite → PlayerView` 单轮提交链;
|
||||
- Turn 请求/结果校验、Fake Provider 与确定性世界书触发;
|
||||
- 可交互的主演出屏、持有物/线索/许诺、关系与回溯面板。
|
||||
|
||||
浏览器模式目前使用确定性的本地 Turn adapter。SQLite、真实 LAPP Turn
|
||||
循环和分支持久化仍在后续里程碑中。
|
||||
浏览器模式继续使用确定性的本地 Turn adapter,方便无桌面壳开发;Tauri
|
||||
模式已经通过 `submit_turn` 使用 SQLite 自动保存和恢复。当前桌面端 provider
|
||||
仍是确定性的娜娜纵切实现,真实 LAPP 上下文编译、工具循环和动态演出快照仍在
|
||||
后续 M2 中。
|
||||
|
||||
当前 Work 环境缺少 Rust 工具链,因此 Rust/Tauri 源码和测试尚未实际编译;
|
||||
合并门必须在 Rust 1.96 环境补跑 `pnpm verify:rust` 与桌面启动冒烟。
|
||||
|
||||
## 开发
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# M1/M2 第二并行波次状态
|
||||
|
||||
日期:2026-07-28
|
||||
集成分支:`integration/v1`
|
||||
起始基线:`fa8bd0d`
|
||||
|
||||
## 本波次目标
|
||||
|
||||
从第一波静态契约和内存测试缝继续接通:
|
||||
|
||||
- `RuntimeState → PlayerView` 脱敏投影;
|
||||
- 与内存实现同语义的 SQLite StoryStore;
|
||||
- 持久化单轮 Turn Engine;
|
||||
- Tauri `submit_turn` 与浏览器/Tauri 双模式前端桥接;
|
||||
- “天亮前一定回来”许诺纵切的首次端到端提交与恢复。
|
||||
|
||||
开发时使用 1 个集成人和 3 个独立 worktree。游戏运行时仍是单模型架构,
|
||||
没有引入导演 Agent。
|
||||
|
||||
## 已集成
|
||||
|
||||
### PlayerView 投影
|
||||
|
||||
- `project_player_view` 是 `RuntimeState` 到玩家可见状态的唯一纯投影。
|
||||
- 仅显示玩家当前持有且存在公开 `ItemSpec` 的物品。
|
||||
- 仅显示 `observer == player` 的知识。
|
||||
- 仅显示玩家参与、已接受或已结束的许诺。
|
||||
- 六维关系只映射为五档区间,不暴露精确数值。
|
||||
- 判定、NPC 物品、隐藏事实、内部规则和 NPC-only 许诺不进入视图。
|
||||
- 关系更新时间沿当前 lineage 查找最近一次 `nana → player` 变化,不受其他关系边影响。
|
||||
|
||||
### SQLite StoryStore
|
||||
|
||||
- `nodes`、`materialized_states`、`branch_heads` 三表初始化。
|
||||
- `append_node` 使用 `IMMEDIATE` 单事务。
|
||||
- 父节点、单根、节点唯一、stale head 与新分支语义和内存实现保持一致。
|
||||
- 支持按分支恢复状态、按 ID 读取节点和按节点读取物化状态。
|
||||
- 写入与恢复均重新计算 `state_hash`。
|
||||
- 节点 JSON 会与 SQLite 中的 story、node、branch、parent 结构列交叉校验。
|
||||
- 有效 JSON 的静默损坏、结构列漂移和无效 JSON 都会被拒绝。
|
||||
|
||||
### 持久化 Turn Engine
|
||||
|
||||
模型侧只能返回内部 `TurnPlan`:
|
||||
|
||||
```text
|
||||
validate request
|
||||
→ load branch state
|
||||
→ stale check
|
||||
→ provider plan
|
||||
→ reducer
|
||||
→ state hash
|
||||
→ atomic append
|
||||
→ infallible PlayerView projection
|
||||
→ TurnResult
|
||||
```
|
||||
|
||||
- 模型不能直接构造最终 `PlayerView`。
|
||||
- reducer、provider、store 和序列化错误都在提交前返回。
|
||||
- projector 被定义为不可失败的防御性纯边界,消除“已提交但整轮报失败”的窗口。
|
||||
- 提交身份由已落库状态覆盖,不信任 projector 提供的 story/node/branch。
|
||||
- 上游、规则和存储错误转换为稳定、脱敏的 `TurnFailure`。
|
||||
|
||||
### Tauri 纵切
|
||||
|
||||
- 首次启动在应用数据目录创建 SQLite 数据库并播种根节点。
|
||||
- 启动时严格区分全新数据库、缺分支数据库和损坏数据库。
|
||||
- `get_demo_player_view` 从已保存状态、当前节点和 lineage 重新投影。
|
||||
- `submit_turn` 走真实 Turn Engine 与 SQLite,而不是静态 `PlayerView`。
|
||||
- 许诺“天亮前一定回来”会创建 accepted Promise、提高 hope、保存节点并返回脱敏视图。
|
||||
- 重新打开文件数据库后恢复同一节点、许诺、关系档位和当前线路历史。
|
||||
- 浏览器开发模式仍保留确定性 adapter;Tauri 模式通过同一前端 API 调用命令。
|
||||
|
||||
## 安全修正
|
||||
|
||||
交叉审查额外修正了以下问题:
|
||||
|
||||
- NPC 与 NPC 之间的许诺不会通过许诺侧栏或历史标签泄漏。
|
||||
- NPC-only 关系变化不会触发玩家关系“最近更新”提示。
|
||||
- 历史 lineage 损坏在启动恢复时会作为存档错误上报,不伪装成正常短历史。
|
||||
- 节点 ID 使用 story、branch、action 的稳定哈希,避免字符清洗和跨分支碰撞。
|
||||
- Tauri 错误保留 `retryable`,前端能读取结构化错误对象的 message。
|
||||
|
||||
## 已通过验证
|
||||
|
||||
```text
|
||||
canonical JSON fixtures parse
|
||||
|
||||
node scripts/verify-contracts.mjs
|
||||
15 schemas and TypeScript DTO source hash verified
|
||||
|
||||
vue-tsc --noEmit
|
||||
|
||||
vitest run
|
||||
4 test files passed
|
||||
12 tests passed
|
||||
|
||||
vite build
|
||||
production build succeeded
|
||||
|
||||
SQLite schema smoke
|
||||
branch_heads, materialized_states, nodes created in memory
|
||||
```
|
||||
|
||||
当前源码共声明 55 项 Rust 单元/集成测试,覆盖 reducer、投影、内存/SQLite
|
||||
存储、Turn Engine 和 Tauri 纵切。
|
||||
|
||||
## 尚未关闭
|
||||
|
||||
当前 Work 环境仍没有 `cargo`、`rustc` 和 `rustfmt`,因此 55 项 Rust 测试、
|
||||
Tauri 宏展开、rusqlite 编译与桌面启动尚未实际验证。必须在 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/M1 标记为完成。
|
||||
|
||||
## 已知的下一阶段缺口
|
||||
|
||||
- Tauri provider 目前是确定性的娜娜纵切,不是真实 LAPP 模型调用。
|
||||
- 动态 suggestions 和完整 scene/expression/pose 快照尚未进入持久化节点;真实
|
||||
provider 接入前必须冻结 presentation snapshot 契约。
|
||||
- 回溯面板尚未调用创建分支命令。
|
||||
- SQLite 尚未加入 schema migration 版本和多实例 busy timeout。
|
||||
|
||||
## 下一波次
|
||||
|
||||
1. 在 Rust 1.96 环境关闭首次编译、格式、Clippy 与 Tauri 启动门。
|
||||
2. 冻结可恢复的 presentation snapshot,避免动态建议和视觉状态在重启后漂移。
|
||||
3. 用 `lapp-rs` 接入真实非流式 TurnPlan provider,并继续保留 Fake Provider CI。
|
||||
4. 接入上下文编译与类型化工具循环,确保整轮只有一次提交。
|
||||
5. 为回溯面板增加“从这里继续”命令,验证旧分支保留和新分支状态隔离。
|
||||
@@ -19,7 +19,7 @@
|
||||
{
|
||||
"id": "knowledge_station_clock",
|
||||
"observer": "player",
|
||||
"subject": "old_station_platform",
|
||||
"subject": "停摆的站钟",
|
||||
"fact": "站钟停在四点十七分,似乎并非自然损坏。",
|
||||
"certainty": "confirmed",
|
||||
"source": "玩家进入站台时亲眼观察",
|
||||
|
||||
@@ -15,7 +15,9 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
nana-domain.workspace = true
|
||||
nana-engine.workspace = true
|
||||
nana-runtime.workspace = true
|
||||
nana-store.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tauri = { version = "2", features = [] }
|
||||
|
||||
+696
-35
@@ -1,28 +1,518 @@
|
||||
use std::{fs, path::Path};
|
||||
|
||||
use nana_domain::{
|
||||
AppInfo, DemoPackSummary, DOMAIN_SCHEMA_VERSION, PlayerView, ResourceBundle, ValidationIssue,
|
||||
validate_bundle,
|
||||
ActionSuggestion, AppInfo, BeatKind, DemoPackSummary, HistoryNodeView, PlayerView,
|
||||
PresentationBeat, Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment,
|
||||
RelationshipDimension, ResourceBundle, RuntimeState, StateDelta, StateOp, StoryNode,
|
||||
TurnFailure, TurnFailureCode, TurnIntent, TurnRequest, TurnResult, ValidationIssue,
|
||||
VisualDirective, DOMAIN_SCHEMA_VERSION, stable_json_hash, validate_bundle,
|
||||
};
|
||||
use nana_engine::{
|
||||
PlayerViewProjectionContext, SceneMetadata, project_player_view,
|
||||
};
|
||||
use nana_runtime::{
|
||||
ProviderError, TurnEngine, TurnPlan, TurnPlanProvider, TurnProjector,
|
||||
};
|
||||
use nana_store::{SqliteStoryStore, StoreError, StoryStore};
|
||||
use serde::Serialize;
|
||||
use tauri::Manager;
|
||||
|
||||
const DEMO_PLAYER_VIEW: &str = include_str!("../../fixtures/player-view/initial.json");
|
||||
const DEMO_RUNTIME_STATE: &str = include_str!("../../fixtures/runtime-state/initial.json");
|
||||
const DEMO_BUNDLE: &str = include_str!("../../content/nana-demo/bundle.json");
|
||||
const DEMO_STORY_ID: &str = "story_nana_demo";
|
||||
const DEMO_BRANCH_ID: &str = "branch_main";
|
||||
const PLAYER_ID: &str = "player";
|
||||
const CHARACTER_ID: &str = "nana";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommandError {
|
||||
code: &'static str,
|
||||
code: String,
|
||||
message: String,
|
||||
retryable: bool,
|
||||
issues: Vec<ValidationIssue>,
|
||||
}
|
||||
|
||||
impl CommandError {
|
||||
fn parse(target: &str, error: &serde_json::Error) -> Self {
|
||||
Self {
|
||||
code: "invalid_embedded_json",
|
||||
code: "invalid_embedded_json".to_owned(),
|
||||
message: format!("failed to parse embedded {target}: {error}"),
|
||||
retryable: false,
|
||||
issues: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn storage(_error: &StoreError) -> Self {
|
||||
Self {
|
||||
code: "storage_unavailable".to_owned(),
|
||||
message: "故事存档暂时不可用。".to_owned(),
|
||||
retryable: true,
|
||||
issues: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn turn(failure: TurnFailure) -> Self {
|
||||
Self {
|
||||
code: turn_failure_code(failure.code).to_owned(),
|
||||
message: failure.message,
|
||||
retryable: failure.retryable,
|
||||
issues: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DemoAppState {
|
||||
store: SqliteStoryStore,
|
||||
bundle: ResourceBundle,
|
||||
}
|
||||
|
||||
impl DemoAppState {
|
||||
fn open(path: impl AsRef<Path>) -> Result<Self, CommandError> {
|
||||
let store = SqliteStoryStore::open(path).map_err(|error| CommandError::storage(&error))?;
|
||||
Self::from_store(store)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn open_in_memory() -> Result<Self, CommandError> {
|
||||
let store =
|
||||
SqliteStoryStore::open_in_memory().map_err(|error| CommandError::storage(&error))?;
|
||||
Self::from_store(store)
|
||||
}
|
||||
|
||||
fn from_store(store: SqliteStoryStore) -> Result<Self, 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".to_owned(),
|
||||
message: "the embedded content bundle failed domain validation".to_owned(),
|
||||
retryable: false,
|
||||
issues: report.issues,
|
||||
});
|
||||
}
|
||||
|
||||
match store.load_state(DEMO_STORY_ID, DEMO_BRANCH_ID) {
|
||||
Ok(state) => {
|
||||
store
|
||||
.load_node(&state.story_id, &state.current_node)
|
||||
.map_err(|error| CommandError::storage(&error))?;
|
||||
}
|
||||
Err(StoreError::StoryNotFound(_)) => seed_demo_story(&store)?,
|
||||
Err(error) => return Err(CommandError::storage(&error)),
|
||||
}
|
||||
|
||||
Ok(Self { store, bundle })
|
||||
}
|
||||
|
||||
fn pack_summary(&self) -> DemoPackSummary {
|
||||
DemoPackSummary {
|
||||
id: self.bundle.id.clone(),
|
||||
display_name: self.bundle.display_name.clone(),
|
||||
revision: self.bundle.revision.clone(),
|
||||
characters: self.bundle.characters.len(),
|
||||
world_books: self.bundle.world_books.len(),
|
||||
personas: self.bundle.personas.len(),
|
||||
plot_modules: self.bundle.plot_modules.len(),
|
||||
item_specs: self.bundle.item_specs.len(),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_player_view(&self) -> Result<PlayerView, CommandError> {
|
||||
let state = self
|
||||
.store
|
||||
.load_state(DEMO_STORY_ID, DEMO_BRANCH_ID)
|
||||
.map_err(|error| CommandError::storage(&error))?;
|
||||
let node = self
|
||||
.store
|
||||
.load_node(&state.story_id, &state.current_node)
|
||||
.map_err(|error| CommandError::storage(&error))?;
|
||||
let lineage = self
|
||||
.load_lineage(&node)
|
||||
.map_err(|error| CommandError::storage(&error))?;
|
||||
Ok(self.project_view_with_lineage(
|
||||
&state,
|
||||
&node,
|
||||
&default_suggestions(),
|
||||
&lineage,
|
||||
))
|
||||
}
|
||||
|
||||
fn submit_turn(&self, request: &TurnRequest) -> Result<TurnResult, CommandError> {
|
||||
let provider = DemoPlanProvider;
|
||||
let projector = DemoProjector { app: self };
|
||||
let mut engine = TurnEngine::new(&self.store, provider, projector);
|
||||
engine
|
||||
.submit_turn(request)
|
||||
.map_err(CommandError::turn)
|
||||
}
|
||||
|
||||
fn project_view(
|
||||
&self,
|
||||
state: &RuntimeState,
|
||||
node: &StoryNode,
|
||||
suggestions: &[ActionSuggestion],
|
||||
) -> PlayerView {
|
||||
let lineage = self
|
||||
.load_lineage(node)
|
||||
.unwrap_or_else(|_| vec![node.clone()]);
|
||||
self.project_view_with_lineage(state, node, suggestions, &lineage)
|
||||
}
|
||||
|
||||
fn project_view_with_lineage(
|
||||
&self,
|
||||
state: &RuntimeState,
|
||||
node: &StoryNode,
|
||||
suggestions: &[ActionSuggestion],
|
||||
lineage: &[StoryNode],
|
||||
) -> PlayerView {
|
||||
let history = history_for(lineage, node);
|
||||
let relationship_updated_at_node = last_player_relationship_update(lineage);
|
||||
let character_name = self
|
||||
.bundle
|
||||
.characters
|
||||
.iter()
|
||||
.find(|character| character.header.id == self.bundle.entry_character)
|
||||
.map_or("角色", |character| character.name.as_str());
|
||||
project_player_view(
|
||||
state,
|
||||
&PlayerViewProjectionContext {
|
||||
player_id: PLAYER_ID,
|
||||
relationship_character_id: CHARACTER_ID,
|
||||
relationship_updated_at_node,
|
||||
item_specs: &self.bundle.item_specs,
|
||||
scene: SceneMetadata {
|
||||
scene_id: "old_station_platform",
|
||||
scene_title: "旧青川站",
|
||||
character_name,
|
||||
},
|
||||
beats: &node.beats,
|
||||
suggestions,
|
||||
history: &history,
|
||||
can_continue: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn load_lineage(&self, current: &StoryNode) -> Result<Vec<StoryNode>, StoreError> {
|
||||
let mut lineage = vec![current.clone()];
|
||||
let mut cursor = current.parent_id.clone();
|
||||
|
||||
while let Some(node_id) = cursor {
|
||||
if lineage.len() >= 200 {
|
||||
return Err(StoreError::StateMismatch(
|
||||
"story lineage exceeds the projection limit",
|
||||
));
|
||||
}
|
||||
let node = self.store.load_node(¤t.story_id, &node_id)?;
|
||||
cursor = node.parent_id.clone();
|
||||
lineage.push(node);
|
||||
}
|
||||
|
||||
lineage.reverse();
|
||||
Ok(lineage)
|
||||
}
|
||||
}
|
||||
|
||||
struct DemoPlanProvider;
|
||||
|
||||
impl TurnPlanProvider for DemoPlanProvider {
|
||||
fn plan_turn(
|
||||
&mut self,
|
||||
request: &TurnRequest,
|
||||
state: &RuntimeState,
|
||||
) -> Result<TurnPlan, ProviderError> {
|
||||
let committed_node_id = node_id_for_action(request);
|
||||
let accepted_promise = is_return_promise(&request.input)
|
||||
&& !state.promises.iter().any(|promise| {
|
||||
promise.promiser == PLAYER_ID
|
||||
&& promise.promisee == CHARACTER_ID
|
||||
&& matches!(
|
||||
promise.status,
|
||||
PromiseStatus::Accepted | PromiseStatus::Fulfilled
|
||||
)
|
||||
});
|
||||
|
||||
let (beats, delta) = if accepted_promise {
|
||||
promise_turn(request, &committed_node_id)
|
||||
} else if request.intent == TurnIntent::Continue {
|
||||
continue_turn(request, state)
|
||||
} else {
|
||||
regular_turn(request)
|
||||
};
|
||||
|
||||
Ok(TurnPlan {
|
||||
committed_node_id,
|
||||
beats,
|
||||
delta,
|
||||
suggestions: default_suggestions(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct DemoProjector<'a> {
|
||||
app: &'a DemoAppState,
|
||||
}
|
||||
|
||||
impl TurnProjector for DemoProjector<'_> {
|
||||
fn project_committed_turn(
|
||||
&mut self,
|
||||
state: &RuntimeState,
|
||||
node: &StoryNode,
|
||||
suggestions: &[ActionSuggestion],
|
||||
) -> PlayerView {
|
||||
self.app.project_view(state, node, suggestions)
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_demo_story(store: &SqliteStoryStore) -> Result<(), CommandError> {
|
||||
let state: RuntimeState = serde_json::from_str(DEMO_RUNTIME_STATE)
|
||||
.map_err(|error| CommandError::parse("RuntimeState fixture", &error))?;
|
||||
let view: PlayerView = serde_json::from_str(DEMO_PLAYER_VIEW)
|
||||
.map_err(|error| CommandError::parse("PlayerView fixture", &error))?;
|
||||
let state_bytes = serde_json::to_vec(&state)
|
||||
.map_err(|error| CommandError::parse("RuntimeState fixture", &error))?;
|
||||
let root = StoryNode {
|
||||
id: state.current_node.clone(),
|
||||
story_id: state.story_id.clone(),
|
||||
branch_id: state.current_branch.clone(),
|
||||
parent_id: None,
|
||||
action_id: "story_created".to_owned(),
|
||||
user_input: String::new(),
|
||||
beats: view.beats,
|
||||
delta: StateDelta { ops: Vec::new() },
|
||||
state_hash: stable_json_hash(&state_bytes),
|
||||
};
|
||||
store
|
||||
.append_node(&root, &state)
|
||||
.map_err(|error| CommandError::storage(&error))
|
||||
}
|
||||
|
||||
fn promise_turn(request: &TurnRequest, node_id: &str) -> (Vec<PresentationBeat>, StateDelta) {
|
||||
let beats = vec![
|
||||
player_action_beat(request),
|
||||
PresentationBeat {
|
||||
id: format!("{}_nana", request.action_id),
|
||||
kind: BeatKind::Dialogue,
|
||||
speaker: Some("娜娜".to_owned()),
|
||||
text: "娜娜看了你一会儿,终于松开攥紧外套的手。“好。我等你到天亮。”".to_owned(),
|
||||
visual: Some(VisualDirective {
|
||||
character: Some(CHARACTER_ID.to_owned()),
|
||||
expression: Some("relieved".to_owned()),
|
||||
pose: Some("holding_coat".to_owned()),
|
||||
scene: None,
|
||||
}),
|
||||
},
|
||||
];
|
||||
let delta = StateDelta {
|
||||
ops: vec![
|
||||
StateOp::CreatePromise {
|
||||
promise: Promise {
|
||||
id: format!("promise_return_before_dawn_{}", request.action_id),
|
||||
promiser: PLAYER_ID.to_owned(),
|
||||
promisee: CHARACTER_ID.to_owned(),
|
||||
content: "天亮前一定回来".to_owned(),
|
||||
status: PromiseStatus::Accepted,
|
||||
weight: PromiseWeight::Major,
|
||||
created_at: node_id.to_owned(),
|
||||
accepted_at: Some(node_id.to_owned()),
|
||||
resolved_at: None,
|
||||
},
|
||||
},
|
||||
StateOp::AdjustRelationship {
|
||||
from: CHARACTER_ID.to_owned(),
|
||||
to: PLAYER_ID.to_owned(),
|
||||
adjustment: RelationshipAdjustment {
|
||||
dimension: RelationshipDimension::Hope,
|
||||
delta: 4,
|
||||
cause: "娜娜接受了玩家天亮前归来的许诺".to_owned(),
|
||||
judgment_rule: Some("nana.promises.accepted".to_owned()),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
(beats, delta)
|
||||
}
|
||||
|
||||
fn continue_turn(request: &TurnRequest, state: &RuntimeState) -> (Vec<PresentationBeat>, StateDelta) {
|
||||
let beats = vec![
|
||||
PresentationBeat {
|
||||
id: format!("{}_rain", request.action_id),
|
||||
kind: BeatKind::Narration,
|
||||
speaker: None,
|
||||
text: "一阵风越过站台,雨点敲在铁棚上,短暂盖过了远处的水声。".to_owned(),
|
||||
visual: None,
|
||||
},
|
||||
PresentationBeat {
|
||||
id: format!("{}_nana", request.action_id),
|
||||
kind: BeatKind::Dialogue,
|
||||
speaker: Some("娜娜".to_owned()),
|
||||
text: "如果你还没想好,就先听一会儿雨吧。".to_owned(),
|
||||
visual: Some(VisualDirective {
|
||||
character: Some(CHARACTER_ID.to_owned()),
|
||||
expression: Some("guarded".to_owned()),
|
||||
pose: None,
|
||||
scene: None,
|
||||
}),
|
||||
},
|
||||
];
|
||||
let ops = state
|
||||
.clocks
|
||||
.iter()
|
||||
.any(|clock| clock.id == "clock_dawn")
|
||||
.then(|| StateOp::AdvanceClock {
|
||||
clock_id: "clock_dawn".to_owned(),
|
||||
delta: 1,
|
||||
})
|
||||
.into_iter()
|
||||
.collect();
|
||||
(beats, StateDelta { ops })
|
||||
}
|
||||
|
||||
fn regular_turn(request: &TurnRequest) -> (Vec<PresentationBeat>, StateDelta) {
|
||||
(
|
||||
vec![
|
||||
player_action_beat(request),
|
||||
PresentationBeat {
|
||||
id: format!("{}_nana", request.action_id),
|
||||
kind: BeatKind::Dialogue,
|
||||
speaker: Some("娜娜".to_owned()),
|
||||
text: "娜娜静静听完,攥着外套的手稍微松开了一些。“我知道了。”".to_owned(),
|
||||
visual: Some(VisualDirective {
|
||||
character: Some(CHARACTER_ID.to_owned()),
|
||||
expression: Some("uneasy".to_owned()),
|
||||
pose: Some("holding_coat".to_owned()),
|
||||
scene: None,
|
||||
}),
|
||||
},
|
||||
],
|
||||
StateDelta { ops: Vec::new() },
|
||||
)
|
||||
}
|
||||
|
||||
fn player_action_beat(request: &TurnRequest) -> PresentationBeat {
|
||||
PresentationBeat {
|
||||
id: format!("{}_player", request.action_id),
|
||||
kind: BeatKind::Action,
|
||||
speaker: Some("你".to_owned()),
|
||||
text: request.input.clone(),
|
||||
visual: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn default_suggestions() -> Vec<ActionSuggestion> {
|
||||
vec![
|
||||
ActionSuggestion {
|
||||
id: "suggestion_promise".to_owned(),
|
||||
label: "认真答应她".to_owned(),
|
||||
draft: "我答应你,天亮前一定回来。".to_owned(),
|
||||
},
|
||||
ActionSuggestion {
|
||||
id: "suggestion_ask".to_owned(),
|
||||
label: "追问原因".to_owned(),
|
||||
draft: "你为什么这么在意天亮之前?".to_owned(),
|
||||
},
|
||||
ActionSuggestion {
|
||||
id: "suggestion_observe".to_owned(),
|
||||
label: "先观察她".to_owned(),
|
||||
draft: "我没有立刻回答,先看了看她攥紧外套的手。".to_owned(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn node_id_for_action(request: &TurnRequest) -> String {
|
||||
let identity = format!(
|
||||
"{}\0{}\0{}",
|
||||
request.story_id, request.branch_id, request.action_id
|
||||
);
|
||||
format!(
|
||||
"node_{}",
|
||||
stable_json_hash(identity.as_bytes()).trim_start_matches("sha256:")
|
||||
)
|
||||
}
|
||||
|
||||
fn is_return_promise(input: &str) -> bool {
|
||||
input.contains("天亮前") && input.contains("回来")
|
||||
}
|
||||
|
||||
fn history_label(node: &StoryNode) -> String {
|
||||
if node.parent_id.is_none() {
|
||||
return "雨夜车站".to_owned();
|
||||
}
|
||||
if node
|
||||
.delta
|
||||
.ops
|
||||
.iter()
|
||||
.any(|op| {
|
||||
matches!(
|
||||
op,
|
||||
StateOp::CreatePromise { promise }
|
||||
if is_player_visible_promise(promise)
|
||||
)
|
||||
})
|
||||
{
|
||||
return "天亮前的许诺".to_owned();
|
||||
}
|
||||
if node.user_input.is_empty() {
|
||||
return "雨声中的停顿".to_owned();
|
||||
}
|
||||
let summary = node.user_input.chars().take(12).collect::<String>();
|
||||
format!("回应:{summary}")
|
||||
}
|
||||
|
||||
fn history_for(lineage: &[StoryNode], current: &StoryNode) -> Vec<HistoryNodeView> {
|
||||
lineage
|
||||
.iter()
|
||||
.map(|node| HistoryNodeView {
|
||||
id: node.id.clone(),
|
||||
parent_id: node.parent_id.clone(),
|
||||
branch_id: node.branch_id.clone(),
|
||||
label: history_label(node),
|
||||
is_current: node.id == current.id,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn last_player_relationship_update(lineage: &[StoryNode]) -> Option<&str> {
|
||||
lineage.iter().rev().find_map(|node| {
|
||||
node.delta
|
||||
.ops
|
||||
.iter()
|
||||
.any(|op| {
|
||||
matches!(
|
||||
op,
|
||||
StateOp::AdjustRelationship { from, to, .. }
|
||||
if from == CHARACTER_ID && to == PLAYER_ID
|
||||
)
|
||||
})
|
||||
.then_some(node.id.as_str())
|
||||
})
|
||||
}
|
||||
|
||||
fn is_player_visible_promise(promise: &Promise) -> bool {
|
||||
(promise.promiser == PLAYER_ID || promise.promisee == PLAYER_ID)
|
||||
&& matches!(
|
||||
promise.status,
|
||||
PromiseStatus::Accepted
|
||||
| PromiseStatus::Fulfilled
|
||||
| PromiseStatus::Broken
|
||||
| PromiseStatus::Released
|
||||
| PromiseStatus::Impossible
|
||||
)
|
||||
}
|
||||
|
||||
const fn turn_failure_code(code: TurnFailureCode) -> &'static str {
|
||||
match code {
|
||||
TurnFailureCode::StaleNode => "stale_node",
|
||||
TurnFailureCode::InvalidInput => "invalid_input",
|
||||
TurnFailureCode::InvalidModelOutput => "invalid_model_output",
|
||||
TurnFailureCode::ProviderUnavailable => "provider_unavailable",
|
||||
TurnFailureCode::Cancelled => "cancelled",
|
||||
TurnFailureCode::TimedOut => "timed_out",
|
||||
TurnFailureCode::Internal => "internal",
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -35,42 +525,39 @@ fn get_app_info() -> AppInfo {
|
||||
}
|
||||
|
||||
#[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))
|
||||
fn get_demo_player_view(state: tauri::State<'_, DemoAppState>) -> Result<PlayerView, CommandError> {
|
||||
state.current_player_view()
|
||||
}
|
||||
|
||||
#[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,
|
||||
});
|
||||
fn get_demo_pack_summary(state: tauri::State<'_, DemoAppState>) -> DemoPackSummary {
|
||||
state.pack_summary()
|
||||
}
|
||||
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(),
|
||||
})
|
||||
|
||||
#[tauri::command]
|
||||
fn submit_turn(
|
||||
state: tauri::State<'_, DemoAppState>,
|
||||
request: TurnRequest,
|
||||
) -> Result<TurnResult, CommandError> {
|
||||
state.submit_turn(&request)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
let app_data = app.path().app_data_dir()?;
|
||||
fs::create_dir_all(&app_data)?;
|
||||
let demo = DemoAppState::open(app_data.join("nana-story.sqlite3"))
|
||||
.map_err(|error| std::io::Error::other(error.message))?;
|
||||
app.manage(demo);
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_app_info,
|
||||
get_demo_pack_summary,
|
||||
get_demo_player_view
|
||||
get_demo_player_view,
|
||||
submit_turn
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("failed to run nana-story");
|
||||
@@ -78,19 +565,193 @@ pub fn run() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{get_demo_pack_summary, get_demo_player_view};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use nana_domain::{
|
||||
Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment,
|
||||
RelationshipBand, RelationshipDimension, StateDelta, StateOp, StoryNode,
|
||||
TurnIntent, TurnRequest,
|
||||
};
|
||||
use nana_store::StoryStore;
|
||||
|
||||
use super::{
|
||||
DemoAppState, DEMO_BRANCH_ID, DEMO_STORY_ID, history_label,
|
||||
last_player_relationship_update,
|
||||
};
|
||||
|
||||
struct TemporaryDatabase {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TemporaryDatabase {
|
||||
fn new() -> Self {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
Self {
|
||||
path: std::env::temp_dir().join(format!(
|
||||
"nana-story-app-{}-{nonce}.sqlite3",
|
||||
std::process::id()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TemporaryDatabase {
|
||||
fn drop(&mut self) {
|
||||
if self.path.exists() {
|
||||
fs::remove_file(&self.path).expect("remove temporary app database");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn promise_request(expected_node_id: &str) -> TurnRequest {
|
||||
TurnRequest {
|
||||
story_id: DEMO_STORY_ID.to_owned(),
|
||||
branch_id: DEMO_BRANCH_ID.to_owned(),
|
||||
expected_node_id: expected_node_id.to_owned(),
|
||||
action_id: "action_promise_test".to_owned(),
|
||||
intent: TurnIntent::SpeakOrAct,
|
||||
input: "我答应你,天亮前一定回来。".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn node(id: &str, parent_id: Option<&str>, ops: Vec<StateOp>) -> StoryNode {
|
||||
StoryNode {
|
||||
id: id.to_owned(),
|
||||
story_id: DEMO_STORY_ID.to_owned(),
|
||||
branch_id: DEMO_BRANCH_ID.to_owned(),
|
||||
parent_id: parent_id.map(str::to_owned),
|
||||
action_id: format!("action_{id}"),
|
||||
user_input: "继续前进".to_owned(),
|
||||
beats: Vec::new(),
|
||||
delta: StateDelta { ops },
|
||||
state_hash: format!("hash_{id}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_player_view_is_valid() {
|
||||
let view = get_demo_player_view().expect("valid PlayerView fixture");
|
||||
fn embedded_demo_is_seeded_and_projected_from_runtime_state() {
|
||||
let app = DemoAppState::open_in_memory().expect("valid demo");
|
||||
let view = app.current_player_view().expect("initial PlayerView");
|
||||
|
||||
assert_eq!(view.character_name, "娜娜");
|
||||
assert_eq!(view.node_id, "node_001");
|
||||
assert_eq!(view.inventory.len(), 1);
|
||||
assert_eq!(view.relationship.trust, RelationshipBand::Guarded);
|
||||
assert!(!view.beats.is_empty());
|
||||
assert_eq!(app.pack_summary().plot_modules, 1);
|
||||
}
|
||||
|
||||
#[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);
|
||||
fn promise_turn_reduces_persists_and_restores_before_projection() {
|
||||
let app = DemoAppState::open_in_memory().expect("valid demo");
|
||||
let result = app
|
||||
.submit_turn(&promise_request("node_001"))
|
||||
.expect("committed turn");
|
||||
|
||||
assert_eq!(result.player_view.promises.len(), 1);
|
||||
assert_eq!(result.player_view.promises[0].content, "天亮前一定回来");
|
||||
assert_eq!(result.player_view.history.len(), 2);
|
||||
|
||||
let persisted = app
|
||||
.store
|
||||
.load_state(DEMO_STORY_ID, DEMO_BRANCH_ID)
|
||||
.expect("persisted state");
|
||||
assert_eq!(persisted.current_node, result.committed_node_id);
|
||||
assert_eq!(persisted.relationships["nana->player"].hope, 35);
|
||||
|
||||
let restored = app.current_player_view().expect("restored PlayerView");
|
||||
assert_eq!(restored.node_id, result.committed_node_id);
|
||||
assert_eq!(restored.promises, result.player_view.promises);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_database_reopens_at_the_committed_player_view() {
|
||||
let database = TemporaryDatabase::new();
|
||||
let committed = {
|
||||
let app = DemoAppState::open(database.path()).expect("new file demo");
|
||||
app.submit_turn(&promise_request("node_001"))
|
||||
.expect("committed file turn")
|
||||
.player_view
|
||||
};
|
||||
|
||||
let reopened = DemoAppState::open(database.path()).expect("reopened file demo");
|
||||
let restored = reopened.current_player_view().expect("restored file view");
|
||||
|
||||
assert_eq!(restored.node_id, committed.node_id);
|
||||
assert_eq!(restored.promises, committed.promises);
|
||||
assert_eq!(restored.relationship, committed.relationship);
|
||||
assert_eq!(restored.history, committed.history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_view_does_not_serialize_hidden_item_facts_or_checks() {
|
||||
let app = DemoAppState::open_in_memory().expect("valid demo");
|
||||
let rendered =
|
||||
serde_json::to_string(&app.current_player_view().expect("PlayerView")).expect("JSON");
|
||||
|
||||
assert!(!rendered.contains("电池仓"));
|
||||
assert!(!rendered.contains("\"checks\""));
|
||||
assert!(!rendered.contains("\"roll\""));
|
||||
assert!(!rendered.contains("\"target\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_does_not_reveal_npc_only_promises() {
|
||||
let private_promise = Promise {
|
||||
id: "private".to_owned(),
|
||||
promiser: "nana".to_owned(),
|
||||
promisee: "sister".to_owned(),
|
||||
content: "不能让玩家知道".to_owned(),
|
||||
status: PromiseStatus::Accepted,
|
||||
weight: PromiseWeight::Major,
|
||||
created_at: "node_private".to_owned(),
|
||||
accepted_at: Some("node_private".to_owned()),
|
||||
resolved_at: None,
|
||||
};
|
||||
let private_node = node(
|
||||
"node_private",
|
||||
Some("node_001"),
|
||||
vec![StateOp::CreatePromise {
|
||||
promise: private_promise,
|
||||
}],
|
||||
);
|
||||
|
||||
assert_eq!(history_label(&private_node), "回应:继续前进");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relationship_metadata_tracks_latest_visible_edge_across_later_nodes() {
|
||||
let root = node("node_001", None, Vec::new());
|
||||
let changed = node(
|
||||
"node_002",
|
||||
Some("node_001"),
|
||||
vec![StateOp::AdjustRelationship {
|
||||
from: "nana".to_owned(),
|
||||
to: "player".to_owned(),
|
||||
adjustment: RelationshipAdjustment {
|
||||
dimension: RelationshipDimension::Trust,
|
||||
delta: 2,
|
||||
cause: "test".to_owned(),
|
||||
judgment_rule: None,
|
||||
},
|
||||
}],
|
||||
);
|
||||
let current = node("node_003", Some("node_002"), Vec::new());
|
||||
|
||||
assert_eq!(
|
||||
last_player_relationship_update(&[root, changed, current]),
|
||||
Some("node_002")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+21
-3
@@ -2,7 +2,25 @@ import { onMounted, ref } from "vue";
|
||||
|
||||
import type { AppInfo, DemoPackSummary, PlayerView, TurnIntent, TurnRequest } from "@contracts";
|
||||
|
||||
import { getAppInfo, getDemoPackSummary, getDemoPlayerView, submitTurn as submitRuntimeTurn } from "./bridge";
|
||||
import {
|
||||
getAppInfo,
|
||||
getDemoPackSummary,
|
||||
getDemoPlayerView,
|
||||
submitTurn as submitRuntimeTurn
|
||||
} from "./bridge";
|
||||
|
||||
function errorMessage(reason: unknown): string {
|
||||
if (reason instanceof Error) return reason.message;
|
||||
if (
|
||||
typeof reason === "object" &&
|
||||
reason !== null &&
|
||||
"message" in reason &&
|
||||
typeof reason.message === "string"
|
||||
) {
|
||||
return reason.message;
|
||||
}
|
||||
return String(reason);
|
||||
}
|
||||
|
||||
export function useDemo() {
|
||||
const appInfo = ref<AppInfo | null>(null);
|
||||
@@ -22,7 +40,7 @@ export function useDemo() {
|
||||
getDemoPlayerView()
|
||||
]);
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : String(reason);
|
||||
error.value = errorMessage(reason);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -53,7 +71,7 @@ export function useDemo() {
|
||||
const result = await submitRuntimeTurn(request, currentView);
|
||||
playerView.value = result.playerView;
|
||||
} catch (reason) {
|
||||
turnError.value = reason instanceof Error ? reason.message : String(reason);
|
||||
turnError.value = errorMessage(reason);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user