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:
2026-02-20 19:18:34 +01:00
co-authored by Claude Opus 4.6
parent 019a7b2221
commit f0e0673c99
7 changed files with 205 additions and 28 deletions
+31 -20
View File
@@ -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,