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>
This commit is contained in:
@@ -15,6 +15,7 @@ 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;
|
||||
@@ -49,6 +50,9 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -252,6 +256,7 @@ pub fn run_npc_conversations(
|
||||
Option<&NpcName>,
|
||||
Option<&NpcConversation>,
|
||||
Option<&ConversationCooldown>,
|
||||
Option<&StableEntityId>,
|
||||
),
|
||||
(With<Npc>, With<ActiveSim>),
|
||||
>,
|
||||
@@ -267,20 +272,25 @@ pub fn run_npc_conversations(
|
||||
) {
|
||||
// --- Phase 1: Initiate new conversations ---
|
||||
|
||||
// Collect eligible NPCs (not in conversation, not on cooldown)
|
||||
let eligible: Vec<(Entity, TilePosition)> = npc_query
|
||||
// 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)| {
|
||||
.filter(|(_, _, _, conv, cooldown, _)| {
|
||||
conv.is_none()
|
||||
&& cooldown
|
||||
.map(|cd| time.tick >= cd.until_tick)
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.map(|(entity, pos, _, _, _)| (entity, *pos))
|
||||
.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.
|
||||
// Iterate pairs in deterministic order (entities from Query iteration).
|
||||
// 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;
|
||||
|
||||
@@ -289,8 +299,8 @@ pub fn run_npc_conversations(
|
||||
break;
|
||||
}
|
||||
for j in (i + 1)..eligible.len() {
|
||||
let (entity_a, pos_a) = eligible[i];
|
||||
let (entity_b, pos_b) = eligible[j];
|
||||
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
|
||||
@@ -332,7 +342,7 @@ pub fn run_npc_conversations(
|
||||
let active_conversations: Vec<(Entity, NpcConversation, TilePosition, Option<String>)> =
|
||||
npc_query
|
||||
.iter()
|
||||
.filter_map(|(entity, pos, name, conv, _)| {
|
||||
.filter_map(|(entity, pos, name, conv, _, _)| {
|
||||
conv.map(|c| {
|
||||
(
|
||||
entity,
|
||||
@@ -366,7 +376,7 @@ pub fn run_npc_conversations(
|
||||
}
|
||||
|
||||
// Check termination: partner moved away or no longer ActiveSim
|
||||
let partner_ok = npc_query.get(conv.partner).ok().map(|(_, pos, _, _, _)| {
|
||||
let partner_ok = npc_query.get(conv.partner).ok().map(|(_, pos, _, _, _, _)| {
|
||||
speaker_pos
|
||||
.manhattan_distance(pos)
|
||||
.map(|d| d <= CONVERSATION_PROXIMITY)
|
||||
@@ -399,9 +409,8 @@ pub fn run_npc_conversations(
|
||||
.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 {
|
||||
// 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];
|
||||
@@ -410,12 +419,13 @@ pub fn run_npc_conversations(
|
||||
let partner_name = npc_query
|
||||
.get(conv.partner)
|
||||
.ok()
|
||||
.and_then(|(_, _, name, _, _)| name.map(|n| n.0.clone()))
|
||||
.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()
|
||||
// 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)
|
||||
@@ -427,7 +437,8 @@ pub fn run_npc_conversations(
|
||||
.map(|lf| lf.is_eavesdropping())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Ambient noise — not yet tracked on ObserverSnapshot, default to 0
|
||||
// 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 =
|
||||
@@ -445,7 +456,7 @@ pub fn run_npc_conversations(
|
||||
speaker_name: speaker_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Unknown".to_string()),
|
||||
target_name: partner_name,
|
||||
target_name: partner_name.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -497,12 +508,12 @@ fn terminate_conversation(
|
||||
.entity(partner)
|
||||
.insert(ConversationCooldown { until_tick: until });
|
||||
|
||||
// Emit conversation_end event
|
||||
// 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) {
|
||||
if let Ok((_, _, mut conv_buffer)) = player_query.single_mut() {
|
||||
for (_, _, mut conv_buffer) in player_query.iter_mut() {
|
||||
conv_buffer.ended.push(ConversationEndEvent {
|
||||
speaker_id: s_sid.0,
|
||||
target_id: t_sid.0,
|
||||
|
||||
Reference in New Issue
Block a user