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:
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user