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:
File diff suppressed because it is too large
Load Diff
@@ -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, ®istry, &player_query, target_entity_id);
|
||||
}
|
||||
Some("Talk") => {
|
||||
handle_talk(&mut commands, ®istry, &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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user