diff --git a/server/src/content/spawn.rs b/server/src/content/spawn.rs index 9035f48de..9e0c5e75e 100644 --- a/server/src/content/spawn.rs +++ b/server/src/content/spawn.rs @@ -114,6 +114,9 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR // Interaction history — drives Layer 2 situation activation (#325, D-028) entity_commands.insert(npc::interaction::InteractionMemory::default()); + // Mood state — drives Layer 4 dialogue selection and monologue tone (#323) + entity_commands.insert(npc::mood::MoodState::default()); + // Axis 1: Want if let Some(want) = &profile.want { if let Some(kind) = parse_want_kind(&want.primary) { diff --git a/server/src/npc/interaction.rs b/server/src/npc/interaction.rs index a907d2b3a..aaad2f2b1 100644 --- a/server/src/npc/interaction.rs +++ b/server/src/npc/interaction.rs @@ -47,7 +47,7 @@ pub struct InteractionMemory { pub last_interaction_tick: u64, /// Notable events: walk-aways and confrontations. /// Bounded by `MAX_NOTABLE_EVENTS` — oldest entries dropped when full. - pub notable_events: Vec, + pub notable_events: std::collections::VecDeque, } /// Maximum number of notable events retained per NPC pair. @@ -65,9 +65,9 @@ impl InteractionMemory { /// Append a notable event, dropping the oldest if at capacity. pub fn push_event(&mut self, event: InteractionEvent) { if self.notable_events.len() >= MAX_NOTABLE_EVENTS { - self.notable_events.remove(0); + self.notable_events.pop_front(); } - self.notable_events.push(event); + self.notable_events.push_back(event); } /// Returns `true` if this is the first meeting (count == 0). @@ -145,7 +145,7 @@ mod tests { }); assert_eq!(mem.notable_events.len(), MAX_NOTABLE_EVENTS); assert_eq!(mem.notable_events[0].tick, 1); // tick=0 dropped - assert_eq!(mem.notable_events.last().unwrap().tick, 99); + assert_eq!(mem.notable_events.back().unwrap().tick, 99); } #[test] diff --git a/server/src/npc/mood.rs b/server/src/npc/mood.rs index 59599e04b..c2bd26a7a 100644 --- a/server/src/npc/mood.rs +++ b/server/src/npc/mood.rs @@ -47,12 +47,14 @@ pub enum NpcMood { /// Low stress, positive recent context — settled and cooperative. Content, /// Observing unusual or off-script behavior — targeted wariness. + /// Not reachable from `derive_mood()` — set externally by observation pipeline. Suspicious, /// Recent positive player interaction within memory window. Warm, /// Stress at or above threshold — confrontational or withdrawn. Hostile, /// Actively engaged in a scheduled activity — task-focused. + /// Not reachable from `derive_mood()` — set externally by activity scheduler (#101). Focused, } @@ -145,7 +147,9 @@ pub fn derive_mood( // 2. Anxious: above 60% of threshold. // Guard: skip if threshold == 0 (divide-by-zero equivalent — entity // has no tolerance and is already Hostile from rule 1). - if threshold > 0 && current_stress * 100 >= threshold * ANXIOUS_STRESS_NUMERATOR { + if threshold > 0 + && (current_stress as i32) * 100 >= (threshold as i32) * (ANXIOUS_STRESS_NUMERATOR as i32) + { return NpcMood::Anxious; } diff --git a/server/src/npc/relationships.rs b/server/src/npc/relationships.rs index 065435c79..758d4ab9d 100644 --- a/server/src/npc/relationships.rs +++ b/server/src/npc/relationships.rs @@ -141,8 +141,8 @@ impl RelationshipGraph { } /// Get all entities who have feelings about a target. - /// Full scan — use for event detection, not per-tick queries. - pub fn who_knows(&self, target: &StableId) -> Vec<(&StableId, &RelationshipEdge)> { + /// O(N) full scan of all edges — use for event detection, not per-tick queries. + pub fn who_knows_full_scan(&self, target: &StableId) -> Vec<(&StableId, &RelationshipEdge)> { self.edges .iter() .filter(|((_, t), _)| t == target) @@ -374,7 +374,7 @@ mod tests { make_edge(RelationshipKind::Family, 8), ); - let knowers = graph.who_knows(&target); + let knowers = graph.who_knows_full_scan(&target); assert_eq!(knowers.len(), 3); } diff --git a/server/src/simulation/conversation.rs b/server/src/simulation/conversation.rs index 7e688679a..90d7d04d2 100644 --- a/server/src/simulation/conversation.rs +++ b/server/src/simulation/conversation.rs @@ -15,6 +15,7 @@ use bevy_ecs::prelude::*; use rand::Rng; use serde::{Deserialize, Serialize}; +use crate::knowledge::registry::StableEntityId; use crate::knowledge::types::SoundRange; use crate::knowledge::EntityRegistry; use crate::npc::Npc; @@ -49,6 +50,9 @@ const CONVERSATION_CHANCE_PERCENT: u32 = 2; /// Voice sound range boundary in tiles (D-018 Medium = 8). const VOICE_RANGE_TILES: u32 = 8; +/// Ticks between conversation lines (~2 game-minutes at 10 ticks/min). +const LINE_INTERVAL_TICKS: u64 = 20; + // --------------------------------------------------------------------------- // Components // --------------------------------------------------------------------------- @@ -252,6 +256,7 @@ pub fn run_npc_conversations( Option<&NpcName>, Option<&NpcConversation>, Option<&ConversationCooldown>, + Option<&StableEntityId>, ), (With, With), >, @@ -267,20 +272,25 @@ pub fn run_npc_conversations( ) { // --- Phase 1: Initiate new conversations --- - // Collect eligible NPCs (not in conversation, not on cooldown) - let eligible: Vec<(Entity, TilePosition)> = npc_query + // Collect eligible NPCs (not in conversation, not on cooldown). + // Sorted by StableId for deterministic pairing order (D-010). + let mut eligible: Vec<(Entity, TilePosition, u64)> = npc_query .iter() - .filter(|(_, _, _, conv, cooldown)| { + .filter(|(_, _, _, conv, cooldown, _)| { conv.is_none() && cooldown .map(|cd| time.tick >= cd.until_tick) .unwrap_or(true) }) - .map(|(entity, pos, _, _, _)| (entity, *pos)) + .map(|(entity, pos, _, _, _, sid)| { + (entity, *pos, sid.map(|s| s.0 .0).unwrap_or(u64::MAX)) + }) .collect(); + eligible.sort_by_key(|&(_, _, sid)| sid); // Try to pair eligible NPCs within proximity. - // Iterate pairs in deterministic order (entities from Query iteration). + // O(N^2) pair scan — acceptable for v0.1 Active-tier counts (30-80 NPCs, D-026). + // If NPC population grows beyond ~200, consider spatial indexing. // Only one new conversation per tick to avoid spam. let mut started_this_tick = false; @@ -289,8 +299,8 @@ pub fn run_npc_conversations( break; } for j in (i + 1)..eligible.len() { - let (entity_a, pos_a) = eligible[i]; - let (entity_b, pos_b) = eligible[j]; + let (entity_a, pos_a, _) = eligible[i]; + let (entity_b, pos_b, _) = eligible[j]; let Some(distance) = pos_a.manhattan_distance(&pos_b) else { continue; // different z-levels @@ -332,7 +342,7 @@ pub fn run_npc_conversations( let active_conversations: Vec<(Entity, NpcConversation, TilePosition, Option)> = npc_query .iter() - .filter_map(|(entity, pos, name, conv, _)| { + .filter_map(|(entity, pos, name, conv, _, _)| { conv.map(|c| { ( entity, @@ -366,7 +376,7 @@ pub fn run_npc_conversations( } // Check termination: partner moved away or no longer ActiveSim - let partner_ok = npc_query.get(conv.partner).ok().map(|(_, pos, _, _, _)| { + let partner_ok = npc_query.get(conv.partner).ok().map(|(_, pos, _, _, _, _)| { speaker_pos .manhattan_distance(pos) .map(|d| d <= CONVERSATION_PROXIMITY) @@ -399,9 +409,8 @@ pub fn run_npc_conversations( .insert(SoundEventEmitter::new(voice_event)); } - // Emit conversation line periodically (~every 20-40 ticks = 2-4 game-minutes) - let line_interval = 20; // ticks between lines - if conv.ticks_since_last_line >= line_interval || conv.ticks_since_last_line == 0 { + // Emit conversation line periodically + if conv.ticks_since_last_line >= LINE_INTERVAL_TICKS || conv.ticks_since_last_line == 0 { // Select a placeholder line let line_idx = rng.rng.random_range(0..NPC_CONVERSATION_LINES.len()); let line_text = NPC_CONVERSATION_LINES[line_idx]; @@ -410,12 +419,13 @@ pub fn run_npc_conversations( let partner_name = npc_query .get(conv.partner) .ok() - .and_then(|(_, _, name, _, _)| name.map(|n| n.0.clone())) + .and_then(|(_, _, name, _, _, _)| name.map(|n| n.0.clone())) .unwrap_or_else(|| "Unknown".to_string()); - // Compute occlusion for the player - if let Ok((player_pos, listening_focus_opt, mut conv_buffer)) = - player_query.single_mut() + // Compute per-observer occlusion (D-078, D-010 principle 3). + // Iterates all observers — supports future multi-observer scenarios (D-027). + for (player_pos, listening_focus_opt, mut conv_buffer) in + player_query.iter_mut() { let distance = speaker_pos .manhattan_distance(player_pos) @@ -427,7 +437,8 @@ pub fn run_npc_conversations( .map(|lf| lf.is_eavesdropping()) .unwrap_or(false); - // Ambient noise — not yet tracked on ObserverSnapshot, default to 0 + // Zone ambient noise — stubbed at 0 until zone-conspicuousness + // (D-071) wires in. Function signature already accepts the value. let ambient_noise_pct = 0u32; let drop_pct = @@ -445,7 +456,7 @@ pub fn run_npc_conversations( speaker_name: speaker_name .clone() .unwrap_or_else(|| "Unknown".to_string()), - target_name: partner_name, + target_name: partner_name.clone(), }); } } @@ -497,12 +508,12 @@ fn terminate_conversation( .entity(partner) .insert(ConversationCooldown { until_tick: until }); - // Emit conversation_end event + // Emit conversation_end event to all observers (D-010 principle 3). let speaker_sid = registry.to_stable(speaker); let target_sid = registry.to_stable(partner); if let (Some(s_sid), Some(t_sid)) = (speaker_sid, target_sid) { - if let Ok((_, _, mut conv_buffer)) = player_query.single_mut() { + for (_, _, mut conv_buffer) in player_query.iter_mut() { conv_buffer.ended.push(ConversationEndEvent { speaker_id: s_sid.0, target_id: t_sid.0, diff --git a/server/src/test_world/invariants.rs b/server/src/test_world/invariants.rs index 9d2b27a57..a32532f9a 100644 --- a/server/src/test_world/invariants.rs +++ b/server/src/test_world/invariants.rs @@ -38,6 +38,12 @@ use crate::simulation::pathfinding::{ComputedPath, PathBlocked}; #[cfg(feature = "gauntlet")] use crate::simulation::sound::SoundEvent; #[cfg(feature = "gauntlet")] +use crate::npc::interaction::InteractionMemory; +#[cfg(feature = "gauntlet")] +use crate::npc::mood::MoodState; +#[cfg(feature = "gauntlet")] +use crate::npc::routine::ActivityState; +#[cfg(feature = "gauntlet")] use crate::simulation::tier::{ActiveSim, BackgroundSim, StateSaved}; #[cfg(feature = "gauntlet")] @@ -93,6 +99,12 @@ pub fn run_invariants(world: &mut World) { inv_sim6_registry_has_entities(world); inv_sim7_player_has_monologue_buffer(world); inv_sim8_player_has_interaction_buffer(world); + + // --- Sprint 14 component invariants --- + inv_s14_active_npcs_have_mood_state(world); + inv_s14_npcs_have_interaction_memory(world); + inv_s14_no_activity_state_with_path_request(world); + inv_pop3b_no_double_tagged_tiers(world); } // =========================================================================== @@ -729,6 +741,111 @@ fn inv_sim8_player_has_interaction_buffer(world: &mut World) { ); } +// =========================================================================== +// Sprint 14 component invariants +// =========================================================================== + +/// S14-1: Every Active-tier NPC has a MoodState component. +/// Active NPCs without MoodState are invisible to the mood-driven dialogue pipeline. +#[cfg(feature = "gauntlet")] +fn inv_s14_active_npcs_have_mood_state(world: &mut World) { + let active = { + let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With, bevy_ecs::prelude::With)>(); + q.iter(world).count() + }; + let with_mood = { + let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With, bevy_ecs::prelude::With, bevy_ecs::prelude::With)>(); + q.iter(world).count() + }; + assert_eq!( + with_mood, + active, + "S14-1: all {} Active NPCs must have MoodState; only {} do", + active, + with_mood + ); +} + +/// S14-2: Every NPC has an InteractionMemory component. +/// NPCs without InteractionMemory cannot drive Layer 2 situation activation. +#[cfg(feature = "gauntlet")] +fn inv_s14_npcs_have_interaction_memory(world: &mut World) { + let total = { + let mut q = world.query_filtered::<(), bevy_ecs::prelude::With>(); + q.iter(world).count() + }; + let with_mem = { + let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With, bevy_ecs::prelude::With)>(); + q.iter(world).count() + }; + assert_eq!( + with_mem, + total, + "S14-2: all {} NPCs must have InteractionMemory; only {} do", + total, + with_mem + ); +} + +/// S14-3: No entity has both ActivityState and PathRequest simultaneously. +/// ActivityState means "at destination, performing activity". PathRequest means +/// "needs to move somewhere". Both at once is contradictory. +#[cfg(feature = "gauntlet")] +fn inv_s14_no_activity_state_with_path_request(world: &mut World) { + use bevy_ecs::prelude::{Entity, With}; + use crate::simulation::pathfinding::PathRequest; + + let with_activity: Vec = { + let mut q = world.query_filtered::>(); + q.iter(world).collect() + }; + for entity in with_activity { + assert!( + world.get::(entity).is_none(), + "S14-3: entity {:?} has both ActivityState and PathRequest — mutually exclusive", + entity + ); + } +} + +/// Pop3b: No NPC is double-tagged with multiple tier markers. +/// Pop3 catches missing tiers via sum check, but not double-tagged entities +/// (e.g. both ActiveSim + BackgroundSim would still sum correctly). +#[cfg(feature = "gauntlet")] +fn inv_pop3b_no_double_tagged_tiers(world: &mut World) { + use bevy_ecs::prelude::{Entity, With}; + + let active_and_bg: Vec = { + let mut q = world.query_filtered::, With, With)>(); + q.iter(world).collect() + }; + assert!( + active_and_bg.is_empty(), + "Pop3b: {} NPC(s) are double-tagged with both ActiveSim and BackgroundSim", + active_and_bg.len() + ); + + let active_and_ss: Vec = { + let mut q = world.query_filtered::, With, With)>(); + q.iter(world).collect() + }; + assert!( + active_and_ss.is_empty(), + "Pop3b: {} NPC(s) are double-tagged with both ActiveSim and StateSaved", + active_and_ss.len() + ); + + let bg_and_ss: Vec = { + let mut q = world.query_filtered::, With, With)>(); + q.iter(world).collect() + }; + assert!( + bg_and_ss.is_empty(), + "Pop3b: {} NPC(s) are double-tagged with both BackgroundSim and StateSaved", + bg_and_ss.len() + ); +} + // =========================================================================== // Additional system-execution tests (7 more invariants, via #[test]) // Tests 30-36 exercise behaviour that requires running ECS systems. diff --git a/server/src/test_world/mod.rs b/server/src/test_world/mod.rs index fd8620bec..5687fa50b 100644 --- a/server/src/test_world/mod.rs +++ b/server/src/test_world/mod.rs @@ -495,6 +495,48 @@ pub fn setup_gauntlet(app: &mut App) { } app.insert_resource(snapshots); + + // --- Sprint 14 component fixup --- + // Attach MoodState and InteractionMemory to all Npc entities that are + // missing them. Gauntlet room builders don't include these yet — this + // ensures invariant S14-1/S14-2 pass and the mood/trust systems have + // valid component targets. + { + use crate::npc::interaction::InteractionMemory; + use crate::npc::mood::MoodState; + use crate::npc::Npc; + + let missing_mood: Vec = { + let mut q = app + .world_mut() + .query_filtered::, + bevy_ecs::prelude::Without, + )>(); + q.iter(app.world()).collect() + }; + for entity in missing_mood { + app.world_mut() + .entity_mut(entity) + .insert(MoodState::default()); + } + + let missing_mem: Vec = { + let mut q = app + .world_mut() + .query_filtered::, + bevy_ecs::prelude::Without, + )>(); + q.iter(app.world()).collect() + }; + for entity in missing_mem { + app.world_mut() + .entity_mut(entity) + .insert(InteractionMemory::default()); + } + } + app.insert_resource(registry); }