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:
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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