Files
nana-story/src-tauri/src/lib.rs
T
Codex 1f935a3206
verify / verify (push) Has been cancelled
feat(app): complete playable branch sessions
2026-07-28 04:42:40 -04:00

2061 lines
73 KiB
Rust

use std::{fs, path::Path, sync::Mutex};
use nana_domain::{
AcquisitionMode, ActionSuggestion, AppInfo, BeatKind, BranchList, BranchSummary,
CheckDifficulty, CheckRecord, CheckResult, DOMAIN_SCHEMA_VERSION, DemoPackSummary,
ForkBranchRequest, ForkBranchResult, HistoryNodeView, ItemAcquisition, ItemInstance,
ItemPlacement, KnowledgeCertainty, KnowledgeRecord, 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, LappAdjudicationModel, OpenLappChatExecutor,
ProviderError, TurnEngine, TurnPlan, TurnPlanProvider, TurnProjector,
load_default_lapp_profile,
};
use nana_store::{ForkError, SqliteStoryStore, StoreError, StoredBranch, StoryStore};
use openlapp::{connection::ListModelsOptions, 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";
#[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 fork(error: &ForkError) -> Self {
match error {
ForkError::BranchAlreadyExists { .. } => Self::stale_branch(),
ForkError::Store(StoreError::ParentNotFound(_)) => {
Self::invalid_input("selected story node is unavailable")
}
ForkError::InvalidBranchId(_) => Self {
code: "internal".to_owned(),
message: "new story branch could not be prepared".to_owned(),
retryable: true,
issues: Vec::new(),
},
ForkError::Store(error) => Self::storage(error),
}
}
}
struct DemoAppState {
store: SqliteStoryStore,
bundle: ResourceBundle,
operation_lock: Mutex<()>,
provider: Mutex<RuntimePlanProvider>,
}
type LappRuntimeProvider =
AdjudicatingTurnPlanProvider<LappAdjudicationModel<OpenLappChatExecutor>>;
enum RuntimePlanProvider {
Demo(DemoPlanProvider),
Lapp(Box<LappRuntimeProvider>),
Unavailable,
}
impl RuntimePlanProvider {
fn configured(bundle: &ResourceBundle, store: &SqliteStoryStore) -> 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(
&profile,
&provider_id,
&model_id,
bundle.clone(),
);
let Ok(model) = model else {
return Self::Unavailable;
};
Self::Lapp(Box::new(AdjudicatingTurnPlanProvider::new(model, catalog)))
}
}
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 }),
}
}
}
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 provider = if cfg!(test) {
RuntimePlanProvider::Demo(DemoPlanProvider)
} else {
RuntimePlanProvider::configured(&bundle, &store)
};
Ok(Self {
store,
bundle,
operation_lock: Mutex::new(()),
provider: Mutex::new(provider),
})
}
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))
}
fn submit_turn(&self, request: &TurnRequest) -> 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 projector = DemoProjector { app: self };
let mut engine = TurnEngine::new(&self.store, &mut *provider, projector);
engine.submit_turn(request).map_err(CommandError::turn)
}
fn fork_branch(&self, request: &ForkBranchRequest) -> Result<ForkBranchResult, CommandError> {
validate_fork_request(request)?;
let _operation = self
.operation_lock
.lock()
.map_err(|_| CommandError::operation_lock())?;
let current = self
.store
.load_state(&request.story_id, &request.current_branch_id)
.map_err(|error| CommandError::storage(&error))?;
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, &current.current_node)
.map_err(|error| CommandError::storage(&error))?;
let current_lineage = self
.load_lineage(&current_node)
.map_err(|error| CommandError::storage(&error))?;
if !current_lineage
.iter()
.any(|node| node.id == request.source_node_id)
{
return Err(CommandError::invalid_input(
"selected story node is not on the current route",
));
}
let branch_id = branch_id_for_fork(request);
let state = self
.store
.fork_branch(&request.story_id, &request.source_node_id, &branch_id)
.map_err(|error| CommandError::fork(&error))?;
let source = self
.store
.load_node(&request.story_id, &request.source_node_id)
.map_err(|error| CommandError::storage(&error))?;
let lineage = self
.load_lineage(&source)
.map_err(|error| CommandError::storage(&error))?;
let player_view = self.project_view_with_lineage(&state, &source, &lineage);
Ok(ForkBranchResult {
branch_id,
player_view,
})
}
fn 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(
&profile,
&request.provider_id,
&request.model_id,
self.bundle.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 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> {
let mut lineage = vec![current.clone()];
let mut cursor = current.parent_id.clone();
while let Some(ref node_id) = cursor {
if lineage.len() >= 200 {
return Err(StoreError::StateMismatch(
"story lineage exceeds the projection limit",
));
}
let node = self.store.load_node(&current.story_id, node_id)?;
cursor.clone_from(&node.parent_id);
lineage.push(node);
}
lineage.reverse();
Ok(lineage)
}
}
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::ProviderUnavailable => "provider_unavailable",
TurnFailureCode::Cancelled => "cancelled",
TurnFailureCode::TimedOut => "timed_out",
TurnFailureCode::Internal => "internal",
}
}
#[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<'_, 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<'_, 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<'_, 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<'_, 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<'_, 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<'_, 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<'_, DemoAppState>,
request: UpdateLappSettingsRequest,
) -> Result<LappSettings, CommandError> {
state.update_lapp_settings(&request)
}
#[tauri::command]
#[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values.
fn submit_turn(
state: tauri::State<'_, DemoAppState>,
request: TurnRequest,
) -> Result<TurnResult, CommandError> {
state.submit_turn(&request)
}
#[tauri::command]
#[allow(clippy::needless_pass_by_value)] // Tauri deserializes command arguments into owned values.
fn fork_branch(
state: tauri::State<'_, DemoAppState>,
request: ForkBranchRequest,
) -> Result<ForkBranchResult, CommandError> {
state.fork_branch(&request)
}
/// Starts the desktop application and opens its local story database.
///
/// # Panics
///
/// 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 = app.path().app_data_dir()?;
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(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,
submit_turn,
fork_branch
])
.run(tauri::generate_context!())
.expect("failed to run nana-story");
}
#[cfg(test)]
mod tests {
use std::{
fs,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use nana_domain::{
ForkBranchRequest, PresentationSnapshot, Promise, PromiseStatus, PromiseWeight,
RelationshipAdjustment, RelationshipBand, RelationshipDimension, RenameBranchRequest,
StateDelta, StateOp, StoryNode, SwitchBranchRequest, TurnIntent, TurnRequest,
};
use nana_store::StoryStore;
use super::{
DEMO_BRANCH_ID, DEMO_STORY_ID, DemoAppState, history_label, last_player_relationship_update,
};
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");
}
}
}
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")
);
}
}