Delete the hand-authored YAML content pipeline (server/src/content/) superseded
by the v0.2 generator-first approach (D-122, D-128). Runtime ECS types that were
co-located with content loading have been extracted to dedicated simulation modules:
- simulation/triangle.rs: TriangleState, TriangleCrisisEventQueue, tick/resolve systems
- simulation/line_pool.rs: LinePoolIndex, AccessTier, TrustTier, Mood, LinePoolIndexResource
- simulation/knowledge_grant.rs: KnowledgeGrant, Prerequisites
Monologue systems (trigger_monologue, trigger_recognition_monologue,
trigger_event_monologue) now use hardcoded fallback lines only; the
ContentStoreResource branch and select_pool_line function are removed.
Deleted: content/{loader,types,line_pool,hot_reload,spawn,instantiation,entanglement,mod}.rs
Deleted: tests/{content_loading,content_runtime,content_scaling,template_instantiation,template_schema}.rs
Deleted: bin/line_preview.rs (v0.1 tool)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
454 lines
14 KiB
Rust
454 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::simulation::triangle::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![],
|
|
modifications: vec![],
|
|
contamination_active: false,
|
|
activated_count: 0,
|
|
last_activation_tick: None,
|
|
}
|
|
}
|
|
|
|
/// 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)"
|
|
);
|
|
}
|