Files
settled-reach/server/src/simulation/pressure.rs
T
jpmschweitzerandClaude Opus 4.6 aa79dd97e7 fix(simulation): Clippy cleanup and CI enforcement (#635)
Fix all Clippy warnings across the server codebase (2411 insertions, 1341
deletions). Raise type-complexity-threshold to 750 and too-many-arguments
to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server
now passes `cargo clippy -- --deny warnings` cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:33:15 +01:00

600 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 0100, 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 (0100).
///
/// 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 (0100).
pub exposure: i32,
/// Institutional pressure (0100).
pub institutional: i32,
/// Relationship pressure (0100).
pub relationship: i32,
/// Total pressure (0100).
pub total: i32,
/// Dominant mood tag, if any.
pub mood: Option<String>,
}
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<SimulationTime>,
awareness_query: Query<&PlayerAwareness, (With<Npc>, With<ActiveSim>)>,
relationship_graph: Res<crate::npc::relationships::RelationshipGraph>,
registry: Res<crate::knowledge::EntityRegistry>,
mut player_query: Query<(Entity, &mut CharacterPressure), With<PlayerCharacter>>,
) {
// Only run on interval ticks
if !time.tick.is_multiple_of(PRESSURE_UPDATE_INTERVAL) {
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::tier::ActiveSim;
use crate::simulation::time::SimulationTime;
use bevy_ecs::world::World;
fn setup_world() -> World {
let mut world = World::new();
world.init_resource::<SimulationTime>();
world.init_resource::<RelationshipGraph>();
world.init_resource::<EntityRegistry>();
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::<SimulationTime>().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::<SimulationTime>().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::<SimulationTime>().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::<SimulationTime>().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::<SimulationTime>().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::<SimulationTime>().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::<SimulationTime>().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::<SimulationTime>().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::<SimulationTime>().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);
}
}