Files
settled-reach/server/tests/environmental_interaction.rs
T
jpmschweitzerandClaude Sonnet 4.6 b6a9b78205 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>
2026-02-27 11:11:45 +01:00

450 lines
14 KiB
Rust

//! 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)"
);
}