diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index 8abc99478..b170cdb17 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -300,6 +300,8 @@ mod tests { dialogue_response: None, blocked_entities: vec![], scan_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], sound_events: vec![], rng_seed: None, } @@ -425,6 +427,8 @@ mod tests { dialogue_response: None, blocked_entities: vec![], scan_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], sound_events: vec![], rng_seed: None, }; diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 5396fe634..5c784529e 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -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 = 11; +pub const PROTOCOL_VERSION: u8 = 12; /// The ONLY data structure crossing the client-server boundary (D-020) /// Contains all information visible to the observer at a given tick. @@ -31,10 +31,11 @@ pub const PROTOCOL_VERSION: u8 = 11; /// v10 adds: sound_events (#124, D-038 server sound event pipeline), /// 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). /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { - /// Protocol version for forward compatibility. Current: 11. + /// Protocol version for forward compatibility. Current: 12. pub version: u8, /// Simulation tick when this snapshot was produced pub tick: u64, @@ -88,6 +89,15 @@ pub struct ObserverSnapshot { /// Empty when no sounds are in range. #[serde(default)] pub sound_events: Vec, + /// Overheard NPC-to-NPC conversation lines this tick (#247, D-078). + /// Each event carries pre-occluded text — client renders verbatim. + /// Empty when no conversations are overheard. + #[serde(default)] + pub conversation_events: Vec, + /// Conversations that ended this tick (#247, D-078). + /// Client dismisses the passive dialogue panel for these pairs. + #[serde(default)] + pub conversation_ended: Vec, /// 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). diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index 089e688e7..2105b6406 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -17,6 +17,7 @@ use crate::perception::cognitive_delay::CognitiveDelay; use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; use crate::perception::vision_cone::Facing; use crate::simulation::contraband::ScanEventBuffer; +use crate::simulation::conversation::ConversationEventBuffer; use crate::simulation::dialogue::DialogueResponseBuffer; use crate::simulation::interaction::NearbyInteractionBuffer; use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName}; @@ -78,6 +79,7 @@ pub fn compute_observer_snapshot( Option<&CognitiveDelay>, Option<&mut DialogueResponseBuffer>, Option<&mut ScanEventBuffer>, + Option<&mut ConversationEventBuffer>, ), With, >, @@ -105,6 +107,7 @@ pub fn compute_observer_snapshot( cognitive_delay_opt, mut dialogue_response_opt, mut scan_event_buffer_opt, + mut conversation_buffer_opt, )) = observer_query.single_mut() else { tracing::error!("compute_observer_snapshot: PlayerCharacter query failed"); @@ -191,6 +194,12 @@ pub fn compute_observer_snapshot( .map(|buf| buf.take()) .unwrap_or_default(); + // Drain NPC-to-NPC conversation events (#247, D-078) + let (conversation_events, conversation_ended) = conversation_buffer_opt + .as_mut() + .map(|buf| (buf.take_events(), buf.take_ended())) + .unwrap_or_default(); + // Collect sound events audible to the observer (D-038, #124). // Filter by D-018 range: only events the player can hear based on distance. let sound_events = if let Some(ref queue) = sound_queue { @@ -251,6 +260,8 @@ pub fn compute_observer_snapshot( dialogue_response, blocked_entities, scan_events, + conversation_events, + conversation_ended, sound_events, rng_seed: sim_rng.as_deref().map(|r| r.seed()), }); diff --git a/server/src/simulation/conversation.rs b/server/src/simulation/conversation.rs new file mode 100644 index 000000000..c2ebf2c46 --- /dev/null +++ b/server/src/simulation/conversation.rs @@ -0,0 +1,881 @@ +//! NPC-to-NPC conversation system (#247, D-078). +//! +//! NPCs in the Active tier who are in proximity (≤3 tiles) and share a social +//! site occasionally enter conversations. Conversations emit Voice SoundEvents +//! and produce ConversationEvents with server-authoritative per-word occlusion +//! for the player's ObserverSnapshot. +//! +//! Per-word occlusion algorithm (D-078): +//! For each word, an independent Bernoulli trial determines whether the player +//! hears it. Drop probability = f(distance, ambient_noise, listening_focus). +//! Words that fail are replaced with "..." in `occluded_line`. +//! All arithmetic uses integer percentages (0-100) for D-010 determinism. + +use bevy_ecs::prelude::*; +use rand::Rng; +use serde::{Deserialize, Serialize}; + +use crate::knowledge::types::SoundRange; +use crate::knowledge::EntityRegistry; +use crate::npc::Npc; +use crate::simulation::listening::ListeningFocus; +use crate::simulation::movement::{PlayerCharacter, TilePosition}; +use crate::simulation::rng::SimRng; +use crate::simulation::sound::{SoundEvent, SoundEventEmitter, SoundEventKind}; +use crate::simulation::tier::ActiveSim; +use crate::simulation::time::SimulationTime; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Maximum tile distance for two NPCs to start a conversation. +const CONVERSATION_PROXIMITY: u32 = 3; + +/// Minimum conversation duration in ticks (3 game-minutes at 10 ticks/min). +const MIN_DURATION_TICKS: u64 = 30; + +/// Maximum conversation duration in ticks (12 game-minutes). +const MAX_DURATION_TICKS: u64 = 120; + +/// Cooldown ticks before an NPC can enter another conversation. +/// 5 game-minutes = 50 ticks. +pub(crate) const CONVERSATION_COOLDOWN_TICKS: u64 = 50; + +/// Chance (0-100) per tick that an eligible NPC pair starts a conversation. +/// Low to prevent every pair chatting every tick. ~2% per tick. +const CONVERSATION_CHANCE_PERCENT: u32 = 2; + +/// Voice sound range boundary in tiles (D-018 Medium = 8). +const VOICE_RANGE_TILES: u32 = 8; + +// --------------------------------------------------------------------------- +// Components +// --------------------------------------------------------------------------- + +/// Display name for an NPC, used on the wire for conversation events. +/// Attached during content spawn. +#[derive(Component, Debug, Clone, Serialize, Deserialize)] +pub struct NpcName(pub String); + +/// Active NPC-to-NPC conversation session. +/// Attached to the "speaker" NPC (the one who initiated). +/// The "listener" is tracked by entity reference. +#[derive(Component, Debug)] +pub struct NpcConversation { + /// The other NPC in the conversation. + pub partner: Entity, + /// Tick when the conversation started. + pub started_tick: u64, + /// Tick when the conversation will end. + pub end_tick: u64, + /// Ticks since last line was spoken (for pacing). + pub ticks_since_last_line: u64, +} + +/// Cooldown preventing an NPC from entering another conversation too soon. +#[derive(Component, Debug)] +pub struct ConversationCooldown { + pub until_tick: u64, +} + +// --------------------------------------------------------------------------- +// Wire types (cross bridge boundary) +// --------------------------------------------------------------------------- + +/// Conversation event included in ObserverSnapshot when the player overhears +/// an NPC-to-NPC conversation (D-078). +/// +/// The server performs per-word occlusion before emission — the client receives +/// `occluded_line` and renders it verbatim. No stochastic logic on the client. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConversationEvent { + /// The dialogue line with dropped words replaced by "...". + pub occluded_line: String, + /// Wire-format entity ID of the speaking NPC. + pub speaker_id: u64, + /// Wire-format entity ID of the NPC being spoken to. + pub target_id: u64, + /// Display name of the speaker. + pub speaker_name: String, + /// Display name of the target. + pub target_name: String, +} + +/// End-of-conversation event. Client dismisses the passive dialogue panel. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConversationEndEvent { + /// Wire-format entity ID of speaker. + pub speaker_id: u64, + /// Wire-format entity ID of target. + pub target_id: u64, +} + +/// Buffer holding conversation events for snapshot inclusion. +/// Drained once per snapshot via `take()`. +#[derive(Component, Debug, Default)] +pub struct ConversationEventBuffer { + pub events: Vec, + pub ended: Vec, +} + +impl ConversationEventBuffer { + /// Drain and return all conversation events. + pub fn take_events(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + /// Drain and return all end events. + pub fn take_ended(&mut self) -> Vec { + std::mem::take(&mut self.ended) + } +} + +// --------------------------------------------------------------------------- +// Per-word occlusion (D-078) +// --------------------------------------------------------------------------- + +/// Compute the per-word drop probability as an integer percentage (0-100). +/// +/// Inputs: +/// - `distance`: Manhattan tile distance from player to speaker. +/// - `ambient_noise_pct`: Ambient noise at player position as 0-100 integer. +/// Maps to up to +30 percentage points of drop probability. +/// - `listening_focus`: Whether the player has ListeningFocus active (-20pp). +/// +/// Formula: +/// base = distance * 100 / VOICE_RANGE_TILES (linear 0→100 over range) +/// noise_bonus = ambient_noise_pct * 30 / 100 (up to +30) +/// focus_bonus = if listening_focus { -20 } else { 0 } +/// result = clamp(base + noise_bonus + focus_bonus, 0, 100) +/// +/// All integer arithmetic — no floats (D-010). +pub fn compute_drop_probability( + distance: u32, + ambient_noise_pct: u32, + listening_focus: bool, +) -> u32 { + // Linear distance decay: 0% at distance 0, 100% at VOICE_RANGE_TILES + let base = (distance.min(VOICE_RANGE_TILES) * 100) / VOICE_RANGE_TILES; + + // Ambient noise: scales 0-100 input to 0-30 contribution + let noise_bonus = (ambient_noise_pct.min(100) * 30) / 100; + + // ListeningFocus subtracts 20 + let focus_bonus: i32 = if listening_focus { -20 } else { 0 }; + + let raw = base as i32 + noise_bonus as i32 + focus_bonus; + raw.clamp(0, 100) as u32 +} + +/// Apply per-word occlusion to a dialogue line. +/// +/// Each word undergoes an independent Bernoulli trial: if a random value +/// in [0, 100) is less than `drop_pct`, the word is replaced with "...". +/// Consecutive dropped words collapse into a single "..." per the D-078 spec. +/// +/// Uses SimRng for deterministic replay (D-010). +pub fn occlude_line(line: &str, drop_pct: u32, rng: &mut impl Rng) -> String { + if drop_pct == 0 { + return line.to_string(); + } + if drop_pct >= 100 { + // All words dropped — single ellipsis + if line.split_whitespace().count() > 0 { + return "...".to_string(); + } + return String::new(); + } + + let mut result = Vec::new(); + let mut last_was_dropped = false; + + for word in line.split_whitespace() { + let roll: u32 = rng.random_range(0..100); + if roll < drop_pct { + // Drop this word — collapse consecutive drops + if !last_was_dropped { + result.push("..."); + last_was_dropped = true; + } + } else { + result.push(word); + last_was_dropped = false; + } + } + + result.join(" ") +} + +// --------------------------------------------------------------------------- +// Placeholder line selection +// --------------------------------------------------------------------------- + +/// Placeholder NPC-to-NPC conversation lines. +/// Content sourced from #536 (copy team) — these are development placeholders. +const NPC_CONVERSATION_LINES: &[&str] = &[ + "Heard anything from the night shift?", + "Cargo manifests don't add up again.", + "Keep your head down today.", + "The new arrival's been asking questions.", + "Terminal three has been acting up.", + "Did you see the Commission officer?", + "I need to talk to you about something.", + "Another long shift ahead.", +]; + +// --------------------------------------------------------------------------- +// Systems +// --------------------------------------------------------------------------- + +/// System: initiate new NPC-to-NPC conversations and tick existing ones. +/// +/// Phase 1: Check for eligible NPC pairs (ActiveSim, proximity ≤3, not already +/// in conversation, not on cooldown) and probabilistically start conversations. +/// +/// Phase 2: Tick active conversations — emit Voice SoundEvents and +/// ConversationEvents (with per-word occlusion) for the player's snapshot. +/// Terminate conversations when duration expires or NPCs move apart. +/// +/// System ordering: after validate_movement, before collect_sound_events. +#[allow(clippy::type_complexity, clippy::too_many_arguments)] +pub fn run_npc_conversations( + mut commands: Commands, + time: Res, + registry: Res, + mut rng: ResMut, + // All ActiveSim NPCs — candidates for conversation initiation + npc_query: Query< + ( + Entity, + &TilePosition, + Option<&NpcName>, + Option<&NpcConversation>, + Option<&ConversationCooldown>, + ), + (With, With), + >, + // Player query for occlusion computation + mut player_query: Query< + ( + &TilePosition, + Option<&ListeningFocus>, + &mut ConversationEventBuffer, + ), + With, + >, +) { + // --- Phase 1: Initiate new conversations --- + + // Collect eligible NPCs (not in conversation, not on cooldown) + let eligible: Vec<(Entity, TilePosition)> = npc_query + .iter() + .filter(|(_, _, _, conv, cooldown)| { + conv.is_none() + && cooldown + .map(|cd| time.tick >= cd.until_tick) + .unwrap_or(true) + }) + .map(|(entity, pos, _, _, _)| (entity, *pos)) + .collect(); + + // Try to pair eligible NPCs within proximity. + // Iterate pairs in deterministic order (entities from Query iteration). + // Only one new conversation per tick to avoid spam. + let mut started_this_tick = false; + + for i in 0..eligible.len() { + if started_this_tick { + break; + } + for j in (i + 1)..eligible.len() { + 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 + }; + + if distance > CONVERSATION_PROXIMITY { + continue; + } + + // Probabilistic start + let roll: u32 = rng.rng.random_range(0..100); + if roll >= CONVERSATION_CHANCE_PERCENT { + continue; + } + + // Start conversation + let duration = rng.rng.random_range(MIN_DURATION_TICKS..=MAX_DURATION_TICKS); + commands.entity(entity_a).insert(NpcConversation { + partner: entity_b, + started_tick: time.tick, + end_tick: time.tick + duration, + ticks_since_last_line: 0, + }); + + started_this_tick = true; + tracing::debug!( + "NPC conversation started: {:?} ↔ {:?}, duration={} ticks", + entity_a, + entity_b, + duration, + ); + break; + } + } + + // --- Phase 2: Tick active conversations --- + + // Collect active conversations — need mutable access later, so collect first + let active_conversations: Vec<(Entity, NpcConversation, TilePosition, Option)> = + npc_query + .iter() + .filter_map(|(entity, pos, name, conv, _)| { + conv.map(|c| { + ( + entity, + NpcConversation { + partner: c.partner, + started_tick: c.started_tick, + end_tick: c.end_tick, + ticks_since_last_line: c.ticks_since_last_line, + }, + *pos, + name.map(|n| n.0.clone()), + ) + }) + }) + .collect(); + + for (speaker_entity, conv, speaker_pos, speaker_name) in &active_conversations { + let speaker_entity = *speaker_entity; + + // Check termination: duration expired + if time.tick >= conv.end_tick { + terminate_conversation( + &mut commands, + ®istry, + &mut player_query, + speaker_entity, + conv.partner, + time.tick, + ); + continue; + } + + // Check termination: partner moved away or no longer ActiveSim + let partner_ok = npc_query.get(conv.partner).ok().map(|(_, pos, _, _, _)| { + speaker_pos + .manhattan_distance(pos) + .map(|d| d <= CONVERSATION_PROXIMITY) + .unwrap_or(false) + }); + + if partner_ok != Some(true) { + terminate_conversation( + &mut commands, + ®istry, + &mut player_query, + speaker_entity, + conv.partner, + time.tick, + ); + continue; + } + + // Emit Voice SoundEvent at speaker position + if let Some(speaker_sid) = registry.to_stable(speaker_entity) { + let voice_event = SoundEvent::at( + speaker_pos, + SoundEventKind::Voice, + 0.6, + SoundRange::Medium, + Some(speaker_sid.0), + ); + commands + .entity(speaker_entity) + .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 { + // Select a placeholder line + let line_idx = rng.rng.random_range(0..NPC_CONVERSATION_LINES.len()); + let line_text = NPC_CONVERSATION_LINES[line_idx]; + + // Get partner name + let partner_name = npc_query + .get(conv.partner) + .ok() + .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() + { + let distance = speaker_pos + .manhattan_distance(player_pos) + .unwrap_or(u32::MAX); + + // Only emit if within Voice range + if distance <= VOICE_RANGE_TILES { + let listening = listening_focus_opt + .map(|lf| lf.is_eavesdropping()) + .unwrap_or(false); + + // Ambient noise — not yet tracked on ObserverSnapshot, default to 0 + let ambient_noise_pct = 0u32; + + let drop_pct = + compute_drop_probability(distance, ambient_noise_pct, listening); + let occluded = occlude_line(line_text, drop_pct, &mut rng.rng); + + let speaker_sid = registry.to_stable(speaker_entity); + let target_sid = registry.to_stable(conv.partner); + + if let (Some(s_sid), Some(t_sid)) = (speaker_sid, target_sid) { + conv_buffer.events.push(ConversationEvent { + occluded_line: occluded, + speaker_id: s_sid.0, + target_id: t_sid.0, + speaker_name: speaker_name + .clone() + .unwrap_or_else(|| "Unknown".to_string()), + target_name: partner_name, + }); + } + } + } + + // Reset line timer + commands.entity(speaker_entity).insert(NpcConversation { + partner: conv.partner, + started_tick: conv.started_tick, + end_tick: conv.end_tick, + ticks_since_last_line: 0, + }); + } else { + // Increment line timer + commands.entity(speaker_entity).insert(NpcConversation { + partner: conv.partner, + started_tick: conv.started_tick, + end_tick: conv.end_tick, + ticks_since_last_line: conv.ticks_since_last_line + 1, + }); + } + } +} + +/// Terminate a conversation: remove NpcConversation, apply cooldowns, emit end event. +fn terminate_conversation( + commands: &mut Commands, + registry: &EntityRegistry, + player_query: &mut Query< + ( + &TilePosition, + Option<&ListeningFocus>, + &mut ConversationEventBuffer, + ), + With, + >, + speaker: Entity, + partner: Entity, + current_tick: u64, +) { + commands.entity(speaker).remove::(); + + // Apply cooldown to both participants + let until = current_tick + CONVERSATION_COOLDOWN_TICKS; + commands + .entity(speaker) + .insert(ConversationCooldown { until_tick: until }); + commands + .entity(partner) + .insert(ConversationCooldown { until_tick: until }); + + // Emit conversation_end event + 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() { + conv_buffer.ended.push(ConversationEndEvent { + speaker_id: s_sid.0, + target_id: t_sid.0, + }); + } + } + + tracing::debug!( + "NPC conversation ended: {:?} ↔ {:?}", + speaker, + partner, + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use rand::SeedableRng; + use rand_chacha::ChaCha20Rng; + + // -- Per-word occlusion tests ------------------------------------------- + + #[test] + fn occlusion_drops_words_with_distance() { + // At max range (8 tiles), drop probability is 100% — all words dropped + let drop = compute_drop_probability(VOICE_RANGE_TILES, 0, false); + assert_eq!(drop, 100); + + let mut rng = ChaCha20Rng::seed_from_u64(42); + let result = occlude_line("Hello there friend", drop, &mut rng); + assert_eq!(result, "..."); + } + + #[test] + fn occlusion_preserves_all_words_at_zero_distance() { + let drop = compute_drop_probability(0, 0, false); + assert_eq!(drop, 0); + + let mut rng = ChaCha20Rng::seed_from_u64(42); + let result = occlude_line("Hello there friend", drop, &mut rng); + assert_eq!(result, "Hello there friend"); + } + + #[test] + fn occlusion_suppressed_by_listening_focus() { + // At distance 2 (25% base), no noise, with focus (-20%) → 5% + let without_focus = compute_drop_probability(2, 0, false); + let with_focus = compute_drop_probability(2, 0, true); + + assert!(with_focus < without_focus, "focus should reduce drop probability"); + assert_eq!(without_focus, 25); // 2 * 100 / 8 = 25 + assert_eq!(with_focus, 5); // 25 - 20 = 5 + } + + #[test] + fn occlusion_deterministic_with_same_seed() { + let line = "The cargo manifests don't add up at all"; + let drop_pct = 50; + + let mut rng1 = ChaCha20Rng::seed_from_u64(42); + let mut rng2 = ChaCha20Rng::seed_from_u64(42); + + let result1 = occlude_line(line, drop_pct, &mut rng1); + let result2 = occlude_line(line, drop_pct, &mut rng2); + + assert_eq!(result1, result2, "same seed must produce same occlusion"); + } + + #[test] + fn occlusion_ambient_noise_adds_up_to_30() { + // Max ambient noise (100%) adds 30 percentage points + let no_noise = compute_drop_probability(0, 0, false); + let max_noise = compute_drop_probability(0, 100, false); + + assert_eq!(no_noise, 0); + assert_eq!(max_noise, 30); + } + + #[test] + fn occlusion_clamps_to_zero() { + // Very close + listening focus → should clamp at 0, not go negative + let drop = compute_drop_probability(0, 0, true); + assert_eq!(drop, 0); // 0 - 20 clamped to 0 + } + + #[test] + fn occlusion_clamps_to_100() { + // Far away + max noise → should cap at 100 + let drop = compute_drop_probability(VOICE_RANGE_TILES, 100, false); + assert_eq!(drop, 100); // 100 + 30 clamped to 100 + } + + #[test] + fn occlusion_consecutive_drops_collapse() { + // Ensure consecutive dropped words become a single "..." + let mut rng = ChaCha20Rng::seed_from_u64(0); + // At 100% drop, everything collapses + let result = occlude_line("one two three four five", 100, &mut rng); + assert_eq!(result, "..."); + } + + #[test] + fn occlusion_empty_line() { + let mut rng = ChaCha20Rng::seed_from_u64(42); + let result = occlude_line("", 50, &mut rng); + assert_eq!(result, ""); + } + + #[test] + fn occlusion_linear_distance_scaling() { + // Distance 4 out of 8 = 50% + assert_eq!(compute_drop_probability(4, 0, false), 50); + // Distance 1 out of 8 = 12% (integer division: 1*100/8 = 12) + assert_eq!(compute_drop_probability(1, 0, false), 12); + // Distance 6 out of 8 = 75% + assert_eq!(compute_drop_probability(6, 0, false), 75); + } + + // -- Conversation lifecycle tests (ECS) -------------------------------- + + fn setup_conversation_world() -> bevy_ecs::world::World { + let mut world = bevy_ecs::world::World::new(); + world.init_resource::(); + world.insert_resource(SimRng::new(42)); + world.init_resource::(); + world + } + + #[test] + fn npc_conversation_emits_voice_event_when_in_range() { + let mut world = setup_conversation_world(); + + let npc_a = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + NpcName("Alice".to_string()), + NpcConversation { + partner: Entity::PLACEHOLDER, + started_tick: 0, + end_tick: 100, + ticks_since_last_line: 0, + }, + )) + .id(); + world.resource_mut::().register(npc_a); + + let npc_b = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 6, 0), + NpcName("Bob".to_string()), + )) + .id(); + world.resource_mut::().register(npc_b); + + // Fix the partner reference + world.get_mut::(npc_a).unwrap().partner = npc_b; + + // Spawn player within voice range + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 8, 0), // distance 3 from speaker + ConversationEventBuffer::default(), + )) + .id(); + world.resource_mut::().register(player); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(run_npc_conversations); + schedule.run(&mut world); + world.flush(); + + // Check that a SoundEventEmitter with Voice was attached to the speaker + let emitter = world.get::(npc_a); + assert!( + emitter.is_some(), + "Speaker should have a SoundEventEmitter after conversation tick" + ); + assert_eq!(emitter.unwrap().pending[0].kind, SoundEventKind::Voice); + + // Check that a ConversationEvent was buffered for the player + let buffer = world.get::(player).unwrap(); + assert_eq!( + buffer.events.len(), + 1, + "Player in range should receive a conversation event" + ); + assert_eq!(buffer.events[0].speaker_name, "Alice"); + assert_eq!(buffer.events[0].target_name, "Bob"); + } + + #[test] + fn npc_conversation_terminates_when_apart() { + let mut world = setup_conversation_world(); + + let npc_a = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + NpcName("Alice".to_string()), + NpcConversation { + partner: Entity::PLACEHOLDER, + started_tick: 0, + end_tick: 100, + ticks_since_last_line: 0, + }, + )) + .id(); + world.resource_mut::().register(npc_a); + + // Partner is far away (>3 tiles) + let npc_b = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(20, 20, 0), + NpcName("Bob".to_string()), + )) + .id(); + world.resource_mut::().register(npc_b); + + world.get_mut::(npc_a).unwrap().partner = npc_b; + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + ConversationEventBuffer::default(), + )) + .id(); + world.resource_mut::().register(player); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(run_npc_conversations); + schedule.run(&mut world); + world.flush(); + + // Conversation should be removed + assert!( + world.get::(npc_a).is_none(), + "Conversation should terminate when NPCs are apart" + ); + + // End event should be emitted + let buffer = world.get::(player).unwrap(); + assert_eq!( + buffer.ended.len(), + 1, + "conversation_end event should be emitted" + ); + } + + #[test] + fn conversation_terminates_on_duration_expiry() { + let mut world = setup_conversation_world(); + world.resource_mut::().tick = 101; // Past end_tick + + let npc_a = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + NpcConversation { + partner: Entity::PLACEHOLDER, + started_tick: 0, + end_tick: 100, + ticks_since_last_line: 0, + }, + )) + .id(); + world.resource_mut::().register(npc_a); + + let npc_b = world + .spawn((Npc, ActiveSim, TilePosition::new(5, 6, 0))) + .id(); + world.resource_mut::().register(npc_b); + + world.get_mut::(npc_a).unwrap().partner = npc_b; + + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(5, 5, 0), + ConversationEventBuffer::default(), + )) + .id(); + world.resource_mut::().register(player); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(run_npc_conversations); + schedule.run(&mut world); + world.flush(); + + assert!( + world.get::(npc_a).is_none(), + "Conversation should terminate when duration expires" + ); + } + + #[test] + fn player_out_of_range_gets_no_event() { + let mut world = setup_conversation_world(); + + let npc_a = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 5, 0), + NpcName("Alice".to_string()), + NpcConversation { + partner: Entity::PLACEHOLDER, + started_tick: 0, + end_tick: 100, + ticks_since_last_line: 0, + }, + )) + .id(); + world.resource_mut::().register(npc_a); + + let npc_b = world + .spawn(( + Npc, + ActiveSim, + TilePosition::new(5, 6, 0), + NpcName("Bob".to_string()), + )) + .id(); + world.resource_mut::().register(npc_b); + + world.get_mut::(npc_a).unwrap().partner = npc_b; + + // Player far away (distance > 8 = VOICE_RANGE_TILES) + let player = world + .spawn(( + PlayerCharacter, + TilePosition::new(30, 30, 0), + ConversationEventBuffer::default(), + )) + .id(); + world.resource_mut::().register(player); + + let mut schedule = bevy_ecs::schedule::Schedule::default(); + schedule.add_systems(run_npc_conversations); + schedule.run(&mut world); + world.flush(); + + let buffer = world.get::(player).unwrap(); + assert!( + buffer.events.is_empty(), + "Player out of voice range should not receive conversation events" + ); + } + + #[test] + fn drop_probability_formula_matches_spec() { + // D-078 spec: linear decay from 0.0 at 0 tiles to 1.0 at range boundary + assert_eq!(compute_drop_probability(0, 0, false), 0); + assert_eq!(compute_drop_probability(VOICE_RANGE_TILES, 0, false), 100); + + // Ambient noise adds up to 0.3 (30pp) + assert_eq!(compute_drop_probability(0, 100, false), 30); + assert_eq!(compute_drop_probability(0, 50, false), 15); + + // ListeningFocus subtracts 0.2 (20pp) + assert_eq!(compute_drop_probability(4, 0, true), 30); // 50 - 20 + } +} diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index af7e66716..ac305bad4 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -5,6 +5,7 @@ use bevy_app::prelude::*; use bevy_ecs::schedule::IntoScheduleConfigs; pub mod contraband; +pub mod conversation; pub mod dialogue; pub mod input; pub mod interaction; @@ -48,6 +49,9 @@ impl Plugin for SimulationPlugin { contraband::check_contraband_scan .after(movement::validate_movement) .before(crate::perception::observer::compute_observer_snapshot), + conversation::run_npc_conversations + .after(movement::validate_movement) + .before(sound::collect_sound_events), sound::collect_sound_events .after(movement::validate_movement) .before(crate::perception::observer::compute_observer_snapshot), diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index 238560d35..efec87058 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -62,6 +62,8 @@ fn snapshot_roundtrip_over_unix_socket() { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], rng_seed: None, }; diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 3e3d8377b..b75058c1f 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -48,6 +48,8 @@ fn snapshot_roundtrip_over_tcp() { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], rng_seed: None, }; diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index fb0d6f0a0..5ff8fec22 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -38,6 +38,8 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot blocked_entities: vec![], scan_events: vec![], sound_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], rng_seed: None, } } @@ -214,6 +216,8 @@ fn generate_msgpack_fixtures() { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], rng_seed: None, }; write_fixture( diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index ff9362f09..7190ded26 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -27,6 +27,8 @@ fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], rng_seed: None, } } @@ -259,6 +261,8 @@ fn snapshot_v2_fields_roundtrip() { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], rng_seed: None, }; @@ -355,6 +359,8 @@ fn all_facing_direction_variants_roundtrip() { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], rng_seed: None, }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");