Merge remote-tracking branch 'origin/server'

# Conflicts:
#	CHANGELOG.md
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack
#	client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack
#	client/tests/fixtures/msgpack/snapshot_empty.msgpack
#	client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack
#	client/tests/fixtures/msgpack/snapshot_one_npc.msgpack
#	client/tests/fixtures/msgpack/snapshot_player.msgpack
#	client/tests/fixtures/msgpack/snapshot_v2_full.msgpack
#	server/src/bridge/types.rs
This commit is contained in:
2026-02-25 10:09:27 +01:00
34 changed files with 4546 additions and 8 deletions
+4
View File
@@ -306,6 +306,8 @@ mod tests {
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
examine_result: None,
character_pressure: None,
sound_events: vec![],
rng_seed: None,
poi_list: vec![],
@@ -439,6 +441,8 @@ mod tests {
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
examine_result: None,
character_pressure: None,
sound_events: vec![],
rng_seed: None,
poi_list: vec![],
+10 -1
View File
@@ -37,7 +37,7 @@ pub const PROTOCOL_VERSION: u8 = 14;
/// v13 adds: tell_state on VisibleEntity (#90, D-024 tell system — for future client use),
/// follow_state (#241, follow mechanic HUD state).
/// v14 adds: poi_list (#151, discovered POIs for minimap rendering),
/// examine_result (#174, character-filtered observation text from Examine verb),
/// examine_result (#242, character-filtered examine observation text),
/// player_knowledge (#264, partial KG dump for journal/knowledge panel).
/// Future fields: ambient sound events, HUD state (D-020 expansion).
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -110,6 +110,15 @@ pub struct ObserverSnapshot {
/// Client shows follow indicator with distance, LOS, and tension.
#[serde(default)]
pub follow_state: Option<crate::simulation::follow::FollowStateWire>,
/// Examine result from Examine verb interaction (#242).
/// Present when the player examined an NPC or object this tick.
/// Client displays character-filtered detail text in an observation panel.
#[serde(default)]
pub examine_result: Option<crate::simulation::examine::ExamineResultEvent>,
/// Character pressure state for client HUD widget (#248).
/// Present when pressure is non-zero. Client renders tension indicator.
#[serde(default)]
pub character_pressure: Option<crate::simulation::pressure::CharacterPressureWire>,
/// RNG seed active at this tick for deterministic replay (#527).
/// The WRONG button writes this to seed.txt so replays reproduce observed bugs.
/// None when the RNG resource is unavailable (should not occur in practice).
+51
View File
@@ -205,6 +205,14 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR
// TODO: CombatCapability — no content schema type exists yet. When combat content
// is authored, add weapon_proficiency + combat_style mapping here.
// Vision + awareness components (#115, #244) — must match generate_npc().
// Without these, vision/awareness systems silently skip content-spawned NPCs.
entity_commands.insert((
npc::vision::NpcVisionState::default(),
npc::vision::NpcMemory::default(),
npc::awareness::PlayerAwareness::default(),
));
let entity = entity_commands.id();
// Register in EntityRegistry for StableId mapping
@@ -1100,6 +1108,49 @@ mod tests {
assert_eq!(edge.trust, 7);
}
#[test]
#[test]
fn spawn_npc_combat_trained_sets_skill_flag() {
// #91: combat_trained: true in YAML sets SkillSet.combat_trained = true.
// Known gap: CombatCapability is NOT yet attached for content-spawned NPCs
// (see TODO in spawn.rs near "Supporting axis 3: Skills"). The procedural
// path (generate.rs) correctly attaches CombatCapability. This test documents
// current behavior so the gap is visible in CI.
let mut world = create_test_world();
let mut profile = create_test_profile();
profile.skills = Some(NpcSkills {
combat_trained: Some(true),
skills: Some({
let mut m = std::collections::BTreeMap::new();
m.insert("combat".to_string(), 7);
m
}),
});
let mut result = SpawnResult::default();
spawn_npc(&mut world, &profile, &mut result);
let entity = world
.resource::<EntityRegistry>()
.to_entity(&result.npc_ids["test-npc"])
.unwrap();
// SkillSet.combat_trained is correctly set from YAML (#91 — done)
let skills = world.get::<npc::SkillSet>(entity).unwrap();
assert!(
skills.combat_trained,
"SkillSet.combat_trained should be true when YAML sets combat_trained: true"
);
// Known gap: CombatCapability not yet attached in content-spawn path.
// The procedural path (generate.rs) does attach it — content path has TODO.
// Update this assertion when the TODO is resolved.
assert!(
world.get::<npc::CombatCapability>(entity).is_none(),
"CombatCapability not yet attached in content-spawn path (known gap — see spawn.rs TODO)"
);
}
#[test]
fn resolve_relationships_skips_unknown_targets() {
let mut world = create_test_world();
+605
View File
@@ -0,0 +1,605 @@
//! NPC player-awareness system (#244).
//!
//! Tracks how aware an NPC is of the player's attention. When the player
//! lingers in an NPC's field of view, the NPC's suspicion accumulates and
//! feeds stress into `ToleranceThreshold` (D-024 axis 4).
//!
//! Reads `NpcVisionState.player_visible` from the NPC vision system (#115).
//! Separate from the follow mechanic (#241): follow tracks player-initiated
//! proximity, awareness tracks NPC-perceived attention.
//!
//! ## System ordering
//!
//! `detect_player_awareness` runs:
//! - after `vision::compute_npc_vision` (needs player_visible)
//! - before `tolerance::check_tolerance_threshold` (feeds stress)
//!
//! ## Suspicion lifecycle
//!
//! 1. Player enters NPC's LOS → `consecutive_los_ticks` starts counting
//! 2. After `AWARENESS_NOTICE_TICKS` consecutive ticks → suspicion starts building
//! 3. Each tick past threshold: `suspicion_level` increases, stress added to tolerance
//! 4. Player leaves LOS → `consecutive_los_ticks` resets, suspicion decays slowly
//!
//! All arithmetic is integer-only (D-010 determinism).
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::npc::vision::NpcVisionState;
use crate::npc::{Npc, ToleranceThreshold};
use crate::simulation::time::SimulationTime;
use crate::simulation::tier::ActiveSim;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Consecutive ticks the player must remain in an NPC's LOS before suspicion
/// starts building. 30 ticks = 3 game-minutes (D-031: 10 ticks = 1 game-minute).
pub const AWARENESS_NOTICE_TICKS: u64 = 30;
/// Stress increment per tick applied to `ToleranceThreshold` once the NPC
/// notices sustained player attention. Lighter than follow stress (2/tick)
/// because watching is less intrusive than tailing.
pub const AWARENESS_STRESS_PER_TICK: i16 = 1;
/// Ticks between suspicion decay checks when player is NOT in LOS.
/// 10 ticks = 1 game-minute (D-031).
pub const AWARENESS_DECAY_INTERVAL: u64 = 10;
/// Suspicion decay amount per interval when player is not in LOS.
pub const AWARENESS_DECAY_AMOUNT: i16 = 1;
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/// Tracks an NPC's awareness of sustained player attention (#244).
///
/// Updated each tick by `detect_player_awareness` for Active-tier NPCs.
/// Feeds stress into `ToleranceThreshold` when `suspicion_level` rises.
///
/// ## Fields
/// - `player_in_los` — mirrors `NpcVisionState.player_visible` for downstream queries
/// - `consecutive_los_ticks` — resets when player leaves LOS
/// - `suspicion_level` — 0100, accumulates while player watches, decays when they leave
#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)]
pub struct PlayerAwareness {
/// Whether the player is currently in this NPC's line of sight.
pub player_in_los: bool,
/// Consecutive ticks the player has been in this NPC's LOS.
pub consecutive_los_ticks: u64,
/// Accumulated suspicion level (0100, integer for D-010).
pub suspicion_level: i16,
}
// ---------------------------------------------------------------------------
// System
// ---------------------------------------------------------------------------
/// Detect sustained player attention and build NPC suspicion.
///
/// For each Active-tier NPC with `PlayerAwareness`:
/// - Sync `player_in_los` from `NpcVisionState.player_visible`
/// - If player visible: increment `consecutive_los_ticks`; once past
/// `AWARENESS_NOTICE_TICKS`, increase `suspicion_level` and apply
/// stress to `ToleranceThreshold`
/// - If player NOT visible: reset `consecutive_los_ticks`, decay
/// `suspicion_level` periodically
pub fn detect_player_awareness(
time: Res<SimulationTime>,
mut npc_query: Query<
(
&NpcVisionState,
&mut PlayerAwareness,
&mut ToleranceThreshold,
),
(With<Npc>, With<ActiveSim>),
>,
) {
for (vision, mut awareness, mut tolerance) in npc_query.iter_mut() {
awareness.player_in_los = vision.player_visible;
if vision.player_visible {
awareness.consecutive_los_ticks += 1;
// Once the NPC has noticed sustained player attention, build suspicion
if awareness.consecutive_los_ticks >= AWARENESS_NOTICE_TICKS {
awareness.suspicion_level =
(awareness.suspicion_level + AWARENESS_STRESS_PER_TICK).min(100);
tolerance.current_stress = tolerance
.current_stress
.saturating_add(AWARENESS_STRESS_PER_TICK);
}
} else {
// Player left LOS — reset consecutive counter
awareness.consecutive_los_ticks = 0;
// Decay suspicion slowly when player is not visible
if time.tick % AWARENESS_DECAY_INTERVAL == 0 && awareness.suspicion_level > 0 {
awareness.suspicion_level =
(awareness.suspicion_level - AWARENESS_DECAY_AMOUNT).max(0);
}
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::npc::vision::NpcVisionState;
use crate::npc::{Npc, ToleranceThreshold};
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::<SimulationTime>();
world
}
fn run_system(world: &mut World) {
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(detect_player_awareness);
schedule.run(world);
}
fn default_tolerance() -> ToleranceThreshold {
ToleranceThreshold {
current_stress: 0,
threshold: 80,
}
}
fn vision_with_player(visible: bool) -> NpcVisionState {
NpcVisionState {
player_visible: visible,
..Default::default()
}
}
// -----------------------------------------------------------------------
// Basic LOS tracking
// -----------------------------------------------------------------------
#[test]
fn player_in_los_increments_consecutive_ticks() {
let mut world = setup_world();
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(true),
PlayerAwareness::default(),
default_tolerance(),
))
.id();
run_system(&mut world);
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
assert!(awareness.player_in_los);
assert_eq!(awareness.consecutive_los_ticks, 1);
}
#[test]
fn player_leaving_los_resets_consecutive_ticks() {
let mut world = setup_world();
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(false),
PlayerAwareness {
player_in_los: true,
consecutive_los_ticks: 20,
suspicion_level: 5,
},
default_tolerance(),
))
.id();
run_system(&mut world);
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
assert!(!awareness.player_in_los);
assert_eq!(awareness.consecutive_los_ticks, 0);
}
#[test]
fn player_in_los_syncs_from_vision_state() {
let mut world = setup_world();
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(true),
PlayerAwareness {
player_in_los: false, // was false
consecutive_los_ticks: 0,
suspicion_level: 0,
},
default_tolerance(),
))
.id();
run_system(&mut world);
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
assert!(awareness.player_in_los, "should sync from NpcVisionState");
}
// -----------------------------------------------------------------------
// Suspicion threshold — no stress before notice ticks
// -----------------------------------------------------------------------
#[test]
fn no_stress_before_notice_threshold() {
let mut world = setup_world();
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(true),
PlayerAwareness {
player_in_los: true,
consecutive_los_ticks: AWARENESS_NOTICE_TICKS - 2, // not yet at threshold
suspicion_level: 0,
},
default_tolerance(),
))
.id();
run_system(&mut world);
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
assert_eq!(
awareness.suspicion_level, 0,
"suspicion should not build before notice threshold"
);
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
assert_eq!(
tolerance.current_stress, 0,
"stress should not increase before notice threshold"
);
}
#[test]
fn stress_starts_at_notice_threshold() {
let mut world = setup_world();
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(true),
PlayerAwareness {
player_in_los: true,
consecutive_los_ticks: AWARENESS_NOTICE_TICKS - 1, // will reach threshold
suspicion_level: 0,
},
default_tolerance(),
))
.id();
run_system(&mut world);
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
assert_eq!(
awareness.consecutive_los_ticks, AWARENESS_NOTICE_TICKS,
"consecutive ticks should reach threshold"
);
assert_eq!(
awareness.suspicion_level, AWARENESS_STRESS_PER_TICK,
"suspicion should increase at threshold"
);
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
assert_eq!(
tolerance.current_stress, AWARENESS_STRESS_PER_TICK,
"stress should increase at threshold"
);
}
// -----------------------------------------------------------------------
// Suspicion accumulation
// -----------------------------------------------------------------------
#[test]
fn suspicion_accumulates_past_threshold() {
let mut world = setup_world();
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(true),
PlayerAwareness {
player_in_los: true,
consecutive_los_ticks: AWARENESS_NOTICE_TICKS + 5,
suspicion_level: 10,
},
ToleranceThreshold {
current_stress: 20,
threshold: 80,
},
))
.id();
run_system(&mut world);
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
assert_eq!(
awareness.suspicion_level,
10 + AWARENESS_STRESS_PER_TICK,
"suspicion should accumulate"
);
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
assert_eq!(
tolerance.current_stress,
20 + AWARENESS_STRESS_PER_TICK,
"stress should accumulate"
);
}
#[test]
fn suspicion_caps_at_100() {
let mut world = setup_world();
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(true),
PlayerAwareness {
player_in_los: true,
consecutive_los_ticks: AWARENESS_NOTICE_TICKS + 100,
suspicion_level: 100,
},
default_tolerance(),
))
.id();
run_system(&mut world);
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
assert_eq!(awareness.suspicion_level, 100, "suspicion should cap at 100");
}
#[test]
fn tolerance_stress_saturates() {
let mut world = setup_world();
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(true),
PlayerAwareness {
player_in_los: true,
consecutive_los_ticks: AWARENESS_NOTICE_TICKS,
suspicion_level: 50,
},
ToleranceThreshold {
current_stress: i16::MAX - 1,
threshold: i16::MAX,
},
))
.id();
run_system(&mut world);
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
assert_eq!(
tolerance.current_stress,
i16::MAX,
"stress should saturate, not overflow"
);
}
// -----------------------------------------------------------------------
// Suspicion decay
// -----------------------------------------------------------------------
#[test]
fn suspicion_decays_when_player_not_in_los_on_interval() {
let mut world = setup_world();
// Set tick to a decay interval boundary
world.resource_mut::<SimulationTime>().tick = 20; // divisible by AWARENESS_DECAY_INTERVAL
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(false),
PlayerAwareness {
player_in_los: false,
consecutive_los_ticks: 0,
suspicion_level: 10,
},
default_tolerance(),
))
.id();
run_system(&mut world);
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
assert_eq!(
awareness.suspicion_level,
10 - AWARENESS_DECAY_AMOUNT,
"suspicion should decay on interval tick"
);
}
#[test]
fn suspicion_does_not_decay_off_interval() {
let mut world = setup_world();
// Set tick to a non-interval boundary
world.resource_mut::<SimulationTime>().tick = 13; // not divisible by AWARENESS_DECAY_INTERVAL
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(false),
PlayerAwareness {
player_in_los: false,
consecutive_los_ticks: 0,
suspicion_level: 10,
},
default_tolerance(),
))
.id();
run_system(&mut world);
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
assert_eq!(
awareness.suspicion_level, 10,
"suspicion should not decay on non-interval tick"
);
}
#[test]
fn suspicion_does_not_go_below_zero() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 10;
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(false),
PlayerAwareness {
player_in_los: false,
consecutive_los_ticks: 0,
suspicion_level: 0,
},
default_tolerance(),
))
.id();
run_system(&mut world);
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
assert_eq!(
awareness.suspicion_level, 0,
"suspicion should not go below zero"
);
}
// -----------------------------------------------------------------------
// Background NPC not processed
// -----------------------------------------------------------------------
#[test]
fn background_npc_not_processed() {
let mut world = setup_world();
let npc = world
.spawn((
Npc,
crate::simulation::tier::BackgroundSim,
vision_with_player(true),
PlayerAwareness::default(),
default_tolerance(),
))
.id();
run_system(&mut world);
let awareness = world.get::<PlayerAwareness>(npc).unwrap();
assert_eq!(
awareness.consecutive_los_ticks, 0,
"background NPC should not have awareness processed"
);
}
// -----------------------------------------------------------------------
// No panic with missing components
// -----------------------------------------------------------------------
#[test]
fn npc_without_awareness_no_panic() {
let mut world = setup_world();
// NPC with vision but no PlayerAwareness — system should skip
world.spawn((
Npc,
ActiveSim,
vision_with_player(true),
default_tolerance(),
));
run_system(&mut world); // should not panic
}
// -----------------------------------------------------------------------
// Integration: follow + awareness stress stacking
// -----------------------------------------------------------------------
#[test]
fn awareness_stress_stacks_with_existing_stress() {
let mut world = setup_world();
// NPC already has stress from other sources (e.g., follow mechanic)
let npc = world
.spawn((
Npc,
ActiveSim,
vision_with_player(true),
PlayerAwareness {
player_in_los: true,
consecutive_los_ticks: AWARENESS_NOTICE_TICKS, // past threshold
suspicion_level: 5,
},
ToleranceThreshold {
current_stress: 30, // pre-existing stress
threshold: 80,
},
))
.id();
run_system(&mut world);
let tolerance = world.get::<ToleranceThreshold>(npc).unwrap();
assert_eq!(
tolerance.current_stress,
30 + AWARENESS_STRESS_PER_TICK,
"awareness stress should stack with existing stress"
);
}
// -----------------------------------------------------------------------
// Constant value assertions (#244 spec compliance)
// -----------------------------------------------------------------------
#[test]
fn awareness_constants_have_expected_values() {
assert_eq!(
AWARENESS_NOTICE_TICKS, 30,
"#244: NPC notices sustained attention after 30 ticks (3 game-minutes)"
);
assert_eq!(
AWARENESS_STRESS_PER_TICK, 1,
"#244: stress per tick lighter than follow stress (D-010 integer)"
);
assert_eq!(
AWARENESS_DECAY_INTERVAL, 10,
"#244: suspicion decays every 10 ticks (1 game-minute, D-031)"
);
assert_eq!(
AWARENESS_DECAY_AMOUNT, 1,
"#244: suspicion decays by 1 per interval"
);
}
}
+730
View File
@@ -0,0 +1,730 @@
//! Background tier state machines (#95, D-026).
//!
//! Four lightweight state machines for Background-tier NPCs, firing once per
//! game-minute (D-031: `TICKS_PER_GAME_MINUTE` = 10).
//!
//! ## State machines
//! 1. **Schedule** — set `ActivityState` from `DailyRoutine` + `DayPhase` (no pathfinding)
//! 2. **Mood** — stress-based derivation (simplified: no phase or warm flag)
//! 3. **Relationships** — per-NPC `trust_level` drifts toward 0 (baseline)
//! 4. **Job** — `JobPerformance.score` drifts based on `Contentment.level`
//!
//! ## Design constraints (D-026)
//! - No pathfinding, LOS, or dialogue — those are Active-tier only.
//! - All arithmetic is integer-only (D-010 determinism requirement).
//! - Background NPCs promoted to Active retain their state machine state (no reset on promotion).
use bevy_ecs::prelude::*;
use crate::npc::{
Contentment, DailyRoutine, JobPerformance, Npc, Relationships, ToleranceThreshold,
};
use crate::npc::mood::{MoodState, NpcMood};
use crate::npc::routine::ActivityState;
use crate::simulation::tier::BackgroundSim;
use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE};
// ---------------------------------------------------------------------------
// Mood derivation (background-tier)
// ---------------------------------------------------------------------------
/// Derive simplified mood for a Background-tier NPC.
///
/// No phase or warm-flag considerations — background NPCs have no active
/// interactions. Priority order:
/// 1. Hostile — stress ≥ threshold
/// 2. Anxious — stress ≥ 60% of threshold (integer arithmetic, D-010)
/// 3. Content — stress < 20
/// 4. Neutral — otherwise
pub fn derive_background_mood(current_stress: i16, threshold: i16) -> NpcMood {
// 1. Hostile: at or above threshold
if current_stress >= threshold {
return NpcMood::Hostile;
}
// 2. Anxious: 60% of threshold reached
// Guard: skip if threshold == 0 (entity already Hostile from rule 1).
if threshold > 0 && (current_stress as i32) * 100 >= (threshold as i32) * 60 {
return NpcMood::Anxious;
}
// 3. Content: low stress
if current_stress < 20 {
return NpcMood::Content;
}
// 4. Neutral: default
NpcMood::Neutral
}
// ---------------------------------------------------------------------------
// Job performance drift
// ---------------------------------------------------------------------------
/// Drift job performance score one point per game-minute based on contentment.
///
/// - contentment > 0 → score + 1 (clamped at 100)
/// - contentment < 0 → score - 1 (clamped at 0)
/// - contentment = 0 → unchanged
pub fn drift_job_performance(score: i16, contentment_level: i16) -> i16 {
match contentment_level.cmp(&0) {
std::cmp::Ordering::Greater => (score + 1).min(100),
std::cmp::Ordering::Less => (score - 1).max(0),
std::cmp::Ordering::Equal => score,
}
}
// ---------------------------------------------------------------------------
// System: background_tick
// ---------------------------------------------------------------------------
/// System: run all four background-tier state machines once per game-minute.
///
/// Fires when `time.tick % TICKS_PER_GAME_MINUTE == 0`. Scoped to
/// `With<BackgroundSim>` — Active-tier NPCs are handled by their dedicated
/// per-tick systems.
///
/// Machine execution order per NPC:
/// 1. Schedule — insert/update `ActivityState` from `DailyRoutine` + `DayPhase`
/// 2. Mood — update `MoodState` from stress/threshold (simplified)
/// 3. Relationships — drift `Relationships.entries[].trust_level` toward 0
/// 4. Job — drift `JobPerformance.score` from `Contentment.level`
///
/// Commands for `ActivityState` are deferred (applied after system runs).
/// Mutable component mutations happen immediately within the iteration.
pub fn background_tick(
time: Res<SimulationTime>,
mut commands: Commands,
mut query: Query<
(
Entity,
Option<&ActivityState>,
&mut MoodState,
Option<&mut Relationships>,
Option<&ToleranceThreshold>,
Option<&DailyRoutine>,
Option<&Contentment>,
Option<&mut JobPerformance>,
),
(With<Npc>, With<BackgroundSim>),
>,
) {
// Fire once per game-minute (D-031: 10 ticks/minute)
if time.tick % TICKS_PER_GAME_MINUTE != 0 {
return;
}
let phase = time.day_phase();
let tick = time.tick;
for (
entity,
activity_opt,
mut mood_state,
rels_opt,
tolerance_opt,
routine_opt,
contentment_opt,
job_opt,
) in query.iter_mut()
{
// --- 1. Schedule: sync ActivityState to current DayPhase ---
// Background NPCs don't pathfind — we directly declare the activity.
if let Some(routine) = routine_opt {
if let Some(entry) = routine.entry_for_phase(phase) {
let needs_update = match activity_opt {
Some(a) => a.phase != phase || a.activity != entry.activity,
None => true,
};
if needs_update {
commands.entity(entity).insert(ActivityState {
activity: entry.activity.clone(),
phase,
started_tick: tick,
});
}
} else if activity_opt.is_some() {
// No routine entry for this phase — clear stale activity
commands.entity(entity).remove::<ActivityState>();
}
}
// --- 2. Mood: simplified stress-based derivation ---
let (stress, threshold) = tolerance_opt
.map(|t| (t.current_stress, t.threshold))
.unwrap_or((0, 50)); // Default: no stress, moderate threshold
let new_mood = derive_background_mood(stress, threshold);
if mood_state.mood != new_mood {
mood_state.mood = new_mood;
mood_state.changed_tick = tick;
}
// --- 3. Relationships: trust drift toward 0 (baseline) ---
if let Some(mut rels) = rels_opt {
for rel in &mut rels.entries {
if rel.trust_level > 0 {
rel.trust_level -= 1;
} else if rel.trust_level < 0 {
rel.trust_level += 1;
}
}
}
// --- 4. Job: performance drift from contentment ---
if let Some(mut job) = job_opt {
let contentment = contentment_opt.map(|c| c.level).unwrap_or(0);
job.score = drift_job_performance(job.score, contentment);
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::npc::{
Contentment, DailyRoutine, JobPerformance, Npc, Relationship, RelationshipKind,
Relationships, RoutineEntry, ToleranceThreshold,
};
use crate::npc::mood::{MoodState, NpcMood};
use crate::npc::routine::ActivityState;
use crate::simulation::movement::TilePosition;
use crate::simulation::tier::{ActiveSim, BackgroundSim};
use crate::simulation::time::{DayPhase, SimulationTime, TICKS_PER_GAME_MINUTE};
use crate::knowledge::types::StableId;
use bevy_ecs::world::World;
// --- derive_background_mood ---
#[test]
fn background_mood_hostile_at_threshold() {
assert_eq!(derive_background_mood(50, 50), NpcMood::Hostile);
}
#[test]
fn background_mood_hostile_above_threshold() {
assert_eq!(derive_background_mood(80, 50), NpcMood::Hostile);
}
#[test]
fn background_mood_anxious_at_60_percent() {
// 60% of threshold=100 is 60. stress=60 → Anxious (60*100 >= 100*60)
assert_eq!(derive_background_mood(60, 100), NpcMood::Anxious);
}
#[test]
fn background_mood_anxious_boundary_below_threshold() {
// threshold=50: 60% = 30. stress=30 → Anxious (30*100=3000 >= 50*60=3000)
assert_eq!(derive_background_mood(30, 50), NpcMood::Anxious);
}
#[test]
fn background_mood_content_low_stress() {
// stress=10 < 20 → Content (not hostile, not anxious)
assert_eq!(derive_background_mood(10, 50), NpcMood::Content);
}
#[test]
fn background_mood_neutral_moderate_stress() {
// stress=25, threshold=50: not hostile, not anxious (25*100=2500 < 50*60=3000),
// not content (25 >= 20) → Neutral
assert_eq!(derive_background_mood(25, 50), NpcMood::Neutral);
}
#[test]
fn background_mood_zero_threshold_is_hostile() {
// stress=0 >= threshold=0 → Hostile
assert_eq!(derive_background_mood(0, 0), NpcMood::Hostile);
}
#[test]
fn background_mood_zero_stress_moderate_threshold_is_content() {
// stress=0 < 20 → Content
assert_eq!(derive_background_mood(0, 50), NpcMood::Content);
}
// --- drift_job_performance ---
#[test]
fn job_drift_up_when_positive_contentment() {
assert_eq!(drift_job_performance(50, 10), 51);
}
#[test]
fn job_drift_down_when_negative_contentment() {
assert_eq!(drift_job_performance(50, -10), 49);
}
#[test]
fn job_drift_unchanged_at_zero_contentment() {
assert_eq!(drift_job_performance(50, 0), 50);
}
#[test]
fn job_drift_clamps_at_100() {
assert_eq!(drift_job_performance(100, 5), 100);
}
#[test]
fn job_drift_clamps_at_0() {
assert_eq!(drift_job_performance(0, -5), 0);
}
// --- background_tick system integration tests ---
fn setup_world() -> World {
let mut world = World::new();
world.init_resource::<SimulationTime>();
world
}
fn run_system(world: &mut World) {
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(background_tick);
schedule.run(world);
}
fn make_routine(phase: DayPhase, activity: &str) -> DailyRoutine {
DailyRoutine {
entries: vec![RoutineEntry {
phase,
location: TilePosition::new(5, 5, 0),
activity: activity.to_string(),
}],
description: "Test routine".into(),
}
}
// --- Tick gating ---
#[test]
fn does_not_fire_on_non_minute_tick() {
let mut world = setup_world();
// Set tick to 5 — not a multiple of TICKS_PER_GAME_MINUTE
world.resource_mut::<SimulationTime>().tick = 5;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
ToleranceThreshold { current_stress: 60, threshold: 50 },
))
.id();
run_system(&mut world);
// Mood should NOT have updated (Hostile would fire if it ran)
let mood = world.get::<MoodState>(npc).unwrap();
assert_eq!(mood.mood, NpcMood::Neutral, "should not fire at tick=5");
}
#[test]
fn fires_at_tick_zero() {
let mut world = setup_world();
// tick=0 is 0 % 10 == 0, so it fires
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
ToleranceThreshold { current_stress: 60, threshold: 50 },
))
.id();
run_system(&mut world);
let mood = world.get::<MoodState>(npc).unwrap();
assert_eq!(mood.mood, NpcMood::Hostile);
}
#[test]
fn fires_at_tick_multiple_of_ticks_per_game_minute() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = TICKS_PER_GAME_MINUTE * 5;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
ToleranceThreshold { current_stress: 60, threshold: 50 },
))
.id();
run_system(&mut world);
let mood = world.get::<MoodState>(npc).unwrap();
assert_eq!(mood.mood, NpcMood::Hostile);
}
// --- Active-tier NPCs not processed ---
#[test]
fn active_npcs_not_processed() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
// ActiveSim NPC — must NOT be processed by background_tick
let npc = world
.spawn((
Npc,
ActiveSim,
MoodState {
mood: NpcMood::Warm,
changed_tick: 0,
},
ToleranceThreshold { current_stress: 60, threshold: 50 },
))
.id();
run_system(&mut world);
let mood = world.get::<MoodState>(npc).unwrap();
assert_eq!(mood.mood, NpcMood::Warm, "ActiveSim NPC must not be updated by background_tick");
}
// --- Mood state machine ---
#[test]
fn mood_hostile_when_stress_at_threshold() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
ToleranceThreshold { current_stress: 50, threshold: 50 },
))
.id();
run_system(&mut world);
assert_eq!(world.get::<MoodState>(npc).unwrap().mood, NpcMood::Hostile);
}
#[test]
fn mood_content_when_no_tolerance() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
// No ToleranceThreshold → defaults (0, 50) → Content (0 < 20)
let npc = world
.spawn((Npc, BackgroundSim, MoodState::default()))
.id();
run_system(&mut world);
assert_eq!(world.get::<MoodState>(npc).unwrap().mood, NpcMood::Content);
}
#[test]
fn mood_records_changed_tick() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = TICKS_PER_GAME_MINUTE * 3;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState { mood: NpcMood::Warm, changed_tick: 0 },
ToleranceThreshold { current_stress: 55, threshold: 50 },
))
.id();
run_system(&mut world);
let mood = world.get::<MoodState>(npc).unwrap();
assert_eq!(mood.mood, NpcMood::Hostile);
assert_eq!(mood.changed_tick, TICKS_PER_GAME_MINUTE * 3);
}
#[test]
fn mood_unchanged_tick_not_updated() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
// Already Content, will derive Content → no change to changed_tick
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState { mood: NpcMood::Content, changed_tick: 42 },
))
.id();
run_system(&mut world);
let mood = world.get::<MoodState>(npc).unwrap();
assert_eq!(mood.mood, NpcMood::Content);
assert_eq!(mood.changed_tick, 42, "changed_tick must not update when mood unchanged");
}
// --- Schedule state machine ---
#[test]
fn schedule_sets_activity_state_for_current_phase() {
let mut world = setup_world();
// tick=0 → DayPhase::Morning
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
make_routine(DayPhase::Morning, "Work"),
))
.id();
run_system(&mut world);
let activity = world.get::<ActivityState>(npc).unwrap();
assert_eq!(activity.activity, "Work");
assert_eq!(activity.phase, DayPhase::Morning);
}
#[test]
fn schedule_no_activity_when_no_routine_entry_for_phase() {
let mut world = setup_world();
// tick=0 → Morning, but routine only has Afternoon
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
make_routine(DayPhase::Afternoon, "Meeting"),
))
.id();
run_system(&mut world);
// No ActivityState should be inserted (Morning has no entry)
assert!(world.get::<ActivityState>(npc).is_none());
}
#[test]
fn schedule_preserves_correct_activity_state() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0; // Morning
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
make_routine(DayPhase::Morning, "Work"),
// Already has correct ActivityState — should not be re-inserted
ActivityState {
activity: "Work".into(),
phase: DayPhase::Morning,
started_tick: 0,
},
))
.id();
run_system(&mut world);
let activity = world.get::<ActivityState>(npc).unwrap();
assert_eq!(activity.activity, "Work");
}
// --- Relationships state machine ---
#[test]
fn relationships_positive_trust_drifts_toward_zero() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
Relationships {
entries: vec![Relationship {
target_id: StableId(1),
kind: RelationshipKind::Friend,
trust_level: 5,
history: vec![],
}],
},
))
.id();
run_system(&mut world);
let rels = world.get::<Relationships>(npc).unwrap();
assert_eq!(rels.entries[0].trust_level, 4, "positive trust decrements by 1");
}
#[test]
fn relationships_negative_trust_drifts_toward_zero() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
Relationships {
entries: vec![Relationship {
target_id: StableId(2),
kind: RelationshipKind::Rival,
trust_level: -4,
history: vec![],
}],
},
))
.id();
run_system(&mut world);
let rels = world.get::<Relationships>(npc).unwrap();
assert_eq!(rels.entries[0].trust_level, -3, "negative trust increments by 1");
}
#[test]
fn relationships_zero_trust_stays_zero() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
Relationships {
entries: vec![Relationship {
target_id: StableId(3),
kind: RelationshipKind::Colleague,
trust_level: 0,
history: vec![],
}],
},
))
.id();
run_system(&mut world);
let rels = world.get::<Relationships>(npc).unwrap();
assert_eq!(rels.entries[0].trust_level, 0, "zero trust unchanged");
}
// --- Job state machine ---
#[test]
fn job_performance_rises_with_positive_contentment() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
JobPerformance { score: 60 },
Contentment { level: 20 },
))
.id();
run_system(&mut world);
let job = world.get::<JobPerformance>(npc).unwrap();
assert_eq!(job.score, 61);
}
#[test]
fn job_performance_falls_with_negative_contentment() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
JobPerformance { score: 60 },
Contentment { level: -15 },
))
.id();
run_system(&mut world);
let job = world.get::<JobPerformance>(npc).unwrap();
assert_eq!(job.score, 59);
}
#[test]
fn job_performance_unchanged_at_zero_contentment() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let npc = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
JobPerformance { score: 50 },
Contentment { level: 0 },
))
.id();
run_system(&mut world);
let job = world.get::<JobPerformance>(npc).unwrap();
assert_eq!(job.score, 50);
}
#[test]
fn job_performance_without_component_no_panic() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
// No JobPerformance — system must not panic
let _npc = world.spawn((Npc, BackgroundSim, MoodState::default())).id();
run_system(&mut world); // must not panic
}
// --- Multiple NPCs independent ---
#[test]
fn multiple_background_npcs_processed_independently() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 0;
let calm = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
ToleranceThreshold { current_stress: 5, threshold: 50 },
))
.id();
let hostile = world
.spawn((
Npc,
BackgroundSim,
MoodState::default(),
ToleranceThreshold { current_stress: 55, threshold: 50 },
))
.id();
run_system(&mut world);
assert_eq!(world.get::<MoodState>(calm).unwrap().mood, NpcMood::Content);
assert_eq!(world.get::<MoodState>(hostile).unwrap().mood, NpcMood::Hostile);
}
}
+81 -5
View File
@@ -31,12 +31,15 @@ use bevy_ecs::prelude::*;
use crate::knowledge::types::{FactId, KnowledgeConfidence, StableId};
use crate::npc::{
CombatCapability, CombatStyle, Contentment, DailyRoutine, InformationInventory, KnownFact,
Npc, PersonalityTrait, PersonalityTraits, Relationship, RelationshipKind, Relationships,
RoutineEntry, Secret, SecretSeverity, Skill, SkillSet, Tell, TellSystem, TellTrigger,
ToleranceThreshold, Want, WantKind, MAX_KEY_RELATIONSHIPS,
CombatCapability, CombatStyle, Contentment, DailyRoutine, InformationInventory, JobPerformance,
KnownFact, Npc, PersonalityTrait, PersonalityTraits, Relationship, RelationshipKind,
Relationships, RoutineEntry, Secret, SecretSeverity, Skill, SkillSet, Tell, TellSystem,
TellTrigger, ToleranceThreshold, Want, WantKind, MAX_KEY_RELATIONSHIPS,
};
use crate::knowledge::graph::KnowledgeGraph;
use crate::npc::mood::MoodState;
use crate::npc::awareness::PlayerAwareness;
use crate::npc::vision::{NpcMemory, NpcVisionState};
use crate::simulation::movement::TilePosition;
use crate::simulation::rng::SimRng;
use crate::simulation::tier::ActiveSim;
@@ -266,7 +269,7 @@ fn gen_routine(rng: &mut SimRng, location_pool: &[(DayPhase, TilePosition)]) ->
DailyRoutine {
entries,
description: format!("Routine schedule"),
description: "Routine schedule".to_string(),
}
}
@@ -480,6 +483,16 @@ pub fn generate_npc(role: &RoleDefinition, world: &mut World, rng: &mut SimRng)
tells,
skills,
MoodState::default(),
JobPerformance::default(),
));
// Vision + knowledge + awareness components (#115, #244, D-041).
// Separate insert to stay within tuple bundle element limit.
entity_builder.insert((
KnowledgeGraph::new(),
NpcVisionState::default(),
NpcMemory::default(),
PlayerAwareness::default(),
));
if let Some(cap) = combat_opt {
@@ -876,6 +889,69 @@ mod tests {
}
}
// -----------------------------------------------------------------------
// Combat capability — positive case (#91, D-024)
// -----------------------------------------------------------------------
#[test]
fn combat_role_probabilistically_produces_combat_capability() {
// #91 positive-case: at least one seed must produce CombatCapability for
// a combat-enabled role. The probability is 2/3 per seed (RNG < 2/3 range),
// so 30 seeds is overwhelmingly likely to produce at least one match.
let role = minimal_role(); // combat_enabled = true
let mut any_combat = false;
for seed in 0..30_u64 {
let mut world = World::new();
let mut rng = make_rng(seed);
let entity = generate_npc(&role, &mut world, &mut rng);
if world.get::<CombatCapability>(entity).is_some() {
any_combat = true;
break;
}
}
assert!(
any_combat,
"combat-enabled role must produce CombatCapability for at least one seed"
);
}
#[test]
fn combat_trained_flag_matches_combat_capability_presence() {
// #91 invariant: SkillSet.combat_trained must be consistent with CombatCapability.
// If CombatCapability is present, combat_trained must be true and vice versa.
let role = minimal_role(); // combat_enabled = true
for seed in 0..50_u64 {
let mut world = World::new();
let mut rng = make_rng(seed);
let entity = generate_npc(&role, &mut world, &mut rng);
let skills = world.get::<SkillSet>(entity).unwrap();
let has_cap = world.get::<CombatCapability>(entity).is_some();
assert_eq!(
skills.combat_trained, has_cap,
"seed {seed}: SkillSet.combat_trained={} must match CombatCapability presence={}",
skills.combat_trained, has_cap
);
}
}
#[test]
fn combat_capability_proficiency_in_valid_range() {
// #91: CombatCapability.weapon_proficiency must be in 3-8 range per gen_skills().
let role = minimal_role(); // combat_enabled = true
for seed in 0..100_u64 {
let mut world = World::new();
let mut rng = make_rng(seed);
let entity = generate_npc(&role, &mut world, &mut rng);
if let Some(cap) = world.get::<CombatCapability>(entity) {
assert!(
cap.weapon_proficiency >= 3 && cap.weapon_proficiency <= 8,
"seed {seed}: weapon_proficiency {} out of valid range 3-8",
cap.weapon_proficiency
);
}
}
}
// -----------------------------------------------------------------------
// ActiveSim tier
// -----------------------------------------------------------------------
+50
View File
@@ -2,6 +2,8 @@
// Implements D-024: 10-axis NPC model + CombatCapability component
// Background tier state machines for schedule, mood, relationships, job
pub mod awareness;
pub mod background;
pub mod disclosure;
pub mod generate;
pub mod interaction;
@@ -11,6 +13,7 @@ pub mod routine;
pub mod tell_state;
pub mod tolerance;
pub mod trait_modifiers;
pub mod vision;
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
@@ -29,6 +32,8 @@ impl Plugin for NpcPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<relationships::RelationshipGraph>()
.init_resource::<relationships::TrustEventQueue>()
.init_resource::<relationships::PropagationQueue>()
.init_resource::<relationships::DelayedTrustQueue>()
.init_resource::<crate::knowledge::KnowledgeEventQueue>()
.init_resource::<routine::PreviousDayPhase>()
.init_resource::<tolerance::ToleranceBreachEventQueue>()
@@ -49,6 +54,9 @@ impl Plugin for NpcPlugin {
.after(crate::simulation::dialogue::process_confrontation_response)
.after(crate::simulation::dialogue::process_dialogue_response)
.before(crate::simulation::time::advance_tick),
relationships::propagate_social_actions
.after(relationships::update_trust)
.before(crate::simulation::time::advance_tick),
relationships::update_relationship_dynamics
.after(relationships::update_trust)
.before(crate::simulation::time::advance_tick),
@@ -65,11 +73,29 @@ impl Plugin for NpcPlugin {
.after(mood::update_mood)
.after(routine::detect_routine_deviation)
.before(crate::perception::observer::compute_observer_snapshot),
background::background_tick
.after(crate::simulation::movement::validate_movement)
.before(crate::simulation::time::advance_tick),
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),
// NPC player-awareness (#244)
awareness::detect_player_awareness
.after(vision::compute_npc_vision)
.before(tolerance::check_tolerance_threshold),
// NPC vision system (#115, D-011)
vision::compute_npc_vision
.after(crate::simulation::movement::validate_movement)
.after(crate::simulation::tier::update_tier_markers)
.before(crate::perception::observer::compute_observer_snapshot),
vision::emit_npc_vision_events
.after(vision::compute_npc_vision)
.before(crate::knowledge::events::process_knowledge_events),
vision::degrade_npc_inferences
.after(vision::emit_npc_vision_events)
.before(crate::simulation::time::advance_tick),
crate::simulation::dialogue::process_talk_interaction
.after(crate::simulation::input::process_player_input),
crate::simulation::dialogue::process_walk_away
@@ -268,6 +294,30 @@ pub struct Contentment {
pub level: i16, // -100..+100, integer for determinism (D-010)
}
// ---------------------------------------------------------------------------
// Job performance (D-026 background state machine — feeds from Contentment)
// ---------------------------------------------------------------------------
/// Tracks how well an NPC performs their job role.
///
/// Drifts based on `Contentment` level in the background-tier state machine:
/// - contentment > 0 → score increases toward 100
/// - contentment < 0 → score decreases toward 0
/// - contentment = 0 → no change
///
/// Updated by `background::background_tick` once per game-minute for
/// Background-tier NPCs. Persists through tier promotions (D-026).
#[derive(Component, Debug, Clone, Serialize, Deserialize)]
pub struct JobPerformance {
pub score: i16, // 0..=100, integer for determinism (D-010)
}
impl Default for JobPerformance {
fn default() -> Self {
Self { score: 50 }
}
}
// ---------------------------------------------------------------------------
// Supporting axis 1: Personality traits (D-024)
// ---------------------------------------------------------------------------
+592 -1
View File
@@ -141,7 +141,8 @@ impl RelationshipGraph {
}
/// Get all entities who have feelings about a target.
/// O(N) full scan of all edges — use for event detection, not per-tick queries.
/// O(N) full scan of all edges. Called once per game-minute (every 10 ticks)
/// by the pressure system — acceptable at v0.1 NPC counts.
pub fn who_knows_full_scan(&self, target: &StableId) -> Vec<(&StableId, &RelationshipEdge)> {
self.edges
.iter()
@@ -201,16 +202,121 @@ impl RelationshipGraph {
// System: update_trust (#324)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Social propagation (#249, D-029)
// ---------------------------------------------------------------------------
/// Minimum absolute trust required on a relationship edge for that edge
/// to carry propagation (D-029: "strong relationships", trust > 3).
pub const PROPAGATION_TRUST_THRESHOLD: i8 = 3;
/// Delay in ticks before third-order propagation is applied.
/// 30 ticks ≈ 3 game-minutes (D-031: 10 ticks/minute).
pub const THIRD_ORDER_DELAY_TICKS: u64 = 30;
/// A first-order trust change that needs social propagation.
///
/// Produced by `update_trust` for each processed event, consumed by
/// `propagate_social_actions` to fan out second- and third-order effects.
#[derive(Debug, Clone)]
pub struct PropagationEvent {
/// The NPC directly affected by the player action (first-order subject).
pub npc: StableId,
/// The player entity (target of how NPCs feel).
pub player: StableId,
/// The first-order delta (same as what was applied to the graph).
pub delta: i8,
}
/// Queue of propagation events emitted by `update_trust`.
#[derive(Resource, Default)]
pub struct PropagationQueue {
events: Vec<PropagationEvent>,
}
impl PropagationQueue {
pub fn push(&mut self, event: PropagationEvent) {
self.events.push(event);
}
pub fn drain(&mut self) -> Vec<PropagationEvent> {
std::mem::take(&mut self.events)
}
}
/// A deferred trust change for third-order propagation.
#[derive(Debug, Clone)]
pub struct DelayedTrustChange {
/// NPC whose trust toward the player will be adjusted.
pub npc: StableId,
/// Player entity.
pub player: StableId,
/// Scaled delta to apply (already clamped to meaningful range).
pub delta: i8,
/// Tick at which this change should be applied.
pub apply_at_tick: u64,
}
/// Queue of deferred third-order trust changes.
#[derive(Resource, Default)]
pub struct DelayedTrustQueue {
pending: Vec<DelayedTrustChange>,
}
impl DelayedTrustQueue {
pub fn push(&mut self, change: DelayedTrustChange) {
self.pending.push(change);
}
/// Drain changes that are due at or before `current_tick`.
pub fn drain_due(&mut self, current_tick: u64) -> Vec<DelayedTrustChange> {
let mut due = Vec::new();
let mut remaining = Vec::new();
for change in self.pending.drain(..) {
if change.apply_at_tick <= current_tick {
due.push(change);
} else {
remaining.push(change);
}
}
self.pending = remaining;
due
}
/// Number of pending deferred changes.
pub fn pending_count(&self) -> usize {
self.pending.len()
}
}
/// Scale a first-order delta by a propagation factor, using integer arithmetic
/// (D-010: integer-only determinism). Returns 0 when the scaled value would
/// round to zero — small deltas naturally attenuate to nothing.
///
/// Factor is expressed in tenths (e.g. 4 = 40%, 2 = 20%).
///
/// Rounding: away from zero (ceiling of abs value, preserving sign).
fn scale_delta(delta: i8, factor_tenths: i8) -> i8 {
// Multiply by factor, round up (ceiling of absolute value)
let scaled_abs = (delta.unsigned_abs() as i16 * factor_tenths as i16 + 9) / 10;
let scaled = scaled_abs.min(10) as i8;
if delta < 0 { -(scaled as i8) } else { scaled as i8 }
}
/// Drain pending trust events and apply deltas to the RelationshipGraph.
///
/// Each event adjusts the NPC→player trust edge. If no edge exists,
/// one is created with default Colleague kind and trust 0 before applying
/// the delta. Trust is clamped to [-10, +10] per D-010.
///
/// Also queues a `PropagationEvent` for each processed event so that
/// `propagate_social_actions` can fan out second- and third-order effects.
///
/// System ordering: after dialogue systems (which emit the events),
/// before advance_tick.
pub fn update_trust(
mut queue: ResMut<TrustEventQueue>,
mut propagation_queue: ResMut<PropagationQueue>,
mut graph: ResMut<RelationshipGraph>,
registry: Res<EntityRegistry>,
time: Res<SimulationTime>,
@@ -244,6 +350,116 @@ pub fn update_trust(
new_trust = edge.trust,
"Trust updated"
);
// Queue propagation event for second/third-order effects (#249)
propagation_queue.push(PropagationEvent {
npc: npc_sid,
player: player_sid,
delta,
});
}
}
/// System: fan out player-action trust changes through the social graph (#249, D-029).
///
/// Processes `PropagationQueue` events (produced by `update_trust`) and applies:
/// - **Second-order** (immediate, 40%): NPCs with trust > 3 toward the first-order NPC
/// - **Third-order** (delayed 30 ticks, 15%): one further hop, same threshold
///
/// Cycle prevention: a visited set per propagation pass prevents A→B→A loops.
/// Propagation topology varies per seed (D-029 anti-metagaming property) because
/// the `RelationshipGraph` is seeded differently per run.
///
/// System ordering: after `update_trust`, before `advance_tick`.
pub fn propagate_social_actions(
mut propagation_queue: ResMut<PropagationQueue>,
mut delay_queue: ResMut<DelayedTrustQueue>,
mut graph: ResMut<RelationshipGraph>,
time: Res<SimulationTime>,
) {
// --- Apply any due delayed third-order changes first ---
for change in delay_queue.drain_due(time.tick) {
if change.delta != 0 {
graph.ensure_edge(change.npc, change.player, time.tick);
graph.adjust_trust(&change.npc, &change.player, change.delta);
tracing::trace!(
npc = change.npc.0,
player = change.player.0,
delta = change.delta,
"Third-order trust propagated (delayed)"
);
}
}
// --- Fan out new propagation events ---
for event in propagation_queue.drain() {
let PropagationEvent { npc, player, delta } = event;
// Visited set prevents cycles (D-029)
let mut visited = std::collections::BTreeSet::new();
visited.insert(npc);
// --- Second-order (immediate, 40% of delta) ---
let second_delta = scale_delta(delta, 4); // 40%
if second_delta != 0 {
// Find NPCs that the first-order NPC trusts strongly
let second_order: Vec<StableId> = graph
.relationships_of(&npc)
.into_iter()
.filter(|(_, edge)| edge.trust > PROPAGATION_TRUST_THRESHOLD)
.map(|(target, _)| *target)
.collect();
for second in &second_order {
if visited.contains(second) {
continue;
}
visited.insert(*second);
graph.ensure_edge(*second, player, time.tick);
graph.adjust_trust(second, &player, second_delta);
tracing::trace!(
first_order_npc = npc.0,
second_order_npc = second.0,
player = player.0,
delta = second_delta,
"Second-order trust propagated"
);
}
// --- Third-order (delayed 30 ticks, 15% of delta) ---
let third_delta = scale_delta(delta, 2); // ~15% (2/10 = 20%, nearest integer approx)
if third_delta != 0 {
let apply_at = time.tick + THIRD_ORDER_DELAY_TICKS;
for second in &second_order {
let third_order: Vec<StableId> = graph
.relationships_of(second)
.into_iter()
.filter(|(_, edge)| edge.trust > PROPAGATION_TRUST_THRESHOLD)
.map(|(target, _)| *target)
.collect();
for third in third_order {
if visited.contains(&third) {
continue;
}
visited.insert(third);
delay_queue.push(DelayedTrustChange {
npc: third,
player,
delta: third_delta,
apply_at_tick: apply_at,
});
tracing::trace!(
third_order_npc = third.0,
player = player.0,
delta = third_delta,
apply_at,
"Third-order trust queued for delayed propagation"
);
}
}
}
}
}
}
@@ -493,6 +709,7 @@ mod tests {
world.init_resource::<EntityRegistry>();
world.init_resource::<RelationshipGraph>();
world.init_resource::<TrustEventQueue>();
world.init_resource::<PropagationQueue>();
world
}
@@ -748,6 +965,380 @@ mod tests {
assert_eq!(edge.trust, 6); // 5 + 1
}
// -- PropagationQueue tests (#249) ----------------------------------------
#[test]
fn propagation_queue_push_and_drain() {
let mut q = PropagationQueue::default();
q.push(PropagationEvent {
npc: StableId(1),
player: StableId(2),
delta: 1,
});
q.push(PropagationEvent {
npc: StableId(3),
player: StableId(2),
delta: -2,
});
let drained = q.drain();
assert_eq!(drained.len(), 2);
assert!(q.drain().is_empty());
}
#[test]
fn delayed_trust_queue_drain_due_filters_by_tick() {
let mut q = DelayedTrustQueue::default();
q.push(DelayedTrustChange {
npc: StableId(1),
player: StableId(10),
delta: 1,
apply_at_tick: 50,
});
q.push(DelayedTrustChange {
npc: StableId(2),
player: StableId(10),
delta: -1,
apply_at_tick: 100,
});
// Only change at tick 50 is due at tick 60
let due = q.drain_due(60);
assert_eq!(due.len(), 1);
assert_eq!(due[0].npc, StableId(1));
// Change at tick 100 is still pending
assert_eq!(q.pending_count(), 1);
// At tick 100 it becomes due
let due2 = q.drain_due(100);
assert_eq!(due2.len(), 1);
assert_eq!(due2[0].npc, StableId(2));
assert_eq!(q.pending_count(), 0);
}
#[test]
fn scale_delta_forty_percent() {
// factor_tenths=4 → 40%
assert_eq!(scale_delta(1, 4), 1); // 0.4 → rounds up to 1
assert_eq!(scale_delta(2, 4), 1); // 0.8 → rounds up to 1
assert_eq!(scale_delta(5, 4), 2); // 2.0 → 2
assert_eq!(scale_delta(10, 4), 4); // 4.0 → 4
assert_eq!(scale_delta(-2, 4), -1); // negative preserved
}
#[test]
fn scale_delta_zero_when_too_small() {
// delta=0 → always 0
assert_eq!(scale_delta(0, 4), 0);
}
// -- propagate_social_actions system tests (#249) -------------------------
fn setup_propagation_world() -> bevy_ecs::world::World {
let mut world = bevy_ecs::world::World::new();
world.init_resource::<SimulationTime>();
world.init_resource::<RelationshipGraph>();
world.init_resource::<PropagationQueue>();
world.init_resource::<DelayedTrustQueue>();
world
}
#[test]
fn second_order_trust_propagates_immediately() {
// Spec (#249, D-029): NPCs strongly connected to the first-order NPC
// receive 40% of the delta in the same tick.
//
// Graph: A → B (trust 5, > threshold 3)
// A → player (will be first-order)
// Event: player action affects A (delta=+2)
// Expected: B gains 40% of 2 = 1 (ceil) toward player
let mut world = setup_propagation_world();
let npc_a = StableId(1);
let npc_b = StableId(2);
let player = StableId(99);
// A strongly trusts B (A→B trust=5)
world.resource_mut::<RelationshipGraph>().set_relationship(
npc_a,
npc_b,
make_edge(RelationshipKind::Friend, 5),
);
// Queue propagation from A
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
npc: npc_a,
player,
delta: 2,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(propagate_social_actions);
schedule.run(&mut world);
// B should now have a trust edge toward the player (positive)
let graph = world.resource::<RelationshipGraph>();
let edge = graph.get_relationship(&npc_b, &player).expect("B should have edge to player");
assert!(
edge.trust > 0,
"Second-order NPC should trust player more after positive first-order event"
);
}
#[test]
fn weak_relationship_does_not_propagate() {
// Spec (#249): Only edges with trust > PROPAGATION_TRUST_THRESHOLD (3) carry propagation.
//
// Graph: A → B (trust 2, ≤ threshold 3)
// Event: player action affects A (delta=+5)
// Expected: B gets no propagation (trust ≤ threshold)
let mut world = setup_propagation_world();
let npc_a = StableId(1);
let npc_b = StableId(2);
let player = StableId(99);
// A weakly trusts B (below threshold)
world.resource_mut::<RelationshipGraph>().set_relationship(
npc_a,
npc_b,
make_edge(RelationshipKind::Colleague, 2),
);
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
npc: npc_a,
player,
delta: 5,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(propagate_social_actions);
schedule.run(&mut world);
// B should NOT have any edge to the player
let graph = world.resource::<RelationshipGraph>();
assert!(
graph.get_relationship(&npc_b, &player).is_none(),
"Weak relationship should not carry propagation"
);
}
#[test]
fn third_order_trust_is_deferred_by_thirty_ticks() {
// Spec (#249): Third-order changes are queued for 30 ticks in the future.
//
// Graph: A → B (trust 5), B → C (trust 5)
// Event: player action affects A (delta=+5)
// Expected: C's change is queued for tick+30, not applied immediately
let mut world = setup_propagation_world();
world.resource_mut::<SimulationTime>().tick = 10; // Set a known tick
let npc_a = StableId(1);
let npc_b = StableId(2);
let npc_c = StableId(3);
let player = StableId(99);
let mut graph = world.resource_mut::<RelationshipGraph>();
graph.set_relationship(npc_a, npc_b, make_edge(RelationshipKind::Friend, 5));
graph.set_relationship(npc_b, npc_c, make_edge(RelationshipKind::Friend, 5));
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
npc: npc_a,
player,
delta: 5,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(propagate_social_actions);
schedule.run(&mut world);
// C should NOT have an edge yet (it's deferred)
{
let graph = world.resource::<RelationshipGraph>();
assert!(
graph.get_relationship(&npc_c, &player).is_none(),
"Third-order changes should be deferred, not applied immediately"
);
}
// Delay queue should have one pending change for C at tick 10+30=40
let delay_queue = world.resource::<DelayedTrustQueue>();
assert_eq!(delay_queue.pending_count(), 1);
}
#[test]
fn delayed_changes_applied_when_due() {
// Once the tick advances past apply_at_tick, the delayed change is applied.
let mut world = setup_propagation_world();
world.resource_mut::<SimulationTime>().tick = 10;
let npc_a = StableId(1);
let npc_b = StableId(2);
let npc_c = StableId(3);
let player = StableId(99);
let mut graph = world.resource_mut::<RelationshipGraph>();
graph.set_relationship(npc_a, npc_b, make_edge(RelationshipKind::Friend, 5));
graph.set_relationship(npc_b, npc_c, make_edge(RelationshipKind::Friend, 5));
// First run: queue the third-order change
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
npc: npc_a,
player,
delta: 5,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(propagate_social_actions);
schedule.run(&mut world);
// Advance tick to 40 (past apply_at_tick = 40)
world.resource_mut::<SimulationTime>().tick = 40;
schedule.run(&mut world);
// C should now have an edge with positive trust
let graph = world.resource::<RelationshipGraph>();
let edge = graph.get_relationship(&npc_c, &player)
.expect("Delayed change should have been applied by now");
assert!(edge.trust > 0, "Third-order trust should be positive after delayed application");
}
#[test]
fn cycle_prevention_no_a_to_b_to_a_loop() {
// Spec (#249, D-029): visited set prevents A→B→A cycles.
//
// Graph: A ↔ B (both trust each other at 5)
// Event: player action affects A (delta=+2)
// A should propagate to B, but B should NOT propagate back to A.
let mut world = setup_propagation_world();
let npc_a = StableId(1);
let npc_b = StableId(2);
let player = StableId(99);
// Bidirectional strong trust
world.resource_mut::<RelationshipGraph>().set_relationship(
npc_a,
npc_b,
make_edge(RelationshipKind::Friend, 5),
);
world.resource_mut::<RelationshipGraph>().set_relationship(
npc_b,
npc_a,
make_edge(RelationshipKind::Friend, 5),
);
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
npc: npc_a,
player,
delta: 2,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(propagate_social_actions);
schedule.run(&mut world);
// A should not get a second-order change from the cycle (A was the origin)
let graph = world.resource::<RelationshipGraph>();
// The only A→player edge effect should be through the propagation event
// (no direct creation in propagate_social_actions — only update_trust does that).
// B should have an edge to player.
assert!(
graph.get_relationship(&npc_b, &player).is_some(),
"B should get second-order propagation from A"
);
// A should NOT have a re-propagated edge (cycle prevented)
assert!(
graph.get_relationship(&npc_a, &player).is_none(),
"A should not receive back-propagation from B (cycle prevention)"
);
}
#[test]
fn negative_delta_propagates_as_negative() {
// Spec (#249): Negative deltas (walk-away, confrontation) also propagate
// with the same sign.
let mut world = setup_propagation_world();
let npc_a = StableId(1);
let npc_b = StableId(2);
let player = StableId(99);
world.resource_mut::<RelationshipGraph>().set_relationship(
npc_a,
npc_b,
make_edge(RelationshipKind::Friend, 5),
);
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
npc: npc_a,
player,
delta: -2, // confrontation
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(propagate_social_actions);
schedule.run(&mut world);
// B should trust player LESS after A was confronted
let graph = world.resource::<RelationshipGraph>();
let edge = graph.get_relationship(&npc_b, &player)
.expect("B should have edge to player");
assert!(
edge.trust < 0,
"Negative propagation should reduce B's trust in player"
);
}
#[test]
fn no_relationships_means_no_propagation() {
// If the first-order NPC has no relationships, the queue is drained
// but nothing propagates.
let mut world = setup_propagation_world();
let npc_a = StableId(1);
let player = StableId(99);
world.resource_mut::<PropagationQueue>().push(PropagationEvent {
npc: npc_a,
player,
delta: 5,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(propagate_social_actions);
schedule.run(&mut world);
let graph = world.resource::<RelationshipGraph>();
assert!(graph.is_empty(), "No relationships → no propagation edges created");
let delay_queue = world.resource::<DelayedTrustQueue>();
assert_eq!(delay_queue.pending_count(), 0, "No delay queue entries either");
}
#[test]
fn update_trust_populates_propagation_queue() {
// Integration: update_trust should push to PropagationQueue for downstream #249.
let mut world = setup_trust_world();
let npc = world.spawn_empty().id();
let player = world.spawn_empty().id();
world.resource_mut::<EntityRegistry>().register(npc);
world.resource_mut::<EntityRegistry>().register(player);
world
.resource_mut::<TrustEventQueue>()
.push(TrustEvent::TalkCompleted { npc, player });
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(update_trust);
schedule.run(&mut world);
// PropagationQueue should have 1 event (for downstream propagation)
let prop_events = world.resource_mut::<PropagationQueue>().drain();
assert_eq!(prop_events.len(), 1);
assert_eq!(prop_events[0].delta, TALK_TRUST_DELTA);
}
#[test]
fn unregistered_entity_event_is_skipped() {
let mut world = setup_trust_world();
+826
View File
@@ -0,0 +1,826 @@
//! NPC vision system (#115, D-011).
//!
//! Active-tier NPCs use the same symmetric shadowcasting as the player.
//! Results stored in `NpcVisionState`; `NpcMemory` tracks last-known
//! positions and zone inferences ("saw you enter building → knows you're
//! inside").
//!
//! ## System ordering
//!
//! 1. `compute_npc_vision` — after movement/tier updates, before snapshot
//! 2. `emit_npc_vision_events` — after compute, before process_knowledge_events
//! 3. `degrade_npc_inferences` — once per game-minute (D-031)
//!
//! ## Performance
//!
//! 3080 Active NPCs × symmetric shadowcast per tick. Confirmed within
//! D-026 Active tier budget by architecture review.
use std::collections::{BTreeMap, BTreeSet};
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::knowledge::types::StableId;
use crate::knowledge::{
EntityRegistry, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType, KnowledgeGraph,
};
use crate::npc::Npc;
use crate::perception::shadowcast::compute_fov;
use crate::perception::vision_cone::{apply_vision_cone, Facing, VisionConeConfig};
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use crate::simulation::spatial::{NaiveSpatialIndex, SpatialIndex};
use crate::simulation::time::{SimulationTime, TICKS_PER_GAME_MINUTE};
use crate::simulation::tier::ActiveSim;
/// NPC vision range in tiles (matches player forward range from VisionConeConfig).
pub const NPC_VISION_RANGE: i32 = 20;
/// Ticks before a zone inference degrades. 600 ticks = 60 game-minutes = 1 game-hour.
pub const INFERENCE_DEGRADE_TICKS: u64 = 600;
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
/// Current field-of-view results for an NPC.
/// Updated each tick for Active-tier NPCs. BTreeSet for determinism (D-010).
#[derive(Component, Debug, Clone, Default)]
pub struct NpcVisionState {
/// StableIds of entities currently in this NPC's LOS.
pub visible_entities: BTreeSet<StableId>,
/// Whether the player character is currently visible.
pub player_visible: bool,
}
/// Persistent memory of entities this NPC has seen.
/// Survives after entities leave LOS (D-011: "saw you enter building →
/// knows you're inside").
#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)]
pub struct NpcMemory {
/// Last known position + tick for entities this NPC has seen.
/// Key: StableId of the observed entity.
pub last_known: BTreeMap<StableId, LastKnownEntry>,
}
/// Record of last-known position for a single entity.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LastKnownEntry {
/// Position where entity was last seen.
pub position: TilePosition,
/// Tick when the entity was last observed.
pub observed_tick: u64,
/// Zone inference: if entity was last seen before leaving LOS,
/// the NPC infers they are still nearby.
pub zone_inference: Option<ZoneInference>,
}
/// Inference that an entity is still near a position based on last observation.
/// Degrades after `degrades_at_tick`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZoneInference {
/// The position where the entity was last seen.
pub last_seen_position: TilePosition,
/// Tick when the entity was seen at this position.
pub observed_tick: u64,
/// Tick at which this inference degrades (NPC stops assuming entity is here).
pub degrades_at_tick: u64,
}
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
/// Compute NPC field-of-view for all Active-tier NPCs.
///
/// For each NPC: run symmetric shadowcasting (same algorithm as player per D-011),
/// apply vision cone if the NPC has a `Facing` component, then check which entities
/// from the spatial index are at visible tiles.
pub fn compute_npc_vision(
walkability: Option<Res<WalkabilityMap>>,
registry: Res<EntityRegistry>,
spatial_index: Res<NaiveSpatialIndex>,
player_query: Query<Entity, With<PlayerCharacter>>,
mut npc_query: Query<
(Entity, &TilePosition, Option<&Facing>, &mut NpcVisionState),
(With<Npc>, With<ActiveSim>),
>,
entity_positions: Query<&TilePosition>,
) {
let Some(walkability) = walkability else {
return;
};
let player_entity = player_query.iter().next();
let player_stable_id = player_entity.and_then(|e| registry.to_stable(e));
let config = VisionConeConfig::default();
for (npc_entity, npc_pos, facing, mut vision_state) in npc_query.iter_mut() {
let z = npc_pos.z;
// Run symmetric shadowcasting — same algorithm as player (D-011, D-035)
let fov = compute_fov(
|x, y| !walkability.can_move_to(&TilePosition::new(x, y, z)),
npc_pos.x,
npc_pos.y,
NPC_VISION_RANGE,
z,
);
// Collect visible tile positions — apply vision cone if NPC has facing
let visible_positions: BTreeSet<(i32, i32)> = if let Some(facing_comp) = facing {
let cone_tiles =
apply_vision_cone(&fov, npc_pos.x, npc_pos.y, facing_comp.0, &config);
cone_tiles.into_iter().map(|(x, y, _)| (x, y)).collect()
} else {
// No facing → omnidirectional vision (full FOV)
fov.visible_tiles().collect()
};
// Find entities at visible positions via spatial index
let mut new_visible = BTreeSet::new();
let mut player_vis = false;
// Single-pass query: all entities within vision range including own tile
let candidates = spatial_index.entities_within(npc_pos, NPC_VISION_RANGE as u32);
for entity in candidates {
if entity == npc_entity {
continue;
}
let Ok(entity_pos) = entity_positions.get(entity) else {
continue;
};
if entity_pos.z != z {
continue;
}
if !visible_positions.contains(&(entity_pos.x, entity_pos.y)) {
continue;
}
let Some(stable_id) = registry.to_stable(entity) else {
continue;
};
new_visible.insert(stable_id);
if Some(stable_id) == player_stable_id {
player_vis = true;
}
}
vision_state.visible_entities = new_visible;
vision_state.player_visible = player_vis;
}
}
/// Emit knowledge events when NPCs gain or lose sight of the player.
///
/// Mirrors the player's `emit_observation_events` pattern but scoped to
/// NPC→player tracking only. NPC-to-NPC vision is stored in `NpcVisionState`
/// for direct query by downstream systems (#244 awareness) without flooding
/// the knowledge event queue.
///
/// Also updates `NpcMemory` with last-known positions and zone inferences.
pub fn emit_npc_vision_events(
time: Res<SimulationTime>,
registry: Res<EntityRegistry>,
mut event_queue: ResMut<KnowledgeEventQueue>,
player_query: Query<(Entity, &TilePosition), With<PlayerCharacter>>,
mut npc_query: Query<
(
Entity,
&NpcVisionState,
&mut NpcMemory,
Option<&KnowledgeGraph>,
),
(With<Npc>, With<ActiveSim>),
>,
) {
let Ok((player_entity, player_pos)) = player_query.single() else {
return;
};
let Some(player_sid) = registry.to_stable(player_entity) else {
return;
};
for (npc_entity, vision_state, mut memory, knowledge_graph) in npc_query.iter_mut() {
if vision_state.player_visible {
// Player is in LOS — update memory and emit DirectObservation
memory.last_known.insert(
player_sid,
LastKnownEntry {
position: *player_pos,
observed_tick: time.tick,
zone_inference: None, // Active observation clears inference
},
);
event_queue.push(KnowledgeEvent {
observer: npc_entity,
tick: time.tick,
event_type: KnowledgeEventType::DirectObservation {
target: player_entity,
position: *player_pos,
},
});
} else {
// Player NOT in LOS — check if they WERE Direct (just left)
let was_direct = knowledge_graph
.and_then(|kg| {
kg.entity_knowledge(&player_sid)
.map(|k| k.confidence == crate::knowledge::KnowledgeConfidence::Direct)
})
.unwrap_or(false);
if was_direct {
// Player just left this NPC's LOS — emit LeftLOS
event_queue.push(KnowledgeEvent {
observer: npc_entity,
tick: time.tick,
event_type: KnowledgeEventType::LeftLOS {
target: player_entity,
},
});
// Create zone inference — NPC remembers where they last saw the player
if let Some(entry) = memory.last_known.get_mut(&player_sid) {
entry.zone_inference = Some(ZoneInference {
last_seen_position: entry.position,
observed_tick: entry.observed_tick,
degrades_at_tick: time.tick + INFERENCE_DEGRADE_TICKS,
});
}
}
}
}
}
/// Degrade stale zone inferences in NPC memory.
///
/// Runs once per game-minute (every 10 ticks per D-031). When a zone
/// inference passes its degradation tick, the inference is removed.
pub fn degrade_npc_inferences(
time: Res<SimulationTime>,
mut npc_query: Query<&mut NpcMemory, (With<Npc>, With<ActiveSim>)>,
) {
if time.tick % TICKS_PER_GAME_MINUTE != 0 {
return;
}
for mut memory in npc_query.iter_mut() {
for entry in memory.last_known.values_mut() {
if let Some(ref inference) = entry.zone_inference {
if time.tick >= inference.degrades_at_tick {
entry.zone_inference = None;
}
}
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
use bevy_ecs::world::World;
fn setup_world(width: i32, height: i32) -> World {
let mut world = World::new();
world.insert_resource(SimulationTime::default());
world.insert_resource(WalkabilityMap::new(width, height, 1));
world.init_resource::<KnowledgeEventQueue>();
world.init_resource::<EntityRegistry>();
world.init_resource::<NaiveSpatialIndex>();
world.init_resource::<VisibilityGeometry>();
world.init_resource::<ActivePerceptionMode>();
world
}
fn pos(x: i32, y: i32) -> TilePosition {
TilePosition::new(x, y, 0)
}
// --- compute_npc_vision tests ---
#[test]
fn npc_sees_nearby_entity_in_open_field() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let mut spatial = NaiveSpatialIndex::new();
// NPC at (16, 16), target at (16, 14) — 2 tiles away, clear LOS
let npc = world
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
.id();
let npc_sid = registry.register(npc);
spatial.update(npc, pos(16, 16));
let target = world.spawn(pos(16, 14)).id();
let target_sid = registry.register(target);
spatial.update(target, pos(16, 14));
// Player entity (required for player_stable_id lookup)
let player = world.spawn((PlayerCharacter, pos(0, 0))).id();
registry.register(player);
world.insert_resource(registry);
world.insert_resource(spatial);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_npc_vision);
schedule.run(&mut world);
let vision = world.get::<NpcVisionState>(npc).unwrap();
assert!(
vision.visible_entities.contains(&target_sid),
"NPC should see nearby entity in open field"
);
assert!(!vision.player_visible, "player is far away");
let _ = npc_sid; // registered for completeness
}
#[test]
fn npc_cannot_see_through_wall() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let mut spatial = NaiveSpatialIndex::new();
let npc = world
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
.id();
registry.register(npc);
spatial.update(npc, pos(16, 16));
// Target behind a wall
let target = world.spawn(pos(16, 14)).id();
let target_sid = registry.register(target);
spatial.update(target, pos(16, 14));
// Wall between NPC and target
let mut walkability = world.resource_mut::<WalkabilityMap>();
walkability.set_walkable(&pos(16, 15), false);
drop(walkability);
world.insert_resource(registry);
world.insert_resource(spatial);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_npc_vision);
schedule.run(&mut world);
let vision = world.get::<NpcVisionState>(npc).unwrap();
assert!(
!vision.visible_entities.contains(&target_sid),
"NPC should not see entity behind wall"
);
}
#[test]
fn npc_detects_player_visible() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let mut spatial = NaiveSpatialIndex::new();
let npc = world
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
.id();
registry.register(npc);
spatial.update(npc, pos(16, 16));
let player = world.spawn((PlayerCharacter, pos(16, 14))).id();
registry.register(player);
spatial.update(player, pos(16, 14));
world.insert_resource(registry);
world.insert_resource(spatial);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_npc_vision);
schedule.run(&mut world);
let vision = world.get::<NpcVisionState>(npc).unwrap();
assert!(vision.player_visible, "NPC should detect player in LOS");
}
#[test]
fn npc_does_not_see_entity_on_different_z_level() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let mut spatial = NaiveSpatialIndex::new();
let npc = world
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
.id();
registry.register(npc);
spatial.update(npc, pos(16, 16));
// Target at same x/y but different z
let target = world.spawn(TilePosition::new(16, 14, 1)).id();
let target_sid = registry.register(target);
spatial.update(target, TilePosition::new(16, 14, 1));
world.insert_resource(registry);
world.insert_resource(spatial);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_npc_vision);
schedule.run(&mut world);
let vision = world.get::<NpcVisionState>(npc).unwrap();
assert!(
!vision.visible_entities.contains(&target_sid),
"NPC should not see entity on different z-level"
);
}
#[test]
fn npc_does_not_see_entity_beyond_range() {
let mut world = setup_world(64, 64);
let mut registry = EntityRegistry::new(0);
let mut spatial = NaiveSpatialIndex::new();
let npc = world
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
.id();
registry.register(npc);
spatial.update(npc, pos(16, 16));
// Target beyond NPC_VISION_RANGE (20 tiles)
let target = world.spawn(pos(16 + NPC_VISION_RANGE + 5, 16)).id();
let target_sid = registry.register(target);
spatial.update(target, pos(16 + NPC_VISION_RANGE + 5, 16));
world.insert_resource(registry);
world.insert_resource(spatial);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_npc_vision);
schedule.run(&mut world);
let vision = world.get::<NpcVisionState>(npc).unwrap();
assert!(
!vision.visible_entities.contains(&target_sid),
"NPC should not see entity beyond vision range"
);
}
#[test]
fn npc_does_not_see_self() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let mut spatial = NaiveSpatialIndex::new();
let npc = world
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
.id();
let npc_sid = registry.register(npc);
spatial.update(npc, pos(16, 16));
world.insert_resource(registry);
world.insert_resource(spatial);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_npc_vision);
schedule.run(&mut world);
let vision = world.get::<NpcVisionState>(npc).unwrap();
assert!(
!vision.visible_entities.contains(&npc_sid),
"NPC should not include itself in visible entities"
);
}
#[test]
fn npc_sees_non_npc_entity_on_same_tile() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let mut spatial = NaiveSpatialIndex::new();
// NPC at (16, 16)
let npc = world
.spawn((Npc, ActiveSim, pos(16, 16), NpcVisionState::default()))
.id();
registry.register(npc);
spatial.update(npc, pos(16, 16));
// Non-NPC entity on the same tile (e.g. dropped item)
let item = world.spawn(pos(16, 16)).id();
let item_sid = registry.register(item);
spatial.update(item, pos(16, 16));
world.insert_resource(registry);
world.insert_resource(spatial);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_npc_vision);
schedule.run(&mut world);
let vision = world.get::<NpcVisionState>(npc).unwrap();
assert!(
vision.visible_entities.contains(&item_sid),
"NPC should see non-NPC entity sharing its tile"
);
}
#[test]
fn background_npc_not_processed() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let mut spatial = NaiveSpatialIndex::new();
// Background NPC — should NOT have its vision computed
let npc = world
.spawn((
Npc,
crate::simulation::tier::BackgroundSim,
pos(16, 16),
NpcVisionState::default(),
))
.id();
registry.register(npc);
spatial.update(npc, pos(16, 16));
let target = world.spawn(pos(16, 14)).id();
let target_sid = registry.register(target);
spatial.update(target, pos(16, 14));
world.insert_resource(registry);
world.insert_resource(spatial);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(compute_npc_vision);
schedule.run(&mut world);
let vision = world.get::<NpcVisionState>(npc).unwrap();
assert!(
vision.visible_entities.is_empty(),
"Background NPC should not have vision computed"
);
let _ = target_sid;
}
// --- emit_npc_vision_events tests ---
#[test]
fn npc_seeing_player_emits_direct_observation() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let player = world.spawn((PlayerCharacter, pos(16, 14))).id();
let player_sid = registry.register(player);
let mut vision = NpcVisionState::default();
vision.visible_entities.insert(player_sid);
vision.player_visible = true;
let npc = world
.spawn((
Npc,
ActiveSim,
pos(16, 16),
vision,
NpcMemory::default(),
KnowledgeGraph::new(),
))
.id();
registry.register(npc);
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(emit_npc_vision_events);
schedule.run(&mut world);
let queue = world.resource::<KnowledgeEventQueue>();
assert!(!queue.is_empty(), "should emit DirectObservation for player");
let event = &queue.events[0];
assert_eq!(event.observer, npc);
assert!(matches!(
event.event_type,
KnowledgeEventType::DirectObservation { target, .. } if target == player
));
}
#[test]
fn npc_losing_player_emits_left_los() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let player = world.spawn((PlayerCharacter, pos(16, 14))).id();
let player_sid = registry.register(player);
// NPC that had Direct knowledge of player (player was just visible)
let mut kg = KnowledgeGraph::new();
kg.observe_entity(player_sid, pos(16, 14), 50);
// Memory with last known position
let mut memory = NpcMemory::default();
memory.last_known.insert(
player_sid,
LastKnownEntry {
position: pos(16, 14),
observed_tick: 50,
zone_inference: None,
},
);
// Vision state: player NOT visible now
let vision = NpcVisionState::default();
let npc = world
.spawn((Npc, ActiveSim, pos(16, 16), vision, memory, kg))
.id();
registry.register(npc);
world.insert_resource(registry);
let mut time = SimulationTime::default();
time.tick = 60;
world.insert_resource(time);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(emit_npc_vision_events);
schedule.run(&mut world);
let queue = world.resource::<KnowledgeEventQueue>();
let has_left_los = queue.events.iter().any(|e| {
matches!(
e.event_type,
KnowledgeEventType::LeftLOS { target } if target == player
)
});
assert!(has_left_los, "should emit LeftLOS when player leaves NPC LOS");
// Check zone inference was created
let npc_memory = world.get::<NpcMemory>(npc).unwrap();
let entry = npc_memory.last_known.get(&player_sid).unwrap();
assert!(
entry.zone_inference.is_some(),
"zone inference should be created when player leaves LOS"
);
assert_eq!(
entry.zone_inference.as_ref().unwrap().degrades_at_tick,
60 + INFERENCE_DEGRADE_TICKS
);
}
#[test]
fn npc_updates_memory_on_player_observation() {
let mut world = setup_world(32, 32);
let mut registry = EntityRegistry::new(0);
let player = world.spawn((PlayerCharacter, pos(10, 10))).id();
let player_sid = registry.register(player);
let mut vision = NpcVisionState::default();
vision.visible_entities.insert(player_sid);
vision.player_visible = true;
let npc = world
.spawn((
Npc,
ActiveSim,
pos(16, 16),
vision,
NpcMemory::default(),
KnowledgeGraph::new(),
))
.id();
registry.register(npc);
let mut time = SimulationTime::default();
time.tick = 100;
world.insert_resource(time);
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(emit_npc_vision_events);
schedule.run(&mut world);
let memory = world.get::<NpcMemory>(npc).unwrap();
let entry = memory.last_known.get(&player_sid).unwrap();
assert_eq!(entry.position, pos(10, 10));
assert_eq!(entry.observed_tick, 100);
assert!(entry.zone_inference.is_none(), "active observation = no inference");
}
// --- degrade_npc_inferences tests ---
#[test]
fn inference_degrades_after_threshold() {
let mut world = setup_world(32, 32);
let player_sid = StableId(1);
let mut memory = NpcMemory::default();
memory.last_known.insert(
player_sid,
LastKnownEntry {
position: pos(10, 10),
observed_tick: 50,
zone_inference: Some(ZoneInference {
last_seen_position: pos(10, 10),
observed_tick: 50,
degrades_at_tick: 100,
}),
},
);
let npc = world.spawn((Npc, ActiveSim, memory)).id();
// Tick 90 (before degradation, on minute boundary)
let mut time = SimulationTime::default();
time.tick = 90;
world.insert_resource(time);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(degrade_npc_inferences);
schedule.run(&mut world);
let mem = world.get::<NpcMemory>(npc).unwrap();
assert!(
mem.last_known[&player_sid].zone_inference.is_some(),
"inference should survive before degradation tick"
);
// Tick 100 (degradation tick, on minute boundary)
let mut time = SimulationTime::default();
time.tick = 100;
world.insert_resource(time);
let mut schedule2 = bevy_ecs::schedule::Schedule::default();
schedule2.add_systems(degrade_npc_inferences);
schedule2.run(&mut world);
let mem = world.get::<NpcMemory>(npc).unwrap();
assert!(
mem.last_known[&player_sid].zone_inference.is_none(),
"inference should be removed at degradation tick"
);
}
#[test]
fn inference_degradation_skips_non_minute_ticks() {
let mut world = setup_world(32, 32);
let player_sid = StableId(1);
let mut memory = NpcMemory::default();
memory.last_known.insert(
player_sid,
LastKnownEntry {
position: pos(10, 10),
observed_tick: 50,
zone_inference: Some(ZoneInference {
last_seen_position: pos(10, 10),
observed_tick: 50,
degrades_at_tick: 55, // already past
}),
},
);
let npc = world.spawn((Npc, ActiveSim, memory)).id();
// Tick 57 — past degradation but NOT a minute boundary
let mut time = SimulationTime::default();
time.tick = 57;
world.insert_resource(time);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(degrade_npc_inferences);
schedule.run(&mut world);
let mem = world.get::<NpcMemory>(npc).unwrap();
assert!(
mem.last_known[&player_sid].zone_inference.is_some(),
"degradation should not run on non-minute ticks"
);
}
#[test]
fn no_events_when_no_player_entity() {
let mut world = setup_world(32, 32);
let registry = EntityRegistry::new(0);
let vision = NpcVisionState::default();
world.spawn((
Npc,
ActiveSim,
pos(16, 16),
vision,
NpcMemory::default(),
KnowledgeGraph::new(),
));
world.insert_resource(registry);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(emit_npc_vision_events);
schedule.run(&mut world); // should not panic
let queue = world.resource::<KnowledgeEventQueue>();
assert!(queue.is_empty(), "no events when no player entity exists");
}
}
+9
View File
@@ -19,6 +19,7 @@ use crate::perception::vision_cone::Facing;
use crate::simulation::contraband::ScanEventBuffer;
use crate::simulation::conversation::ConversationEventBuffer;
use crate::simulation::dialogue::DialogueResponseBuffer;
use crate::simulation::examine::ExamineResultBuffer;
use crate::simulation::follow::FollowTarget;
use crate::simulation::interaction::NearbyInteractionBuffer;
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
@@ -83,6 +84,7 @@ pub fn compute_observer_snapshot(
Option<&mut ScanEventBuffer>,
Option<&mut ConversationEventBuffer>,
Option<&FollowTarget>,
Option<&mut ExamineResultBuffer>,
),
With<PlayerCharacter>,
>,
@@ -98,6 +100,7 @@ pub fn compute_observer_snapshot(
poi_query: Query<&PointOfInterest>,
mut buffer: ResMut<SnapshotBuffer>,
sim_rng: Option<Res<SimRng>>,
pressure_query: Query<&crate::simulation::pressure::CharacterPressure, With<PlayerCharacter>>,
) {
let Ok((
observer_entity,
@@ -114,6 +117,7 @@ pub fn compute_observer_snapshot(
mut scan_event_buffer_opt,
mut conversation_buffer_opt,
follow_target_opt,
mut examine_result_buffer_opt,
)) = observer_query.single_mut()
else {
tracing::error!("compute_observer_snapshot: PlayerCharacter query failed");
@@ -199,6 +203,7 @@ pub fn compute_observer_snapshot(
let current_monologue = monologue_buffer.take();
let dialogue_response = dialogue_response_opt.as_mut().and_then(|buf| buf.take());
let examine_result = examine_result_buffer_opt.as_mut().and_then(|buf| buf.take());
let scan_events = scan_event_buffer_opt
.as_mut()
.map(|buf| buf.take())
@@ -391,6 +396,10 @@ pub fn compute_observer_snapshot(
conversation_events,
conversation_ended,
follow_state,
examine_result,
character_pressure: pressure_query.iter().next().map(|p| {
crate::simulation::pressure::CharacterPressureWire::from(p)
}),
sound_events,
rng_seed: sim_rng.as_deref().map(|r| r.seed()),
poi_list,
+166
View File
@@ -2583,3 +2583,169 @@ fn no_zone_map_resource_tiles_have_no_zone_id() {
);
}
}
// ---------------------------------------------------------------------------
// #337 — Tell state → snapshot integration (D-024 tell system)
// ---------------------------------------------------------------------------
/// Helper: run derive_tell_state + two-stage observer pipeline together.
fn run_tell_plus_observer_pipeline(world: &mut World) {
use crate::npc::tell_state::derive_tell_state;
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems((
derive_tell_state,
compute_visibility_geometry.after(derive_tell_state),
compute_observer_snapshot
.after(compute_visibility_geometry)
.after(derive_tell_state),
));
schedule.run(world);
}
#[test]
fn tell_state_nervous_appears_in_snapshot_for_major_secret_high_stress() {
// Spec (#337, D-024): NPC with Major secret + stress past midpoint shows
// TellCategory::Nervous in ObserverSnapshot.entities[].tell_state.
// End-to-end pipeline: axis values → derive_tell_state → DerivedTellState
// → compute_observer_snapshot → VisibleEntity.tell_state.
use crate::npc::mood::{MoodState, NpcMood};
use crate::npc::tell_state::{DerivedTellState, TellCategory};
use crate::npc::{Contentment, Npc, Secret, SecretSeverity, ToleranceThreshold};
use crate::simulation::tier::ActiveSim;
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// NPC directly north — in forward LOS — with Major secret + stress > midpoint.
// stress=60, threshold=100 → stress*2=120 > 100 → Nervous (D-024 priority 2)
world.spawn((
Npc,
ActiveSim,
TilePosition::new(16, 14, 0),
Secret {
description: "criminal record".into(),
severity: SecretSeverity::Major,
known_by: vec![],
},
ToleranceThreshold { current_stress: 60, threshold: 100 },
Contentment { level: 0 },
MoodState { mood: NpcMood::Neutral, changed_tick: 0 },
DerivedTellState::default(),
));
run_tell_plus_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
let npc = snapshot
.entities
.iter()
.find(|e| matches!(e.kind, EntityKind::Npc))
.expect("NPC should be visible in snapshot");
assert_eq!(
npc.tell_state,
Some(TellCategory::Nervous),
"NPC with Major secret + stress past midpoint should show Nervous tell in snapshot"
);
}
#[test]
fn tell_state_none_for_neutral_npc_in_snapshot() {
// Spec (#337, D-024): neutral NPC shows tell_state = None in snapshot.
// Verifies the pipeline correctly omits tell when no conditions are met.
use crate::npc::mood::{MoodState, NpcMood};
use crate::npc::tell_state::DerivedTellState;
use crate::npc::{Contentment, Npc, Secret, SecretSeverity, ToleranceThreshold};
use crate::simulation::tier::ActiveSim;
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// NPC in LOS — neutral state (Minor secret, low stress, neutral mood, low contentment)
world.spawn((
Npc,
ActiveSim,
TilePosition::new(16, 14, 0),
Secret {
description: "minor embarrassment".into(),
severity: SecretSeverity::Minor,
known_by: vec![],
},
ToleranceThreshold { current_stress: 10, threshold: 100 },
Contentment { level: 0 },
MoodState { mood: NpcMood::Neutral, changed_tick: 0 },
DerivedTellState::default(),
));
run_tell_plus_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
let npc = snapshot
.entities
.iter()
.find(|e| matches!(e.kind, EntityKind::Npc))
.expect("NPC should be visible in snapshot");
assert_eq!(
npc.tell_state,
None,
"neutral NPC should have no tell state in snapshot"
);
}
#[test]
fn tell_state_none_when_npc_has_no_derived_tell_component() {
// Spec (#337): NPC without DerivedTellState component has tell_state = None.
// Verifies Option<&DerivedTellState> query handles absent component gracefully.
let mut world = setup_world(32, 32);
world.spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing(FacingDirection::North),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueBuffer::default(),
));
// NPC with no DerivedTellState component at all
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)));
run_observer_pipeline(&mut world);
let buffer = world.resource::<SnapshotBuffer>();
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
let npc = snapshot
.entities
.iter()
.find(|e| matches!(e.kind, EntityKind::Npc))
.expect("NPC should be visible in snapshot");
assert_eq!(
npc.tell_state,
None,
"NPC without DerivedTellState component should have tell_state = None"
);
}
+374
View File
@@ -0,0 +1,374 @@
//! Examine interaction system (#242).
//!
//! Handles the Examine verb: player examines an NPC or object at close range,
//! generating character-filtered observation text and a DirectObservation
//! KnowledgeGraph entry.
//!
//! Pipeline:
//! Interact { verb: "Examine NPC" | "ExamineNpc" | "ExamineObject" }
//! → process_player_input inserts ExamineRequest on player
//! → process_examine_interaction reads request, generates text, pushes KG event
//! → ExamineResultBuffer consumed by compute_observer_snapshot
//! → ObserverSnapshot.examine_result delivered to client
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::bridge::types::CharacterArchetype;
use crate::knowledge::events::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType};
use crate::knowledge::EntityRegistry;
use crate::npc::mood::{MoodState, NpcMood};
use crate::npc::{PersonalityTrait, PersonalityTraits, ToleranceThreshold};
use crate::simulation::interaction::CLOSE_RANGE;
use crate::simulation::movement::{PlayerCharacter, TilePosition};
use crate::simulation::time::SimulationTime;
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
/// Marker: player requested Examine interaction with a target entity this tick.
///
/// Inserted by process_player_input when verb == "Examine NPC", "ExamineNpc",
/// "Examine Object", or "ExamineObject". Consumed and removed by
/// process_examine_interaction each tick.
#[derive(Component, Debug)]
pub struct ExamineRequest {
pub target: Entity,
}
/// Character-filtered examination result for snapshot delivery.
///
/// Content differs per CharacterArchetype:
/// Smuggler — physical threat read, cargo-handling posture, opportunity windows.
/// Detective — procedural tells, behavioral inconsistencies, stress indicators.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExamineResultEvent {
/// Character-filtered observation text for client display.
pub text: String,
/// Wire-format entity identifier of the examined entity.
pub target_entity_id: u64,
}
/// Buffer holding the examine result for snapshot inclusion.
///
/// Consumed once per snapshot via `take()`. Cleared at snapshot build time.
/// Attach to the player entity alongside other buffer components.
#[derive(Component, Debug, Default)]
pub struct ExamineResultBuffer {
pub(crate) result: Option<ExamineResultEvent>,
}
impl ExamineResultBuffer {
/// Drain and return the examine result, leaving the buffer empty.
pub fn take(&mut self) -> Option<ExamineResultEvent> {
self.result.take()
}
}
// ---------------------------------------------------------------------------
// Text generation (deterministic, integer-only — D-010)
// ---------------------------------------------------------------------------
/// Stress ratio 0..=100 derived from ToleranceThreshold components.
/// Uses integer multiplication to avoid division by zero.
fn stress_ratio(threshold: &ToleranceThreshold) -> u8 {
if threshold.threshold <= 0 {
return 0;
}
((threshold.current_stress.max(0) as i32 * 100) / threshold.threshold as i32).clamp(0, 100)
as u8
}
/// Map NpcMood to a terse descriptor shared by both archetypes.
fn mood_word(mood: NpcMood) -> &'static str {
match mood {
NpcMood::Neutral => "neutral",
NpcMood::Anxious => "anxious",
NpcMood::Frustrated => "frustrated",
NpcMood::Content => "at ease",
NpcMood::Suspicious => "watchful",
NpcMood::Warm => "open",
NpcMood::Hostile => "hostile",
NpcMood::Focused => "focused",
}
}
fn has_trait(traits_opt: Option<&PersonalityTraits>, t: PersonalityTrait) -> bool {
traits_opt.map(|p| p.traits.contains(&t)).unwrap_or(false)
}
/// Generate character-filtered examination text from NPC component state.
/// All logic is pure, deterministic, and integer-based (D-010).
pub fn generate_examine_text(
mood: NpcMood,
ratio: u8,
archetype: CharacterArchetype,
traits_opt: Option<&PersonalityTraits>,
) -> String {
let stress_label = match ratio {
0..=30 => "relaxed",
31..=60 => "tense",
61..=85 => "stressed",
_ => "near breaking point",
};
let mood_label = mood_word(mood);
match archetype {
CharacterArchetype::Smuggler => {
// Physical threat read + cargo opportunity window
let threat = if matches!(mood, NpcMood::Hostile | NpcMood::Suspicious) {
"Threat posture. Don't push it."
} else if has_trait(traits_opt, PersonalityTrait::Bold) {
"Confident bearing. Will push back if cornered."
} else if has_trait(traits_opt, PersonalityTrait::Cautious) {
"Nervous type. Predictable under pressure."
} else {
"No obvious threat read."
};
let window = if ratio > 60 {
"Too distracted to track cargo movement."
} else if matches!(mood, NpcMood::Focused) {
"Paying close attention to this section."
} else {
"Standard patrol pattern. Window is there."
};
format!("Appears {mood_label}, {stress_label}. {threat} {window}")
}
CharacterArchetype::Detective => {
// Procedural tells + behavioral read
let tell = if has_trait(traits_opt, PersonalityTrait::Deceptive) {
"Controlled affect — practiced concealment."
} else if matches!(mood, NpcMood::Anxious | NpcMood::Frustrated) {
"Involuntary stress markers present."
} else if matches!(mood, NpcMood::Suspicious) {
"Scanning. Aware of being observed."
} else {
"Baseline presentation."
};
let read = if ratio > 60 {
"Under pressure — potential liability or asset."
} else if matches!(mood, NpcMood::Content | NpcMood::Warm) {
"Comfortable. Less guarded than usual."
} else {
"Routine behavior pattern."
};
format!("Subject: {mood_label}, {stress_label}. {tell} {read}")
}
}
}
// ---------------------------------------------------------------------------
// System
// ---------------------------------------------------------------------------
/// Process examine interaction: generate character-filtered observation text,
/// push DirectObservation to KnowledgeGraph, write result to ExamineResultBuffer.
///
/// System ordering: after process_player_input, before compute_observer_snapshot.
#[allow(clippy::type_complexity)]
pub fn process_examine_interaction(
mut commands: Commands,
time: Res<SimulationTime>,
registry: Res<EntityRegistry>,
mut kg_events: ResMut<KnowledgeEventQueue>,
mut player_query: Query<
(
Entity,
&TilePosition,
&ExamineRequest,
Option<&CharacterArchetype>,
&mut ExamineResultBuffer,
),
With<PlayerCharacter>,
>,
npc_query: Query<(
&TilePosition,
Option<&MoodState>,
Option<&ToleranceThreshold>,
Option<&PersonalityTraits>,
)>,
) {
let Ok((player_entity, player_pos, examine_req, archetype_opt, mut result_buffer)) =
player_query.single_mut()
else {
return;
};
let target = examine_req.target;
let archetype = archetype_opt.copied().unwrap_or_default();
// Range check — examine requires close range (same as Talk/Confront)
let Ok((target_pos, mood_opt, tolerance_opt, traits_opt)) = npc_query.get(target) else {
tracing::warn!(?target, "process_examine_interaction: target not in query");
commands.entity(player_entity).remove::<ExamineRequest>();
return;
};
let distance = player_pos.manhattan_distance(target_pos).unwrap_or(u32::MAX);
if distance > CLOSE_RANGE {
tracing::info!(
distance,
"Examine: target out of range (max {})",
CLOSE_RANGE
);
commands.entity(player_entity).remove::<ExamineRequest>();
return;
}
let mood = mood_opt.map(|m| m.mood).unwrap_or(NpcMood::Neutral);
let ratio = tolerance_opt.map(stress_ratio).unwrap_or(0);
let text = generate_examine_text(mood, ratio, archetype, traits_opt);
// Push DirectObservation to KnowledgeEventQueue
kg_events.push(KnowledgeEvent {
observer: player_entity,
tick: time.tick,
event_type: KnowledgeEventType::DirectObservation {
target,
position: *target_pos,
},
});
// Resolve target wire ID for snapshot
let target_entity_id = registry.to_stable(target).map(|sid| sid.0).unwrap_or_else(|| {
tracing::warn!(?target, "Examine: target not in EntityRegistry, using bits");
target.to_bits()
});
result_buffer.result = Some(ExamineResultEvent {
text,
target_entity_id,
});
tracing::debug!(
target_entity_id,
"Examine: DirectObservation pushed, result written to buffer"
);
commands.entity(player_entity).remove::<ExamineRequest>();
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::npc::PersonalityTrait;
fn traits(t: &[PersonalityTrait]) -> PersonalityTraits {
PersonalityTraits { traits: t.to_vec() }
}
#[test]
fn smuggler_hostile_npc_gives_threat_read() {
let text = generate_examine_text(
NpcMood::Hostile,
20,
CharacterArchetype::Smuggler,
None,
);
assert!(text.contains("Threat posture"), "expected threat read, got: {text}");
}
#[test]
fn smuggler_focused_npc_notes_attention() {
let text = generate_examine_text(
NpcMood::Focused,
30,
CharacterArchetype::Smuggler,
None,
);
assert!(text.contains("close attention"), "expected attention note, got: {text}");
}
#[test]
fn smuggler_high_stress_identifies_distraction() {
let text = generate_examine_text(
NpcMood::Anxious,
80,
CharacterArchetype::Smuggler,
None,
);
assert!(text.contains("Too distracted"), "expected distraction read, got: {text}");
}
#[test]
fn detective_deceptive_npc_notes_concealment() {
let t = traits(&[PersonalityTrait::Deceptive]);
let text = generate_examine_text(
NpcMood::Neutral,
20,
CharacterArchetype::Detective,
Some(&t),
);
assert!(text.contains("Controlled affect"), "expected concealment note, got: {text}");
}
#[test]
fn detective_anxious_npc_notes_stress_markers() {
let text = generate_examine_text(
NpcMood::Anxious,
50,
CharacterArchetype::Detective,
None,
);
assert!(
text.contains("stress markers"),
"expected stress markers, got: {text}"
);
}
#[test]
fn detective_content_npc_notes_low_guard() {
let text = generate_examine_text(
NpcMood::Content,
10,
CharacterArchetype::Detective,
None,
);
assert!(
text.contains("Less guarded"),
"expected low guard note, got: {text}"
);
}
#[test]
fn stress_ratio_zero_when_threshold_zero() {
let t = ToleranceThreshold { current_stress: 50, threshold: 0 };
assert_eq!(stress_ratio(&t), 0);
}
#[test]
fn stress_ratio_clamped_at_100() {
let t = ToleranceThreshold { current_stress: 200, threshold: 100 };
assert_eq!(stress_ratio(&t), 100);
}
#[test]
fn stress_ratio_negative_stress_is_zero() {
let t = ToleranceThreshold { current_stress: -10, threshold: 70 };
assert_eq!(stress_ratio(&t), 0);
}
#[test]
fn examine_result_buffer_take_drains() {
let mut buf = ExamineResultBuffer::default();
assert!(buf.take().is_none());
buf.result = Some(ExamineResultEvent {
text: "Test".into(),
target_entity_id: 42,
});
assert!(buf.take().is_some());
assert!(buf.take().is_none()); // idempotent drain
}
}
+69
View File
@@ -221,6 +221,16 @@ pub fn process_player_input(
current_tick,
);
}
Some("Examine NPC") | Some("ExamineNpc") | Some("Examine Object")
| Some("ExamineObject") | Some("Observe") => {
handle_examine(
&mut commands,
&registry,
&player_query,
&all_positions,
target_entity_id,
);
}
Some("Confront") => {
handle_confront(
&mut commands,
@@ -471,6 +481,65 @@ fn handle_talk(
tracing::debug!(target_id, "Talk: TalkRequest marker set on player");
}
/// Handle Examine verb: insert ExamineRequest marker on the player entity (#242).
/// Examine is available at close range (same as Talk). Range check here matches
/// the server-side guard in process_examine_interaction.
#[allow(clippy::type_complexity)]
fn handle_examine(
commands: &mut Commands,
registry: &EntityRegistry,
player_query: &Query<
(
Entity,
&TilePosition,
Option<&mut crate::simulation::stance::Stance>,
Option<&mut crate::simulation::stance::PlayerMoveCooldown>,
),
With<PlayerCharacter>,
>,
all_positions: &Query<&TilePosition>,
target_entity_id: Option<u64>,
) {
let Some(target_id) = target_entity_id else {
tracing::warn!("Examine verb without target_entity_id");
return;
};
let Ok((player_entity, player_pos, _, _)) = player_query.single() else {
return;
};
let target_stable = StableId(target_id);
let Some(target_entity) = registry.to_entity(&target_stable) else {
tracing::warn!(target_id, "Examine: target entity not in registry");
return;
};
// Server-side range check: reject Examine if target is beyond close range
if let Ok(target_pos) = all_positions.get(target_entity) {
let distance = player_pos
.manhattan_distance(target_pos)
.unwrap_or(u32::MAX);
if distance > crate::simulation::interaction::CLOSE_RANGE {
tracing::info!(
target_id,
distance,
"Examine: target out of range (max {})",
crate::simulation::interaction::CLOSE_RANGE,
);
return;
}
}
commands
.entity(player_entity)
.insert(crate::simulation::examine::ExamineRequest {
target: target_entity,
});
tracing::debug!(target_id, "Examine: ExamineRequest marker set on player");
}
/// Handle DialogueResponse action: set DialogueResponseRequest marker (#539).
/// The follow-up dialogue pipeline runs in process_dialogue_response (dialogue.rs).
///
+10
View File
@@ -7,6 +7,7 @@ use bevy_ecs::schedule::IntoScheduleConfigs;
pub mod contraband;
pub mod conversation;
pub mod dialogue;
pub mod examine;
pub mod follow;
pub mod input;
pub mod interaction;
@@ -19,7 +20,9 @@ pub mod path_follow;
pub mod pathfinding;
pub mod poi;
pub mod poi_discovery;
pub mod pressure;
pub mod rng;
pub mod save_state;
pub mod sound;
pub mod spatial;
pub mod stance;
@@ -78,6 +81,13 @@ impl Plugin for SimulationPlugin {
poi_discovery::discover_pois
.after(crate::perception::observer::compute_visibility_geometry)
.before(crate::perception::observer::compute_observer_snapshot),
examine::process_examine_interaction
.after(input::process_player_input)
.before(crate::perception::observer::compute_observer_snapshot),
// Character pressure (#248) — reads NPC awareness + relationship graph
pressure::update_character_pressure
.after(crate::npc::awareness::detect_player_awareness)
.before(crate::perception::observer::compute_observer_snapshot),
time::advance_tick.after(path_follow::cleanup_path_blocked),
),
);
+600
View File
@@ -0,0 +1,600 @@
//! 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 % 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::<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);
}
}
+320
View File
@@ -0,0 +1,320 @@
//! Save state data model (#256, D-010).
//!
//! `SaveStateV1` is the versioned serialization envelope for full game state.
//! Shares architecture with #96 (state serialization system) — this module
//! defines the data model only; ECS extraction and injection is #257.
//!
//! ## Write format: MessagePack
//!
//! Decision: MessagePack via `rmp_serde` (consistent with IPC protocol, D-020).
//! Both the IPC protocol and save files use the same codec for simplicity.
//! RON/YAML alternatives were considered — MessagePack chosen for consistency.
//! Human-readable debug output can be derived via the Debug impl or a separate
//! conversion step; a full RON bridge is deferred beyond v0.1.
//!
//! ## Versioning strategy
//!
//! `format_version: u8` bumps on breaking schema changes. Loader checks version
//! and rejects incompatible saves. `serde(default)` on optional new fields allows
//! forward-compatible extensions within the same major version.
//!
//! ## What is captured (v0.1 scope)
//!
//! - Simulation clock: `tick` + `tick_rate` for correct time reconstruction
//! - RNG seed: reproduce the same random sequence on load (D-010)
//! - Player knowledge graph: the observer's epistemics at save time
//! - Global relationship graph: the NPC social web (resource, not per-entity)
//! - Per-NPC summary state: the axis values that drive tell/mood/dialogue
//!
//! ## Not yet captured (deferred to #257 and beyond)
//!
//! - Full ECS world extraction/injection (system not yet written)
//! - Pathfinding state (reconstructed from position + routine)
//! - Tier transitions in-flight (dropped to background state on load)
//! - `NpcMemory` (intentionally excluded — stale inferences would be wrong after
//! reload; memory degrades naturally over time so reset-on-load is acceptable)
use serde::{Deserialize, Serialize};
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::types::StableId;
use crate::npc::{SecretSeverity, Relationships};
use crate::npc::relationships::RelationshipGraph;
use crate::simulation::movement::TilePosition;
use crate::simulation::time::TickRate;
/// Current format version. Bump on any breaking schema change.
pub const SAVE_FORMAT_VERSION: u8 = 1;
/// Top-level save state envelope (#256, D-010).
///
/// Serialized with MessagePack (rmp_serde) for storage. Load with
/// `rmp_serde::from_slice::<SaveStateV1>(&bytes)`.
///
/// Versioned from day one: check `format_version == SAVE_FORMAT_VERSION`
/// before trusting content.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SaveStateV1 {
/// Format version. Must equal `SAVE_FORMAT_VERSION` on load.
pub format_version: u8,
/// Simulation tick at the moment of save.
pub tick: u64,
/// Active tick rate at save time (Full/Half/Paused).
pub tick_rate: TickRate,
/// RNG seed active at save time for deterministic replay (D-010).
/// On load, seed the RNG from this value before advancing any ticks.
pub seed: u64,
/// Player character knowledge graph — the observer's epistemics at save time.
pub player_knowledge: KnowledgeGraph,
/// Global NPC relationship graph resource.
/// Serialized as a unit: all directed edges between NPCs and player.
pub relationship_graph: RelationshipGraph,
/// Per-NPC summary state for each simulated NPC.
/// Order is deterministic (sorted by stable_id in ascending order).
pub npc_states: Vec<NpcSaveState>,
}
/// Per-NPC state snapshot for `SaveStateV1`.
///
/// Captures the D-024 axis values and position. On load, the full NPC entity
/// is reconstructed by injecting these values into the appropriate components.
/// Field order matches the 10-axis model (D-024) for readability.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NpcSaveState {
/// Stable entity identifier (survives serialization — D-020).
pub stable_id: StableId,
/// Last known tile position.
pub position: TilePosition,
// Axis 2: Secret severity (description is regenerated from content on load)
pub secret_severity: SecretSeverity,
// Axis 3: Per-NPC relationship slots
pub relationships: Option<Relationships>,
// Axis 4: Tolerance — current stress level at save time
pub current_stress: i16,
/// Tolerance threshold value (does not change at runtime).
pub tolerance_threshold: i16,
// Axis 7: Contentment
pub contentment: i16,
/// Per-NPC knowledge graph (if present — Active-tier NPCs carry KG).
pub knowledge_graph: Option<KnowledgeGraph>,
}
impl SaveStateV1 {
/// Serialize to MessagePack bytes.
pub fn to_bytes(&self) -> Result<Vec<u8>, rmp_serde::encode::Error> {
rmp_serde::to_vec_named(self)
}
/// Deserialize from MessagePack bytes.
pub fn from_bytes(bytes: &[u8]) -> Result<Self, rmp_serde::decode::Error> {
rmp_serde::from_slice(bytes)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::graph::KnowledgeGraph;
use crate::knowledge::types::{FactId, FactKnowledge, KnowledgeConfidence, StableId};
use crate::npc::relationships::RelationshipGraph;
use crate::simulation::movement::TilePosition;
use crate::simulation::time::TickRate;
fn minimal_save_state() -> SaveStateV1 {
SaveStateV1 {
format_version: SAVE_FORMAT_VERSION,
tick: 0,
tick_rate: TickRate::Full,
seed: 42,
player_knowledge: KnowledgeGraph::new(),
relationship_graph: RelationshipGraph::new(),
npc_states: vec![],
}
}
// -----------------------------------------------------------------------
// Roundtrip tests: serialize → deserialize → re-serialize → bytes match
// -----------------------------------------------------------------------
#[test]
fn empty_save_state_roundtrips() {
// Spec (#256): roundtrip must produce identical state.
// Strategy: bytes(original) == bytes(roundtrip(original))
let state = minimal_save_state();
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
let bytes2 = recovered.to_bytes().expect("re-serialize");
assert_eq!(bytes, bytes2, "empty save state must roundtrip losslessly");
}
#[test]
fn format_version_preserved_in_roundtrip() {
let state = minimal_save_state();
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
assert_eq!(
recovered.format_version, SAVE_FORMAT_VERSION,
"format version must survive roundtrip"
);
}
#[test]
fn tick_and_seed_preserved_in_roundtrip() {
let mut state = minimal_save_state();
state.tick = 12345;
state.seed = 0xDEADBEEF;
state.tick_rate = TickRate::Half;
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
assert_eq!(recovered.tick, 12345);
assert_eq!(recovered.seed, 0xDEADBEEF);
assert_eq!(recovered.tick_rate, TickRate::Half);
}
#[test]
fn npc_states_roundtrip_with_position_and_axes() {
// Spec (#256): per-NPC D-024 axis values must survive serialization.
let mut state = minimal_save_state();
state.npc_states = vec![
NpcSaveState {
stable_id: StableId(101),
position: TilePosition::new(10, 20, 0),
secret_severity: crate::npc::SecretSeverity::Major,
relationships: None,
current_stress: 45,
tolerance_threshold: 80,
contentment: -15,
knowledge_graph: None,
},
NpcSaveState {
stable_id: StableId(202),
position: TilePosition::new(5, 5, 1),
secret_severity: crate::npc::SecretSeverity::Minor,
relationships: None,
current_stress: 0,
tolerance_threshold: 60,
contentment: 30,
knowledge_graph: None,
},
];
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
assert_eq!(recovered.npc_states.len(), 2);
let npc1 = &recovered.npc_states[0];
assert_eq!(npc1.stable_id, StableId(101));
assert_eq!(npc1.position, TilePosition::new(10, 20, 0));
assert_eq!(npc1.secret_severity, crate::npc::SecretSeverity::Major);
assert_eq!(npc1.current_stress, 45);
assert_eq!(npc1.tolerance_threshold, 80);
assert_eq!(npc1.contentment, -15);
let npc2 = &recovered.npc_states[1];
assert_eq!(npc2.stable_id, StableId(202));
assert_eq!(npc2.contentment, 30);
}
#[test]
fn player_knowledge_graph_roundtrips() {
// Spec (#256): KnowledgeGraph is "already serializable" — verify it
// survives a save state roundtrip intact.
use crate::knowledge::types::{KnowledgeSource, KnowledgeState};
use crate::simulation::movement::TilePosition;
let mut state = minimal_save_state();
let mut kg = KnowledgeGraph::new();
kg.observe_entity(StableId(50), TilePosition::new(3, 3, 0), 5);
// Add a fact
kg.facts.insert(
FactId("contraband.ring_exists".into()),
FactKnowledge {
confidence: KnowledgeConfidence::KnowsOf,
source: KnowledgeSource::Background,
state: KnowledgeState::Active,
acquired_tick: 100,
disclosure_blocked: false,
},
);
state.player_knowledge = kg;
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
// Roundtrip the recovered state again — bytes must still match
let bytes2 = recovered.to_bytes().expect("re-serialize");
assert_eq!(
bytes, bytes2,
"KnowledgeGraph roundtrip must be idempotent"
);
}
#[test]
fn relationship_graph_roundtrips() {
use crate::npc::relationships::RelationshipEdge;
use crate::npc::RelationshipKind;
use crate::knowledge::types::StableId;
let mut state = minimal_save_state();
let mut rg = RelationshipGraph::new();
rg.set_relationship(
StableId(1),
StableId(2),
RelationshipEdge {
kind: RelationshipKind::Friend,
trust: 5,
history: vec![],
last_interaction_tick: 0,
},
);
state.relationship_graph = rg;
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
let bytes2 = recovered.to_bytes().expect("re-serialize");
assert_eq!(bytes, bytes2, "RelationshipGraph roundtrip must be lossless");
}
#[test]
fn npc_with_knowledge_graph_roundtrips() {
// Spec (#256): Active-tier NPCs carry KnowledgeGraph — must survive roundtrip.
let mut state = minimal_save_state();
let mut npc_kg = KnowledgeGraph::new();
npc_kg.observe_entity(StableId(99), TilePosition::new(7, 7, 0), 10);
state.npc_states = vec![NpcSaveState {
stable_id: StableId(1),
position: TilePosition::new(1, 1, 0),
secret_severity: crate::npc::SecretSeverity::Minor,
relationships: None,
current_stress: 0,
tolerance_threshold: 50,
contentment: 0,
knowledge_graph: Some(npc_kg),
}];
let bytes = state.to_bytes().expect("serialize");
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
let bytes2 = recovered.to_bytes().expect("re-serialize");
assert_eq!(
bytes, bytes2,
"NPC KnowledgeGraph roundtrip must be lossless"
);
}
#[test]
fn save_format_version_constant_is_one() {
// Document the version explicitly so CI catches unintentional bumps.
assert_eq!(SAVE_FORMAT_VERSION, 1);
}
}
+22
View File
@@ -21,6 +21,15 @@ pub trait SpatialIndex: Send + Sync {
/// Return all entities at the exact `position`.
fn entities_at(&self, position: &TilePosition) -> Vec<Entity>;
/// Return all entities within Manhattan distance `radius` of `position`,
/// **including** entities exactly at `position`. Single-pass alternative to
/// `entities_in_range` + `entities_at`.
fn entities_within(&self, position: &TilePosition, radius: u32) -> Vec<Entity> {
let mut result = self.entities_in_range(position, radius);
result.extend(self.entities_at(position));
result
}
/// Insert or update an entity's position in the index.
fn update(&mut self, entity: Entity, position: TilePosition);
@@ -78,6 +87,19 @@ impl SpatialIndex for NaiveSpatialIndex {
.collect()
}
fn entities_within(&self, position: &TilePosition, radius: u32) -> Vec<Entity> {
self.entries
.iter()
.filter(|(_, pos)| {
pos == position
|| pos
.manhattan_distance(position)
.is_some_and(|d| d <= radius)
})
.map(|(entity, _)| *entity)
.collect()
}
fn update(&mut self, entity: Entity, position: TilePosition) {
if let Some(entry) = self.entries.iter_mut().find(|(e, _)| *e == entity) {
entry.1 = position;
+1
View File
@@ -183,6 +183,7 @@ pub fn setup_gauntlet(app: &mut App) {
profile,
profile.initial_stance(),
PlayerMoveCooldown::default(),
crate::simulation::pressure::CharacterPressure::default(),
))
.id();
registry.register(player);