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
+73 -47
View File
@@ -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>();
}
+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
+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::*;
+7
View File
@@ -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");
+449
View File
@@ -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)"
);
}