diff --git a/README.md b/README.md index a8834e3..0602088 100644 --- a/README.md +++ b/README.md @@ -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` 与桌面启动冒烟。 ## 开发 diff --git a/docs/status/2026-07-28-m1-wave2.md b/docs/status/2026-07-28-m1-wave2.md new file mode 100644 index 0000000..6d2ff3e --- /dev/null +++ b/docs/status/2026-07-28-m1-wave2.md @@ -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. 为回溯面板增加“从这里继续”命令,验证旧分支保留和新分支状态隔离。 diff --git a/fixtures/runtime-state/initial.json b/fixtures/runtime-state/initial.json index 4ac586b..4ba93e5 100644 --- a/fixtures/runtime-state/initial.json +++ b/fixtures/runtime-state/initial.json @@ -19,7 +19,7 @@ { "id": "knowledge_station_clock", "observer": "player", - "subject": "old_station_platform", + "subject": "停摆的站钟", "fact": "站钟停在四点十七分,似乎并非自然损坏。", "certainty": "confirmed", "source": "玩家进入站台时亲眼观察", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 588c2dc..fd555c8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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 = [] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4bfc11b..13110ad 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, } 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) -> Result { + let store = SqliteStoryStore::open(path).map_err(|error| CommandError::storage(&error))?; + Self::from_store(store) + } + + #[cfg(test)] + fn open_in_memory() -> Result { + let store = + SqliteStoryStore::open_in_memory().map_err(|error| CommandError::storage(&error))?; + Self::from_store(store) + } + + fn from_store(store: SqliteStoryStore) -> Result { + 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 { + 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 { + 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, 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 { + 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, 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, 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, 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 { + 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::(); + format!("回应:{summary}") +} + +fn history_for(lineage: &[StoryNode], current: &StoryNode) -> Vec { + 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 { - 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 { + state.current_player_view() } #[tauri::command] -fn get_demo_pack_summary() -> Result { - 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(), - }) +fn get_demo_pack_summary(state: tauri::State<'_, DemoAppState>) -> DemoPackSummary { + state.pack_summary() +} + +#[tauri::command] +fn submit_turn( + state: tauri::State<'_, DemoAppState>, + request: TurnRequest, +) -> Result { + 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}, + }; - #[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()); + 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) -> 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_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 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 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") + ); } } diff --git a/src/app/useDemo.ts b/src/app/useDemo.ts index 1216da9..56019ae 100644 --- a/src/app/useDemo.ts +++ b/src/app/useDemo.ts @@ -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(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; }