merge: resolve CHANGELOG.md conflict from server branch

This commit is contained in:
2026-02-21 15:13:29 +01:00
35 changed files with 5463 additions and 72 deletions
+8
View File
@@ -12,6 +12,14 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- Follow target stub on GameState — `follow_target_id` field ready for server #241 Follow verb
- Manual exponential camera smoothing — CAMERA_SMOOTHING_SPEED constant (8.0), same lerp pattern as entity renderer (#117)
- 31 new Sprint 15 validation tests — camera smoothing, UI framework z-layers, entity footprint, Sprint 14 regressions
- SpatialIndex trait with naive Vec implementation — entities_in_range, entities_at, update methods with Manhattan distance (#340)
- NPC generation pipeline — procedural seeding of all 10 D-024 axes via SimRng with constraint validation (#92)
- Personality and tell system — 5 tell categories (Nervous, Angry, Friendly, Guarded, RoutineDeviation) derived from NPC axis values each tick (#90)
- Tolerance threshold monitoring — ToleranceBreachEvent on stress exceeding per-NPC threshold, mood FSM integration (#105)
- Routine deviation detection — RoutineDeviationEvent on wrong location/activity for day phase, absence detection, pathfinding-aware (#243)
- Follow mechanic — Follow verb, proximity/LOS tracking, double-frequency observation events, NPC suspicion accumulation, configurable thresholds (#241)
- Monologue event triggers — observe_npc, hear_sound, observe_anomaly, witness_interaction, post_conversation with D-035 context tags (#119)
- Protocol v13 — tell_state on VisibleEntity, follow_state on ObserverSnapshot, Follow verb
## [v0.1.14] — 2026-02-21
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1092,7 +1092,7 @@ dependencies = [
[[package]]
name = "settled-reach-server"
version = "0.1.13"
version = "0.1.14"
dependencies = [
"bevy_app",
"bevy_ecs",
+10 -1
View File
@@ -173,6 +173,11 @@ impl Plugin for BridgePlugin {
.after(crate::perception::anomaly::detect_anomalies),
crate::simulation::monologue::process_sprint_anomaly_monologue
.after(crate::simulation::monologue::trigger_recognition_monologue),
crate::simulation::monologue::trigger_event_monologue
.after(crate::simulation::monologue::process_sprint_anomaly_monologue)
.after(crate::simulation::sound::collect_sound_events)
.after(crate::simulation::conversation::run_npc_conversations)
.after(crate::simulation::dialogue::process_walk_away),
crate::simulation::dialogue::process_talk_interaction
.after(crate::simulation::input::process_player_input),
crate::simulation::dialogue::process_walk_away
@@ -180,10 +185,14 @@ impl Plugin for BridgePlugin {
.after(crate::simulation::dialogue::process_talk_interaction),
crate::simulation::dialogue::process_confrontation_response
.after(crate::simulation::input::process_player_input),
crate::simulation::follow::update_follow_state
.after(crate::perception::observer::compute_visibility_geometry)
.after(crate::simulation::movement::validate_movement)
.before(crate::perception::observer::compute_observer_snapshot),
crate::perception::observer::compute_observer_snapshot
.after(crate::perception::observer::compute_visibility_geometry)
.after(crate::simulation::interaction::compute_nearby_interactions)
.after(crate::simulation::monologue::process_sprint_anomaly_monologue)
.after(crate::simulation::monologue::trigger_event_monologue)
.after(crate::simulation::dialogue::process_talk_interaction)
.after(crate::simulation::dialogue::process_confrontation_response)
.before(crate::simulation::time::advance_tick),
+5
View File
@@ -240,6 +240,7 @@ mod tests {
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
tell_state: None,
},
VisibleEntity {
entity_id: 100,
@@ -250,6 +251,7 @@ mod tests {
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Known,
observation: EntityVisibility::Visible,
tell_state: None,
},
VisibleEntity {
entity_id: 200,
@@ -260,6 +262,7 @@ mod tests {
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
tell_state: None,
},
],
visible_tiles: vec![VisibleTile {
@@ -302,6 +305,7 @@ mod tests {
scan_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
sound_events: vec![],
rng_seed: None,
}
@@ -431,6 +435,7 @@ mod tests {
scan_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
sound_events: vec![],
rng_seed: None,
};
+20 -2
View File
@@ -15,7 +15,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
/// negotiation is unnecessary. Client should reject snapshots with version !=
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
/// period, then the default is removed once both sides are updated.
pub const PROTOCOL_VERSION: u8 = 12;
pub const PROTOCOL_VERSION: u8 = 13;
/// The ONLY data structure crossing the client-server boundary (D-020)
/// Contains all information visible to the observer at a given tick.
@@ -32,10 +32,12 @@ pub const PROTOCOL_VERSION: u8 = 12;
/// rng_seed (#527, deterministic replay — completes WRONG button loop).
/// v11 adds: zone_id on VisibleTile (#523, D-077 OQ-09 resolution + D-073 crossfade).
/// v12 adds: conversation_events, conversation_ended (#247, D-078 NPC-to-NPC conversations).
/// v13 adds: tell_state on VisibleEntity (#90, D-024 tell system — for future client use),
/// follow_state (#241, follow mechanic HUD state).
/// Future fields: ambient sound events, HUD state (D-020 expansion).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObserverSnapshot {
/// Protocol version for forward compatibility. Current: 12.
/// Protocol version for forward compatibility. Current: 13.
pub version: u8,
/// Simulation tick when this snapshot was produced
pub tick: u64,
@@ -98,6 +100,11 @@ pub struct ObserverSnapshot {
/// Client dismisses the passive dialogue panel for these pairs.
#[serde(default)]
pub conversation_ended: Vec<crate::simulation::conversation::ConversationEndEvent>,
/// Follow-mode state for client HUD display (#241).
/// Present when the player is actively following an NPC.
/// Client shows follow indicator with distance, LOS, and tension.
#[serde(default)]
pub follow_state: Option<crate::simulation::follow::FollowStateWire>,
/// 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).
@@ -270,6 +277,11 @@ pub struct VisibleEntity {
/// Visible = in LOS right now. Remembered = known but not in LOS.
#[serde(default)]
pub observation: EntityVisibility,
/// Current observable tell category for NPC entities (#90, D-024).
/// None for non-NPC entities or NPCs with no active tell this tick.
/// v0.1: field is emitted for future client use; client may ignore.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tell_state: Option<crate::npc::tell_state::TellCategory>,
}
/// Category of visible entity
@@ -452,6 +464,12 @@ pub enum VerbKind {
/// Furniture — sit/use
Sit,
// --- NPC extended verbs ---
/// Follow an NPC — designate as follow target (#241).
/// Close range only. Enters follow mode: server tracks distance, LOS,
/// and NPC suspicion. Replaces previous follow target if any.
Follow,
// --- Phase 2 verbs (observer, KG-gated, #422) ---
/// Confront an NPC about known facts/contradictions.
/// Phase 2 only: injected when observer has KnowsDetails+ confidence.
+893
View File
@@ -0,0 +1,893 @@
//! Procedural NPC generation pipeline (#92).
//!
//! Takes a `RoleDefinition`, seeds all 10 NPC axes via `SimRng` (D-010 principle 4),
//! applies constraint validation, and spawns a fully-populated NPC entity.
//!
//! ## Determinism guarantee
//! All randomness flows through `SimRng` (ChaCha20). Same seed → same NPC.
//! Integer arithmetic only — no floats, no HashMap, no OS entropy.
//!
//! ## 10 axes
//! 1. Want — primary drive and intensity
//! 2. Secret — vulnerability and severity
//! 3. Relationships — 03 key relationships drawn from a target pool
//! 4. Tolerance — threshold and initial stress (validated: stress < threshold)
//! 5. DailyRoutine — phase→location assignments from location pool
//! 6. InformationInventory — seeded facts from role definition
//! 7. Contentment — initial level in 30..+30
//! 8. PersonalityTraits — 23 traits, no contradictory pairs
//! 9. TellSystem — tells derived from personality + secret severity
//! 10. SkillSet — 24 skills from role focus + optional CombatCapability
//!
//! ## Constraint validation
//! - Relationships: no duplicate target StableIds
//! - Tolerance: initial stress strictly < threshold (never immediately triggers)
//! - Routine: at most one entry per DayPhase (deduplication by phase)
use rand::Rng;
use std::collections::BTreeMap;
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,
};
use crate::npc::mood::MoodState;
use crate::simulation::movement::TilePosition;
use crate::simulation::rng::SimRng;
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::DayPhase;
// ---------------------------------------------------------------------------
// RoleDefinition
// ---------------------------------------------------------------------------
/// Template driving procedural NPC generation.
///
/// Describes the role constraints for all 10 generation axes. The generator
/// uses `SimRng` to make choices within these constraints deterministically.
#[derive(Debug, Clone)]
pub struct RoleDefinition {
/// Human-readable role label (e.g. "guard", "dockworker", "administrator").
pub name: String,
/// Available phase→location pairs for routine generation (axis 5).
/// The generator picks at most one entry per `DayPhase` from this pool.
pub location_pool: Vec<(DayPhase, TilePosition)>,
/// Candidate `StableId`s for relationship targets (axis 3).
/// Generator draws 0`MAX_KEY_RELATIONSHIPS` unique targets from this pool.
pub relationship_targets: Vec<StableId>,
/// Seeded knowledge facts for this role (axis 6).
pub known_facts: Vec<(FactId, KnowledgeConfidence)>,
/// Skill types biased toward for this role (axis 10).
/// Generator always includes these, then adds random extras up to the cap.
pub skill_focus: Vec<Skill>,
/// Whether this role may receive a `CombatCapability` component.
pub combat_enabled: bool,
}
// ---------------------------------------------------------------------------
// Contradictory trait pairs
// ---------------------------------------------------------------------------
/// Returns `true` if the two traits are contradictory and cannot coexist.
fn traits_contradict(a: PersonalityTrait, b: PersonalityTrait) -> bool {
use PersonalityTrait::*;
matches!(
(a, b),
(Cautious, Bold)
| (Bold, Cautious)
| (Honest, Deceptive)
| (Deceptive, Honest)
| (Compassionate, Ruthless)
| (Ruthless, Compassionate)
| (Curious, Incurious)
| (Incurious, Curious)
| (Social, Reclusive)
| (Reclusive, Social)
)
}
// ---------------------------------------------------------------------------
// Enum pickers (deterministic index → variant)
// ---------------------------------------------------------------------------
fn pick_want_kind(idx: usize) -> WantKind {
use WantKind::*;
const VARIANTS: [WantKind; 9] = [
Wealth, Safety, Knowledge, Connection, Power, Freedom, Justice, Revenge, Happiness,
];
VARIANTS[idx % VARIANTS.len()]
}
fn pick_secret_severity(idx: usize) -> SecretSeverity {
match idx % 3 {
0 => SecretSeverity::Minor,
1 => SecretSeverity::Moderate,
_ => SecretSeverity::Major,
}
}
fn pick_relationship_kind(idx: usize) -> RelationshipKind {
use RelationshipKind::*;
const VARIANTS: [RelationshipKind; 7] =
[Colleague, Friend, Rival, Romantic, Family, Superior, Subordinate];
VARIANTS[idx % VARIANTS.len()]
}
fn pick_personality_trait(idx: usize) -> PersonalityTrait {
use PersonalityTrait::*;
const VARIANTS: [PersonalityTrait; 10] = [
Cautious, Bold, Honest, Deceptive, Compassionate, Ruthless, Curious, Incurious, Social,
Reclusive,
];
VARIANTS[idx % VARIANTS.len()]
}
fn pick_skill(idx: usize) -> Skill {
use Skill::*;
const VARIANTS: [Skill; 8] =
[Combat, Intimidation, Medical, Observation, Persuasion, Piloting, Stealth, Technical];
VARIANTS[idx % VARIANTS.len()]
}
fn pick_combat_style(idx: usize) -> CombatStyle {
match idx % 4 {
0 => CombatStyle::Ranged,
1 => CombatStyle::Melee,
2 => CombatStyle::Evasive,
_ => CombatStyle::Defensive,
}
}
// ---------------------------------------------------------------------------
// Axis generators
// ---------------------------------------------------------------------------
fn gen_want(rng: &mut SimRng, role: &RoleDefinition) -> Want {
let kind_idx = rng.rng.random_range(0..9_usize);
let intensity = rng.rng.random_range(1_u8..=10);
Want {
primary: pick_want_kind(kind_idx),
intensity,
description: format!("{} driven by {:?}", role.name, pick_want_kind(kind_idx)),
}
}
fn gen_secret(rng: &mut SimRng, role: &RoleDefinition) -> Secret {
let sev_idx = rng.rng.random_range(0..3_usize);
let severity = pick_secret_severity(sev_idx);
Secret {
description: format!("{} has a {:?} secret", role.name, severity),
severity,
known_by: vec![],
}
}
fn gen_relationships(rng: &mut SimRng, targets: &[StableId]) -> Relationships {
if targets.is_empty() {
return Relationships { entries: vec![] };
}
// Number of relationships: 0..=min(3, targets.len())
let max = MAX_KEY_RELATIONSHIPS.min(targets.len());
let count = rng.rng.random_range(0..=(max));
// Pick `count` unique targets — simple shuffle prefix via Fisher-Yates on indices.
let mut indices: Vec<usize> = (0..targets.len()).collect();
for i in 0..count {
let swap = rng.rng.random_range(i..targets.len());
indices.swap(i, swap);
}
let entries = indices[..count]
.iter()
.map(|&target_idx| {
let kind_idx = rng.rng.random_range(0..7_usize);
let trust: i8 = rng.rng.random_range(-4_i8..=4);
Relationship {
target_id: targets[target_idx],
kind: pick_relationship_kind(kind_idx),
trust_level: trust,
history: vec![],
}
})
.collect();
Relationships { entries }
}
fn gen_tolerance(rng: &mut SimRng) -> ToleranceThreshold {
// Threshold: 40..=80 — varies per NPC, not hardcoded.
let threshold: i16 = rng.rng.random_range(40_i16..=80);
// Constraint: initial stress strictly < threshold (never immediately triggers).
let stress_max = (threshold - 1).max(0);
let current_stress: i16 = if stress_max == 0 {
0
} else {
rng.rng.random_range(0_i16..stress_max)
};
ToleranceThreshold {
current_stress,
threshold,
}
}
fn gen_routine(rng: &mut SimRng, location_pool: &[(DayPhase, TilePosition)]) -> DailyRoutine {
if location_pool.is_empty() {
return DailyRoutine {
entries: vec![],
description: "No fixed schedule".into(),
};
}
// Deduplicate by phase: at most one entry per DayPhase.
// Build a BTreeMap from phase index → (phase, location) to guarantee uniqueness.
let mut phase_map: BTreeMap<u8, (DayPhase, TilePosition)> = BTreeMap::new();
for &(phase, loc) in location_pool {
let key = match phase {
DayPhase::Morning => 0,
DayPhase::Afternoon => 1,
DayPhase::Evening => 2,
DayPhase::Night => 3,
};
// If multiple entries for the same phase, pick deterministically.
// Use the last entry in the pool — caller controls priority by ordering.
phase_map.insert(key, (phase, loc));
}
// Select 1..=pool_size entries (biased toward having at least 2 phases).
let available: Vec<(DayPhase, TilePosition)> = phase_map.into_values().collect();
let count = rng.rng.random_range(1..=available.len());
// Pick `count` from the available phases (shuffle prefix).
let mut indices: Vec<usize> = (0..available.len()).collect();
for i in 0..count {
let swap = rng.rng.random_range(i..available.len());
indices.swap(i, swap);
}
let entries: Vec<RoutineEntry> = indices[..count]
.iter()
.map(|&i| {
let (phase, location) = available[i];
let activity_names = ["Work", "Patrol", "Rest", "Meeting", "Training", "Maintenance"];
let act_idx = rng.rng.random_range(0..activity_names.len());
RoutineEntry {
phase,
location,
activity: activity_names[act_idx].to_string(),
}
})
.collect();
DailyRoutine {
entries,
description: format!("Routine schedule"),
}
}
fn gen_information_inventory(facts: &[(FactId, KnowledgeConfidence)]) -> InformationInventory {
InformationInventory {
facts: facts
.iter()
.map(|(fact_id, confidence)| KnownFact {
fact_id: fact_id.clone(),
confidence: *confidence,
})
.collect(),
}
}
fn gen_contentment(rng: &mut SimRng) -> Contentment {
Contentment {
level: rng.rng.random_range(-20_i16..=20),
}
}
fn gen_personality_traits(rng: &mut SimRng) -> PersonalityTraits {
let count = rng.rng.random_range(2_usize..=3);
let mut chosen: Vec<PersonalityTrait> = Vec::with_capacity(count);
let mut attempts = 0_usize;
while chosen.len() < count && attempts < 50 {
attempts += 1;
let idx = rng.rng.random_range(0..10_usize);
let candidate = pick_personality_trait(idx);
// Reject if contradicts any already-chosen trait.
if chosen.iter().any(|&t| traits_contradict(t, candidate)) {
continue;
}
// Reject duplicates.
if chosen.contains(&candidate) {
continue;
}
chosen.push(candidate);
}
PersonalityTraits { traits: chosen }
}
fn gen_tells(traits: &PersonalityTraits, secret: &Secret) -> TellSystem {
let mut tells: Vec<Tell> = Vec::new();
// Guarded tell: Major secret → becomes evasive under stress.
if secret.severity == SecretSeverity::Major {
tells.push(Tell {
trigger: TellTrigger::StressAboveThreshold,
behavior: "becomes evasive and avoids eye contact".to_string(),
});
}
for &trait_ in &traits.traits {
match trait_ {
PersonalityTrait::Cautious => tells.push(Tell {
trigger: TellTrigger::StressAboveThreshold,
behavior: "checks surroundings repeatedly".to_string(),
}),
PersonalityTrait::Bold => tells.push(Tell {
trigger: TellTrigger::Always,
behavior: "maintains confident posture".to_string(),
}),
PersonalityTrait::Honest => tells.push(Tell {
trigger: TellTrigger::Always,
behavior: "makes direct eye contact".to_string(),
}),
PersonalityTrait::Deceptive => tells.push(Tell {
trigger: TellTrigger::StressAboveThreshold,
behavior: "affects exaggerated calm".to_string(),
}),
PersonalityTrait::Social => tells.push(Tell {
trigger: TellTrigger::Always,
behavior: "greets passersby unprompted".to_string(),
}),
PersonalityTrait::Reclusive => tells.push(Tell {
trigger: TellTrigger::Always,
behavior: "avoids eye contact and moves away from groups".to_string(),
}),
PersonalityTrait::Ruthless => tells.push(Tell {
trigger: TellTrigger::StressAboveThreshold,
behavior: "speaks curtly and dismisses others".to_string(),
}),
PersonalityTrait::Compassionate => tells.push(Tell {
trigger: TellTrigger::Always,
behavior: "pauses to check on distressed individuals".to_string(),
}),
PersonalityTrait::Curious => tells.push(Tell {
trigger: TellTrigger::Always,
behavior: "lingers near unusual activity".to_string(),
}),
PersonalityTrait::Incurious => tells.push(Tell {
trigger: TellTrigger::Always,
behavior: "moves through space without pausing".to_string(),
}),
}
}
// Cap at 3 tells — avoid overwhelming the observer snapshot.
tells.truncate(3);
TellSystem { tells }
}
fn gen_skills(rng: &mut SimRng, role: &RoleDefinition) -> (SkillSet, Option<CombatCapability>) {
let mut skills: BTreeMap<Skill, u8> = BTreeMap::new();
// Always include role skill focus with higher proficiency.
for &skill in &role.skill_focus {
let prof: u8 = rng.rng.random_range(5_u8..=9);
skills.insert(skill, prof);
}
// Add 1-2 random extra skills (not already present).
let extras = rng.rng.random_range(1_usize..=2);
let mut extra_attempts = 0_usize;
while extra_attempts < 20 && skills.len() < (role.skill_focus.len() + extras).min(6) {
extra_attempts += 1;
let idx = rng.rng.random_range(0..8_usize);
let skill = pick_skill(idx);
skills.entry(skill).or_insert_with(|| rng.rng.random_range(2_u8..=5));
}
let combat_trained = role.combat_enabled && rng.rng.random_range(0..3_u32) < 2;
let skill_set = SkillSet {
skills,
combat_trained,
};
let combat_cap = if combat_trained {
let style_idx = rng.rng.random_range(0..4_usize);
let prof: u8 = rng.rng.random_range(3_u8..=8);
Some(CombatCapability {
weapon_proficiency: prof,
combat_style: pick_combat_style(style_idx),
})
} else {
None
};
(skill_set, combat_cap)
}
// ---------------------------------------------------------------------------
// Main entry point
// ---------------------------------------------------------------------------
/// Generate a fully-populated NPC entity from a role definition.
///
/// Spawns the entity into `world` with all 10 D-024 axis components.
/// All randomness flows through `rng` — deterministic for a fixed seed (D-010).
///
/// **Caller invariant:** The spawned entity does NOT include `TilePosition`
/// or `ActiveSim`. The caller must place the NPC in the world (add
/// `TilePosition`, register in `EntityRegistry`, and assign a simulation
/// tier) after generation.
///
/// Returns the newly spawned `Entity` ID.
pub fn generate_npc(role: &RoleDefinition, world: &mut World, rng: &mut SimRng) -> Entity {
// Generate all axes before spawning to keep the borrow checker happy.
let want = gen_want(rng, role);
let secret = gen_secret(rng, role);
let relationships = gen_relationships(rng, &role.relationship_targets);
let tolerance = gen_tolerance(rng);
let routine = gen_routine(rng, &role.location_pool);
let inventory = gen_information_inventory(&role.known_facts);
let contentment = gen_contentment(rng);
let personality = gen_personality_traits(rng);
let tells = gen_tells(&personality, &secret);
let (skills, combat_opt) = gen_skills(rng, role);
// Determinism assertion: tolerance constraint must hold.
debug_assert!(
tolerance.current_stress < tolerance.threshold,
"NPC generation violated tolerance constraint: stress {} >= threshold {}",
tolerance.current_stress,
tolerance.threshold,
);
// Determinism assertion: no duplicate relationship targets.
debug_assert!(
{
let mut seen: Vec<StableId> = Vec::new();
let mut ok = true;
for rel in &relationships.entries {
if seen.contains(&rel.target_id) {
ok = false;
break;
}
seen.push(rel.target_id);
}
ok
},
"NPC generation produced duplicate relationship targets"
);
// Spawn entity with all components.
let mut entity_builder = world.spawn((
Npc,
ActiveSim,
want,
secret,
relationships,
tolerance,
routine,
inventory,
contentment,
personality,
tells,
skills,
MoodState::default(),
));
if let Some(cap) = combat_opt {
entity_builder.insert(cap);
}
entity_builder.id()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::types::FactId;
use bevy_ecs::world::World;
fn make_rng(seed: u64) -> SimRng {
SimRng::new(seed)
}
fn minimal_role() -> RoleDefinition {
RoleDefinition {
name: "guard".into(),
location_pool: vec![
(DayPhase::Morning, TilePosition::new(5, 5, 0)),
(DayPhase::Afternoon, TilePosition::new(10, 10, 0)),
(DayPhase::Evening, TilePosition::new(5, 5, 0)),
],
relationship_targets: vec![StableId(1), StableId(2), StableId(3)],
known_facts: vec![],
skill_focus: vec![Skill::Combat, Skill::Observation],
combat_enabled: true,
}
}
fn merchant_role() -> RoleDefinition {
RoleDefinition {
name: "merchant".into(),
location_pool: vec![
(DayPhase::Morning, TilePosition::new(20, 5, 0)),
(DayPhase::Afternoon, TilePosition::new(20, 5, 0)),
],
relationship_targets: vec![StableId(10), StableId(11)],
known_facts: vec![(
FactId("contraband.ring_exists".into()),
KnowledgeConfidence::KnowsOf,
)],
skill_focus: vec![Skill::Persuasion],
combat_enabled: false,
}
}
// -----------------------------------------------------------------------
// Basic spawning
// -----------------------------------------------------------------------
#[test]
fn generate_npc_spawns_entity() {
let mut world = World::new();
let mut rng = make_rng(42);
let role = minimal_role();
let entity = generate_npc(&role, &mut world, &mut rng);
assert!(world.get_entity(entity).is_ok(), "spawned entity must exist");
}
#[test]
fn generated_npc_has_required_components() {
let mut world = World::new();
let mut rng = make_rng(1);
let role = minimal_role();
let entity = generate_npc(&role, &mut world, &mut rng);
assert!(world.get::<Npc>(entity).is_some(), "must have Npc marker");
assert!(world.get::<Want>(entity).is_some(), "must have Want");
assert!(world.get::<Secret>(entity).is_some(), "must have Secret");
assert!(
world.get::<Relationships>(entity).is_some(),
"must have Relationships"
);
assert!(
world.get::<ToleranceThreshold>(entity).is_some(),
"must have ToleranceThreshold"
);
assert!(
world.get::<DailyRoutine>(entity).is_some(),
"must have DailyRoutine"
);
assert!(
world.get::<InformationInventory>(entity).is_some(),
"must have InformationInventory"
);
assert!(
world.get::<Contentment>(entity).is_some(),
"must have Contentment"
);
assert!(
world.get::<PersonalityTraits>(entity).is_some(),
"must have PersonalityTraits"
);
assert!(
world.get::<TellSystem>(entity).is_some(),
"must have TellSystem"
);
assert!(world.get::<SkillSet>(entity).is_some(), "must have SkillSet");
assert!(
world.get::<MoodState>(entity).is_some(),
"must have MoodState"
);
}
// -----------------------------------------------------------------------
// Determinism
// -----------------------------------------------------------------------
#[test]
fn same_seed_produces_identical_npc() {
let role = minimal_role();
let mut world_a = World::new();
let mut rng_a = make_rng(99);
let ea = generate_npc(&role, &mut world_a, &mut rng_a);
let mut world_b = World::new();
let mut rng_b = make_rng(99);
let eb = generate_npc(&role, &mut world_b, &mut rng_b);
// Compare all value-type components for equality.
let want_a = world_a.get::<Want>(ea).unwrap();
let want_b = world_b.get::<Want>(eb).unwrap();
assert_eq!(want_a.primary, want_b.primary, "Want.primary must match");
assert_eq!(want_a.intensity, want_b.intensity, "Want.intensity must match");
let tol_a = world_a.get::<ToleranceThreshold>(ea).unwrap();
let tol_b = world_b.get::<ToleranceThreshold>(eb).unwrap();
assert_eq!(tol_a.threshold, tol_b.threshold);
assert_eq!(tol_a.current_stress, tol_b.current_stress);
let con_a = world_a.get::<Contentment>(ea).unwrap();
let con_b = world_b.get::<Contentment>(eb).unwrap();
assert_eq!(con_a.level, con_b.level);
}
#[test]
fn different_seeds_may_produce_different_npcs() {
let role = minimal_role();
let mut world_a = World::new();
let mut rng_a = make_rng(1);
let ea = generate_npc(&role, &mut world_a, &mut rng_a);
let mut world_b = World::new();
let mut rng_b = make_rng(2);
let eb = generate_npc(&role, &mut world_b, &mut rng_b);
// It's statistically extremely unlikely both produce identical tolerance thresholds.
let tol_a = world_a.get::<ToleranceThreshold>(ea).unwrap();
let tol_b = world_b.get::<ToleranceThreshold>(eb).unwrap();
// We don't assert inequality (could theoretically be equal) but this
// documents that seeding drives variance.
let _ = (tol_a, tol_b);
}
// -----------------------------------------------------------------------
// Constraint: tolerance
// -----------------------------------------------------------------------
#[test]
fn tolerance_constraint_never_immediately_triggers() {
let role = minimal_role();
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);
let tol = world.get::<ToleranceThreshold>(entity).unwrap();
assert!(
tol.current_stress < tol.threshold,
"seed {seed}: stress={} must be < threshold={}",
tol.current_stress,
tol.threshold
);
}
}
// -----------------------------------------------------------------------
// Constraint: relationships (no duplicate targets)
// -----------------------------------------------------------------------
#[test]
fn relationships_have_no_duplicate_targets() {
let role = minimal_role();
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 rels = world.get::<Relationships>(entity).unwrap();
let mut seen: Vec<StableId> = Vec::new();
for rel in &rels.entries {
assert!(
!seen.contains(&rel.target_id),
"seed {seed}: duplicate relationship target {:?}",
rel.target_id
);
seen.push(rel.target_id);
}
}
}
#[test]
fn relationships_respect_max_key_relationships() {
let role = minimal_role();
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 rels = world.get::<Relationships>(entity).unwrap();
assert!(
rels.entries.len() <= MAX_KEY_RELATIONSHIPS,
"seed {seed}: {} relationships exceeds max {}",
rels.entries.len(),
MAX_KEY_RELATIONSHIPS
);
}
}
#[test]
fn empty_relationship_targets_produces_no_relationships() {
let mut role = minimal_role();
role.relationship_targets = vec![];
let mut world = World::new();
let mut rng = make_rng(7);
let entity = generate_npc(&role, &mut world, &mut rng);
let rels = world.get::<Relationships>(entity).unwrap();
assert!(rels.entries.is_empty());
}
// -----------------------------------------------------------------------
// Constraint: personality (no contradictory pairs)
// -----------------------------------------------------------------------
#[test]
fn personality_traits_contain_no_contradictory_pairs() {
let role = minimal_role();
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);
let traits = world.get::<PersonalityTraits>(entity).unwrap();
for i in 0..traits.traits.len() {
for j in (i + 1)..traits.traits.len() {
assert!(
!traits_contradict(traits.traits[i], traits.traits[j]),
"seed {seed}: contradictory trait pair {:?} and {:?}",
traits.traits[i],
traits.traits[j]
);
}
}
}
}
#[test]
fn personality_traits_count_is_2_or_3() {
let role = minimal_role();
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 traits = world.get::<PersonalityTraits>(entity).unwrap();
assert!(
traits.traits.len() >= 2 && traits.traits.len() <= 3,
"seed {seed}: got {} traits, expected 2 or 3",
traits.traits.len()
);
}
}
// -----------------------------------------------------------------------
// Constraint: routine (at most one entry per phase)
// -----------------------------------------------------------------------
#[test]
fn routine_has_at_most_one_entry_per_phase() {
let role = minimal_role();
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 routine = world.get::<DailyRoutine>(entity).unwrap();
let mut phases: Vec<DayPhase> = routine.entries.iter().map(|e| e.phase).collect();
let original_len = phases.len();
phases.dedup(); // only works if sorted — but we check by unique count instead
let unique: std::collections::BTreeSet<u8> = routine
.entries
.iter()
.map(|e| match e.phase {
DayPhase::Morning => 0,
DayPhase::Afternoon => 1,
DayPhase::Evening => 2,
DayPhase::Night => 3,
})
.collect();
assert_eq!(
unique.len(),
original_len,
"seed {seed}: duplicate phase in routine"
);
}
}
// -----------------------------------------------------------------------
// Information inventory from role
// -----------------------------------------------------------------------
#[test]
fn information_inventory_matches_role_known_facts() {
let role = merchant_role();
let mut world = World::new();
let mut rng = make_rng(5);
let entity = generate_npc(&role, &mut world, &mut rng);
let inv = world.get::<InformationInventory>(entity).unwrap();
assert_eq!(inv.facts.len(), 1);
assert_eq!(inv.facts[0].fact_id.0, "contraband.ring_exists");
assert_eq!(inv.facts[0].confidence, KnowledgeConfidence::KnowsOf);
}
// -----------------------------------------------------------------------
// Combat capability
// -----------------------------------------------------------------------
#[test]
fn non_combat_role_never_gets_combat_capability() {
let role = merchant_role(); // combat_enabled = false
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();
assert!(
!skills.combat_trained,
"seed {seed}: non-combat role should not be combat trained"
);
assert!(
world.get::<CombatCapability>(entity).is_none(),
"seed {seed}: non-combat role must not have CombatCapability"
);
}
}
// -----------------------------------------------------------------------
// Skill focus present in SkillSet
// -----------------------------------------------------------------------
#[test]
fn skill_focus_always_present_in_skill_set() {
let role = minimal_role(); // skill_focus = [Combat, Observation]
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);
let skills = world.get::<SkillSet>(entity).unwrap();
for focused_skill in &role.skill_focus {
assert!(
skills.skills.contains_key(focused_skill),
"seed {seed}: skill {:?} from role focus must be in SkillSet",
focused_skill
);
}
}
}
// -----------------------------------------------------------------------
// Want intensity in 1-10 range
// -----------------------------------------------------------------------
#[test]
fn want_intensity_in_valid_range() {
let role = minimal_role();
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 want = world.get::<Want>(entity).unwrap();
assert!(
want.intensity >= 1 && want.intensity <= 10,
"seed {seed}: Want intensity {} out of range",
want.intensity
);
}
}
// -----------------------------------------------------------------------
// ActiveSim tier
// -----------------------------------------------------------------------
#[test]
fn generated_npc_spawns_in_active_tier() {
let mut world = World::new();
let mut rng = make_rng(0);
let entity = generate_npc(&minimal_role(), &mut world, &mut rng);
assert!(
world.get::<ActiveSim>(entity).is_some(),
"generated NPCs must spawn as ActiveSim"
);
}
}
+15
View File
@@ -2,10 +2,13 @@
// Implements D-024: 10-axis NPC model + CombatCapability component
// Background tier state machines for schedule, mood, relationships, job
pub mod generate;
pub mod interaction;
pub mod mood;
pub mod relationships;
pub mod routine;
pub mod tell_state;
pub mod tolerance;
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
@@ -25,6 +28,8 @@ impl Plugin for NpcPlugin {
app.init_resource::<relationships::RelationshipGraph>()
.init_resource::<relationships::TrustEventQueue>()
.init_resource::<routine::PreviousDayPhase>()
.init_resource::<tolerance::ToleranceBreachEventQueue>()
.init_resource::<routine::RoutineDeviationEventQueue>()
.add_systems(
Update,
(
@@ -44,6 +49,16 @@ impl Plugin for NpcPlugin {
routine::enter_activity
.after(crate::simulation::movement::validate_movement)
.before(crate::perception::observer::compute_observer_snapshot),
tolerance::check_tolerance_threshold
.after(mood::update_mood)
.before(crate::simulation::time::advance_tick),
routine::detect_routine_deviation
.after(routine::enter_activity)
.before(crate::perception::observer::compute_observer_snapshot),
tell_state::derive_tell_state
.after(mood::update_mood)
.after(routine::detect_routine_deviation)
.before(crate::perception::observer::compute_observer_snapshot),
),
);
+649 -1
View File
@@ -1,11 +1,19 @@
//! Daily routine system (#88, #101).
//! Daily routine system (#88, #101, #243).
//!
//! Detects day-phase transitions (D-031) and issues PathRequests for NPCs
//! whose DailyRoutine has a location for the new phase. Tracks NPC activity
//! state when they arrive at their routine destination (#101).
//!
//! Routine deviation detection (#243): each tick, compares Active-tier NPCs'
//! current position and activity against their expected routine. Emits
//! `RoutineDeviationEvent` when an NPC deviates from their schedule. This is
//! the primary server-side detective mechanic per D-027 criterion 4.
//!
//! Pipeline: phase transition → PathRequest → pathfinder → path_follow →
//! NPC arrives → enter_activity sets ActivityState.
//!
//! Deviation pipeline: enter_activity runs → detect_routine_deviation compares
//! position/activity against schedule → emits RoutineDeviationEvent if mismatch.
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
@@ -173,6 +181,191 @@ pub fn enter_activity(
}
}
// ---------------------------------------------------------------------------
// Routine deviation detection (#243)
// ---------------------------------------------------------------------------
/// Type of routine deviation detected.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum RoutineDeviationType {
/// NPC is not at their expected location for the current day phase,
/// and is not currently travelling there (no PathRequest or ComputedPath).
/// Primary absence detection case — feeds the detective mechanic (D-027).
WrongLocation,
/// NPC is at their expected location for the current phase, but is
/// performing a different activity (or ActivityState is absent).
WrongActivity,
}
/// Event: an NPC has deviated from their scheduled routine.
///
/// Emitted once per deviation episode (not every tick while deviated).
/// Feeds `observe_anomaly` monologue triggers (#119, this sprint).
/// Primary detective mechanic per D-027 criterion 4.
#[derive(Debug, Clone)]
pub struct RoutineDeviationEvent {
/// The NPC entity that deviated.
pub entity: Entity,
/// Type of deviation detected.
pub deviation_type: RoutineDeviationType,
/// Current day phase when deviation was detected.
pub phase: DayPhase,
/// Tick when deviation was first detected.
pub tick: u64,
/// Where the NPC should be (from their DailyRoutine).
pub expected_location: TilePosition,
/// Where the NPC actually is.
pub actual_location: TilePosition,
/// Activity NPC should be performing.
pub expected_activity: String,
/// Activity NPC is actually performing (None if ActivityState absent).
pub actual_activity: Option<String>,
}
/// Resource: queue of routine deviation events.
///
/// Drained by the observation event generator (#239) which routes them to
/// `observe_anomaly` monologue triggers (#119).
#[derive(Resource, Default)]
pub struct RoutineDeviationEventQueue {
pub events: Vec<RoutineDeviationEvent>,
}
impl RoutineDeviationEventQueue {
pub fn push(&mut self, event: RoutineDeviationEvent) {
self.events.push(event);
}
pub fn drain(&mut self) -> Vec<RoutineDeviationEvent> {
std::mem::take(&mut self.events)
}
pub fn len(&self) -> usize {
self.events.len()
}
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
}
/// Marker: NPC is currently deviating from their routine.
///
/// Inserted by `detect_routine_deviation` on first deviation detection.
/// Removed when NPC returns to their routine.
/// Guards against duplicate events on consecutive deviation ticks.
#[derive(Component, Debug, Clone, Copy)]
pub struct CurrentlyDeviating;
/// System: detect when Active-tier NPCs deviate from their scheduled routine.
///
/// Runs after `enter_activity` (so ActivityState is current). For each Active
/// NPC with a `DailyRoutine` entry for the current phase:
/// - If NPC is not at expected location AND not pathfinding: `WrongLocation`.
/// - If NPC is at expected location but activity is wrong/absent: `WrongActivity`.
/// - If on-schedule: clear `CurrentlyDeviating` marker.
///
/// NPCs with no routine entry for the current phase are not monitored.
/// Scoped to `ActiveSim` — Background-tier NPCs are not monitored (D-026).
pub fn detect_routine_deviation(
mut commands: Commands,
time: Res<SimulationTime>,
mut queue: ResMut<RoutineDeviationEventQueue>,
npcs: Query<
(
Entity,
&TilePosition,
&DailyRoutine,
Option<&ActivityState>,
Option<&PathRequest>,
Option<&ComputedPath>,
Option<&CurrentlyDeviating>,
),
(With<Npc>, With<ActiveSim>),
>,
) {
let phase = time.day_phase();
let tick = time.tick;
for (entity, pos, routine, activity_opt, path_req, computed_path, deviating_opt) in
npcs.iter()
{
let Some(entry) = routine.entry_for_phase(phase) else {
// No routine entry for this phase — nothing to deviate from.
// Clear any stale deviation marker from a previous phase.
if deviating_opt.is_some() {
commands.entity(entity).remove::<CurrentlyDeviating>();
}
continue;
};
let is_pathfinding = path_req.is_some() || computed_path.is_some();
// Determine deviation type
let deviation = if *pos != entry.location {
if is_pathfinding {
// Still travelling to destination — not deviated yet.
if deviating_opt.is_some() {
commands.entity(entity).remove::<CurrentlyDeviating>();
}
continue;
}
// Not at expected location, not en route → WrongLocation
Some(RoutineDeviationType::WrongLocation)
} else {
// At expected location — check activity
let correct = match activity_opt {
Some(state) => state.activity == entry.activity && state.phase == phase,
None => false, // No ActivityState when at location → WrongActivity
};
if correct {
None // On schedule
} else {
Some(RoutineDeviationType::WrongActivity)
}
};
match (deviation, deviating_opt) {
(Some(dev_type), None) => {
// New deviation — insert marker and emit event
commands.entity(entity).insert(CurrentlyDeviating);
queue.push(RoutineDeviationEvent {
entity,
deviation_type: dev_type,
phase,
tick,
expected_location: entry.location,
actual_location: *pos,
expected_activity: entry.activity.clone(),
actual_activity: activity_opt.map(|a| a.activity.clone()),
});
tracing::debug!(
"Entity {:?}: routine deviation {:?} at phase {:?} tick {}",
entity,
dev_type,
phase,
tick
);
}
(None, Some(_)) => {
// Returned to routine — clear marker
commands.entity(entity).remove::<CurrentlyDeviating>();
tracing::debug!(
"Entity {:?}: returned to routine at phase {:?} tick {}",
entity,
phase,
tick
);
}
// (Some, Some): still deviated — no duplicate event
// (None, None): on schedule — no action
_ => {}
}
}
}
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
@@ -657,4 +850,459 @@ mod tests {
// PathRequest should be set for the new phase location
assert!(world.get::<PathRequest>(entity).is_some());
}
// -- detect_routine_deviation tests (#243) --------------------------------
fn setup_deviation_world() -> World {
let mut world = World::new();
world.init_resource::<SimulationTime>();
world.init_resource::<RoutineDeviationEventQueue>();
world.init_resource::<PreviousDayPhase>();
world
}
fn run_deviation_system(world: &mut World) {
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(detect_routine_deviation);
schedule.run(world);
world.flush();
}
#[test]
fn deviation_event_emitted_wrong_location() {
let mut world = setup_deviation_world();
// Time = Afternoon
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let expected_loc = TilePosition::new(10, 10, 0);
let entity = world
.spawn((
Npc,
ActiveSim,
TilePosition::new(5, 5, 0), // Wrong location, not pathfinding
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: expected_loc,
activity: "Work".into(),
}],
description: "Test".into(),
},
// No PathRequest, no ComputedPath — NPC is just absent
))
.id();
run_deviation_system(&mut world);
let queue = world.resource::<RoutineDeviationEventQueue>();
assert_eq!(queue.len(), 1, "should emit one deviation event");
let evt = &queue.events[0];
assert_eq!(evt.entity, entity);
assert_eq!(evt.deviation_type, RoutineDeviationType::WrongLocation);
assert_eq!(evt.expected_location, expected_loc);
assert_eq!(evt.actual_location, TilePosition::new(5, 5, 0));
assert_eq!(evt.expected_activity, "Work");
assert!(evt.actual_activity.is_none());
}
#[test]
fn no_deviation_when_at_correct_location_and_activity() {
let mut world = setup_deviation_world();
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let loc = TilePosition::new(10, 10, 0);
world.spawn((
Npc,
ActiveSim,
loc,
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: loc,
activity: "Work".into(),
}],
description: "Test".into(),
},
ActivityState {
activity: "Work".into(),
phase: DayPhase::Afternoon,
started_tick: MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE,
},
));
run_deviation_system(&mut world);
let queue = world.resource::<RoutineDeviationEventQueue>();
assert!(queue.is_empty(), "on-schedule NPC should not emit a deviation event");
}
#[test]
fn no_deviation_when_pathfinding_to_destination() {
let mut world = setup_deviation_world();
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let expected_loc = TilePosition::new(10, 10, 0);
world.spawn((
Npc,
ActiveSim,
TilePosition::new(5, 5, 0), // Not there yet
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: expected_loc,
activity: "Work".into(),
}],
description: "Test".into(),
},
PathRequest { goal: expected_loc }, // En route
));
run_deviation_system(&mut world);
let queue = world.resource::<RoutineDeviationEventQueue>();
assert!(
queue.is_empty(),
"NPC with PathRequest is en route — not yet deviated"
);
}
#[test]
fn no_deviation_when_computed_path_active() {
let mut world = setup_deviation_world();
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let expected_loc = TilePosition::new(10, 10, 0);
world.spawn((
Npc,
ActiveSim,
TilePosition::new(5, 5, 0),
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: expected_loc,
activity: "Work".into(),
}],
description: "Test".into(),
},
ComputedPath {
steps: vec![TilePosition::new(6, 5, 0)],
current_index: 0,
},
));
run_deviation_system(&mut world);
let queue = world.resource::<RoutineDeviationEventQueue>();
assert!(
queue.is_empty(),
"NPC with ComputedPath is walking — not yet deviated"
);
}
#[test]
fn deviation_event_emitted_wrong_activity() {
let mut world = setup_deviation_world();
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let loc = TilePosition::new(10, 10, 0);
let entity = world
.spawn((
Npc,
ActiveSim,
loc, // At correct location
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: loc,
activity: "Work".into(),
}],
description: "Test".into(),
},
ActivityState {
activity: "Idle".into(), // Wrong activity
phase: DayPhase::Afternoon,
started_tick: 100,
},
))
.id();
run_deviation_system(&mut world);
let queue = world.resource::<RoutineDeviationEventQueue>();
assert_eq!(queue.len(), 1);
let evt = &queue.events[0];
assert_eq!(evt.entity, entity);
assert_eq!(evt.deviation_type, RoutineDeviationType::WrongActivity);
assert_eq!(evt.expected_activity, "Work");
assert_eq!(evt.actual_activity.as_deref(), Some("Idle"));
}
#[test]
fn wrong_activity_when_activity_state_absent_at_location() {
let mut world = setup_deviation_world();
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let loc = TilePosition::new(10, 10, 0);
let entity = world
.spawn((
Npc,
ActiveSim,
loc, // At correct location
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: loc,
activity: "Work".into(),
}],
description: "Test".into(),
},
// No ActivityState — NPC is at location but hasn't settled
))
.id();
run_deviation_system(&mut world);
let queue = world.resource::<RoutineDeviationEventQueue>();
assert_eq!(queue.len(), 1);
let evt = &queue.events[0];
assert_eq!(evt.entity, entity);
assert_eq!(evt.deviation_type, RoutineDeviationType::WrongActivity);
assert!(evt.actual_activity.is_none());
}
#[test]
fn no_duplicate_events_while_deviated() {
let mut world = setup_deviation_world();
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let expected_loc = TilePosition::new(10, 10, 0);
world.spawn((
Npc,
ActiveSim,
TilePosition::new(5, 5, 0),
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: expected_loc,
activity: "Work".into(),
}],
description: "Test".into(),
},
CurrentlyDeviating, // Already marked as deviating
));
run_deviation_system(&mut world);
let queue = world.resource::<RoutineDeviationEventQueue>();
assert!(
queue.is_empty(),
"no duplicate deviation event while already marked as deviating"
);
}
#[test]
fn deviation_marker_cleared_when_npc_returns_to_routine() {
let mut world = setup_deviation_world();
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let loc = TilePosition::new(10, 10, 0);
let entity = world
.spawn((
Npc,
ActiveSim,
loc, // Now at correct location
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: loc,
activity: "Work".into(),
}],
description: "Test".into(),
},
ActivityState {
activity: "Work".into(),
phase: DayPhase::Afternoon,
started_tick: 100,
},
CurrentlyDeviating, // Was deviating, now returned
))
.id();
run_deviation_system(&mut world);
assert!(
world.get::<CurrentlyDeviating>(entity).is_none(),
"CurrentlyDeviating marker should be removed when NPC returns to routine"
);
let queue = world.resource::<RoutineDeviationEventQueue>();
assert!(queue.is_empty(), "no event on deviation resolution");
}
#[test]
fn no_routine_for_phase_clears_deviation_marker() {
let mut world = setup_deviation_world();
// Night phase — NPC has no routine entry
world.resource_mut::<SimulationTime>().tick = 3 * MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let entity = world
.spawn((
Npc,
ActiveSim,
TilePosition::new(5, 5, 0),
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Morning,
location: TilePosition::new(5, 5, 0),
activity: "Sleep".into(),
}],
description: "Test".into(),
},
CurrentlyDeviating, // Stale marker from previous phase
))
.id();
run_deviation_system(&mut world);
// No entry for Night → marker cleared, no event
assert!(
world.get::<CurrentlyDeviating>(entity).is_none(),
"stale deviation marker cleared when NPC has no routine for current phase"
);
let queue = world.resource::<RoutineDeviationEventQueue>();
assert!(queue.is_empty());
}
#[test]
fn background_npc_not_monitored_for_deviation() {
let mut world = setup_deviation_world();
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
world.spawn((
Npc,
crate::simulation::tier::BackgroundSim,
TilePosition::new(5, 5, 0), // Wrong location
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: TilePosition::new(10, 10, 0),
activity: "Work".into(),
}],
description: "Test".into(),
},
));
run_deviation_system(&mut world);
let queue = world.resource::<RoutineDeviationEventQueue>();
assert!(
queue.is_empty(),
"Background-tier NPCs must not generate deviation events"
);
}
#[test]
fn multiple_npcs_deviated_independently() {
let mut world = setup_deviation_world();
world.resource_mut::<SimulationTime>().tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE;
let expected_loc = TilePosition::new(10, 10, 0);
// NPC 1: wrong location (will deviate)
let e1 = world
.spawn((
Npc,
ActiveSim,
TilePosition::new(1, 1, 0),
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: expected_loc,
activity: "Work".into(),
}],
description: "Test".into(),
},
))
.id();
// NPC 2: on schedule (no deviation)
let loc2 = TilePosition::new(20, 20, 0);
world.spawn((
Npc,
ActiveSim,
loc2,
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: loc2,
activity: "Bar".into(),
}],
description: "Test".into(),
},
ActivityState {
activity: "Bar".into(),
phase: DayPhase::Afternoon,
started_tick: MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE,
},
));
// NPC 3: wrong activity (will deviate)
let loc3 = TilePosition::new(30, 30, 0);
let e3 = world
.spawn((
Npc,
ActiveSim,
loc3,
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: loc3,
activity: "Inspect".into(),
}],
description: "Test".into(),
},
ActivityState {
activity: "Loiter".into(), // Wrong
phase: DayPhase::Afternoon,
started_tick: 100,
},
))
.id();
run_deviation_system(&mut world);
let queue = world.resource::<RoutineDeviationEventQueue>();
assert_eq!(queue.len(), 2, "two NPCs should deviate independently");
let deviated: Vec<Entity> = queue.events.iter().map(|e| e.entity).collect();
assert!(deviated.contains(&e1));
assert!(deviated.contains(&e3));
}
#[test]
fn deviation_event_records_current_tick_and_phase() {
let mut world = setup_deviation_world();
let tick = MINUTES_PER_PHASE * TICKS_PER_GAME_MINUTE + 42;
world.resource_mut::<SimulationTime>().tick = tick;
world.spawn((
Npc,
ActiveSim,
TilePosition::new(5, 5, 0),
DailyRoutine {
entries: vec![RoutineEntry {
phase: DayPhase::Afternoon,
location: TilePosition::new(10, 10, 0),
activity: "Work".into(),
}],
description: "Test".into(),
},
));
run_deviation_system(&mut world);
let queue = world.resource::<RoutineDeviationEventQueue>();
let evt = &queue.events[0];
assert_eq!(evt.tick, tick);
assert_eq!(evt.phase, DayPhase::Afternoon);
}
}
+575
View File
@@ -0,0 +1,575 @@
//! Tell state derivation system (#90, D-024 tell system).
//!
//! Derives the current observable tell category from NPC axis values each tick.
//! Tell state is NOT authored per NPC — it flows from simulation state.
//!
//! ## 5 tell categories (D-024)
//! - `Nervous`: Major secret + stress > half the threshold
//! - `Angry`: Contentment < 20 AND Hostile mood
//! - `Friendly`: Contentment > +20 AND at least one relationship with trust > 3
//! - `Guarded`: Major secret (any stress level)
//! - `RoutineDeviation`: NPC has a `RoutineDeviation` component this tick
//!
//! ## Priority order (highest wins)
//! RoutineDeviation > Nervous > Angry > Guarded > Friendly > None
//!
//! ## v0.1 output
//! `DerivedTellState` is read by the observer snapshot system and emitted into
//! `ObserverSnapshot.entities[].tell_state`. Client renders as monologue text;
//! visual animation is deferred beyond v0.1.
use bevy_ecs::prelude::*;
use serde::{Deserialize, Serialize};
use crate::npc::mood::{MoodState, NpcMood};
use crate::npc::{
Contentment, Npc, Relationships, RoutineDeviation, Secret, SecretSeverity, ToleranceThreshold,
};
use crate::simulation::tier::ActiveSim;
// ---------------------------------------------------------------------------
// TellCategory enum
// ---------------------------------------------------------------------------
/// Observable tell category emitted into the observer snapshot (#90, D-024).
///
/// Derived each tick from NPC simulation state — not authored per NPC.
/// Five categories correspond to the D-024 tell taxonomy.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum TellCategory {
/// NPC exhibits nervous behaviour: Major secret + stress exceeds half of threshold.
Nervous,
/// NPC exhibits angry behaviour: low contentment and Hostile mood.
Angry,
/// NPC appears warm and open: high contentment with a positively-trusted relationship.
Friendly,
/// NPC appears guarded or evasive: Major secret (any stress).
Guarded,
/// NPC has deviated from their expected routine — primary detective mechanic (D-027).
RoutineDeviation,
}
// ---------------------------------------------------------------------------
// DerivedTellState component
// ---------------------------------------------------------------------------
/// Per-NPC component: the current observable tell category this tick.
///
/// Updated each tick by [`derive_tell_state`] for Active-tier NPCs.
/// `None` means no notable tell is observable (normal / neutral state).
///
/// Read by the observer snapshot system to populate
/// `ObserverSnapshot.entities[].tell_state`.
#[derive(Component, Debug, Clone, Default)]
pub struct DerivedTellState {
/// Current tell category, or `None` if no tell is active.
pub category: Option<TellCategory>,
}
// ---------------------------------------------------------------------------
// Derivation helpers
// ---------------------------------------------------------------------------
fn derive_category(
secret: &Secret,
tolerance: &ToleranceThreshold,
contentment: &Contentment,
mood_state: &MoodState,
relationships_opt: Option<&Relationships>,
deviation_opt: Option<&RoutineDeviation>,
) -> Option<TellCategory> {
// Priority 1: RoutineDeviation (primary detective mechanic, D-027 criterion 4)
if deviation_opt.is_some() {
return Some(TellCategory::RoutineDeviation);
}
// Priority 2: Nervous — Major secret with stress past the midpoint
if secret.severity == SecretSeverity::Major
&& tolerance.threshold > 0
&& tolerance.current_stress * 2 > tolerance.threshold
{
return Some(TellCategory::Nervous);
}
// Priority 3: Angry — low contentment combined with Hostile mood
if contentment.level < -20 && mood_state.mood == NpcMood::Hostile {
return Some(TellCategory::Angry);
}
// Priority 4: Guarded — Major secret at any stress level
if secret.severity == SecretSeverity::Major {
return Some(TellCategory::Guarded);
}
// Priority 5: Friendly — high contentment with at least one trusted relationship
if contentment.level > 20 {
let has_positive_relationship = relationships_opt
.map(|rels| rels.entries.iter().any(|r| r.trust_level > 3))
.unwrap_or(false);
if has_positive_relationship {
return Some(TellCategory::Friendly);
}
}
None
}
// ---------------------------------------------------------------------------
// Derivation system
// ---------------------------------------------------------------------------
/// System: derive tell state from NPC axis values for all Active-tier NPCs.
///
/// Runs after `update_mood` — requires a fresh `MoodState`.
/// Writes the result into `DerivedTellState`, which the observer snapshot
/// system reads to populate `VisibleEntity.tell_state`.
///
/// Scoped to `ActiveSim`: Background-tier NPCs retain their last-known tell
/// state, consistent with D-026 tier policy.
pub fn derive_tell_state(
mut npcs: Query<
(
&Secret,
&ToleranceThreshold,
&Contentment,
&MoodState,
Option<&Relationships>,
Option<&RoutineDeviation>,
&mut DerivedTellState,
),
(With<Npc>, With<ActiveSim>),
>,
) {
for (secret, tolerance, contentment, mood_state, relationships_opt, deviation_opt, mut tell) in
npcs.iter_mut()
{
tell.category = derive_category(
secret,
tolerance,
contentment,
mood_state,
relationships_opt,
deviation_opt,
);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::types::StableId;
use crate::npc::{Relationship, RelationshipKind, RoutineDeviation as NpcRoutineDeviation};
use crate::npc::{SecretSeverity, ToleranceThreshold};
use crate::npc::mood::NpcMood;
use crate::npc::DeviationTrigger;
fn neutral_secret() -> Secret {
Secret {
description: "minor embarrassment".into(),
severity: SecretSeverity::Minor,
known_by: vec![],
}
}
fn major_secret() -> Secret {
Secret {
description: "criminal record".into(),
severity: SecretSeverity::Major,
known_by: vec![],
}
}
fn moderate_secret() -> Secret {
Secret {
description: "moderate secret".into(),
severity: SecretSeverity::Moderate,
known_by: vec![],
}
}
fn tolerance(stress: i16, threshold: i16) -> ToleranceThreshold {
ToleranceThreshold {
current_stress: stress,
threshold,
}
}
fn contentment(level: i16) -> Contentment {
Contentment { level }
}
fn mood(m: NpcMood) -> MoodState {
MoodState { mood: m, changed_tick: 0 }
}
fn positive_relationships() -> Relationships {
Relationships {
entries: vec![Relationship {
target_id: StableId(1),
kind: RelationshipKind::Friend,
trust_level: 5,
history: vec![],
}],
}
}
fn neutral_relationships() -> Relationships {
Relationships {
entries: vec![Relationship {
target_id: StableId(1),
kind: RelationshipKind::Colleague,
trust_level: 0,
history: vec![],
}],
}
}
fn deviation() -> NpcRoutineDeviation {
NpcRoutineDeviation {
trigger: DeviationTrigger::WalkAway,
tick: 100,
}
}
// -----------------------------------------------------------------------
// Priority 1: RoutineDeviation beats everything
// -----------------------------------------------------------------------
#[test]
fn routine_deviation_beats_nervous() {
let result = derive_category(
&major_secret(),
&tolerance(90, 100), // Stress past midpoint — would be Nervous
&contentment(0),
&mood(NpcMood::Neutral),
None,
Some(&deviation()),
);
assert_eq!(result, Some(TellCategory::RoutineDeviation));
}
#[test]
fn routine_deviation_beats_angry() {
let result = derive_category(
&neutral_secret(),
&tolerance(0, 50),
&contentment(-50),
&mood(NpcMood::Hostile),
None,
Some(&deviation()),
);
assert_eq!(result, Some(TellCategory::RoutineDeviation));
}
#[test]
fn no_deviation_component_skips_deviation_category() {
let result = derive_category(
&neutral_secret(),
&tolerance(0, 50),
&contentment(0),
&mood(NpcMood::Neutral),
None,
None, // No deviation
);
assert_ne!(result, Some(TellCategory::RoutineDeviation));
}
// -----------------------------------------------------------------------
// Priority 2: Nervous
// -----------------------------------------------------------------------
#[test]
fn major_secret_stress_past_midpoint_is_nervous() {
// stress=60, threshold=100 → stress*2=120 > 100 → nervous
let result = derive_category(
&major_secret(),
&tolerance(60, 100),
&contentment(0),
&mood(NpcMood::Neutral),
None,
None,
);
assert_eq!(result, Some(TellCategory::Nervous));
}
#[test]
fn major_secret_stress_at_midpoint_is_guarded_not_nervous() {
// stress=50, threshold=100 → stress*2=100 is NOT > 100 → Guarded fallthrough
let result = derive_category(
&major_secret(),
&tolerance(50, 100),
&contentment(0),
&mood(NpcMood::Neutral),
None,
None,
);
assert_eq!(result, Some(TellCategory::Guarded));
}
#[test]
fn minor_secret_high_stress_not_nervous() {
let result = derive_category(
&neutral_secret(), // Minor — not Major
&tolerance(90, 100),
&contentment(0),
&mood(NpcMood::Neutral),
None,
None,
);
assert_ne!(result, Some(TellCategory::Nervous));
}
#[test]
fn nervous_requires_nonzero_threshold() {
// threshold=0: stress*2=0 NOT > 0 → skip nervous
let result = derive_category(
&major_secret(),
&tolerance(0, 0),
&contentment(0),
&mood(NpcMood::Neutral),
None,
None,
);
// Still Guarded (Major secret, priority 4)
assert_eq!(result, Some(TellCategory::Guarded));
}
// -----------------------------------------------------------------------
// Priority 3: Angry
// -----------------------------------------------------------------------
#[test]
fn low_contentment_hostile_mood_is_angry() {
let result = derive_category(
&neutral_secret(),
&tolerance(0, 50),
&contentment(-30), // Below -20
&mood(NpcMood::Hostile),
None,
None,
);
assert_eq!(result, Some(TellCategory::Angry));
}
#[test]
fn hostile_mood_without_low_contentment_not_angry() {
let result = derive_category(
&neutral_secret(),
&tolerance(0, 50),
&contentment(0), // Not low enough
&mood(NpcMood::Hostile),
None,
None,
);
assert_ne!(result, Some(TellCategory::Angry));
}
#[test]
fn low_contentment_without_hostile_mood_not_angry() {
let result = derive_category(
&neutral_secret(),
&tolerance(0, 50),
&contentment(-30),
&mood(NpcMood::Anxious), // Not Hostile
None,
None,
);
assert_ne!(result, Some(TellCategory::Angry));
}
#[test]
fn contentment_at_boundary_minus_20_not_angry() {
// -20 is NOT < -20
let result = derive_category(
&neutral_secret(),
&tolerance(0, 50),
&contentment(-20),
&mood(NpcMood::Hostile),
None,
None,
);
assert_ne!(result, Some(TellCategory::Angry));
}
// -----------------------------------------------------------------------
// Priority 4: Guarded
// -----------------------------------------------------------------------
#[test]
fn major_secret_low_stress_is_guarded() {
let result = derive_category(
&major_secret(),
&tolerance(5, 100), // Low stress, not nervous
&contentment(0),
&mood(NpcMood::Neutral),
None,
None,
);
assert_eq!(result, Some(TellCategory::Guarded));
}
#[test]
fn moderate_secret_not_guarded() {
let result = derive_category(
&moderate_secret(),
&tolerance(0, 50),
&contentment(0),
&mood(NpcMood::Neutral),
None,
None,
);
// Moderate secret doesn't trigger Guarded
assert_ne!(result, Some(TellCategory::Guarded));
}
// -----------------------------------------------------------------------
// Priority 5: Friendly
// -----------------------------------------------------------------------
#[test]
fn high_contentment_positive_relationship_is_friendly() {
let result = derive_category(
&neutral_secret(),
&tolerance(0, 50),
&contentment(30), // Above +20
&mood(NpcMood::Neutral),
Some(&positive_relationships()), // Trust > 3
None,
);
assert_eq!(result, Some(TellCategory::Friendly));
}
#[test]
fn high_contentment_no_positive_relationship_not_friendly() {
let result = derive_category(
&neutral_secret(),
&tolerance(0, 50),
&contentment(30),
&mood(NpcMood::Neutral),
Some(&neutral_relationships()), // Trust = 0
None,
);
assert_ne!(result, Some(TellCategory::Friendly));
}
#[test]
fn high_contentment_no_relationships_not_friendly() {
let result = derive_category(
&neutral_secret(),
&tolerance(0, 50),
&contentment(30),
&mood(NpcMood::Neutral),
None, // No relationships at all
None,
);
assert_ne!(result, Some(TellCategory::Friendly));
}
#[test]
fn contentment_at_boundary_plus_20_not_friendly() {
// +20 is NOT > 20
let result = derive_category(
&neutral_secret(),
&tolerance(0, 50),
&contentment(20),
&mood(NpcMood::Neutral),
Some(&positive_relationships()),
None,
);
assert_ne!(result, Some(TellCategory::Friendly));
}
// -----------------------------------------------------------------------
// None result
// -----------------------------------------------------------------------
#[test]
fn neutral_npc_returns_none() {
let result = derive_category(
&neutral_secret(),
&tolerance(10, 50),
&contentment(0),
&mood(NpcMood::Neutral),
None,
None,
);
assert_eq!(result, None);
}
#[test]
fn no_tell_for_minor_secret_low_stress_neutral_mood() {
let result = derive_category(
&neutral_secret(),
&tolerance(0, 50),
&contentment(-5), // Not low enough for angry
&mood(NpcMood::Anxious), // Not Hostile
None,
None,
);
assert_eq!(result, None);
}
// -----------------------------------------------------------------------
// Bevy ECS integration: system updates DerivedTellState
// -----------------------------------------------------------------------
#[test]
fn system_updates_derived_tell_state() {
use bevy_ecs::world::World;
use crate::npc::Npc;
let mut world = World::new();
// Spawn an NPC that should have RoutineDeviation tell
let entity = world
.spawn((
Npc,
ActiveSim,
major_secret(),
tolerance(60, 100),
contentment(0),
mood(NpcMood::Neutral),
DerivedTellState::default(),
deviation(), // RoutineDeviation present
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(derive_tell_state);
schedule.run(&mut world);
let state = world.get::<DerivedTellState>(entity).unwrap();
assert_eq!(state.category, Some(TellCategory::RoutineDeviation));
}
#[test]
fn system_sets_none_for_neutral_npc() {
use bevy_ecs::world::World;
use crate::npc::Npc;
let mut world = World::new();
let entity = world
.spawn((
Npc,
ActiveSim,
neutral_secret(),
tolerance(10, 80),
contentment(5),
mood(NpcMood::Neutral),
DerivedTellState::default(),
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(derive_tell_state);
schedule.run(&mut world);
let state = world.get::<DerivedTellState>(entity).unwrap();
assert_eq!(state.category, None);
}
}
+567
View File
@@ -0,0 +1,567 @@
//! Tolerance threshold monitoring system (#105).
//!
//! Monitors Active-tier NPCs each tick. When `current_stress >= threshold`,
//! emits a `ToleranceBreachEvent` (once per crossing) and marks the NPC with
//! `ToleranceBreached`. When stress drops below threshold, clears the marker.
//!
//! ## Integration
//! - `npc::mood::update_mood` reads `ToleranceThreshold` directly to derive
//! `Hostile`/`Anxious` mood — no duplication needed here.
//! - Future #250 (Triangle escalation) consumes `ToleranceBreachEventQueue`.
//!
//! All arithmetic is integer-only (D-010 determinism).
use bevy_ecs::prelude::*;
use crate::npc::{Npc, ToleranceThreshold};
use crate::simulation::tier::ActiveSim;
use crate::simulation::time::SimulationTime;
// ---------------------------------------------------------------------------
// Event and queue
// ---------------------------------------------------------------------------
/// Emitted when an NPC's stress first crosses their tolerance threshold.
///
/// Produced once per crossing transition (not every tick while breached).
/// Consumed by future #250 (Triangle escalation system).
///
/// Threshold value comes from per-NPC generation seed — not hardcoded.
#[derive(Debug, Clone)]
pub struct ToleranceBreachEvent {
/// The NPC entity that exceeded their threshold.
pub entity: Entity,
/// Tick when the breach occurred.
pub tick: u64,
/// Stress level at the moment of breach.
pub stress_at_breach: i16,
/// The NPC's tolerance threshold (per-NPC, seeded at generation).
pub threshold: i16,
}
/// Resource: queue of tolerance breach events.
///
/// Drained once per tick by consumers. Multiple consumers may drain the queue
/// in sequence — the first consumer gets all events, subsequent consumers get
/// nothing (caller's responsibility to coordinate if multiple consumers exist).
#[derive(Resource, Default)]
pub struct ToleranceBreachEventQueue {
pub events: Vec<ToleranceBreachEvent>,
}
impl ToleranceBreachEventQueue {
/// Push a breach event into the queue.
pub fn push(&mut self, event: ToleranceBreachEvent) {
self.events.push(event);
}
/// Drain all pending events.
pub fn drain(&mut self) -> Vec<ToleranceBreachEvent> {
std::mem::take(&mut self.events)
}
/// Number of pending events.
pub fn len(&self) -> usize {
self.events.len()
}
/// Whether the queue is empty.
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
}
// ---------------------------------------------------------------------------
// Marker component
// ---------------------------------------------------------------------------
/// Marker: NPC is currently in a tolerance breach state (stress >= threshold).
///
/// Inserted by `check_tolerance_threshold` on first breach.
/// Removed when stress drops below threshold.
/// Guards against duplicate events on consecutive breach ticks.
#[derive(Component, Debug, Clone, Copy)]
pub struct ToleranceBreached;
// ---------------------------------------------------------------------------
// System
// ---------------------------------------------------------------------------
/// System: monitor tolerance thresholds for Active-tier NPCs.
///
/// Runs once per tick. For each NPC with `ToleranceThreshold`:
/// - `stress >= threshold` and not yet marked: insert `ToleranceBreached`, emit event.
/// - `stress < threshold` and currently marked: remove `ToleranceBreached`.
/// - Already in correct state: no action.
///
/// Mood shift (Hostile/Anxious) is delegated to `npc::mood::update_mood`,
/// which reads `ToleranceThreshold` each tick. No duplication here.
///
/// Future behavioral reactions (confrontation-initiation, avoidance):
/// other systems should read `ToleranceBreachEventQueue` or query for
/// `ToleranceBreached` components.
///
/// Scoped to `ActiveSim` — Background-tier NPCs are not monitored per
/// tick (D-026). Background NPCs retain their last-known mood state.
pub fn check_tolerance_threshold(
mut commands: Commands,
time: Res<SimulationTime>,
mut queue: ResMut<ToleranceBreachEventQueue>,
npcs: Query<
(Entity, &ToleranceThreshold, Option<&ToleranceBreached>),
(With<Npc>, With<ActiveSim>),
>,
) {
let tick = time.tick;
for (entity, tolerance, breach_opt) in npcs.iter() {
let is_breached = tolerance.current_stress >= tolerance.threshold;
let was_breached = breach_opt.is_some();
match (is_breached, was_breached) {
(true, false) => {
// Transition: OK → Breached. Insert marker and emit event.
commands.entity(entity).insert(ToleranceBreached);
queue.push(ToleranceBreachEvent {
entity,
tick,
stress_at_breach: tolerance.current_stress,
threshold: tolerance.threshold,
});
tracing::debug!(
"Entity {:?}: tolerance breached (stress={}, threshold={}) at tick {}",
entity,
tolerance.current_stress,
tolerance.threshold,
tick
);
}
(false, true) => {
// Transition: Breached → OK. Remove marker.
commands.entity(entity).remove::<ToleranceBreached>();
tracing::debug!(
"Entity {:?}: tolerance breach resolved (stress={}, threshold={}) at tick {}",
entity,
tolerance.current_stress,
tolerance.threshold,
tick
);
}
// (true, true): still breached — no action, no duplicate event.
// (false, false): still fine — no action.
_ => {}
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::npc::{Npc, ToleranceThreshold};
use crate::simulation::tier::{ActiveSim, BackgroundSim};
use crate::simulation::time::SimulationTime;
use bevy_ecs::world::World;
fn setup_world() -> World {
let mut world = World::new();
world.init_resource::<SimulationTime>();
world.init_resource::<ToleranceBreachEventQueue>();
world
}
fn run_system(world: &mut World) {
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(check_tolerance_threshold);
schedule.run(world);
world.flush();
}
// -----------------------------------------------------------------------
// Breach event emission
// -----------------------------------------------------------------------
#[test]
fn breach_event_emitted_when_stress_equals_threshold() {
let mut world = setup_world();
world.spawn((
Npc,
ActiveSim,
ToleranceThreshold {
current_stress: 50,
threshold: 50,
},
));
run_system(&mut world);
let queue = world.resource::<ToleranceBreachEventQueue>();
assert_eq!(queue.len(), 1, "should emit exactly one breach event");
assert_eq!(queue.events[0].stress_at_breach, 50);
assert_eq!(queue.events[0].threshold, 50);
}
#[test]
fn breach_event_emitted_when_stress_above_threshold() {
let mut world = setup_world();
world.spawn((
Npc,
ActiveSim,
ToleranceThreshold {
current_stress: 80,
threshold: 50,
},
));
run_system(&mut world);
let queue = world.resource::<ToleranceBreachEventQueue>();
assert_eq!(queue.len(), 1);
assert_eq!(queue.events[0].stress_at_breach, 80);
assert_eq!(queue.events[0].threshold, 50);
}
#[test]
fn no_breach_event_when_stress_below_threshold() {
let mut world = setup_world();
world.spawn((
Npc,
ActiveSim,
ToleranceThreshold {
current_stress: 49,
threshold: 50,
},
));
run_system(&mut world);
let queue = world.resource::<ToleranceBreachEventQueue>();
assert!(queue.is_empty(), "stress=49 < threshold=50 should not breach");
}
#[test]
fn no_breach_event_at_zero_stress() {
let mut world = setup_world();
world.spawn((
Npc,
ActiveSim,
ToleranceThreshold {
current_stress: 0,
threshold: 50,
},
));
run_system(&mut world);
let queue = world.resource::<ToleranceBreachEventQueue>();
assert!(queue.is_empty());
}
// -----------------------------------------------------------------------
// Breach event tick field
// -----------------------------------------------------------------------
#[test]
fn breach_event_records_current_tick() {
let mut world = setup_world();
world.resource_mut::<SimulationTime>().tick = 42;
world.spawn((
Npc,
ActiveSim,
ToleranceThreshold {
current_stress: 60,
threshold: 50,
},
));
run_system(&mut world);
let queue = world.resource::<ToleranceBreachEventQueue>();
assert_eq!(queue.events[0].tick, 42);
}
// -----------------------------------------------------------------------
// No duplicate events (ToleranceBreached marker)
// -----------------------------------------------------------------------
#[test]
fn no_duplicate_event_on_consecutive_breach_ticks() {
let mut world = setup_world();
let entity = world
.spawn((
Npc,
ActiveSim,
ToleranceThreshold {
current_stress: 60,
threshold: 50,
},
ToleranceBreached, // Already marked — already breached
))
.id();
run_system(&mut world);
// Still breached but marker was already present → no new event
let queue = world.resource::<ToleranceBreachEventQueue>();
assert!(
queue.is_empty(),
"no duplicate event when already in breach state"
);
// Marker should still be present
assert!(world.get::<ToleranceBreached>(entity).is_some());
}
// -----------------------------------------------------------------------
// Breach resolution
// -----------------------------------------------------------------------
#[test]
fn breach_marker_cleared_when_stress_drops_below_threshold() {
let mut world = setup_world();
let entity = world
.spawn((
Npc,
ActiveSim,
ToleranceThreshold {
current_stress: 30, // Dropped below threshold
threshold: 50,
},
ToleranceBreached, // Was breached
))
.id();
run_system(&mut world);
assert!(
world.get::<ToleranceBreached>(entity).is_none(),
"marker should be removed when stress drops below threshold"
);
// No new breach event on resolution
let queue = world.resource::<ToleranceBreachEventQueue>();
assert!(queue.is_empty(), "resolution should not emit a new event");
}
#[test]
fn breach_event_emitted_again_after_recovery() {
let mut world = setup_world();
// Start: stress resolved (marker absent, stress below threshold)
let entity = world
.spawn((
Npc,
ActiveSim,
ToleranceThreshold {
current_stress: 60, // Re-breached
threshold: 50,
},
// No ToleranceBreached — recovery had cleared it
))
.id();
run_system(&mut world);
// Should emit new event after recovery
let queue = world.resource::<ToleranceBreachEventQueue>();
assert_eq!(
queue.len(),
1,
"new breach event after recovery and re-breach"
);
assert!(world.get::<ToleranceBreached>(entity).is_some());
}
// -----------------------------------------------------------------------
// Tier scoping
// -----------------------------------------------------------------------
#[test]
fn background_npc_not_monitored() {
let mut world = setup_world();
world.spawn((
Npc,
BackgroundSim, // Background tier — excluded
ToleranceThreshold {
current_stress: 100,
threshold: 50,
},
));
run_system(&mut world);
let queue = world.resource::<ToleranceBreachEventQueue>();
assert!(
queue.is_empty(),
"Background-tier NPCs must not generate breach events"
);
}
#[test]
fn active_npc_without_tolerance_threshold_not_matched() {
// Should not crash — query requires ToleranceThreshold, so entity is simply skipped
let mut world = setup_world();
world.spawn((
Npc,
ActiveSim,
// No ToleranceThreshold component
));
run_system(&mut world); // must not panic
let queue = world.resource::<ToleranceBreachEventQueue>();
assert!(queue.is_empty());
}
// -----------------------------------------------------------------------
// Multiple NPCs
// -----------------------------------------------------------------------
#[test]
fn multiple_npcs_breached_independently() {
let mut world = setup_world();
// NPC 1: will breach (stress >= threshold)
let e1 = world
.spawn((
Npc,
ActiveSim,
ToleranceThreshold {
current_stress: 60,
threshold: 50,
},
))
.id();
// NPC 2: fine (stress < threshold)
let e2 = world
.spawn((
Npc,
ActiveSim,
ToleranceThreshold {
current_stress: 30,
threshold: 50,
},
))
.id();
// NPC 3: will breach (different threshold — per-NPC, not hardcoded)
let e3 = world
.spawn((
Npc,
ActiveSim,
ToleranceThreshold {
current_stress: 90,
threshold: 80,
},
))
.id();
run_system(&mut world);
let queue = world.resource::<ToleranceBreachEventQueue>();
assert_eq!(queue.len(), 2, "two NPCs should breach independently");
let breached_entities: Vec<Entity> = queue.events.iter().map(|e| e.entity).collect();
assert!(breached_entities.contains(&e1));
assert!(!breached_entities.contains(&e2));
assert!(breached_entities.contains(&e3));
}
#[test]
fn breach_event_entity_field_matches_spawned_entity() {
let mut world = setup_world();
let entity = world
.spawn((
Npc,
ActiveSim,
ToleranceThreshold {
current_stress: 50,
threshold: 50,
},
))
.id();
run_system(&mut world);
let queue = world.resource::<ToleranceBreachEventQueue>();
assert_eq!(queue.events[0].entity, entity);
}
// -----------------------------------------------------------------------
// Edge cases
// -----------------------------------------------------------------------
#[test]
fn zero_threshold_with_zero_stress_breaches() {
// 0 >= 0 → breach. Edge case: entity with threshold=0 is always breached.
let mut world = setup_world();
let entity = world
.spawn((
Npc,
ActiveSim,
ToleranceThreshold {
current_stress: 0,
threshold: 0,
},
))
.id();
run_system(&mut world);
let queue = world.resource::<ToleranceBreachEventQueue>();
assert_eq!(queue.len(), 1, "stress=0 >= threshold=0 must breach");
assert!(world.get::<ToleranceBreached>(entity).is_some());
}
#[test]
fn threshold_at_100_only_breaches_at_100() {
let mut world = setup_world();
world.spawn((
Npc,
ActiveSim,
ToleranceThreshold {
current_stress: 99,
threshold: 100,
},
));
run_system(&mut world);
let queue = world.resource::<ToleranceBreachEventQueue>();
assert!(queue.is_empty(), "stress=99 < threshold=100 should not breach");
}
#[test]
fn mixed_active_and_background_npcs() {
let mut world = setup_world();
// Active NPC — should breach
let active = world
.spawn((
Npc,
ActiveSim,
ToleranceThreshold {
current_stress: 60,
threshold: 50,
},
))
.id();
// Background NPC — should NOT breach
world.spawn((
Npc,
BackgroundSim,
ToleranceThreshold {
current_stress: 100,
threshold: 50,
},
));
run_system(&mut world);
let queue = world.resource::<ToleranceBreachEventQueue>();
assert_eq!(queue.len(), 1);
assert_eq!(queue.events[0].entity, active);
}
}
+6
View File
@@ -68,6 +68,12 @@ impl ObservationEventQueue {
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
/// Iterate over observation events without draining.
/// Used by monologue trigger system (#119) to react to previous-tick events.
pub fn iter(&self) -> impl Iterator<Item = &ObservationEvent> {
self.events.iter()
}
}
/// System: interpret visible snapshot against known routines and knowledge.
+34 -1
View File
@@ -19,6 +19,7 @@ use crate::perception::vision_cone::Facing;
use crate::simulation::contraband::ScanEventBuffer;
use crate::simulation::conversation::ConversationEventBuffer;
use crate::simulation::dialogue::DialogueResponseBuffer;
use crate::simulation::follow::FollowTarget;
use crate::simulation::interaction::NearbyInteractionBuffer;
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
use crate::simulation::monologue::{MonologueBuffer, SprintAnomalyQueue};
@@ -80,6 +81,7 @@ pub fn compute_observer_snapshot(
Option<&mut DialogueResponseBuffer>,
Option<&mut ScanEventBuffer>,
Option<&mut ConversationEventBuffer>,
Option<&FollowTarget>,
),
With<PlayerCharacter>,
>,
@@ -89,6 +91,7 @@ pub fn compute_observer_snapshot(
Option<&PlayerCharacter>,
Option<&crate::npc::Npc>,
Option<&AccessRule>,
Option<&crate::npc::tell_state::DerivedTellState>,
)>,
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
mut buffer: ResMut<SnapshotBuffer>,
@@ -108,6 +111,7 @@ pub fn compute_observer_snapshot(
mut dialogue_response_opt,
mut scan_event_buffer_opt,
mut conversation_buffer_opt,
follow_target_opt,
)) = observer_query.single_mut()
else {
tracing::error!("compute_observer_snapshot: PlayerCharacter query failed");
@@ -245,6 +249,29 @@ pub fn compute_observer_snapshot(
None => geometry.visible_tiles.clone(),
};
// Build follow-mode state for client HUD (#241)
let follow_state = follow_target_opt.and_then(|ft| {
let target_wire_id = registry.to_stable(ft.target)?.0;
// Distance computed from current positions
let all_query_iter = all_entities.iter();
let target_pos = all_query_iter
.filter_map(|(e, pos, _, _, _, _)| (e == ft.target).then_some(pos))
.next()?;
let distance = observer_pos
.manhattan_distance(target_pos)
.unwrap_or(u32::MAX);
let has_los = target_pos.z == geometry.observer_z
&& geometry
.visible_positions
.contains(&(target_pos.x, target_pos.y));
Some(crate::simulation::follow::FollowStateWire {
target_entity_id: target_wire_id,
distance,
has_los,
proximity_ticks: ft.proximity_ticks,
})
});
buffer.snapshot = Some(ObserverSnapshot {
version: crate::bridge::types::PROTOCOL_VERSION,
tick: time.tick,
@@ -262,6 +289,7 @@ pub fn compute_observer_snapshot(
scan_events,
conversation_events,
conversation_ended,
follow_state,
sound_events,
rng_seed: sim_rng.as_deref().map(|r| r.seed()),
});
@@ -282,13 +310,14 @@ fn filter_visible_entities(
Option<&PlayerCharacter>,
Option<&crate::npc::Npc>,
Option<&AccessRule>,
Option<&crate::npc::tell_state::DerivedTellState>,
)>,
) -> (Vec<VisibleEntity>, BTreeSet<u64>, Vec<u64>) {
let mut entities = Vec::new();
let mut visible_ids: BTreeSet<u64> = BTreeSet::new();
let mut blocked_ids: BTreeSet<u64> = BTreeSet::new();
for (entity, pos, is_player, is_npc, access_rule) in all_entities.iter() {
for (entity, pos, is_player, is_npc, access_rule, tell_opt) in all_entities.iter() {
if pos.z != geometry.observer_z {
continue;
}
@@ -343,6 +372,8 @@ fn filter_visible_entities(
RelationshipState::Unknown
};
let tell_state = tell_opt.and_then(|t| t.category);
visible_ids.insert(wire_id);
entities.push(VisibleEntity {
entity_id: wire_id,
@@ -353,6 +384,7 @@ fn filter_visible_entities(
visibility: sector,
relationship,
observation: EntityVisibility::Visible,
tell_state,
});
}
@@ -413,6 +445,7 @@ fn collect_remembered_entities(
kind: EntityKind::Npc,
visibility: VisibilitySector::Forward,
relationship: knowledge.relationship,
tell_state: None, // Remembered entities have no live tell state
observation: EntityVisibility::Remembered {
confidence: knowledge.confidence,
age_ticks,
+8 -7
View File
@@ -759,8 +759,8 @@ fn phase2_confront_injected_for_npc_with_knows_details() {
let snapshot = buffer.snapshot.as_ref().unwrap();
assert_eq!(snapshot.nearby_interactions.len(), 1);
let interaction = &snapshot.nearby_interactions[0];
// Should have Talk, ExamineNpc, AND Confront (Phase 2 injected)
assert_eq!(interaction.verbs.len(), 3);
// Should have Talk, ExamineNpc, Follow, AND Confront (Phase 2 injected)
assert_eq!(interaction.verbs.len(), 4);
let confront = interaction
.verbs
.iter()
@@ -1322,16 +1322,17 @@ fn phase2_poi_with_confront_verb_order() {
let verbs = &snapshot.nearby_interactions[0].verbs;
assert_eq!(
verbs.len(),
3,
"POI+KnowsDetails: ExamineNpc + Talk + Confront"
4,
"POI+KnowsDetails: ExamineNpc + Talk + Follow + Confront"
);
// POI flips ExamineNpc to priority 1, Talk to 2, Confront at 3
// POI flips ExamineNpc to priority 1, Talk to 2, Follow at 3, Confront at 3
assert_eq!(verbs[0].kind, VerbKind::ExamineNpc);
assert_eq!(verbs[0].priority, 1);
assert_eq!(verbs[1].kind, VerbKind::Talk);
assert_eq!(verbs[1].priority, 2);
assert_eq!(verbs[2].kind, VerbKind::Confront);
assert_eq!(verbs[2].priority, 3);
// Follow and Confront both at priority 3 — sorted by VerbKind discriminant
assert!(verbs.iter().any(|v| v.kind == VerbKind::Follow));
assert!(verbs.iter().any(|v| v.kind == VerbKind::Confront));
}
// -----------------------------------------------------------------------
+5
View File
@@ -587,6 +587,7 @@ pub fn process_walk_away(
mut commands: Commands,
mut event_queue: ResMut<crate::knowledge::KnowledgeEventQueue>,
mut trust_queue: ResMut<TrustEventQueue>,
mut post_conv_queue: ResMut<crate::simulation::monologue::PostConversationQueue>,
time: Res<SimulationTime>,
query: Query<(Entity, Option<&ActiveDialogue>, &WalkAwayRequest), With<PlayerCharacter>>,
mut npc_mem_query: Query<Option<&mut InteractionMemory>>,
@@ -635,6 +636,9 @@ pub fn process_walk_away(
});
}
// Post-conversation monologue trigger (#119, D-035)
post_conv_queue.push(target);
tracing::debug!(
"Walk-away during {:?} dialogue at tick {} (started tick {}): \
target {:?} → Tier2 animation + routine deviation",
@@ -1121,6 +1125,7 @@ mod tests {
world.init_resource::<EntityRegistry>();
world.init_resource::<crate::knowledge::KnowledgeEventQueue>();
world.init_resource::<TrustEventQueue>();
world.init_resource::<crate::simulation::monologue::PostConversationQueue>();
world
}
File diff suppressed because it is too large Load Diff
+84 -2
View File
@@ -180,7 +180,16 @@ pub fn process_player_input(
PlayerAction::Interact {
target_entity_id,
ref verb,
} => match verb.as_deref() {
} => {
// Cancel follow when player uses any non-Follow verb (#241).
if verb.as_deref() != Some("Follow") {
if let Ok((player_entity, _, _, _)) = player_query.single() {
commands
.entity(player_entity)
.remove::<crate::simulation::follow::FollowTarget>();
}
}
match verb.as_deref() {
Some("Take") => {
handle_take(
&mut commands,
@@ -202,6 +211,16 @@ pub fn process_player_input(
target_entity_id,
);
}
Some("Follow") => {
handle_follow(
&mut commands,
&registry,
&player_query,
&all_positions,
target_entity_id,
current_tick,
);
}
Some("Confront") => {
handle_confront(
&mut commands,
@@ -228,7 +247,7 @@ pub fn process_player_input(
verb,
);
}
},
}}
PlayerAction::WalkAway => {
if let Ok((player_entity, _, _, _)) = player_query.single() {
commands
@@ -501,6 +520,69 @@ fn handle_confront(
);
}
/// Handle Follow verb: designate an NPC as follow target (#241).
/// Sets FollowTarget on the player entity. Replaces any existing follow target.
/// Server-side range check: Follow requires CLOSE_RANGE (same as Talk).
#[allow(clippy::type_complexity)]
fn handle_follow(
commands: &mut Commands,
registry: &EntityRegistry,
player_query: &Query<
(
Entity,
&TilePosition,
Option<&mut Stance>,
Option<&mut PlayerMoveCooldown>,
),
With<PlayerCharacter>,
>,
all_positions: &Query<&TilePosition>,
target_entity_id: Option<u64>,
current_tick: u64,
) {
let Some(target_id) = target_entity_id else {
tracing::warn!("Follow 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, "Follow: target entity not in registry");
return;
};
// Server-side range check: reject Follow 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,
"Follow: target out of range (max {})",
crate::simulation::interaction::CLOSE_RANGE,
);
return;
}
}
commands
.entity(player_entity)
.insert(crate::simulation::follow::FollowTarget {
target: target_entity,
started_tick: current_tick,
proximity_ticks: 0,
los_lost_ticks: 0,
});
tracing::debug!(target_id, "Follow: FollowTarget set on player");
}
/// Handle Place verb: remove an item from inventory and place it on the ground
/// at the player's current position. Removes CarriedBy + InventorySlot, adds
/// TilePosition at the player's current tile.
+16 -7
View File
@@ -208,9 +208,9 @@ pub fn compute_nearby_interactions(
let mut verbs = Vec::new();
if is_npc.is_some() {
// NPC verb logic — unchanged from #404
// NPC verb logic — Talk + ExamineNpc (#404), Follow (#241)
if is_close {
// Default priority: Talk first, Observe second.
// Default priority: Talk first, Observe second, Follow third.
// Observer adjusts priority for POI entities.
verbs.push(VerbOption {
kind: VerbKind::Talk,
@@ -224,8 +224,14 @@ pub fn compute_nearby_interactions(
priority: 2,
available: true,
});
verbs.push(VerbOption {
kind: VerbKind::Follow,
label: "Follow".into(),
priority: 3,
available: true,
});
} else {
// Mid range: only Examine NPC (Talk requires close range)
// Mid range: only Examine NPC (Talk + Follow require close range)
verbs.push(VerbOption {
kind: VerbKind::ExamineNpc,
label: "Examine NPC".into(),
@@ -346,7 +352,7 @@ mod tests {
// -----------------------------------------------------------------------
#[test]
fn npc_in_close_range_gets_talk_and_observe() {
fn npc_in_close_range_gets_talk_examine_follow() {
let mut world = setup_world();
spawn_player(&mut world, 5, 5);
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
@@ -357,11 +363,13 @@ mod tests {
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 1);
assert_eq!(buffer.interactions[0].verbs.len(), 2);
assert_eq!(buffer.interactions[0].verbs.len(), 3);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Talk);
assert_eq!(buffer.interactions[0].verbs[0].priority, 1);
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::ExamineNpc);
assert_eq!(buffer.interactions[0].verbs[1].priority, 2);
assert_eq!(buffer.interactions[0].verbs[2].kind, VerbKind::Follow);
assert_eq!(buffer.interactions[0].verbs[2].priority, 3);
}
#[test]
@@ -845,10 +853,11 @@ mod tests {
let buffer = read_buffer(&mut world);
assert_eq!(buffer.interactions.len(), 1);
// Should get NPC verbs (Talk + ExamineNpc), NOT Terminal verbs (Use + Observe)
assert_eq!(buffer.interactions[0].verbs.len(), 2);
// Should get NPC verbs (Talk + ExamineNpc + Follow), NOT Terminal verbs (Use + Observe)
assert_eq!(buffer.interactions[0].verbs.len(), 3);
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Talk);
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::ExamineNpc);
assert_eq!(buffer.interactions[0].verbs[2].kind, VerbKind::Follow);
}
/// All ObjectType primary verbs are close_only (except Observe).
+6
View File
@@ -7,6 +7,7 @@ use bevy_ecs::schedule::IntoScheduleConfigs;
pub mod contraband;
pub mod conversation;
pub mod dialogue;
pub mod follow;
pub mod input;
pub mod interaction;
pub mod inventory;
@@ -17,6 +18,7 @@ pub mod path_follow;
pub mod pathfinding;
pub mod rng;
pub mod sound;
pub mod spatial;
pub mod stance;
pub mod tier;
pub mod time;
@@ -37,6 +39,9 @@ impl Plugin for SimulationPlugin {
.init_resource::<input::InputQueue>()
.init_resource::<crate::knowledge::EntityRegistry>()
.init_resource::<sound::SoundEventQueue>()
.init_resource::<spatial::NaiveSpatialIndex>()
.init_resource::<follow::FollowEndEventQueue>()
.init_resource::<monologue::PostConversationQueue>()
.add_systems(
Update,
(
@@ -45,6 +50,7 @@ impl Plugin for SimulationPlugin {
path_follow::follow_paths.after(pathfinding::compute_paths),
movement::validate_movement.after(path_follow::follow_paths),
path_follow::cleanup_path_blocked.after(movement::validate_movement),
spatial::sync_spatial_index.after(movement::validate_movement),
listening::update_listening_focus.after(movement::validate_movement),
contraband::check_contraband_scan
.after(movement::validate_movement)
File diff suppressed because it is too large Load Diff
+496
View File
@@ -0,0 +1,496 @@
// SpatialIndex trait + naive Vec implementation (#340)
// Provides proximity queries for follow mechanic (#241), NPC vision (#115, deferred).
// Trait abstraction allows grid/quadtree replacement without touching callers.
//
// Uses bevy_ecs Entity handles (not StableId) — this is a simulation-layer
// spatial optimization, not a knowledge graph concern.
use bevy_ecs::prelude::*;
use crate::simulation::movement::TilePosition;
/// Trait for spatial proximity queries over entities with TilePosition.
///
/// Implementations must be registered as a Bevy Resource.
/// All methods operate on the same z-level — cross-z queries return empty.
pub trait SpatialIndex: Send + Sync {
/// Return all entities within Manhattan distance `radius` of `position` on the same z-level.
/// Does NOT include entities exactly at `position` — use `entities_at` for that.
fn entities_in_range(&self, position: &TilePosition, radius: u32) -> Vec<Entity>;
/// Return all entities at the exact `position`.
fn entities_at(&self, position: &TilePosition) -> Vec<Entity>;
/// Insert or update an entity's position in the index.
fn update(&mut self, entity: Entity, position: TilePosition);
/// Remove an entity from the index (e.g. on despawn or tier transition).
fn remove(&mut self, entity: Entity);
}
/// Naive Vec-backed spatial index — O(n) queries, sufficient for Active tier (30-80 NPCs).
///
/// Replace with grid or quadtree when profiling shows this is a bottleneck.
/// Deterministic iteration: entries stored in insertion order, but callers
/// should not depend on ordering (sort by Entity::to_bits() if needed).
///
/// ## Migration cost for grid/quadtree swap
///
/// `sync_spatial_index` takes `ResMut<NaiveSpatialIndex>` directly because
/// bevy_ecs cannot store `dyn SpatialIndex` as a Resource. A swap to Grid or
/// BVH requires changing the concrete type in: (1) `sync_spatial_index` system
/// parameter, (2) `SimulationPlugin` resource registration, (3) any system
/// that queries `Res<NaiveSpatialIndex>` (currently: `update_follow_state`).
/// The `SpatialIndex` trait ensures the API surface stays identical — only the
/// type name changes at call sites. Estimated: ~5 lines per caller.
#[derive(Resource, Debug, Default)]
pub struct NaiveSpatialIndex {
entries: Vec<(Entity, TilePosition)>,
}
impl NaiveSpatialIndex {
pub fn new() -> Self {
Self {
entries: Vec::new(),
}
}
}
impl SpatialIndex for NaiveSpatialIndex {
fn entities_in_range(&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 entities_at(&self, position: &TilePosition) -> Vec<Entity> {
self.entries
.iter()
.filter(|(_, pos)| pos == position)
.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;
} else {
self.entries.push((entity, position));
}
}
fn remove(&mut self, entity: Entity) {
self.entries.retain(|(e, _)| *e != entity);
}
}
/// System: sync TilePosition changes into the NaiveSpatialIndex each tick.
///
/// Runs after movement validation so positions are final for the tick.
/// Only tracks entities with TilePosition — entities without it are not indexed.
pub fn sync_spatial_index(
mut index: ResMut<NaiveSpatialIndex>,
query: Query<(Entity, &TilePosition), Changed<TilePosition>>,
mut removed: RemovedComponents<TilePosition>,
) {
for (entity, pos) in query.iter() {
index.update(entity, *pos);
}
for entity in removed.read() {
index.remove(entity);
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy_ecs::world::World;
fn make_entity(world: &mut World) -> Entity {
world.spawn_empty().id()
}
#[test]
fn entities_at_exact_position() {
let mut world = World::new();
let mut index = NaiveSpatialIndex::new();
let e1 = make_entity(&mut world);
let e2 = make_entity(&mut world);
let e3 = make_entity(&mut world);
let pos = TilePosition::new(5, 5, 0);
index.update(e1, pos);
index.update(e2, pos);
index.update(e3, TilePosition::new(6, 5, 0));
let at = index.entities_at(&pos);
assert_eq!(at.len(), 2);
assert!(at.contains(&e1));
assert!(at.contains(&e2));
assert!(!at.contains(&e3));
}
#[test]
fn entities_in_range_excludes_exact_position() {
let mut world = World::new();
let mut index = NaiveSpatialIndex::new();
let e_at = make_entity(&mut world);
let e_near = make_entity(&mut world);
let center = TilePosition::new(5, 5, 0);
index.update(e_at, center);
index.update(e_near, TilePosition::new(5, 6, 0));
let in_range = index.entities_in_range(&center, 2);
assert!(!in_range.contains(&e_at), "entity at center should be excluded");
assert!(in_range.contains(&e_near));
}
#[test]
fn entities_in_range_manhattan_distance() {
let mut world = World::new();
let mut index = NaiveSpatialIndex::new();
let e_close = make_entity(&mut world);
let e_boundary = make_entity(&mut world);
let e_far = make_entity(&mut world);
let center = TilePosition::new(5, 5, 0);
index.update(e_close, TilePosition::new(5, 6, 0)); // distance 1
index.update(e_boundary, TilePosition::new(7, 5, 0)); // distance 2
index.update(e_far, TilePosition::new(8, 5, 0)); // distance 3
let in_range = index.entities_in_range(&center, 2);
assert!(in_range.contains(&e_close));
assert!(in_range.contains(&e_boundary));
assert!(!in_range.contains(&e_far));
}
#[test]
fn different_z_level_excluded() {
let mut world = World::new();
let mut index = NaiveSpatialIndex::new();
let e = make_entity(&mut world);
index.update(e, TilePosition::new(5, 5, 1));
let center = TilePosition::new(5, 5, 0);
assert!(index.entities_in_range(&center, 10).is_empty());
assert!(index.entities_at(&center).is_empty());
}
#[test]
fn update_moves_existing_entity() {
let mut world = World::new();
let mut index = NaiveSpatialIndex::new();
let e = make_entity(&mut world);
let old_pos = TilePosition::new(5, 5, 0);
let new_pos = TilePosition::new(10, 10, 0);
index.update(e, old_pos);
assert_eq!(index.entities_at(&old_pos).len(), 1);
index.update(e, new_pos);
assert!(index.entities_at(&old_pos).is_empty());
assert_eq!(index.entities_at(&new_pos).len(), 1);
}
#[test]
fn remove_entity() {
let mut world = World::new();
let mut index = NaiveSpatialIndex::new();
let e = make_entity(&mut world);
let pos = TilePosition::new(5, 5, 0);
index.update(e, pos);
assert_eq!(index.entities_at(&pos).len(), 1);
index.remove(e);
assert!(index.entities_at(&pos).is_empty());
}
#[test]
fn remove_nonexistent_entity_is_noop() {
let mut world = World::new();
let mut index = NaiveSpatialIndex::new();
let e = make_entity(&mut world);
index.remove(e); // should not panic
}
#[test]
fn empty_index_returns_empty() {
let index = NaiveSpatialIndex::new();
let pos = TilePosition::new(5, 5, 0);
assert!(index.entities_at(&pos).is_empty());
assert!(index.entities_in_range(&pos, 10).is_empty());
}
#[test]
fn zero_radius_returns_nothing() {
let mut world = World::new();
let mut index = NaiveSpatialIndex::new();
let e = make_entity(&mut world);
let center = TilePosition::new(5, 5, 0);
index.update(e, TilePosition::new(5, 6, 0)); // distance 1
// Radius 0: only exact position would match, but entities_in_range excludes center
assert!(index.entities_in_range(&center, 0).is_empty());
}
// -----------------------------------------------------------------------
// Additional QA correctness tests (Hoshe, Sprint 15)
// -----------------------------------------------------------------------
/// Verify that updating an entity twice does not insert duplicates.
/// Callers of update() rely on the index having at most one entry per entity.
#[test]
fn update_does_not_duplicate_entity() {
let mut world = World::new();
let mut index = NaiveSpatialIndex::new();
let e = make_entity(&mut world);
let pos = TilePosition::new(5, 5, 0);
index.update(e, pos);
index.update(e, pos); // same position again
assert_eq!(
index.entries.len(),
1,
"update with same position must not insert a duplicate entry"
);
assert_eq!(index.entities_at(&pos).len(), 1);
}
/// Moving an entity does not accumulate stale entries.
#[test]
fn update_to_new_position_does_not_leave_old_entry() {
let mut world = World::new();
let mut index = NaiveSpatialIndex::new();
let e = make_entity(&mut world);
index.update(e, TilePosition::new(5, 5, 0));
index.update(e, TilePosition::new(10, 10, 0));
index.update(e, TilePosition::new(15, 15, 0));
// Entry list should still have exactly one entry for this entity
assert_eq!(index.entries.len(), 1);
}
/// `entities_in_range` with radius 1: includes distance-1, excludes distance-2.
#[test]
fn entities_in_range_radius_1_boundary() {
let mut world = World::new();
let mut index = NaiveSpatialIndex::new();
let e_dist1_x = make_entity(&mut world);
let e_dist1_y = make_entity(&mut world);
let e_dist2 = make_entity(&mut world);
let center = TilePosition::new(5, 5, 0);
index.update(e_dist1_x, TilePosition::new(6, 5, 0)); // Manhattan = 1
index.update(e_dist1_y, TilePosition::new(5, 4, 0)); // Manhattan = 1
index.update(e_dist2, TilePosition::new(7, 5, 0)); // Manhattan = 2
let in_range = index.entities_in_range(&center, 1);
assert!(in_range.contains(&e_dist1_x));
assert!(in_range.contains(&e_dist1_y));
assert!(!in_range.contains(&e_dist2), "distance 2 must not appear in radius-1 result");
}
/// Diagonal: Manhattan distance covers all 4 orthogonal directions.
#[test]
fn entities_in_range_all_four_directions() {
let mut world = World::new();
let mut index = NaiveSpatialIndex::new();
let north = make_entity(&mut world);
let south = make_entity(&mut world);
let east = make_entity(&mut world);
let west = make_entity(&mut world);
let corner = make_entity(&mut world); // distance 2 via diagonal (Manhattan = 2)
let center = TilePosition::new(10, 10, 0);
index.update(north, TilePosition::new(10, 11, 0)); // distance 1
index.update(south, TilePosition::new(10, 9, 0)); // distance 1
index.update(east, TilePosition::new(11, 10, 0)); // distance 1
index.update(west, TilePosition::new(9, 10, 0)); // distance 1
index.update(corner, TilePosition::new(11, 11, 0)); // distance 2
let in_range = index.entities_in_range(&center, 2);
assert!(in_range.contains(&north));
assert!(in_range.contains(&south));
assert!(in_range.contains(&east));
assert!(in_range.contains(&west));
assert!(in_range.contains(&corner));
}
/// Entities just outside radius are excluded even when within Euclidean distance.
/// (5, 5) vs (8, 8): Manhattan = 6, Euclidean ≈ 4.2. Radius 5 → excluded.
#[test]
fn manhattan_excludes_diagonal_entity_within_euclidean() {
let mut world = World::new();
let mut index = NaiveSpatialIndex::new();
let e = make_entity(&mut world);
let center = TilePosition::new(5, 5, 0);
index.update(e, TilePosition::new(8, 8, 0)); // Manhattan = 6
let in_range = index.entities_in_range(&center, 5);
assert!(
!in_range.contains(&e),
"Manhattan distance 6 must not appear in radius-5 result"
);
}
/// `entities_at` returns empty when no entity is at the queried position.
#[test]
fn entities_at_returns_empty_for_unoccupied_position() {
let mut world = World::new();
let mut index = NaiveSpatialIndex::new();
let e = make_entity(&mut world);
index.update(e, TilePosition::new(5, 5, 0));
assert!(index.entities_at(&TilePosition::new(6, 5, 0)).is_empty());
}
/// Multiple entities at the same position — all returned by entities_at.
#[test]
fn entities_at_handles_multiple_entities_same_tile() {
let mut world = World::new();
let mut index = NaiveSpatialIndex::new();
let pos = TilePosition::new(5, 5, 0);
let entities: Vec<Entity> = (0..5).map(|_| make_entity(&mut world)).collect();
for &e in &entities {
index.update(e, pos);
}
let at = index.entities_at(&pos);
assert_eq!(at.len(), 5, "all entities at same tile must be returned");
for e in &entities {
assert!(at.contains(e));
}
}
/// Large radius includes all entities in the index (except those at center).
#[test]
fn large_radius_includes_all_entities() {
let mut world = World::new();
let mut index = NaiveSpatialIndex::new();
let center = TilePosition::new(50, 50, 0);
let mut entities = vec![];
for i in 0..10_i32 {
let e = make_entity(&mut world);
index.update(e, TilePosition::new(i, i, 0)); // All far from center
entities.push(e);
}
let in_range = index.entities_in_range(&center, 200);
assert_eq!(in_range.len(), 10, "radius 200 should include all 10 entities");
}
/// Remove one entity from a multi-entity index, others remain.
#[test]
fn remove_one_entity_others_intact() {
let mut world = World::new();
let mut index = NaiveSpatialIndex::new();
let pos = TilePosition::new(5, 5, 0);
let e1 = make_entity(&mut world);
let e2 = make_entity(&mut world);
let e3 = make_entity(&mut world);
index.update(e1, pos);
index.update(e2, pos);
index.update(e3, TilePosition::new(6, 5, 0));
index.remove(e1);
let at = index.entities_at(&pos);
assert_eq!(at.len(), 1, "only e2 should remain at the position");
assert!(at.contains(&e2));
assert!(!at.contains(&e1));
// e3 unaffected
assert_eq!(index.entities_at(&TilePosition::new(6, 5, 0)).len(), 1);
}
/// Stress test: 100 entities, correctness at scale.
#[test]
fn stress_100_entities_correctness() {
let mut world = World::new();
let mut index = NaiveSpatialIndex::new();
let center = TilePosition::new(0, 0, 0);
let mut in_range_expected = 0u32;
for i in 0..100_i32 {
let e = make_entity(&mut world);
let pos = TilePosition::new(i, 0, 0); // distance = i from center
index.update(e, pos);
if i > 0 && i <= 10 {
in_range_expected += 1;
}
}
let result = index.entities_in_range(&center, 10);
assert_eq!(
result.len(),
in_range_expected as usize,
"radius-10 from origin should include exactly 10 entities (distance 1..10)"
);
}
/// sync_spatial_index system test: Changed<TilePosition> updates the index.
#[test]
fn sync_system_tracks_position_changes() {
use super::sync_spatial_index;
let mut world = World::new();
world.init_resource::<NaiveSpatialIndex>();
let start = TilePosition::new(5, 5, 0);
let dest = TilePosition::new(10, 10, 0);
let entity = world.spawn(start).id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(sync_spatial_index);
// First run: entity inserted with initial position
schedule.run(&mut world);
{
let idx = world.resource::<NaiveSpatialIndex>();
assert_eq!(idx.entities_at(&start).len(), 1, "entity should be at start after first sync");
}
// Update position
world.entity_mut(entity).insert(dest);
// Second run: entity moved
schedule.run(&mut world);
{
let idx = world.resource::<NaiveSpatialIndex>();
assert!(idx.entities_at(&start).is_empty(), "old position should be cleared");
assert_eq!(idx.entities_at(&dest).len(), 1, "entity should be at new position");
}
}
}
+2
View File
@@ -53,6 +53,7 @@ fn snapshot_roundtrip_over_unix_socket() {
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
tell_state: None,
}],
visible_tiles: vec![],
nearby_interactions: vec![],
@@ -64,6 +65,7 @@ fn snapshot_roundtrip_over_unix_socket() {
sound_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
rng_seed: None,
};
+2
View File
@@ -39,6 +39,7 @@ fn snapshot_roundtrip_over_tcp() {
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
tell_state: None,
}],
visible_tiles: vec![],
nearby_interactions: vec![],
@@ -50,6 +51,7 @@ fn snapshot_roundtrip_over_tcp() {
sound_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
rng_seed: None,
};
+9
View File
@@ -40,6 +40,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
sound_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
rng_seed: None,
}
}
@@ -59,6 +60,7 @@ fn generate_msgpack_fixtures() {
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
tell_state: None,
}],
);
write_fixture(
@@ -102,6 +104,7 @@ fn generate_msgpack_fixtures() {
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
tell_state: None,
}],
);
write_fixture(
@@ -122,6 +125,7 @@ fn generate_msgpack_fixtures() {
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Known,
observation: EntityVisibility::Visible,
tell_state: None,
},
VisibleEntity {
entity_id: 2,
@@ -132,6 +136,7 @@ fn generate_msgpack_fixtures() {
visibility: VisibilitySector::Peripheral,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
tell_state: None,
},
VisibleEntity {
entity_id: 3,
@@ -142,6 +147,7 @@ fn generate_msgpack_fixtures() {
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
tell_state: None,
},
VisibleEntity {
entity_id: 4,
@@ -152,6 +158,7 @@ fn generate_msgpack_fixtures() {
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
tell_state: None,
},
],
);
@@ -182,6 +189,7 @@ fn generate_msgpack_fixtures() {
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
tell_state: None,
}],
visible_tiles: vec![
VisibleTile {
@@ -218,6 +226,7 @@ fn generate_msgpack_fixtures() {
sound_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
rng_seed: None,
};
write_fixture(
+3 -2
View File
@@ -38,6 +38,7 @@
"z": 0
}
],
"follow_state": null,
"game_time": {
"day": 0,
"day_phase": "Morning",
@@ -48,7 +49,7 @@
"pending_recognitions": [
{
"entity_id": 1,
"remaining_ticks": 2,
"remaining_ticks": 1,
"total_delay_ticks": 6,
"x": 16.5,
"y": 13.5,
@@ -70,7 +71,7 @@
"scan_events": [],
"sound_events": [],
"tick": 8,
"version": 12,
"version": 13,
"visible_tiles": [
{
"tile_kind": "Wall",
+8 -1
View File
@@ -29,6 +29,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
sound_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
rng_seed: None,
}
}
@@ -46,6 +47,7 @@ fn observer_snapshot_roundtrip() {
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
tell_state: None,
}],
);
@@ -199,6 +201,7 @@ fn all_entity_kind_variants_roundtrip() {
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
tell_state: None,
};
let snapshot = test_snapshot(0, vec![entity]);
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
@@ -235,6 +238,7 @@ fn snapshot_v2_fields_roundtrip() {
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
tell_state: None,
}],
visible_tiles: vec![
VisibleTile {
@@ -263,6 +267,7 @@ fn snapshot_v2_fields_roundtrip() {
sound_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
rng_seed: None,
};
@@ -318,7 +323,7 @@ fn protocol_version_constant_matches_snapshot() {
let snapshot = test_snapshot(0, vec![]);
assert_eq!(snapshot.version, PROTOCOL_VERSION);
assert_eq!(
PROTOCOL_VERSION, 12,
PROTOCOL_VERSION, 13,
"bump this assertion when protocol version changes"
);
}
@@ -361,6 +366,7 @@ fn all_facing_direction_variants_roundtrip() {
sound_events: vec![],
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
rng_seed: None,
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
@@ -839,6 +845,7 @@ fn boundary_value_in_entity_id() {
visibility: VisibilitySector::Forward,
relationship: RelationshipState::Unknown,
observation: EntityVisibility::Visible,
tell_state: None,
}],
);
let bytes = rmp_serde::to_vec_named(&snapshot)