Files
settled-reach/server/tests/content_loading.rs
T
jpmschweitzerandClaude Opus 4.6 e66352e0ea feat(simulation): sprint 9 gauntlet — test infrastructure and first 3 rooms
Add Gauntlet test world with 3 rooms (Inventory Warehouse, Occlusion
Corridor, Pause Chamber) + Central Hub, room constants module, room
reset trigger mechanism, Layer 3 subprocess integration test, golden
file comparison engine and test suite, and content runtime validation.

Tickets: #482, #484, #485, #487, #488, #489, #490

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 02:25:57 +01:00

549 lines
19 KiB
Rust

//! Integration test: content loading pipeline.
//!
//! Tests the full pipeline: discover content → deserialize YAML → spawn ECS entities.
//! Uses real content files from content/ directory for structural content,
//! and a test fixture for isolated NPC profile spawning.
//!
//! Also includes runtime validation (#489): boot the full plugin stack with real
//! content, tick 10 times over TCP, and assert a valid ObserverSnapshot.
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use std::collections::BTreeMap;
use std::path::PathBuf;
use settled_reach_server::content::loader::{load_content, ContentStore};
use settled_reach_server::content::spawn::spawn_content;
use settled_reach_server::content::types::*;
use settled_reach_server::content::{ContentConfig, ContentPlugin, ContentStoreResource};
use settled_reach_server::knowledge::registry::EntityRegistry;
use settled_reach_server::npc;
use settled_reach_server::simulation::SimulationPlugin;
/// Find the content root relative to the test binary location.
fn content_root() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest_dir).join("../content")
}
// -----------------------------------------------------------------------
// Test: real content discovery and structural loading
// -----------------------------------------------------------------------
#[test]
fn discover_real_content_structure() {
let root = content_root();
if !root.join("content.yaml").exists() {
// Skip if content directory is not present (e.g. CI without content)
eprintln!("Skipping: content directory not found at {:?}", root);
return;
}
let store = load_content(&root).expect("content loading should succeed");
// Manifest should be present
assert!(store.manifest.is_some());
let manifest = store.manifest.as_ref().unwrap();
assert_eq!(manifest.version, "0.1.0");
assert!(!manifest.campaigns.is_empty());
// At least one district should be discovered
assert!(
!store.districts.is_empty(),
"should discover at least one district"
);
}
#[test]
fn load_real_transit_district() {
let root = content_root();
if !root.join("content.yaml").exists() {
return;
}
let store = load_content(&root).expect("content loading should succeed");
// The transit district should be discovered
let transit = store
.districts
.get("krenn.sova.transit")
.expect("transit district should be discovered");
// District metadata
assert!(transit.meta.is_some());
let meta = transit.meta.as_ref().unwrap();
assert_eq!(meta.display_name, "Sova Transit District");
assert_eq!(meta.npc_count, 17);
// 5 triangles from ticket #391
assert_eq!(transit.triangles.len(), 5);
let triangle_ids: Vec<&str> = transit
.triangles
.iter()
.map(|t| t.canonical_id.as_str())
.collect();
assert!(triangle_ids.contains(&"hub-power"));
assert!(triangle_ids.contains(&"worried-knowledge"));
assert!(triangle_ids.contains(&"bar-tensions"));
assert!(triangle_ids.contains(&"worried-partner"));
assert!(triangle_ids.contains(&"informant-question"));
// Each triangle should have exactly 3 members
for triangle in &transit.triangles {
assert_eq!(
triangle.members.len(),
3,
"Triangle {} should have 3 members",
triangle.canonical_id
);
}
// 5 pools from ticket #389
assert_eq!(transit.pools.len(), 5);
let pool_ids: Vec<&str> = transit.pools.iter().map(|p| p.pool_id.as_str()).collect();
assert!(pool_ids.contains(&"transit:friend_smuggler"));
assert!(pool_ids.contains(&"transit:friend_detective"));
assert!(pool_ids.contains(&"transit:bar_regulars"));
assert!(pool_ids.contains(&"transit:compromised_inspector"));
assert!(pool_ids.contains(&"transit:primary_contraband"));
// 3 templates from ticket #390
assert_eq!(transit.templates.len(), 3);
let template_ids: Vec<&str> = transit
.templates
.iter()
.map(|t| t.template_id.as_str())
.collect();
assert!(template_ids.contains(&"logistics-hub"));
assert!(template_ids.contains(&"bar"));
assert!(template_ids.contains(&"smuggling-ring"));
// 20 NPC profiles: 17 NPCs + 2 PC-as-NPC + 1 extended NPC (nils-davan)
// Populated by #398 (wiki→YAML NPC conversion)
assert_eq!(
transit.npc_profiles.len(),
20,
"Expected 20 parseable NPC profiles (17 NPCs + 2 PCs + 1 extended)"
);
}
#[test]
fn verify_triangle_fork_structure() {
let root = content_root();
if !root.join("content.yaml").exists() {
return;
}
let store = load_content(&root).expect("content loading should succeed");
let transit = store.districts.get("krenn.sova.transit").unwrap();
// Hub power triangle: should have 1 fork with 3 outcomes
let hub_power = transit
.triangles
.iter()
.find(|t| t.canonical_id == "hub-power")
.expect("hub-power triangle should exist");
assert_eq!(hub_power.forks.len(), 1);
assert_eq!(hub_power.forks[0].id, "volume-escalation");
assert_eq!(hub_power.forks[0].outcomes.len(), 3);
let outcome_ids: Vec<&str> = hub_power.forks[0]
.outcomes
.iter()
.filter_map(|o| o.id.as_deref())
.collect();
assert!(outcome_ids.contains(&"escalate"));
assert!(outcome_ids.contains(&"stabilize"));
assert!(outcome_ids.contains(&"mediate"));
// Resolution states
assert_eq!(hub_power.resolution_states.len(), 3);
}
#[test]
fn verify_pool_candidates() {
let root = content_root();
if !root.join("content.yaml").exists() {
return;
}
let store = load_content(&root).expect("content loading should succeed");
let transit = store.districts.get("krenn.sova.transit").unwrap();
// bar_regulars pool should have 5 candidates
let bar_regulars = transit
.pools
.iter()
.find(|p| p.pool_id == "transit:bar_regulars")
.expect("bar_regulars pool should exist");
assert_eq!(bar_regulars.candidates.len(), 5);
assert_eq!(bar_regulars.category, "npc_group");
}
#[test]
fn verify_template_role_slots() {
let root = content_root();
if !root.join("content.yaml").exists() {
return;
}
let store = load_content(&root).expect("content loading should succeed");
let transit = store.districts.get("krenn.sova.transit").unwrap();
// Logistics hub should have 5 role slots
let hub = transit
.templates
.iter()
.find(|t| t.template_id == "logistics-hub")
.expect("logistics-hub template should exist");
assert_eq!(hub.role_slots.len(), 5);
// Should have v01_assignments
assert!(hub.v01_assignments.is_some());
let assignments = hub.v01_assignments.as_ref().unwrap();
assert!(assignments.contains_key("shift-supervisor"));
}
// -----------------------------------------------------------------------
// Test: NPC spawning pipeline with test fixture data
// -----------------------------------------------------------------------
#[test]
fn spawn_npc_from_content_store() {
let mut world = World::new();
world.init_resource::<EntityRegistry>();
// Create a minimal content store with one test NPC
let mut store = ContentStore::default();
let mut district = settled_reach_server::content::loader::DistrictContent::default();
district.npc_profiles.push(NpcProfile {
canonical_id: "test-worker".to_string(),
display_name: "Test Worker".to_string(),
tier: 2,
pattern: Some("ANCHOR".to_string()),
motivation: Some("CIVILIAN".to_string()),
description: Some("A test dock worker".to_string()),
want: Some(NpcWant {
primary: "Safety".to_string(),
intensity: Some(5),
description: Some("Wants a quiet life".to_string()),
}),
secret: None,
relationships: vec![],
tolerance: Some(NpcTolerance {
threshold: Some(70),
description: None,
}),
routine: None,
information: None,
contentment: Some(NpcContentment {
level: Some(30),
description: None,
}),
personality: None,
tells: vec![],
skills: Some(NpcSkills {
combat_trained: Some(false),
skills: Some({
let mut m = BTreeMap::new();
m.insert("technical".to_string(), 5);
m
}),
}),
triangle_membership: vec![],
trust_levels: None,
friend_arc: None,
dual_lens: None,
});
store
.districts
.insert("test.district".to_string(), district);
let result = spawn_content(&mut world, &store);
// Verify entity was spawned
assert_eq!(result.npcs_spawned, 1);
assert!(result.npc_ids.contains_key("test-worker"));
// Verify ECS components
let stable_id = result.npc_ids["test-worker"];
let entity = world
.resource::<EntityRegistry>()
.to_entity(&stable_id)
.unwrap();
assert!(world.get::<npc::Npc>(entity).is_some());
let want = world.get::<npc::Want>(entity).unwrap();
assert_eq!(want.primary, npc::WantKind::Safety);
assert_eq!(want.intensity, 5);
let tolerance = world.get::<npc::ToleranceThreshold>(entity).unwrap();
assert_eq!(tolerance.threshold, 70);
let skills = world.get::<npc::SkillSet>(entity).unwrap();
assert_eq!(skills.skills[&npc::Skill::Technical], 5);
}
// -----------------------------------------------------------------------
// Test: ContentPlugin integration with bevy App
// -----------------------------------------------------------------------
#[test]
fn content_plugin_loads_via_app() {
let root = content_root();
if !root.join("content.yaml").exists() {
return;
}
let mut app = App::new();
app.add_plugins(SimulationPlugin);
app.add_plugins(settled_reach_server::npc::NpcPlugin);
app.insert_resource(ContentConfig {
content_root: root,
..Default::default()
});
app.add_plugins(ContentPlugin);
// Run startup systems
app.update();
// ContentStoreResource should be inserted
assert!(
app.world().contains_resource::<ContentStoreResource>(),
"ContentStoreResource should be present after startup"
);
let store = &app.world().resource::<ContentStoreResource>().0;
assert!(!store.districts.is_empty());
}
// -----------------------------------------------------------------------
// Test: Full spawn pipeline with real content — 10-axis gap closure
// -----------------------------------------------------------------------
#[test]
fn spawn_real_content_with_relationships_and_secrets() {
let root = content_root();
if !root.join("content.yaml").exists() {
return;
}
let mut world = World::new();
world.init_resource::<EntityRegistry>();
world.init_resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
let store = load_content(&root).expect("content loading should succeed");
let result = spawn_content(&mut world, &store);
// All 20 profiles should spawn
assert_eq!(result.npcs_spawned, 20);
assert!(result.npc_ids.contains_key("npc:kael-davan"));
assert!(result.npc_ids.contains_key("npc:voss"));
assert!(result.npc_ids.contains_key("npc:pc-smuggler"));
assert!(result.npc_ids.contains_key("npc:nils-davan"));
// Verify ALL 20 NPCs have Want components (Option C: exact enum keywords in YAML)
let registry = world.resource::<EntityRegistry>();
let mut npcs_with_want = 0;
for (canonical_id, stable_id) in &result.npc_ids {
let entity = registry
.to_entity(stable_id)
.unwrap_or_else(|| panic!("{} should have an entity", canonical_id));
assert!(
world.get::<npc::Want>(entity).is_some(),
"NPC {} should have a Want component",
canonical_id
);
npcs_with_want += 1;
}
assert_eq!(
npcs_with_want, 20,
"All 20 NPCs should have Want components"
);
// Spot-check specific Want values
let kael_entity = registry
.to_entity(&result.npc_ids["npc:kael-davan"])
.unwrap();
let kael_want = world
.get::<npc::Want>(kael_entity)
.expect("Kael should have Want");
assert_eq!(kael_want.primary, npc::WantKind::Safety);
// Verify Kael has a Secret component
let kael_secret = world
.get::<npc::Secret>(kael_entity)
.expect("Kael should have Secret");
assert!(kael_secret.description.contains("ring"));
assert_eq!(kael_secret.severity, npc::SecretSeverity::Major);
// Verify Kael has Relationships (7 defined in YAML)
let kael_rels = world
.get::<npc::Relationships>(kael_entity)
.expect("Kael should have Relationships");
assert!(
kael_rels.entries.len() >= 5,
"Kael should have at least 5 resolved relationships, got {}",
kael_rels.entries.len()
);
// Verify Kael has KnowledgeGraph (background facts from information.knows)
let kael_kg = world
.get::<settled_reach_server::knowledge::graph::KnowledgeGraph>(kael_entity)
.expect("Kael should have KnowledgeGraph");
assert!(
kael_kg.knows_fact(&settled_reach_server::knowledge::types::FactId(
"contraband.ring_exists".to_string()
))
);
// Verify global RelationshipGraph was populated
let graph = world.resource::<settled_reach_server::npc::relationships::RelationshipGraph>();
assert!(
graph.edge_count() >= 20,
"Expected at least 20 relationship edges, got {}",
graph.edge_count()
);
// Verify Nils (off-stage) also has correct data
let nils_entity = world
.resource::<EntityRegistry>()
.to_entity(&result.npc_ids["npc:nils-davan"])
.unwrap();
let nils_want = world
.get::<npc::Want>(nils_entity)
.expect("Nils should have Want");
assert_eq!(nils_want.primary, npc::WantKind::Power);
}
// -----------------------------------------------------------------------
// Test: Runtime validation — boot + tick 10 + snapshot (#489)
//
// Smoke test for production content. Boots the full plugin stack with real
// content over TCP, ticks 10 times, and asserts a valid ObserverSnapshot.
// Catches runtime panics from broken entity references, missing components,
// or content schema issues that pass YAML validation but fail at tick time.
// -----------------------------------------------------------------------
#[test]
fn content_runtime_boot_tick_10_snapshot() {
use settled_reach_server::bridge::framing::{read_framed, write_framed};
use settled_reach_server::bridge::tcp::TcpBridge;
use settled_reach_server::bridge::types::*;
use settled_reach_server::bridge::{BridgePlugin, BridgeResource};
use settled_reach_server::knowledge::KnowledgeGraph;
use settled_reach_server::perception::cognitive_delay::CognitiveDelay;
use settled_reach_server::perception::vision_cone::Facing;
use settled_reach_server::simulation::interaction::NearbyInteractionBuffer;
use settled_reach_server::simulation::listening::ListeningFocus;
use settled_reach_server::simulation::monologue::{
MonologueBuffer, MonologueState, SprintAnomalyQueue,
};
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown};
use std::io::{BufReader, BufWriter};
use std::net::{TcpListener, TcpStream};
use std::thread;
let root = content_root();
if !root.join("content.yaml").exists() {
eprintln!("Skipping: content directory not found at {:?}", root);
return;
}
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
let server_addr = listener.local_addr().expect("get local addr");
// Server thread: full plugin stack with real content
let server_handle = thread::spawn(move || {
let bridge = TcpBridge::accept_on(listener).expect("accept connection");
let mut app = App::new();
app.add_plugins(SimulationPlugin);
app.add_plugins(BridgePlugin);
app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin);
app.add_plugins(settled_reach_server::npc::NpcPlugin);
app.insert_resource(ContentConfig {
content_root: root,
..Default::default()
});
app.add_plugins(ContentPlugin);
app.insert_resource(BridgeResource::new(bridge));
app.insert_resource(WalkabilityMap::new(32, 32, 1));
// Spawn player with all required observer pipeline components
let profile = MovementProfile::smuggler();
app.world_mut().spawn((
PlayerCharacter,
TilePosition::new(16, 16, 0),
Facing::default(),
KnowledgeGraph::new(),
NearbyInteractionBuffer::default(),
MonologueState::default(),
MonologueBuffer::default(),
SprintAnomalyQueue::default(),
CognitiveDelay::default(),
ListeningFocus::new(TilePosition::new(16, 16, 0)),
profile,
profile.initial_stance(),
PlayerMoveCooldown::default(),
));
// Tick 10 times — any panic here means content has a runtime bug
for _ in 0..10 {
app.update();
}
});
// Client: connect and receive 10 snapshots
let stream = TcpStream::connect(server_addr).expect("client connect");
let mut reader = BufReader::new(stream.try_clone().expect("clone for reader"));
let mut writer = BufWriter::new(stream);
let mut last_snapshot = None;
for tick in 0..10 {
let payload = read_framed(&mut reader)
.unwrap_or_else(|e| panic!("read error at tick {}: {}", tick, e))
.unwrap_or_else(|| panic!("unexpected EOF at tick {}", tick));
let snapshot: ObserverSnapshot = rmp_serde::from_slice(&payload)
.unwrap_or_else(|e| panic!("deserialization error at tick {}: {}", tick, e));
last_snapshot = Some(snapshot);
// Send empty input for next tick
let empty: Vec<PlayerInput> = vec![];
let input_payload = rmp_serde::to_vec(&empty).expect("serialize empty input");
if let Err(_) = write_framed(&mut writer, &input_payload) {
// Server may have shut down after tick 10 — that's fine
break;
}
}
drop(reader);
drop(writer);
// Server thread must not have panicked
server_handle
.join()
.expect("server thread panicked — content triggered a runtime error during tick processing");
// Validate final snapshot
let snapshot = last_snapshot.expect("should have received at least one snapshot");
assert_eq!(
snapshot.version, PROTOCOL_VERSION,
"snapshot protocol version mismatch"
);
// Content-spawned NPCs should be visible (they all spawn at 0,0,0 by default)
// The player is at 16,16 — content NPCs are far away but the player entity itself
// should always be in the snapshot
assert!(
!snapshot.entities.is_empty(),
"snapshot should contain at least the player entity"
);
}