Files
settled-reach/server/src/simulation/conversation.rs
T
jpmschweitzerandClaude Opus 4.6 f0e0673c99 fix(simulation): address PR #50 review — determinism, overflow, invariants
- Sort eligible NPCs by StableId for deterministic conversation pairing (D-010)
- Replace single_mut() with per-observer iteration (D-010 principle 3, D-027)
- Widen current_stress * 100 to i32 in mood derivation to prevent i16 overflow
- Promote line_interval to named constant LINE_INTERVAL_TICKS
- Add 4 Sprint 14 component invariants (MoodState, InteractionMemory,
  ActivityState+PathRequest exclusion, double-tagged tier detection)
- Attach MoodState to content spawn pipeline and gauntlet fixup
- VecDeque for InteractionMemory.notable_events (O(1) pop_front)
- Rename who_knows() to who_knows_full_scan() to communicate O(N) cost
- Add O(N^2) growth limit comment on conversation pair scan
- Document Suspicious/Focused as externally-set moods on enum variants

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:18:34 +01:00

1159 lines
40 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::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;
/// 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);
/// 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>,
Option<&StableEntityId>,
),
(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).
// 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
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
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];
// 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 per-observer occlusion (D-078, D-010 principle 3).
// Iterates all observers — supports future multi-observer scenarios (D-027).
for (player_pos, listening_focus_opt, mut conv_buffer) in
player_query.iter_mut()
{
let distance = speaker_pos
.manhattan_distance(player_pos)
.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);
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.clone(),
});
}
}
}
// 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 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 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
}
// -- 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(),
))
.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(),
))
.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(),
));
// 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(),
));
// 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)"
);
}
#[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(),
});
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(),
});
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());
}
}