feat(simulation): sprint 10 — hub teleport, confrontation response, walk-away phase 2, gauntlet rooms 4-7, blocked_entities debug field

#491: PlayerAction::TeleportToHub — moves player to hub spawn, clears
dialogue/monologue/interaction buffer, Gauntlet-only with log warning.
#520: ConfrontationDelivered event — Tier 2 animation shift, relationship
state decrement (D-033), monologue spike emission.
#519: Walk-away Phase 2 — NPC animation shift + routine deviation on
dialogue exit (D-064).
#498: Four new Gauntlet rooms — Interaction Gallery, Fog Theater, Crowd
Plaza, Dialogue Room with constants and wiring.
#514: blocked_entities Vec<u64> on ObserverSnapshot, protocol v9.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 12:58:14 +01:00
co-authored by Claude Opus 4.6
parent b4a3784218
commit 273d29f26f
18 changed files with 1219 additions and 50 deletions
+411 -7
View File
@@ -18,12 +18,13 @@
use bevy_ecs::prelude::*;
use rand::Rng;
use crate::bridge::types::{DialogueResponseEvent, RelationshipState};
use crate::bridge::types::{DialogueResponseEvent, MonologueEvent, RelationshipState};
use crate::content::line_pool::{
AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier,
};
use crate::content::LinePoolIndexResource;
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
use crate::simulation::monologue::{MonologueBuffer, MonologueState};
use crate::simulation::movement::PlayerCharacter;
use crate::simulation::rng::SimRng;
use crate::simulation::time::SimulationTime;
@@ -117,6 +118,18 @@ pub struct ActiveDialogue {
#[derive(Component, Debug)]
pub struct WalkAwayRequest;
/// Marker: player delivered a confrontation this tick (#520, D-063).
///
/// Set by process_player_input when Interact{verb: "Confront"} is received.
/// Consumed by process_confrontation_response each tick. Triggers:
/// 1. Target NPC shifts to AnimationTier::Tier2 (D-047)
/// 2. Observer KG relationship state decremented (D-033 color fade)
/// 3. Monologue spike event emitted
#[derive(Component, Debug)]
pub struct ConfrontationDelivered {
pub target: Entity,
}
/// Buffer holding the dialogue response for snapshot inclusion.
///
/// Consumed once per snapshot via `take()`. Cleared at snapshot build time.
@@ -299,7 +312,7 @@ pub fn select_dialogue_line<'a>(
/// line to DialogueResponseBuffer.
///
/// System ordering: after process_player_input, before compute_observer_snapshot.
#[allow(clippy::type_complexity)]
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub fn process_talk_interaction(
mut commands: Commands,
time: Res<SimulationTime>,
@@ -478,11 +491,13 @@ pub fn process_talk_interaction(
// System: process_walk_away (D-064)
// ---------------------------------------------------------------------------
/// Process walk-away requests during active dialogue.
/// Process walk-away requests during active dialogue (D-064 Phases 2+3).
///
/// When the player moves (WASD) during an active dialogue, the client sends
/// PlayerAction::WalkAway which sets WalkAwayRequest. This system:
/// 1. Emits IncompleteInteraction knowledge event (recorded in KG)
/// 1. Target NPC shifts to AnimationTier::Tier2 (D-047 ambiguous animation)
/// 2. NPC routine deviation recorded (storyteller hook)
/// 3. Emits IncompleteInteraction knowledge event (recorded in KG)
/// 2. Clears ActiveDialogue state
/// 3. Removes the WalkAwayRequest marker
///
@@ -500,21 +515,38 @@ pub fn process_walk_away(
};
if let Some(active_dialogue) = active_dialogue_opt {
// Emit IncompleteInteraction knowledge event
let target = active_dialogue.target;
// Phase 2, Effect 1: Shift target NPC to Tier 2 animation (D-047)
commands
.entity(target)
.insert(crate::npc::AnimationTier::Tier2);
// Phase 2, Effect 2: Record routine deviation on target NPC
commands
.entity(target)
.insert(crate::npc::RoutineDeviation {
trigger: crate::npc::DeviationTrigger::WalkAway,
tick: time.tick,
});
// Phase 3: Emit IncompleteInteraction knowledge event
event_queue.push(crate::knowledge::KnowledgeEvent {
observer: player_entity,
tick: time.tick,
event_type: crate::knowledge::KnowledgeEventType::IncompleteInteraction {
target: active_dialogue.target,
target,
interaction_type: active_dialogue.interaction_type,
},
});
tracing::debug!(
"Walk-away during {:?} dialogue at tick {} (started tick {})",
"Walk-away during {:?} dialogue at tick {} (started tick {}): \
target {:?} → Tier2 animation + routine deviation",
active_dialogue.interaction_type,
time.tick,
active_dialogue.started_tick,
target,
);
commands.entity(player_entity).remove::<ActiveDialogue>();
@@ -525,6 +557,104 @@ pub fn process_walk_away(
commands.entity(player_entity).remove::<WalkAwayRequest>();
}
/// Hardcoded confrontation monologue lines (D-063).
/// Fired as a monologue spike when the player delivers a confrontation.
/// Future: move to content pools with trigger="confrontation_delivered".
const CONFRONTATION_LINES: &[(&str, &str)] = &[
(
"confront_01",
"That changed everything between us. No going back.",
),
(
"confront_02",
"The look on their face... they know I know.",
),
(
"confront_03",
"Cards on the table. Let's see what happens next.",
),
];
/// Process confrontation world response (#520, D-063).
///
/// Reads ConfrontationDelivered marker (set by input system), applies three
/// server-authoritative effects:
/// 1. Target NPC shifts to AnimationTier::Tier2 (D-047)
/// 2. Observer's KG relationship state decremented (D-033 color fade)
/// 3. Monologue spike: immediate monologue line bypassing cooldown
///
/// System ordering: after process_player_input, before compute_observer_snapshot.
pub fn process_confrontation_response(
mut commands: Commands,
time: Res<SimulationTime>,
registry: Res<EntityRegistry>,
mut rng: ResMut<crate::simulation::rng::SimRng>,
mut query: Query<
(
Entity,
&ConfrontationDelivered,
&mut KnowledgeGraph,
&mut MonologueBuffer,
&mut MonologueState,
),
With<PlayerCharacter>,
>,
) {
let Ok((
player_entity,
confrontation,
mut observer_kg,
mut monologue_buf,
mut monologue_state,
)) = query.single_mut()
else {
return;
};
let target = confrontation.target;
// Effect 1: Shift target NPC to Tier 2 animation (D-047)
commands
.entity(target)
.insert(crate::npc::AnimationTier::Tier2);
// Effect 2: Decrement observer's relationship with the target (D-033 color fade)
if let Some(target_sid) = registry.to_stable(target) {
let old_rel = observer_kg.relationship_with(&target_sid);
let new_rel = old_rel.decrement();
if new_rel != old_rel {
observer_kg.set_relationship(&target_sid, new_rel);
tracing::info!(
target_id = target_sid.0,
?old_rel,
?new_rel,
"Confrontation: relationship decremented"
);
}
}
// Effect 3: Monologue spike — bypass cooldown, fire immediately
let idx = rng.rng.random_range(0..CONFRONTATION_LINES.len());
let (id, text) = CONFRONTATION_LINES[idx];
monologue_buf.set(MonologueEvent {
id: id.to_string(),
text: text.to_string(),
duration_seconds: 5.0,
});
monologue_state.last_fired_tick = time.tick;
tracing::info!(
tick = time.tick,
monologue_id = id,
"Confrontation delivered: Tier 2 anim + relationship decrement + monologue spike"
);
// Clean up marker
commands
.entity(player_entity)
.remove::<ConfrontationDelivered>();
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -1362,5 +1492,279 @@ mod tests {
);
}
// -- Walk-away Phase 2 tests (D-064, #519) ---------------------------------
#[test]
fn walk_away_shifts_npc_to_tier2_animation() {
use crate::knowledge::KnowledgeEventQueue;
use crate::npc::AnimationTier;
let mut world = setup_dialogue_world();
world.init_resource::<KnowledgeEventQueue>();
let npc = world.spawn_empty().id();
world.resource_mut::<EntityRegistry>().register(npc);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 6, 0),
KnowledgeGraph::new(),
ActiveDialogue {
target: npc,
interaction_type: crate::knowledge::events::InteractionType::Talk,
started_tick: 10,
},
WalkAwayRequest,
))
.id();
world.resource_mut::<EntityRegistry>().register(player);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_walk_away);
schedule.run(&mut world);
world.flush();
let tier = world.get::<AnimationTier>(npc).unwrap();
assert_eq!(
*tier,
AnimationTier::Tier2,
"Walk-away should shift NPC to Tier 2 animation"
);
}
#[test]
fn walk_away_records_routine_deviation() {
use crate::knowledge::KnowledgeEventQueue;
use crate::npc::{DeviationTrigger, RoutineDeviation};
let mut world = setup_dialogue_world();
world.init_resource::<KnowledgeEventQueue>();
world.resource_mut::<SimulationTime>().tick = 42;
let npc = world.spawn_empty().id();
world.resource_mut::<EntityRegistry>().register(npc);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 6, 0),
KnowledgeGraph::new(),
ActiveDialogue {
target: npc,
interaction_type: crate::knowledge::events::InteractionType::Talk,
started_tick: 10,
},
WalkAwayRequest,
))
.id();
world.resource_mut::<EntityRegistry>().register(player);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_walk_away);
schedule.run(&mut world);
world.flush();
let deviation = world.get::<RoutineDeviation>(npc).unwrap();
assert_eq!(
deviation.trigger,
DeviationTrigger::WalkAway,
"Deviation trigger should be WalkAway"
);
assert_eq!(deviation.tick, 42, "Deviation should record the walk-away tick");
}
#[test]
fn walk_away_without_dialogue_does_not_affect_npcs() {
use crate::knowledge::KnowledgeEventQueue;
use crate::npc::{AnimationTier, RoutineDeviation};
let mut world = setup_dialogue_world();
world.init_resource::<KnowledgeEventQueue>();
let npc = world.spawn_empty().id();
world.resource_mut::<EntityRegistry>().register(npc);
// Player with WalkAwayRequest but NO ActiveDialogue
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 6, 0),
KnowledgeGraph::new(),
WalkAwayRequest,
))
.id();
world.resource_mut::<EntityRegistry>().register(player);
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_walk_away);
schedule.run(&mut world);
world.flush();
assert!(
world.get::<AnimationTier>(npc).is_none(),
"NPC should not get AnimationTier when no dialogue was active"
);
assert!(
world.get::<RoutineDeviation>(npc).is_none(),
"NPC should not get RoutineDeviation when no dialogue was active"
);
}
use rand::SeedableRng;
// === Confrontation Response Tests (#520, D-063) ===
#[test]
fn confrontation_shifts_npc_to_tier2() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(SimulationTime::default());
world.init_resource::<EntityRegistry>();
world.insert_resource(SimRng::new(42));
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
.id();
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0);
kg.set_relationship(&npc_sid, RelationshipState::Known);
world.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
kg,
ConfrontationDelivered { target: npc },
MonologueBuffer::default(),
MonologueState::default(),
));
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_confrontation_response);
schedule.run(&mut world);
world.flush();
let tier = world.get::<crate::npc::AnimationTier>(npc);
assert_eq!(
tier,
Some(&crate::npc::AnimationTier::Tier2),
"NPC should shift to Tier2 after confrontation"
);
}
#[test]
fn confrontation_decrements_relationship() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(SimulationTime::default());
world.init_resource::<EntityRegistry>();
world.insert_resource(SimRng::new(42));
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
.id();
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0);
kg.set_relationship(&npc_sid, RelationshipState::Known);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
kg,
ConfrontationDelivered { target: npc },
MonologueBuffer::default(),
MonologueState::default(),
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_confrontation_response);
schedule.run(&mut world);
let player_kg = world.get::<KnowledgeGraph>(player).unwrap();
assert_eq!(
player_kg.relationship_with(&npc_sid),
RelationshipState::PersonOfInterest,
"Known → PersonOfInterest after confrontation"
);
}
#[test]
fn confrontation_emits_monologue_spike() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(SimulationTime::default());
world.init_resource::<EntityRegistry>();
world.insert_resource(SimRng::new(42));
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
.id();
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
kg,
ConfrontationDelivered { target: npc },
MonologueBuffer::default(),
MonologueState::default(),
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_confrontation_response);
schedule.run(&mut world);
let mut buffer = world.get_mut::<MonologueBuffer>(player).unwrap();
let event = buffer.take();
assert!(event.is_some(), "Monologue spike should be emitted");
assert!(
event.unwrap().id.starts_with("confront_"),
"Should be a confrontation monologue line"
);
}
#[test]
fn confrontation_clears_marker() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(SimulationTime::default());
world.init_resource::<EntityRegistry>();
world.insert_resource(SimRng::new(42));
let npc = world
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
.id();
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
let mut kg = KnowledgeGraph::new();
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0);
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(5, 5, 0),
kg,
ConfrontationDelivered { target: npc },
MonologueBuffer::default(),
MonologueState::default(),
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_confrontation_response);
schedule.run(&mut world);
world.flush();
assert!(
world.get::<ConfrontationDelivered>(player).is_none(),
"Marker should be removed after processing"
);
}
}
+280 -3
View File
@@ -75,7 +75,7 @@ impl InputQueue {
/// Drains InputQueue for the current tick, converts PlayerActions to ECS components.
/// Handles stance toggling (D-053), movement cooldown, and Take/Place verbs (#424).
#[allow(clippy::type_complexity)]
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub fn process_player_input(
mut input_queue: ResMut<InputQueue>,
mut time: ResMut<SimulationTime>,
@@ -104,8 +104,14 @@ pub fn process_player_input(
for input in inputs {
// 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) {
// Only Pause/Unpause/TeleportToHub are processed — everything else is discarded.
// TeleportToHub is exempted because it's a Gauntlet QA action (#491).
if paused
&& !matches!(
input.action,
PlayerAction::Pause | PlayerAction::Unpause | PlayerAction::TeleportToHub
)
{
continue;
}
match input.action {
@@ -196,6 +202,15 @@ pub fn process_player_input(
target_entity_id,
);
}
Some("Confront") => {
handle_confront(
&mut commands,
&registry,
&player_query,
&all_positions,
target_entity_id,
);
}
Some("Reset") => {
handle_reset(
&mut commands,
@@ -222,6 +237,9 @@ pub fn process_player_input(
tracing::debug!("WalkAway: marker set on player");
}
}
PlayerAction::TeleportToHub => {
handle_teleport_to_hub(&mut player_query, &mut commands);
}
PlayerAction::UsePerceptionMode(ref mode) => {
tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode);
}
@@ -400,6 +418,65 @@ fn handle_talk(
tracing::debug!(target_id, "Talk: TalkRequest marker set on player");
}
/// Handle Confront verb: set ConfrontationDelivered marker on the player entity (#520, D-063).
/// The confrontation response system runs in process_confrontation_response (dialogue.rs).
/// Server-side range check: Confront requires CLOSE_RANGE (same as Talk).
#[allow(clippy::type_complexity)]
fn handle_confront(
commands: &mut Commands,
registry: &EntityRegistry,
player_query: &Query<
(
Entity,
&TilePosition,
Option<&mut Stance>,
Option<&mut PlayerMoveCooldown>,
),
With<PlayerCharacter>,
>,
all_positions: &Query<&TilePosition>,
target_entity_id: Option<u64>,
) {
let Some(target_id) = target_entity_id else {
tracing::warn!("Confront verb without target_entity_id");
return;
};
let Ok((player_entity, player_pos, _, _)) = 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, "Confront: target entity not in registry");
return;
};
// Server-side range check: reject Confront if target is beyond close range
if let Ok(target_pos) = all_positions.get(target_entity) {
let distance = player_pos
.manhattan_distance(target_pos)
.unwrap_or(u32::MAX);
if distance > crate::simulation::interaction::CLOSE_RANGE {
tracing::info!(
target_id,
distance,
"Confront: target out of range (max {})",
crate::simulation::interaction::CLOSE_RANGE,
);
return;
}
}
commands
.entity(player_entity)
.insert(crate::simulation::dialogue::ConfrontationDelivered {
target: target_entity,
});
tracing::debug!(target_id, "Confront: ConfrontationDelivered 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.
@@ -512,6 +589,62 @@ fn handle_reset(
);
}
/// Handle TeleportToHub: move player to hub spawn, clear interaction state (#491).
///
/// Gauntlet-only action. On non-Gauntlet maps (feature disabled), logs a warning
/// and returns. On Gauntlet maps, moves the player to HUB.spawn and removes
/// dialogue, monologue, and interaction markers to prevent stale state.
///
/// Does NOT affect: room state, inventory, game time, knowledge graph.
#[allow(clippy::type_complexity)]
fn handle_teleport_to_hub(
player_query: &mut Query<
(
Entity,
&TilePosition,
Option<&mut Stance>,
Option<&mut PlayerMoveCooldown>,
),
With<PlayerCharacter>,
>,
commands: &mut Commands,
) {
#[cfg(not(feature = "gauntlet"))]
{
tracing::warn!("TeleportToHub rejected: not a Gauntlet map");
return;
}
#[cfg(feature = "gauntlet")]
{
let Ok((player_entity, _, _, _)) = player_query.single() else {
return;
};
let hub_spawn = crate::test_world::constants::HUB.spawn;
// Move player to hub spawn
commands.entity(player_entity).insert(hub_spawn);
// Clear any pending movement
commands.entity(player_entity).remove::<MoveIntent>();
// Clear dialogue/interaction markers
commands
.entity(player_entity)
.remove::<crate::simulation::dialogue::TalkRequest>()
.remove::<crate::simulation::dialogue::ActiveDialogue>()
.remove::<crate::simulation::dialogue::WalkAwayRequest>();
tracing::info!(
x = hub_spawn.x,
y = hub_spawn.y,
z = hub_spawn.z,
"TeleportToHub: player moved to hub spawn"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1690,4 +1823,148 @@ mod tests {
schedule.add_systems(process_player_input);
schedule.run(&mut world); // should not panic
}
// === TeleportToHub Tests (#491) ===
#[cfg(feature = "gauntlet")]
#[test]
fn teleport_to_hub_moves_player() {
// #491: TeleportToHub moves player to hub spawn position.
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
// Spawn player at a non-hub position
let player = world
.spawn((PlayerCharacter, TilePosition::new(84, 58, 0)))
.id();
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::TeleportToHub,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
let pos = world.get::<TilePosition>(player).expect("player has position");
let hub_spawn = crate::test_world::constants::HUB.spawn;
assert_eq!(pos.x, hub_spawn.x, "player x at hub spawn");
assert_eq!(pos.y, hub_spawn.y, "player y at hub spawn");
assert_eq!(pos.z, hub_spawn.z, "player z at hub spawn");
}
#[cfg(feature = "gauntlet")]
#[test]
fn teleport_to_hub_clears_dialogue_markers() {
// #491: TeleportToHub removes ActiveDialogue and TalkRequest.
let mut world = bevy_ecs::world::World::new();
world.insert_resource(InputQueue::default());
world.insert_resource(SimulationTime::default());
world.init_resource::<crate::knowledge::EntityRegistry>();
// Spawn a fake NPC target
let npc = world.spawn(TilePosition::new(10, 10, 0)).id();
// Spawn player with active dialogue state
let player = world
.spawn((
PlayerCharacter,
TilePosition::new(84, 58, 0),
crate::simulation::dialogue::TalkRequest { target: npc },
crate::simulation::dialogue::ActiveDialogue {
target: npc,
interaction_type: crate::knowledge::events::InteractionType::Talk,
started_tick: 0,
},
))
.id();
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::TeleportToHub,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
assert!(
world
.get::<crate::simulation::dialogue::TalkRequest>(player)
.is_none(),
"TalkRequest cleared after teleport"
);
assert!(
world
.get::<crate::simulation::dialogue::ActiveDialogue>(player)
.is_none(),
"ActiveDialogue cleared after teleport"
);
}
#[cfg(feature = "gauntlet")]
#[test]
fn teleport_to_hub_clears_move_intent() {
// #491: TeleportToHub removes any pending MoveIntent.
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(84, 58, 0),
MoveIntent {
target: TilePosition::new(85, 58, 0),
},
))
.id();
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::TeleportToHub,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
assert!(
world.get::<MoveIntent>(player).is_none(),
"MoveIntent cleared after teleport"
);
}
#[cfg(feature = "gauntlet")]
#[test]
fn teleport_to_hub_allowed_while_paused() {
// #491: TeleportToHub is a QA action — allowed even when 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(84, 58, 0)))
.id();
world.resource_mut::<InputQueue>().push(PlayerInput {
tick: 0,
action: PlayerAction::TeleportToHub,
});
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(process_player_input);
schedule.run(&mut world);
let pos = world.get::<TilePosition>(player).expect("player has position");
let hub_spawn = crate::test_world::constants::HUB.spawn;
assert_eq!(pos.x, hub_spawn.x, "teleport works while paused");
}
}
+6
View File
@@ -108,6 +108,12 @@ impl MonologueBuffer {
pub fn take(&mut self) -> Option<MonologueEvent> {
self.event.take()
}
/// Set a monologue event, replacing any pending event.
/// Used by confrontation response (#520, D-063) to emit a monologue spike.
pub fn set(&mut self, event: MonologueEvent) {
self.event = Some(event);
}
}
/// Queued sprint anomaly for delayed "double-take" monologue (#428, D-055).