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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user