feat(app): persist rewind branches and enable LAPP
This commit is contained in:
+314
-16
@@ -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, ¤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<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");
|
||||
|
||||
Reference in New Issue
Block a user