use std::{fs, path::Path}; use nana_domain::{ ActionSuggestion, AppInfo, BeatKind, DOMAIN_SCHEMA_VERSION, DemoPackSummary, HistoryNodeView, PlayerView, PresentationBeat, PresentationCharacter, PresentationScene, PresentationSnapshot, Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment, RelationshipDimension, ResourceBundle, RuntimeState, StateDelta, StateOp, StoryNode, TurnFailure, TurnFailureCode, TurnIntent, TurnRequest, TurnResult, ValidationIssue, VisualDirective, stable_json_hash, validate_bundle, }; use nana_engine::{ SceneMetadata, StoryNodePlayerViewProjectionContext, project_story_node_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: String, message: String, retryable: bool, issues: Vec, } impl CommandError { fn parse(target: &str, error: &serde_json::Error) -> Self { Self { 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, &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) -> PlayerView { let lineage = self .load_lineage(node) .unwrap_or_else(|_| vec![node.clone()]); self.project_view_with_lineage(state, node, &lineage) } fn project_view_with_lineage( &self, state: &RuntimeState, node: &StoryNode, 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_story_node_player_view( state, node, &StoryNodePlayerViewProjectionContext { player_id: PLAYER_ID, relationship_character_id: CHARACTER_ID, relationship_updated_at_node, item_specs: &self.bundle.item_specs, history: &history, legacy_scene: SceneMetadata { scene_id: "old_station_platform", scene_title: "旧青川站", character_name, }, }, ) } fn load_lineage(&self, current: &StoryNode) -> Result, StoreError> { let mut lineage = vec![current.clone()]; let mut cursor = current.parent_id.clone(); while let Some(ref 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.clone_from(&node.parent_id); 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, presentation: demo_presentation(beats), delta, }) } } struct DemoProjector<'a> { app: &'a DemoAppState, } impl TurnProjector for DemoProjector<'_> { fn project_committed_turn(&mut self, state: &RuntimeState, node: &StoryNode) -> PlayerView { self.app.project_view(state, node) } } 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(), presentation: PresentationSnapshot { scene: PresentationScene { id: view.scene_id, title: view.scene_title, }, character: PresentationCharacter { id: CHARACTER_ID.to_owned(), name: view.character_name, expression: view.character_expression, pose: view.character_pose, }, beats: view.beats, suggestions: view.suggestions, can_continue: view.can_continue, }, 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: Some("holding_coat".to_owned()), 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 demo_presentation(beats: Vec) -> PresentationSnapshot { let scene_id = beats .iter() .filter_map(|beat| beat.visual.as_ref()) .filter_map(|visual| visual.scene.as_deref()) .next_back() .unwrap_or("old_station_platform") .to_owned(); let expression = beats .iter() .filter_map(|beat| beat.visual.as_ref()) .filter_map(|visual| visual.expression.clone()) .next_back(); let pose = beats .iter() .filter_map(|beat| beat.visual.as_ref()) .filter_map(|visual| visual.pose.clone()) .next_back(); PresentationSnapshot { scene: PresentationScene { id: scene_id, title: "旧青川站".to_owned(), }, character: PresentationCharacter { id: CHARACTER_ID.to_owned(), name: "娜娜".to_owned(), expression, pose, }, beats, suggestions: default_suggestions(), can_continue: true, } } 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] fn get_app_info() -> AppInfo { AppInfo { name: "听娜娜讲故事".to_owned(), version: env!("CARGO_PKG_VERSION").to_owned(), contract_version: DOMAIN_SCHEMA_VERSION, } } #[tauri::command] #[allow(clippy::needless_pass_by_value)] // Tauri extracts managed state through this owned wrapper. fn get_demo_player_view(state: tauri::State<'_, DemoAppState>) -> Result { state.current_player_view() } #[tauri::command] #[allow(clippy::needless_pass_by_value)] // Tauri extracts managed state through this owned wrapper. fn get_demo_pack_summary(state: tauri::State<'_, DemoAppState>) -> DemoPackSummary { state.pack_summary() } #[tauri::command] #[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values. fn submit_turn( state: tauri::State<'_, DemoAppState>, request: TurnRequest, ) -> Result { state.submit_turn(&request) } /// Starts the desktop application and opens its local story database. /// /// # Panics /// /// Panics when the Tauri runtime cannot be started. #[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, submit_turn ]) .run(tauri::generate_context!()) .expect("failed to run nana-story"); } #[cfg(test)] mod tests { use std::{ fs, path::{Path, PathBuf}, time::{SystemTime, UNIX_EPOCH}, }; use nana_domain::{ PresentationSnapshot, Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment, RelationshipBand, RelationshipDimension, StateDelta, StateOp, StoryNode, TurnIntent, TurnRequest, }; use nana_store::StoryStore; use super::{ DEMO_BRANCH_ID, DEMO_STORY_ID, DemoAppState, 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(), presentation: PresentationSnapshot::default(), delta: StateDelta { ops }, state_hash: format!("hash_{id}"), } } #[test] 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); assert_eq!(restored.beats, result.player_view.beats); assert_eq!(restored.suggestions, result.player_view.suggestions); assert_eq!( restored.character_expression, result.player_view.character_expression ); assert_eq!(restored.character_pose, result.player_view.character_pose); } #[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); assert_eq!(restored.beats, committed.beats); assert_eq!(restored.suggestions, committed.suggestions); assert_eq!( restored.character_expression, committed.character_expression ); assert_eq!(restored.character_pose, committed.character_pose); } #[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") ); } }