fix(simulation): address PR #26 review comments (13 items)

Warnings fixed:
- Add WalkAway to all_player_action_variants_roundtrip test
- Warn and skip on unresolvable speaker_entity_id (was silent 0)
- Change MonologueState.shown_ids from Vec to HashSet (O(1) lookup)
- Add cross-plugin ordering: trigger_recognition_monologue after
  detect_anomalies (latent determinism bug)
- Add TODO for unreachable Secret trust tier

Suggestions addressed:
- Server-side range check for Talk verb in handle_talk (CLOSE_RANGE)
- Emit IncompleteInteraction before overwriting ActiveDialogue
- Add different_seed_produces_different_replay determinism test
- Replace panic with assert for unknown fixture naming convention
- Fix duplicate "Observe" label: ExamineNpc now uses "Examine NPC"
- Change DialogueCooldownTracker.used from Vec to BTreeMap (D-041)
- Add .after(process_talk_interaction) to process_walk_away ordering
- Collapse dead conditional in main.rs (both branches identical)

468 tests pass, 0 failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 18:12:45 +01:00
co-authored by Claude Opus 4.6
parent a5ab8238e1
commit 12d1fd505e
8 changed files with 223 additions and 34 deletions
+41 -11
View File
@@ -73,26 +73,26 @@ impl Default for CurrentMood {
/// Entries older than the cooldown window are pruned each query.
#[derive(Component, Debug, Default)]
pub struct DialogueCooldownTracker {
used: Vec<(String, u64)>, // (line_id, tick_used)
used: std::collections::BTreeMap<String, u64>, // line_id → tick_used (D-041)
}
impl DialogueCooldownTracker {
/// Record that a line was used at the given tick.
pub fn record(&mut self, line_id: &str, tick: u64) {
self.used.push((line_id.to_string(), tick));
self.used.insert(line_id.to_string(), tick);
}
/// Check if a line is on cooldown at the given tick.
pub fn is_on_cooldown(&self, line_id: &str, tick: u64) -> bool {
self.used
.iter()
.any(|(id, used_tick)| id == line_id && tick.saturating_sub(*used_tick) < LINE_COOLDOWN_TICKS)
.get(line_id)
.is_some_and(|used_tick| tick.saturating_sub(*used_tick) < LINE_COOLDOWN_TICKS)
}
/// Prune entries older than the cooldown window.
pub fn prune(&mut self, tick: u64) {
self.used
.retain(|(_, used_tick)| tick.saturating_sub(*used_tick) < LINE_COOLDOWN_TICKS);
.retain(|_, used_tick| tick.saturating_sub(*used_tick) < LINE_COOLDOWN_TICKS);
}
}
@@ -161,6 +161,10 @@ pub fn available_access_tiers(relationship: RelationshipState) -> Vec<AccessTier
/// v0.1 mapping:
/// - Friendly → Real (relationship depth unlocks deeper trust)
/// - All others → Surface
///
/// TODO: TrustTier::Secret is currently unreachable. It should gate on
/// KG confidence (e.g., KnowsDetails+ for a specific secret topic) rather
/// than RelationshipState alone. Tracked for Phase 2 narrative expansion.
pub fn relationship_to_trust(relationship: RelationshipState) -> TrustTier {
match relationship {
RelationshipState::Friendly => TrustTier::Real,
@@ -296,6 +300,7 @@ pub fn process_talk_interaction(
line_pool: Option<Res<LinePoolIndexResource>>,
registry: Res<EntityRegistry>,
mut rng: ResMut<SimRng>,
mut event_queue: ResMut<crate::knowledge::KnowledgeEventQueue>,
mut player_query: Query<
(
Entity,
@@ -303,6 +308,7 @@ pub fn process_talk_interaction(
&TalkRequest,
&mut DialogueResponseBuffer,
&mut DialogueCooldownTracker,
Option<&ActiveDialogue>,
),
With<PlayerCharacter>,
>,
@@ -312,7 +318,7 @@ pub fn process_talk_interaction(
return;
};
let Ok((player_entity, observer_kg, talk_request, mut response_buffer, mut cooldown)) =
let Ok((player_entity, observer_kg, talk_request, mut response_buffer, mut cooldown, active_dialogue_opt)) =
player_query.single_mut()
else {
return;
@@ -396,17 +402,40 @@ pub fn process_talk_interaction(
);
if let Some(line) = selected {
// Resolve wire ID for the speaker
let speaker_wire_id = registry.to_stable(target).map(|s| s.0).unwrap_or(0);
// Resolve wire ID for the speaker — skip if target not in registry
let Some(speaker_stable) = registry.to_stable(target) else {
tracing::warn!(
"Talk target {:?} not in EntityRegistry — cannot resolve wire ID, skipping dialogue",
target
);
commands.entity(player_entity).remove::<TalkRequest>();
return;
};
response_buffer.response = Some(DialogueResponseEvent {
line_id: line.id.clone(),
text: line.text.clone(),
speaker_entity_id: speaker_wire_id,
speaker_entity_id: speaker_stable.0,
});
cooldown.record(&line.id, time.tick);
// Emit IncompleteInteraction if overwriting an existing dialogue session
if let Some(prev) = active_dialogue_opt {
event_queue.push(crate::knowledge::KnowledgeEvent {
observer: player_entity,
tick: time.tick,
event_type: crate::knowledge::KnowledgeEventType::IncompleteInteraction {
target: prev.target,
interaction_type: prev.interaction_type,
},
});
tracing::debug!(
"Overwriting active {:?} dialogue — emitted IncompleteInteraction",
prev.interaction_type,
);
}
// Track active dialogue for walk-away detection (D-064)
commands.entity(player_entity).insert(ActiveDialogue {
target,
@@ -417,7 +446,7 @@ pub fn process_talk_interaction(
tracing::debug!(
"Dialogue selected: id={}, speaker={}, location={}, role={}",
line.id,
speaker_wire_id,
speaker_stable.0,
profile.location,
profile.role,
);
@@ -687,7 +716,7 @@ mod tests {
tracker.record("recent", LINE_COOLDOWN_TICKS);
tracker.prune(LINE_COOLDOWN_TICKS);
assert_eq!(tracker.used.len(), 1);
assert_eq!(tracker.used[0].0, "recent");
assert!(tracker.used.contains_key("recent"));
}
// -- Selection tests -----------------------------------------------------
@@ -778,6 +807,7 @@ mod tests {
world.init_resource::<SimulationTime>();
world.insert_resource(SimRng::new(42));
world.init_resource::<EntityRegistry>();
world.init_resource::<crate::knowledge::KnowledgeEventQueue>();
world
}
+19 -2
View File
@@ -90,6 +90,7 @@ pub fn process_player_input(
With<PlayerCharacter>,
>,
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
all_positions: Query<&TilePosition>,
) {
let current_tick = time.tick;
let paused = time.paused();
@@ -189,7 +190,7 @@ pub fn process_player_input(
handle_place(&mut commands, &registry, &player_query, target_entity_id);
}
Some("Talk") => {
handle_talk(&mut commands, &registry, &player_query, target_entity_id);
handle_talk(&mut commands, &registry, &player_query, &all_positions, target_entity_id);
}
_ => {
tracing::info!(
@@ -328,6 +329,7 @@ fn handle_take(
/// Handle Talk verb: set TalkRequest marker on the player entity for the target NPC.
/// The actual dialogue pipeline runs in process_talk_interaction (dialogue.rs).
/// Server-side range check: Talk requires CLOSE_RANGE (same as interaction system).
#[allow(clippy::type_complexity)]
fn handle_talk(
commands: &mut Commands,
@@ -341,6 +343,7 @@ fn handle_talk(
),
With<PlayerCharacter>,
>,
all_positions: &Query<&TilePosition>,
target_entity_id: Option<u64>,
) {
let Some(target_id) = target_entity_id else {
@@ -348,7 +351,7 @@ fn handle_talk(
return;
};
let Ok((player_entity, _, _, _)) = player_query.single() else {
let Ok((player_entity, player_pos, _, _)) = player_query.single() else {
return;
};
@@ -358,6 +361,20 @@ fn handle_talk(
return;
};
// Server-side range check: reject Talk if target is beyond close range
if let Ok(target_pos) = all_positions.get(target_entity) {
let distance = player_pos.manhattan_distance(target_pos).unwrap_or(u32::MAX);
if distance > crate::simulation::interaction::CLOSE_RANGE {
tracing::info!(
target_id,
distance,
"Talk: target out of range (max {})",
crate::simulation::interaction::CLOSE_RANGE,
);
return;
}
}
commands
.entity(player_entity)
.insert(crate::simulation::dialogue::TalkRequest {
+2 -2
View File
@@ -219,7 +219,7 @@ pub fn compute_nearby_interactions(
});
verbs.push(VerbOption {
kind: VerbKind::ExamineNpc,
label: "Observe".into(),
label: "Examine NPC".into(),
priority: 2,
available: true,
});
@@ -227,7 +227,7 @@ pub fn compute_nearby_interactions(
// Mid range: only Examine NPC (Talk requires close range)
verbs.push(VerbOption {
kind: VerbKind::ExamineNpc,
label: "Observe".into(),
label: "Examine NPC".into(),
priority: 1,
available: true,
});
+6 -4
View File
@@ -8,6 +8,8 @@
// When sprinting past a Contradicted entity, a delayed "double-take" monologue
// fires retroactively. Detection in observer pipeline, processing here.
use std::collections::HashSet;
use bevy_ecs::prelude::*;
use rand::Rng;
@@ -81,7 +83,7 @@ pub struct MonologueState {
/// Whether the enter_location monologue has fired this session.
pub entered: bool,
/// IDs of lines already shown (dedup within session).
pub shown_ids: Vec<String>,
pub shown_ids: HashSet<String>,
/// Character type for pool filtering. v0.1: always "detective".
pub character: String,
}
@@ -93,7 +95,7 @@ impl Default for MonologueState {
last_position: None,
idle_ticks: 0,
entered: false,
shown_ids: Vec::new(),
shown_ids: HashSet::new(),
// v0.1: default to detective; character selection sets this
character: "detective".to_string(),
}
@@ -337,7 +339,7 @@ pub fn trigger_recognition_monologue(
duration_seconds: DISPLAY_DURATION,
});
state.shown_ids.push(id.clone());
state.shown_ids.insert(id.clone());
state.last_fired_tick = time.tick;
// Mark this pending recognition as having fired its monologue
@@ -453,7 +455,7 @@ pub fn trigger_monologue(
duration_seconds: DISPLAY_DURATION,
});
state.shown_ids.push(id.to_string());
state.shown_ids.insert(id.to_string());
state.last_fired_tick = time.tick;
// Reset idle counter so time_idle doesn't fire again immediately
state.idle_ticks = 0;