805 lines
28 KiB
Rust
805 lines
28 KiB
Rust
use std::{fs, path::Path};
|
|
|
|
use nana_domain::{
|
|
ActionSuggestion, AppInfo, BeatKind, DOMAIN_SCHEMA_VERSION, DemoPackSummary, HistoryNodeView,
|
|
PlayerView, PresentationBeat, PresentationCharacter, PresentationScene, PresentationSnapshot,
|
|
Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment, RelationshipDimension,
|
|
ResourceBundle, RuntimeState, StateDelta, StateOp, StoryNode, TurnFailure, TurnFailureCode,
|
|
TurnIntent, TurnRequest, TurnResult, ValidationIssue, VisualDirective, stable_json_hash,
|
|
validate_bundle,
|
|
};
|
|
use nana_engine::{
|
|
SceneMetadata, StoryNodePlayerViewProjectionContext, project_story_node_player_view,
|
|
};
|
|
use nana_runtime::{ProviderError, TurnEngine, TurnPlan, TurnPlanProvider, TurnProjector};
|
|
use nana_store::{SqliteStoryStore, StoreError, StoryStore};
|
|
use 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";
|
|
|
|
#[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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
struct DemoAppState {
|
|
store: SqliteStoryStore,
|
|
bundle: ResourceBundle,
|
|
}
|
|
|
|
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)),
|
|
}
|
|
|
|
Ok(Self { store, bundle })
|
|
}
|
|
|
|
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 state = self
|
|
.store
|
|
.load_state(DEMO_STORY_ID, DEMO_BRANCH_ID)
|
|
.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 provider = DemoPlanProvider;
|
|
let projector = DemoProjector { app: self };
|
|
let mut engine = TurnEngine::new(&self.store, provider, projector);
|
|
engine.submit_turn(request).map_err(CommandError::turn)
|
|
}
|
|
|
|
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(¤t.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) = if accepted_promise {
|
|
promise_turn(request, &committed_node_id)
|
|
} else if request.intent == TurnIntent::Continue {
|
|
continue_turn(request, state)
|
|
} else {
|
|
regular_turn(request)
|
|
};
|
|
|
|
Ok(TurnPlan {
|
|
committed_node_id,
|
|
presentation: demo_presentation(beats),
|
|
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::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 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>) -> 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: default_suggestions(),
|
|
can_continue: true,
|
|
}
|
|
}
|
|
|
|
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 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 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.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 deserializes command arguments into owned values.
|
|
fn submit_turn(
|
|
state: tauri::State<'_, DemoAppState>,
|
|
request: TurnRequest,
|
|
) -> Result<TurnResult, CommandError> {
|
|
state.submit_turn(&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,
|
|
submit_turn
|
|
])
|
|
.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::{
|
|
PresentationSnapshot, Promise, PromiseStatus, PromiseWeight, RelationshipAdjustment,
|
|
RelationshipBand, RelationshipDimension, StateDelta, StateOp, StoryNode, 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 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 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 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")
|
|
);
|
|
}
|
|
}
|