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
+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);
}