//! Unprompted disclosure system (D-081). //! //! Two-system pipeline: //! - `derive_disclosure_candidates`: per-NPC KG filter, recomputed every 30 ticks //! - `process_unprompted_disclosure`: trigger gates, StableId-ordered firing //! //! NPCs check only their own KG (D-010 principle 2 — no cross-entity KG reads). //! Disclosure grants the fact to the player's KG via `KnowledgeGranted` event //! and emits a placeholder `MonologueEvent` (Layer 4 line selection in #172). use std::collections::BTreeSet; use bevy_ecs::prelude::*; use crate::bridge::types::MonologueEvent; use crate::knowledge::events::{ KnowledgeEvent, KnowledgeEventType, ProcessedFactGrant, ProcessedKnowledgeGrant, }; use crate::knowledge::types::{ FactId, KnowledgeConfidence, KnowledgeSource, KnowledgeState, RelationshipState, StableId, }; use crate::knowledge::{KnowledgeEventQueue, KnowledgeGraph, StableEntityId}; use crate::npc::mood::{MoodState, NpcMood}; use crate::npc::relationships::RelationshipGraph; use crate::npc::trait_modifiers::{traits_to_keys, TraitModifierConfig}; use crate::npc::{Contentment, Npc, PersonalityTraits}; use crate::simulation::monologue::MonologueBuffer; use crate::simulation::movement::{PlayerCharacter, TilePosition}; use crate::simulation::spatial::{NaiveSpatialIndex, SpatialIndex}; use crate::simulation::tier::ActiveSim; use crate::simulation::time::SimulationTime; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /// Recompute `DisclosureCandidates` every N ticks (30 ticks = 3 game-minutes at 10 tps). const CANDIDATE_REFRESH_TICKS: u64 = 30; /// Per-NPC cooldown ticks after a disclosure fires (300 = 30 game-minutes). const NPC_COOLDOWN_TICKS: u64 = 300; /// Minimum ticks between any two disclosures (global rate limit). const GLOBAL_RATE_LIMIT_TICKS: u64 = 10; /// Max candidates retained in `DisclosureCandidates`. const MAX_CANDIDATES: usize = 10; /// Witness inhibition check radius (Manhattan distance, tiles). const WITNESS_RADIUS: u32 = 5; /// Minimum NPC→player trust for Surface-tier disclosure. const SURFACE_TRUST: i8 = 0; /// NPC→player trust at which witness inhibition is waived (Secret tier, D-081). const SECRET_TRUST: i8 = 7; /// Trust level below which a nearby NPC counts as an untrusted witness. const REAL_TRUST: i8 = 3; /// Player proximity range for candidate recompute (tiles). Matches voice range. const PLAYER_RANGE_TILES: u32 = 8; // --------------------------------------------------------------------------- // Components // --------------------------------------------------------------------------- /// Per-NPC computed disclosure candidate pool (D-081). /// /// Recomputed every `CANDIDATE_REFRESH_TICKS` ticks by `derive_disclosure_candidates`. /// Consumed by `process_unprompted_disclosure` when all trigger gates pass. /// /// Facts are sorted: confidence desc, then `acquired_tick` desc. Capped at /// `MAX_CANDIDATES`. #[derive(Component, Debug, Clone, Default)] pub struct DisclosureCandidates { /// Fact IDs eligible for disclosure, ordered by priority. pub candidates: Vec, /// Tick when this pool was last computed. 0 = never computed. pub computed_tick: u64, } /// Per-NPC disclosure cooldown state (D-081). /// /// Tracks which facts have been disclosed during the current window /// (primary narrative quality gate) and the per-NPC silence period. #[derive(Component, Debug, Clone, Default)] pub struct DisclosureCooldown { /// Facts disclosed this window — prevents repeating the same fact. pub per_fact_history: BTreeSet, /// Tick when the per-NPC cooldown expires. 0 = no active cooldown. pub npc_cooldown_until: u64, } // --------------------------------------------------------------------------- // Resources // --------------------------------------------------------------------------- /// Global rate limiter: at most 1 unprompted disclosure per `GLOBAL_RATE_LIMIT_TICKS`. /// /// When multiple NPCs are eligible simultaneously, the one with the lowest /// StableId fires first (deterministic, D-010 principle 4). #[derive(Resource, Debug, Clone, Default)] pub struct DisclosureGlobalRateLimit { pub last_disclosure_tick: u64, } // --------------------------------------------------------------------------- // derive_disclosure_candidates // --------------------------------------------------------------------------- /// Recompute `DisclosureCandidates` for each Active NPC. /// /// Runs every `CANDIDATE_REFRESH_TICKS` ticks (checked per-NPC via `computed_tick`). /// Filters the NPC's own KG through: /// /// - `KnowledgeState::Active` only /// - Confidence >= `KnowsOf` (or trait-lowered threshold via `TraitModifierConfig`) /// - Not in `per_fact_history` /// - `disclosure_blocked != true` /// - ToldBy exclusion if Cautious trait is configured /// /// Applies Stage 1 trait filters from `TraitModifierConfig`. Sorted by /// confidence desc then `acquired_tick` desc. Capped at `MAX_CANDIDATES`. pub fn derive_disclosure_candidates( time: Res, trait_config: Res, mut npc_query: Query< ( &KnowledgeGraph, &DisclosureCooldown, Option<&PersonalityTraits>, &mut DisclosureCandidates, ), (With, With), >, ) { let current_tick = time.tick; for (kg, cooldown, traits_opt, mut candidates) in &mut npc_query { // Only recompute when the refresh interval has elapsed. if candidates.computed_tick != 0 && current_tick.saturating_sub(candidates.computed_tick) < CANDIDATE_REFRESH_TICKS { continue; } let trait_keys = traits_opt .map(|t| traits_to_keys(&t.traits)) .unwrap_or_default(); // Effective confidence floor. // `lowest_min_confidence` returns the most permissive threshold across // all traits (additive expansion — e.g., Gossipy sets Suspects, // which wins over Cautious raising to KnowsDetails). let min_confidence = trait_config .lowest_min_confidence(&trait_keys) .unwrap_or(KnowledgeConfidence::KnowsOf); // Cautious trait: exclude facts with ToldBy (rumour) source. let exclude_told_by = trait_keys.iter().any(|k| { trait_config .modifier_for(k) .is_some_and(|m| m.stage1.exclude_told_by) }); // Loyal trait: exclude facts sourced from high-trust entities. // "Don't gossip about your friends" — if ToldBy source has Friendly // relationship in this NPC's KG, suppress the fact. D-081. let exclude_high_trust = trait_config.any_excludes_high_trust(&trait_keys); let mut pool: Vec<(FactId, u64, KnowledgeConfidence)> = kg .known_facts_iter() .filter(|(fact_id, fact)| { // Active state only. if fact.state != KnowledgeState::Active { return false; } // Not already disclosed this window. if cooldown.per_fact_history.contains(*fact_id) { return false; } // Existentially dangerous secrets never disclosed (D-080). if fact.disclosure_blocked { return false; } // Confidence threshold. if fact.confidence < min_confidence { return false; } // Cautious: skip ToldBy-source facts. if exclude_told_by && matches!(fact.source, KnowledgeSource::ToldBy { .. }) { return false; } // Loyal: skip facts from high-trust (Friendly) source entities. if exclude_high_trust { if let KnowledgeSource::ToldBy { source_id, .. } = &fact.source { if kg.relationship_with(source_id) == RelationshipState::Friendly { return false; } } } true }) .map(|(id, fact)| (id.clone(), fact.acquired_tick, fact.confidence)) .collect(); // Sort: confidence desc, then acquired_tick desc (most recent first). pool.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1))); pool.truncate(MAX_CANDIDATES); candidates.candidates = pool.into_iter().map(|(id, _, _)| id).collect(); candidates.computed_tick = current_tick; } } // --------------------------------------------------------------------------- // process_unprompted_disclosure // --------------------------------------------------------------------------- /// Collected during the read pass; used for sorting and winner selection. struct EligibleNpc { entity: Entity, stable_id: StableId, pos: TilePosition, fact_id: FactId, override_witness: bool, } /// Fire one unprompted disclosure per tick window when all trigger gates pass (D-081). /// /// Trigger gates (all must pass for a given NPC): /// /// 1. NPC has a `StableEntityId` (required for trust lookup) /// 2. `DisclosureCandidates` pool is non-empty /// 3. NPC→player trust >= `SURFACE_TRUST` /// 4. `MoodState` != `NpcMood::Hostile` /// 5. `Contentment.level` >= −10 /// 6. Per-NPC cooldown not active /// 7. Player within `PLAYER_RANGE_TILES` /// 8. Witness inhibition: no untrusted NPCs within `WITNESS_RADIUS` tiles /// (waived if NPC→player trust >= `SECRET_TRUST` or Talkative trait) /// 9. Location privacy: stubbed as always-pass — full impl in #172 /// /// Multiple eligible NPCs sorted by ascending StableId (D-010 principle 4). /// First in order that also passes witness inhibition fires. /// Subject to global rate limit (`GLOBAL_RATE_LIMIT_TICKS`). pub fn process_unprompted_disclosure( time: Res, spatial: Res, relationship_graph: Res, trait_config: Res, mut rate_limit: ResMut, mut event_queue: ResMut, player_pos_query: Query< (Entity, &TilePosition, Option<&StableEntityId>), With, >, mut player_mono_query: Query<&mut MonologueBuffer, With>, mut npc_query: Query< ( Entity, &TilePosition, Option<&StableEntityId>, Option<&MoodState>, Option<&Contentment>, Option<&PersonalityTraits>, &DisclosureCandidates, &mut DisclosureCooldown, ), (With, With), >, witness_sid_query: Query, With>, ) { let current_tick = time.tick; // Gate: global rate limit — at most 1 disclosure per GLOBAL_RATE_LIMIT_TICKS. if current_tick.saturating_sub(rate_limit.last_disclosure_tick) < GLOBAL_RATE_LIMIT_TICKS { return; } // Collect player state. Single-player assumption (D-010). let Ok((player_entity, player_pos_ref, player_sid_opt)) = player_pos_query.single() else { return; }; let player_pos = *player_pos_ref; let player_sid: Option = player_sid_opt.map(|s| s.0); // --- Read pass: collect all NPCs passing the non-spatial gates --- let mut eligible: Vec = npc_query .iter() .filter_map( |(entity, pos, sid_opt, mood_opt, content_opt, traits_opt, candidates, cooldown)| { // Gate 1: must have a StableId for trust lookup. let npc_sid = sid_opt?.0; // Gate 2: candidate pool non-empty. // Takes highest-priority candidate (sorted by confidence desc, // then recency desc in derive_disclosure_candidates). Full Layer 4 // line selection with variety tracking is deferred to #172. let fact_id = candidates.candidates.first()?.clone(); // Gate 3: NPC→player trust >= Surface. let npc_player_trust = player_sid .and_then(|psid| relationship_graph.get_relationship(&npc_sid, &psid)) .map(|e| e.trust) .unwrap_or(0); if npc_player_trust < SURFACE_TRUST { return None; } // Gate 4: mood not Hostile. if mood_opt.is_some_and(|ms| ms.mood == NpcMood::Hostile) { return None; } // Gate 5: contentment >= -10. if content_opt.is_some_and(|c| c.level < -10) { return None; } // Gate 6: per-NPC cooldown not active. if current_tick < cooldown.npc_cooldown_until { return None; } // Gate 7: player within PLAYER_RANGE_TILES (same z-level only). let in_range = pos .manhattan_distance(&player_pos) .is_some_and(|d| d <= PLAYER_RANGE_TILES); if !in_range { return None; } // Gate 9: location privacy — stubbed always-pass. // Full implementation deferred to #172 (Layer 4 disclosure pipeline). // Compute witness inhibition override for gate 8. let trait_keys = traits_opt .map(|t| traits_to_keys(&t.traits)) .unwrap_or_default(); let override_witness = npc_player_trust >= SECRET_TRUST || trait_config.any_overrides_witness_inhibition(&trait_keys); Some(EligibleNpc { entity, stable_id: npc_sid, pos: *pos, fact_id, override_witness, }) }, ) .collect(); if eligible.is_empty() { return; } // Sort by ascending StableId for determinism (D-010 principle 4). eligible.sort_by_key(|c| c.stable_id); // Gate 8: witness inhibition — find first NPC that passes spatial check. let winner = eligible.into_iter().find(|candidate| { if candidate.override_witness { return true; } !has_untrusted_witness( &candidate.pos, candidate.stable_id, player_entity, &spatial, &witness_sid_query, &relationship_graph, ) }); let Some(winner) = winner else { return; }; // --- Fire the disclosure --- // 1. Grant the fact to the player's KG via KnowledgeGranted event (ToldBy source). // Confidence capped at KnowsOf (same rule as NPC-to-NPC transfer, D-080). event_queue.push(KnowledgeEvent { observer: player_entity, tick: current_tick, event_type: KnowledgeEventType::KnowledgeGranted { grant: ProcessedKnowledgeGrant::Fact(ProcessedFactGrant { fact_id: winner.fact_id.clone(), confidence: KnowledgeConfidence::KnowsOf, }), source: KnowledgeSource::ToldBy { source_id: winner.stable_id, tick: current_tick, }, }, }); // 2. Placeholder monologue event — actual line selection deferred to #172 // (Layer 4 unprompted disclosure pipeline reads DisclosureCandidates). if let Ok(mut mono_buf) = player_mono_query.single_mut() { mono_buf.set(MonologueEvent { id: format!("disclosure_{}", winner.fact_id.0), text: String::from("(Layer 4 line selection — ticket #172)"), duration_seconds: 4.0, }); } // 3. Update NPC cooldown state. // Note: re-queries npc_query mutably after the read pass above. This is // safe because the read pass only borrows shared refs and completes before // this point. The two-phase pattern (read → select winner → write) avoids // holding a mutable borrow during iteration. if let Ok((_, _, _, _, _, _, _, mut cooldown)) = npc_query.get_mut(winner.entity) { cooldown.per_fact_history.insert(winner.fact_id.clone()); cooldown.npc_cooldown_until = current_tick + NPC_COOLDOWN_TICKS; } // 4. Advance global rate limit. rate_limit.last_disclosure_tick = current_tick; tracing::debug!( tick = current_tick, npc_sid = ?winner.stable_id, fact_id = %winner.fact_id.0, "unprompted disclosure fired" ); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Returns true if any untrusted NPC is within `WITNESS_RADIUS` of `pos`. /// /// "Untrusted" = the disclosing NPC's trust toward that witness is below /// `REAL_TRUST`. The player entity is excluded (they are the target). fn has_untrusted_witness( pos: &TilePosition, npc_sid: StableId, player_entity: Entity, spatial: &NaiveSpatialIndex, witness_sid_query: &Query, With>, relationship_graph: &RelationshipGraph, ) -> bool { for witness_entity in spatial.entities_in_range(pos, WITNESS_RADIUS) { if witness_entity == player_entity { continue; } if let Ok(Some(witness_sid)) = witness_sid_query.get(witness_entity) { let trust = relationship_graph .get_relationship(&npc_sid, &witness_sid.0) .map(|e| e.trust) .unwrap_or(0); if trust < REAL_TRUST { return true; } } } false } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use bevy_app::prelude::*; use super::*; use crate::knowledge::graph::KnowledgeGraph; use crate::knowledge::types::{KnowledgeConfidence, KnowledgeSource, KnowledgeState}; use crate::simulation::tier::ActiveSim; use crate::simulation::time::SimulationTime; fn make_fact( confidence: KnowledgeConfidence, state: KnowledgeState, acquired_tick: u64, disclosure_blocked: bool, ) -> crate::knowledge::types::FactKnowledge { crate::knowledge::types::FactKnowledge { confidence, source: KnowledgeSource::Background, state, acquired_tick, disclosure_blocked, } } fn make_kg(facts: Vec<(FactId, crate::knowledge::types::FactKnowledge)>) -> KnowledgeGraph { KnowledgeGraph::with_background(facts) } /// Build a minimal App for derive_disclosure_candidates tests. fn build_app() -> App { let mut app = App::new(); app.init_resource::(); app.init_resource::(); app.add_systems(Update, derive_disclosure_candidates); app } // ----------------------------------------------------------------------- // derive_disclosure_candidates tests // ----------------------------------------------------------------------- #[test] fn active_knowsof_fact_becomes_candidate() { let mut app = build_app(); let fact_id = FactId("investigation.clue".to_string()); let kg = make_kg(vec![( fact_id.clone(), make_fact( KnowledgeConfidence::KnowsOf, KnowledgeState::Active, 5, false, ), )]); let npc = app .world_mut() .spawn(( Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default(), )) .id(); app.update(); let candidates = app.world().get::(npc).unwrap(); assert!( candidates.candidates.contains(&fact_id), "Active KnowsOf fact should be in pool" ); } #[test] fn disclosure_blocked_fact_excluded() { let mut app = build_app(); let fact_id = FactId("secret.dangerous".to_string()); let kg = make_kg(vec![( fact_id.clone(), make_fact( KnowledgeConfidence::KnowsDetails, KnowledgeState::Active, 5, true, ), )]); let npc = app .world_mut() .spawn(( Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default(), )) .id(); app.update(); let candidates = app.world().get::(npc).unwrap(); assert!( !candidates.candidates.contains(&fact_id), "disclosure_blocked fact must never be a candidate" ); } #[test] fn stale_fact_excluded() { let mut app = build_app(); let fact_id = FactId("cargo.manifest".to_string()); let kg = make_kg(vec![( fact_id.clone(), make_fact( KnowledgeConfidence::KnowsOf, KnowledgeState::Stale, 5, false, ), )]); let npc = app .world_mut() .spawn(( Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default(), )) .id(); app.update(); let candidates = app.world().get::(npc).unwrap(); assert!( !candidates.candidates.contains(&fact_id), "Stale fact must be excluded" ); } #[test] fn already_disclosed_fact_excluded() { let mut app = build_app(); let fact_id = FactId("dock.schedule".to_string()); let kg = make_kg(vec![( fact_id.clone(), make_fact( KnowledgeConfidence::KnowsOf, KnowledgeState::Active, 5, false, ), )]); let mut cooldown = DisclosureCooldown::default(); cooldown.per_fact_history.insert(fact_id.clone()); let npc = app .world_mut() .spawn(( Npc, ActiveSim, kg, cooldown, DisclosureCandidates::default(), )) .id(); app.update(); let candidates = app.world().get::(npc).unwrap(); assert!( !candidates.candidates.contains(&fact_id), "Fact in per_fact_history must be excluded" ); } #[test] fn candidates_capped_at_max() { let mut app = build_app(); // Spawn 15 facts — only MAX_CANDIDATES should survive. let facts: Vec<_> = (0..15) .map(|i| { ( FactId(format!("fact.{:02}", i)), make_fact( KnowledgeConfidence::KnowsOf, KnowledgeState::Active, i as u64, false, ), ) }) .collect(); let kg = make_kg(facts); let npc = app .world_mut() .spawn(( Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default(), )) .id(); app.update(); let candidates = app.world().get::(npc).unwrap(); assert_eq!( candidates.candidates.len(), MAX_CANDIDATES, "Candidate pool must be capped at MAX_CANDIDATES" ); } #[test] fn refresh_skipped_within_interval() { let mut app = build_app(); let fact_id = FactId("investigation.clue".to_string()); let kg = make_kg(vec![( fact_id.clone(), make_fact( KnowledgeConfidence::KnowsOf, KnowledgeState::Active, 5, false, ), )]); // Set computed_tick = 1 (non-zero). Tick 5 is within the 30-tick refresh window. let mut candidates = DisclosureCandidates::default(); candidates.computed_tick = 1; let npc = app .world_mut() .spawn(( Npc, ActiveSim, kg, DisclosureCooldown::default(), candidates, )) .id(); // Advance tick to 5 (within CANDIDATE_REFRESH_TICKS = 30). app.world_mut().resource_mut::().tick = 5; app.update(); let candidates = app.world().get::(npc).unwrap(); assert_eq!( candidates.computed_tick, 1, "Refresh should be skipped within the interval" ); assert!( candidates.candidates.is_empty(), "Candidates should remain empty (no recompute)" ); } #[test] fn suspects_confidence_below_default_floor_excluded() { let mut app = build_app(); let fact_id = FactId("rumour.vague".to_string()); let kg = make_kg(vec![( fact_id.clone(), make_fact( KnowledgeConfidence::Suspects, KnowledgeState::Active, 5, false, ), )]); let npc = app .world_mut() .spawn(( Npc, ActiveSim, kg, DisclosureCooldown::default(), DisclosureCandidates::default(), )) .id(); app.update(); let candidates = app.world().get::(npc).unwrap(); assert!( !candidates.candidates.contains(&fact_id), "Suspects-confidence fact must be below the KnowsOf default floor" ); } }