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>
465 lines
14 KiB
Rust
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)"
|
|
);
|
|
}
|