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>
600 lines
20 KiB
Rust
600 lines
20 KiB
Rust
//! 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<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);
|
||
}
|
||
}
|