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:
2026-02-27 11:11:45 +01:00
co-authored by Claude Sonnet 4.6
parent c4e09bff77
commit b6a9b78205
5 changed files with 803 additions and 49 deletions
+156 -1
View File
@@ -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::*;