use std::{fs, path::Path, sync::Mutex}; use nana_domain::{ AcquisitionMode, ActionSuggestion, AppInfo, BeatKind, BranchList, BranchSummary, CheckDifficulty, CheckRecord, CheckResult, DOMAIN_SCHEMA_VERSION, DemoPackSummary, ForkBranchRequest, ForkBranchResult, HistoryNodeView, ItemAcquisition, ItemInstance, ItemPlacement, KnowledgeCertainty, KnowledgeRecord, LappMode, LappModelOption, LappSettings, PlayerView, PresentationBeat, PresentationCharacter, PresentationScene, PresentationSnapshot, Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment, RelationshipDimension, RenameBranchRequest, ResourceBundle, ResourceId, RuntimeState, StateDelta, StateOp, StoryNode, SwitchBranchRequest, SwitchBranchResult, TurnFailure, TurnFailureCode, TurnIntent, TurnRequest, TurnResult, UpdateLappSettingsRequest, ValidationIssue, VisualDirective, stable_json_hash, validate_bundle, }; use nana_engine::{ SceneMetadata, StoryNodePlayerViewProjectionContext, project_story_node_player_view, }; use nana_runtime::{ AdjudicatingTurnPlanProvider, AdjudicationCatalog, LappAdjudicationModel, OpenLappChatExecutor, ProviderError, TurnEngine, TurnPlan, TurnPlanProvider, TurnProjector, load_default_lapp_profile, }; use nana_store::{ForkError, SqliteStoryStore, StoreError, StoredBranch, StoryStore}; use openlapp::{connection::ListModelsOptions, list_models}; 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"; const LAPP_PROVIDER_SETTING: &str = "lapp.provider_id"; const LAPP_MODEL_SETTING: &str = "lapp.model_id"; #[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(), } } fn invalid_input(message: impl Into) -> Self { Self { code: "invalid_input".to_owned(), message: message.into(), retryable: false, issues: Vec::new(), } } fn stale_branch() -> Self { Self { code: "stale_node".to_owned(), message: "story branch changed; refresh and retry".to_owned(), retryable: true, issues: Vec::new(), } } fn operation_lock() -> Self { Self { code: "storage_unavailable".to_owned(), message: "故事存档暂时不可用。".to_owned(), retryable: true, issues: Vec::new(), } } fn fork(error: &ForkError) -> Self { match error { ForkError::BranchAlreadyExists { .. } => Self::stale_branch(), ForkError::Store(StoreError::ParentNotFound(_)) => { Self::invalid_input("selected story node is unavailable") } ForkError::InvalidBranchId(_) => Self { code: "internal".to_owned(), message: "new story branch could not be prepared".to_owned(), retryable: true, issues: Vec::new(), }, ForkError::Store(error) => Self::storage(error), } } } struct DemoAppState { store: SqliteStoryStore, bundle: ResourceBundle, operation_lock: Mutex<()>, provider: Mutex, } type LappRuntimeProvider = AdjudicatingTurnPlanProvider>; enum RuntimePlanProvider { Demo(DemoPlanProvider), Lapp(Box), Unavailable, } impl RuntimePlanProvider { fn configured(bundle: &ResourceBundle, store: &SqliteStoryStore) -> Self { if demo_provider_requested() { return Self::Demo(DemoPlanProvider); } let Ok(catalog) = AdjudicationCatalog::from_bundle(bundle) else { return Self::Unavailable; }; let Ok(profile) = load_default_lapp_profile() else { return Self::Unavailable; }; let selected = selected_lapp_model( &profile, store.get_app_setting(LAPP_PROVIDER_SETTING).ok().flatten(), store.get_app_setting(LAPP_MODEL_SETTING).ok().flatten(), ); let Some((provider_id, model_id)) = selected.0.zip(selected.1) else { return Self::Unavailable; }; if !lapp_model_options(&profile) .iter() .any(|model| model.provider_id == provider_id && model.model_id == model_id) { return Self::Unavailable; } let model = LappAdjudicationModel::from_profile_and_model( &profile, &provider_id, &model_id, bundle.clone(), ); let Ok(model) = model else { return Self::Unavailable; }; Self::Lapp(Box::new(AdjudicatingTurnPlanProvider::new(model, catalog))) } } fn demo_provider_requested() -> bool { std::env::var("NANA_STORY_PROVIDER").is_ok_and(|provider| provider.eq_ignore_ascii_case("demo")) } impl TurnPlanProvider for RuntimePlanProvider { fn plan_turn( &mut self, request: &TurnRequest, state: &RuntimeState, ) -> Result { match self { Self::Demo(provider) => provider.plan_turn(request, state), Self::Lapp(provider) => provider.plan_turn(request, state), Self::Unavailable => Err(ProviderError::Configuration { code: None }), } } } 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)), } let provider = if cfg!(test) { RuntimePlanProvider::Demo(DemoPlanProvider) } else { RuntimePlanProvider::configured(&bundle, &store) }; Ok(Self { store, bundle, operation_lock: Mutex::new(()), provider: Mutex::new(provider), }) } 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 active_branch = self .store .active_branch(DEMO_STORY_ID) .map_err(|error| CommandError::storage(&error))?; let state = self .store .load_state(DEMO_STORY_ID, &active_branch) .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 _operation = self .operation_lock .lock() .map_err(|_| CommandError::operation_lock())?; let active_branch = self .store .active_branch(&request.story_id) .map_err(|error| CommandError::storage(&error))?; if active_branch != request.branch_id { return Err(CommandError::stale_branch()); } let current_state = self .store .load_state(&request.story_id, &request.branch_id) .map_err(|error| CommandError::storage(&error))?; if current_state .world_flags .get("nana.ending.returned_before_dawn") .copied() .unwrap_or(false) { return Err(CommandError::invalid_input( "这一夜的故事已经结束;可从回溯中选择另一条线路。", )); } let mut provider = self .provider .lock() .map_err(|_| CommandError::operation_lock())?; let projector = DemoProjector { app: self }; let mut engine = TurnEngine::new(&self.store, &mut *provider, projector); engine.submit_turn(request).map_err(CommandError::turn) } fn fork_branch(&self, request: &ForkBranchRequest) -> Result { validate_fork_request(request)?; let _operation = self .operation_lock .lock() .map_err(|_| CommandError::operation_lock())?; let current = self .store .load_state(&request.story_id, &request.current_branch_id) .map_err(|error| CommandError::storage(&error))?; let active_branch = self .store .active_branch(&request.story_id) .map_err(|error| CommandError::storage(&error))?; if active_branch != request.current_branch_id { return Err(CommandError::stale_branch()); } if current.current_node != request.expected_current_node_id { return Err(CommandError::stale_branch()); } let current_node = self .store .load_node(&request.story_id, ¤t.current_node) .map_err(|error| CommandError::storage(&error))?; let current_lineage = self .load_lineage(¤t_node) .map_err(|error| CommandError::storage(&error))?; if !current_lineage .iter() .any(|node| node.id == request.source_node_id) { return Err(CommandError::invalid_input( "selected story node is not on the current route", )); } let branch_id = branch_id_for_fork(request); let state = self .store .fork_branch(&request.story_id, &request.source_node_id, &branch_id) .map_err(|error| CommandError::fork(&error))?; let source = self .store .load_node(&request.story_id, &request.source_node_id) .map_err(|error| CommandError::storage(&error))?; let lineage = self .load_lineage(&source) .map_err(|error| CommandError::storage(&error))?; let player_view = self.project_view_with_lineage(&state, &source, &lineage); Ok(ForkBranchResult { branch_id, player_view, }) } fn branch_list(&self) -> Result { let active_branch_id = self .store .active_branch(DEMO_STORY_ID) .map_err(|error| CommandError::storage(&error))?; let branches = self .store .list_branches(DEMO_STORY_ID) .map_err(|error| CommandError::storage(&error))? .into_iter() .map(|branch| self.branch_summary(branch, &active_branch_id)) .collect::, _>>()?; Ok(BranchList { story_id: DEMO_STORY_ID.to_owned(), active_branch_id, branches, }) } fn branch_summary( &self, branch: StoredBranch, active_branch_id: &str, ) -> Result { let head = self .store .load_node(DEMO_STORY_ID, &branch.head_node_id) .map_err(|error| CommandError::storage(&error))?; Ok(BranchSummary { is_active: branch.branch_id == active_branch_id, branch_id: branch.branch_id, name: branch.name, head_node_id: branch.head_node_id, head_label: history_label(&head), source_node_id: branch.source_node_id, }) } fn switch_branch( &self, request: &SwitchBranchRequest, ) -> Result { validate_switch_branch_request(request)?; let _operation = self .operation_lock .lock() .map_err(|_| CommandError::operation_lock())?; let state = self .store .switch_active_branch( &request.story_id, &request.expected_active_branch_id, &request.branch_id, ) .map_err(|error| match error { StoreError::StaleBranchHead { .. } => CommandError::stale_branch(), _ => 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(SwitchBranchResult { branch_id: request.branch_id.clone(), player_view: self.project_view_with_lineage(&state, &node, &lineage), }) } fn rename_branch(&self, request: &RenameBranchRequest) -> Result { validate_rename_branch_request(request)?; let _operation = self .operation_lock .lock() .map_err(|_| CommandError::operation_lock())?; self.store .rename_branch(&request.story_id, &request.branch_id, &request.name) .map_err(|error| match error { StoreError::StateMismatch("invalid branch name") => { CommandError::invalid_input("线路名称需为 1–40 个可见字符。") } _ => CommandError::storage(&error), })?; self.branch_list() } fn lapp_settings(&self) -> LappSettings { if demo_provider_requested() { return LappSettings { mode: LappMode::Demo, selected_provider_id: None, selected_model_id: None, available_models: Vec::new(), status_message: "当前由 NANA_STORY_PROVIDER=demo 使用确定性纵切。".to_owned(), }; } let Ok(profile) = load_default_lapp_profile() else { return LappSettings { mode: LappMode::Unavailable, selected_provider_id: None, selected_model_id: None, available_models: Vec::new(), status_message: "未找到可用的 LAPP profile。请先在系统 LAPP 中配置供应商与凭据。" .to_owned(), }; }; let available_models = lapp_model_options(&profile); let saved_provider = self .store .get_app_setting(LAPP_PROVIDER_SETTING) .ok() .flatten(); let saved_model = self .store .get_app_setting(LAPP_MODEL_SETTING) .ok() .flatten(); let (selected_provider_id, selected_model_id) = selected_lapp_model(&profile, saved_provider, saved_model); let selected_is_available = selected_provider_id .as_ref() .zip(selected_model_id.as_ref()) .is_some_and(|(provider_id, model_id)| { available_models .iter() .any(|model| &model.provider_id == provider_id && &model.model_id == model_id) }); let (mode, status_message) = if selected_is_available { ( LappMode::Lapp, "已读取 LAPP profile;凭据只会在生成时由系统 Vault 解析。".to_owned(), ) } else { ( LappMode::Unavailable, "LAPP 中没有可用于工具调用的已启用聊天模型。".to_owned(), ) }; LappSettings { mode, selected_provider_id, selected_model_id, available_models, status_message, } } fn update_lapp_settings( &self, request: &UpdateLappSettingsRequest, ) -> Result { if demo_provider_requested() { return Err(CommandError::invalid_input( "确定性演示模式由环境变量锁定,无法在应用内切换模型。", )); } validate_lapp_settings_request(request)?; let profile = load_default_lapp_profile().map_err(|_| CommandError { code: "provider_unavailable".to_owned(), message: "未找到可用的 LAPP profile。".to_owned(), retryable: true, issues: Vec::new(), })?; let available = lapp_model_options(&profile); if !available.iter().any(|model| { model.provider_id == request.provider_id && model.model_id == request.model_id }) { return Err(CommandError::invalid_input( "所选模型不可用,或未声明聊天与工具调用能力。", )); } let catalog = AdjudicationCatalog::from_bundle(&self.bundle).map_err(|_| CommandError { code: "internal".to_owned(), message: "内容包无法建立可信裁决目录。".to_owned(), retryable: false, issues: Vec::new(), })?; let model = LappAdjudicationModel::from_profile_and_model( &profile, &request.provider_id, &request.model_id, self.bundle.clone(), ) .map_err(|_| CommandError { code: "provider_unavailable".to_owned(), message: "所选 LAPP 模型无法初始化。".to_owned(), retryable: true, issues: Vec::new(), })?; let replacement = RuntimePlanProvider::Lapp(Box::new(AdjudicatingTurnPlanProvider::new(model, catalog))); let _operation = self .operation_lock .lock() .map_err(|_| CommandError::operation_lock())?; let mut provider = self .provider .lock() .map_err(|_| CommandError::operation_lock())?; self.store .set_app_settings(&[ (LAPP_PROVIDER_SETTING, &request.provider_id), (LAPP_MODEL_SETTING, &request.model_id), ]) .map_err(|error| CommandError::storage(&error))?; *provider = replacement; drop(provider); Ok(self.lapp_settings()) } 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, suggestions, can_continue) = if accepted_promise { let (beats, delta) = promise_turn(request, &committed_node_id); (beats, delta, investigation_suggestions(), true) } else if state .world_flags .get("nana.tunnel.entered") .copied() .unwrap_or(false) { let (beats, delta) = return_before_dawn_turn(request, state, &committed_node_id); (beats, delta, Vec::new(), false) } else if state .world_flags .get("nana.clue.maintenance_door") .copied() .unwrap_or(false) { let (beats, delta) = enter_tunnel_turn(request); (beats, delta, return_suggestions(), true) } else if state.promises.iter().any(|promise| { promise.promiser == PLAYER_ID && promise.promisee == CHARACTER_ID && promise.status == PromiseStatus::Accepted }) { let (beats, delta) = investigate_platform_turn(request, &committed_node_id); (beats, delta, tunnel_suggestions(), true) } else if request.intent == TurnIntent::Continue { let (beats, delta) = continue_turn(request, state); (beats, delta, default_suggestions(), true) } else { let (beats, delta) = regular_turn(request); (beats, delta, default_suggestions(), true) }; Ok(TurnPlan { committed_node_id, presentation: demo_presentation(beats, suggestions, can_continue), 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::SetWorldFlag { key: "nana.promise.return_before_dawn".to_owned(), value: true, }, 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 investigate_platform_turn( request: &TurnRequest, node_id: &str, ) -> (Vec, StateDelta) { let beats = vec![ player_action_beat(request), PresentationBeat { id: format!("{}_search", request.action_id), kind: BeatKind::Narration, speaker: None, text: "你打开旧手电,沿着站台边缘寻找。斜光扫过积水,一道被锈迹遮住的检修门轮廓显了出来。" .to_owned(), visual: Some(VisualDirective { character: None, expression: None, pose: None, scene: Some("station_maintenance_door".to_owned()), }), }, PresentationBeat { id: format!("{}_ticket", 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("startled".to_owned()), pose: Some("reaching_out".to_owned()), scene: None, }), }, ]; let delta = StateDelta { ops: vec![ StateOp::RecordCheck { check: CheckRecord { id: format!("check_find_door_{}", request.action_id), action_id: request.action_id.clone(), actor: PLAYER_ID.to_owned(), skill: "spot_hidden".to_owned(), target: 56, difficulty: CheckDifficulty::Regular, bonus_dice: 0, roll: 42, result: CheckResult::Success, pushed_from: None, node_id: node_id.to_owned(), }, }, StateOp::AddKnowledge { record: KnowledgeRecord { id: format!("knowledge_maintenance_door_{}", request.action_id), observer: PLAYER_ID.to_owned(), subject: Some("封锁隧道的检修门".to_owned()), fact: "旧站台下方的检修门通向封锁隧道,妹妹留下的车票指向四点十七分。" .to_owned(), certainty: KnowledgeCertainty::Confirmed, source: "玩家用手电检查站台边缘".to_owned(), learned_at: node_id.to_owned(), last_verified_at: Some(node_id.to_owned()), }, }, StateOp::AddItem { item: ItemInstance { id: format!("item_half_ticket_{}", request.action_id), spec_ref: ResourceId("nana.item.half_ticket".to_owned()), owner: PLAYER_ID.to_owned(), holder: PLAYER_ID.to_owned(), placement: ItemPlacement::Bag, quantity: 1, condition: "damp".to_owned(), state_tags: vec!["sister_clue".to_owned()], acquisition: ItemAcquisition { mode: AcquisitionMode::Found, from: Some("old_station_platform".to_owned()), at_node: node_id.to_owned(), }, }, }, StateOp::SetWorldFlag { key: "nana.clue.maintenance_door".to_owned(), value: true, }, StateOp::AdvanceClock { clock_id: "clock_dawn".to_owned(), delta: 2, }, ], }; (beats, delta) } fn enter_tunnel_turn(request: &TurnRequest) -> (Vec, StateDelta) { let beats = vec![ player_action_beat(request), PresentationBeat { id: format!("{}_door", request.action_id), kind: BeatKind::Narration, speaker: None, text: "检修门在肩膀的撞击下松开。手电光伸进隧道,只照见没过鞋面的水和一串向深处延伸的脚印。" .to_owned(), visual: Some(VisualDirective { character: None, expression: None, pose: None, scene: Some("sealed_tunnel".to_owned()), }), }, 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("determined".to_owned()), pose: Some("at_door".to_owned()), scene: None, }), }, ]; let delta = StateDelta { ops: vec![ StateOp::SetWorldFlag { key: "nana.tunnel.entered".to_owned(), value: true, }, StateOp::AdjustRelationship { from: CHARACTER_ID.to_owned(), to: PLAYER_ID.to_owned(), adjustment: RelationshipAdjustment { dimension: RelationshipDimension::Respect, delta: 3, cause: "玩家为兑现许诺进入封锁隧道".to_owned(), judgment_rule: Some("nana.promises.accepted_risk".to_owned()), }, }, StateOp::AdvanceClock { clock_id: "clock_dawn".to_owned(), delta: 2, }, ], }; (beats, delta) } fn return_before_dawn_turn( request: &TurnRequest, state: &RuntimeState, node_id: &str, ) -> (Vec, StateDelta) { let promise_id = state .promises .iter() .find(|promise| { promise.promiser == PLAYER_ID && promise.promisee == CHARACTER_ID && promise.status == PromiseStatus::Accepted }) .map_or_else( || "promise_return_before_dawn".to_owned(), |promise| promise.id.clone(), ); let beats = vec![ PresentationBeat { id: format!("{}_return", request.action_id), kind: BeatKind::Narration, speaker: None, text: "天色发白前,你重新推开检修门。雨已经小了,娜娜仍坐在原处,怀里抱着那件湿透的外套。" .to_owned(), visual: Some(VisualDirective { character: Some(CHARACTER_ID.to_owned()), expression: Some("disbelieving".to_owned()), pose: Some("waiting".to_owned()), scene: Some("station_before_dawn".to_owned()), }), }, 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("lowered_guard".to_owned()), scene: None, }), }, ]; let delta = StateDelta { ops: vec![ StateOp::UpdatePromise { promise_id, status: PromiseStatus::Fulfilled, resolved_at: Some(node_id.to_owned()), }, StateOp::SetWorldFlag { key: "nana.ending.returned_before_dawn".to_owned(), value: true, }, StateOp::AdjustRelationship { from: CHARACTER_ID.to_owned(), to: PLAYER_ID.to_owned(), adjustment: RelationshipAdjustment { dimension: RelationshipDimension::Trust, delta: 7, cause: "玩家在天亮前履行了归来的许诺".to_owned(), judgment_rule: Some("nana.promises.proven_by_action".to_owned()), }, }, StateOp::AdvanceClock { clock_id: "clock_dawn".to_owned(), delta: 1, }, ], }; (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, suggestions: Vec, can_continue: bool, ) -> 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, can_continue, } } 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 investigation_suggestions() -> Vec { vec![ ActionSuggestion { id: "suggestion_search".to_owned(), label: "检查站台边缘".to_owned(), draft: "我打开旧手电,沿着站台边缘寻找她妹妹留下的线索。".to_owned(), }, ActionSuggestion { id: "suggestion_ticket".to_owned(), label: "查看检票口".to_owned(), draft: "我去看看废弃检票口附近有没有被忽略的东西。".to_owned(), }, ] } fn tunnel_suggestions() -> Vec { vec![ ActionSuggestion { id: "suggestion_enter".to_owned(), label: "打开检修门".to_owned(), draft: "我带上手电,打开检修门进入封锁隧道。".to_owned(), }, ActionSuggestion { id: "suggestion_wait".to_owned(), label: "让娜娜留下".to_owned(), draft: "你留在这里等我,我会按约定回来。".to_owned(), }, ] } fn return_suggestions() -> Vec { vec![ActionSuggestion { id: "suggestion_return".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 branch_id_for_fork(request: &ForkBranchRequest) -> String { let identity = format!( "{}\0{}\0{}\0{}", request.story_id, request.current_branch_id, request.source_node_id, request.action_id ); format!( "branch_{}", stable_json_hash(identity.as_bytes()).trim_start_matches("sha256:") ) } fn validate_fork_request(request: &ForkBranchRequest) -> Result<(), CommandError> { for (field, value) in [ ("story_id", request.story_id.as_str()), ("current_branch_id", request.current_branch_id.as_str()), ( "expected_current_node_id", request.expected_current_node_id.as_str(), ), ("source_node_id", request.source_node_id.as_str()), ("action_id", request.action_id.as_str()), ] { if value.trim().is_empty() { return Err(CommandError::invalid_input(format!( "{field} must not be empty" ))); } } Ok(()) } fn validate_switch_branch_request(request: &SwitchBranchRequest) -> Result<(), CommandError> { for (field, value) in [ ("story_id", request.story_id.as_str()), ("branch_id", request.branch_id.as_str()), ( "expected_active_branch_id", request.expected_active_branch_id.as_str(), ), ] { if value.trim().is_empty() { return Err(CommandError::invalid_input(format!( "{field} must not be empty" ))); } } Ok(()) } fn validate_rename_branch_request(request: &RenameBranchRequest) -> Result<(), CommandError> { if request.story_id.trim().is_empty() || request.branch_id.trim().is_empty() { return Err(CommandError::invalid_input( "story_id and branch_id must not be empty", )); } if request.name.trim().is_empty() { return Err(CommandError::invalid_input("线路名称不能为空。")); } Ok(()) } fn validate_lapp_settings_request(request: &UpdateLappSettingsRequest) -> Result<(), CommandError> { if request.provider_id.trim().is_empty() || request.model_id.trim().is_empty() { return Err(CommandError::invalid_input( "provider_id and model_id must not be empty", )); } Ok(()) } fn lapp_model_options(profile: &openlapp::Profile) -> Vec { let mut models = list_models(profile, &ListModelsOptions::default()) .into_iter() .filter(|model| { model.capabilities.as_ref().is_some_and(|capabilities| { capabilities.iter().any(|value| value == "chat") && capabilities.iter().any(|value| value == "tool-call") }) }) .map(|model| LappModelOption { provider_id: model.provider_id, provider_name: model.provider_name, model_id: model.model_id, model_name: model.model_name, }) .collect::>(); models.sort_by(|left, right| { (&left.provider_id, &left.model_id).cmp(&(&right.provider_id, &right.model_id)) }); models } fn selected_lapp_model( profile: &openlapp::Profile, saved_provider: Option, saved_model: Option, ) -> (Option, Option) { match (saved_provider, saved_model) { (Some(provider), Some(model)) => (Some(provider), Some(model)), _ => profile .global .as_ref() .and_then(|global| global.defaults.get("chat")) .map_or((None, None), |selected| { ( Some(selected.provider_id.clone()), Some(selected.model_id.clone()), ) }), } } 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 .delta .ops .iter() .any(|op| matches!(op, StateOp::AddKnowledge { .. })) { return "检修门的线索".to_owned(); } if node.delta.ops.iter().any(|op| { matches!( op, StateOp::SetWorldFlag { key, value } if key == "nana.tunnel.entered" && *value ) }) { return "进入封锁隧道".to_owned(); } if node.delta.ops.iter().any(|op| { matches!( op, StateOp::UpdatePromise { status: PromiseStatus::Fulfilled, .. } ) }) { 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 extracts managed state through this owned wrapper. fn get_branch_list(state: tauri::State<'_, DemoAppState>) -> Result { state.branch_list() } #[tauri::command] #[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values. fn switch_branch( state: tauri::State<'_, DemoAppState>, request: SwitchBranchRequest, ) -> Result { state.switch_branch(&request) } #[tauri::command] #[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values. fn rename_branch( state: tauri::State<'_, DemoAppState>, request: RenameBranchRequest, ) -> Result { state.rename_branch(&request) } #[tauri::command] #[allow(clippy::needless_pass_by_value)] // Tauri extracts managed state through this owned wrapper. fn get_lapp_settings(state: tauri::State<'_, DemoAppState>) -> LappSettings { state.lapp_settings() } #[tauri::command] #[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values. fn update_lapp_settings( state: tauri::State<'_, DemoAppState>, request: UpdateLappSettingsRequest, ) -> Result { state.update_lapp_settings(&request) } #[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) } #[tauri::command] #[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values. fn fork_branch( state: tauri::State<'_, DemoAppState>, request: ForkBranchRequest, ) -> Result { state.fork_branch(&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, get_branch_list, switch_branch, rename_branch, get_lapp_settings, update_lapp_settings, submit_turn, fork_branch ]) .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::{ ForkBranchRequest, PresentationSnapshot, Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment, RelationshipBand, RelationshipDimension, RenameBranchRequest, StateDelta, StateOp, StoryNode, SwitchBranchRequest, 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 fork_request(expected_current_node_id: &str, source_node_id: &str) -> ForkBranchRequest { ForkBranchRequest { story_id: DEMO_STORY_ID.to_owned(), current_branch_id: DEMO_BRANCH_ID.to_owned(), expected_current_node_id: expected_current_node_id.to_owned(), source_node_id: source_node_id.to_owned(), action_id: "action_fork_test".to_owned(), } } fn continue_request(branch_id: &str, expected_node_id: &str, action_id: &str) -> TurnRequest { TurnRequest { story_id: DEMO_STORY_ID.to_owned(), branch_id: branch_id.to_owned(), expected_node_id: expected_node_id.to_owned(), action_id: action_id.to_owned(), intent: TurnIntent::Continue, input: String::new(), } } 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 complete_before_dawn_slice_records_check_item_promise_and_ending_once() { let app = DemoAppState::open_in_memory().expect("valid demo"); let promise = app .submit_turn(&promise_request("node_001")) .expect("promise turn"); let clue = app .submit_turn(&continue_request( DEMO_BRANCH_ID, &promise.committed_node_id, "action_find_clue", )) .expect("hidden search turn"); assert_eq!(clue.player_view.inventory.len(), 2); assert_eq!(clue.player_view.inventory[1].name, "半张旧车票"); assert_eq!( clue.player_view .knowledge .last() .map(|record| record.title.as_str()), Some("封锁隧道的检修门") ); let tunnel = app .submit_turn(&continue_request( DEMO_BRANCH_ID, &clue.committed_node_id, "action_enter_tunnel", )) .expect("enter tunnel turn"); let returned = app .submit_turn(&continue_request( DEMO_BRANCH_ID, &tunnel.committed_node_id, "action_return_before_dawn", )) .expect("return turn"); assert!(!returned.player_view.can_continue); assert_eq!( returned.player_view.promises[0].status, PromiseStatus::Fulfilled ); assert_eq!( returned .player_view .history .last() .map(|node| node.label.as_str()), Some("天亮前归来") ); let persisted = app .store .load_state(DEMO_STORY_ID, DEMO_BRANCH_ID) .expect("completed state"); assert_eq!(persisted.checks.len(), 1); assert_eq!(persisted.items.len(), 2); assert_eq!(persisted.clocks[0].value, 5); assert_eq!(persisted.promises[0].status, PromiseStatus::Fulfilled); assert_eq!(persisted.relationships["nana->player"].trust, 41); assert_eq!(persisted.relationships["nana->player"].respect, 48); assert_eq!( persisted .world_flags .get("nana.ending.returned_before_dawn"), Some(&true) ); let rejected = app .submit_turn(&continue_request( DEMO_BRANCH_ID, &returned.committed_node_id, "action_after_ending", )) .expect_err("ending is terminal"); assert_eq!(rejected.code, "invalid_input"); assert_eq!( app.store .load_state(DEMO_STORY_ID, DEMO_BRANCH_ID) .expect("unchanged ending") .current_node, returned.committed_node_id ); } #[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 rewind_forks_from_visible_history_without_moving_the_main_branch() { let app = DemoAppState::open_in_memory().expect("valid demo"); let main = app .submit_turn(&promise_request("node_001")) .expect("committed main turn"); let forked = app .fork_branch(&fork_request(&main.committed_node_id, "node_001")) .expect("fork from root"); assert_ne!(forked.branch_id, DEMO_BRANCH_ID); assert_eq!(forked.player_view.branch_id, forked.branch_id); assert_eq!(forked.player_view.node_id, "node_001"); assert_eq!(forked.player_view.history.len(), 1); assert!(forked.player_view.promises.is_empty()); let main_state = app .store .load_state(DEMO_STORY_ID, DEMO_BRANCH_ID) .expect("main branch remains readable"); assert_eq!(main_state.current_node, main.committed_node_id); assert_eq!(main_state.promises.len(), 1); let fork_turn = app .submit_turn(&TurnRequest { story_id: DEMO_STORY_ID.to_owned(), branch_id: forked.branch_id.clone(), expected_node_id: "node_001".to_owned(), action_id: "action_fork_continues".to_owned(), intent: TurnIntent::SpeakOrAct, input: "我先看看站台另一侧。".to_owned(), }) .expect("fork advances independently"); assert_eq!(fork_turn.player_view.branch_id, forked.branch_id); assert_eq!( app.store .load_state(DEMO_STORY_ID, DEMO_BRANCH_ID) .expect("main head") .current_node, main.committed_node_id ); } #[test] fn reopen_then_rewind_and_advance_preserves_both_branches() { let database = TemporaryDatabase::new(); let main_node = { let app = DemoAppState::open(database.path()).expect("new file demo"); app.submit_turn(&promise_request("node_001")) .expect("committed main turn") .committed_node_id }; let (fork_branch, fork_node) = { let app = DemoAppState::open(database.path()).expect("reopened before rewind"); let forked = app .fork_branch(&fork_request(&main_node, "node_001")) .expect("durable fork"); let turn = app .submit_turn(&TurnRequest { story_id: DEMO_STORY_ID.to_owned(), branch_id: forked.branch_id.clone(), expected_node_id: "node_001".to_owned(), action_id: "action_after_reopen_fork".to_owned(), intent: TurnIntent::Continue, input: String::new(), }) .expect("advance fork"); (forked.branch_id, turn.committed_node_id) }; let reopened = DemoAppState::open(database.path()).expect("reopened after fork"); let main = reopened .store .load_state(DEMO_STORY_ID, DEMO_BRANCH_ID) .expect("main branch"); let fork = reopened .store .load_state(DEMO_STORY_ID, &fork_branch) .expect("fork branch"); assert_eq!(main.current_node, main_node); assert_eq!(main.promises.len(), 1); assert_eq!(fork.current_node, fork_node); assert_eq!(fork.current_branch, fork_branch); assert!(fork.promises.is_empty()); } #[test] fn branch_list_rename_switch_and_active_reopen_restore_the_selected_route() { let database = TemporaryDatabase::new(); let (main_node, fork_branch) = { let app = DemoAppState::open(database.path()).expect("new file demo"); let main = app .submit_turn(&promise_request("node_001")) .expect("main promise"); let forked = app .fork_branch(&fork_request(&main.committed_node_id, "node_001")) .expect("fork root"); let list = app.branch_list().expect("forked branch list"); assert_eq!(list.branches.len(), 2); assert_eq!(list.active_branch_id, forked.branch_id); assert!(list.branches[1].is_active); let renamed = app .rename_branch(&RenameBranchRequest { story_id: DEMO_STORY_ID.to_owned(), branch_id: forked.branch_id.clone(), name: "先听完雨".to_owned(), }) .expect("renamed branch"); assert_eq!(renamed.branches[1].name, "先听完雨"); let switched = app .switch_branch(&SwitchBranchRequest { story_id: DEMO_STORY_ID.to_owned(), branch_id: DEMO_BRANCH_ID.to_owned(), expected_active_branch_id: forked.branch_id.clone(), }) .expect("switch to main"); assert_eq!(switched.player_view.node_id, main.committed_node_id); (main.committed_node_id, forked.branch_id) }; let reopened = DemoAppState::open(database.path()).expect("reopened selected route"); let view = reopened.current_player_view().expect("active PlayerView"); assert_eq!(view.branch_id, DEMO_BRANCH_ID); assert_eq!(view.node_id, main_node); let list = reopened.branch_list().expect("persisted branch list"); assert_eq!(list.active_branch_id, DEMO_BRANCH_ID); assert_eq!( list.branches .iter() .find(|branch| branch.branch_id == fork_branch) .map(|branch| branch.name.as_str()), Some("先听完雨") ); } #[test] fn full_file_vertical_slice_reopens_rewinds_and_keeps_completed_main_isolated() { let database = TemporaryDatabase::new(); let completed_main_node = { let app = DemoAppState::open(database.path()).expect("new playable demo"); let promise = app .submit_turn(&promise_request("node_001")) .expect("promise"); let clue = app .submit_turn(&continue_request( DEMO_BRANCH_ID, &promise.committed_node_id, "vertical_find_clue", )) .expect("hidden check and item"); let tunnel = app .submit_turn(&continue_request( DEMO_BRANCH_ID, &clue.committed_node_id, "vertical_enter_tunnel", )) .expect("enter tunnel"); app.submit_turn(&continue_request( DEMO_BRANCH_ID, &tunnel.committed_node_id, "vertical_return", )) .expect("fulfilled return") .committed_node_id }; let fork_branch = { let app = DemoAppState::open(database.path()).expect("reopen completed story"); let completed = app.current_player_view().expect("completed main view"); assert_eq!(completed.node_id, completed_main_node); assert!(!completed.can_continue); assert_eq!(completed.promises[0].status, PromiseStatus::Fulfilled); let forked = app .fork_branch(&fork_request(&completed_main_node, "node_001")) .expect("rewind completed route"); let advanced = app .submit_turn(&continue_request( &forked.branch_id, "node_001", "vertical_alternate_route", )) .expect("advance alternate route"); assert!(advanced.player_view.promises.is_empty()); forked.branch_id }; let reopened = DemoAppState::open(database.path()).expect("reopen alternate route"); let active = reopened .current_player_view() .expect("restored alternate view"); assert_eq!(active.branch_id, fork_branch); assert!(active.promises.is_empty()); let main = reopened .store .load_state(DEMO_STORY_ID, DEMO_BRANCH_ID) .expect("completed main remains intact"); assert_eq!(main.current_node, completed_main_node); assert_eq!(main.promises[0].status, PromiseStatus::Fulfilled); assert_eq!(main.checks.len(), 1); let alternate = reopened .store .load_state(DEMO_STORY_ID, &fork_branch) .expect("alternate remains isolated"); assert!(alternate.promises.is_empty()); assert!(alternate.checks.is_empty()); assert_eq!( reopened.branch_list().expect("two branches").branches.len(), 2 ); } #[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") ); } }