feat(simulation): dialogue pipeline, ContentSlug, and walk-away KG recording

Implement full dialogue selection pipeline (D-028): 4-layer filtering
engine with access tier, situation derivation, trust tier, and weighted
topic+mood scoring via SimRng. Add ContentSlug component for stable
content identity across save/load. Add walk-away KG recording with
IncompleteInteraction events per D-064 three-phase consequences. Bump
protocol to v8 with DialogueResponseEvent. Fixes #305, #427, #452.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 17:41:23 +01:00
co-authored by Claude Opus 4.6
parent 695d2ac843
commit 35f55cfa46
9 changed files with 1834 additions and 7 deletions
+8 -1
View File
@@ -167,12 +167,19 @@ impl Plugin for BridgePlugin {
.after(crate::simulation::movement::validate_movement),
crate::simulation::monologue::trigger_monologue
.after(crate::simulation::movement::validate_movement),
crate::simulation::monologue::process_sprint_anomaly_monologue
crate::simulation::monologue::trigger_recognition_monologue
.after(crate::simulation::monologue::trigger_monologue),
crate::simulation::monologue::process_sprint_anomaly_monologue
.after(crate::simulation::monologue::trigger_recognition_monologue),
crate::simulation::dialogue::process_talk_interaction
.after(crate::simulation::input::process_player_input),
crate::simulation::dialogue::process_walk_away
.after(crate::simulation::input::process_player_input),
crate::perception::observer::compute_observer_snapshot
.after(crate::perception::observer::compute_visibility_geometry)
.after(crate::simulation::interaction::compute_nearby_interactions)
.after(crate::simulation::monologue::process_sprint_anomaly_monologue)
.after(crate::simulation::dialogue::process_talk_interaction)
.before(crate::simulation::time::advance_tick),
crate::perception::observation::emit_observation_events
.after(crate::perception::observer::compute_observer_snapshot),
+23 -1
View File
@@ -15,7 +15,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
/// negotiation is unnecessary. Client should reject snapshots with version !=
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
/// period, then the default is removed once both sides are updated.
pub const PROTOCOL_VERSION: u8 = 7;
pub const PROTOCOL_VERSION: u8 = 8;
/// The ONLY data structure crossing the client-server boundary (D-020)
/// Contains all information visible to the observer at a given tick.
@@ -26,6 +26,7 @@ pub const PROTOCOL_VERSION: u8 = 7;
/// v5 adds: current_monologue (#414 internal monologue pipeline).
/// v6 adds: player_stance (#449, D-053), player_inventory (#449, D-065).
/// v7 adds: pending_recognitions (#423, D-060 cognitive delay).
/// v8 adds: dialogue_response (#305, D-028 dialogue pipeline).
/// Future fields: ambient sound events, HUD state (D-020 expansion).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObserverSnapshot {
@@ -62,6 +63,11 @@ pub struct ObserverSnapshot {
/// Empty when no recognitions are pending.
#[serde(default)]
pub pending_recognitions: Vec<PendingRecognitionWire>,
/// Dialogue response from Talk verb interaction (#305, D-028).
/// Present when the player talked to an NPC this tick and a line was selected.
/// Client shows speaker name + dialogue text in a dialogue box.
#[serde(default)]
pub dialogue_response: Option<DialogueResponseEvent>,
}
/// Game time data for client display (D-031)
@@ -300,6 +306,10 @@ pub enum PlayerAction {
UsePerceptionMode(String),
Pause,
Unpause,
/// Player walked away during active dialogue (WASD during conversation, D-064).
/// Client sends this when movement input is detected while dialogue box is visible.
/// Server records incomplete interaction in KG and clears dialogue state.
WalkAway,
/// Set tick rate: Full (1.0), Half (0.5), or Paused (0.0) per D-052
SetTickRate(TickRate),
/// Move one step up the stance ladder (toward Sprint) per D-053
@@ -434,6 +444,18 @@ pub struct MonologueEvent {
pub duration_seconds: f32,
}
/// Dialogue response event sent to the client for display (#305, D-028).
/// Contains the selected line and speaker identity. Client renders a dialogue box.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DialogueResponseEvent {
/// Dialogue line ID (for dedup and cooldown tracking)
pub line_id: String,
/// The dialogue text to display
pub text: String,
/// Wire-format entity identifier of the speaking NPC
pub speaker_entity_id: u64,
}
/// Snapshot buffer resource for staging outgoing ObserverSnapshots
#[derive(Resource, Debug, Default)]
pub struct SnapshotBuffer {
+36 -1
View File
@@ -32,6 +32,19 @@ use crate::simulation::interaction::Interactable;
use crate::simulation::movement::TilePosition;
use crate::simulation::time::DayPhase;
/// Stable content identifier from YAML (e.g., "kael-davan", "sera-venn").
///
/// Bridges authoring identity to ECS entities. Independent of StableId —
/// StableId is runtime entity tracking (KG references), ContentSlug is
/// authoring/content identity (which authored NPC template). Not all entities
/// have ContentSlugs (e.g., procedurally spawned NPCs, furniture).
///
/// Used by #427 (walk-away KG recording) to record interaction memory
/// against a stable content identity rather than an Entity (which is
/// unstable across save/load).
#[derive(Component, Debug, Clone, PartialEq, Eq, Hash)]
pub struct ContentSlug(pub String);
/// Result of spawning content into the ECS world.
#[derive(Debug, Default)]
pub struct SpawnResult {
@@ -184,7 +197,9 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR
// Register in EntityRegistry for StableId mapping
let stable_id = world.resource_mut::<EntityRegistry>().register(entity);
world.entity_mut(entity).insert(StableEntityId(stable_id));
world
.entity_mut(entity)
.insert((StableEntityId(stable_id), ContentSlug(profile.canonical_id.clone())));
result
.npc_ids
@@ -722,6 +737,26 @@ mod tests {
assert!(!skills.combat_trained);
}
#[test]
fn spawn_npc_attaches_content_slug() {
let mut world = create_test_world();
let profile = create_test_profile();
let mut result = SpawnResult::default();
spawn_npc(&mut world, &profile, &mut result);
let stable_id = result.npc_ids["test-npc"];
let entity = world
.resource::<EntityRegistry>()
.to_entity(&stable_id)
.unwrap();
let slug = world
.get::<ContentSlug>(entity)
.expect("ContentSlug should be attached during spawn");
assert_eq!(slug.0, "test-npc");
}
#[test]
fn spawn_npc_minimal_profile() {
let mut world = create_test_world();
+37
View File
@@ -29,6 +29,25 @@ pub enum KnowledgeEventType {
},
/// Entity left observer's LOS (downgrades from Direct).
LeftLOS { target: Entity },
/// Observer walked away from an active interaction (D-064).
/// Records incompleteness in the target's known_attributes for future
/// dialogue/monologue consequences.
IncompleteInteraction {
target: Entity,
interaction_type: InteractionType,
},
}
/// Type of interaction for walk-away recording (D-064).
/// Differentiates casual conversation from confrontation —
/// future dialogue may react differently.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InteractionType {
/// Normal Talk conversation.
Talk,
/// Confrontation (D-063). Walking away from confrontation
/// carries heavier consequences than casual talk.
Confront,
}
/// Resource: queue of pending knowledge events.
@@ -98,6 +117,24 @@ pub fn process_knowledge_events(
tracing::error!("LeftLOS target {:?} not in EntityRegistry", target);
}
}
KnowledgeEventType::IncompleteInteraction {
target,
interaction_type,
} => {
if let Some(stable_id) = registry.to_stable(target) {
observer_kg.record_incomplete_interaction(
&stable_id,
interaction_type,
event.tick,
);
tracing::debug!(
"Recorded incomplete {:?} interaction with {:?} at tick {}",
interaction_type,
stable_id,
event.tick,
);
}
}
}
}
}
+55
View File
@@ -145,6 +145,61 @@ impl KnowledgeGraph {
}
}
/// Record an incomplete interaction with an entity (D-064 walk-away).
///
/// Appends to known_attributes["incomplete_interactions"] as a
/// comma-separated list of "tick:type" entries. Creates the entity
/// entry if it doesn't exist (at Suspects confidence).
pub fn record_incomplete_interaction(
&mut self,
target: &StableId,
interaction_type: super::events::InteractionType,
tick: u64,
) {
let entry = self
.entities
.entry(*target)
.or_insert_with(|| EntityKnowledge {
last_known_position: None,
last_observed_tick: 0,
last_updated_tick: 0,
confidence: KnowledgeConfidence::Suspects,
source: KnowledgeSource::DirectObservation { tick },
state: KnowledgeState::Active,
relationship: RelationshipState::Unknown,
known_attributes: BTreeMap::new(),
});
let type_str = match interaction_type {
super::events::InteractionType::Talk => "talk",
super::events::InteractionType::Confront => "confront",
};
let record = format!("{}:{}", tick, type_str);
entry
.known_attributes
.entry("incomplete_interactions".to_string())
.and_modify(|v| {
v.push(',');
v.push_str(&record);
})
.or_insert(record);
entry.last_updated_tick = tick;
}
/// Check if the observer has any incomplete interactions with an entity.
///
/// Returns true if known_attributes["incomplete_interactions"] exists
/// and is non-empty. Used by dialogue/monologue systems to gate
/// post-conversation reactions (D-064 phase 3).
pub fn has_incomplete_interaction(&self, target: &StableId) -> bool {
self.entities
.get(target)
.and_then(|e| e.known_attributes.get("incomplete_interactions"))
.is_some_and(|v| !v.is_empty())
}
/// Set relationship state for an entity.
pub fn set_relationship(&mut self, target: &StableId, state: RelationshipState) {
if let Some(entry) = self.entities.get_mut(target) {
+1 -1
View File
@@ -12,7 +12,7 @@ pub mod graph;
pub mod registry;
pub mod types;
pub use events::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType};
pub use events::{InteractionType, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType};
pub use graph::KnowledgeGraph;
pub use registry::{EntityRegistry, StableEntityId};
pub use types::*;
File diff suppressed because it is too large Load Diff
+359 -3
View File
@@ -99,8 +99,14 @@ pub fn process_player_input(
let mut move_attempted = false;
for input in inputs {
// Discard movement while paused (D-052). Pause/Unpause still processed.
if paused && input.action.is_movement() {
// Discard all gameplay actions while paused (D-052, R2-OQ-01).
// Only Pause/Unpause are processed — everything else is discarded.
if paused
&& !matches!(
input.action,
PlayerAction::Pause | PlayerAction::Unpause
)
{
continue;
}
match input.action {
@@ -182,14 +188,25 @@ pub fn process_player_input(
Some("Place") => {
handle_place(&mut commands, &registry, &player_query, target_entity_id);
}
Some("Talk") => {
handle_talk(&mut commands, &registry, &player_query, target_entity_id);
}
_ => {
tracing::info!(
"Interact: target={:?}, verb={:?} — logged only, dialogue dispatch future scope (#415)",
"Interact: target={:?}, verb={:?} — logged only",
target_entity_id,
verb,
);
}
},
PlayerAction::WalkAway => {
if let Ok((player_entity, _, _, _)) = player_query.single() {
commands
.entity(player_entity)
.insert(crate::simulation::dialogue::WalkAwayRequest);
tracing::debug!("WalkAway: marker set on player");
}
}
PlayerAction::UsePerceptionMode(ref mode) => {
tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode);
}
@@ -309,6 +326,47 @@ 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).
#[allow(clippy::type_complexity)]
fn handle_talk(
commands: &mut Commands,
registry: &EntityRegistry,
player_query: &Query<
(
Entity,
&TilePosition,
Option<&mut Stance>,
Option<&mut PlayerMoveCooldown>,
),
With<PlayerCharacter>,
>,
target_entity_id: Option<u64>,
) {
let Some(target_id) = target_entity_id else {
tracing::warn!("Talk verb without target_entity_id");
return;
};
let Ok((player_entity, _, _, _)) = player_query.single() else {
return;
};
let target_stable = StableId(target_id);
let Some(target_entity) = registry.to_entity(&target_stable) else {
tracing::warn!(target_id, "Talk: target entity not in registry");
return;
};
commands
.entity(player_entity)
.insert(crate::simulation::dialogue::TalkRequest {
target: target_entity,
});
tracing::debug!(target_id, "Talk: TalkRequest marker set on player");
}
/// Handle Place verb: remove an item from inventory and place it on the ground
/// at the player's current position. Removes CarriedBy + InventorySlot, adds
/// TilePosition at the player's current tile.
@@ -979,6 +1037,304 @@ mod tests {
);
}
// === Pause Guard Tests (#461, #462, #463) ===
// Prevent Bug #3 recurrence: player movement while paused.
// The pause guard at process_player_input discards movement inputs
// when SimulationTime.tick_rate == TickRate::Paused (D-052).
#[test]
fn movement_discarded_while_paused() {
// #461: Movement input rejected while paused — prevents Bug #3 recurrence.
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
let mut time = SimulationTime::default();
time.tick_rate = TickRate::Paused;
world.insert_resource(time);
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
// Movement must be discarded — no MoveIntent created
assert!(
world.get::<MoveIntent>(player).is_none(),
"MoveNorth must be discarded while paused (Bug #3 guard)"
);
}
#[test]
fn unpause_accepted_while_paused() {
// #462: Unpause command is the one control action allowed while paused.
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
let mut time = SimulationTime::default();
time.tick_rate = TickRate::Paused;
world.insert_resource(time);
world.init_resource::<crate::knowledge::EntityRegistry>();
// Player entity required for process_player_input (even if no movement)
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Unpause,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
assert_eq!(
world.resource::<SimulationTime>().tick_rate,
TickRate::Full,
"Unpause must be accepted while paused"
);
}
#[test]
fn pause_unpause_roundtrip_with_movement() {
// #463: Full cycle — pause -> move (rejected) -> unpause -> move (accepted).
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
// Step 1: Pause
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Pause,
});
schedule.run(&mut world);
assert_eq!(
world.resource::<SimulationTime>().tick_rate,
TickRate::Paused,
"Step 1: game should be paused"
);
// Step 2: Move while paused — must be rejected
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
});
schedule.run(&mut world);
assert!(
world.get::<MoveIntent>(player).is_none(),
"Step 2: movement must be rejected while paused"
);
// Step 3: Unpause
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Unpause,
});
schedule.run(&mut world);
assert_eq!(
world.resource::<SimulationTime>().tick_rate,
TickRate::Full,
"Step 3: game should be unpaused"
);
// Step 4: Move after unpause — must succeed
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
});
schedule.run(&mut world);
assert!(
world.get::<MoveIntent>(player).is_some(),
"Step 4: movement must succeed after unpause"
);
}
// === Remaining Pause Guard Tests (#468) ===
// Edge cases: stance, interact, batch discard, and SetTickRate while paused.
#[test]
fn stance_toggle_rejected_while_paused() {
// #468: Stance toggle rejected while paused.
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
let mut time = SimulationTime::default();
time.tick_rate = TickRate::Paused;
world.insert_resource(time);
world.init_resource::<crate::knowledge::EntityRegistry>();
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
Stance::default(), // Walk
PlayerMoveCooldown::default(),
));
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::ToggleStanceUp,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
let mut query = world.query::<&Stance>();
let stance = query.single(&world).unwrap();
assert_eq!(
stance.0,
MovementStance::Walk,
"Stance toggle must be rejected while paused"
);
}
#[test]
fn interact_rejected_while_paused() {
// #468: Interact rejected while paused.
// This test verifies no panic and no side effects — interact is a no-op while paused.
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
let mut time = SimulationTime::default();
time.tick_rate = TickRate::Paused;
world.insert_resource(time);
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
.id();
world
.resource_mut::<crate::knowledge::EntityRegistry>()
.register(player);
// Spawn item on the ground
let item = world
.spawn((TilePosition::new(5, 4, 0), ItemName("Manifest Copy".into())))
.id();
let item_sid = world
.resource_mut::<crate::knowledge::EntityRegistry>()
.register(item);
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::Interact {
target_entity_id: Some(item_sid.0),
verb: Some("Take".into()),
},
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
// Item must remain on ground — Take rejected while paused
assert!(
world.get::<TilePosition>(item).is_some(),
"Item must stay on ground — interact rejected while paused"
);
assert!(
world.get::<CarriedBy>(item).is_none(),
"Item must not be picked up while paused"
);
}
#[test]
fn batch_discard_while_paused() {
// #468: All inputs in a batch discarded while paused (except Pause/Unpause).
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
let mut time = SimulationTime::default();
time.tick_rate = TickRate::Paused;
world.insert_resource(time);
world.init_resource::<crate::knowledge::EntityRegistry>();
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
Stance::default(),
PlayerMoveCooldown::default(),
))
.id();
// Push a batch of mixed inputs — all should be discarded except Unpause
let queue = &mut world.resource_mut::<InputQueue>();
queue.push(PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
});
queue.push(PlayerInput {
tick: 0,
action: PlayerAction::ToggleStanceUp,
});
queue.push(PlayerInput {
tick: 0,
action: PlayerAction::SetTickRate(TickRate::Half),
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
// All gameplay actions discarded
assert!(
world.get::<MoveIntent>(player).is_none(),
"Movement discarded in batch"
);
let mut query = world.query::<&Stance>();
let stance = query.single(&world).unwrap();
assert_eq!(
stance.0,
MovementStance::Walk,
"Stance unchanged in batch"
);
assert_eq!(
world.resource::<SimulationTime>().tick_rate,
TickRate::Paused,
"SetTickRate discarded in batch — still paused"
);
}
#[test]
fn set_tick_rate_rejected_while_paused() {
// #468 / R2-OQ-01: SetTickRate(Half) while paused is a bug — must be rejected.
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
let mut time = SimulationTime::default();
time.tick_rate = TickRate::Paused;
world.insert_resource(time);
world.init_resource::<crate::knowledge::EntityRegistry>();
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::SetTickRate(TickRate::Half),
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
assert_eq!(
world.resource::<SimulationTime>().tick_rate,
TickRate::Paused,
"SetTickRate must be rejected while paused (R2-OQ-01)"
);
}
#[test]
fn take_without_target_id_is_noop() {
// Edge case: Take verb with no target_entity_id should not panic
+1
View File
@@ -4,6 +4,7 @@
use bevy_app::prelude::*;
use bevy_ecs::schedule::IntoScheduleConfigs;
pub mod dialogue;
pub mod input;
pub mod interaction;
pub mod inventory;