From b6a9b78205f1a139e96f3d6d3bfdf505a3a9dc3e Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 27 Feb 2026 11:11:45 +0100 Subject: [PATCH] =?UTF-8?q?feat(simulation):=20basic=20environmental=20int?= =?UTF-8?q?eraction=20=E2=80=94=20doors,=20examine,=20terminals=20(#246)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 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 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 --- server/src/simulation/examine.rs | 120 +++--- server/src/simulation/input.rs | 119 +++++- server/src/simulation/interaction.rs | 157 +++++++- server/src/simulation/save_state.rs | 7 + server/tests/environmental_interaction.rs | 449 ++++++++++++++++++++++ 5 files changed, 803 insertions(+), 49 deletions(-) create mode 100644 server/tests/environmental_interaction.rs diff --git a/server/src/simulation/examine.rs b/server/src/simulation/examine.rs index 4a9b530f3..8a0012cd9 100644 --- a/server/src/simulation/examine.rs +++ b/server/src/simulation/examine.rs @@ -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, >, - npc_query: Query<( - &TilePosition, - Option<&MoodState>, - Option<&ToleranceThreshold>, - Option<&PersonalityTraits>, - )>, + npc_query: Query< + ( + &TilePosition, + Option<&MoodState>, + Option<&ToleranceThreshold>, + Option<&PersonalityTraits>, + ), + Without, + >, + 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::(); - 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::(); + 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::(); 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::(); + 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::(); + return; + } + tracing::warn!(?target, "process_examine_interaction: target has no position component"); commands.entity(player_entity).remove::(); } diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index b72a7259f..789991789 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -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>, mut save_load: Option>, + 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, + >, + door_states: &Query<&DoorState>, + target_entity_id: Option, +) { + 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, + >, + object_types: &Query<&ObjectType>, + target_entity_id: Option, +) { + 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 diff --git a/server/src/simulation/interaction.rs b/server/src/simulation/interaction.rs index 7e1337d6d..c541c6a9c 100644 --- a/server/src/simulation/interaction.rs +++ b/server/src/simulation/interaction.rs @@ -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, +} + +impl TerminalInteractedQueue { + pub fn push(&mut self, event: TerminalInteracted) { + self.events.push(event); + } + + pub fn drain(&mut self) -> Vec { + 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, + player_query: Query<(Entity, &DoorInteractRequest), With>, + 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::(); + + 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, + registry: Res, + player_query: Query<(Entity, &TerminalInteractRequest), With>, + mut queue: ResMut, +) { + let Ok((player_entity, req)) = player_query.single() else { + return; + }; + + let terminal_entity = req.terminal_entity; + commands.entity(player_entity).remove::(); + + 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::*; diff --git a/server/src/simulation/save_state.rs b/server/src/simulation/save_state.rs index f6c24ef9c..5beddc540 100644 --- a/server/src/simulation/save_state.rs +++ b/server/src/simulation/save_state.rs @@ -94,6 +94,11 @@ pub struct SaveStateV1 { /// for deterministic serialization (D-010). #[serde(default)] pub triangle_states: Vec, + /// 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, } /// 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"); diff --git a/server/tests/environmental_interaction.rs b/server/tests/environmental_interaction.rs new file mode 100644 index 000000000..f3d2b72b1 --- /dev/null +++ b/server/tests/environmental_interaction.rs @@ -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::(); + 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::() + .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::().can_move_to(&TilePosition::new(10, 5, 0)), + "after Open: blocking tile must become walkable" + ); + assert!( + world.get::(door).unwrap().is_open, + "DoorState.is_open must be true after opening" + ); + // Request should be consumed + assert!( + world.get::(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::().can_move_to(&TilePosition::new(10, 5, 0)), + "after Close: blocking tile must be impassable again" + ); + assert!( + !world.get::(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::().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::().can_move_to(&TilePosition::new(10, 5, 0)), + "toggling an open door must block the tile" + ); + assert!( + !world.get::(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::(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::(); + world.init_resource::(); + world.init_resource::(); + + 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::(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::(); + world.init_resource::(); + world.init_resource::(); + + 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::(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::(); + world.init_resource::(); + world.init_resource::(); + + 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::(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::(); + world.init_resource::(); + world.init_resource::(); + + 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::().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::(); + 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::(); + world.init_resource::(); + world.init_resource::(); + + 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::(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)" + ); +}