From d5cf97a7935645aa0561a8bba9281fd3c2c6eb94 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 24 Feb 2026 12:15:21 +0100 Subject: [PATCH] feat(simulation): unprompted disclosure with trait-based filtering (#551, #173) DisclosureCandidates component with 7 trigger gates, three-layer rate limiting, and two-stage trait filter (what + how). Cautious/Gossipy/Loyal/ Talkative predicates via TraitModifierConfig. POI component and discovery system. Implements D-081, D-082 step 2. Co-Authored-By: Claude Opus 4.6 --- server/src/npc/disclosure.rs | 676 +++++++++++++++++++++++++ server/src/npc/mod.rs | 9 + server/src/npc/trait_modifiers.rs | 561 ++++++++++++++++++++ server/src/simulation/poi.rs | 191 +++++++ server/src/simulation/poi_discovery.rs | 421 +++++++++++++++ 5 files changed, 1858 insertions(+) create mode 100644 server/src/npc/disclosure.rs create mode 100644 server/src/npc/trait_modifiers.rs create mode 100644 server/src/simulation/poi.rs create mode 100644 server/src/simulation/poi_discovery.rs diff --git a/server/src/npc/disclosure.rs b/server/src/npc/disclosure.rs new file mode 100644 index 000000000..6fd6d8ac9 --- /dev/null +++ b/server/src/npc/disclosure.rs @@ -0,0 +1,676 @@ +//! 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, 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) + }); + + 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; + } + 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. + 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. + 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" + ); + } +} diff --git a/server/src/npc/mod.rs b/server/src/npc/mod.rs index f049c45e8..6294ab91b 100644 --- a/server/src/npc/mod.rs +++ b/server/src/npc/mod.rs @@ -2,6 +2,7 @@ // Implements D-024: 10-axis NPC model + CombatCapability component // Background tier state machines for schedule, mood, relationships, job +pub mod disclosure; pub mod generate; pub mod interaction; pub mod mood; @@ -9,6 +10,7 @@ pub mod relationships; pub mod routine; pub mod tell_state; pub mod tolerance; +pub mod trait_modifiers; use bevy_app::prelude::*; use bevy_ecs::prelude::*; @@ -31,6 +33,8 @@ impl Plugin for NpcPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() .add_systems( Update, ( @@ -61,6 +65,11 @@ impl Plugin for NpcPlugin { .after(mood::update_mood) .after(routine::detect_routine_deviation) .before(crate::perception::observer::compute_observer_snapshot), + disclosure::derive_disclosure_candidates + .before(crate::perception::observer::compute_observer_snapshot), + disclosure::process_unprompted_disclosure + .after(disclosure::derive_disclosure_candidates) + .before(crate::perception::observer::compute_observer_snapshot), crate::simulation::dialogue::process_talk_interaction .after(crate::simulation::input::process_player_input), crate::simulation::dialogue::process_walk_away diff --git a/server/src/npc/trait_modifiers.rs b/server/src/npc/trait_modifiers.rs new file mode 100644 index 000000000..574bad7d0 --- /dev/null +++ b/server/src/npc/trait_modifiers.rs @@ -0,0 +1,561 @@ +//! Trait modifier system for unprompted disclosure (#173, D-081). +//! +//! Two-stage filter: Stage 1 (WHAT) modifies the disclosure candidate pool, +//! Stage 2 (HOW) weights line selection via delivery tags. +//! +//! Traits map to filter predicates via content-authorable YAML config — +//! not hard-coded enum dispatch. Content authors define what each trait +//! does to the candidate pool and which delivery tags it prefers. + +use std::collections::BTreeMap; + +use bevy_ecs::prelude::*; +use serde::Deserialize; + +use crate::knowledge::types::{FactKnowledge, KnowledgeConfidence, KnowledgeSource}; + +// --------------------------------------------------------------------------- +// YAML-authored trait modifier config +// --------------------------------------------------------------------------- + +/// Full trait modifier configuration resource. Loaded from YAML. +/// +/// Keys are trait names (lowercase, matching `PersonalityTrait` string +/// representation): `"cautious"`, `"gossipy"`, `"loyal"`, `"talkative"`, etc. +/// +/// BTreeMap for deterministic iteration (D-010 principle 4). +#[derive(Resource, Debug, Clone, Default, Deserialize)] +pub struct TraitModifierConfig { + /// Trait name → modifier rules. Trait names are lowercase_snake_case. + #[serde(default)] + pub modifiers: BTreeMap, +} + +/// A single trait's filter and scoring rules. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct TraitModifier { + /// Stage 1: candidate pool filter (WHAT gets disclosed). + #[serde(default)] + pub stage1: Stage1Filter, + /// Stage 2: line pool scoring (HOW it's delivered). + #[serde(default)] + pub stage2: Stage2Scoring, +} + +/// Stage 1 filter predicates — modify the disclosure candidate pool. +/// +/// Applied per-fact during candidate selection in `DisclosureCandidates` +/// (#551). Multiple traits compose additively: if any trait includes a +/// candidate that would otherwise be excluded, it's included. +/// +/// Default values (all false/None) produce no modification to the +/// baseline filter, which requires KnowsOf minimum confidence and +/// includes all source types. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Stage1Filter { + /// Minimum confidence to enter the disclosure pool. + /// Parsed at load time: "suspects", "knows_of", "knows_details", "direct". + /// None = use system default (KnowsOf). + #[serde(default)] + pub min_confidence: Option, + /// If true, exclude facts with `ToldBy` source (won't pass on rumors). + /// Cautious trait behavior. + #[serde(default)] + pub exclude_told_by: bool, + /// If true, exclude facts linked to entities with trust_level >= Real + /// in NPC Relationships. Loyal trait behavior. + #[serde(default)] + pub exclude_high_trust_entities: bool, + /// If true, override the witness inhibition gate. Talkative trait behavior. + #[serde(default)] + pub override_witness_inhibition: bool, +} + +/// Stage 2 scoring — influence line selection weighting. +/// +/// Delivery tags in `IndexedDialogueLine.tags` are matched against +/// the NPC's trait-derived preferred tags. Lines with matching tags +/// receive a scoring bonus during Layer 4 selection. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Stage2Scoring { + /// Preferred delivery tags for line selection weighting. + /// Examples: `["cautious_delivery"]`, `["gossip_delivery", "casual_delivery"]`. + /// Lines with matching tags receive a scoring bonus. + #[serde(default)] + pub delivery_tags: Vec, +} + +// --------------------------------------------------------------------------- +// Filter predicate evaluation +// --------------------------------------------------------------------------- + +impl Stage1Filter { + /// Parse the min_confidence string into a `KnowledgeConfidence` value. + /// Returns `None` (use system default) for unparseable or absent values. + pub fn min_confidence_level(&self) -> Option { + self.min_confidence.as_deref().and_then(parse_confidence) + } + + /// Evaluate whether a fact passes this trait's Stage 1 filter. + /// + /// Returns `false` if the fact should be excluded by this trait. + /// The caller (#551) composes multiple trait filters: a fact is + /// included if it passes the composite filter. + pub fn allows_fact(&self, fact: &FactKnowledge) -> bool { + // Check minimum confidence + if let Some(min) = self.min_confidence_level() { + if fact.confidence < min { + return false; + } + } + + // Exclude ToldBy-source facts (Cautious behavior) + if self.exclude_told_by { + if matches!(fact.source, KnowledgeSource::ToldBy { .. }) { + return false; + } + } + + true + } +} + +impl Stage2Scoring { + /// Check if a line's tags contain any of this trait's preferred delivery tags. + /// Returns the number of matching tags (0 = no bonus). + pub fn tag_match_count(&self, line_tags: &[String]) -> usize { + self.delivery_tags + .iter() + .filter(|dt| line_tags.contains(dt)) + .count() + } + + /// Check if a line has at least one matching delivery tag. + pub fn has_matching_tag(&self, line_tags: &[String]) -> bool { + self.tag_match_count(line_tags) > 0 + } +} + +impl TraitModifierConfig { + /// Look up the modifier for a trait by name. + pub fn modifier_for(&self, trait_name: &str) -> Option<&TraitModifier> { + self.modifiers.get(trait_name) + } + + /// Collect all Stage 2 delivery tags for a set of trait names. + /// Returns a deduplicated, sorted list for deterministic matching. + pub fn delivery_tags_for(&self, trait_names: &[String]) -> Vec { + let mut tags: Vec = trait_names + .iter() + .filter_map(|name| self.modifiers.get(name.as_str())) + .flat_map(|m| m.stage2.delivery_tags.iter().cloned()) + .collect(); + tags.sort(); + tags.dedup(); + tags + } + + /// Check if any trait in the set overrides witness inhibition. + pub fn any_overrides_witness_inhibition(&self, trait_names: &[String]) -> bool { + trait_names.iter().any(|name| { + self.modifiers + .get(name.as_str()) + .is_some_and(|m| m.stage1.override_witness_inhibition) + }) + } + + /// Check if any trait in the set excludes high-trust entity facts. + pub fn any_excludes_high_trust(&self, trait_names: &[String]) -> bool { + trait_names.iter().any(|name| { + self.modifiers + .get(name.as_str()) + .is_some_and(|m| m.stage1.exclude_high_trust_entities) + }) + } + + /// Get the most permissive (lowest) min_confidence across all traits. + /// Returns None if no traits specify a minimum (use system default). + pub fn lowest_min_confidence(&self, trait_names: &[String]) -> Option { + trait_names + .iter() + .filter_map(|name| self.modifiers.get(name.as_str())) + .filter_map(|m| m.stage1.min_confidence_level()) + .min() + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Parse a confidence string from YAML config to enum value. +fn parse_confidence(s: &str) -> Option { + match s { + "suspects" => Some(KnowledgeConfidence::Suspects), + "knows_of" => Some(KnowledgeConfidence::KnowsOf), + "knows_details" => Some(KnowledgeConfidence::KnowsDetails), + "direct" => Some(KnowledgeConfidence::Direct), + _ => { + tracing::warn!("Unknown confidence level in trait config: {:?}", s); + None + } + } +} + +/// Convert a `PersonalityTrait` to its lowercase YAML key. +/// Used to look up trait modifiers from the config. +pub fn trait_to_key(trait_val: &super::PersonalityTrait) -> &'static str { + match trait_val { + super::PersonalityTrait::Cautious => "cautious", + super::PersonalityTrait::Bold => "bold", + super::PersonalityTrait::Honest => "honest", + super::PersonalityTrait::Deceptive => "deceptive", + super::PersonalityTrait::Compassionate => "compassionate", + super::PersonalityTrait::Ruthless => "ruthless", + super::PersonalityTrait::Curious => "curious", + super::PersonalityTrait::Incurious => "incurious", + super::PersonalityTrait::Social => "social", + super::PersonalityTrait::Reclusive => "reclusive", + } +} + +/// Convert an NPC's personality trait list to YAML config keys. +pub fn traits_to_keys(traits: &[super::PersonalityTrait]) -> Vec { + traits.iter().map(|t| trait_to_key(t).to_string()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_fact(confidence: KnowledgeConfidence, source: KnowledgeSource) -> FactKnowledge { + FactKnowledge { + confidence, + source, + state: crate::knowledge::types::KnowledgeState::Active, + acquired_tick: 100, + disclosure_blocked: false, + } + } + + fn cautious_config() -> TraitModifierConfig { + let yaml = r#" +modifiers: + cautious: + stage1: + min_confidence: "knows_details" + exclude_told_by: true + stage2: + delivery_tags: ["cautious_delivery"] + gossipy: + stage1: + min_confidence: "suspects" + stage2: + delivery_tags: ["gossip_delivery", "casual_delivery"] + loyal: + stage1: + exclude_high_trust_entities: true + stage2: + delivery_tags: ["professional_delivery"] + talkative: + stage1: + override_witness_inhibition: true + min_confidence: "suspects" + stage2: + delivery_tags: ["casual_delivery", "gossip_delivery"] +"#; + serde_yaml::from_str(yaml).expect("valid trait config YAML") + } + + #[test] + fn parse_config_from_yaml() { + let config = cautious_config(); + assert_eq!(config.modifiers.len(), 4); + assert!(config.modifiers.contains_key("cautious")); + assert!(config.modifiers.contains_key("gossipy")); + assert!(config.modifiers.contains_key("loyal")); + assert!(config.modifiers.contains_key("talkative")); + } + + #[test] + fn cautious_excludes_low_confidence() { + let config = cautious_config(); + let cautious = &config.modifiers["cautious"].stage1; + + let suspects_fact = make_fact( + KnowledgeConfidence::Suspects, + KnowledgeSource::DirectObservation { tick: 50 }, + ); + let details_fact = make_fact( + KnowledgeConfidence::KnowsDetails, + KnowledgeSource::DirectObservation { tick: 50 }, + ); + + assert!(!cautious.allows_fact(&suspects_fact), "Cautious excludes Suspects"); + assert!(cautious.allows_fact(&details_fact), "Cautious allows KnowsDetails"); + } + + #[test] + fn cautious_excludes_told_by() { + let config = cautious_config(); + let cautious = &config.modifiers["cautious"].stage1; + + let told_fact = make_fact( + KnowledgeConfidence::KnowsDetails, + KnowledgeSource::ToldBy { + source_id: crate::knowledge::types::StableId(42), + tick: 50, + }, + ); + assert!(!cautious.allows_fact(&told_fact), "Cautious excludes ToldBy"); + } + + #[test] + fn gossipy_includes_suspects() { + let config = cautious_config(); + let gossipy = &config.modifiers["gossipy"].stage1; + + let suspects_fact = make_fact( + KnowledgeConfidence::Suspects, + KnowledgeSource::DirectObservation { tick: 50 }, + ); + assert!(gossipy.allows_fact(&suspects_fact), "Gossipy includes Suspects"); + } + + #[test] + fn talkative_overrides_witness_inhibition() { + let config = cautious_config(); + let traits = vec!["talkative".to_string()]; + assert!(config.any_overrides_witness_inhibition(&traits)); + + let traits = vec!["cautious".to_string()]; + assert!(!config.any_overrides_witness_inhibition(&traits)); + } + + #[test] + fn loyal_excludes_high_trust() { + let config = cautious_config(); + let traits = vec!["loyal".to_string()]; + assert!(config.any_excludes_high_trust(&traits)); + + let traits = vec!["gossipy".to_string()]; + assert!(!config.any_excludes_high_trust(&traits)); + } + + #[test] + fn lowest_min_confidence_picks_most_permissive() { + let config = cautious_config(); + // Gossipy (suspects) + Cautious (knows_details) → suspects wins + let traits = vec!["gossipy".to_string(), "cautious".to_string()]; + assert_eq!( + config.lowest_min_confidence(&traits), + Some(KnowledgeConfidence::Suspects) + ); + } + + #[test] + fn delivery_tags_deduped_and_sorted() { + let config = cautious_config(); + // Gossipy + Talkative both have "casual_delivery" and "gossip_delivery" + let traits = vec!["gossipy".to_string(), "talkative".to_string()]; + let tags = config.delivery_tags_for(&traits); + assert_eq!(tags, vec!["casual_delivery", "gossip_delivery"]); + } + + #[test] + fn stage2_tag_matching() { + let config = cautious_config(); + let scoring = &config.modifiers["cautious"].stage2; + + let line_tags = vec!["cautious_delivery".to_string(), "observation".to_string()]; + assert!(scoring.has_matching_tag(&line_tags)); + assert_eq!(scoring.tag_match_count(&line_tags), 1); + + let no_match_tags = vec!["gossip_delivery".to_string()]; + assert!(!scoring.has_matching_tag(&no_match_tags)); + } + + #[test] + fn unknown_trait_returns_none() { + let config = cautious_config(); + assert!(config.modifier_for("unknown_trait").is_none()); + } + + #[test] + fn trait_to_key_roundtrip() { + use super::super::PersonalityTrait; + assert_eq!(trait_to_key(&PersonalityTrait::Cautious), "cautious"); + assert_eq!(trait_to_key(&PersonalityTrait::Bold), "bold"); + assert_eq!(trait_to_key(&PersonalityTrait::Social), "social"); + } + + #[test] + fn traits_to_keys_conversion() { + use super::super::PersonalityTrait; + let traits = vec![PersonalityTrait::Cautious, PersonalityTrait::Social]; + let keys = traits_to_keys(&traits); + assert_eq!(keys, vec!["cautious", "social"]); + } + + #[test] + fn empty_config_is_no_op() { + let config = TraitModifierConfig::default(); + let traits = vec!["cautious".to_string()]; + assert!(!config.any_overrides_witness_inhibition(&traits)); + assert!(!config.any_excludes_high_trust(&traits)); + assert_eq!(config.lowest_min_confidence(&traits), None); + assert!(config.delivery_tags_for(&traits).is_empty()); + } + + #[test] + fn default_filter_allows_everything() { + let filter = Stage1Filter::default(); + let fact = make_fact( + KnowledgeConfidence::Suspects, + KnowledgeSource::ToldBy { + source_id: crate::knowledge::types::StableId(1), + tick: 10, + }, + ); + assert!(filter.allows_fact(&fact), "Default filter allows all facts"); + } + + #[test] + fn parse_confidence_values() { + assert_eq!(parse_confidence("suspects"), Some(KnowledgeConfidence::Suspects)); + assert_eq!(parse_confidence("knows_of"), Some(KnowledgeConfidence::KnowsOf)); + assert_eq!(parse_confidence("knows_details"), Some(KnowledgeConfidence::KnowsDetails)); + assert_eq!(parse_confidence("direct"), Some(KnowledgeConfidence::Direct)); + assert_eq!(parse_confidence("invalid"), None); + } + + // --- Coverage gap closure tests --- + + #[test] + fn cautious_excludes_knows_of_below_threshold() { + // Cautious min_confidence is "knows_details". KnowsOf < KnowsDetails, + // so a KnowsOf fact must be excluded (not just Suspects). + let config = cautious_config(); + let cautious = &config.modifiers["cautious"].stage1; + + let knows_of_fact = make_fact( + KnowledgeConfidence::KnowsOf, + KnowledgeSource::DirectObservation { tick: 50 }, + ); + assert!( + !cautious.allows_fact(&knows_of_fact), + "Cautious should exclude KnowsOf (below knows_details threshold)" + ); + } + + #[test] + fn cautious_allows_direct_confidence() { + // Direct > KnowsDetails, so Direct passes cautious min_confidence. + let config = cautious_config(); + let cautious = &config.modifiers["cautious"].stage1; + + let direct_fact = make_fact( + KnowledgeConfidence::Direct, + KnowledgeSource::DirectObservation { tick: 50 }, + ); + assert!( + cautious.allows_fact(&direct_fact), + "Cautious should allow Direct confidence (above threshold)" + ); + } + + #[test] + fn gossipy_allows_all_confidence_levels() { + // Gossipy min_confidence is "suspects" — all confidence levels pass. + let config = cautious_config(); + let gossipy = &config.modifiers["gossipy"].stage1; + + for (confidence, label) in [ + (KnowledgeConfidence::Suspects, "Suspects"), + (KnowledgeConfidence::KnowsOf, "KnowsOf"), + (KnowledgeConfidence::KnowsDetails, "KnowsDetails"), + (KnowledgeConfidence::Direct, "Direct"), + ] { + let fact = make_fact(confidence, KnowledgeSource::DirectObservation { tick: 50 }); + assert!( + gossipy.allows_fact(&fact), + "Gossipy should allow {} confidence", + label + ); + } + } + + #[test] + fn all_personality_traits_map_to_unique_keys() { + use super::super::PersonalityTrait; + + let all_traits = vec![ + PersonalityTrait::Cautious, + PersonalityTrait::Bold, + PersonalityTrait::Honest, + PersonalityTrait::Deceptive, + PersonalityTrait::Compassionate, + PersonalityTrait::Ruthless, + PersonalityTrait::Curious, + PersonalityTrait::Incurious, + PersonalityTrait::Social, + PersonalityTrait::Reclusive, + ]; + + let keys: Vec<&str> = all_traits.iter().map(|t| trait_to_key(t)).collect(); + + // All 10 traits produce a non-empty key + for (trait_, key) in all_traits.iter().zip(keys.iter()) { + assert!(!key.is_empty(), "{:?} must map to a non-empty key", trait_); + } + + // All keys are unique (no two traits share a key) + let mut sorted = keys.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!( + sorted.len(), + all_traits.len(), + "All personality traits must map to distinct keys" + ); + } + + #[test] + fn stage2_multiple_tag_matches_counts_correctly() { + // When a line has two matching delivery tags, tag_match_count returns 2. + let config = cautious_config(); + // Talkative has: ["casual_delivery", "gossip_delivery"] + let talkative_scoring = &config.modifiers["talkative"].stage2; + + let line_tags = vec![ + "casual_delivery".to_string(), + "gossip_delivery".to_string(), + "unrelated_tag".to_string(), + ]; + assert_eq!( + talkative_scoring.tag_match_count(&line_tags), + 2, + "Both delivery tags should match" + ); + assert!(talkative_scoring.has_matching_tag(&line_tags)); + } + + #[test] + fn gossipy_does_not_exclude_told_by() { + // Gossipy has no exclude_told_by restriction — it should pass ToldBy facts. + let config = cautious_config(); + let gossipy = &config.modifiers["gossipy"].stage1; + + let told_fact = make_fact( + KnowledgeConfidence::Suspects, + KnowledgeSource::ToldBy { + source_id: crate::knowledge::types::StableId(5), + tick: 10, + }, + ); + assert!( + gossipy.allows_fact(&told_fact), + "Gossipy should not exclude ToldBy-source facts" + ); + } +} diff --git a/server/src/simulation/poi.rs b/server/src/simulation/poi.rs new file mode 100644 index 000000000..04eade838 --- /dev/null +++ b/server/src/simulation/poi.rs @@ -0,0 +1,191 @@ +//! Point of Interest data model (#148). +//! +//! POIs are discoverable world locations: quest-relevant places, hidden +//! areas, landmarks, vendors, etc. They integrate with the knowledge +//! graph via `FactId("poi.*")` namespace per D-079. +//! +//! Discovery system (#149) uses `KnowledgeEventType::KnowledgeGranted` +//! with `Fact` variant to grant POI facts to observers. + +use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::knowledge::types::FactId; +use crate::simulation::movement::TilePosition; + +/// Category of point of interest. Determines client-side icon and color. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum PoiCategory { + /// Named location (dock, bar, office, residential block). + Location, + /// Vendor or service provider (fixer, medic, data broker). + Service, + /// Quest-relevant target (drop point, meeting place, evidence site). + QuestTarget, + /// Hidden area (secret passage, concealed cache, restricted zone). + Hidden, + /// Navigation landmark visible from a distance. + Landmark, +} + +/// How a POI was placed in the world (content provenance). +/// +/// Distinct from visibility rules: discovery_source tracks *why* the POI +/// exists; visibility tracks *how* it can be found. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum PoiDiscoverySource { + /// Part of the map template — always present on this map. + MapTemplate, + /// Procedurally generated at world creation. + Procedural, + /// Created by a quest or storyline event at runtime. + QuestGenerated, + /// Revealed by NPC testimony via knowledge grant. + NpcRevealed, +} + +/// Rules governing when an observer can discover this POI. +/// +/// Discovery adds `FactId("poi.{poi_id}")` to the observer's knowledge +/// graph. The discovery system (#149) evaluates these rules each tick +/// for POIs not yet known to the observer. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum PoiVisibility { + /// Discoverable when within line of sight (standard LOS rules). + LineOfSight, + /// Discoverable only within a specific tile range (Manhattan distance). + Proximity { range: u32 }, + /// Not discoverable by observation. Requires a `KnowledgeGranted` + /// event from dialogue, evidence, or NPC testimony. + KnowledgeOnly, + /// Discoverable by LOS, but only if the observer already knows a + /// prerequisite fact. Example: a hidden door visible only if the + /// observer knows `"quest.secret_passage_hint"`. + RequiresFact { fact_id: String }, +} + +/// Point of Interest ECS component (#148). +/// +/// Attached to world entities that represent discoverable locations. +/// When an observer discovers a POI, `FactId("poi.{poi_id}")` is added +/// to their `KnowledgeGraph` via the discovery system (#149). +/// +/// BTreeMap ordering note: POI entities use `StableEntityId` like all +/// other entities. The `poi_id` string is for the fact namespace only. +#[derive(Component, Debug, Clone, Serialize, Deserialize)] +pub struct PointOfInterest { + /// Unique identifier within the `poi.*` fact namespace. + /// Format: `lowercase_snake_case`. Example: `"docking_bay_7"`. + /// Must be unique across all POIs in the world. + pub poi_id: String, + /// Display name shown to the player after discovery. + pub name: String, + /// World position of the POI (center tile). + pub position: TilePosition, + /// Category for client-side rendering (icon, minimap marker). + pub category: PoiCategory, + /// Content provenance — how this POI was placed in the world. + pub discovery_source: PoiDiscoverySource, + /// Rules for when/how an observer can discover this POI. + pub visibility: PoiVisibility, +} + +impl PointOfInterest { + /// Generate the `FactId` for this POI in the knowledge graph. + /// Format: `"poi.{poi_id}"` per D-079 namespace convention. + pub fn fact_id(&self) -> FactId { + FactId(format!("poi.{}", self.poi_id)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_poi(id: &str, category: PoiCategory, visibility: PoiVisibility) -> PointOfInterest { + PointOfInterest { + poi_id: id.to_string(), + name: format!("Test POI {}", id), + position: TilePosition::new(10, 20, 0), + category, + discovery_source: PoiDiscoverySource::MapTemplate, + visibility, + } + } + + #[test] + fn fact_id_uses_poi_namespace() { + let poi = make_poi("docking_bay_7", PoiCategory::Location, PoiVisibility::LineOfSight); + assert_eq!(poi.fact_id(), FactId("poi.docking_bay_7".to_string())); + } + + #[test] + fn fact_id_format_is_deterministic() { + let poi1 = make_poi("cargo_hold", PoiCategory::Hidden, PoiVisibility::KnowledgeOnly); + let poi2 = make_poi("cargo_hold", PoiCategory::Hidden, PoiVisibility::KnowledgeOnly); + assert_eq!(poi1.fact_id(), poi2.fact_id()); + } + + #[test] + fn different_poi_ids_produce_different_fact_ids() { + let poi1 = make_poi("bay_alpha", PoiCategory::Location, PoiVisibility::LineOfSight); + let poi2 = make_poi("bay_beta", PoiCategory::Location, PoiVisibility::LineOfSight); + assert_ne!(poi1.fact_id(), poi2.fact_id()); + } + + #[test] + fn proximity_visibility_stores_range() { + let poi = make_poi( + "hidden_cache", + PoiCategory::Hidden, + PoiVisibility::Proximity { range: 5 }, + ); + match poi.visibility { + PoiVisibility::Proximity { range } => assert_eq!(range, 5), + _ => panic!("Expected Proximity visibility"), + } + } + + #[test] + fn requires_fact_visibility_stores_fact_id() { + let poi = make_poi( + "secret_door", + PoiCategory::Hidden, + PoiVisibility::RequiresFact { + fact_id: "quest.secret_passage_hint".to_string(), + }, + ); + match &poi.visibility { + PoiVisibility::RequiresFact { fact_id } => { + assert_eq!(fact_id, "quest.secret_passage_hint"); + } + _ => panic!("Expected RequiresFact visibility"), + } + } + + #[test] + fn poi_categories_are_distinct() { + assert_ne!(PoiCategory::Location, PoiCategory::Service); + assert_ne!(PoiCategory::QuestTarget, PoiCategory::Hidden); + assert_ne!(PoiCategory::Hidden, PoiCategory::Landmark); + } + + #[test] + fn poi_discovery_sources_are_distinct() { + assert_ne!(PoiDiscoverySource::MapTemplate, PoiDiscoverySource::Procedural); + assert_ne!( + PoiDiscoverySource::QuestGenerated, + PoiDiscoverySource::NpcRevealed + ); + } + + #[test] + fn poi_serialization_roundtrip() { + let poi = make_poi("med_bay", PoiCategory::Service, PoiVisibility::LineOfSight); + let serialized = serde_yaml::to_string(&poi).expect("serialize"); + let deserialized: PointOfInterest = + serde_yaml::from_str(&serialized).expect("deserialize"); + assert_eq!(deserialized.poi_id, "med_bay"); + assert_eq!(deserialized.category, PoiCategory::Service); + } +} diff --git a/server/src/simulation/poi_discovery.rs b/server/src/simulation/poi_discovery.rs new file mode 100644 index 000000000..f784c4772 --- /dev/null +++ b/server/src/simulation/poi_discovery.rs @@ -0,0 +1,421 @@ +//! POI discovery system (#149). +//! +//! Detects when the player observer discovers a Point of Interest and +//! grants the corresponding `FactId("poi.*")` to their knowledge graph. +//! +//! Discovery methods handled here: +//! - Physical discovery (LOS, proximity) — checked each tick +//! +//! Discovery methods handled elsewhere: +//! - Character background — inserted at spawn time by content system +//! - NPC tips / research — via `KnowledgeGranted` event (#546) + +use bevy_ecs::prelude::*; + +use crate::knowledge::graph::KnowledgeGraph; +use crate::knowledge::types::{ + FactId, FactKnowledge, KnowledgeConfidence, KnowledgeSource, KnowledgeState, +}; +use crate::perception::query::VisibilityGeometry; +use crate::simulation::movement::{PlayerCharacter, TilePosition}; +use crate::simulation::poi::{PoiVisibility, PointOfInterest}; +use crate::simulation::time::SimulationTime; + +/// Event emitted when the player discovers a POI. +/// +/// Other systems (monologue, minimap update, storyteller) can react to +/// this event. Consumed and cleared each tick. +#[derive(Debug, Clone)] +pub struct PoiDiscoveredEvent { + /// The `poi_id` string of the discovered POI. + pub poi_id: String, + /// Display name for monologue/UI use. + pub name: String, + /// Tick when discovered. + pub tick: u64, +} + +/// Resource: queue of POI discovery events from the current tick. +#[derive(Resource, Default)] +pub struct PoiDiscoveryEventQueue { + events: Vec, +} + +impl PoiDiscoveryEventQueue { + pub fn push(&mut self, event: PoiDiscoveredEvent) { + self.events.push(event); + } + + pub fn drain(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } +} + +/// System: check for POI physical discovery by the player observer. +/// +/// Runs after visibility geometry is computed. For each undiscovered POI, +/// checks visibility rules against the observer's position and known facts. +/// Discovered POIs are added as `FactId("poi.*")` facts to the observer's +/// KnowledgeGraph with `DirectObservation` source. +pub fn discover_pois( + time: Res, + geometry: Res, + mut discovery_queue: ResMut, + poi_query: Query<&PointOfInterest>, + mut observer_query: Query<(&TilePosition, &mut KnowledgeGraph), With>, +) { + let Ok((observer_pos, mut kg)) = observer_query.single_mut() else { + return; + }; + + for poi in poi_query.iter() { + let fact_id = poi.fact_id(); + + // Skip already-known POIs + if kg.knows_fact(&fact_id) { + continue; + } + + if can_discover(observer_pos, &geometry, &kg, poi) { + kg.facts.insert( + fact_id, + FactKnowledge { + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::DirectObservation { tick: time.tick }, + state: KnowledgeState::Active, + acquired_tick: time.tick, + disclosure_blocked: false, + }, + ); + + discovery_queue.push(PoiDiscoveredEvent { + poi_id: poi.poi_id.clone(), + name: poi.name.clone(), + tick: time.tick, + }); + + tracing::info!( + poi_id = %poi.poi_id, + name = %poi.name, + tick = time.tick, + "Player discovered POI" + ); + } + } +} + +/// Evaluate whether an observer can discover a POI based on its visibility rules. +fn can_discover( + observer_pos: &TilePosition, + geometry: &VisibilityGeometry, + kg: &KnowledgeGraph, + poi: &PointOfInterest, +) -> bool { + match &poi.visibility { + PoiVisibility::LineOfSight => { + poi.position.z == geometry.observer_z + && geometry + .visible_positions + .contains(&(poi.position.x, poi.position.y)) + } + PoiVisibility::Proximity { range } => observer_pos + .manhattan_distance(&poi.position) + .is_some_and(|d| d <= *range), + PoiVisibility::KnowledgeOnly => { + // Not discoverable by physical observation. + // Requires KnowledgeGranted event from dialogue/evidence. + false + } + PoiVisibility::RequiresFact { fact_id } => { + // Must know the prerequisite fact AND see the POI in LOS. + kg.knows_fact(&FactId(fact_id.clone())) + && poi.position.z == geometry.observer_z + && geometry + .visible_positions + .contains(&(poi.position.x, poi.position.y)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::simulation::poi::{PoiCategory, PoiDiscoverySource}; + use std::collections::BTreeSet; + + fn make_poi( + id: &str, + position: TilePosition, + visibility: PoiVisibility, + ) -> PointOfInterest { + PointOfInterest { + poi_id: id.to_string(), + name: format!("Test {}", id), + position, + category: PoiCategory::Location, + discovery_source: PoiDiscoverySource::MapTemplate, + visibility, + } + } + + fn make_geometry(visible: &[(i32, i32)], z: i32) -> VisibilityGeometry { + VisibilityGeometry { + visible_tiles: vec![], + visible_positions: visible.iter().copied().collect::>(), + sector_lookup: Default::default(), + observer_z: z, + } + } + + // --- can_discover tests --- + + #[test] + fn los_poi_discovered_when_in_visible_positions() { + let observer_pos = TilePosition::new(5, 5, 0); + let poi = make_poi("bay", TilePosition::new(10, 5, 0), PoiVisibility::LineOfSight); + let geometry = make_geometry(&[(10, 5)], 0); + let kg = KnowledgeGraph::new(); + + assert!(can_discover(&observer_pos, &geometry, &kg, &poi)); + } + + #[test] + fn los_poi_not_discovered_when_not_visible() { + let observer_pos = TilePosition::new(5, 5, 0); + let poi = make_poi("bay", TilePosition::new(10, 5, 0), PoiVisibility::LineOfSight); + let geometry = make_geometry(&[(8, 5)], 0); // (10,5) not in visible set + let kg = KnowledgeGraph::new(); + + assert!(!can_discover(&observer_pos, &geometry, &kg, &poi)); + } + + #[test] + fn los_poi_not_discovered_on_different_z() { + let observer_pos = TilePosition::new(5, 5, 0); + let poi = make_poi("bay", TilePosition::new(10, 5, 1), PoiVisibility::LineOfSight); + let geometry = make_geometry(&[(10, 5)], 0); // observer on z=0, poi on z=1 + let kg = KnowledgeGraph::new(); + + assert!(!can_discover(&observer_pos, &geometry, &kg, &poi)); + } + + #[test] + fn proximity_poi_discovered_within_range() { + let observer_pos = TilePosition::new(5, 5, 0); + let poi = make_poi( + "cache", + TilePosition::new(7, 5, 0), + PoiVisibility::Proximity { range: 3 }, + ); + let geometry = make_geometry(&[], 0); + let kg = KnowledgeGraph::new(); + + // Manhattan distance = 2, range = 3 → discovered + assert!(can_discover(&observer_pos, &geometry, &kg, &poi)); + } + + #[test] + fn proximity_poi_not_discovered_outside_range() { + let observer_pos = TilePosition::new(5, 5, 0); + let poi = make_poi( + "cache", + TilePosition::new(10, 5, 0), + PoiVisibility::Proximity { range: 3 }, + ); + let geometry = make_geometry(&[], 0); + let kg = KnowledgeGraph::new(); + + // Manhattan distance = 5, range = 3 → not discovered + assert!(!can_discover(&observer_pos, &geometry, &kg, &poi)); + } + + #[test] + fn proximity_poi_not_discovered_different_z() { + let observer_pos = TilePosition::new(5, 5, 0); + let poi = make_poi( + "cache", + TilePosition::new(5, 6, 1), // different z + PoiVisibility::Proximity { range: 3 }, + ); + let geometry = make_geometry(&[], 0); + let kg = KnowledgeGraph::new(); + + // manhattan_distance returns None for different z + assert!(!can_discover(&observer_pos, &geometry, &kg, &poi)); + } + + #[test] + fn knowledge_only_never_discovered_physically() { + let observer_pos = TilePosition::new(5, 5, 0); + let poi = make_poi( + "secret", + TilePosition::new(5, 5, 0), // same tile + PoiVisibility::KnowledgeOnly, + ); + let geometry = make_geometry(&[(5, 5)], 0); + let kg = KnowledgeGraph::new(); + + assert!(!can_discover(&observer_pos, &geometry, &kg, &poi)); + } + + #[test] + fn requires_fact_discovered_when_fact_known_and_visible() { + let observer_pos = TilePosition::new(5, 5, 0); + let poi = make_poi( + "hidden_door", + TilePosition::new(8, 5, 0), + PoiVisibility::RequiresFact { + fact_id: "quest.secret_hint".to_string(), + }, + ); + let geometry = make_geometry(&[(8, 5)], 0); + let kg = KnowledgeGraph::with_background(vec![( + FactId("quest.secret_hint".to_string()), + FactKnowledge { + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::Background, + state: KnowledgeState::Active, + acquired_tick: 0, + disclosure_blocked: false, + }, + )]); + + assert!(can_discover(&observer_pos, &geometry, &kg, &poi)); + } + + #[test] + fn requires_fact_not_discovered_without_fact() { + let observer_pos = TilePosition::new(5, 5, 0); + let poi = make_poi( + "hidden_door", + TilePosition::new(8, 5, 0), + PoiVisibility::RequiresFact { + fact_id: "quest.secret_hint".to_string(), + }, + ); + let geometry = make_geometry(&[(8, 5)], 0); + let kg = KnowledgeGraph::new(); // no facts + + assert!(!can_discover(&observer_pos, &geometry, &kg, &poi)); + } + + #[test] + fn requires_fact_not_discovered_when_not_visible() { + let observer_pos = TilePosition::new(5, 5, 0); + let poi = make_poi( + "hidden_door", + TilePosition::new(8, 5, 0), + PoiVisibility::RequiresFact { + fact_id: "quest.secret_hint".to_string(), + }, + ); + let geometry = make_geometry(&[], 0); // not visible + let kg = KnowledgeGraph::with_background(vec![( + FactId("quest.secret_hint".to_string()), + FactKnowledge { + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::Background, + state: KnowledgeState::Active, + acquired_tick: 0, + disclosure_blocked: false, + }, + )]); + + assert!(!can_discover(&observer_pos, &geometry, &kg, &poi)); + } + + // --- System integration test --- + + #[test] + fn discover_pois_system_grants_fact() { + use bevy_ecs::world::World; + + let mut world = World::new(); + + // Resources + let mut time = SimulationTime::default(); + time.tick = 50; + world.insert_resource(time); + world.insert_resource(make_geometry(&[(10, 5)], 0)); + world.insert_resource(PoiDiscoveryEventQueue::default()); + + // Player observer + world.spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + KnowledgeGraph::new(), + )); + + // POI entity + world.spawn(make_poi( + "docking_bay", + TilePosition::new(10, 5, 0), + PoiVisibility::LineOfSight, + )); + + // Run system + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(discover_pois); + schedule.run(&mut world); + + // Verify: player now knows the POI fact + let mut query = world.query_filtered::<&KnowledgeGraph, With>(); + let kg = query.single(&world).expect("player should exist"); + let fact_id = FactId("poi.docking_bay".to_string()); + assert!(kg.knows_fact(&fact_id), "Player should know poi.docking_bay"); + assert_eq!( + kg.facts.get(&fact_id).unwrap().confidence, + KnowledgeConfidence::KnowsOf + ); + + // Verify: discovery event was emitted + let queue = world.resource::(); + assert_eq!(queue.events.len(), 1); + assert_eq!(queue.events[0].poi_id, "docking_bay"); + assert_eq!(queue.events[0].tick, 50); + } + + #[test] + fn discover_pois_system_skips_already_known() { + use bevy_ecs::world::World; + + let mut world = World::new(); + + let mut time = SimulationTime::default(); + time.tick = 100; + world.insert_resource(time); + world.insert_resource(make_geometry(&[(10, 5)], 0)); + world.insert_resource(PoiDiscoveryEventQueue::default()); + + // Player already knows this POI + let kg = KnowledgeGraph::with_background(vec![( + FactId("poi.docking_bay".to_string()), + FactKnowledge { + confidence: KnowledgeConfidence::KnowsOf, + source: KnowledgeSource::Background, + state: KnowledgeState::Active, + acquired_tick: 0, + disclosure_blocked: false, + }, + )]); + world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0), kg)); + + world.spawn(make_poi( + "docking_bay", + TilePosition::new(10, 5, 0), + PoiVisibility::LineOfSight, + )); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(discover_pois); + schedule.run(&mut world); + + // No new events — already known + let queue = world.resource::(); + assert!(queue.is_empty(), "No discovery event for already-known POI"); + } +}