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::{
|
use nana_domain::{
|
||||||
ActionSuggestion, AppInfo, BeatKind, DOMAIN_SCHEMA_VERSION, DemoPackSummary, HistoryNodeView,
|
ActionSuggestion, AppInfo, BeatKind, DOMAIN_SCHEMA_VERSION, DemoPackSummary, ForkBranchRequest,
|
||||||
PlayerView, PresentationBeat, PresentationCharacter, PresentationScene, PresentationSnapshot,
|
ForkBranchResult, HistoryNodeView, PlayerView, PresentationBeat, PresentationCharacter,
|
||||||
Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment, RelationshipDimension,
|
PresentationScene, PresentationSnapshot, Promise, PromiseStatus, PromiseWeight,
|
||||||
ResourceBundle, RuntimeState, StateDelta, StateOp, StoryNode, TurnFailure, TurnFailureCode,
|
RelationshipAdjustment, RelationshipDimension, ResourceBundle, RuntimeState, StateDelta,
|
||||||
TurnIntent, TurnRequest, TurnResult, ValidationIssue, VisualDirective, stable_json_hash,
|
StateOp, StoryNode, TurnFailure, TurnFailureCode, TurnIntent, TurnRequest, TurnResult,
|
||||||
validate_bundle,
|
ValidationIssue, VisualDirective, stable_json_hash, validate_bundle,
|
||||||
};
|
};
|
||||||
use nana_engine::{
|
use nana_engine::{
|
||||||
SceneMetadata, StoryNodePlayerViewProjectionContext, project_story_node_player_view,
|
SceneMetadata, StoryNodePlayerViewProjectionContext, project_story_node_player_view,
|
||||||
};
|
};
|
||||||
use nana_runtime::{ProviderError, TurnEngine, TurnPlan, TurnPlanProvider, TurnProjector};
|
use nana_runtime::{
|
||||||
use nana_store::{SqliteStoryStore, StoreError, StoryStore};
|
AdjudicatingTurnPlanProvider, AdjudicationCatalog, LappAdjudicationModel, OpenLappChatExecutor,
|
||||||
|
ProviderError, TurnEngine, TurnPlan, TurnPlanProvider, TurnProjector,
|
||||||
|
};
|
||||||
|
use nana_store::{ForkError, SqliteStoryStore, StoreError, StoryStore};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
@@ -60,11 +63,97 @@ impl CommandError {
|
|||||||
issues: Vec::new(),
|
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 {
|
struct DemoAppState {
|
||||||
store: SqliteStoryStore,
|
store: SqliteStoryStore,
|
||||||
bundle: ResourceBundle,
|
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 {
|
impl DemoAppState {
|
||||||
@@ -103,7 +192,18 @@ impl DemoAppState {
|
|||||||
Err(error) => return Err(CommandError::storage(&error)),
|
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 {
|
fn pack_summary(&self) -> DemoPackSummary {
|
||||||
@@ -135,12 +235,70 @@ impl DemoAppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn submit_turn(&self, request: &TurnRequest) -> Result<TurnResult, CommandError> {
|
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 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)
|
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 {
|
fn project_view(&self, state: &RuntimeState, node: &StoryNode) -> PlayerView {
|
||||||
let lineage = self
|
let lineage = self
|
||||||
.load_lineage(node)
|
.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 {
|
fn is_return_promise(input: &str) -> bool {
|
||||||
input.contains("天亮前") && input.contains("回来")
|
input.contains("天亮前") && input.contains("回来")
|
||||||
}
|
}
|
||||||
@@ -571,6 +760,15 @@ fn submit_turn(
|
|||||||
state.submit_turn(&request)
|
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.
|
/// Starts the desktop application and opens its local story database.
|
||||||
///
|
///
|
||||||
/// # Panics
|
/// # Panics
|
||||||
@@ -591,7 +789,8 @@ pub fn run() {
|
|||||||
get_app_info,
|
get_app_info,
|
||||||
get_demo_pack_summary,
|
get_demo_pack_summary,
|
||||||
get_demo_player_view,
|
get_demo_player_view,
|
||||||
submit_turn
|
submit_turn,
|
||||||
|
fork_branch
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("failed to run nana-story");
|
.expect("failed to run nana-story");
|
||||||
@@ -606,9 +805,9 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
use nana_domain::{
|
use nana_domain::{
|
||||||
PresentationSnapshot, Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment,
|
ForkBranchRequest, PresentationSnapshot, Promise, PromiseStatus, PromiseWeight,
|
||||||
RelationshipBand, RelationshipDimension, StateDelta, StateOp, StoryNode, TurnIntent,
|
RelationshipAdjustment, RelationshipBand, RelationshipDimension, StateDelta, StateOp,
|
||||||
TurnRequest,
|
StoryNode, TurnIntent, TurnRequest,
|
||||||
};
|
};
|
||||||
use nana_store::StoryStore;
|
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 {
|
fn node(id: &str, parent_id: Option<&str>, ops: Vec<StateOp>) -> StoryNode {
|
||||||
StoryNode {
|
StoryNode {
|
||||||
id: id.to_owned(),
|
id: id.to_owned(),
|
||||||
@@ -741,6 +950,95 @@ mod tests {
|
|||||||
assert_eq!(restored.character_pose, committed.character_pose);
|
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]
|
#[test]
|
||||||
fn player_view_does_not_serialize_hidden_item_facts_or_checks() {
|
fn player_view_does_not_serialize_hidden_item_facts_or_checks() {
|
||||||
let app = DemoAppState::open_in_memory().expect("valid demo");
|
let app = DemoAppState::open_in_memory().expect("valid demo");
|
||||||
|
|||||||
Reference in New Issue
Block a user