Files
settled-reach/server/tests/environmental_interaction.rs
T
jpmschweitzerandClaude Opus 4.6 cae3d3ab85 refactor(simulation): strip archetype trace + HeritageRoot per cascade (#877, #878)
Sprint 37 dead-code sweep closing out two stale supersession chains:

#877 (D-167, 2026-03-24): Removes HeritageRoot type alias and
ZonePaletteModifier::Heritage variant from server/src/simulation/
generator.rs. The 7 abstract heritage roots were retired in favour of
the corridor cultural system; these two stubs were the only remaining
references.

#878 (D-032 + cascade rule): Strips the entire CharacterArchetype
(Smuggler/Detective) trace from the server. Per lead direction
2026-04-21 and the development cascade (CLAUDE.md), character/NPC/
verb-differentiation/monologue code is Phase 6 detail that should
not exist in code yet. The running archetype trace was pre-cascade
filler, not production — production is only the client's character-
creation UI and insert screens (client follow-up in #882).

Deleted:
- CharacterArchetype enum + StartupMessage.character_archetype field
- archetype_verb_label() + archetype branch of apply_phase2_verb_filter
  (D-057 character-verb differentiation — marked superseded)
- MonologueState.character partitioning
- Gauntlet archetype plumbing (setup_gauntlet no longer takes an archetype)
- server/content/schemas/drama_module.schema.yaml (zero Rust consumers)
- server/content/modules/tier1/smuggling_ring_v0_1.yaml
- server/tests/archetype_monologue.rs (regression guard for the removed system)
- server/tests/v01_integration_playthrough.rs (archetype-dependent)

Decision updates:
- decisions/content.md D-032 supersession rewritten to cite the cascade
  (v0.2 drop invalidated the prior D-117 framing).
- decisions/content.md D-035 tag taxonomy: `character` enum footnote
  updated; field noted as unused, do not reintroduce without a
  confirmed Phase 6 design.
- decisions/perception.md D-057: archetype-verb differentiation marked
  superseded.

Also bundles the types.rs version-field removal from #874 since the
file was already touched here.

Full trace audit in docs/architecture/sprint-37-878-audit.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 08:55:48 +02:00

465 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::KnowledgeGraph;
use settled_reach_server::simulation::triangle::TemplateReferenceMap;
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},
},
};
// ---------------------------------------------------------------------------
// 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![],
modifications: vec![],
contamination_active: false,
activated_count: 0,
last_activation_tick: None,
selected_bookmark: settled_reach_server::bookmark::SelectedBookmark::default(),
}
}
/// 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)"
);
}