//! Character goal/pressure framework (#248). //! //! Defines systemic pressures on the player character that modulate monologue //! salience and observation priority. Not scripted arcs — emergent from //! interaction of existing D-024 axes. //! //! ## Three pressure axes //! //! - **Exposure**: rises when NPCs notice the player (#244 PlayerAwareness) //! - **Relationship**: rises when NPCs distrust the player (negative trust in //! `RelationshipGraph`) //! - **Institutional**: detective-specific pressure (stub in v0.1) //! //! ## Update frequency //! //! Runs every `PRESSURE_UPDATE_INTERVAL` ticks (1 game-minute) to avoid //! per-tick overhead of relationship graph scans. //! //! ## Output surface //! //! - `CharacterPressureWire` in `ObserverSnapshot` for client HUD //! - `pressure_mood()` helper for monologue salience weighting — high pressure //! biases toward anxiety-tagged lines (D-035 mood tags) //! //! All arithmetic is integer-only (D-010 determinism). use bevy_ecs::prelude::*; use serde::{Deserialize, Serialize}; use crate::npc::awareness::PlayerAwareness; use crate::npc::Npc; use crate::simulation::movement::PlayerCharacter; use crate::simulation::tier::ActiveSim; use crate::simulation::time::SimulationTime; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /// Ticks between pressure recalculations. 10 ticks = 1 game-minute (D-031). pub const PRESSURE_UPDATE_INTERVAL: u64 = 10; /// Exposure pressure per suspicious NPC. Scaled so ~5 suspicious NPCs ≈ 50 pressure. pub const EXPOSURE_PER_SUSPICIOUS_NPC: i32 = 10; /// Relationship pressure per hostile edge (trust ≤ -3). Scaled so ~4 hostile NPCs ≈ 60 pressure. pub const RELATIONSHIP_PER_HOSTILE_EDGE: i32 = 15; /// Pressure threshold above which monologue mood shifts to "anxious". pub const MOOD_ANXIOUS_THRESHOLD: i32 = 50; /// Pressure threshold above which monologue mood shifts to "frustrated". /// Below anxious threshold but above this → frustrated. pub const MOOD_FRUSTRATED_THRESHOLD: i32 = 30; /// Trust value at or below which a relationship counts as "hostile" for /// relationship pressure. pub const HOSTILE_TRUST_THRESHOLD: i8 = -3; // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- /// Systemic pressure on the player character (#248). /// /// Attached to the player entity. Updated every game-minute by /// `update_character_pressure`. Feeds into monologue salience weighting /// and ObserverSnapshot HUD data. /// /// All values are 0–100, integer for D-010 determinism. #[derive(Component, Debug, Clone, Default, Serialize, Deserialize)] pub struct CharacterPressure { /// Exposure pressure: rises when NPCs notice the player watching them. /// Derived from aggregate `PlayerAwareness.suspicion_level` across Active NPCs. pub exposure: i32, /// Institutional pressure: detective-specific systemic pressure. /// Stub in v0.1 — future sprints wire this to detective interaction patterns. pub institutional: i32, /// Relationship pressure: rises when NPCs distrust the player. /// Derived from negative trust edges in the `RelationshipGraph`. pub relationship: i32, } impl CharacterPressure { /// Total pressure as a simple average of all axes (0–100). /// /// Uses integer division — remainders are floor-truncated (D-010, no floats). /// Maximum rounding error is 2 units (e.g. axis sum 101 → 33 instead of 33.67). pub fn total(&self) -> i32 { // Simple average, clamped. Integer division truncates toward zero (D-010). ((self.exposure + self.institutional + self.relationship) / 3).clamp(0, 100) } /// Dominant mood tag for monologue salience weighting. /// /// Returns the D-035 mood tag that should be preferred when selecting /// monologue lines. `None` when pressure is low — baseline monologue /// selection applies. pub fn pressure_mood(&self) -> Option<&'static str> { let total = self.total(); if total >= MOOD_ANXIOUS_THRESHOLD { Some("anxious") } else if total >= MOOD_FRUSTRATED_THRESHOLD { Some("frustrated") } else { None } } } // --------------------------------------------------------------------------- // Wire type for ObserverSnapshot // --------------------------------------------------------------------------- /// Character pressure data for client HUD display (#248). /// /// Included in `ObserverSnapshot` when pressure is non-zero. /// Client renders as a tension/pressure indicator widget. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CharacterPressureWire { /// Exposure pressure (0–100). pub exposure: i32, /// Institutional pressure (0–100). pub institutional: i32, /// Relationship pressure (0–100). pub relationship: i32, /// Total pressure (0–100). pub total: i32, /// Dominant mood tag, if any. pub mood: Option, } impl From<&CharacterPressure> for CharacterPressureWire { fn from(p: &CharacterPressure) -> Self { Self { exposure: p.exposure, institutional: p.institutional, relationship: p.relationship, total: p.total(), mood: p.pressure_mood().map(String::from), } } } // --------------------------------------------------------------------------- // System // --------------------------------------------------------------------------- /// Update character pressure from NPC awareness and relationship state. /// /// Runs every `PRESSURE_UPDATE_INTERVAL` ticks (1 game-minute). /// /// - **Exposure**: count Active NPCs with `suspicion_level > 0`, scale by /// `EXPOSURE_PER_SUSPICIOUS_NPC`, cap at 100 /// - **Relationship**: count hostile trust edges (trust ≤ -3) toward the /// player in `RelationshipGraph`, scale by `RELATIONSHIP_PER_HOSTILE_EDGE` /// - **Institutional**: stub (0) in v0.1 pub fn update_character_pressure( time: Res, awareness_query: Query<&PlayerAwareness, (With, With)>, relationship_graph: Res, registry: Res, mut player_query: Query<(Entity, &mut CharacterPressure), With>, ) { // Only run on interval ticks if time.tick % PRESSURE_UPDATE_INTERVAL != 0 { return; } let Ok((player_entity, mut pressure)) = player_query.single_mut() else { return; }; // --- Exposure pressure --- let suspicious_count = awareness_query .iter() .filter(|a| a.suspicion_level > 0) .count() as i32; pressure.exposure = (suspicious_count * EXPOSURE_PER_SUSPICIOUS_NPC).min(100); // --- Relationship pressure --- let player_stable = registry.to_stable(player_entity); if let Some(player_sid) = player_stable { // O(N) over all relationship edges — called once per game-minute, not every tick. // Acceptable at v0.1 NPC counts (<100 NPCs = <100 edge iterations). let hostile_edges = relationship_graph .who_knows_full_scan(&player_sid) .iter() .filter(|(_, edge)| edge.trust <= HOSTILE_TRUST_THRESHOLD) .count() as i32; pressure.relationship = (hostile_edges * RELATIONSHIP_PER_HOSTILE_EDGE).min(100); } // --- Institutional pressure --- // Stub: v0.1 has no detective-specific interaction patterns yet. // Future sprints wire this to game-time progression, investigation progress, // and institutional NPC interactions. // pressure.institutional stays at whatever it was (default 0). } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::knowledge::EntityRegistry; use crate::npc::awareness::PlayerAwareness; use crate::npc::relationships::{RelationshipEdge, RelationshipGraph}; use crate::npc::{Npc, RelationshipKind}; use crate::simulation::movement::PlayerCharacter; use crate::simulation::time::SimulationTime; use crate::simulation::tier::ActiveSim; use bevy_ecs::world::World; fn setup_world() -> World { let mut world = World::new(); world.init_resource::(); world.init_resource::(); world.init_resource::(); world } fn run_system(world: &mut World) { let mut schedule = bevy_ecs::schedule::Schedule::default(); schedule.add_systems(update_character_pressure); schedule.run(world); } // ----------------------------------------------------------------------- // Update interval gating // ----------------------------------------------------------------------- #[test] fn skips_non_interval_ticks() { let mut world = setup_world(); world.resource_mut::().tick = 13; // not on interval world.spawn(( Npc, ActiveSim, PlayerAwareness { suspicion_level: 50, ..Default::default() }, )); world.spawn((PlayerCharacter, CharacterPressure::default())); run_system(&mut world); let mut q = world.query::<&CharacterPressure>(); let pressure = q.single(&world).unwrap(); assert_eq!(pressure.exposure, 0, "should not update on non-interval tick"); } #[test] fn runs_on_interval_tick() { let mut world = setup_world(); world.resource_mut::().tick = 10; // on interval world.spawn(( Npc, ActiveSim, PlayerAwareness { suspicion_level: 50, ..Default::default() }, )); world.spawn((PlayerCharacter, CharacterPressure::default())); run_system(&mut world); let mut q = world.query::<&CharacterPressure>(); let pressure = q.single(&world).unwrap(); assert_eq!( pressure.exposure, EXPOSURE_PER_SUSPICIOUS_NPC, "should update on interval tick" ); } // ----------------------------------------------------------------------- // Exposure pressure // ----------------------------------------------------------------------- #[test] fn exposure_from_suspicious_npcs() { let mut world = setup_world(); world.resource_mut::().tick = 10; // 3 suspicious NPCs for _ in 0..3 { world.spawn(( Npc, ActiveSim, PlayerAwareness { suspicion_level: 10, ..Default::default() }, )); } // 2 non-suspicious NPCs for _ in 0..2 { world.spawn(( Npc, ActiveSim, PlayerAwareness::default(), )); } world.spawn((PlayerCharacter, CharacterPressure::default())); run_system(&mut world); let mut q = world.query::<&CharacterPressure>(); let pressure = q.single(&world).unwrap(); assert_eq!( pressure.exposure, 3 * EXPOSURE_PER_SUSPICIOUS_NPC, "3 suspicious NPCs × {} per NPC", EXPOSURE_PER_SUSPICIOUS_NPC ); } #[test] fn exposure_caps_at_100() { let mut world = setup_world(); world.resource_mut::().tick = 10; // 20 suspicious NPCs — would be 200, should cap at 100 for _ in 0..20 { world.spawn(( Npc, ActiveSim, PlayerAwareness { suspicion_level: 50, ..Default::default() }, )); } world.spawn((PlayerCharacter, CharacterPressure::default())); run_system(&mut world); let mut q = world.query::<&CharacterPressure>(); let pressure = q.single(&world).unwrap(); assert_eq!(pressure.exposure, 100, "exposure should cap at 100"); } #[test] fn no_suspicious_npcs_zero_exposure() { let mut world = setup_world(); world.resource_mut::().tick = 10; world.spawn((Npc, ActiveSim, PlayerAwareness::default())); world.spawn((PlayerCharacter, CharacterPressure::default())); run_system(&mut world); let mut q = world.query::<&CharacterPressure>(); let pressure = q.single(&world).unwrap(); assert_eq!(pressure.exposure, 0); } // ----------------------------------------------------------------------- // Relationship pressure // ----------------------------------------------------------------------- #[test] fn relationship_pressure_from_hostile_edges() { let mut world = setup_world(); world.resource_mut::().tick = 10; let mut registry = EntityRegistry::new(0); let player = world .spawn((PlayerCharacter, CharacterPressure::default())) .id(); let player_sid = registry.register(player); // Two NPCs with hostile trust toward the player let npc1 = world.spawn(Npc).id(); let npc1_sid = registry.register(npc1); let npc2 = world.spawn(Npc).id(); let npc2_sid = registry.register(npc2); let mut graph = RelationshipGraph::new(); graph.set_relationship( npc1_sid, player_sid, RelationshipEdge { kind: RelationshipKind::Colleague, trust: -5, // hostile history: vec![], last_interaction_tick: 0, }, ); graph.set_relationship( npc2_sid, player_sid, RelationshipEdge { kind: RelationshipKind::Colleague, trust: -4, // hostile history: vec![], last_interaction_tick: 0, }, ); world.insert_resource(registry); world.insert_resource(graph); run_system(&mut world); let mut q = world.query::<&CharacterPressure>(); let pressure = q.single(&world).unwrap(); assert_eq!( pressure.relationship, 2 * RELATIONSHIP_PER_HOSTILE_EDGE, "2 hostile edges × {} per edge", RELATIONSHIP_PER_HOSTILE_EDGE ); } #[test] fn neutral_trust_no_relationship_pressure() { let mut world = setup_world(); world.resource_mut::().tick = 10; let mut registry = EntityRegistry::new(0); let player = world .spawn((PlayerCharacter, CharacterPressure::default())) .id(); let player_sid = registry.register(player); let npc = world.spawn(Npc).id(); let npc_sid = registry.register(npc); let mut graph = RelationshipGraph::new(); graph.set_relationship( npc_sid, player_sid, RelationshipEdge { kind: RelationshipKind::Colleague, trust: 0, // neutral history: vec![], last_interaction_tick: 0, }, ); world.insert_resource(registry); world.insert_resource(graph); run_system(&mut world); let mut q = world.query::<&CharacterPressure>(); let pressure = q.single(&world).unwrap(); assert_eq!(pressure.relationship, 0); } #[test] fn trust_at_boundary_not_hostile() { let mut world = setup_world(); world.resource_mut::().tick = 10; let mut registry = EntityRegistry::new(0); let player = world .spawn((PlayerCharacter, CharacterPressure::default())) .id(); let player_sid = registry.register(player); let npc = world.spawn(Npc).id(); let npc_sid = registry.register(npc); let mut graph = RelationshipGraph::new(); graph.set_relationship( npc_sid, player_sid, RelationshipEdge { kind: RelationshipKind::Colleague, trust: -2, // above hostile threshold (-3) history: vec![], last_interaction_tick: 0, }, ); world.insert_resource(registry); world.insert_resource(graph); run_system(&mut world); let mut q = world.query::<&CharacterPressure>(); let pressure = q.single(&world).unwrap(); assert_eq!( pressure.relationship, 0, "trust -2 should not count as hostile (threshold is -3)" ); } // ----------------------------------------------------------------------- // Total + mood // ----------------------------------------------------------------------- #[test] fn total_is_average_clamped() { let p = CharacterPressure { exposure: 60, institutional: 30, relationship: 45, }; // (60 + 30 + 45) / 3 = 45 assert_eq!(p.total(), 45); } #[test] fn total_does_not_go_below_zero() { let p = CharacterPressure { exposure: 0, institutional: 0, relationship: 0, }; assert_eq!(p.total(), 0); } #[test] fn pressure_mood_anxious() { let p = CharacterPressure { exposure: 100, institutional: 100, relationship: 100, }; // total = 100, >= 50 → anxious assert_eq!(p.pressure_mood(), Some("anxious")); } #[test] fn pressure_mood_frustrated() { let p = CharacterPressure { exposure: 50, institutional: 50, relationship: 20, }; // total = (50+50+20)/3 = 40, >= 30 but < 50 → frustrated assert_eq!(p.pressure_mood(), Some("frustrated")); } #[test] fn pressure_mood_none_when_low() { let p = CharacterPressure { exposure: 10, institutional: 0, relationship: 10, }; // total = (10+0+10)/3 = 6, < 30 → None assert_eq!(p.pressure_mood(), None); } // ----------------------------------------------------------------------- // Wire roundtrip // ----------------------------------------------------------------------- #[test] fn wire_roundtrip() { let p = CharacterPressure { exposure: 30, institutional: 10, relationship: 50, }; let wire = CharacterPressureWire::from(&p); let json = serde_json::to_string(&wire).expect("should serialize"); let decoded: CharacterPressureWire = serde_json::from_str(&json).expect("should deserialize"); assert_eq!(decoded.exposure, 30); assert_eq!(decoded.institutional, 10); assert_eq!(decoded.relationship, 50); assert_eq!(decoded.total, p.total()); assert_eq!(decoded.mood, p.pressure_mood().map(String::from)); } // ----------------------------------------------------------------------- // No player entity — no panic // ----------------------------------------------------------------------- #[test] fn no_player_no_panic() { let mut world = setup_world(); world.resource_mut::().tick = 10; run_system(&mut world); // should not panic } // ----------------------------------------------------------------------- // Constant value assertions (#248 spec compliance) // ----------------------------------------------------------------------- #[test] fn pressure_constants_have_expected_values() { assert_eq!( PRESSURE_UPDATE_INTERVAL, 10, "#248: pressure updates every 10 ticks (1 game-minute, D-031)" ); assert_eq!(EXPOSURE_PER_SUSPICIOUS_NPC, 10); assert_eq!(RELATIONSHIP_PER_HOSTILE_EDGE, 15); assert_eq!(MOOD_ANXIOUS_THRESHOLD, 50); assert_eq!(MOOD_FRUSTRATED_THRESHOLD, 30); assert_eq!(HOSTILE_TRUST_THRESHOLD, -3); } }