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:
@@ -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![],
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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` — 0–100, 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 (0–100, 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
//!
|
||||
//! 30–80 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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
®istry,
|
||||
&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).
|
||||
///
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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 0–100, integer for D-010 determinism.
|
||||
#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct CharacterPressure {
|
||||
/// Exposure pressure: rises when NPCs notice the player watching them.
|
||||
/// Derived from aggregate `PlayerAwareness.suspicion_level` across Active NPCs.
|
||||
pub exposure: i32,
|
||||
/// Institutional pressure: detective-specific systemic pressure.
|
||||
/// Stub in v0.1 — future sprints wire this to detective interaction patterns.
|
||||
pub institutional: i32,
|
||||
/// Relationship pressure: rises when NPCs distrust the player.
|
||||
/// Derived from negative trust edges in the `RelationshipGraph`.
|
||||
pub relationship: i32,
|
||||
}
|
||||
|
||||
impl CharacterPressure {
|
||||
/// Total pressure as a simple average of all axes (0–100).
|
||||
///
|
||||
/// Uses integer division — remainders are floor-truncated (D-010, no floats).
|
||||
/// Maximum rounding error is 2 units (e.g. axis sum 101 → 33 instead of 33.67).
|
||||
pub fn total(&self) -> i32 {
|
||||
// Simple average, clamped. Integer division truncates toward zero (D-010).
|
||||
((self.exposure + self.institutional + self.relationship) / 3).clamp(0, 100)
|
||||
}
|
||||
|
||||
/// Dominant mood tag for monologue salience weighting.
|
||||
///
|
||||
/// Returns the D-035 mood tag that should be preferred when selecting
|
||||
/// monologue lines. `None` when pressure is low — baseline monologue
|
||||
/// selection applies.
|
||||
pub fn pressure_mood(&self) -> Option<&'static str> {
|
||||
let total = self.total();
|
||||
if total >= MOOD_ANXIOUS_THRESHOLD {
|
||||
Some("anxious")
|
||||
} else if total >= MOOD_FRUSTRATED_THRESHOLD {
|
||||
Some("frustrated")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire type for ObserverSnapshot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Character pressure data for client HUD display (#248).
|
||||
///
|
||||
/// Included in `ObserverSnapshot` when pressure is non-zero.
|
||||
/// Client renders as a tension/pressure indicator widget.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CharacterPressureWire {
|
||||
/// Exposure pressure (0–100).
|
||||
pub exposure: i32,
|
||||
/// Institutional pressure (0–100).
|
||||
pub institutional: i32,
|
||||
/// Relationship pressure (0–100).
|
||||
pub relationship: i32,
|
||||
/// Total pressure (0–100).
|
||||
pub total: i32,
|
||||
/// Dominant mood tag, if any.
|
||||
pub mood: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&CharacterPressure> for CharacterPressureWire {
|
||||
fn from(p: &CharacterPressure) -> Self {
|
||||
Self {
|
||||
exposure: p.exposure,
|
||||
institutional: p.institutional,
|
||||
relationship: p.relationship,
|
||||
total: p.total(),
|
||||
mood: p.pressure_mood().map(String::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Update character pressure from NPC awareness and relationship state.
|
||||
///
|
||||
/// Runs every `PRESSURE_UPDATE_INTERVAL` ticks (1 game-minute).
|
||||
///
|
||||
/// - **Exposure**: count Active NPCs with `suspicion_level > 0`, scale by
|
||||
/// `EXPOSURE_PER_SUSPICIOUS_NPC`, cap at 100
|
||||
/// - **Relationship**: count hostile trust edges (trust ≤ -3) toward the
|
||||
/// player in `RelationshipGraph`, scale by `RELATIONSHIP_PER_HOSTILE_EDGE`
|
||||
/// - **Institutional**: stub (0) in v0.1
|
||||
pub fn update_character_pressure(
|
||||
time: Res<SimulationTime>,
|
||||
awareness_query: Query<&PlayerAwareness, (With<Npc>, With<ActiveSim>)>,
|
||||
relationship_graph: Res<crate::npc::relationships::RelationshipGraph>,
|
||||
registry: Res<crate::knowledge::EntityRegistry>,
|
||||
mut player_query: Query<(Entity, &mut CharacterPressure), With<PlayerCharacter>>,
|
||||
) {
|
||||
// Only run on interval ticks
|
||||
if time.tick % 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user