use std::collections::{BTreeSet, VecDeque}; use nana_domain::{ PlayerView, PresentationSnapshot, RuntimeState, StateDelta, StoryNode, TurnFailure, TurnFailureCode, TurnIntent, TurnRequest, TurnResult, WorldBookEntry, }; use nana_engine::{ReduceError, apply_delta}; use nana_store::{StoreError, StoryStore}; use thiserror::Error; mod adjudication; mod context; mod lapp_provider; pub use adjudication::{ AdjudicatingTurnPlanProvider, AdjudicationCatalog, AdjudicationError, AdjudicationModel, AdjudicationModelInput, AdjudicationModelResponse, AdjudicationRunError, AdjudicationToolCall, CatalogError, DEFAULT_MAX_ADJUDICATION_STEPS, HIDDEN_CHECK_TOOL_NAME, HiddenCheckRequest, QualitativeCheckOutcome, classify_roll, deterministic_roll, }; pub use context::{ CharacterMemory, CompiledSceneContext, ContextBudget, ContextCharacterCard, ContextCompileError, ContextInventoryItem, ContextJudgmentRule, ContextPersona, ContextPlotEvent, ContextPlotOutcome, ContextPlotPressure, ContextSkill, ContextStateMemory, ContextStatePosition, ContextSummary, ContextTurn, ContextWorldBookEntry, HiddenCheckTreatment, NarrativeSafety, PlayerMemory, ResourceProvenance, ResourceStringTreatment, SCENE_CONTEXT_SCHEMA_VERSION, SceneContext, SharedMemory, SummaryClassification, SummaryMemory, SummaryTreatment, compile_scene_context, compile_scene_context_with_budget, encode_compiled_scene_context, }; pub use lapp_provider::{ ChatExecutor, LappAdjudicationModel, LappTurnPlanProvider, OpenLappChatExecutor, TURN_PLAN_TOOL_NAME, }; pub const LAPP_BASELINE_COMMIT: &str = "5ba3c659e1536ec4bee16340faca603940a5cb17"; pub const MAX_WORLD_BOOK_ENTRIES: usize = 8; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InvalidModelOutputKind { InvalidJson, InvalidSchema, InvalidShape, InvalidPlan, } #[derive(Debug, Error)] pub enum ProviderError { #[error("no recorded response remains")] FixtureExhausted, #[error("LAPP profile could not be loaded")] Profile { code: openlapp::ErrorCode }, #[error("LAPP chat client could not be configured")] Configuration { code: Option }, #[error("LAPP chat request failed")] Upstream { code: Option }, #[error("model returned an invalid turn plan")] InvalidModelOutput { kind: InvalidModelOutputKind }, #[error("turn context could not be encoded")] ContextEncoding, } pub trait TurnProvider { fn complete_turn(&mut self, request: &TurnRequest) -> Result; } /// Non-view model output used by the persistent turn engine. /// /// The provider can propose player-facing presentation and state changes, but it /// cannot construct the final [`PlayerView`]. That view is derived from committed /// state by a separate trusted projection boundary. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TurnPlan { pub committed_node_id: String, pub presentation: PresentationSnapshot, pub delta: StateDelta, } /// Produces the uncommitted model plan for a turn. pub trait TurnPlanProvider { fn plan_turn( &mut self, request: &TurnRequest, state: &RuntimeState, ) -> Result; } impl TurnPlanProvider for &mut Provider { fn plan_turn( &mut self, request: &TurnRequest, state: &RuntimeState, ) -> Result { (**self).plan_turn(request, state) } } /// Projects only already-committed state into the player-safe read model. /// /// Projection is deliberately infallible: it is a pure, defensive operation /// that omits or degrades data it cannot safely represent. This prevents a /// post-commit error window where the branch advances but the turn reports a /// failure. pub trait TurnProjector { fn project_committed_turn(&mut self, state: &RuntimeState, node: &StoryNode) -> PlayerView; } /// Store-backed single-turn coordinator. /// /// All fallible work is completed before the atomic append. Projection runs /// only after the store confirms the new branch head. pub struct TurnEngine<'store, Store, Provider, Projector> { store: &'store Store, provider: Provider, projector: Projector, } impl<'store, Store, Provider, Projector> TurnEngine<'store, Store, Provider, Projector> where Store: StoryStore, Provider: TurnPlanProvider, Projector: TurnProjector, { #[must_use] pub fn new(store: &'store Store, provider: Provider, projector: Projector) -> Self { Self { store, provider, projector, } } /// Validate, plan, reduce, commit, then project one player turn. pub fn submit_turn(&mut self, request: &TurnRequest) -> Result { validate_turn_request(request)?; let current = self .store .load_state(&request.story_id, &request.branch_id) .map_err(|error| map_store_error(&error))?; if current.current_node != request.expected_node_id { return Err(stale_node()); } let plan = self .provider .plan_turn(request, ¤t) .map_err(|error| map_provider_error(&error))?; validate_turn_plan(request, &plan)?; let mut committed = apply_delta(¤t, &plan.delta).map_err(map_reduce_error)?; committed.current_node.clone_from(&plan.committed_node_id); committed.current_branch.clone_from(&request.branch_id); let state_hash = hash_runtime_state(&committed)?; let node = StoryNode { id: plan.committed_node_id.clone(), story_id: request.story_id.clone(), branch_id: request.branch_id.clone(), parent_id: Some(current.current_node), action_id: request.action_id.clone(), user_input: request.input.clone(), presentation: plan.presentation, delta: plan.delta, state_hash, }; self.store .append_node(&node, &committed) .map_err(|error| map_store_error(&error))?; let mut player_view = self.projector.project_committed_turn(&committed, &node); // Identity comes from the committed state, never from projection input. // Normalizing these fields keeps even a defensive fallback projector // aligned with the commit it represents. player_view.story_id.clone_from(&committed.story_id); player_view.node_id.clone_from(&committed.current_node); player_view.branch_id.clone_from(&committed.current_branch); Ok(TurnResult { committed_node_id: node.id, player_view, }) } #[must_use] pub fn provider(&self) -> &Provider { &self.provider } #[must_use] pub fn projector(&self) -> &Projector { &self.projector } } #[derive(Debug)] pub struct FakeProvider { responses: VecDeque, } impl FakeProvider { #[must_use] pub fn new(responses: impl IntoIterator) -> Self { Self { responses: responses.into_iter().collect(), } } } impl TurnProvider for FakeProvider { fn complete_turn(&mut self, _request: &TurnRequest) -> Result { self.responses .pop_front() .ok_or(ProviderError::FixtureExhausted) } } /// Validate the parts of a turn request that do not require persisted story state. /// /// Stale-node detection belongs to the store boundary. This validation deliberately /// does not infer any semantics for regenerate or pushed-check turns. pub fn validate_turn_request(request: &TurnRequest) -> Result<(), TurnFailure> { validate_required_text("story_id", &request.story_id)?; validate_required_text("branch_id", &request.branch_id)?; validate_required_text("expected_node_id", &request.expected_node_id)?; validate_required_text("action_id", &request.action_id)?; if matches!(&request.intent, TurnIntent::SpeakOrAct) && request.input.trim().is_empty() { return Err(invalid_input( "input must not be empty for a speak_or_act turn", )); } Ok(()) } /// Execute one provider turn without persistence or network policy. /// /// This is the deterministic seam used by fixtures and later by the store-backed /// runtime: request validation happens before provider invocation and the provider /// result is checked before it can cross into another layer. pub fn execute_turn( provider: &mut impl TurnProvider, request: &TurnRequest, ) -> Result { validate_turn_request(request)?; let result = provider .complete_turn(request) .map_err(|error| map_provider_error(&error))?; validate_turn_result(request, &result)?; Ok(result) } /// Select world-book entries using deterministic lexical triggers. /// /// Entries retain their input order. Duplicate ids keep the first matching entry. /// Required flags are an all-of gate; an entry then needs either a matching tag or /// a non-empty keyword contained in the turn input. #[must_use] pub fn select_world_book_entries( entries: &[WorldBookEntry], active_flags: &BTreeSet, trigger_tags: &BTreeSet, input: &str, limit: usize, ) -> Vec { let effective_limit = limit.min(MAX_WORLD_BOOK_ENTRIES); if effective_limit == 0 { return Vec::new(); } let normalized_tags = trigger_tags .iter() .map(|tag| normalize_trigger(tag)) .filter(|tag| !tag.is_empty()) .collect::>(); let normalized_input = input.to_lowercase(); let mut selected_ids = BTreeSet::new(); let mut selected = Vec::new(); for entry in entries { if selected.len() == effective_limit { break; } if !entry .required_flags .iter() .all(|flag| active_flags.contains(flag)) { continue; } let tag_matches = entry .tags .iter() .map(|tag| normalize_trigger(tag)) .any(|tag| !tag.is_empty() && normalized_tags.contains(&tag)); let keyword_matches = entry.keywords.iter().any(|keyword| { let keyword = keyword.trim().to_lowercase(); !keyword.is_empty() && normalized_input.contains(&keyword) }); if (tag_matches || keyword_matches) && selected_ids.insert(entry.id.clone()) { selected.push(entry.clone()); } } selected } pub fn load_default_lapp_profile() -> Result { openlapp::load_default_profile().map_err(|error| ProviderError::Profile { code: error.code() }) } #[must_use] pub fn provider_failure(message: impl Into) -> TurnFailure { TurnFailure { code: TurnFailureCode::ProviderUnavailable, message: message.into(), retryable: true, } } fn validate_turn_result(request: &TurnRequest, result: &TurnResult) -> Result<(), TurnFailure> { if result.committed_node_id.trim().is_empty() { return Err(invalid_model_output("committed node id is empty")); } if result.committed_node_id == request.expected_node_id { return Err(invalid_model_output( "provider returned the uncommitted expected node", )); } if result.player_view.story_id != request.story_id { return Err(invalid_model_output( "player view story does not match the request", )); } if result.player_view.branch_id != request.branch_id { return Err(invalid_model_output( "player view branch does not match the request", )); } if result.player_view.node_id != result.committed_node_id { return Err(invalid_model_output( "player view node does not match the committed node", )); } Ok(()) } fn validate_turn_plan(request: &TurnRequest, plan: &TurnPlan) -> Result<(), TurnFailure> { if plan.committed_node_id.trim().is_empty() { return Err(invalid_model_output("committed node id is empty")); } if plan.committed_node_id == request.expected_node_id { return Err(invalid_model_output( "provider returned the uncommitted expected node", )); } Ok(()) } fn hash_runtime_state(state: &RuntimeState) -> Result { serde_json::to_vec(state) .map(|bytes| nana_domain::stable_json_hash(&bytes)) .map_err(|_| internal_failure("turn state could not be prepared")) } fn map_reduce_error(_error: ReduceError) -> TurnFailure { invalid_model_output("turn plan could not be applied") } fn map_provider_error(error: &ProviderError) -> TurnFailure { match error { ProviderError::InvalidModelOutput { .. } => { invalid_model_output("model returned an invalid turn plan") } ProviderError::FixtureExhausted | ProviderError::Profile { .. } | ProviderError::Configuration { .. } | ProviderError::Upstream { .. } | ProviderError::ContextEncoding => provider_unavailable(), } } fn map_store_error(error: &StoreError) -> TurnFailure { match error { StoreError::StaleBranchHead { .. } => stale_node(), StoreError::StoryNotFound(_) | StoreError::BranchNotFound { .. } => TurnFailure { code: TurnFailureCode::InvalidInput, message: "story or branch is unavailable".into(), retryable: false, }, StoreError::NodeAlreadyExists(_) | StoreError::ParentNotFound(_) | StoreError::StateMismatch(_) | StoreError::StateHashMismatch { .. } | StoreError::Sqlite(_) | StoreError::Serialization(_) | StoreError::Poisoned => internal_failure("turn could not be committed"), } } fn validate_required_text(field: &str, value: &str) -> Result<(), TurnFailure> { if value.trim().is_empty() { Err(invalid_input(format!("{field} must not be empty"))) } else { Ok(()) } } fn invalid_input(message: impl Into) -> TurnFailure { TurnFailure { code: TurnFailureCode::InvalidInput, message: message.into(), retryable: false, } } fn invalid_model_output(message: impl Into) -> TurnFailure { TurnFailure { code: TurnFailureCode::InvalidModelOutput, message: message.into(), retryable: true, } } fn provider_unavailable() -> TurnFailure { provider_failure("turn provider is unavailable") } fn stale_node() -> TurnFailure { TurnFailure { code: TurnFailureCode::StaleNode, message: "story branch changed; refresh and retry".into(), retryable: true, } } fn internal_failure(message: impl Into) -> TurnFailure { TurnFailure { code: TurnFailureCode::Internal, message: message.into(), retryable: true, } } fn normalize_trigger(value: &str) -> String { value.trim().to_lowercase() } #[cfg(test)] mod tests { use std::collections::BTreeSet; use nana_domain::{ PlayerView, RelationshipBand, RelationshipView, TurnFailureCode, TurnIntent, TurnRequest, TurnResult, WorldBookEntry, }; use super::{ FakeProvider, MAX_WORLD_BOOK_ENTRIES, ProviderError, TurnProvider, execute_turn, select_world_book_entries, validate_turn_request, }; fn request(intent: TurnIntent, input: &str) -> TurnRequest { TurnRequest { story_id: "story_1".into(), branch_id: "branch_main".into(), expected_node_id: "node_1".into(), action_id: "action_1".into(), intent, input: input.into(), } } fn player_view(node_id: &str) -> PlayerView { PlayerView { story_id: "story_1".into(), node_id: node_id.into(), branch_id: "branch_main".into(), scene_id: "station".into(), scene_title: "Station".into(), character_name: "Nana".into(), character_expression: None, character_pose: None, beats: Vec::new(), suggestions: Vec::new(), inventory: Vec::new(), knowledge: Vec::new(), promises: Vec::new(), relationship: RelationshipView { affinity: RelationshipBand::Warming, trust: RelationshipBand::Guarded, hope: RelationshipBand::Guarded, respect: RelationshipBand::Warming, intimacy: RelationshipBand::Guarded, attachment: RelationshipBand::Warming, updated_at_node: None, }, history: Vec::new(), can_continue: true, } } fn result(node_id: &str) -> TurnResult { TurnResult { committed_node_id: node_id.into(), player_view: player_view(node_id), } } fn entry( id: &str, keywords: &[&str], tags: &[&str], required_flags: &[&str], ) -> WorldBookEntry { WorldBookEntry { id: id.into(), title: id.into(), content: format!("content for {id}"), keywords: keywords.iter().map(ToString::to_string).collect(), tags: tags.iter().map(ToString::to_string).collect(), required_flags: required_flags.iter().map(ToString::to_string).collect(), } } #[test] fn continue_allows_empty_input() { assert!(validate_turn_request(&request(TurnIntent::Continue, "")).is_ok()); } #[test] fn speak_or_act_rejects_empty_input() { let failure = validate_turn_request(&request(TurnIntent::SpeakOrAct, " \n ")) .expect_err("blank player input must be rejected"); assert_eq!(failure.code, TurnFailureCode::InvalidInput); assert!(!failure.retryable); assert_eq!( failure.message, "input must not be empty for a speak_or_act turn" ); } #[test] fn required_request_identifiers_must_not_be_blank() { for field in ["story_id", "branch_id", "expected_node_id", "action_id"] { let mut request = request(TurnIntent::Continue, ""); match field { "story_id" => request.story_id = " ".into(), "branch_id" => request.branch_id = " ".into(), "expected_node_id" => request.expected_node_id = " ".into(), "action_id" => request.action_id = " ".into(), _ => unreachable!(), } let failure = validate_turn_request(&request).expect_err("blank identifier must be rejected"); assert_eq!(failure.code, TurnFailureCode::InvalidInput); assert_eq!(failure.message, format!("{field} must not be empty")); } } #[test] fn regenerate_and_push_check_remain_valid_without_extra_rules() { assert!(validate_turn_request(&request(TurnIntent::Regenerate, "")).is_ok()); assert!(validate_turn_request(&request(TurnIntent::PushCheck, "")).is_ok()); } #[test] fn fake_provider_returns_fixtures_fifo() { let mut provider = FakeProvider::new([result("node_2"), result("node_3")]); let request = request(TurnIntent::Continue, ""); assert_eq!( provider .complete_turn(&request) .expect("first response") .committed_node_id, "node_2" ); assert_eq!( provider .complete_turn(&request) .expect("second response") .committed_node_id, "node_3" ); assert!(matches!( provider.complete_turn(&request), Err(ProviderError::FixtureExhausted) )); } #[test] fn execution_validates_before_consuming_the_provider() { let mut provider = FakeProvider::new([result("node_2")]); let invalid = request(TurnIntent::SpeakOrAct, ""); assert_eq!( execute_turn(&mut provider, &invalid) .expect_err("invalid request must fail before the provider") .code, TurnFailureCode::InvalidInput ); let committed = execute_turn( &mut provider, &request(TurnIntent::SpeakOrAct, "I will return."), ) .expect("the queued response must still be available"); assert_eq!(committed.committed_node_id, "node_2"); } #[test] fn execution_rejects_an_uncommitted_or_inconsistent_result() { let request = request(TurnIntent::Continue, ""); let mut stale = FakeProvider::new([result("node_1")]); let stale_failure = execute_turn(&mut stale, &request).expect_err("old expected node is not a commit"); assert_eq!(stale_failure.code, TurnFailureCode::InvalidModelOutput); let mut wrong_node = result("node_2"); wrong_node.player_view.node_id = "node_other".into(); let mut provider = FakeProvider::new([wrong_node]); let failure = execute_turn(&mut provider, &request).expect_err("view node must match committed node"); assert_eq!(failure.code, TurnFailureCode::InvalidModelOutput); let mut wrong_story = result("node_2"); wrong_story.player_view.story_id = "story_other".into(); let mut provider = FakeProvider::new([wrong_story]); assert_eq!( execute_turn(&mut provider, &request) .expect_err("view story must match request") .code, TurnFailureCode::InvalidModelOutput ); let mut wrong_branch = result("node_2"); wrong_branch.player_view.branch_id = "branch_other".into(); let mut provider = FakeProvider::new([wrong_branch]); assert_eq!( execute_turn(&mut provider, &request) .expect_err("view branch must match request") .code, TurnFailureCode::InvalidModelOutput ); } #[test] fn execution_redacts_provider_details() { struct FailingProvider; impl TurnProvider for FailingProvider { fn complete_turn( &mut self, _request: &TurnRequest, ) -> Result { Err(ProviderError::Upstream { code: Some(openlapp::ErrorCode::HttpStatus), }) } } let upstream = ProviderError::Upstream { code: Some(openlapp::ErrorCode::HttpStatus), }; assert_eq!(upstream.to_string(), "LAPP chat request failed"); let failure = execute_turn(&mut FailingProvider, &request(TurnIntent::Continue, "")) .expect_err("provider failure should be mapped"); assert_eq!(failure.code, TurnFailureCode::ProviderUnavailable); assert_eq!(failure.message, "turn provider is unavailable"); assert!(!failure.message.contains("secret")); assert!(failure.retryable); } #[test] fn world_book_selection_applies_flags_keywords_and_tags() { let entries = vec![ entry("public_station", &["station"], &[], &[]), entry("flagged_station", &["station"], &[], &["clock_seen"]), entry("rain_lore", &[], &[" Weather "], &[]), entry("not_triggered", &["forest"], &["family"], &[]), ]; let flags = BTreeSet::new(); let tags = BTreeSet::from([String::from("weather")]); let selected = select_world_book_entries(&entries, &flags, &tags, "Return to the STATION", 8); assert_eq!( selected .iter() .map(|entry| entry.id.as_str()) .collect::>(), ["public_station", "rain_lore"] ); let flags = BTreeSet::from([String::from("clock_seen")]); let selected = select_world_book_entries(&entries, &flags, &tags, "Return to the station", 8); assert_eq!( selected .iter() .map(|entry| entry.id.as_str()) .collect::>(), ["public_station", "flagged_station", "rain_lore"] ); } #[test] fn world_book_selection_is_stable_deduplicated_and_limited() { let mut entries = vec![ entry("first", &["rain"], &[], &[]), entry("duplicate", &["rain"], &[], &[]), entry("duplicate", &["rain"], &[], &[]), ]; entries.extend( (0..MAX_WORLD_BOOK_ENTRIES + 3) .map(|index| entry(&format!("extra_{index}"), &["rain"], &[], &[])), ); let selected = select_world_book_entries(&entries, &BTreeSet::new(), &BTreeSet::new(), "rain", 3); assert_eq!( selected .iter() .map(|entry| entry.id.as_str()) .collect::>(), ["first", "duplicate", "extra_0"] ); let capped = select_world_book_entries( &entries, &BTreeSet::new(), &BTreeSet::new(), "rain", usize::MAX, ); assert_eq!(capped.len(), MAX_WORLD_BOOK_ENTRIES); let repeated = select_world_book_entries(&entries, &BTreeSet::new(), &BTreeSet::new(), "rain", 3); assert_eq!(selected, repeated); } } #[cfg(test)] mod persistent_turn_tests { use std::collections::{BTreeMap, VecDeque}; use nana_domain::{ ActionSuggestion, BeatKind, PlayerView, PresentationBeat, PresentationCharacter, PresentationScene, PresentationSnapshot, RelationshipAdjustment, RelationshipBand, RelationshipDimension, RelationshipView, RuntimeState, StateDelta, StateOp, StoryNode, TurnFailureCode, TurnIntent, TurnRequest, }; use nana_store::{InMemoryStoryStore, StoryStore}; use super::{ ProviderError, TurnEngine, TurnPlan, TurnPlanProvider, TurnProjector, hash_runtime_state, }; struct RecordingPlanProvider { responses: VecDeque>, calls: usize, } impl RecordingPlanProvider { fn new(response: Result) -> Self { Self { responses: VecDeque::from([response]), calls: 0, } } } impl TurnPlanProvider for RecordingPlanProvider { fn plan_turn( &mut self, _request: &TurnRequest, _state: &RuntimeState, ) -> Result { self.calls += 1; self.responses .pop_front() .unwrap_or(Err(ProviderError::FixtureExhausted)) } } struct RecordingProjector<'store> { store: &'store InMemoryStoryStore, calls: usize, } impl TurnProjector for RecordingProjector<'_> { fn project_committed_turn(&mut self, state: &RuntimeState, node: &StoryNode) -> PlayerView { self.calls += 1; let stored = self .store .load_state(&state.story_id, &state.current_branch) .expect("projection must run after append"); assert_eq!(stored, *state); assert_eq!(node.id, state.current_node); PlayerView { // Deliberately wrong: the engine normalizes commit identity. story_id: "untrusted_story".into(), node_id: "untrusted_node".into(), branch_id: "untrusted_branch".into(), scene_id: "station".into(), scene_title: "Station".into(), character_name: "Nana".into(), character_expression: node.presentation.character.expression.clone(), character_pose: node.presentation.character.pose.clone(), beats: node.presentation.beats.clone(), suggestions: node.presentation.suggestions.clone(), inventory: Vec::new(), knowledge: Vec::new(), promises: Vec::new(), relationship: RelationshipView { affinity: RelationshipBand::Warming, trust: RelationshipBand::Guarded, hope: RelationshipBand::Guarded, respect: RelationshipBand::Warming, intimacy: RelationshipBand::Guarded, attachment: RelationshipBand::Warming, updated_at_node: Some(node.id.clone()), }, history: Vec::new(), can_continue: true, } } } fn state(node: &str, branch: &str) -> RuntimeState { RuntimeState { story_id: "story_1".into(), current_node: node.into(), current_branch: branch.into(), world_flags: BTreeMap::new(), relationships: BTreeMap::new(), relationship_states: Vec::new(), promises: Vec::new(), knowledge: Vec::new(), items: Vec::new(), clocks: Vec::new(), checks: Vec::new(), } } fn node(id: &str, parent_id: Option<&str>, branch: &str) -> StoryNode { StoryNode { id: id.into(), story_id: "story_1".into(), branch_id: branch.into(), parent_id: parent_id.map(Into::into), action_id: format!("action_{id}"), user_input: String::new(), presentation: PresentationSnapshot::default(), delta: StateDelta { ops: Vec::new() }, state_hash: hash_runtime_state(&state(id, branch)).expect("serializable test state"), } } fn seeded_store() -> InMemoryStoryStore { let store = InMemoryStoryStore::new(); store .append_node( &node("node_1", None, "branch_main"), &state("node_1", "branch_main"), ) .expect("seed root"); store } fn request(expected_node_id: &str) -> TurnRequest { TurnRequest { story_id: "story_1".into(), branch_id: "branch_main".into(), expected_node_id: expected_node_id.into(), action_id: "action_2".into(), intent: TurnIntent::SpeakOrAct, input: "I will return before dawn.".into(), } } fn plan(node_id: &str, delta: StateDelta) -> TurnPlan { TurnPlan { committed_node_id: node_id.into(), presentation: PresentationSnapshot { scene: PresentationScene { id: "station".into(), title: "Station".into(), }, character: PresentationCharacter { id: "nana".into(), name: "Nana".into(), expression: Some("guarded".into()), pose: Some("holding_coat".into()), }, beats: vec![PresentationBeat { id: "beat_1".into(), kind: BeatKind::Dialogue, speaker: Some("Nana".into()), text: "Then I will wait.".into(), visual: None, }], suggestions: vec![ActionSuggestion { id: "suggestion_1".into(), label: "Promise".into(), draft: "I promise.".into(), }], can_continue: true, }, delta, } } fn projector(store: &InMemoryStoryStore) -> RecordingProjector<'_> { RecordingProjector { store, calls: 0 } } #[test] fn successful_turn_commits_before_projecting() { let store = seeded_store(); let delta = StateDelta { ops: vec![StateOp::SetWorldFlag { key: "promise_spoken".into(), value: true, }], }; let mut engine = TurnEngine::new( &store, RecordingPlanProvider::new(Ok(plan("node_2", delta))), projector(&store), ); let result = engine.submit_turn(&request("node_1")).expect("turn"); assert_eq!(result.committed_node_id, "node_2"); assert_eq!(result.player_view.story_id, "story_1"); assert_eq!(result.player_view.node_id, "node_2"); assert_eq!(result.player_view.branch_id, "branch_main"); assert_eq!(result.player_view.suggestions.len(), 1); assert_eq!(engine.provider().calls, 1); assert_eq!(engine.projector().calls, 1); let committed = store .load_state("story_1", "branch_main") .expect("committed state"); assert_eq!(committed.current_node, "node_2"); assert_eq!(committed.world_flags.get("promise_spoken"), Some(&true)); } #[test] fn stale_request_does_not_call_provider_or_move_head() { let store = seeded_store(); let mut engine = TurnEngine::new( &store, RecordingPlanProvider::new(Ok(plan("node_2", StateDelta { ops: Vec::new() }))), projector(&store), ); let failure = engine .submit_turn(&request("node_stale")) .expect_err("stale request"); assert_eq!(failure.code, TurnFailureCode::StaleNode); assert!(failure.retryable); assert_eq!(engine.provider().calls, 0); assert_eq!(engine.projector().calls, 0); assert_eq!( store.branch_head("story_1", "branch_main").expect("head"), Some("node_1".into()) ); } #[test] fn reducer_failure_is_redacted_and_does_not_save() { let store = seeded_store(); let delta = StateDelta { ops: vec![StateOp::AdjustRelationship { from: "nana".into(), to: "player".into(), adjustment: RelationshipAdjustment { dimension: RelationshipDimension::Trust, delta: 9, cause: "secret model reasoning".into(), judgment_rule: Some("secret.rule".into()), }, }], }; let mut engine = TurnEngine::new( &store, RecordingPlanProvider::new(Ok(plan("node_2", delta))), projector(&store), ); let failure = engine .submit_turn(&request("node_1")) .expect_err("invalid delta"); assert_eq!(failure.code, TurnFailureCode::InvalidModelOutput); assert_eq!(failure.message, "turn plan could not be applied"); assert!(!failure.message.contains("secret")); assert_eq!(engine.provider().calls, 1); assert_eq!(engine.projector().calls, 0); assert_eq!( store.branch_head("story_1", "branch_main").expect("head"), Some("node_1".into()) ); } #[test] fn provider_failure_is_redacted_and_does_not_save() { let store = seeded_store(); let mut engine = TurnEngine::new( &store, RecordingPlanProvider::new(Err(ProviderError::Upstream { code: Some(openlapp::ErrorCode::HttpStatus), })), projector(&store), ); let failure = engine .submit_turn(&request("node_1")) .expect_err("provider failure"); assert_eq!(failure.code, TurnFailureCode::ProviderUnavailable); assert_eq!(failure.message, "turn provider is unavailable"); assert!(!failure.message.contains("secret")); assert_eq!(engine.projector().calls, 0); assert_eq!( store.branch_head("story_1", "branch_main").expect("head"), Some("node_1".into()) ); } #[test] fn append_failure_does_not_project_or_move_requested_branch() { let store = seeded_store(); store .append_node( &node("node_duplicate", Some("node_1"), "branch_other"), &state("node_duplicate", "branch_other"), ) .expect("seed duplicate id on another branch"); let mut engine = TurnEngine::new( &store, RecordingPlanProvider::new(Ok(plan("node_duplicate", StateDelta { ops: Vec::new() }))), projector(&store), ); let failure = engine .submit_turn(&request("node_1")) .expect_err("duplicate node append"); assert_eq!(failure.code, TurnFailureCode::Internal); assert_eq!(failure.message, "turn could not be committed"); assert_eq!(engine.projector().calls, 0); assert_eq!( store.branch_head("story_1", "branch_main").expect("head"), Some("node_1".into()) ); } #[test] fn turn_plan_is_non_view_provider_output_and_state_hash_is_stable() { fn provider_output( provider: &mut impl TurnPlanProvider, request: &TurnRequest, state: &RuntimeState, ) -> TurnPlan { provider.plan_turn(request, state).expect("turn plan") } let current = state("node_1", "branch_main"); let expected = hash_runtime_state(¤t).expect("hash"); assert_eq!(hash_runtime_state(¤t).expect("repeat hash"), expected); let mut provider = RecordingPlanProvider::new(Ok(plan("node_2", StateDelta { ops: Vec::new() }))); let output = provider_output(&mut provider, &request("node_1"), ¤t); assert_eq!(output.committed_node_id, "node_2"); assert_eq!(output.presentation.beats.len(), 1); assert_eq!(output.presentation.suggestions.len(), 1); } }