2636 lines
94 KiB
Rust
2636 lines
94 KiB
Rust
use std::{
|
|
fs,
|
|
path::{Path, PathBuf},
|
|
sync::{
|
|
Arc, Mutex,
|
|
atomic::{AtomicBool, Ordering},
|
|
},
|
|
time::Duration,
|
|
};
|
|
|
|
use nana_domain::{
|
|
AcquisitionMode, ActionSuggestion, AppInfo, BeatKind, BranchList, BranchSummary,
|
|
CheckDifficulty, CheckRecord, CheckResult, DOMAIN_SCHEMA_VERSION, DemoPackSummary,
|
|
ForkBranchRequest, ForkBranchResult, HistoryNodeView, ItemAcquisition, ItemInstance,
|
|
ItemPlacement, KnowledgeCertainty, KnowledgeRecord, LappConnectionTestResult, 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, BranchHistoryProjection,
|
|
LappAdjudicationModel, LappNativeCallGate, LappNativeCallPermit, OpenLappChatExecutor,
|
|
ProviderError, TurnControl, TurnEngine, TurnPlan, TurnPlanProvider, TurnProjector,
|
|
load_default_lapp_profile,
|
|
};
|
|
use nana_store::{ForkError, SqliteStoryStore, StoreError, StoredBranch, StoryStore};
|
|
use openlapp::{
|
|
ModelSelector,
|
|
client::{Client, TestConnectionResult},
|
|
connection::ListModelsOptions,
|
|
credential::{CredentialResolver, DefaultCredentialResolver},
|
|
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";
|
|
const TURN_TIMEOUT: Duration = Duration::from_secs(90);
|
|
const CONNECTION_TEST_TIMEOUT: Duration = Duration::from_secs(35);
|
|
|
|
fn story_data_dir(
|
|
default_path: PathBuf,
|
|
override_path: Option<PathBuf>,
|
|
) -> Result<PathBuf, std::io::Error> {
|
|
let Some(path) = override_path else {
|
|
return Ok(default_path);
|
|
};
|
|
if !path.is_absolute() {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
"NANA_STORY_SMOKE_DATA_DIR must be an absolute path",
|
|
));
|
|
}
|
|
let resolved = fs::canonicalize(path)?;
|
|
let temporary_root = fs::canonicalize(std::env::temp_dir())?;
|
|
if resolved == temporary_root || !resolved.starts_with(&temporary_root) {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
"NANA_STORY_SMOKE_DATA_DIR must be a child of the temporary directory",
|
|
));
|
|
}
|
|
Ok(resolved)
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct CommandError {
|
|
code: String,
|
|
message: String,
|
|
retryable: bool,
|
|
issues: Vec<ValidationIssue>,
|
|
}
|
|
|
|
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<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 turn_in_progress() -> Self {
|
|
Self {
|
|
code: "turn_in_progress".to_owned(),
|
|
message: "已有一轮故事正在生成。".to_owned(),
|
|
retryable: true,
|
|
issues: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn background_task() -> Self {
|
|
Self {
|
|
code: "internal".to_owned(),
|
|
message: "故事回合未能完成。".to_owned(),
|
|
retryable: true,
|
|
issues: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn connection_test_in_progress() -> Self {
|
|
Self {
|
|
code: "connection_test_in_progress".to_owned(),
|
|
message: "已有一项模型连接测试正在进行。".to_owned(),
|
|
retryable: true,
|
|
issues: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn connection_test_timed_out() -> Self {
|
|
Self {
|
|
code: "timed_out".to_owned(),
|
|
message: "模型连接测试等待超时。".to_owned(),
|
|
retryable: true,
|
|
issues: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn connection_background_task() -> Self {
|
|
Self {
|
|
code: "internal".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>,
|
|
in_flight_turn: Arc<Mutex<Option<InFlightTurn>>>,
|
|
connection_test_in_flight: Arc<AtomicBool>,
|
|
lapp_native_call_gate: LappNativeCallGate,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct InFlightTurn {
|
|
action_id: String,
|
|
control: TurnControl,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct InFlightTurnGuard {
|
|
slot: Arc<Mutex<Option<InFlightTurn>>>,
|
|
action_id: String,
|
|
}
|
|
|
|
impl Drop for InFlightTurnGuard {
|
|
fn drop(&mut self) {
|
|
if let Ok(mut slot) = self.slot.lock()
|
|
&& slot
|
|
.as_ref()
|
|
.is_some_and(|turn| turn.action_id == self.action_id)
|
|
{
|
|
*slot = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ConnectionTestGuard {
|
|
in_flight: Arc<AtomicBool>,
|
|
}
|
|
|
|
impl Drop for ConnectionTestGuard {
|
|
fn drop(&mut self) {
|
|
self.in_flight.store(false, Ordering::Release);
|
|
}
|
|
}
|
|
|
|
struct AppliedLappTarget {
|
|
profile: openlapp::Profile,
|
|
provider_id: String,
|
|
model_id: String,
|
|
}
|
|
|
|
type LappRuntimeProvider =
|
|
AdjudicatingTurnPlanProvider<LappAdjudicationModel<OpenLappChatExecutor>>;
|
|
|
|
enum RuntimePlanProvider {
|
|
Demo(DemoPlanProvider),
|
|
Lapp(Box<LappRuntimeProvider>),
|
|
Unavailable,
|
|
#[cfg(test)]
|
|
RetiredTest,
|
|
}
|
|
|
|
impl RuntimePlanProvider {
|
|
#[cfg(test)]
|
|
fn configured(
|
|
_bundle: &ResourceBundle,
|
|
_store: &SqliteStoryStore,
|
|
_native_call_gate: LappNativeCallGate,
|
|
) -> Self {
|
|
Self::Demo(DemoPlanProvider)
|
|
}
|
|
|
|
#[cfg(not(test))]
|
|
fn configured(
|
|
bundle: &ResourceBundle,
|
|
store: &SqliteStoryStore,
|
|
native_call_gate: LappNativeCallGate,
|
|
) -> 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_with_gate(
|
|
&profile,
|
|
&provider_id,
|
|
&model_id,
|
|
bundle.clone(),
|
|
native_call_gate,
|
|
);
|
|
let Ok(model) = model else {
|
|
return Self::Unavailable;
|
|
};
|
|
Self::Lapp(Box::new(AdjudicatingTurnPlanProvider::new(model, catalog)))
|
|
}
|
|
|
|
fn is_retired(&self) -> bool {
|
|
match self {
|
|
Self::Lapp(provider) => provider.model().executor().is_retired(),
|
|
#[cfg(test)]
|
|
Self::RetiredTest => true,
|
|
Self::Demo(_) | Self::Unavailable => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
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<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 }),
|
|
#[cfg(test)]
|
|
Self::RetiredTest => Err(ProviderError::Cancelled),
|
|
}
|
|
}
|
|
|
|
fn plan_turn_with_control(
|
|
&mut self,
|
|
request: &TurnRequest,
|
|
state: &RuntimeState,
|
|
control: &TurnControl,
|
|
) -> Result<TurnPlan, ProviderError> {
|
|
match self {
|
|
Self::Demo(provider) => provider.plan_turn_with_control(request, state, control),
|
|
Self::Lapp(provider) => provider.plan_turn_with_control(request, state, control),
|
|
Self::Unavailable => Err(ProviderError::Configuration { code: None }),
|
|
#[cfg(test)]
|
|
Self::RetiredTest => Err(ProviderError::Cancelled),
|
|
}
|
|
}
|
|
|
|
fn plan_turn_with_history(
|
|
&mut self,
|
|
request: &TurnRequest,
|
|
state: &RuntimeState,
|
|
branch_history: &BranchHistoryProjection,
|
|
) -> Result<TurnPlan, ProviderError> {
|
|
match self {
|
|
Self::Demo(provider) => provider.plan_turn_with_history(request, state, branch_history),
|
|
Self::Lapp(provider) => provider.plan_turn_with_history(request, state, branch_history),
|
|
Self::Unavailable => Err(ProviderError::Configuration { code: None }),
|
|
#[cfg(test)]
|
|
Self::RetiredTest => Err(ProviderError::Cancelled),
|
|
}
|
|
}
|
|
|
|
fn plan_turn_with_history_and_control(
|
|
&mut self,
|
|
request: &TurnRequest,
|
|
state: &RuntimeState,
|
|
branch_history: &BranchHistoryProjection,
|
|
control: &TurnControl,
|
|
) -> Result<TurnPlan, ProviderError> {
|
|
match self {
|
|
Self::Demo(provider) => {
|
|
provider.plan_turn_with_history_and_control(request, state, branch_history, control)
|
|
}
|
|
Self::Lapp(provider) => {
|
|
provider.plan_turn_with_history_and_control(request, state, branch_history, control)
|
|
}
|
|
Self::Unavailable => Err(ProviderError::Configuration { code: None }),
|
|
#[cfg(test)]
|
|
Self::RetiredTest => Err(ProviderError::Cancelled),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl DemoAppState {
|
|
fn open(path: impl AsRef<Path>) -> Result<Self, CommandError> {
|
|
let store = SqliteStoryStore::open(path).map_err(|error| CommandError::storage(&error))?;
|
|
Self::from_store(store)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn open_in_memory() -> Result<Self, CommandError> {
|
|
let store =
|
|
SqliteStoryStore::open_in_memory().map_err(|error| CommandError::storage(&error))?;
|
|
Self::from_store(store)
|
|
}
|
|
|
|
fn from_store(store: SqliteStoryStore) -> Result<Self, CommandError> {
|
|
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 lapp_native_call_gate = LappNativeCallGate::new();
|
|
let provider = if cfg!(test) {
|
|
RuntimePlanProvider::Demo(DemoPlanProvider)
|
|
} else {
|
|
RuntimePlanProvider::configured(&bundle, &store, lapp_native_call_gate.clone())
|
|
};
|
|
|
|
Ok(Self {
|
|
store,
|
|
bundle,
|
|
operation_lock: Mutex::new(()),
|
|
provider: Mutex::new(provider),
|
|
in_flight_turn: Arc::new(Mutex::new(None)),
|
|
connection_test_in_flight: Arc::new(AtomicBool::new(false)),
|
|
lapp_native_call_gate,
|
|
})
|
|
}
|
|
|
|
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<PlayerView, CommandError> {
|
|
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))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn submit_turn(&self, request: &TurnRequest) -> Result<TurnResult, CommandError> {
|
|
let control = TurnControl::with_timeout(TURN_TIMEOUT);
|
|
let _in_flight = self.register_turn(&request.action_id, &control)?;
|
|
self.submit_turn_with_control(request, &control)
|
|
}
|
|
|
|
fn submit_turn_with_control(
|
|
&self,
|
|
request: &TurnRequest,
|
|
control: &TurnControl,
|
|
) -> Result<TurnResult, CommandError> {
|
|
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 result = {
|
|
let projector = DemoProjector { app: self };
|
|
let mut engine = TurnEngine::new(&self.store, &mut *provider, projector);
|
|
engine.submit_turn_with_control(request, control)
|
|
};
|
|
if provider.is_retired() {
|
|
// An interrupted LAPP request may leave one isolated native Vault
|
|
// call running. Replace the retired executor before the UI can
|
|
// offer its single safe retry.
|
|
*provider = RuntimePlanProvider::configured(
|
|
&self.bundle,
|
|
&self.store,
|
|
self.lapp_native_call_gate.clone(),
|
|
);
|
|
}
|
|
result.map_err(CommandError::turn)
|
|
}
|
|
|
|
fn register_turn(
|
|
&self,
|
|
action_id: &str,
|
|
control: &TurnControl,
|
|
) -> Result<InFlightTurnGuard, CommandError> {
|
|
let mut slot = self
|
|
.in_flight_turn
|
|
.lock()
|
|
.map_err(|_| CommandError::operation_lock())?;
|
|
if slot.is_some() {
|
|
return Err(CommandError::turn_in_progress());
|
|
}
|
|
*slot = Some(InFlightTurn {
|
|
action_id: action_id.to_owned(),
|
|
control: control.clone(),
|
|
});
|
|
Ok(InFlightTurnGuard {
|
|
slot: Arc::clone(&self.in_flight_turn),
|
|
action_id: action_id.to_owned(),
|
|
})
|
|
}
|
|
|
|
fn cancel_turn(&self, action_id: &str) -> Result<bool, CommandError> {
|
|
let slot = self
|
|
.in_flight_turn
|
|
.lock()
|
|
.map_err(|_| CommandError::operation_lock())?;
|
|
Ok(slot
|
|
.as_ref()
|
|
.filter(|turn| turn.action_id == action_id)
|
|
.is_some_and(|turn| turn.control.cancel()))
|
|
}
|
|
|
|
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))?;
|
|
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<BranchList, CommandError> {
|
|
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::<Result<Vec<_>, _>>()?;
|
|
Ok(BranchList {
|
|
story_id: DEMO_STORY_ID.to_owned(),
|
|
active_branch_id,
|
|
branches,
|
|
})
|
|
}
|
|
|
|
fn branch_summary(
|
|
&self,
|
|
branch: StoredBranch,
|
|
active_branch_id: &str,
|
|
) -> Result<BranchSummary, CommandError> {
|
|
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<SwitchBranchResult, CommandError> {
|
|
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<BranchList, CommandError> {
|
|
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<LappSettings, CommandError> {
|
|
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_with_gate(
|
|
&profile,
|
|
&request.provider_id,
|
|
&request.model_id,
|
|
self.bundle.clone(),
|
|
self.lapp_native_call_gate.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 applied_lapp_target(&self) -> Result<AppliedLappTarget, CommandError> {
|
|
if demo_provider_requested() {
|
|
return Err(CommandError::invalid_input(
|
|
"确定性演示模式不连接外部模型。",
|
|
));
|
|
}
|
|
let _operation = self
|
|
.operation_lock
|
|
.try_lock()
|
|
.map_err(|_| CommandError::connection_test_in_progress())?;
|
|
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 (provider_id, model_id) = selected_lapp_model(
|
|
&profile,
|
|
self.store
|
|
.get_app_setting(LAPP_PROVIDER_SETTING)
|
|
.map_err(|error| CommandError::storage(&error))?,
|
|
self.store
|
|
.get_app_setting(LAPP_MODEL_SETTING)
|
|
.map_err(|error| CommandError::storage(&error))?,
|
|
);
|
|
let Some((provider_id, model_id)) = provider_id.zip(model_id) else {
|
|
return Err(CommandError::invalid_input(
|
|
"请先应用一个支持聊天与工具调用的模型。",
|
|
));
|
|
};
|
|
if !lapp_model_options(&profile)
|
|
.iter()
|
|
.any(|model| model.provider_id == provider_id && model.model_id == model_id)
|
|
{
|
|
return Err(CommandError::invalid_input(
|
|
"当前应用的模型不可用,或未声明聊天与工具调用能力。",
|
|
));
|
|
}
|
|
Ok(AppliedLappTarget {
|
|
profile,
|
|
provider_id,
|
|
model_id,
|
|
})
|
|
}
|
|
|
|
fn register_connection_test(&self) -> Result<ConnectionTestGuard, CommandError> {
|
|
self.connection_test_in_flight
|
|
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
|
.map_err(|_| CommandError::connection_test_in_progress())?;
|
|
Ok(ConnectionTestGuard {
|
|
in_flight: Arc::clone(&self.connection_test_in_flight),
|
|
})
|
|
}
|
|
|
|
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<Vec<StoryNode>, StoreError> {
|
|
self.store
|
|
.load_ancestor_chain(¤t.story_id, ¤t.id)
|
|
}
|
|
}
|
|
|
|
struct DemoPlanProvider;
|
|
|
|
impl TurnPlanProvider for DemoPlanProvider {
|
|
fn plan_turn(
|
|
&mut self,
|
|
request: &TurnRequest,
|
|
state: &RuntimeState,
|
|
) -> Result<TurnPlan, ProviderError> {
|
|
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<PresentationBeat>, 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<PresentationBeat>, 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<PresentationBeat>, 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<PresentationBeat>, 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<PresentationBeat>, 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<PresentationBeat>, 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<PresentationBeat>,
|
|
suggestions: Vec<ActionSuggestion>,
|
|
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<ActionSuggestion> {
|
|
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<ActionSuggestion> {
|
|
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<ActionSuggestion> {
|
|
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<ActionSuggestion> {
|
|
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<LappModelOption> {
|
|
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::<Vec<_>>();
|
|
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<String>,
|
|
saved_model: Option<String>,
|
|
) -> (Option<String>, Option<String>) {
|
|
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::<String>();
|
|
format!("回应:{summary}")
|
|
}
|
|
|
|
fn history_for(lineage: &[StoryNode], current: &StoryNode) -> Vec<HistoryNodeView> {
|
|
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::ProviderConfiguration => "provider_configuration",
|
|
TurnFailureCode::ProviderCredentials => "provider_credentials",
|
|
TurnFailureCode::ProviderRateLimited => "provider_rate_limited",
|
|
TurnFailureCode::ProviderRejected => "provider_rejected",
|
|
TurnFailureCode::ProviderUnavailable => "provider_unavailable",
|
|
TurnFailureCode::Cancelled => "cancelled",
|
|
TurnFailureCode::TimedOut => "timed_out",
|
|
TurnFailureCode::Internal => "internal",
|
|
}
|
|
}
|
|
|
|
fn project_connection_test(result: TestConnectionResult) -> LappConnectionTestResult {
|
|
let message = if result.ok {
|
|
"连接成功,模型能够响应最小请求。".to_owned()
|
|
} else {
|
|
"连接失败,请检查 LAPP 配置与网络。".to_owned()
|
|
};
|
|
LappConnectionTestResult {
|
|
ok: result.ok,
|
|
provider_id: result.provider_id,
|
|
model_id: result.model_id,
|
|
message,
|
|
diagnostic_code: result.code.map(|code| code.to_string()),
|
|
}
|
|
}
|
|
|
|
fn run_lapp_connection_test(
|
|
target: AppliedLappTarget,
|
|
native_call_permit: LappNativeCallPermit,
|
|
) -> Result<LappConnectionTestResult, CommandError> {
|
|
let _native_call_permit = native_call_permit;
|
|
let selector = ModelSelector::Explicit {
|
|
provider_id: target.provider_id,
|
|
model: target.model_id,
|
|
};
|
|
let resolver: Arc<dyn CredentialResolver> = Arc::new(DefaultCredentialResolver::system());
|
|
let client = Client::new(&target.profile, &selector, resolver).map_err(|_| CommandError {
|
|
code: "provider_configuration".to_owned(),
|
|
message: "当前应用的 LAPP 模型无法初始化。".to_owned(),
|
|
retryable: false,
|
|
issues: Vec::new(),
|
|
})?;
|
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.map_err(|_| CommandError::connection_background_task())?;
|
|
Ok(project_connection_test(
|
|
runtime.block_on(client.test_connection()),
|
|
))
|
|
}
|
|
|
|
#[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<'_, Arc<DemoAppState>>,
|
|
) -> Result<PlayerView, CommandError> {
|
|
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<'_, Arc<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<'_, Arc<DemoAppState>>) -> Result<BranchList, CommandError> {
|
|
state.branch_list()
|
|
}
|
|
|
|
#[tauri::command]
|
|
#[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values.
|
|
fn switch_branch(
|
|
state: tauri::State<'_, Arc<DemoAppState>>,
|
|
request: SwitchBranchRequest,
|
|
) -> Result<SwitchBranchResult, CommandError> {
|
|
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<'_, Arc<DemoAppState>>,
|
|
request: RenameBranchRequest,
|
|
) -> Result<BranchList, CommandError> {
|
|
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<'_, Arc<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<'_, Arc<DemoAppState>>,
|
|
request: UpdateLappSettingsRequest,
|
|
) -> Result<LappSettings, CommandError> {
|
|
state.update_lapp_settings(&request)
|
|
}
|
|
|
|
#[tauri::command]
|
|
#[allow(clippy::needless_pass_by_value)] // Tauri extracts managed state through this owned wrapper.
|
|
async fn test_lapp_connection(
|
|
state: tauri::State<'_, Arc<DemoAppState>>,
|
|
) -> Result<LappConnectionTestResult, CommandError> {
|
|
let state = Arc::clone(state.inner());
|
|
let target = state.applied_lapp_target()?;
|
|
let in_flight = state.register_connection_test()?;
|
|
let native_call_permit = state
|
|
.lapp_native_call_gate
|
|
.try_acquire()
|
|
.ok_or_else(CommandError::connection_test_in_progress)?;
|
|
let (sender, receiver) = tokio::sync::oneshot::channel();
|
|
std::thread::Builder::new()
|
|
.name("nana-lapp-connection-test".into())
|
|
.spawn(move || {
|
|
let _in_flight = in_flight;
|
|
let _ = sender.send(run_lapp_connection_test(target, native_call_permit));
|
|
})
|
|
.map_err(|_| CommandError::connection_background_task())?;
|
|
|
|
match tokio::time::timeout(CONNECTION_TEST_TIMEOUT, receiver).await {
|
|
Ok(Ok(result)) => result,
|
|
Ok(Err(_)) => Err(CommandError::connection_background_task()),
|
|
Err(_) => Err(CommandError::connection_test_timed_out()),
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
#[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values.
|
|
async fn submit_turn(
|
|
state: tauri::State<'_, Arc<DemoAppState>>,
|
|
request: TurnRequest,
|
|
) -> Result<TurnResult, CommandError> {
|
|
let state = Arc::clone(state.inner());
|
|
let control = TurnControl::with_timeout(TURN_TIMEOUT);
|
|
let in_flight = state.register_turn(&request.action_id, &control)?;
|
|
tauri::async_runtime::spawn_blocking(move || {
|
|
let _in_flight = in_flight;
|
|
state.submit_turn_with_control(&request, &control)
|
|
})
|
|
.await
|
|
.map_err(|_| CommandError::background_task())?
|
|
}
|
|
|
|
#[tauri::command]
|
|
#[allow(clippy::needless_pass_by_value)] // Tauri extracts managed state and owned command data.
|
|
fn cancel_turn(
|
|
state: tauri::State<'_, Arc<DemoAppState>>,
|
|
action_id: String,
|
|
) -> Result<bool, CommandError> {
|
|
state.cancel_turn(&action_id)
|
|
}
|
|
|
|
#[tauri::command]
|
|
#[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values.
|
|
fn fork_branch(
|
|
state: tauri::State<'_, Arc<DemoAppState>>,
|
|
request: ForkBranchRequest,
|
|
) -> Result<ForkBranchResult, CommandError> {
|
|
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 = story_data_dir(
|
|
app.path().app_data_dir()?,
|
|
std::env::var_os("NANA_STORY_SMOKE_DATA_DIR").map(PathBuf::from),
|
|
)?;
|
|
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(Arc::new(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,
|
|
test_lapp_connection,
|
|
submit_turn,
|
|
cancel_turn,
|
|
fork_branch
|
|
])
|
|
.run(tauri::generate_context!())
|
|
.expect("failed to run nana-story");
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::{
|
|
fs,
|
|
path::{Path, PathBuf},
|
|
sync::Arc,
|
|
thread,
|
|
time::{SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
use nana_domain::{
|
|
ForkBranchRequest, PresentationSnapshot, Promise, PromiseStatus, PromiseWeight,
|
|
RelationshipAdjustment, RelationshipBand, RelationshipDimension, RenameBranchRequest,
|
|
StateDelta, StateOp, StoryNode, SwitchBranchRequest, TurnIntent, TurnRequest,
|
|
};
|
|
use nana_runtime::TurnControl;
|
|
use nana_store::StoryStore;
|
|
use openlapp::{ErrorCode, client::TestConnectionResult};
|
|
|
|
use super::{
|
|
DEMO_BRANCH_ID, DEMO_STORY_ID, DemoAppState, RuntimePlanProvider, history_label,
|
|
last_player_relationship_update, project_connection_test, story_data_dir,
|
|
};
|
|
|
|
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");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn lapp_connection_result_keeps_only_safe_diagnostics() {
|
|
let projected = project_connection_test(TestConnectionResult {
|
|
ok: false,
|
|
provider_id: "provider.safe".to_owned(),
|
|
model_id: "model.safe".to_owned(),
|
|
protocol: "openai-responses".to_owned(),
|
|
code: Some(ErrorCode::HttpStatus),
|
|
message: Some("api_key=must-not-cross-app-boundary".to_owned()),
|
|
});
|
|
|
|
assert!(!projected.ok);
|
|
assert_eq!(projected.provider_id, "provider.safe");
|
|
assert_eq!(projected.model_id, "model.safe");
|
|
assert_eq!(projected.diagnostic_code.as_deref(), Some("HTTP_STATUS"));
|
|
assert_eq!(projected.message, "连接失败,请检查 LAPP 配置与网络。");
|
|
assert!(!projected.message.contains("must-not-cross"));
|
|
}
|
|
|
|
#[test]
|
|
fn smoke_data_override_requires_an_existing_absolute_temporary_child() {
|
|
let default = PathBuf::from("default-data");
|
|
assert_eq!(
|
|
story_data_dir(default.clone(), None).expect("default path"),
|
|
default
|
|
);
|
|
assert!(story_data_dir(PathBuf::from("default"), Some(std::env::temp_dir())).is_err());
|
|
|
|
let nonce = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_nanos();
|
|
let smoke = std::env::temp_dir().join(format!(
|
|
"nana-story-smoke-dir-{}-{nonce}",
|
|
std::process::id()
|
|
));
|
|
fs::create_dir_all(&smoke).expect("create smoke directory");
|
|
|
|
let resolved = story_data_dir(PathBuf::from("unused"), Some(smoke.clone()))
|
|
.expect("temporary override");
|
|
assert_eq!(resolved, fs::canonicalize(&smoke).expect("canonical smoke"));
|
|
assert!(story_data_dir(PathBuf::from("default"), Some(PathBuf::from("relative"))).is_err());
|
|
|
|
fs::remove_dir(&smoke).expect("remove smoke directory");
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn smoke_data_override_rejects_a_symlink_escape() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let nonce = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_nanos();
|
|
let outside = std::env::current_dir()
|
|
.expect("current directory")
|
|
.join(format!("nana-story-smoke-outside-{nonce}"));
|
|
fs::create_dir_all(&outside).expect("create outside target");
|
|
let temporary_root = fs::canonicalize(std::env::temp_dir()).expect("temporary root");
|
|
let outside_resolved = fs::canonicalize(&outside).expect("outside target");
|
|
if !outside_resolved.starts_with(&temporary_root) {
|
|
let link = std::env::temp_dir().join(format!(
|
|
"nana-story-smoke-link-{}-{nonce}",
|
|
std::process::id()
|
|
));
|
|
symlink(&outside, &link).expect("create escape symlink");
|
|
assert!(story_data_dir(PathBuf::from("default"), Some(link.clone())).is_err());
|
|
fs::remove_file(link).expect("remove escape symlink");
|
|
}
|
|
fs::remove_dir(outside).expect("remove outside target");
|
|
}
|
|
|
|
#[test]
|
|
fn in_flight_turn_registry_accepts_cancel_and_rejects_overlap() {
|
|
let app = DemoAppState::open_in_memory().expect("app");
|
|
let control = TurnControl::new();
|
|
let guard = app
|
|
.register_turn("action_in_flight", &control)
|
|
.expect("register turn");
|
|
|
|
let overlapping = app
|
|
.register_turn("action_overlap", &TurnControl::new())
|
|
.expect_err("only one turn may run");
|
|
assert_eq!(overlapping.code, "turn_in_progress");
|
|
assert!(app.cancel_turn("action_in_flight").expect("cancel"));
|
|
assert!(control.is_cancelled());
|
|
assert!(!app.cancel_turn("action_other").expect("wrong action"));
|
|
|
|
drop(guard);
|
|
assert!(
|
|
!app.cancel_turn("action_in_flight")
|
|
.expect("cleared registry")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn connection_test_registry_allows_only_one_worker() {
|
|
let app = DemoAppState::open_in_memory().expect("app");
|
|
let guard = app
|
|
.register_connection_test()
|
|
.expect("first connection test");
|
|
let overlapping = app
|
|
.register_connection_test()
|
|
.expect_err("connection tests must not overlap");
|
|
assert_eq!(overlapping.code, "connection_test_in_progress");
|
|
|
|
drop(guard);
|
|
app.register_connection_test()
|
|
.expect("connection slot should be released");
|
|
}
|
|
|
|
#[test]
|
|
fn cancellation_before_worker_start_does_not_move_the_branch_head() {
|
|
let app = Arc::new(DemoAppState::open_in_memory().expect("app"));
|
|
let before = app.current_player_view().expect("initial view");
|
|
let control = TurnControl::new();
|
|
let request = promise_request(&before.node_id);
|
|
let in_flight = app
|
|
.register_turn(&request.action_id, &control)
|
|
.expect("register before scheduling");
|
|
assert!(app.cancel_turn(&request.action_id).expect("cancel pending"));
|
|
|
|
let worker_app = Arc::clone(&app);
|
|
let failure = thread::spawn(move || {
|
|
let _in_flight = in_flight;
|
|
worker_app.submit_turn_with_control(&request, &control)
|
|
})
|
|
.join()
|
|
.expect("worker")
|
|
.expect_err("cancelled turn");
|
|
|
|
assert_eq!(failure.code, "cancelled");
|
|
assert!(failure.retryable);
|
|
assert_eq!(
|
|
app.current_player_view().expect("unchanged view").node_id,
|
|
before.node_id
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn retired_lapp_provider_is_rebuilt_before_same_action_retry() {
|
|
let app = DemoAppState::open_in_memory().expect("app");
|
|
assert!(!RuntimePlanProvider::Unavailable.is_retired());
|
|
let before = app.current_player_view().expect("initial view");
|
|
let request = promise_request(&before.node_id);
|
|
*app.provider.lock().expect("provider") = RuntimePlanProvider::RetiredTest;
|
|
|
|
let failure = app
|
|
.submit_turn(&request)
|
|
.expect_err("retired provider should surface its interruption");
|
|
assert_eq!(failure.code, "cancelled");
|
|
assert_eq!(
|
|
app.current_player_view().expect("unchanged view").node_id,
|
|
before.node_id
|
|
);
|
|
|
|
let retried = app
|
|
.submit_turn(&request)
|
|
.expect("same action should reach rebuilt provider");
|
|
assert_ne!(retried.committed_node_id, before.node_id);
|
|
assert_eq!(retried.player_view.node_id, retried.committed_node_id);
|
|
}
|
|
|
|
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<StateOp>) -> 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")
|
|
);
|
|
}
|
|
}
|