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
+118 -1
View File
@@ -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,
&registry,
&player_query,
&door_states,
target_entity_id,
);
}
Some("Use") => {
handle_terminal_interact(
&mut commands,
&registry,
&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