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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
®istry,
|
||||
&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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user