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
+142 -4
View File
@@ -9,6 +9,7 @@
//! - Fix D (#458): Movers sorted by Entity::to_bits() in collision resolution
use bevy_app::prelude::*;
use bevy_ecs::prelude::Entity;
use settled_reach_server::bridge::types::*;
use settled_reach_server::bridge::{BridgePlugin, SnapshotBuffer};
use settled_reach_server::knowledge::registry::EntityRegistry;
@@ -342,7 +343,144 @@ fn gauntlet_deterministic_replay() {
}
}
// Note: a `different_seed_produces_different_replay` test is deferred until
// the monologue/dialogue systems consume SimRng during the test window.
// Currently the proof room with idle inputs doesn't trigger random events,
// so different seeds produce identical outputs (correct but untestable).
/// Different seeds must produce different outputs when the simulation exercises SimRng.
///
/// The proof room NPCs don't have DialogueProfile, so Talk alone won't trigger
/// dialogue selection (which consumes SimRng). However, monologue content pools
/// may fire during idle ticks if ContentPlugin is loaded with matching lines.
///
/// This test builds a variant setup with dialogue-capable NPCs and a minimal
/// line pool, then sends Talk inputs to exercise the weighted random selection
/// path (select_dialogue_line) which consumes SimRng.
#[test]
fn different_seed_produces_different_replay() {
use settled_reach_server::content::line_pool::{
AccessTier, IndexedDialogueLine, IndexedDialoguePool, LinePoolIndex, Mood, Situation,
TrustTier,
};
use settled_reach_server::content::LinePoolIndexResource;
use settled_reach_server::simulation::dialogue::{
CurrentMood, DialogueCooldownTracker, DialogueProfile, DialogueResponseBuffer,
};
/// Build a deterministic app with dialogue-capable NPCs.
fn build_app_with_dialogue(seed: u64) -> App {
let mut app = build_deterministic_app(seed);
// Add DialogueResponseBuffer + DialogueCooldownTracker to the player
// (safe: compute_observer_snapshot uses Option<&mut DialogueResponseBuffer>)
{
let mut q = app
.world_mut()
.query_filtered::<Entity, bevy_ecs::query::With<PlayerCharacter>>();
let player = q.single(app.world()).unwrap();
app.world_mut().entity_mut(player).insert((
DialogueResponseBuffer::default(),
DialogueCooldownTracker::default(),
));
}
// Add DialogueProfile + CurrentMood to NPC2 (at 14,18 — visible to player)
// NPC2 is the 3rd entity registered (index 2) but we find it by position.
{
let mut q = app.world_mut().query::<(Entity, &TilePosition)>();
let npc2 = q
.iter(app.world())
.find(|(_, pos)| pos.x == 14 && pos.y == 18)
.map(|(e, _)| e)
.expect("NPC2 at (14,18) should exist");
app.world_mut().entity_mut(npc2).insert((
DialogueProfile {
location: "the-terminal".to_string(),
role: "dock-worker".to_string(),
},
CurrentMood(Mood::Comfortable),
));
}
// Insert a line pool with multiple lines so weighted selection is non-trivial
let mut index = LinePoolIndex::default();
let lines: Vec<IndexedDialogueLine> = (0..10)
.map(|i| IndexedDialogueLine {
id: format!("test_line_{:03}", i),
text: format!("Line variant {}.", i),
role: "dock-worker".to_string(),
access: vec![AccessTier::Public],
trust: TrustTier::Surface,
situation: vec![Situation::Routine],
topic: vec![],
mood: if i % 2 == 0 {
vec![Mood::Comfortable]
} else {
vec![]
},
tags: vec![],
knowledge_grant: None,
})
.collect();
let pool = IndexedDialoguePool {
location: "the-terminal".to_string(),
role: "dock-worker".to_string(),
lines,
};
index.dialogue.insert(
("the-terminal".to_string(), "dock-worker".to_string()),
pool,
);
app.insert_resource(LinePoolIndexResource(index));
app
}
// Resolve NPC2's StableId for Talk input (it's the 3rd registered entity, sid=2)
let npc2_sid = 2u64;
let inputs: Vec<Vec<PlayerInput>> = vec![
vec![], // tick 0: idle
vec![PlayerInput {
tick: 1,
action: PlayerAction::Interact {
target_entity_id: Some(npc2_sid),
verb: Some("Talk".to_string()),
},
}],
vec![], // tick 2: idle
vec![], // tick 3: idle
];
let mut run_a = build_app_with_dialogue(42);
let mut run_b = build_app_with_dialogue(9999);
let mut snapshots_a = Vec::new();
let mut snapshots_b = Vec::new();
for tick_inputs in &inputs {
for app_ref in [&mut run_a, &mut run_b] {
let mut queue = app_ref
.world_mut()
.resource_mut::<settled_reach_server::simulation::input::InputQueue>();
for input in tick_inputs {
queue.push(input.clone());
}
}
run_a.update();
run_b.update();
for (app_ref, snaps) in [(&run_a, &mut snapshots_a), (&run_b, &mut snapshots_b)] {
let buffer = app_ref.world().resource::<SnapshotBuffer>();
if let Some(snapshot) = &buffer.snapshot {
let bytes = rmp_serde::to_vec_named(snapshot).expect("serialize snapshot");
snaps.push(bytes);
}
}
}
// At least one snapshot should differ between the two seeds
let any_different = snapshots_a
.iter()
.zip(snapshots_b.iter())
.any(|(a, b)| a != b);
assert!(
any_different,
"Different seeds should produce at least one different snapshot when dialogue exercises SimRng"
);
}
+7 -2
View File
@@ -98,6 +98,7 @@ fn all_player_action_variants_roundtrip() {
PlayerAction::SetTickRate(TickRate::Half),
PlayerAction::ToggleStanceUp,
PlayerAction::ToggleStanceDown,
PlayerAction::WalkAway,
];
for action in actions {
@@ -159,7 +160,11 @@ fn all_fixtures_deserialize() {
rmp_serde::from_slice::<u64>(&bytes)
.unwrap_or_else(|e| panic!("deserialize boundary raw fixture {}: {}", name, e));
} else {
panic!("unknown fixture naming convention: {}", name);
assert!(
false,
"unknown fixture naming convention: {} — add a deserialization branch for this prefix",
name
);
}
count += 1;
}
@@ -533,7 +538,7 @@ fn pending_recognition_wire_roundtrip() {
#[test]
fn all_verb_kind_variants_roundtrip() {
let all_verbs = [
(VerbKind::ExamineNpc, "Observe"),
(VerbKind::ExamineNpc, "Examine NPC"),
(VerbKind::Talk, "Talk"),
(VerbKind::Observe, "Observe"),
(VerbKind::Read, "Read"),