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
+1 -1
View File
@@ -978,7 +978,7 @@ dependencies = [
[[package]]
name = "settled-reach-server"
version = "0.1.0"
version = "0.1.9"
dependencies = [
"bevy_app",
"bevy_ecs",
+3
View File
@@ -178,11 +178,14 @@ impl Plugin for BridgePlugin {
crate::simulation::dialogue::process_walk_away
.after(crate::simulation::input::process_player_input)
.after(crate::simulation::dialogue::process_talk_interaction),
crate::simulation::dialogue::process_confrontation_response
.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)
.after(crate::simulation::dialogue::process_confrontation_response)
.before(crate::simulation::time::advance_tick),
crate::perception::observation::emit_observation_events
.after(crate::perception::observer::compute_observer_snapshot),
+24
View File
@@ -159,6 +159,12 @@ pub fn format_snapshot_text(snapshot: &ObserverSnapshot) -> String {
.ok();
}
// Blocked entities (debug, #514)
if !snapshot.blocked_entities.is_empty() {
let ids: Vec<String> = snapshot.blocked_entities.iter().map(|id| id.to_string()).collect();
writeln!(out, "Blocked (LOS): {} [{}]", snapshot.blocked_entities.len(), ids.join(", ")).ok();
}
writeln!(out, "===").ok();
out
}
@@ -281,6 +287,7 @@ mod tests {
}),
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
}
}
@@ -402,10 +409,27 @@ mod tests {
current_monologue: None,
pending_recognitions: vec![],
dialogue_response: None,
blocked_entities: vec![],
};
let text = format_snapshot_text(&snap);
assert!(text.contains("Tick 0"));
assert!(text.contains("Player (-1,-1)"));
assert!(text.contains("Tiles: 0 visible"));
}
#[test]
fn blocked_entities_rendered() {
let mut snap = make_snapshot();
snap.blocked_entities = vec![42, 99, 1024];
let text = format_snapshot_text(&snap);
assert!(text.contains("Blocked (LOS): 3"));
assert!(text.contains("[42, 99, 1024]"));
}
#[test]
fn blocked_entities_empty_not_rendered() {
let snap = make_snapshot();
let text = format_snapshot_text(&snap);
assert!(!text.contains("Blocked (LOS)"));
}
}
+11 -1
View File
@@ -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 = 8;
pub const PROTOCOL_VERSION: u8 = 9;
/// The ONLY data structure crossing the client-server boundary (D-020)
/// Contains all information visible to the observer at a given tick.
@@ -27,6 +27,7 @@ pub const PROTOCOL_VERSION: u8 = 8;
/// 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).
/// v9 adds: blocked_entities (#514, debug field for LOS-blocked entities).
/// Future fields: ambient sound events, HUD state (D-020 expansion).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObserverSnapshot {
@@ -68,6 +69,11 @@ pub struct ObserverSnapshot {
/// Client shows speaker name + dialogue text in a dialogue box.
#[serde(default)]
pub dialogue_response: Option<DialogueResponseEvent>,
/// Debug field: entity IDs on the same z-level that are not visible due to
/// LOS obstruction or being outside the vision cone (#514).
/// Sorted ascending for deterministic output. Client can safely ignore.
#[serde(default)]
pub blocked_entities: Vec<u64>,
}
/// Game time data for client display (D-031)
@@ -316,6 +322,10 @@ pub enum PlayerAction {
ToggleStanceUp,
/// Move one step down the stance ladder (toward Crouch) per D-053
ToggleStanceDown,
/// Teleport player to the Gauntlet hub spawn point (#491).
/// Clears dialogue, monologue, and interaction buffers.
/// Rejected with a log warning on non-Gauntlet maps.
TeleportToHub,
}
impl PlayerAction {
+17
View File
@@ -129,6 +129,23 @@ pub enum RelationshipState {
Hostile,
}
impl RelationshipState {
/// Decrement relationship state toward more negative (D-063 confrontation response).
///
/// Friendly → Known → PersonOfInterest → Hostile.
/// Unknown stays Unknown (can't confront a stranger meaningfully).
/// Hostile stays Hostile (already worst state).
pub fn decrement(self) -> Self {
match self {
Self::Friendly => Self::Known,
Self::Known => Self::PersonOfInterest,
Self::PersonOfInterest => Self::Hostile,
Self::Unknown => Self::Unknown,
Self::Hostile => Self::Hostile,
}
}
}
// --- Entity Knowledge ---
/// What entity A knows about entity B.
+36
View File
@@ -35,6 +35,42 @@ impl Plugin for NpcPlugin {
#[derive(Component, Debug)]
pub struct Npc;
/// NPC animation tier (D-047).
///
/// Tier 1 (clear): public daily activities — instantly readable.
/// Tier 2 (ambiguous): privately motivated behaviors — player sees the action
/// but cannot determine the intention.
///
/// NPCs start at Tier 1. Confrontation (D-063) and other triggers shift to Tier 2.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
pub enum AnimationTier {
/// Clear, readable public activities (walking, working, talking).
#[default]
Tier1,
/// Ambiguous, privately motivated behaviors (pausing, lingering, looking around).
Tier2,
}
/// Tracks why and when an NPC's routine deviated from normal (D-064 Phase 2).
///
/// Inserted when a player action causes an NPC to break from their scheduled
/// behavior. Acts as a hook for the storyteller system and affects future
/// interactions (e.g., second-approach dialogue differences).
#[derive(Component, Debug, Clone)]
pub struct RoutineDeviation {
pub trigger: DeviationTrigger,
pub tick: u64,
}
/// What caused an NPC's routine deviation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DeviationTrigger {
/// Player walked away mid-dialogue (D-064 Phase 2).
WalkAway,
/// Player delivered a confrontation (D-063).
Confrontation,
}
// ---------------------------------------------------------------------------
// Axis 1: Want (D-024)
// ---------------------------------------------------------------------------
+2 -4
View File
@@ -142,10 +142,8 @@ pub fn emit_observation_events(
let pending_ids: Vec<crate::knowledge::types::StableId> =
delay.pending().iter().map(|p| p.stable_id).collect();
for sid in pending_ids {
if !visible_stable_ids.contains(&sid.0) {
if delay.cancel(&sid).is_some() {
tracing::debug!("Cognitive delay cancelled: stable_id={} left LOS", sid.0);
}
if !visible_stable_ids.contains(&sid.0) && delay.cancel(&sid).is_some() {
tracing::debug!("Cognitive delay cancelled: stable_id={} left LOS", sid.0);
}
}
}
+24 -14
View File
@@ -116,7 +116,7 @@ pub fn compute_observer_snapshot(
})
.unwrap_or_default();
let (mut entities, visible_ids) =
let (mut entities, visible_ids, blocked_entities) =
filter_visible_entities(&geometry, &registry, observer_kg, &all_entities);
collect_remembered_entities(
@@ -206,11 +206,13 @@ pub fn compute_observer_snapshot(
current_monologue,
pending_recognitions,
dialogue_response,
blocked_entities,
});
}
/// Filter entities by visibility using precomputed geometry.
/// Returns (visible entities, set of visible wire IDs).
/// Returns (visible entities, set of visible wire IDs, blocked entity IDs).
/// Blocked entities are on the same z-level but not in visible_positions (#514).
#[allow(clippy::type_complexity)]
fn filter_visible_entities(
geometry: &VisibilityGeometry,
@@ -222,15 +224,31 @@ fn filter_visible_entities(
Option<&PlayerCharacter>,
Option<&crate::npc::Npc>,
)>,
) -> (Vec<VisibleEntity>, BTreeSet<u64>) {
) -> (Vec<VisibleEntity>, BTreeSet<u64>, Vec<u64>) {
let mut entities = Vec::new();
let mut visible_ids: BTreeSet<u64> = BTreeSet::new();
let mut blocked_ids: BTreeSet<u64> = BTreeSet::new();
for (entity, pos, is_player, is_npc) in all_entities.iter() {
if pos.z != geometry.observer_z {
continue;
}
// Resolve wire ID early — needed for both visible and blocked paths
let wire_id = registry
.to_stable(entity)
.map(|sid| sid.0)
.unwrap_or_else(|| {
tracing::error!(?entity, "entity not in EntityRegistry");
entity.to_bits()
});
if !geometry.visible_positions.contains(&(pos.x, pos.y)) {
// Same z-level but not visible — blocked by LOS or outside vision cone.
// Exclude the player entity (always at origin, always visible).
if is_player.is_none() {
blocked_ids.insert(wire_id);
}
continue;
}
@@ -257,16 +275,6 @@ fn filter_visible_entities(
RelationshipState::Unknown
};
// Fallback to Entity::to_bits() is intentional for per-frame systems:
// panicking would crash the server every tick. The error log makes this
// loud enough to catch in testing while keeping the server alive.
let wire_id = registry
.to_stable(entity)
.map(|sid| sid.0)
.unwrap_or_else(|| {
tracing::error!(?entity, "entity visible but not in EntityRegistry");
entity.to_bits()
});
visible_ids.insert(wire_id);
entities.push(VisibleEntity {
entity_id: wire_id,
@@ -280,7 +288,9 @@ fn filter_visible_entities(
});
}
(entities, visible_ids)
// BTreeSet iteration is sorted — deterministic output guaranteed
let blocked_vec: Vec<u64> = blocked_ids.into_iter().collect();
(entities, visible_ids, blocked_vec)
}
/// Collect remembered entities from the knowledge graph — entities the observer
+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).
+34 -3
View File
@@ -189,16 +189,19 @@ pub const INTERACTION_GALLERY_STABLE_IDS: (u64, u64) = (24, 28);
pub const PAUSE_CHAMBER_STABLE_IDS: (u64, u64) = (29, 29);
pub const DIALOGUE_ROOM_STABLE_IDS: (u64, u64) = (30, 33);
pub const CROWD_PLAZA_STABLE_IDS: (u64, u64) = (34, 48);
pub const RESET_PLATE_STABLE_IDS: (u64, u64) = (49, 51);
pub const RESET_PLATE_STABLE_IDS: (u64, u64) = (49, 55);
/// Number of actively-spawned entities in the current Gauntlet build.
/// Derived from StableId ranges of built rooms + player + reset plates.
/// Reserved (unbuilt) rooms do not contribute entities.
/// Derived from StableId ranges of all rooms + player + reset plates.
pub const EXPECTED_ENTITY_COUNT: usize = 1 // player (StableId 0)
+ (HUB_STABLE_IDS.1 - HUB_STABLE_IDS.0 + 1) as usize
+ (FOG_THEATER_STABLE_IDS.1 - FOG_THEATER_STABLE_IDS.0 + 1) as usize
+ (OCCLUSION_STABLE_IDS.1 - OCCLUSION_STABLE_IDS.0 + 1) as usize
+ (INVENTORY_STABLE_IDS.1 - INVENTORY_STABLE_IDS.0 + 1) as usize
+ (INTERACTION_GALLERY_STABLE_IDS.1 - INTERACTION_GALLERY_STABLE_IDS.0 + 1) as usize
+ (PAUSE_CHAMBER_STABLE_IDS.1 - PAUSE_CHAMBER_STABLE_IDS.0 + 1) as usize
+ (DIALOGUE_ROOM_STABLE_IDS.1 - DIALOGUE_ROOM_STABLE_IDS.0 + 1) as usize
+ (CROWD_PLAZA_STABLE_IDS.1 - CROWD_PLAZA_STABLE_IDS.0 + 1) as usize
+ (RESET_PLATE_STABLE_IDS.1 - RESET_PLATE_STABLE_IDS.0 + 1) as usize;
#[cfg(test)]
@@ -233,6 +236,34 @@ mod tests {
assert_eq!(room.name, "pause_chamber");
}
#[test]
fn room_at_finds_fog_theater() {
let pos = TilePosition { x: 56, y: 18, z: 0 };
let room = room_at(&pos).expect("Fog Theater observer should be in a room");
assert_eq!(room.name, "fog_theater");
}
#[test]
fn room_at_finds_interaction_gallery() {
let pos = TilePosition { x: 14, y: 92, z: 0 };
let room = room_at(&pos).expect("Interaction Gallery observer should be in a room");
assert_eq!(room.name, "interaction_gallery");
}
#[test]
fn room_at_finds_dialogue_room() {
let pos = TilePosition { x: 50, y: 114, z: 0 };
let room = room_at(&pos).expect("Dialogue Room observer should be in a room");
assert_eq!(room.name, "dialogue_room");
}
#[test]
fn room_at_finds_crowd_plaza() {
let pos = TilePosition { x: 96, y: 94, z: 0 };
let room = room_at(&pos).expect("Crowd Plaza observer should be in a room");
assert_eq!(room.name, "crowd_plaza");
}
#[test]
fn room_at_returns_none_for_corridor() {
// Point inside corridor-E (between Hub and Occlusion)
+77 -17
View File
@@ -19,14 +19,14 @@
//! StableId ranges (from gestalt-round3.md):
//! Player: 0
//! Hub signs: 1-4
//! Fog Theater: 5-8 (reserved, not yet built)
//! Fog Theater: 5-8
//! Occlusion Corridor: 9-12
//! Inventory Warehouse: 13-23
//! Interaction Gallery: 24-28 (reserved, not yet built)
//! Interaction Gallery: 24-28
//! Pause Chamber: 29
//! Dialogue Room: 30-33 (reserved, not yet built)
//! Crowd Plaza: 34-48 (reserved, not yet built)
//! Reset plates: 49-51 (Occlusion, Inventory, Pause)
//! Dialogue Room: 30-33
//! Crowd Plaza: 34-48
//! Reset plates: 49-55
#[cfg(feature = "gauntlet")]
pub mod constants;
@@ -86,14 +86,22 @@ pub fn setup_gauntlet(app: &mut App) {
// Carve room interiors (2-tile-thick walls → interior starts 2 tiles in)
carve_room_interior(&mut walkability, 38, 46, 24, 24); // Hub
carve_room_interior(&mut walkability, 28, 2, 44, 32); // Fog Theater
carve_room_interior(&mut walkability, 74, 48, 42, 22); // Occlusion Corridor
carve_room_interior(&mut walkability, 2, 40, 30, 28); // Inventory Warehouse
carve_room_interior(&mut walkability, 2, 82, 24, 20); // Interaction Gallery
carve_room_interior(&mut walkability, 42, 78, 16, 16); // Pause Chamber
carve_room_interior(&mut walkability, 36, 104, 28, 20); // Dialogue Room
carve_room_interior(&mut walkability, 80, 78, 32, 32); // Crowd Plaza
// Carve corridors between hub and rooms
carve_corridor(&mut walkability, 48, 34, 6, 12); // corridor-N: Hub ↔ Fog Theater
carve_corridor(&mut walkability, 62, 55, 12, 6); // corridor-E: Hub ↔ Occlusion
carve_corridor(&mut walkability, 32, 55, 6, 6); // corridor-W: Hub ↔ Inventory
carve_corridor(&mut walkability, 47, 70, 6, 8); // corridor-S: Hub ↔ Pause Chamber
carve_corridor(&mut walkability, 12, 68, 6, 14); // corridor-SW: Inventory ↔ Interaction Gallery
carve_corridor(&mut walkability, 48, 94, 6, 10); // corridor-S2: Pause ↔ Dialogue Room
carve_corridor(&mut walkability, 58, 84, 22, 6); // corridor-E2: Pause ↔ Crowd Plaza
// Set up Occlusion Corridor walls (relative positions converted to absolute)
// North wall segment: rel x=[14,22], y=[6,7] — blocks LOS to npc_hidden_wall
@@ -142,8 +150,8 @@ pub fn setup_gauntlet(app: &mut App) {
// --- Hub signs (StableId 1-4) ---
rooms::hub::spawn_entities(app, &mut registry);
// --- Fog Theater (StableId 5-8) — reserved, not yet built ---
registry.reserve_up_to(9);
// --- Fog Theater (StableId 5-8) ---
rooms::fog_theater::spawn_entities(app, &mut registry);
// --- Occlusion Corridor (StableId 9-12) ---
rooms::occlusion_corridor::spawn_entities(app, &mut registry);
@@ -151,23 +159,29 @@ pub fn setup_gauntlet(app: &mut App) {
// --- Inventory Warehouse (StableId 13-23) ---
rooms::inventory_warehouse::spawn_entities(app, &mut registry);
// --- Interaction Gallery (StableId 24-28) — reserved, not yet built ---
registry.reserve_up_to(29);
// --- Interaction Gallery (StableId 24-28) ---
rooms::interaction_gallery::spawn_entities(app, &mut registry);
// --- Pause Chamber (StableId 29) ---
rooms::pause_chamber::spawn_entities(app, &mut registry);
// --- Dialogue Room (StableId 30-33) — reserved, not yet built ---
// --- Crowd Plaza (StableId 34-48) — reserved, not yet built ---
registry.reserve_up_to(49);
// --- Dialogue Room (StableId 30-33) ---
rooms::dialogue_room::spawn_entities(app, &mut registry);
// --- Reset plates (StableId 49-51) ---
// --- Crowd Plaza (StableId 34-48) ---
rooms::crowd_plaza::spawn_entities(app, &mut registry);
// --- Reset plates (StableId 49-55) ---
// Spawned at corridor entrances per workshop-outcomes.md Section 8.
// Each plate triggers reset of its associated room.
let reset_plates: &[(&str, TilePosition)] = &[
("occlusion_corridor", constants::OCCLUSION_CORRIDOR.reset_plate.expect("occlusion_corridor should have a reset_plate")),
("inventory_warehouse", constants::INVENTORY_WAREHOUSE.reset_plate.expect("inventory_warehouse should have a reset_plate")),
("pause_chamber", constants::PAUSE_CHAMBER.reset_plate.expect("pause_chamber should have a reset_plate")),
("fog_theater", constants::FOG_THEATER.reset_plate.expect("fog_theater should have a reset_plate")),
("interaction_gallery", constants::INTERACTION_GALLERY.reset_plate.expect("interaction_gallery should have a reset_plate")),
("dialogue_room", constants::DIALOGUE_ROOM.reset_plate.expect("dialogue_room should have a reset_plate")),
("crowd_plaza", constants::CROWD_PLAZA.reset_plate.expect("crowd_plaza should have a reset_plate")),
];
for &(room_name, pos) in reset_plates {
let entity = app
@@ -189,6 +203,15 @@ pub fn setup_gauntlet(app: &mut App) {
// --- Populate RoomSnapshots for reset mechanism (#490) ---
let mut snapshots = RoomSnapshots::default();
// Fog Theater entities (StableId 5-8): NPCs only
for id in constants::FOG_THEATER_STABLE_IDS.0..=constants::FOG_THEATER_STABLE_IDS.1 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("fog_theater", entity, *pos, false);
}
}
}
// Occlusion Corridor entities (StableId 9-12): NPCs only, no floor items
for id in 9..=12 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
@@ -208,6 +231,15 @@ pub fn setup_gauntlet(app: &mut App) {
}
}
// Interaction Gallery entities (StableId 24-28): objects only, no floor items
for id in constants::INTERACTION_GALLERY_STABLE_IDS.0..=constants::INTERACTION_GALLERY_STABLE_IDS.1 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("interaction_gallery", entity, *pos, false);
}
}
}
// Pause Chamber entity (StableId 29): NPC only
if let Some(entity) = registry.to_entity(&StableId(29)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
@@ -215,6 +247,24 @@ pub fn setup_gauntlet(app: &mut App) {
}
}
// Dialogue Room entities (StableId 30-33): NPCs only
for id in constants::DIALOGUE_ROOM_STABLE_IDS.0..=constants::DIALOGUE_ROOM_STABLE_IDS.1 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("dialogue_room", entity, *pos, false);
}
}
}
// Crowd Plaza entities (StableId 34-48): NPCs only
for id in constants::CROWD_PLAZA_STABLE_IDS.0..=constants::CROWD_PLAZA_STABLE_IDS.1 {
if let Some(entity) = registry.to_entity(&StableId(id)) {
if let Some(pos) = app.world().get::<TilePosition>(entity) {
snapshots.record("crowd_plaza", entity, *pos, false);
}
}
}
app.insert_resource(snapshots);
app.insert_resource(registry);
}
@@ -331,9 +381,9 @@ mod tests {
assert!(registry.to_entity(&StableId(id)).is_some(), "Hub sign at StableId {}", id);
}
// Fog Theater 5-8 reserved (no entities)
// Fog Theater at 5-8
for id in 5..=8 {
assert!(registry.to_entity(&StableId(id)).is_none(), "Fog Theater {} reserved", id);
assert!(registry.to_entity(&StableId(id)).is_some(), "Fog Theater at StableId {}", id);
}
// Occlusion Corridor at 9-12
@@ -346,14 +396,24 @@ mod tests {
assert!(registry.to_entity(&StableId(id)).is_some(), "Inventory at StableId {}", id);
}
// Interaction Gallery 24-28 reserved
// Interaction Gallery at 24-28
for id in 24..=28 {
assert!(registry.to_entity(&StableId(id)).is_none(), "Gallery {} reserved", id);
assert!(registry.to_entity(&StableId(id)).is_some(), "Gallery at StableId {}", id);
}
// Pause Chamber at 29
assert!(registry.to_entity(&StableId(29)).is_some(), "Pause Chamber at StableId 29");
// Dialogue Room at 30-33
for id in 30..=33 {
assert!(registry.to_entity(&StableId(id)).is_some(), "Dialogue Room at StableId {}", id);
}
// Crowd Plaza at 34-48
for id in 34..=48 {
assert!(registry.to_entity(&StableId(id)).is_some(), "Crowd Plaza at StableId {}", id);
}
// Reset plates at 49-51
for id in constants::RESET_PLATE_STABLE_IDS.0..=constants::RESET_PLATE_STABLE_IDS.1 {
assert!(
@@ -0,0 +1,85 @@
//! Crowd Plaza — Room 7 (32x32)
//!
//! Density stress test room. 15 NPCs in a 5x3 grid to test perception,
//! snapshot, and tick-budget performance under high entity density.
//! Validates D-026 tick budget holds with many visible entities.
//!
//! Observer position: (16, 16) relative = (96, 94) absolute, facing West.
//!
//! Entities (StableId 34-48):
//! crowd_npc_00..crowd_npc_14 — 15 NPCs in a 5-column x 3-row grid
//! Grid starts at rel (4, 4) = abs (84, 82), spacing: 5x cols, 8y rows.
use bevy_app::prelude::*;
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
use crate::simulation::interaction::Interactable;
use crate::simulation::movement::TilePosition;
use crate::simulation::path_follow::MovementSpeed;
/// Room origin (top-left corner including walls).
const ORIGIN_X: i32 = 80;
const ORIGIN_Y: i32 = 78;
/// Grid layout constants.
const GRID_COLS: usize = 5;
const GRID_ROWS: usize = 3;
const GRID_START_X: i32 = 4;
const GRID_START_Y: i32 = 4;
const GRID_SPACING_X: i32 = 5;
const GRID_SPACING_Y: i32 = 8;
/// WantKind cycle for variety across 15 NPCs.
const WANT_CYCLE: &[WantKind] = &[
WantKind::Wealth,
WantKind::Safety,
WantKind::Knowledge,
WantKind::Connection,
WantKind::Power,
WantKind::Freedom,
WantKind::Justice,
WantKind::Revenge,
WantKind::Happiness,
];
/// Spawn Crowd Plaza entities in canonical order (StableId 34-48).
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
let mut index = 0usize;
for row in 0..GRID_ROWS {
for col in 0..GRID_COLS {
let rx = GRID_START_X + col as i32 * GRID_SPACING_X;
let ry = GRID_START_Y + row as i32 * GRID_SPACING_Y;
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
let want_kind = WANT_CYCLE[index % WANT_CYCLE.len()];
let entity = app
.world_mut()
.spawn((
Npc,
Interactable,
pos,
Want {
primary: want_kind,
intensity: ((index % 8) + 2) as u8, // 2-9 range
description: format!("Crowd Plaza NPC #{:02}", index),
},
Contentment {
level: (index as i16 * 7 - 50).clamp(-100, 100),
},
ToleranceThreshold {
current_stress: (index as i16 * 5) % 80,
threshold: 40 + (index as i16 % 4) * 10,
},
MovementSpeed::default(),
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
index += 1;
}
}
}
@@ -0,0 +1,87 @@
//! Dialogue Room — Room 6 (28x20)
//!
//! Tests D-041 (knowledge graph), D-028 (dialogue filtering pipeline).
//!
//! Layout: Four NPCs with DialogueProfile components, enabling the full
//! dialogue selection pipeline (access tier, situation, trust, mood scoring).
//! NPCs have varying trust/contentment to test different dialogue branches.
//!
//! Observer position: (14, 10) relative = (50, 114) absolute, facing North.
//!
//! Entities (StableId 30-33):
//! npc_dialogue_a (44, 112) — Relaxed dock worker
//! npc_dialogue_b (50, 110) — Guarded technician
//! npc_dialogue_c (56, 112) — Stressed supervisor
//! npc_dialogue_d (50, 118) — Distant observer (range test)
use bevy_app::prelude::*;
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
use crate::simulation::dialogue::{CurrentMood, DialogueProfile};
use crate::simulation::interaction::Interactable;
use crate::simulation::movement::TilePosition;
use crate::simulation::path_follow::MovementSpeed;
/// Room origin (top-left corner including walls).
const ORIGIN_X: i32 = 36;
const ORIGIN_Y: i32 = 104;
/// NPC definitions: (name, rel_x, rel_y, want_kind, intensity, contentment, stress, threshold, location, role).
const NPCS: &[(&str, i32, i32, WantKind, u8, i16, i16, i16, &str, &str)] = &[
(
"npc_dialogue_a", 8, 8, WantKind::Connection, 4, 20, 10, 60,
"the-terminal", "dock-worker",
),
(
"npc_dialogue_b", 14, 6, WantKind::Safety, 6, 0, 25, 45,
"the-terminal", "technician",
),
(
"npc_dialogue_c", 20, 8, WantKind::Power, 7, -15, 40, 50,
"the-terminal", "supervisor",
),
(
"npc_dialogue_d", 14, 14, WantKind::Knowledge, 3, 10, 5, 70,
"the-terminal", "observer",
),
];
/// Spawn Dialogue Room entities in canonical order (StableId 30-33).
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
for &(name, rx, ry, want_kind, intensity, contentment, stress, threshold, location, role) in
NPCS
{
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
let entity = app
.world_mut()
.spawn((
Npc,
Interactable,
pos,
Want {
primary: want_kind,
intensity,
description: format!("Dialogue Room test NPC: {}", name),
},
Contentment {
level: contentment,
},
ToleranceThreshold {
current_stress: stress,
threshold,
},
MovementSpeed::default(),
DialogueProfile {
location: location.to_string(),
role: role.to_string(),
},
CurrentMood::default(),
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
}
@@ -0,0 +1,66 @@
//! Fog Theater — Room 1 (44x32)
//!
//! Tests D-059 (fog layers), D-060 (cognitive delay for fog recognition).
//!
//! Layout: Large open room with NPCs at varying distances from observer.
//! Tests visibility at clear, peripheral, deep-fog, and edge-of-range
//! distances. No internal walls — fog layers are distance-based, not
//! occlusion-based (that's the Occlusion Corridor's job).
//!
//! Observer position: (28, 16) relative = (56, 18) absolute, facing South.
//!
//! Entities (StableId 5-8):
//! npc_fog_clear (56, 22) — 4 tiles south, clear vision cone
//! npc_fog_peripheral (46, 18) — 10 tiles west, peripheral sector
//! npc_fog_deep (34, 10) — far NW corner, deep fog range
//! npc_fog_edge (68, 28) — far SE, edge-of-range test
use bevy_app::prelude::*;
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
use crate::simulation::interaction::Interactable;
use crate::simulation::movement::TilePosition;
use crate::simulation::path_follow::MovementSpeed;
/// Room origin (top-left corner including walls).
const ORIGIN_X: i32 = 28;
const ORIGIN_Y: i32 = 2;
/// NPC definitions: (name, relative_x, relative_y, want_kind, want_intensity).
const NPCS: &[(&str, i32, i32, WantKind, u8)] = &[
("npc_fog_clear", 28, 20, WantKind::Safety, 5),
("npc_fog_peripheral", 18, 16, WantKind::Knowledge, 6),
("npc_fog_deep", 6, 8, WantKind::Freedom, 3),
("npc_fog_edge", 40, 26, WantKind::Wealth, 4),
];
/// Spawn Fog Theater entities in canonical order (StableId 5-8).
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
for &(name, rx, ry, want_kind, intensity) in NPCS {
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
let entity = app
.world_mut()
.spawn((
Npc,
Interactable,
pos,
Want {
primary: want_kind,
intensity,
description: format!("Fog Theater test NPC: {}", name),
},
Contentment { level: 0 },
ToleranceThreshold {
current_stress: 0,
threshold: 50,
},
MovementSpeed::default(),
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
}
@@ -0,0 +1,51 @@
//! Interaction Gallery — Room 4 (24x20)
//!
//! Tests D-057 (entity interaction vertical list, verb generation per type).
//!
//! Layout: One entity of each ObjectType to test that each type generates
//! the correct interaction verb(s). Container is already tested in
//! Inventory Warehouse, so this room covers the remaining 5 types.
//!
//! Observer position: (12, 10) relative = (14, 92) absolute, facing East.
//!
//! Entities (StableId 24-28):
//! obj_notice (8, 88) — Readable (Read verb)
//! obj_terminal (8, 94) — Terminal (Use verb)
//! obj_hatch (20, 88) — Door (Open/Close verb)
//! obj_pickup (20, 94) — Pickup (Pick up verb)
//! obj_bench (14, 96) — Furniture (Sit verb)
use bevy_app::prelude::*;
use crate::bridge::types::ObjectType;
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
use crate::simulation::interaction::Interactable;
use crate::simulation::movement::TilePosition;
/// Room origin (top-left corner including walls).
const ORIGIN_X: i32 = 2;
const ORIGIN_Y: i32 = 82;
/// Object definitions: (name, relative_x, relative_y, object_type).
const OBJECTS: &[(&str, i32, i32, ObjectType)] = &[
("obj_notice", 6, 6, ObjectType::Readable),
("obj_terminal", 6, 12, ObjectType::Terminal),
("obj_hatch", 18, 6, ObjectType::Door),
("obj_pickup", 18, 12, ObjectType::Pickup),
("obj_bench", 12, 14, ObjectType::Furniture),
];
/// Spawn Interaction Gallery entities in canonical order (StableId 24-28).
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
for &(_name, rx, ry, obj_type) in OBJECTS {
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
let entity = app
.world_mut()
.spawn((Interactable, obj_type, pos))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
}
+4
View File
@@ -3,7 +3,11 @@
//! Each room module exports a `spawn_entities()` function that creates
//! entities in canonical order for deterministic StableId assignment.
pub mod crowd_plaza;
pub mod dialogue_room;
pub mod fog_theater;
pub mod hub;
pub mod interaction_gallery;
pub mod inventory_warehouse;
pub mod occlusion_corridor;
pub mod pause_chamber;