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:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
®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<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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user