use std::collections::{BTreeSet, VecDeque}; use nana_domain::{ TurnFailure, TurnFailureCode, TurnIntent, TurnRequest, TurnResult, WorldBookEntry, }; use thiserror::Error; pub const LAPP_BASELINE_COMMIT: &str = "5ba3c659e1536ec4bee16340faca603940a5cb17"; pub const MAX_WORLD_BOOK_ENTRIES: usize = 8; #[derive(Debug, Error)] pub enum ProviderError { #[error("no recorded response remains")] FixtureExhausted, #[error("LAPP profile could not be loaded")] Profile(String), } pub trait TurnProvider { fn complete_turn(&mut self, request: &TurnRequest) -> Result; } #[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(|_| provider_unavailable())?; 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(error.to_string())) } #[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_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 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(), 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::Profile( "secret upstream endpoint and token".into(), )) } } let upstream = ProviderError::Profile("secret upstream endpoint and token".into()); assert!(!upstream.to_string().contains("secret")); 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); } }