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
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;