feat(simulation): NPC-to-NPC conversation system (#247, D-078)

Implements server-authoritative per-word occlusion for NPC conversations.
ConversationEventBuffer drains into ObserverSnapshot each tick so the client
receives only words audible from the player's position. Updates test fixtures
to include the new conversation_events and conversation_ended fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 18:41:44 +01:00
co-authored by Claude Sonnet 4.6
parent 4e53a2e979
commit aa0d8ced37
9 changed files with 926 additions and 2 deletions
+4
View File
@@ -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,
};
+12 -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 = 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<crate::simulation::sound::SoundEvent>,
/// 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<crate::simulation::conversation::ConversationEvent>,
/// Conversations that ended this tick (#247, D-078).
/// Client dismisses the passive dialogue panel for these pairs.
#[serde(default)]
pub conversation_ended: Vec<crate::simulation::conversation::ConversationEndEvent>,
/// 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).
+11
View File
@@ -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<PlayerCharacter>,
>,
@@ -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()),
});
+881
View File
@@ -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<ConversationEvent>,
pub ended: Vec<ConversationEndEvent>,
}
impl ConversationEventBuffer {
/// Drain and return all conversation events.
pub fn take_events(&mut self) -> Vec<ConversationEvent> {
std::mem::take(&mut self.events)
}
/// Drain and return all end events.
pub fn take_ended(&mut self) -> Vec<ConversationEndEvent> {
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<SimulationTime>,
registry: Res<EntityRegistry>,
mut rng: ResMut<SimRng>,
// All ActiveSim NPCs — candidates for conversation initiation
npc_query: Query<
(
Entity,
&TilePosition,
Option<&NpcName>,
Option<&NpcConversation>,
Option<&ConversationCooldown>,
),
(With<Npc>, With<ActiveSim>),
>,
// Player query for occlusion computation
mut player_query: Query<
(
&TilePosition,
Option<&ListeningFocus>,
&mut ConversationEventBuffer,
),
With<PlayerCharacter>,
>,
) {
// --- 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<String>)> =
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,
&registry,
&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,
&registry,
&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<PlayerCharacter>,
>,
speaker: Entity,
partner: Entity,
current_tick: u64,
) {
commands.entity(speaker).remove::<NpcConversation>();
// 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::<SimulationTime>();
world.insert_resource(SimRng::new(42));
world.init_resource::<EntityRegistry>();
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::<EntityRegistry>().register(npc_a);
let npc_b = world
.spawn((
Npc,
ActiveSim,
TilePosition::new(5, 6, 0),
NpcName("Bob".to_string()),
))
.id();
world.resource_mut::<EntityRegistry>().register(npc_b);
// Fix the partner reference
world.get_mut::<NpcConversation>(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::<EntityRegistry>().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::<SoundEventEmitter>(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::<ConversationEventBuffer>(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::<EntityRegistry>().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::<EntityRegistry>().register(npc_b);
world.get_mut::<NpcConversation>(npc_a).unwrap().partner = npc_b;
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
ConversationEventBuffer::default(),
))
.id();
world.resource_mut::<EntityRegistry>().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::<NpcConversation>(npc_a).is_none(),
"Conversation should terminate when NPCs are apart"
);
// End event should be emitted
let buffer = world.get::<ConversationEventBuffer>(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::<SimulationTime>().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::<EntityRegistry>().register(npc_a);
let npc_b = world
.spawn((Npc, ActiveSim, TilePosition::new(5, 6, 0)))
.id();
world.resource_mut::<EntityRegistry>().register(npc_b);
world.get_mut::<NpcConversation>(npc_a).unwrap().partner = npc_b;
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
ConversationEventBuffer::default(),
))
.id();
world.resource_mut::<EntityRegistry>().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::<NpcConversation>(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::<EntityRegistry>().register(npc_a);
let npc_b = world
.spawn((
Npc,
ActiveSim,
TilePosition::new(5, 6, 0),
NpcName("Bob".to_string()),
))
.id();
world.resource_mut::<EntityRegistry>().register(npc_b);
world.get_mut::<NpcConversation>(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::<EntityRegistry>().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::<ConversationEventBuffer>(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
}
}
+4
View File
@@ -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),
+2
View File
@@ -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,
};
+2
View File
@@ -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,
};
+4
View File
@@ -38,6 +38,8 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> 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(
+6
View File
@@ -27,6 +27,8 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> 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");