Files
settled-reach/server/src/simulation/conversation.rs
T
jpmschweitzerandClaude Opus 4.6 aa79dd97e7 fix(simulation): Clippy cleanup and CI enforcement (#635)
Fix all Clippy warnings across the server codebase (2411 insertions, 1341
deletions). Raise type-complexity-threshold to 750 and too-many-arguments
to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server
now passes `cargo clippy -- --deny warnings` cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:33:15 +01:00

1441 lines
50 KiB
Rust

//! 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::registry::StableEntityId;
use crate::knowledge::types::SoundRange;
use crate::knowledge::EntityRegistry;
use crate::knowledge::KnowledgeGraph;
use crate::npc::Npc;
use crate::simulation::dialogue::DialogueProfile;
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;
/// Ticks between conversation lines (~2 game-minutes at 10 ticks/min).
const LINE_INTERVAL_TICKS: u64 = 20;
// ---------------------------------------------------------------------------
// 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);
/// Map a dialogue role string to a display label for use when the player
/// does not yet know the NPC's real name.
pub fn display_label_for_role(role: &str) -> String {
match role {
"dock-worker" => "Dock Worker",
"courier" => "Courier",
"maintenance-tech" => "Technician",
"new-hire" | "day-worker" | "transit-worker" => "Worker",
"scheduler" => "Scheduler",
"shift-supervisor" => "Supervisor",
"bartender" => "Bartender",
"bar-regular" => "Patron",
_ => "Bystander",
}
.to_string()
}
/// Color index (0-7) for rendering this NPC with a distinct color in the
/// conversation log. Assigned at spawn time as `(stable_id % 8)`.
#[derive(Component, Debug, Clone, Copy, Serialize, Deserialize)]
pub struct NpcColorIndex(pub u8);
/// 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 (real name if known to player, else role label).
#[serde(default)]
pub speaker_name: String,
/// Display name of the target (real name if known to player, else role label).
#[serde(default)]
pub target_name: String,
/// Color index (0-7) for the speaker's conversation log entry.
#[serde(default)]
pub speaker_color_index: u8,
/// Color index (0-7) for the target's conversation log entry.
#[serde(default)]
pub target_color_index: u8,
}
/// 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>,
Option<&StableEntityId>,
Option<&NpcColorIndex>,
Option<&DialogueProfile>,
),
(With<Npc>, With<ActiveSim>),
>,
// Player query for occlusion computation
mut player_query: Query<
(
&TilePosition,
Option<&ListeningFocus>,
&mut ConversationEventBuffer,
&KnowledgeGraph,
),
With<PlayerCharacter>,
>,
) {
// --- Phase 1: Initiate new conversations ---
// Collect eligible NPCs (not in conversation, not on cooldown).
// Sorted by StableId for deterministic pairing order (D-010).
let mut eligible: Vec<(Entity, TilePosition, u64)> = npc_query
.iter()
.filter(|(_, _, _, conv, cooldown, _, _, _)| {
conv.is_none()
&& cooldown
.map(|cd| time.tick >= cd.until_tick)
.unwrap_or(true)
})
.map(|(entity, pos, _, _, _, sid, _, _)| {
(entity, *pos, sid.map(|s| s.0 .0).unwrap_or(u64::MAX))
})
.collect();
eligible.sort_by_key(|&(_, _, sid)| sid);
// Try to pair eligible NPCs within proximity.
// O(N^2) pair scan — acceptable for v0.1 Active-tier counts (30-80 NPCs, D-026).
// If NPC population grows beyond ~200, consider spatial indexing.
// Only one new conversation per tick to avoid spam.
let mut started_this_tick = false;
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.
// Tuple: (entity, conv, pos, npc_real_name, npc_role, npc_color_index)
let active_conversations: Vec<(
Entity,
NpcConversation,
TilePosition,
Option<String>,
Option<String>,
Option<u8>,
)> = npc_query
.iter()
.filter_map(|(entity, pos, name, conv, _, _, color_idx, profile)| {
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()),
profile.map(|p| p.role.clone()),
color_idx.map(|ci| ci.0),
)
})
})
.collect();
for (speaker_entity, conv, speaker_pos, speaker_name, speaker_role, speaker_color) 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
if conv.ticks_since_last_line >= LINE_INTERVAL_TICKS || conv.ticks_since_last_line == 0 {
// Select a placeholder line
let line_idx = rng.rng.random_range(0..NPC_CONVERSATION_LINES.len());
let line_text = NPC_CONVERSATION_LINES[line_idx];
// Collect partner display info (real name, role, color) once — used
// per-observer below to resolve display names against each observer's KG.
let (partner_real_name, partner_role, partner_color) = npc_query
.get(conv.partner)
.ok()
.map(|(_, _, pname, _, _, _, pcolor, pprofile)| {
(
pname.map(|n| n.0.clone()),
pprofile.map(|p| p.role.clone()),
pcolor.map(|ci| ci.0).unwrap_or(0u8),
)
})
.unwrap_or((None, None, 0u8));
let speaker_sid = registry.to_stable(speaker_entity);
let target_sid = registry.to_stable(conv.partner);
// Compute per-observer occlusion (D-078, D-010 principle 3).
// Iterates all observers — supports future multi-observer scenarios (D-027).
for (player_pos, listening_focus_opt, mut conv_buffer, player_kg) in
player_query.iter_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);
// Zone ambient noise — stubbed at 0 until zone-conspicuousness
// (D-071) wires in. Function signature already accepts the value.
let ambient_noise_pct = 0u32;
let drop_pct = compute_drop_probability(distance, ambient_noise_pct, listening);
let occluded = occlude_line(line_text, drop_pct, &mut rng.rng);
if let (Some(s_sid), Some(t_sid)) = (speaker_sid, target_sid) {
// Resolve speaker display name per this observer's KG.
let speaker_display = {
let known = player_kg
.entity_knowledge(&s_sid)
.map(|e| e.known_attributes.contains_key("name"))
.unwrap_or(false);
if known {
speaker_name
.clone()
.unwrap_or_else(|| "Unknown".to_string())
} else {
speaker_role
.as_deref()
.map(display_label_for_role)
.unwrap_or_else(|| "Bystander".to_string())
}
};
// Resolve target display name per this observer's KG.
let target_display = {
let known = player_kg
.entity_knowledge(&t_sid)
.map(|e| e.known_attributes.contains_key("name"))
.unwrap_or(false);
if known {
partner_real_name
.clone()
.unwrap_or_else(|| "Unknown".to_string())
} else {
partner_role
.as_deref()
.map(display_label_for_role)
.unwrap_or_else(|| "Bystander".to_string())
}
};
conv_buffer.events.push(ConversationEvent {
occluded_line: occluded,
speaker_id: s_sid.0,
target_id: t_sid.0,
speaker_name: speaker_display,
target_name: target_display,
speaker_color_index: speaker_color.unwrap_or(0),
target_color_index: partner_color,
});
}
}
}
// 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,
&KnowledgeGraph,
),
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 to all observers (D-010 principle 3).
let speaker_sid = registry.to_stable(speaker);
let target_sid = registry.to_stable(partner);
if let (Some(s_sid), Some(t_sid)) = (speaker_sid, target_sid) {
for (_, _, mut conv_buffer, _) in player_query.iter_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 crate::knowledge::KnowledgeGraph;
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(),
KnowledgeGraph::new(),
))
.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"
);
// Player KG has no "name" attribute for either NPC, and NPCs have no
// DialogueProfile, so both should fall back to the Bystander label.
assert_eq!(buffer.events[0].speaker_name, "Bystander");
assert_eq!(buffer.events[0].target_name, "Bystander");
// Color index defaults to 0 when NpcColorIndex is not attached.
assert_eq!(buffer.events[0].speaker_color_index, 0);
assert_eq!(buffer.events[0].target_color_index, 0);
}
#[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(),
KnowledgeGraph::new(),
))
.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(),
KnowledgeGraph::new(),
))
.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(),
KnowledgeGraph::new(),
))
.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
}
// -- Additional QA coverage (Hoshe, Sprint 14) --------------------------
#[test]
fn cooldown_applied_to_both_npcs_after_distance_termination() {
// When a conversation terminates (NPCs drift apart), both NPCs must
// receive ConversationCooldown to prevent immediate re-pairing.
let mut world = setup_conversation_world();
world.resource_mut::<SimulationTime>().tick = 100;
let npc_a = world
.spawn((
Npc,
ActiveSim,
TilePosition::new(5, 5, 0),
NpcName("Alice".to_string()),
NpcConversation {
partner: Entity::PLACEHOLDER,
started_tick: 50,
end_tick: 200,
ticks_since_last_line: 0,
},
))
.id();
world.resource_mut::<EntityRegistry>().register(npc_a);
// Partner far away — conversation should terminate this tick
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(),
KnowledgeGraph::new(),
))
.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();
// Both NPCs must have ConversationCooldown applied
let cooldown_a = world.get::<ConversationCooldown>(npc_a);
assert!(
cooldown_a.is_some(),
"Speaker (npc_a) must get ConversationCooldown after termination"
);
assert_eq!(
cooldown_a.unwrap().until_tick,
100 + CONVERSATION_COOLDOWN_TICKS,
"Cooldown until_tick must be current_tick + CONVERSATION_COOLDOWN_TICKS"
);
let cooldown_b = world.get::<ConversationCooldown>(npc_b);
assert!(
cooldown_b.is_some(),
"Partner (npc_b) must get ConversationCooldown after termination"
);
assert_eq!(
cooldown_b.unwrap().until_tick,
100 + CONVERSATION_COOLDOWN_TICKS,
"Both NPCs receive the same cooldown duration"
);
}
#[test]
fn cooldown_applied_after_duration_expiry() {
// Termination by duration should also apply cooldowns.
let mut world = setup_conversation_world();
world.resource_mut::<SimulationTime>().tick = 200;
let npc_a = world
.spawn((
Npc,
ActiveSim,
TilePosition::new(5, 5, 0),
NpcConversation {
partner: Entity::PLACEHOLDER,
started_tick: 0,
end_tick: 100, // expired
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(),
KnowledgeGraph::new(),
))
.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::<ConversationCooldown>(npc_a).is_some(),
"Speaker must get cooldown after duration expiry"
);
assert!(
world.get::<ConversationCooldown>(npc_b).is_some(),
"Partner must get cooldown after duration expiry"
);
}
#[test]
fn npc_on_active_cooldown_cannot_start_conversation() {
// An NPC with ConversationCooldown (until_tick > current_tick) must
// not be eligible for new conversation initiation.
let mut world = setup_conversation_world();
world.resource_mut::<SimulationTime>().tick = 50;
// NPC on cooldown (expires at tick 100, current is 50)
world.spawn((
Npc,
ActiveSim,
TilePosition::new(5, 5, 0),
ConversationCooldown { until_tick: 100 },
));
world.spawn((
Npc,
ActiveSim,
TilePosition::new(5, 6, 0),
ConversationCooldown { until_tick: 100 },
));
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
ConversationEventBuffer::default(),
KnowledgeGraph::new(),
));
// Run many ticks — no conversation should ever start because all NPCs are on cooldown
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(run_npc_conversations);
for _ in 0..50 {
schedule.run(&mut world);
world.flush();
}
// Verify no NpcConversation was created
let mut conv_query = world.query::<&NpcConversation>();
assert!(
conv_query.iter(&world).count() == 0,
"NPCs on cooldown must not enter conversations"
);
}
#[test]
fn expired_cooldown_allows_conversation_initiation() {
// A cooldown whose until_tick <= current_tick should not block the NPC.
let mut world = setup_conversation_world();
// Set tick high enough that the cooldown has expired
world.resource_mut::<SimulationTime>().tick = 200;
// Both NPCs have cooldowns that expired at tick 100
world.spawn((
Npc,
ActiveSim,
TilePosition::new(5, 5, 0),
ConversationCooldown { until_tick: 100 }, // expired at 200
));
world.spawn((
Npc,
ActiveSim,
TilePosition::new(5, 6, 0),
ConversationCooldown { until_tick: 100 }, // expired at 200
));
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
ConversationEventBuffer::default(),
KnowledgeGraph::new(),
));
// With 2% chance per tick, over 300 ticks a conversation is extremely likely.
// Use a fresh world per attempt but share the schedule.
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(run_npc_conversations);
// Run until we see a conversation or hit max attempts
let mut found = false;
for _ in 0..300 {
schedule.run(&mut world);
world.flush();
let mut conv_query = world.query::<&NpcConversation>();
if conv_query.iter(&world).count() > 0 {
found = true;
break;
}
}
assert!(
found,
"Expired cooldown should allow conversation initiation (2% per tick, 300 attempts)"
);
}
// -- Name masking tests (Sprint 15) --------------------------------------
#[test]
fn conversation_uses_role_label_when_name_not_in_player_kg() {
// Player KG has an entry for the NPC but no "name" attribute.
// ConversationEvent.speaker_name should be the role label.
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,
},
DialogueProfile {
location: "the-terminal".to_string(),
role: "dock-worker".to_string(),
},
))
.id();
let npc_a_sid = world.resource_mut::<EntityRegistry>().register(npc_a);
let npc_b = world
.spawn((
Npc,
ActiveSim,
TilePosition::new(5, 6, 0),
NpcName("Bob".to_string()),
DialogueProfile {
location: "the-terminal".to_string(),
role: "courier".to_string(),
},
))
.id();
let npc_b_sid = world.resource_mut::<EntityRegistry>().register(npc_b);
world.get_mut::<NpcConversation>(npc_a).unwrap().partner = npc_b;
// Player KG observes both NPCs but has NO "name" attribute for either.
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_a_sid, TilePosition::new(5, 5, 0), 0);
kg.observe_entity(npc_b_sid, TilePosition::new(5, 6, 0), 0);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
ConversationEventBuffer::default(),
kg,
))
.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_eq!(buffer.events.len(), 1);
// No "name" attribute → falls back to role label
assert_eq!(
buffer.events[0].speaker_name, "Dock Worker",
"speaker with no KG name attribute should show role label"
);
assert_eq!(
buffer.events[0].target_name, "Courier",
"target with no KG name attribute should show role label"
);
}
#[test]
fn conversation_uses_real_name_when_name_in_player_kg() {
// Player KG has a "name" attribute for the speaker.
// ConversationEvent.speaker_name should use NpcName.0.
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,
},
DialogueProfile {
location: "the-terminal".to_string(),
role: "dock-worker".to_string(),
},
))
.id();
let npc_a_sid = world.resource_mut::<EntityRegistry>().register(npc_a);
let npc_b = world
.spawn((
Npc,
ActiveSim,
TilePosition::new(5, 6, 0),
NpcName("Bob".to_string()),
DialogueProfile {
location: "the-terminal".to_string(),
role: "courier".to_string(),
},
))
.id();
let npc_b_sid = world.resource_mut::<EntityRegistry>().register(npc_b);
world.get_mut::<NpcConversation>(npc_a).unwrap().partner = npc_b;
// Player KG has "name" attribute for both NPCs (name has been revealed).
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_a_sid, TilePosition::new(5, 5, 0), 0);
kg.entities
.get_mut(&npc_a_sid)
.unwrap()
.known_attributes
.insert("name".to_string(), "Alice".to_string());
kg.observe_entity(npc_b_sid, TilePosition::new(5, 6, 0), 0);
kg.entities
.get_mut(&npc_b_sid)
.unwrap()
.known_attributes
.insert("name".to_string(), "Bob".to_string());
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
ConversationEventBuffer::default(),
kg,
))
.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_eq!(buffer.events.len(), 1);
// "name" attribute present → use NpcName.0
assert_eq!(
buffer.events[0].speaker_name, "Alice",
"speaker with KG name attribute should show real name"
);
assert_eq!(
buffer.events[0].target_name, "Bob",
"target with KG name attribute should show real name"
);
}
#[test]
fn buffer_take_events_drains_and_returns_events() {
let mut buffer = ConversationEventBuffer::default();
buffer.events.push(ConversationEvent {
occluded_line: "Hello".to_string(),
speaker_id: 1,
target_id: 2,
speaker_name: "Alice".to_string(),
target_name: "Bob".to_string(),
speaker_color_index: 0,
target_color_index: 1,
});
buffer.events.push(ConversationEvent {
occluded_line: "World".to_string(),
speaker_id: 1,
target_id: 2,
speaker_name: "Alice".to_string(),
target_name: "Bob".to_string(),
speaker_color_index: 0,
target_color_index: 1,
});
let taken = buffer.take_events();
assert_eq!(taken.len(), 2, "take_events should return all events");
assert!(
buffer.events.is_empty(),
"Buffer should be empty after take_events"
);
// Second call returns empty
let taken2 = buffer.take_events();
assert!(
taken2.is_empty(),
"Second take_events call should return empty vec"
);
}
#[test]
fn buffer_take_ended_drains_and_returns_end_events() {
let mut buffer = ConversationEventBuffer::default();
buffer.ended.push(ConversationEndEvent {
speaker_id: 10,
target_id: 20,
});
let taken = buffer.take_ended();
assert_eq!(taken.len(), 1, "take_ended should return all end events");
assert!(
buffer.ended.is_empty(),
"ended buffer should be empty after take_ended"
);
// Second call returns empty
assert!(buffer.take_ended().is_empty());
}
}