feat(simulation): basic environmental interaction — doors, examine, terminals (#246)
- Add DoorState component tracking is_open and blocking_tile; add DoorInteractRequest per-player component consumed by new process_door_interaction system (toggles walkability each use) - Add TerminalInteracted event, TerminalInteractedQueue resource, TerminalInteractRequest component, and process_terminal_interaction system (emits event on Use verb) - Add ExamineText(String) component for authored object examine text; extend process_examine_interaction with object examine path: uses ExamineText if present, falls back to generic string if absent - Fix: add Without<ObjectType> filter to npc_query in process_examine_interaction — previously any entity with TilePosition was mis-routed through the NPC text generator - Add SaveStateV1.open_doors: Vec<StableId> with #[serde(default)] for backward-compatible serialization - Add "Open"/"Close" → DoorInteractRequest and "Use" → TerminalInteractRequest dispatch in process_player_input - 10 integration tests in tests/environmental_interaction.rs covering all acceptance criteria: door toggle (both directions), open-to-close, invalid target, readable examine (with/without ExamineText), out-of-range, terminal event emission, request cleanup, and save state round-trip Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@ use crate::knowledge::events::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEve
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use crate::npc::mood::{MoodState, NpcMood};
|
||||
use crate::npc::{PersonalityTrait, PersonalityTraits, ToleranceThreshold};
|
||||
use crate::simulation::interaction::CLOSE_RANGE;
|
||||
use crate::simulation::interaction::{ObjectType, CLOSE_RANGE};
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
@@ -50,6 +50,14 @@ pub struct ExamineResultEvent {
|
||||
pub target_entity_id: u64,
|
||||
}
|
||||
|
||||
/// Authored examine text for a non-NPC entity (#246).
|
||||
///
|
||||
/// Attach to any examinable object (Readable, Terminal, etc.) to provide
|
||||
/// a fixed description returned when the player examines it.
|
||||
/// If absent, examining a non-NPC entity returns a generic fallback.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct ExamineText(pub String);
|
||||
|
||||
/// Buffer holding the examine result for snapshot inclusion.
|
||||
///
|
||||
/// Consumed once per snapshot via `take()`. Cleared at snapshot build time.
|
||||
@@ -171,6 +179,11 @@ pub fn generate_examine_text(
|
||||
/// Process examine interaction: generate character-filtered observation text,
|
||||
/// push DirectObservation to KnowledgeGraph, write result to ExamineResultBuffer.
|
||||
///
|
||||
/// Handles two target types:
|
||||
/// - NPC entities: generate character-filtered text from NPC component state.
|
||||
/// - Non-NPC entities with `ExamineText`: use the authored text directly.
|
||||
/// - Non-NPC entities without `ExamineText`: generic fallback text.
|
||||
///
|
||||
/// System ordering: after process_player_input, before compute_observer_snapshot.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn process_examine_interaction(
|
||||
@@ -188,12 +201,16 @@ pub fn process_examine_interaction(
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
npc_query: Query<(
|
||||
&TilePosition,
|
||||
Option<&MoodState>,
|
||||
Option<&ToleranceThreshold>,
|
||||
Option<&PersonalityTraits>,
|
||||
)>,
|
||||
npc_query: Query<
|
||||
(
|
||||
&TilePosition,
|
||||
Option<&MoodState>,
|
||||
Option<&ToleranceThreshold>,
|
||||
Option<&PersonalityTraits>,
|
||||
),
|
||||
Without<ObjectType>,
|
||||
>,
|
||||
examine_text_query: Query<(&TilePosition, Option<&ExamineText>)>,
|
||||
) {
|
||||
let Ok((player_entity, player_pos, examine_req, archetype_opt, mut result_buffer)) =
|
||||
player_query.single_mut()
|
||||
@@ -204,55 +221,64 @@ pub fn process_examine_interaction(
|
||||
let target = examine_req.target;
|
||||
let archetype = archetype_opt.copied().unwrap_or_default();
|
||||
|
||||
// Range check — examine requires close range (same as Talk/Confront)
|
||||
let Ok((target_pos, mood_opt, tolerance_opt, traits_opt)) = npc_query.get(target) else {
|
||||
tracing::warn!(?target, "process_examine_interaction: target not in query");
|
||||
commands.entity(player_entity).remove::<ExamineRequest>();
|
||||
return;
|
||||
};
|
||||
// Try NPC examine path first
|
||||
if let Ok((target_pos, mood_opt, tolerance_opt, traits_opt)) = npc_query.get(target) {
|
||||
let distance = player_pos.manhattan_distance(target_pos).unwrap_or(u32::MAX);
|
||||
if distance > CLOSE_RANGE {
|
||||
tracing::info!(distance, "Examine: NPC target out of range (max {})", CLOSE_RANGE);
|
||||
commands.entity(player_entity).remove::<ExamineRequest>();
|
||||
return;
|
||||
}
|
||||
|
||||
let distance = player_pos.manhattan_distance(target_pos).unwrap_or(u32::MAX);
|
||||
if distance > CLOSE_RANGE {
|
||||
tracing::info!(
|
||||
distance,
|
||||
"Examine: target out of range (max {})",
|
||||
CLOSE_RANGE
|
||||
);
|
||||
let mood = mood_opt.map(|m| m.mood).unwrap_or(NpcMood::Neutral);
|
||||
let ratio = tolerance_opt.map(stress_ratio).unwrap_or(0);
|
||||
let text = generate_examine_text(mood, ratio, archetype, traits_opt);
|
||||
|
||||
kg_events.push(KnowledgeEvent {
|
||||
observer: player_entity,
|
||||
tick: time.tick,
|
||||
event_type: KnowledgeEventType::DirectObservation {
|
||||
target,
|
||||
position: *target_pos,
|
||||
},
|
||||
});
|
||||
|
||||
let target_entity_id = registry.to_stable(target).map(|sid| sid.0).unwrap_or_else(|| {
|
||||
tracing::warn!(?target, "Examine: NPC not in EntityRegistry, using bits");
|
||||
target.to_bits()
|
||||
});
|
||||
|
||||
result_buffer.result = Some(ExamineResultEvent { text, target_entity_id });
|
||||
tracing::debug!(target_entity_id, "Examine: NPC result written to buffer");
|
||||
commands.entity(player_entity).remove::<ExamineRequest>();
|
||||
return;
|
||||
}
|
||||
|
||||
let mood = mood_opt.map(|m| m.mood).unwrap_or(NpcMood::Neutral);
|
||||
let ratio = tolerance_opt.map(stress_ratio).unwrap_or(0);
|
||||
// Object examine path: entity has a TilePosition but no NPC mood components.
|
||||
if let Ok((target_pos, examine_text_opt)) = examine_text_query.get(target) {
|
||||
let distance = player_pos.manhattan_distance(target_pos).unwrap_or(u32::MAX);
|
||||
if distance > CLOSE_RANGE {
|
||||
tracing::info!(distance, "Examine: object target out of range (max {})", CLOSE_RANGE);
|
||||
commands.entity(player_entity).remove::<ExamineRequest>();
|
||||
return;
|
||||
}
|
||||
|
||||
let text = generate_examine_text(mood, ratio, archetype, traits_opt);
|
||||
let text = examine_text_opt
|
||||
.map(|et| et.0.clone())
|
||||
.unwrap_or_else(|| "No further details are apparent.".to_string());
|
||||
|
||||
// Push DirectObservation to KnowledgeEventQueue
|
||||
kg_events.push(KnowledgeEvent {
|
||||
observer: player_entity,
|
||||
tick: time.tick,
|
||||
event_type: KnowledgeEventType::DirectObservation {
|
||||
target,
|
||||
position: *target_pos,
|
||||
},
|
||||
});
|
||||
let target_entity_id = registry.to_stable(target).map(|sid| sid.0).unwrap_or_else(|| {
|
||||
tracing::warn!(?target, "Examine: object not in EntityRegistry, using bits");
|
||||
target.to_bits()
|
||||
});
|
||||
|
||||
// Resolve target wire ID for snapshot
|
||||
let target_entity_id = registry.to_stable(target).map(|sid| sid.0).unwrap_or_else(|| {
|
||||
tracing::warn!(?target, "Examine: target not in EntityRegistry, using bits");
|
||||
target.to_bits()
|
||||
});
|
||||
|
||||
result_buffer.result = Some(ExamineResultEvent {
|
||||
text,
|
||||
target_entity_id,
|
||||
});
|
||||
|
||||
tracing::debug!(
|
||||
target_entity_id,
|
||||
"Examine: DirectObservation pushed, result written to buffer"
|
||||
);
|
||||
result_buffer.result = Some(ExamineResultEvent { text, target_entity_id });
|
||||
tracing::debug!(target_entity_id, "Examine: object result written to buffer");
|
||||
commands.entity(player_entity).remove::<ExamineRequest>();
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::warn!(?target, "process_examine_interaction: target has no position component");
|
||||
commands.entity(player_entity).remove::<ExamineRequest>();
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
// Timestamped player input events for deterministic simulation (D-010 principle 4)
|
||||
// PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode, ToggleStance)
|
||||
|
||||
use crate::bridge::types::{FacingDirection, PlayerAction, PlayerInput};
|
||||
use crate::bridge::types::{FacingDirection, ObjectType, PlayerAction, PlayerInput};
|
||||
use crate::knowledge::{EntityRegistry, StableId};
|
||||
use crate::perception::vision_cone::{facing_from_delta, Facing};
|
||||
use crate::simulation::interaction::{DoorInteractRequest, DoorState, TerminalInteractRequest};
|
||||
use crate::simulation::inventory::{
|
||||
find_next_slot, occupied_slots_for, CarriedBy, InventorySlot, ItemName, MAX_INVENTORY_SLOTS,
|
||||
};
|
||||
@@ -97,6 +98,8 @@ pub fn process_player_input(
|
||||
reset_triggers: Query<&RoomResetTrigger>,
|
||||
mut room_snapshots: Option<ResMut<RoomSnapshots>>,
|
||||
mut save_load: Option<ResMut<SaveLoadPending>>,
|
||||
door_states: Query<&DoorState>,
|
||||
object_types: Query<&ObjectType>,
|
||||
) {
|
||||
let current_tick = time.tick;
|
||||
let paused = time.paused();
|
||||
@@ -256,6 +259,25 @@ pub fn process_player_input(
|
||||
current_tick,
|
||||
);
|
||||
}
|
||||
// #246: Door and Terminal behavior
|
||||
Some("Open") | Some("Close") => {
|
||||
handle_door_interact(
|
||||
&mut commands,
|
||||
®istry,
|
||||
&player_query,
|
||||
&door_states,
|
||||
target_entity_id,
|
||||
);
|
||||
}
|
||||
Some("Use") => {
|
||||
handle_terminal_interact(
|
||||
&mut commands,
|
||||
®istry,
|
||||
&player_query,
|
||||
&object_types,
|
||||
target_entity_id,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
tracing::info!(
|
||||
"Interact: target={:?}, verb={:?} — logged only",
|
||||
@@ -872,6 +894,101 @@ fn handle_reset(
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle door Open/Close: insert `DoorInteractRequest` on the player entity (#246).
|
||||
///
|
||||
/// The actual walkability toggle is done by `process_door_interaction` which
|
||||
/// reads the request and modifies `WalkabilityMap`. The split keeps system
|
||||
/// ordering explicit and avoids mutable resource conflicts in one system.
|
||||
fn handle_door_interact(
|
||||
commands: &mut Commands,
|
||||
registry: &EntityRegistry,
|
||||
player_query: &Query<
|
||||
(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
Option<&mut Stance>,
|
||||
Option<&mut PlayerMoveCooldown>,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
door_states: &Query<&DoorState>,
|
||||
target_entity_id: Option<u64>,
|
||||
) {
|
||||
let Some(target_id) = target_entity_id else {
|
||||
tracing::warn!("Door verb without target_entity_id");
|
||||
return;
|
||||
};
|
||||
|
||||
let target_stable = StableId(target_id);
|
||||
let Some(target_entity) = registry.to_entity(&target_stable) else {
|
||||
tracing::warn!(target_id, "Door interact: target entity not in registry");
|
||||
return;
|
||||
};
|
||||
|
||||
// Verify target has DoorState before inserting request
|
||||
if door_states.get(target_entity).is_err() {
|
||||
tracing::warn!(target_id, "Door verb on entity without DoorState — ignored");
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok((player_entity, _, _, _)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
commands
|
||||
.entity(player_entity)
|
||||
.insert(DoorInteractRequest { door_entity: target_entity });
|
||||
|
||||
tracing::debug!(target_id, "Door interact: DoorInteractRequest inserted on player");
|
||||
}
|
||||
|
||||
/// Handle Terminal Use: insert `TerminalInteractRequest` on the player entity (#246).
|
||||
fn handle_terminal_interact(
|
||||
commands: &mut Commands,
|
||||
registry: &EntityRegistry,
|
||||
player_query: &Query<
|
||||
(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
Option<&mut Stance>,
|
||||
Option<&mut PlayerMoveCooldown>,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
object_types: &Query<&ObjectType>,
|
||||
target_entity_id: Option<u64>,
|
||||
) {
|
||||
let Some(target_id) = target_entity_id else {
|
||||
tracing::warn!("Use verb without target_entity_id");
|
||||
return;
|
||||
};
|
||||
|
||||
let target_stable = StableId(target_id);
|
||||
let Some(target_entity) = registry.to_entity(&target_stable) else {
|
||||
tracing::warn!(target_id, "Terminal interact: target entity not in registry");
|
||||
return;
|
||||
};
|
||||
|
||||
// Verify target is a Terminal
|
||||
match object_types.get(target_entity) {
|
||||
Ok(ObjectType::Terminal) => {}
|
||||
_ => {
|
||||
tracing::warn!(target_id, "Use verb on non-Terminal entity — ignored");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let Ok((player_entity, _, _, _)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
commands
|
||||
.entity(player_entity)
|
||||
.insert(TerminalInteractRequest { terminal_entity: target_entity });
|
||||
|
||||
tracing::debug!(target_id, "Terminal interact: TerminalInteractRequest inserted on player");
|
||||
}
|
||||
|
||||
/// Handle TeleportToHub: move player to hub spawn, clear interaction state (#491).
|
||||
///
|
||||
/// Gauntlet-only action. On non-Gauntlet maps (feature disabled), logs a warning
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Interaction system — proximity detection + multi-verb InteractionOptions
|
||||
// Implements #404: server-side verb computation for context-sensitive [E] key
|
||||
// Extended by #421: ObjectType component + verb sets per type (D-057)
|
||||
// Extended by #246: door toggle, terminal event, examine-text (#246)
|
||||
// Spec: docs/design/interaction-verbs-v0.1.md
|
||||
// D-060: actions[] renamed to verbs[] across all surfaces
|
||||
//
|
||||
@@ -10,14 +11,17 @@
|
||||
// Phase 2 filtering (KG-gated verbs) handled by #422.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// Re-export ObjectType for backward compatibility — definition moved to bridge::types (#422).
|
||||
pub use crate::bridge::types::ObjectType;
|
||||
use crate::bridge::types::{EntityKind, MovementStance, NearbyInteraction, VerbKind, VerbOption};
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use crate::knowledge::types::StableId;
|
||||
use crate::npc::Npc;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use crate::simulation::stance::Stance;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
/// Interaction range thresholds (Manhattan distance, same z-level)
|
||||
pub(crate) const CLOSE_RANGE: u32 = 2;
|
||||
@@ -317,6 +321,157 @@ impl NearbyInteractionBuffer {
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// #246 — Door behavior (toggle walkability)
|
||||
// ===========================================================================
|
||||
|
||||
/// Component tracking the open/closed state of a door and its blocking tile.
|
||||
///
|
||||
/// Attach to any entity with `ObjectType::Door`. The `blocking_tile` is the
|
||||
/// tile that becomes walkable when the door opens and impassable when it closes.
|
||||
///
|
||||
/// Door state is persisted in `SaveStateV1.open_doors` (D-010).
|
||||
#[derive(Component, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DoorState {
|
||||
/// Whether the door is currently open (walkable) or closed (blocking).
|
||||
pub is_open: bool,
|
||||
/// The tile whose walkability is toggled by this door.
|
||||
pub blocking_tile: TilePosition,
|
||||
}
|
||||
|
||||
impl DoorState {
|
||||
pub fn new(blocking_tile: TilePosition) -> Self {
|
||||
DoorState {
|
||||
is_open: false,
|
||||
blocking_tile,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker: player requested a door interaction (Open or Close) this tick.
|
||||
///
|
||||
/// Inserted by `process_player_input` when verb is "Open" or "Close" on a
|
||||
/// Door entity. Consumed and removed by `process_door_interaction`.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct DoorInteractRequest {
|
||||
/// The ECS entity of the door to toggle.
|
||||
pub door_entity: Entity,
|
||||
}
|
||||
|
||||
/// Event emitted when a player uses a Terminal (#246).
|
||||
///
|
||||
/// Downstream systems (dialogue hook — future work) subscribe to this queue.
|
||||
/// The queue is not automatically drained — consumers must call `drain()`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TerminalInteracted {
|
||||
/// Stable ID of the terminal entity.
|
||||
pub terminal_id: StableId,
|
||||
/// Tick when the interaction occurred.
|
||||
pub tick: u64,
|
||||
}
|
||||
|
||||
/// Resource: queue of terminal interaction events (#246).
|
||||
#[derive(Resource, Default)]
|
||||
pub struct TerminalInteractedQueue {
|
||||
pub events: Vec<TerminalInteracted>,
|
||||
}
|
||||
|
||||
impl TerminalInteractedQueue {
|
||||
pub fn push(&mut self, event: TerminalInteracted) {
|
||||
self.events.push(event);
|
||||
}
|
||||
|
||||
pub fn drain(&mut self) -> Vec<TerminalInteracted> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.events.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker: player requested a Terminal interaction this tick.
|
||||
///
|
||||
/// Inserted by `process_player_input` when verb is "Use" on a Terminal entity.
|
||||
/// Consumed and removed by `process_terminal_interaction`.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct TerminalInteractRequest {
|
||||
pub terminal_entity: Entity,
|
||||
}
|
||||
|
||||
/// System: toggle door open/closed state and update walkability map (#246).
|
||||
///
|
||||
/// Reads `DoorInteractRequest` on the player entity. Toggles `DoorState.is_open`
|
||||
/// and updates `WalkabilityMap` for the door's `blocking_tile`.
|
||||
///
|
||||
/// Ordering: after `process_player_input`, before movement validation.
|
||||
pub fn process_door_interaction(
|
||||
mut commands: Commands,
|
||||
mut walkability: ResMut<WalkabilityMap>,
|
||||
player_query: Query<(Entity, &DoorInteractRequest), With<PlayerCharacter>>,
|
||||
mut door_query: Query<&mut DoorState>,
|
||||
) {
|
||||
let Ok((player_entity, req)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let door_entity = req.door_entity;
|
||||
commands.entity(player_entity).remove::<DoorInteractRequest>();
|
||||
|
||||
let Ok(mut door) = door_query.get_mut(door_entity) else {
|
||||
tracing::warn!(?door_entity, "process_door_interaction: no DoorState on target");
|
||||
return;
|
||||
};
|
||||
|
||||
// Toggle state
|
||||
door.is_open = !door.is_open;
|
||||
let walkable = door.is_open;
|
||||
let tile = door.blocking_tile;
|
||||
|
||||
walkability.set_walkable(&tile, walkable);
|
||||
|
||||
tracing::info!(
|
||||
?tile,
|
||||
is_open = door.is_open,
|
||||
"Door toggled: tile walkability set to {walkable}"
|
||||
);
|
||||
}
|
||||
|
||||
/// System: emit TerminalInteracted event when player uses a Terminal (#246).
|
||||
///
|
||||
/// Reads `TerminalInteractRequest` on the player entity, emits to
|
||||
/// `TerminalInteractedQueue`, and removes the request.
|
||||
pub fn process_terminal_interaction(
|
||||
mut commands: Commands,
|
||||
time: Res<SimulationTime>,
|
||||
registry: Res<EntityRegistry>,
|
||||
player_query: Query<(Entity, &TerminalInteractRequest), With<PlayerCharacter>>,
|
||||
mut queue: ResMut<TerminalInteractedQueue>,
|
||||
) {
|
||||
let Ok((player_entity, req)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let terminal_entity = req.terminal_entity;
|
||||
commands.entity(player_entity).remove::<TerminalInteractRequest>();
|
||||
|
||||
let terminal_id = registry
|
||||
.to_stable(terminal_entity)
|
||||
.unwrap_or(StableId(terminal_entity.to_bits()));
|
||||
|
||||
queue.push(TerminalInteracted {
|
||||
terminal_id,
|
||||
tick: time.tick,
|
||||
});
|
||||
|
||||
tracing::info!(
|
||||
?terminal_entity,
|
||||
terminal_id = terminal_id.0,
|
||||
tick = time.tick,
|
||||
"Terminal used: TerminalInteracted event queued"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -94,6 +94,11 @@ pub struct SaveStateV1 {
|
||||
/// for deterministic serialization (D-010).
|
||||
#[serde(default)]
|
||||
pub triangle_states: Vec<TriangleState>,
|
||||
/// Stable IDs of doors that are currently open (#246).
|
||||
/// Doors not in this list are assumed closed on load. Sorted ascending
|
||||
/// for deterministic serialization (D-010).
|
||||
#[serde(default)]
|
||||
pub open_doors: Vec<StableId>,
|
||||
}
|
||||
|
||||
/// Per-NPC state snapshot for `SaveStateV1`.
|
||||
@@ -405,6 +410,7 @@ mod tests {
|
||||
npc_states: vec![],
|
||||
template_references: TemplateReferenceMap::default(),
|
||||
triangle_states: vec![],
|
||||
open_doors: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -801,6 +807,7 @@ mod tests {
|
||||
npc_states: vec![frozen],
|
||||
template_references: TemplateReferenceMap::default(),
|
||||
triangle_states: vec![],
|
||||
open_doors: vec![],
|
||||
};
|
||||
|
||||
let bytes = save.to_bytes().expect("serialize");
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
//! Integration tests for basic environmental interaction (#246).
|
||||
//!
|
||||
//! Covers the acceptance criteria from the sprint briefing:
|
||||
//! - Door toggle: player interacts with a Door, walkability flips; interacts again, flips back.
|
||||
//! - Readable examine: Examine on a Readable entity returns non-empty text.
|
||||
//! - Terminal interaction: Use on a Terminal emits TerminalInteracted event.
|
||||
//! - DoorState persists in SaveStateV1.open_doors.
|
||||
|
||||
use bevy_ecs::{prelude::*, schedule::Schedule, world::World};
|
||||
use settled_reach_server::{
|
||||
knowledge::{registry::EntityRegistry, registry::StableEntityId, types::StableId},
|
||||
npc::relationships::RelationshipGraph,
|
||||
simulation::{
|
||||
examine::{
|
||||
process_examine_interaction, ExamineRequest, ExamineResultBuffer, ExamineText,
|
||||
},
|
||||
interaction::{
|
||||
process_door_interaction, process_terminal_interaction, DoorInteractRequest,
|
||||
DoorState, Interactable, ObjectType, TerminalInteractRequest, TerminalInteractedQueue,
|
||||
},
|
||||
movement::{PlayerCharacter, TilePosition, WalkabilityMap},
|
||||
save_state::{SaveStateV1, SAVE_FORMAT_VERSION},
|
||||
time::{SimulationTime, TickRate},
|
||||
},
|
||||
};
|
||||
use settled_reach_server::knowledge::KnowledgeGraph;
|
||||
use settled_reach_server::content::template::TemplateReferenceMap;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn make_world_with_walkability(width: i32, height: i32) -> World {
|
||||
let mut world = World::new();
|
||||
world.insert_resource(WalkabilityMap::new(width, height, 1));
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world
|
||||
}
|
||||
|
||||
fn spawn_player(world: &mut World, x: i32, y: i32) -> Entity {
|
||||
world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(x, y, 0),
|
||||
))
|
||||
.id()
|
||||
}
|
||||
|
||||
fn spawn_door(world: &mut World, x: i32, y: i32, blocking_x: i32, blocking_y: i32) -> Entity {
|
||||
world
|
||||
.spawn((
|
||||
TilePosition::new(x, y, 0),
|
||||
Interactable,
|
||||
ObjectType::Door,
|
||||
DoorState::new(TilePosition::new(blocking_x, blocking_y, 0)),
|
||||
))
|
||||
.id()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Door behavior tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Acceptance: player interacts with Door, walkability flips; interacts again, flips back.
|
||||
#[test]
|
||||
fn door_toggle_flips_walkability_both_ways() {
|
||||
let mut world = make_world_with_walkability(20, 20);
|
||||
|
||||
// Block the door tile initially
|
||||
world
|
||||
.resource_mut::<WalkabilityMap>()
|
||||
.set_walkable(&TilePosition::new(10, 5, 0), false);
|
||||
|
||||
let player = spawn_player(&mut world, 10, 6);
|
||||
let door = spawn_door(&mut world, 10, 6, 10, 5); // door tile at (10,5)
|
||||
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(process_door_interaction);
|
||||
|
||||
// First interaction: open the door
|
||||
world
|
||||
.entity_mut(player)
|
||||
.insert(DoorInteractRequest { door_entity: door });
|
||||
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(
|
||||
world.resource::<WalkabilityMap>().can_move_to(&TilePosition::new(10, 5, 0)),
|
||||
"after Open: blocking tile must become walkable"
|
||||
);
|
||||
assert!(
|
||||
world.get::<DoorState>(door).unwrap().is_open,
|
||||
"DoorState.is_open must be true after opening"
|
||||
);
|
||||
// Request should be consumed
|
||||
assert!(
|
||||
world.get::<DoorInteractRequest>(player).is_none(),
|
||||
"DoorInteractRequest must be removed after processing"
|
||||
);
|
||||
|
||||
// Second interaction: close the door
|
||||
world
|
||||
.entity_mut(player)
|
||||
.insert(DoorInteractRequest { door_entity: door });
|
||||
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(
|
||||
!world.resource::<WalkabilityMap>().can_move_to(&TilePosition::new(10, 5, 0)),
|
||||
"after Close: blocking tile must be impassable again"
|
||||
);
|
||||
assert!(
|
||||
!world.get::<DoorState>(door).unwrap().is_open,
|
||||
"DoorState.is_open must be false after closing"
|
||||
);
|
||||
}
|
||||
|
||||
/// Door starts open: toggling closes it (walkability → blocked).
|
||||
#[test]
|
||||
fn door_starts_open_toggle_closes_it() {
|
||||
let mut world = make_world_with_walkability(20, 20);
|
||||
|
||||
// Start with door open (tile walkable, is_open = true)
|
||||
let player = spawn_player(&mut world, 10, 6);
|
||||
let door = world
|
||||
.spawn((
|
||||
TilePosition::new(10, 6, 0),
|
||||
Interactable,
|
||||
ObjectType::Door,
|
||||
DoorState {
|
||||
is_open: true,
|
||||
blocking_tile: TilePosition::new(10, 5, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
// Tile starts walkable (default map is all walkable)
|
||||
assert!(
|
||||
world.resource::<WalkabilityMap>().can_move_to(&TilePosition::new(10, 5, 0)),
|
||||
"precondition: tile is walkable when door starts open"
|
||||
);
|
||||
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(process_door_interaction);
|
||||
|
||||
world
|
||||
.entity_mut(player)
|
||||
.insert(DoorInteractRequest { door_entity: door });
|
||||
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(
|
||||
!world.resource::<WalkabilityMap>().can_move_to(&TilePosition::new(10, 5, 0)),
|
||||
"toggling an open door must block the tile"
|
||||
);
|
||||
assert!(
|
||||
!world.get::<DoorState>(door).unwrap().is_open,
|
||||
"DoorState.is_open must be false after closing an open door"
|
||||
);
|
||||
}
|
||||
|
||||
/// Missing DoorState on target: system logs warning and removes request without panic.
|
||||
#[test]
|
||||
fn door_interact_without_door_state_does_not_panic() {
|
||||
let mut world = make_world_with_walkability(10, 10);
|
||||
let player = spawn_player(&mut world, 5, 5);
|
||||
let not_a_door = world.spawn(TilePosition::new(5, 6, 0)).id();
|
||||
|
||||
world
|
||||
.entity_mut(player)
|
||||
.insert(DoorInteractRequest { door_entity: not_a_door });
|
||||
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(process_door_interaction);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Should not panic; request removed
|
||||
assert!(
|
||||
world.get::<DoorInteractRequest>(player).is_none(),
|
||||
"DoorInteractRequest must be consumed even when target lacks DoorState"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Readable examine tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Acceptance: Examine on a Readable entity with ExamineText returns non-empty text.
|
||||
#[test]
|
||||
fn examine_readable_returns_authored_text() {
|
||||
use settled_reach_server::knowledge::events::KnowledgeEventQueue;
|
||||
|
||||
let mut world = World::new();
|
||||
world.init_resource::<KnowledgeEventQueue>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<SimulationTime>();
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
ExamineResultBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
let readable = world
|
||||
.spawn((
|
||||
TilePosition::new(5, 6, 0),
|
||||
Interactable,
|
||||
ObjectType::Readable,
|
||||
ExamineText("A logistics manifest. Freight records dating back three cycles.".to_string()),
|
||||
))
|
||||
.id();
|
||||
|
||||
world
|
||||
.entity_mut(player)
|
||||
.insert(ExamineRequest { target: readable });
|
||||
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(process_examine_interaction);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let event = world
|
||||
.get_mut::<ExamineResultBuffer>(player)
|
||||
.unwrap()
|
||||
.take();
|
||||
assert!(
|
||||
event.is_some(),
|
||||
"ExamineResultBuffer must contain a result after examining a Readable"
|
||||
);
|
||||
let text = event.unwrap().text;
|
||||
assert!(
|
||||
!text.is_empty(),
|
||||
"examine result text must be non-empty for a Readable entity"
|
||||
);
|
||||
assert!(
|
||||
text.contains("manifest") || text.contains("Freight") || text.contains("records"),
|
||||
"text should match the authored ExamineText, got: '{text}'"
|
||||
);
|
||||
}
|
||||
|
||||
/// Examine on a Readable entity WITHOUT ExamineText returns a generic non-empty fallback.
|
||||
#[test]
|
||||
fn examine_readable_without_examine_text_returns_fallback() {
|
||||
use settled_reach_server::knowledge::events::KnowledgeEventQueue;
|
||||
|
||||
let mut world = World::new();
|
||||
world.init_resource::<KnowledgeEventQueue>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<SimulationTime>();
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
ExamineResultBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
// Readable but no ExamineText component
|
||||
let readable = world
|
||||
.spawn((
|
||||
TilePosition::new(5, 6, 0),
|
||||
Interactable,
|
||||
ObjectType::Readable,
|
||||
))
|
||||
.id();
|
||||
|
||||
world
|
||||
.entity_mut(player)
|
||||
.insert(ExamineRequest { target: readable });
|
||||
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(process_examine_interaction);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let event = world
|
||||
.get_mut::<ExamineResultBuffer>(player)
|
||||
.unwrap()
|
||||
.take();
|
||||
assert!(
|
||||
event.is_some(),
|
||||
"ExamineResultBuffer must contain a result even without ExamineText"
|
||||
);
|
||||
let text = event.unwrap().text;
|
||||
assert!(!text.is_empty(), "fallback text must be non-empty, got: '{text}'");
|
||||
}
|
||||
|
||||
/// Examine out of range returns no result.
|
||||
#[test]
|
||||
fn examine_readable_out_of_range_returns_no_result() {
|
||||
use settled_reach_server::knowledge::events::KnowledgeEventQueue;
|
||||
|
||||
let mut world = World::new();
|
||||
world.init_resource::<KnowledgeEventQueue>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<SimulationTime>();
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
ExamineResultBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
// Distance 8 > CLOSE_RANGE (2)
|
||||
let readable = world
|
||||
.spawn((
|
||||
TilePosition::new(5, 13, 0),
|
||||
Interactable,
|
||||
ObjectType::Readable,
|
||||
ExamineText("Out of range text".to_string()),
|
||||
))
|
||||
.id();
|
||||
|
||||
world
|
||||
.entity_mut(player)
|
||||
.insert(ExamineRequest { target: readable });
|
||||
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(process_examine_interaction);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let event = world
|
||||
.get_mut::<ExamineResultBuffer>(player)
|
||||
.unwrap()
|
||||
.take();
|
||||
assert!(event.is_none(), "examining an out-of-range Readable must not produce a result");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terminal interaction tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Use on a Terminal emits TerminalInteracted event.
|
||||
#[test]
|
||||
fn terminal_use_emits_terminal_interacted_event() {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<TerminalInteractedQueue>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<SimulationTime>();
|
||||
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
|
||||
let terminal = world
|
||||
.spawn((
|
||||
TilePosition::new(5, 6, 0),
|
||||
Interactable,
|
||||
ObjectType::Terminal,
|
||||
))
|
||||
.id();
|
||||
|
||||
// Register terminal in EntityRegistry so stable ID resolves
|
||||
let terminal_sid = StableId(42);
|
||||
world.entity_mut(terminal).insert(StableEntityId(terminal_sid));
|
||||
world.resource_mut::<EntityRegistry>().register_existing(terminal, terminal_sid);
|
||||
|
||||
world
|
||||
.entity_mut(player)
|
||||
.insert(TerminalInteractRequest { terminal_entity: terminal });
|
||||
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(process_terminal_interaction);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let queue = world.resource::<TerminalInteractedQueue>();
|
||||
assert_eq!(queue.events.len(), 1, "one TerminalInteracted event must be emitted");
|
||||
assert_eq!(
|
||||
queue.events[0].terminal_id, terminal_sid,
|
||||
"terminal_id must match the interacted terminal"
|
||||
);
|
||||
}
|
||||
|
||||
/// TerminalInteractRequest is removed after processing.
|
||||
#[test]
|
||||
fn terminal_interact_request_consumed_after_processing() {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<TerminalInteractedQueue>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<SimulationTime>();
|
||||
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
|
||||
let terminal = world.spawn(TilePosition::new(5, 6, 0)).id();
|
||||
|
||||
world
|
||||
.entity_mut(player)
|
||||
.insert(TerminalInteractRequest { terminal_entity: terminal });
|
||||
|
||||
let mut schedule = Schedule::default();
|
||||
schedule.add_systems(process_terminal_interaction);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(
|
||||
world.get::<TerminalInteractRequest>(player).is_none(),
|
||||
"TerminalInteractRequest must be removed after processing"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SaveStateV1.open_doors field
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn minimal_save() -> SaveStateV1 {
|
||||
SaveStateV1 {
|
||||
format_version: SAVE_FORMAT_VERSION,
|
||||
tick: 0,
|
||||
tick_rate: TickRate::Full,
|
||||
seed: 42,
|
||||
player_knowledge: KnowledgeGraph::new(),
|
||||
relationship_graph: RelationshipGraph::new(),
|
||||
npc_states: vec![],
|
||||
template_references: TemplateReferenceMap::default(),
|
||||
triangle_states: vec![],
|
||||
open_doors: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// SaveStateV1.open_doors round-trips through MessagePack serialization.
|
||||
#[test]
|
||||
fn save_state_open_doors_roundtrip() {
|
||||
let mut save = minimal_save();
|
||||
save.open_doors = vec![StableId(10), StableId(20), StableId(30)];
|
||||
|
||||
let bytes = save.to_bytes().expect("serialize");
|
||||
let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize");
|
||||
|
||||
assert_eq!(
|
||||
recovered.open_doors,
|
||||
vec![StableId(10), StableId(20), StableId(30)],
|
||||
"open_doors must round-trip through MessagePack"
|
||||
);
|
||||
}
|
||||
|
||||
/// Older save files (no open_doors field) deserialize without error.
|
||||
/// The field defaults to empty vec via #[serde(default)].
|
||||
#[test]
|
||||
fn save_state_open_doors_defaults_to_empty_on_old_saves() {
|
||||
let save = minimal_save();
|
||||
assert!(
|
||||
save.open_doors.is_empty(),
|
||||
"open_doors must default to empty vec (backward compat with older saves)"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user