feat(app): persist rewind branches and enable LAPP

This commit is contained in:
Codex
2026-07-28 03:40:38 -04:00
parent 19297dc62d
commit 7347b2c5ae
+314 -16
View File
@@ -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<String>) -> 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<RuntimePlanProvider>,
}
type LappRuntimeProvider =
AdjudicatingTurnPlanProvider<LappAdjudicationModel<OpenLappChatExecutor>>;
enum RuntimePlanProvider {
Demo(DemoPlanProvider),
Lapp(Box<LappRuntimeProvider>),
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<TurnPlan, ProviderError> {
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<TurnResult, CommandError> {
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<ForkBranchResult, CommandError> {
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, &current.current_node)
.map_err(|error| CommandError::storage(&error))?;
let current_lineage = self
.load_lineage(&current_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<ForkBranchResult, CommandError> {
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<StateOp>) -> 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");