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
+3
View File
@@ -114,6 +114,9 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR
// Interaction history — drives Layer 2 situation activation (#325, D-028)
entity_commands.insert(npc::interaction::InteractionMemory::default());
// Mood state — drives Layer 4 dialogue selection and monologue tone (#323)
entity_commands.insert(npc::mood::MoodState::default());
// Axis 1: Want
if let Some(want) = &profile.want {
if let Some(kind) = parse_want_kind(&want.primary) {
+4 -4
View File
@@ -47,7 +47,7 @@ pub struct InteractionMemory {
pub last_interaction_tick: u64,
/// Notable events: walk-aways and confrontations.
/// Bounded by `MAX_NOTABLE_EVENTS` — oldest entries dropped when full.
pub notable_events: Vec<InteractionEvent>,
pub notable_events: std::collections::VecDeque<InteractionEvent>,
}
/// Maximum number of notable events retained per NPC pair.
@@ -65,9 +65,9 @@ impl InteractionMemory {
/// Append a notable event, dropping the oldest if at capacity.
pub fn push_event(&mut self, event: InteractionEvent) {
if self.notable_events.len() >= MAX_NOTABLE_EVENTS {
self.notable_events.remove(0);
self.notable_events.pop_front();
}
self.notable_events.push(event);
self.notable_events.push_back(event);
}
/// Returns `true` if this is the first meeting (count == 0).
@@ -145,7 +145,7 @@ mod tests {
});
assert_eq!(mem.notable_events.len(), MAX_NOTABLE_EVENTS);
assert_eq!(mem.notable_events[0].tick, 1); // tick=0 dropped
assert_eq!(mem.notable_events.last().unwrap().tick, 99);
assert_eq!(mem.notable_events.back().unwrap().tick, 99);
}
#[test]
+5 -1
View File
@@ -47,12 +47,14 @@ pub enum NpcMood {
/// Low stress, positive recent context — settled and cooperative.
Content,
/// Observing unusual or off-script behavior — targeted wariness.
/// Not reachable from `derive_mood()` — set externally by observation pipeline.
Suspicious,
/// Recent positive player interaction within memory window.
Warm,
/// Stress at or above threshold — confrontational or withdrawn.
Hostile,
/// Actively engaged in a scheduled activity — task-focused.
/// Not reachable from `derive_mood()` — set externally by activity scheduler (#101).
Focused,
}
@@ -145,7 +147,9 @@ pub fn derive_mood(
// 2. Anxious: above 60% of threshold.
// Guard: skip if threshold == 0 (divide-by-zero equivalent — entity
// has no tolerance and is already Hostile from rule 1).
if threshold > 0 && current_stress * 100 >= threshold * ANXIOUS_STRESS_NUMERATOR {
if threshold > 0
&& (current_stress as i32) * 100 >= (threshold as i32) * (ANXIOUS_STRESS_NUMERATOR as i32)
{
return NpcMood::Anxious;
}
+3 -3
View File
@@ -141,8 +141,8 @@ impl RelationshipGraph {
}
/// Get all entities who have feelings about a target.
/// Full scan — use for event detection, not per-tick queries.
pub fn who_knows(&self, target: &StableId) -> Vec<(&StableId, &RelationshipEdge)> {
/// O(N) full scan of all edges — use for event detection, not per-tick queries.
pub fn who_knows_full_scan(&self, target: &StableId) -> Vec<(&StableId, &RelationshipEdge)> {
self.edges
.iter()
.filter(|((_, t), _)| t == target)
@@ -374,7 +374,7 @@ mod tests {
make_edge(RelationshipKind::Family, 8),
);
let knowers = graph.who_knows(&target);
let knowers = graph.who_knows_full_scan(&target);
assert_eq!(knowers.len(), 3);
}
+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,
+117
View File
@@ -38,6 +38,12 @@ use crate::simulation::pathfinding::{ComputedPath, PathBlocked};
#[cfg(feature = "gauntlet")]
use crate::simulation::sound::SoundEvent;
#[cfg(feature = "gauntlet")]
use crate::npc::interaction::InteractionMemory;
#[cfg(feature = "gauntlet")]
use crate::npc::mood::MoodState;
#[cfg(feature = "gauntlet")]
use crate::npc::routine::ActivityState;
#[cfg(feature = "gauntlet")]
use crate::simulation::tier::{ActiveSim, BackgroundSim, StateSaved};
#[cfg(feature = "gauntlet")]
@@ -93,6 +99,12 @@ pub fn run_invariants(world: &mut World) {
inv_sim6_registry_has_entities(world);
inv_sim7_player_has_monologue_buffer(world);
inv_sim8_player_has_interaction_buffer(world);
// --- Sprint 14 component invariants ---
inv_s14_active_npcs_have_mood_state(world);
inv_s14_npcs_have_interaction_memory(world);
inv_s14_no_activity_state_with_path_request(world);
inv_pop3b_no_double_tagged_tiers(world);
}
// ===========================================================================
@@ -729,6 +741,111 @@ fn inv_sim8_player_has_interaction_buffer(world: &mut World) {
);
}
// ===========================================================================
// Sprint 14 component invariants
// ===========================================================================
/// S14-1: Every Active-tier NPC has a MoodState component.
/// Active NPCs without MoodState are invisible to the mood-driven dialogue pipeline.
#[cfg(feature = "gauntlet")]
fn inv_s14_active_npcs_have_mood_state(world: &mut World) {
let active = {
let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With<Npc>, bevy_ecs::prelude::With<ActiveSim>)>();
q.iter(world).count()
};
let with_mood = {
let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With<Npc>, bevy_ecs::prelude::With<ActiveSim>, bevy_ecs::prelude::With<MoodState>)>();
q.iter(world).count()
};
assert_eq!(
with_mood,
active,
"S14-1: all {} Active NPCs must have MoodState; only {} do",
active,
with_mood
);
}
/// S14-2: Every NPC has an InteractionMemory component.
/// NPCs without InteractionMemory cannot drive Layer 2 situation activation.
#[cfg(feature = "gauntlet")]
fn inv_s14_npcs_have_interaction_memory(world: &mut World) {
let total = {
let mut q = world.query_filtered::<(), bevy_ecs::prelude::With<Npc>>();
q.iter(world).count()
};
let with_mem = {
let mut q = world.query_filtered::<(), (bevy_ecs::prelude::With<Npc>, bevy_ecs::prelude::With<InteractionMemory>)>();
q.iter(world).count()
};
assert_eq!(
with_mem,
total,
"S14-2: all {} NPCs must have InteractionMemory; only {} do",
total,
with_mem
);
}
/// S14-3: No entity has both ActivityState and PathRequest simultaneously.
/// ActivityState means "at destination, performing activity". PathRequest means
/// "needs to move somewhere". Both at once is contradictory.
#[cfg(feature = "gauntlet")]
fn inv_s14_no_activity_state_with_path_request(world: &mut World) {
use bevy_ecs::prelude::{Entity, With};
use crate::simulation::pathfinding::PathRequest;
let with_activity: Vec<Entity> = {
let mut q = world.query_filtered::<Entity, With<ActivityState>>();
q.iter(world).collect()
};
for entity in with_activity {
assert!(
world.get::<PathRequest>(entity).is_none(),
"S14-3: entity {:?} has both ActivityState and PathRequest — mutually exclusive",
entity
);
}
}
/// Pop3b: No NPC is double-tagged with multiple tier markers.
/// Pop3 catches missing tiers via sum check, but not double-tagged entities
/// (e.g. both ActiveSim + BackgroundSim would still sum correctly).
#[cfg(feature = "gauntlet")]
fn inv_pop3b_no_double_tagged_tiers(world: &mut World) {
use bevy_ecs::prelude::{Entity, With};
let active_and_bg: Vec<Entity> = {
let mut q = world.query_filtered::<Entity, (With<Npc>, With<ActiveSim>, With<BackgroundSim>)>();
q.iter(world).collect()
};
assert!(
active_and_bg.is_empty(),
"Pop3b: {} NPC(s) are double-tagged with both ActiveSim and BackgroundSim",
active_and_bg.len()
);
let active_and_ss: Vec<Entity> = {
let mut q = world.query_filtered::<Entity, (With<Npc>, With<ActiveSim>, With<StateSaved>)>();
q.iter(world).collect()
};
assert!(
active_and_ss.is_empty(),
"Pop3b: {} NPC(s) are double-tagged with both ActiveSim and StateSaved",
active_and_ss.len()
);
let bg_and_ss: Vec<Entity> = {
let mut q = world.query_filtered::<Entity, (With<Npc>, With<BackgroundSim>, With<StateSaved>)>();
q.iter(world).collect()
};
assert!(
bg_and_ss.is_empty(),
"Pop3b: {} NPC(s) are double-tagged with both BackgroundSim and StateSaved",
bg_and_ss.len()
);
}
// ===========================================================================
// Additional system-execution tests (7 more invariants, via #[test])
// Tests 30-36 exercise behaviour that requires running ECS systems.
+42
View File
@@ -495,6 +495,48 @@ pub fn setup_gauntlet(app: &mut App) {
}
app.insert_resource(snapshots);
// --- Sprint 14 component fixup ---
// Attach MoodState and InteractionMemory to all Npc entities that are
// missing them. Gauntlet room builders don't include these yet — this
// ensures invariant S14-1/S14-2 pass and the mood/trust systems have
// valid component targets.
{
use crate::npc::interaction::InteractionMemory;
use crate::npc::mood::MoodState;
use crate::npc::Npc;
let missing_mood: Vec<bevy_ecs::prelude::Entity> = {
let mut q = app
.world_mut()
.query_filtered::<bevy_ecs::prelude::Entity, (
bevy_ecs::prelude::With<Npc>,
bevy_ecs::prelude::Without<MoodState>,
)>();
q.iter(app.world()).collect()
};
for entity in missing_mood {
app.world_mut()
.entity_mut(entity)
.insert(MoodState::default());
}
let missing_mem: Vec<bevy_ecs::prelude::Entity> = {
let mut q = app
.world_mut()
.query_filtered::<bevy_ecs::prelude::Entity, (
bevy_ecs::prelude::With<Npc>,
bevy_ecs::prelude::Without<InteractionMemory>,
)>();
q.iter(app.world()).collect()
};
for entity in missing_mem {
app.world_mut()
.entity_mut(entity)
.insert(InteractionMemory::default());
}
}
app.insert_resource(registry);
}