From 7347b2c5aebdd07b2ff111468fd4b593c360c6fa Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 28 Jul 2026 03:40:38 -0400 Subject: [PATCH] feat(app): persist rewind branches and enable LAPP --- src-tauri/src/lib.rs | 330 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 314 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0529dad..1a8f6bd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,18 +1,21 @@ -use std::{fs, path::Path}; +use std::{fs, path::Path, sync::Mutex}; 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, + ActionSuggestion, AppInfo, BeatKind, DOMAIN_SCHEMA_VERSION, DemoPackSummary, ForkBranchRequest, + ForkBranchResult, 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 nana_runtime::{ + AdjudicatingTurnPlanProvider, AdjudicationCatalog, LappAdjudicationModel, OpenLappChatExecutor, + ProviderError, TurnEngine, TurnPlan, TurnPlanProvider, TurnProjector, +}; +use nana_store::{ForkError, SqliteStoryStore, StoreError, StoryStore}; use serde::Serialize; use tauri::Manager; @@ -60,11 +63,97 @@ impl CommandError { 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) -> Self { + if std::env::var("NANA_STORY_PROVIDER") + .is_ok_and(|provider| provider.eq_ignore_ascii_case("demo")) + { + return Self::Demo(DemoPlanProvider); + } + + let Ok(catalog) = AdjudicationCatalog::from_bundle(bundle) else { + return Self::Unavailable; + }; + let Ok(model) = LappAdjudicationModel::from_default_profile(bundle.clone()) else { + return Self::Unavailable; + }; + Self::Lapp(Box::new(AdjudicatingTurnPlanProvider::new(model, catalog))) + } +} + +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 { @@ -103,7 +192,18 @@ impl DemoAppState { Err(error) => return Err(CommandError::storage(&error)), } - Ok(Self { store, bundle }) + let provider = if cfg!(test) { + RuntimePlanProvider::Demo(DemoPlanProvider) + } else { + RuntimePlanProvider::configured(&bundle) + }; + + Ok(Self { + store, + bundle, + operation_lock: Mutex::new(()), + provider: Mutex::new(provider), + }) } fn pack_summary(&self) -> DemoPackSummary { @@ -135,12 +235,70 @@ impl DemoAppState { } fn submit_turn(&self, request: &TurnRequest) -> Result { - let provider = DemoPlanProvider; + let _operation = self + .operation_lock + .lock() + .map_err(|_| CommandError::operation_lock())?; + let mut provider = self + .provider + .lock() + .map_err(|_| CommandError::operation_lock())?; let projector = DemoProjector { app: self }; - let mut engine = TurnEngine::new(&self.store, provider, projector); + 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))?; + 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 project_view(&self, state: &RuntimeState, node: &StoryNode) -> PlayerView { let lineage = self .load_lineage(node) @@ -464,6 +622,37 @@ fn node_id_for_action(request: &TurnRequest) -> String { ) } +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 is_return_promise(input: &str) -> bool { input.contains("天亮前") && input.contains("回来") } @@ -571,6 +760,15 @@ fn submit_turn( 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 @@ -591,7 +789,8 @@ pub fn run() { get_app_info, get_demo_pack_summary, get_demo_player_view, - submit_turn + submit_turn, + fork_branch ]) .run(tauri::generate_context!()) .expect("failed to run nana-story"); @@ -606,9 +805,9 @@ mod tests { }; use nana_domain::{ - PresentationSnapshot, Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment, - RelationshipBand, RelationshipDimension, StateDelta, StateOp, StoryNode, TurnIntent, - TurnRequest, + ForkBranchRequest, PresentationSnapshot, Promise, PromiseStatus, PromiseWeight, + RelationshipAdjustment, RelationshipBand, RelationshipDimension, StateDelta, StateOp, + StoryNode, TurnIntent, TurnRequest, }; use nana_store::StoryStore; @@ -658,6 +857,16 @@ mod tests { } } + 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 node(id: &str, parent_id: Option<&str>, ops: Vec) -> StoryNode { StoryNode { id: id.to_owned(), @@ -741,6 +950,95 @@ mod tests { 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 player_view_does_not_serialize_hidden_item_facts_or_checks() { let app = DemoAppState::open_in_memory().expect("valid demo");