refactor(simulation): remove v0.1 content loading system (#655)

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>
This commit is contained in:
2026-03-13 09:07:57 +01:00
co-authored by Claude Sonnet 4.6
parent b979cab41e
commit ce0df2f320
39 changed files with 721 additions and 8943 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ use bevy_ecs::prelude::*;
use bevy_ecs::schedule::Schedule;
use std::collections::BTreeMap;
use settled_reach_server::content::template::{
use settled_reach_server::simulation::triangle::{
RoleId, TemplateId, TriangleClassification, TriangleId, TrianglePhase, TriangleState,
};
use settled_reach_server::knowledge::types::StableId;
-617
View File
@@ -1,617 +0,0 @@
//! 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.
//!
//! Runtime validation (TCP boot + tick) is in content_runtime.rs.
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"));
// 23 NPC profiles: 17 NPCs + 2 PC-as-NPC + 1 extended NPC (nils-davan) + 3 Sprint 14 additions
// Populated by #398 (wiki→YAML NPC conversion) and Sprint 14 content expansion
assert_eq!(
transit.npc_profiles.len(),
23,
"Expected 23 parseable NPC profiles"
);
}
#[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>();
world.init_resource::<settled_reach_server::knowledge::ContentEntityRegistry>();
// 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::knowledge::ContentEntityRegistry>();
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 23 profiles should spawn
assert_eq!(result.npcs_spawned, 23);
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, 23,
"All 23 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: EntanglementTag assignment from authored content (#176, D-029)
// -----------------------------------------------------------------------
#[test]
fn entanglement_tags_assigned_from_triangle_membership() {
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::knowledge::ContentEntityRegistry>();
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);
// Acceptance: 17+ NPCs spawn (we have 23 authored profiles)
assert!(
result.npcs_spawned >= 17,
"Expected 17+ NPCs, got {}",
result.npcs_spawned
);
let registry = world.resource::<EntityRegistry>();
// Every NPC must have an EntanglementTag
let mut intrigue_count = 0u32;
let mut flat_count = 0u32;
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));
let tag = world
.get::<npc::EntanglementTag>(entity)
.unwrap_or_else(|| panic!("{} must have EntanglementTag", canonical_id));
match tag {
npc::EntanglementTag::Intrigue => intrigue_count += 1,
npc::EntanglementTag::Flat => flat_count += 1,
npc::EntanglementTag::Mundane => {} // reserved for procedural NPCs
}
}
// Acceptance: at least one EntanglementTag::Intrigue entity
assert!(
intrigue_count >= 1,
"At least one NPC must be EntanglementTag::Intrigue, got 0"
);
// Stronger assertion: we know 13 authored NPCs have non-empty triangle_membership
assert!(
intrigue_count >= 10,
"Expected 10+ Intrigue NPCs (authored triangle members), got {}",
intrigue_count
);
// Some NPCs should be Flat (no triangle membership)
assert!(
flat_count >= 1,
"At least one NPC should be EntanglementTag::Flat, got 0"
);
// Spot-check: Kael (triangle member) must be Intrigue
let kael_entity = registry
.to_entity(&result.npc_ids["npc:kael-davan"])
.unwrap();
assert_eq!(
*world.get::<npc::EntanglementTag>(kael_entity).unwrap(),
npc::EntanglementTag::Intrigue,
"Kael (triangle member) must be Intrigue"
);
// Spot-check: Devra (empty triangle_membership) must be Flat
let devra_entity = registry
.to_entity(&result.npc_ids["npc:devra"])
.unwrap();
assert_eq!(
*world.get::<npc::EntanglementTag>(devra_entity).unwrap(),
npc::EntanglementTag::Flat,
"Devra (no triangle membership) must be Flat"
);
}
// -----------------------------------------------------------------------
// Test: Authored triangle instantiation (#188, D-087)
// -----------------------------------------------------------------------
#[test]
fn authored_triangles_instantiated_from_content() {
use settled_reach_server::content::template::{TriangleClassification, TriangleState};
use settled_reach_server::simulation::tier::ActiveSim;
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::knowledge::ContentEntityRegistry>();
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);
// Query all TriangleState entities (clone to release world borrow)
let triangles: Vec<TriangleState> = {
let mut q = world.query::<&TriangleState>();
q.iter(&world).cloned().collect()
};
// Acceptance: exactly 5 authored triangles
assert_eq!(
triangles.len(),
5,
"Expected 5 authored triangles, got {}",
triangles.len()
);
// Count by classification (D-087)
let active_count = triangles
.iter()
.filter(|t| t.classification == TriangleClassification::ActiveFork)
.count();
let passive_count = triangles
.iter()
.filter(|t| t.classification == TriangleClassification::PassiveTension)
.count();
assert_eq!(
active_count, 3,
"Expected 3 ActiveFork triangles, got {}",
active_count
);
assert_eq!(
passive_count, 2,
"Expected 2 PassiveTension triangles, got {}",
passive_count
);
// All 5 must have exactly 3 role assignments (triangle = 3 NPCs)
for triangle in &triangles {
assert_eq!(
triangle.role_assignments.len(),
3,
"Triangle {:?} should have 3 role assignments, got {}",
triangle.triangle_id,
triangle.role_assignments.len()
);
}
// All role assignments must point to valid NPC entities in the registry
let registry = world.resource::<EntityRegistry>();
for triangle in &triangles {
for (role, stable_id) in &triangle.role_assignments {
assert!(
registry.to_entity(stable_id).is_some(),
"Triangle {:?} role '{}' points to StableId {:?} with no entity",
triangle.triangle_id,
role.0,
stable_id,
);
}
}
// All triangle entities must have ActiveSim marker
let mut active_query = world.query::<(&TriangleState, &ActiveSim)>();
let active_triangles: Vec<_> = active_query.iter(&world).collect();
assert_eq!(
active_triangles.len(),
5,
"All 5 triangles must have ActiveSim marker"
);
// Verify StableIds in role assignments correspond to spawned NPC canonical_ids
let all_npc_stable_ids: std::collections::BTreeSet<_> =
result.npc_ids.values().copied().collect();
for triangle in &triangles {
for (role, stable_id) in &triangle.role_assignments {
assert!(
all_npc_stable_ids.contains(stable_id),
"Triangle {:?} role '{}' StableId {:?} not in spawned NPC set",
triangle.triangle_id,
role.0,
stable_id,
);
}
}
}
// Runtime validation test (boot + tick 10 + snapshot) moved to
// server/tests/content_runtime.rs per architectural review.
-162
View File
@@ -1,162 +0,0 @@
//! Runtime validation: boot full plugin stack with real content, tick 10
//! times over TCP, assert valid ObserverSnapshot (#489).
//!
//! Separated from content_loading.rs (structural loading tests) per
//! architectural review — TCP runtime tests have different failure modes
//! and timeout characteristics.
use bevy_app::prelude::*;
use std::net::{TcpListener, TcpStream};
use std::path::PathBuf;
use std::sync::{Arc, Barrier};
use std::thread;
use std::time::Duration;
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::content::{ContentConfig, ContentPlugin};
use settled_reach_server::knowledge::registry::EntityRegistry;
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 settled_reach_server::simulation::SimulationPlugin;
fn content_root() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest_dir).join("../content")
}
/// 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 std::io::{BufReader, BufWriter};
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");
// Barrier keeps the server thread alive until the client has finished
// reading all snapshots, preventing a TCP RST race under parallel execution.
let barrier = Arc::new(Barrier::new(2));
let server_barrier = barrier.clone();
// 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();
let mut registry = EntityRegistry::new(0);
let player = 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(),
))
.id();
registry.register(player);
app.insert_resource(registry);
// Tick 10 times — any panic here means content has a runtime bug
for _ in 0..10 {
app.update();
}
// Wait for client to finish reading before dropping the TCP socket
server_barrier.wait();
});
// Client: connect with read timeout and receive 10 snapshots
let stream = TcpStream::connect(server_addr).expect("client connect");
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.expect("set read timeout");
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 write_framed(&mut writer, &input_payload).is_err() {
// Server may have shut down after tick 10 — that's fine
break;
}
}
drop(reader);
drop(writer);
// Signal server thread that client is done reading
barrier.wait();
// 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"
);
}
-523
View File
@@ -1,523 +0,0 @@
//! Content scaling test (#513, D-026).
//!
//! Verifies that adding extra NPCs doesn't degrade tick timing beyond
//! acceptable bounds. Runs the Gauntlet baseline, then adds additional
//! NPCs and compares:
//! 1. Tick timing stays within D-026 budget (100ms)
//! 2. Baseline entities still behave identically (deterministic)
//!
//! Sprint 11 adds two new tests (#513 deliverable):
//! - max_npc_pack_tick_budget: 80 NPCs (D-026 Active tier ceiling), 100 ticks,
//! per-tick budget assertion (every tick < 100ms, not just average).
//! - max_npc_pack_behavioral_regression: verifies that adding 46 extra NPCs to
//! hit the Active tier ceiling doesn't change original entity behavior at tick 100.
//!
//! Run with: cargo test --test content_scaling -- --nocapture
use bevy_app::prelude::*;
use std::collections::BTreeMap;
use std::time::Instant;
use settled_reach_server::bridge::types::*;
use settled_reach_server::bridge::BridgePlugin;
use settled_reach_server::knowledge::registry::{EntityRegistry, StableEntityId};
use settled_reach_server::knowledge::{
KnowledgeConfidence, KnowledgeGraph, KnowledgePlugin, StableId,
};
use settled_reach_server::npc::{Contentment, Npc, NpcPlugin, ToleranceThreshold, Want, WantKind};
use settled_reach_server::simulation::interaction::Interactable;
use settled_reach_server::simulation::movement::TilePosition;
use settled_reach_server::simulation::path_follow::MovementSpeed;
use settled_reach_server::simulation::SimulationPlugin;
/// Number of ticks to run for timing measurements.
const TIMING_TICKS: usize = 50;
/// D-026 budget: 100ms per tick maximum.
const MAX_TICK_MS: f64 = 100.0;
/// Extra NPC counts for scaling tiers.
const EXTRA_NPC_COUNTS: &[usize] = &[0, 15, 50];
/// D-026 Active tier ceiling: maximum NPCs in full simulation.
const ACTIVE_TIER_NPC_CEILING: usize = 80;
/// Ticks for the full stress test (#513 spec: 100 ticks, 80 NPCs).
const STRESS_TICKS: usize = 100;
/// Known NPC count in the full Gauntlet world (all rooms, Sprint 11 included).
/// Fog Theater: 4, Occlusion Corridor: 4, Inventory Warehouse: 1, Pause Chamber: 1,
/// Dialogue Room: 4, Crowd Plaza: 15, Sprint Gauntlet: 1, Eavesdrop Alcove: 2,
/// Confrontation Stage: 2 = 34 total.
///
/// Manually maintained — update when rooms are added/changed. Future: derive
/// from StableId ranges in constants.rs to avoid manual sync.
const GAUNTLET_NPC_COUNT: usize = 34;
/// Extra NPCs to spawn on top of the Gauntlet baseline to reach Active tier ceiling.
const STRESS_EXTRA_NPCS: usize = ACTIVE_TIER_NPC_CEILING - GAUNTLET_NPC_COUNT;
/// Set up a Gauntlet world and return the app.
fn setup_baseline() -> App {
let mut app = App::new();
app.add_plugins(SimulationPlugin);
app.add_plugins(BridgePlugin);
app.add_plugins(KnowledgePlugin);
app.add_plugins(NpcPlugin);
#[cfg(feature = "gauntlet")]
settled_reach_server::test_world::setup_gauntlet(&mut app, settled_reach_server::bridge::types::CharacterArchetype::default());
app
}
/// Spawn N extra NPCs spread across the Gauntlet hub area.
/// NPCs are placed in a grid starting at (40, 48) to stay within walkable space.
fn spawn_extra_npcs(app: &mut App, count: usize) {
// Remove registry from world so we can mutate it while also spawning entities.
let mut registry = app
.world_mut()
.remove_resource::<EntityRegistry>()
.expect("EntityRegistry should exist after setup_gauntlet");
let cols = 10;
for i in 0..count {
let x = 40 + (i % cols) as i32;
let y = 48 + (i / cols) as i32;
let pos = TilePosition::new(x, y, 0);
let entity = app
.world_mut()
.spawn((
Npc,
Interactable,
pos,
Want {
primary: WantKind::Safety,
intensity: 5,
description: format!("extra_npc_{}", i),
},
Contentment { level: 0 },
ToleranceThreshold {
current_stress: 0,
threshold: 50,
},
MovementSpeed::default(),
))
.id();
let sid = registry.register(entity);
app.world_mut()
.entity_mut(entity)
.insert(StableEntityId(sid));
}
app.insert_resource(registry);
}
/// Tick the app N times and return average milliseconds per tick.
fn measure_tick_timing(app: &mut App, ticks: usize) -> f64 {
// Warm-up tick (first tick has startup overhead)
app.update();
let start = Instant::now();
for _ in 0..ticks {
app.update();
}
let elapsed = start.elapsed();
elapsed.as_secs_f64() * 1000.0 / ticks as f64
}
/// Collect snapshot entity IDs from the VisibilityGeometry and entity count.
fn count_entities(app: &App) -> usize {
let registry = app.world().resource::<EntityRegistry>();
registry.len() as usize
}
/// Collect the player's KnowledgeGraph confidence levels for all Gauntlet entities
/// (StableIds 0..=max_id). Used to detect KG-level behavioral regression.
#[cfg(feature = "gauntlet")]
fn player_kg_snapshot(app: &App, max_id: u64) -> BTreeMap<u64, KnowledgeConfidence> {
let registry = app.world().resource::<EntityRegistry>();
let player_entity = registry
.to_entity(&StableId(0))
.expect("player entity at StableId 0");
match app.world().get::<KnowledgeGraph>(player_entity) {
Some(kg) => kg
.entities
.iter()
.filter(|(id, _)| id.0 <= max_id)
.map(|(id, entry)| (id.0, entry.confidence))
.collect(),
None => BTreeMap::new(),
}
}
/// Baseline tick timing: Gauntlet with default entities stays within D-026 budget.
#[test]
#[cfg(feature = "gauntlet")]
fn baseline_tick_timing_within_budget() {
let mut app = setup_baseline();
let entity_count = count_entities(&app);
let avg_ms = measure_tick_timing(&mut app, TIMING_TICKS);
eprintln!(
"Baseline: {} entities, avg {:.3}ms/tick over {} ticks",
entity_count, avg_ms, TIMING_TICKS
);
assert!(
avg_ms < MAX_TICK_MS,
"Baseline tick timing ({:.3}ms) exceeds D-026 budget ({}ms)",
avg_ms,
MAX_TICK_MS
);
}
/// Scaling test: adding NPCs keeps tick timing within D-026 budget.
/// Tests 0 (baseline), 15, and 50 extra NPCs.
#[test]
#[cfg(feature = "gauntlet")]
fn scaling_tick_timing_within_budget() {
let mut results: Vec<(usize, usize, f64)> = Vec::new();
for &extra_count in EXTRA_NPC_COUNTS {
let mut app = setup_baseline();
if extra_count > 0 {
spawn_extra_npcs(&mut app, extra_count);
}
let total_entities = count_entities(&app);
let avg_ms = measure_tick_timing(&mut app, TIMING_TICKS);
results.push((extra_count, total_entities, avg_ms));
}
eprintln!(
"\n=== Content Scaling Results (D-026: {}ms budget) ===",
MAX_TICK_MS
);
eprintln!("{:<12} {:<10} {:<15}", "Extra NPCs", "Total", "Avg ms/tick");
eprintln!("{:-<37}", "");
for &(extra, total, avg_ms) in &results {
let status = if avg_ms < MAX_TICK_MS { "OK" } else { "OVER" };
eprintln!("{:<12} {:<10} {:<15.3} {}", extra, total, avg_ms, status);
}
// Assert all tiers stay within budget
for &(extra, _total, avg_ms) in &results {
assert!(
avg_ms < MAX_TICK_MS,
"Tick timing with +{} NPCs ({:.3}ms) exceeds D-026 budget ({}ms)",
extra,
avg_ms,
MAX_TICK_MS
);
}
// Assert scaling is reasonable: +50 NPCs shouldn't more than 5x the baseline
if results.len() >= 2 {
let baseline_ms = results[0].2;
let max_extra_ms = results.last().unwrap().2;
let scaling_factor = max_extra_ms / baseline_ms;
eprintln!(
"\nScaling factor (baseline → +{} NPCs): {:.2}x",
results.last().unwrap().0,
scaling_factor
);
assert!(
scaling_factor < 5.0,
"Scaling factor {:.2}x exceeds 5x threshold — possible O(n^2) regression",
scaling_factor
);
}
}
/// Determinism test: baseline entities produce identical snapshots regardless
/// of extra NPCs being present. The original Gauntlet entities (StableId 0
/// through RESET_PLATE_STABLE_IDS.1) should have the same positions and
/// visibility after the same number of ticks.
#[test]
#[cfg(feature = "gauntlet")]
fn extra_npcs_dont_affect_baseline_behavior() {
// Run baseline
let mut baseline_app = setup_baseline();
for _ in 0..10 {
baseline_app.update();
}
let baseline_buffer = baseline_app
.world()
.resource::<SnapshotBuffer>()
.snapshot
.clone();
// Run with extra NPCs
let mut scaled_app = setup_baseline();
spawn_extra_npcs(&mut scaled_app, 15);
for _ in 0..10 {
scaled_app.update();
}
let scaled_buffer = scaled_app
.world()
.resource::<SnapshotBuffer>()
.snapshot
.clone();
let baseline_snap = baseline_buffer.expect("baseline should produce a snapshot");
let scaled_snap = scaled_buffer.expect("scaled should produce a snapshot");
// Same tick
assert_eq!(
baseline_snap.tick, scaled_snap.tick,
"tick count should match"
);
// Same game time
assert_eq!(
baseline_snap.game_time.time_of_day, scaled_snap.game_time.time_of_day,
"game time should match"
);
// Player position should be identical
let baseline_player = baseline_snap
.entities
.iter()
.find(|e| e.kind == EntityKind::Player);
let scaled_player = scaled_snap
.entities
.iter()
.find(|e| e.kind == EntityKind::Player);
assert!(baseline_player.is_some(), "baseline should have player");
assert!(scaled_player.is_some(), "scaled should have player");
let bp = baseline_player.unwrap();
let sp = scaled_player.unwrap();
assert_eq!(bp.x, sp.x, "player x should match");
assert_eq!(bp.y, sp.y, "player y should match");
// Original entities (entity_id <= max Gauntlet StableId) visible in baseline
// should still be visible in scaled run. Extra NPCs may add to the visible
// set, but shouldn't remove baseline visibility.
let max_baseline_id = settled_reach_server::test_world::constants::RESET_PLATE_STABLE_IDS.1;
let baseline_original_ids: Vec<u64> = baseline_snap
.entities
.iter()
.filter(|e| e.entity_id <= max_baseline_id)
.map(|e| e.entity_id)
.collect();
let scaled_original_ids: Vec<u64> = scaled_snap
.entities
.iter()
.filter(|e| e.entity_id <= max_baseline_id)
.map(|e| e.entity_id)
.collect();
assert_eq!(
baseline_original_ids, scaled_original_ids,
"Original Gauntlet entities (id <= max_baseline_id) should be identical in both runs"
);
}
// =============================================================================
// Sprint 11 / #513 — Max-NPC Pack Stress Tests
// =============================================================================
/// Stress test: Active tier ceiling (80 NPCs), 100 ticks, per-tick budget check.
///
/// Spawns the full Gauntlet baseline ({GAUNTLET_NPC_COUNT} NPCs) plus
/// {STRESS_EXTRA_NPCS} extra NPCs to reach the D-026 Active tier ceiling (80).
/// Runs {STRESS_TICKS} ticks and asserts that EVERY individual tick (not just
/// the average) completes within the 100ms D-026 budget.
///
/// Outputs a PERF_RESULT JSON line compatible with the perf-baseline tooling
/// (same format as tests/perf/baseline.json) so CI can compare against the
/// stored baseline.
#[test]
#[cfg(feature = "gauntlet")]
fn max_npc_pack_tick_budget() {
let mut app = setup_baseline();
spawn_extra_npcs(&mut app, STRESS_EXTRA_NPCS);
let total_entities = count_entities(&app);
// Warm-up: first tick has bevy startup overhead.
app.update();
// Measure STRESS_TICKS, recording each tick individually.
let mut per_tick_us: Vec<u64> = Vec::with_capacity(STRESS_TICKS);
for _ in 0..STRESS_TICKS {
let start = Instant::now();
app.update();
per_tick_us.push(start.elapsed().as_micros() as u64);
}
// --- Statistics ---
let min_us = *per_tick_us.iter().min().unwrap();
let max_us = *per_tick_us.iter().max().unwrap();
let sum: u64 = per_tick_us.iter().sum();
let mean_us = sum / per_tick_us.len() as u64;
let mut sorted = per_tick_us.clone();
sorted.sort_unstable();
let p95_idx = ((sorted.len() - 1) as f64 * 0.95).floor() as usize;
let p95_us = sorted[p95_idx.min(sorted.len() - 1)];
eprintln!(
"\n=== Max-NPC Pack Stress Test — D-026 tick budget ({} NPCs, {} ticks) ===",
ACTIVE_TIER_NPC_CEILING, STRESS_TICKS
);
eprintln!(
"Entities in world: {} (Gauntlet NPCs: {} extra: {})",
total_entities, GAUNTLET_NPC_COUNT, STRESS_EXTRA_NPCS
);
eprintln!(
"Timing: min={:.3}ms mean={:.3}ms p95={:.3}ms max={:.3}ms budget={}ms",
min_us as f64 / 1000.0,
mean_us as f64 / 1000.0,
p95_us as f64 / 1000.0,
max_us as f64 / 1000.0,
MAX_TICK_MS
);
// Emit PERF_RESULT in the same format as tooling/perf-baseline so output
// can be diffed against tests/perf/baseline.json by CI tooling.
println!(
"PERF_RESULT:{}",
serde_json::json!({
"test": "max_npc_pack_tick_budget",
"spec": "D-026",
"tick_timing": {
"warmup_ticks": 1,
"measured_ticks": STRESS_TICKS,
"min_us": min_us,
"max_us": max_us,
"mean_us": mean_us,
"p95_us": p95_us,
},
"entities": {
"total_in_world": total_entities,
"active_tier_npcs": ACTIVE_TIER_NPC_CEILING,
"gauntlet_npcs": GAUNTLET_NPC_COUNT,
"extra_npcs": STRESS_EXTRA_NPCS,
},
})
);
// Core assertion: EVERY tick must be within the D-026 100ms budget.
// Average-only checks can mask spikes — verify each individual tick.
let budget_us = (MAX_TICK_MS * 1000.0) as u64;
let over_budget: Vec<(usize, u64)> = per_tick_us
.iter()
.enumerate()
.filter(|(_, &us)| us > budget_us)
.map(|(i, &us)| (i, us))
.collect();
assert!(
over_budget.is_empty(),
"D-026 tick budget exceeded with {} NPCs: {} of {} ticks over {}ms\n worst: tick {} at {:.3}ms",
ACTIVE_TIER_NPC_CEILING,
over_budget.len(),
STRESS_TICKS,
MAX_TICK_MS,
over_budget[0].0,
over_budget[0].1 as f64 / 1000.0
);
}
/// Behavioral regression: 80 NPCs must not disturb original entity state at tick 100.
///
/// Runs the pure Gauntlet (GAUNTLET_NPC_COUNT NPCs) and the full 80-NPC stress
/// pack for STRESS_TICKS ticks. Asserts:
/// 1. Snapshot entity IDs for all Gauntlet entities (StableId 0..=65) are identical.
/// 2. Player's KnowledgeGraph confidence entries for Gauntlet entity range are identical.
///
/// This validates D-010 determinism: extra Active-tier NPCs must not affect the
/// simulation of original entities via LOS, KG, or ECS phase ordering.
/// Spec: #513, D-026, D-010.
#[test]
#[cfg(feature = "gauntlet")]
fn max_npc_pack_behavioral_regression() {
use settled_reach_server::test_world::constants::SPRINT11_RESET_PLATE_STABLE_IDS;
// The highest StableId belonging to a Gauntlet entity (Sprint 11 reset plates).
let max_gauntlet_id = SPRINT11_RESET_PLATE_STABLE_IDS.1;
// --- Baseline run: pure Gauntlet, no extra NPCs ---
let mut baseline_app = setup_baseline();
for _ in 0..STRESS_TICKS {
baseline_app.update();
}
let baseline_snapshot = baseline_app
.world()
.resource::<SnapshotBuffer>()
.snapshot
.clone();
let baseline_kg = player_kg_snapshot(&baseline_app, max_gauntlet_id);
// --- Stress run: Gauntlet + extra NPCs to reach 80 NPC Active tier ceiling ---
let mut stress_app = setup_baseline();
spawn_extra_npcs(&mut stress_app, STRESS_EXTRA_NPCS);
for _ in 0..STRESS_TICKS {
stress_app.update();
}
let stress_snapshot = stress_app
.world()
.resource::<SnapshotBuffer>()
.snapshot
.clone();
let stress_kg = player_kg_snapshot(&stress_app, max_gauntlet_id);
let baseline_snap = baseline_snapshot.expect("baseline Gauntlet should produce a snapshot");
let stress_snap = stress_snapshot.expect("80-NPC stress run should produce a snapshot");
// Tick index must match (same number of updates).
assert_eq!(
baseline_snap.tick, stress_snap.tick,
"tick count should match between baseline and stress run"
);
// --- 1. Snapshot entity comparison ---
// Collect and sort entity IDs for original Gauntlet entities only.
// Extra NPCs (StableId > max_gauntlet_id) are excluded from comparison.
let mut baseline_ids: Vec<u64> = baseline_snap
.entities
.iter()
.filter(|e| e.entity_id <= max_gauntlet_id)
.map(|e| e.entity_id)
.collect();
let mut stress_ids: Vec<u64> = stress_snap
.entities
.iter()
.filter(|e| e.entity_id <= max_gauntlet_id)
.map(|e| e.entity_id)
.collect();
baseline_ids.sort_unstable();
stress_ids.sort_unstable();
assert_eq!(
baseline_ids, stress_ids,
"Gauntlet entity visibility at tick {} must be identical: baseline {} entities vs {} with {} extra NPCs",
STRESS_TICKS,
baseline_ids.len(),
stress_ids.len(),
STRESS_EXTRA_NPCS
);
// --- 2. Knowledge graph comparison ---
// Player's KG confidence levels for Gauntlet entities (StableId 0..=max_gauntlet_id)
// must be identical in both runs. Extra NPCs in the hub may be added to the
// player's KG (higher StableIds), but must not affect original entity entries.
assert_eq!(
baseline_kg, stress_kg,
"Player KG confidence entries for Gauntlet entities (id <= {}) differ at tick {}\n baseline: {} entries stress: {} entries",
max_gauntlet_id,
STRESS_TICKS,
baseline_kg.len(),
stress_kg.len()
);
eprintln!(
"Behavioral regression PASS: {} Gauntlet entities identical at tick {} ({} NPCs vs {} NPCs)",
baseline_ids.len(),
STRESS_TICKS,
GAUNTLET_NPC_COUNT,
ACTIVE_TIER_NPC_CEILING
);
}
+2 -3
View File
@@ -352,11 +352,10 @@ fn gauntlet_deterministic_replay() {
/// path (select_dialogue_line) which consumes SimRng.
#[test]
fn different_seed_produces_different_replay() {
use settled_reach_server::content::line_pool::{
use settled_reach_server::simulation::line_pool::{
AccessTier, IndexedDialogueLine, IndexedDialoguePool, LinePoolIndex, Mood, Situation,
TrustTier,
TrustTier, LinePoolIndexResource,
};
use settled_reach_server::content::LinePoolIndexResource;
use settled_reach_server::simulation::dialogue::{
CurrentMood, DialogueCooldownTracker, DialogueProfile, DialogueResponseBuffer,
};
+1 -1
View File
@@ -24,7 +24,7 @@ use settled_reach_server::{
},
};
use settled_reach_server::knowledge::KnowledgeGraph;
use settled_reach_server::content::template::TemplateReferenceMap;
use settled_reach_server::simulation::triangle::TemplateReferenceMap;
// ---------------------------------------------------------------------------
// Helpers
+3 -19
View File
@@ -8,7 +8,6 @@
use bevy_app::prelude::*;
use std::io::{BufReader, BufWriter};
use std::net::{TcpListener, TcpStream};
use std::path::PathBuf;
use std::thread;
use std::time::{Duration, Instant};
@@ -16,7 +15,7 @@ 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::content::{ContentConfig, ContentPlugin};
use settled_reach_server::simulation::line_pool::{LinePoolIndex, LinePoolIndexResource};
use settled_reach_server::knowledge::registry::EntityRegistry;
use settled_reach_server::knowledge::KnowledgeGraph;
use settled_reach_server::perception::cognitive_delay::CognitiveDelay;
@@ -34,10 +33,6 @@ const WARMUP_TICKS: usize = 5;
const MEASURE_TICKS: usize = 50;
const TOTAL_TICKS: usize = WARMUP_TICKS + MEASURE_TICKS;
fn content_root() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest_dir).join("../content")
}
fn read_rss_kb() -> Option<u64> {
std::fs::read_to_string("/proc/self/status")
@@ -64,17 +59,10 @@ fn read_rss_kb() -> Option<u64> {
#[test]
#[ignore]
fn perf_tick_timing() {
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, timed ticks
let server_root = root.clone();
// Server thread: full plugin stack, timed ticks
let server_handle = thread::spawn(move || -> Vec<Duration> {
let bridge = TcpBridge::accept_on(listener).expect("accept connection");
@@ -83,11 +71,7 @@ fn perf_tick_timing() {
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: server_root,
..Default::default()
});
app.add_plugins(ContentPlugin);
app.insert_resource(LinePoolIndexResource(LinePoolIndex::default()));
app.insert_resource(BridgeResource::new(bridge));
app.insert_resource(WalkabilityMap::new(32, 32, 1));
+3 -3
View File
@@ -128,7 +128,7 @@ fn triangle_activation_event_inserts_routine_deviation_on_anchor_npc() {
// Inject a TriangleActivatedEvent pointing to an NPC, run one tick, assert
// RoutineDeviation is inserted on that NPC by escalate_tells_on_activation.
use settled_reach_server::storyteller::{TriangleActivatedEvent, TriangleActivatedQueue};
use settled_reach_server::content::template::{TriangleId};
use settled_reach_server::simulation::triangle::{TriangleId};
let mut app = build_storyteller_app();
// Run one tick so the world is fully initialized before we inject
@@ -163,7 +163,7 @@ fn triangle_activation_produces_routine_deviation_tell_in_snapshot() {
// End-to-end: after activation event, DerivedTellState on anchor NPC must be
// TellCategory::RoutineDeviation. This verifies the full axis-9 pipeline.
use settled_reach_server::storyteller::{TriangleActivatedEvent, TriangleActivatedQueue};
use settled_reach_server::content::template::TriangleId;
use settled_reach_server::simulation::triangle::TriangleId;
let mut app = build_storyteller_app();
app.update(); // initialize
@@ -199,7 +199,7 @@ fn routine_deviation_expires_after_duration() {
// Edge case: D-027 criterion 4 must continue to fire DURING the window
// and stop firing AFTER it. NPCs shouldn't be permanently flagged.
use settled_reach_server::storyteller::{TriangleActivatedEvent, TriangleActivatedQueue};
use settled_reach_server::content::template::TriangleId;
use settled_reach_server::simulation::triangle::TriangleId;
// NOTE: TELL_ESCALATION_DURATION_TICKS constant (= 300) expected in storyteller module.
// This test will need updating once the constant is public.
-221
View File
@@ -1,221 +0,0 @@
//! End-to-end tests for the template instantiation engine (#161).
//!
//! Verifies the full pipeline:
//! load YAML → validate → spawn NPCs → generate triangles → lifecycle
//!
//! Spec refs:
//! - D-023: three-tier content model
//! - D-024: 10-axis NPC model, minimum 2 triangles per social site
//! - D-025: social site as atomic template unit, single-ownership
//! - D-010: determinism (same seed → same layout)
use std::path::PathBuf;
use bevy_ecs::prelude::*;
use settled_reach_server::{
content::{
instantiation::{
instantiate_template, load_template_from_file, unload_template,
ActiveTemplateInstances,
},
template::{TemplateId, TemplateOwnership, TriangleState},
},
knowledge::{registry::EntityRegistry, StableEntityId},
simulation::rng::SimRng,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn templates_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data/templates")
}
fn make_test_world() -> World {
let mut world = World::new();
world.init_resource::<EntityRegistry>();
world
}
// ---------------------------------------------------------------------------
// End-to-end: load logistics-hub YAML, instantiate, assert structure (#161)
// ---------------------------------------------------------------------------
#[test]
fn template_instantiation_end_to_end_logistics_hub() {
let path = templates_dir().join("logistics-hub.yaml");
let template_def =
load_template_from_file(&path).expect("logistics-hub.yaml must load and parse");
let mut world = make_test_world();
let template_id = TemplateId::from_seed_and_slug(42, "logistics-hub");
let mut rng = SimRng::new(42);
let instance = instantiate_template(&mut world, &template_def, template_id, 42, &mut rng)
.expect("logistics-hub must instantiate without validation errors");
// --- NPCs: all 4 role slots filled ---
assert_eq!(
instance.npc_entities.len(),
4,
"logistics-hub has 4 role slots — 4 NPC entities expected"
);
// --- TemplateOwnership on every NPC ---
let expected_roles = [
"logistics-manager",
"dock-worker",
"ring-contact",
"security-guard",
];
let mut seen_roles: Vec<String> = Vec::new();
for &entity in &instance.npc_entities {
let ownership = world
.get::<TemplateOwnership>(entity)
.expect("every spawned NPC must have TemplateOwnership");
assert_eq!(
ownership.template_id, template_id,
"TemplateOwnership.template_id must match the instantiated template"
);
let role = &ownership.role_id.0;
assert!(
expected_roles.contains(&role.as_str()),
"unexpected role '{}' — not in logistics-hub role list",
role,
);
seen_roles.push(role.clone());
}
// Every role slot must appear exactly once.
for role in &expected_roles {
assert_eq!(
seen_roles.iter().filter(|r| r.as_str() == *role).count(),
1,
"role '{}' must appear exactly once",
role,
);
}
// --- 2+ TriangleState entities (D-024 minimum) ---
assert!(
instance.triangle_entities.len() >= 2,
"logistics-hub must produce at least 2 TriangleState entities (D-024), got {}",
instance.triangle_entities.len(),
);
for &entity in &instance.triangle_entities {
assert!(
world.get::<TriangleState>(entity).is_some(),
"triangle entity {:?} must carry a TriangleState component",
entity,
);
}
// --- Instance registered in ActiveTemplateInstances ---
let active = world.resource::<ActiveTemplateInstances>();
assert!(
active.get(template_id).is_some(),
"instantiated template must be tracked in ActiveTemplateInstances",
);
}
// ---------------------------------------------------------------------------
// Lifecycle: unload despawns all entities
// ---------------------------------------------------------------------------
#[test]
fn template_instantiation_unload_despawns_entities() {
let path = templates_dir().join("logistics-hub.yaml");
let template_def = load_template_from_file(&path).expect("must parse");
let mut world = make_test_world();
let template_id = TemplateId::from_seed_and_slug(99, "logistics-hub");
let mut rng = SimRng::new(99);
let instance =
instantiate_template(&mut world, &template_def, template_id, 99, &mut rng)
.expect("must instantiate");
let all_entities: Vec<Entity> = instance
.npc_entities
.iter()
.chain(instance.triangle_entities.iter())
.cloned()
.collect();
assert!(!all_entities.is_empty(), "sanity: some entities were spawned");
unload_template(&mut world, template_id);
// All spawned entities must be gone.
for entity in &all_entities {
assert!(
world.get_entity(*entity).is_err(),
"entity {:?} must be despawned after unload_template",
entity,
);
}
// Instance removed from tracking.
let active = world.resource::<ActiveTemplateInstances>();
assert!(
active.get(template_id).is_none(),
"unloaded template must be removed from ActiveTemplateInstances",
);
}
// ---------------------------------------------------------------------------
// Determinism: same seed → same NPC StableId assignment (D-010)
// ---------------------------------------------------------------------------
#[test]
fn template_instantiation_is_deterministic() {
let path = templates_dir().join("logistics-hub.yaml");
let template_def = load_template_from_file(&path).expect("must parse");
let template_id = TemplateId::from_seed_and_slug(42, "logistics-hub");
let mut world1 = make_test_world();
let instance1 =
instantiate_template(&mut world1, &template_def, template_id, 42, &mut SimRng::new(42))
.expect("must instantiate");
let mut world2 = make_test_world();
let instance2 =
instantiate_template(&mut world2, &template_def, template_id, 42, &mut SimRng::new(42))
.expect("must instantiate");
// Collect (role → StableId) pairs from each world and compare.
let role_stable_ids = |world: &World, entities: &[Entity]| {
let mut pairs: Vec<(String, u64)> = entities
.iter()
.map(|&e| {
let role = world.get::<TemplateOwnership>(e).unwrap().role_id.0.clone();
let sid = world.get::<StableEntityId>(e).unwrap().0 .0;
(role, sid)
})
.collect();
pairs.sort();
pairs
};
let pairs1 = role_stable_ids(&world1, &instance1.npc_entities);
let pairs2 = role_stable_ids(&world2, &instance2.npc_entities);
assert_eq!(
pairs1, pairs2,
"instantiate_template must be deterministic (D-010): same seed → same layout"
);
}
// ---------------------------------------------------------------------------
// YAML loading: invalid path returns Err
// ---------------------------------------------------------------------------
#[test]
fn load_template_from_file_nonexistent_path_returns_err() {
let path = templates_dir().join("nonexistent-template-xyzzy.yaml");
let result = load_template_from_file(&path);
assert!(
result.is_err(),
"loading a nonexistent file must return Err"
);
}
-906
View File
@@ -1,906 +0,0 @@
//! Integration tests for the template schema system (tickets #163, #164, #165, #106, #159).
//!
//! Tests YAML round-trips, validation logic, and ECS component interactions
//! against the spec decisions:
//! - D-023: three-tier content model
//! - D-024: 10-axis NPC model, triangles as atomic unit
//! - D-025: social site / single-ownership model
//! - D-028: dialogue tagged pools
//! - D-087: v0.1 triangle configuration
//! - D-089: self-contained triangle forks, no cross-triangle cascade
//! - D-010: determinism (no HashMap, FNV-1a IDs)
use settled_reach_server::content::template::{
validate_role_schemas_no_duplicate_ids, ConflictType, CrossTemplateLinkSpec, FullTemplateDef,
NpcAxis, PrivacyLevel, RelationshipConstraint, RoleId, RoleSchema, SightlineZone, SpaceSpec,
TemplateDialoguePoolRef, TemplateId, TemplateOwnership, TemplateReference,
TemplateReferenceMap, TemplateRoutineEntry, TrafficPattern, TriangleDef, TriangleId,
TrustRange,
};
use settled_reach_server::npc::{PersonalityTrait, RelationshipKind, Skill};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn make_role_schema(id: &str) -> RoleSchema {
RoleSchema {
role_id: RoleId::new(id),
required_traits: vec![],
skill_focus: vec![],
relationship_constraints: vec![],
routine_template: vec![],
}
}
fn make_triangle(roles: [&str; 3], conflict: ConflictType) -> TriangleDef {
let role_arr = [
RoleId::new(roles[0]),
RoleId::new(roles[1]),
RoleId::new(roles[2]),
];
let triangle_id = TriangleId::from_seed_and_roles(42, &role_arr);
TriangleDef {
triangle_id,
roles: role_arr,
conflict_type: conflict,
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
relationship_constraints: vec![],
}
}
// ---------------------------------------------------------------------------
// #163: Role definition schema — YAML round-trips
// ---------------------------------------------------------------------------
#[test]
fn role_schema_minimal_yaml_parse() {
let yaml = r#"
role_id: "guard"
skill_focus:
- Combat
- Observation
"#;
let schema: RoleSchema = serde_yaml::from_str(yaml).expect("minimal schema must parse");
assert_eq!(schema.role_id, RoleId::new("guard"));
assert_eq!(schema.skill_focus.len(), 2);
assert!(schema.required_traits.is_empty());
assert!(schema.relationship_constraints.is_empty());
assert!(schema.routine_template.is_empty());
}
#[test]
fn role_schema_full_yaml_parse() {
let yaml = r#"
role_id: "dock-worker"
required_traits:
- Cautious
- Honest
skill_focus:
- Technical
- Observation
relationship_constraints:
- with_role: "ring-contact"
kind: Colleague
required_trust:
min: -2
max: 2
routine_template:
- phase: "morning"
location: "terminal-cargo-bay"
activity: "freight-handling"
- phase: "evening"
location: "bar-last-shift"
"#;
let schema: RoleSchema = serde_yaml::from_str(yaml).expect("full schema must parse");
assert_eq!(schema.role_id, RoleId::new("dock-worker"));
assert_eq!(schema.required_traits.len(), 2);
assert_eq!(schema.required_traits[0], PersonalityTrait::Cautious);
assert_eq!(schema.skill_focus.len(), 2);
assert_eq!(schema.relationship_constraints.len(), 1);
assert_eq!(
schema.relationship_constraints[0].with_role,
RoleId::new("ring-contact")
);
assert_eq!(schema.relationship_constraints[0].required_trust.min, -2);
assert_eq!(schema.relationship_constraints[0].required_trust.max, 2);
assert_eq!(schema.routine_template.len(), 2);
assert_eq!(schema.routine_template[0].phase, "morning");
assert_eq!(schema.routine_template[0].activity, Some("freight-handling".to_string()));
assert_eq!(schema.routine_template[1].activity, None);
}
#[test]
fn role_schema_yaml_roundtrip_preserves_all_fields() {
let schema = RoleSchema {
role_id: RoleId::new("ring-contact"),
required_traits: vec![PersonalityTrait::Deceptive, PersonalityTrait::Social],
skill_focus: vec![Skill::Stealth, Skill::Persuasion],
relationship_constraints: vec![
RelationshipConstraint {
with_role: RoleId::new("dock-worker"),
kind: RelationshipKind::Colleague,
required_trust: TrustRange { min: 0, max: 4 },
},
RelationshipConstraint {
with_role: RoleId::new("ring-leader"),
kind: RelationshipKind::Superior,
required_trust: TrustRange { min: 1, max: 4 },
},
],
routine_template: vec![
TemplateRoutineEntry {
phase: "morning".into(),
location: "terminal-cargo-bay".into(),
activity: Some("oversight".into()),
},
TemplateRoutineEntry {
phase: "evening".into(),
location: "maintenance-corridor".into(),
activity: None,
},
],
};
let yaml = serde_yaml::to_string(&schema).expect("serialize");
let restored: RoleSchema = serde_yaml::from_str(&yaml).expect("deserialize");
assert_eq!(restored.role_id, schema.role_id);
assert_eq!(restored.required_traits, schema.required_traits);
assert_eq!(restored.skill_focus, schema.skill_focus);
assert_eq!(
restored.relationship_constraints.len(),
schema.relationship_constraints.len()
);
assert_eq!(
restored.relationship_constraints[0].required_trust,
schema.relationship_constraints[0].required_trust
);
assert_eq!(restored.routine_template.len(), schema.routine_template.len());
assert_eq!(
restored.routine_template[0].activity,
schema.routine_template[0].activity
);
}
// ---------------------------------------------------------------------------
// #163: Role definition schema — validation
// ---------------------------------------------------------------------------
#[test]
fn self_referential_constraint_rejected() {
let schema = RoleSchema {
role_id: RoleId::new("dock-worker"),
required_traits: vec![],
skill_focus: vec![],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("dock-worker"), // same as role_id
kind: RelationshipKind::Colleague,
required_trust: TrustRange { min: 0, max: 4 },
}],
routine_template: vec![],
};
let result = schema.validate();
assert!(result.is_err(), "self-referential constraint must be rejected");
assert!(
result.unwrap_err().contains("self-referential"),
"error must mention self-referential"
);
}
#[test]
fn invalid_trust_range_rejected() {
let schema = RoleSchema {
role_id: RoleId::new("guard"),
required_traits: vec![],
skill_focus: vec![],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("captain"),
kind: RelationshipKind::Superior,
required_trust: TrustRange { min: 3, max: 1 }, // invalid: min > max
}],
routine_template: vec![],
};
let result = schema.validate();
assert!(result.is_err(), "TrustRange min > max must be rejected");
assert!(
result.unwrap_err().contains("trust min"),
"error must mention trust min"
);
}
#[test]
fn collection_with_duplicate_role_ids_rejected() {
let schemas = vec![
make_role_schema("dock-worker"),
make_role_schema("ring-contact"),
make_role_schema("dock-worker"), // duplicate
];
let result = validate_role_schemas_no_duplicate_ids(&schemas);
assert!(result.is_err(), "duplicate role_ids must be rejected");
let msg = result.unwrap_err();
assert!(msg.contains("dock-worker"), "error must name the duplicate: {}", msg);
}
#[test]
fn collection_with_unique_role_ids_ok() {
let schemas = vec![
make_role_schema("dock-worker"),
make_role_schema("ring-contact"),
make_role_schema("logistics-manager"),
];
assert!(validate_role_schemas_no_duplicate_ids(&schemas).is_ok());
}
// ---------------------------------------------------------------------------
// #164: Spatial requirement specification — YAML round-trips
// ---------------------------------------------------------------------------
#[test]
fn space_spec_minimal_yaml_parse() {
let yaml = r#"
tile_count_min: 30
tile_count_max: 80
privacy_level: Public
traffic_pattern: Thoroughfare
"#;
let spec: SpaceSpec = serde_yaml::from_str(yaml).expect("minimal SpaceSpec must parse");
assert_eq!(spec.tile_count_min, 30);
assert_eq!(spec.tile_count_max, 80);
assert_eq!(spec.privacy_level, PrivacyLevel::Public);
assert_eq!(spec.traffic_pattern, TrafficPattern::Thoroughfare);
assert!(spec.sightline_zones.is_empty());
}
#[test]
fn space_spec_full_yaml_parse() {
let yaml = r#"
tile_count_min: 30
tile_count_max: 80
sightline_zones:
- name: "bar-counter"
radius: 4
- name: "corner-booth"
radius: 2
privacy_level: SemiPrivate
traffic_pattern: Destination
"#;
let spec: SpaceSpec = serde_yaml::from_str(yaml).expect("full SpaceSpec must parse");
assert_eq!(spec.sightline_zones.len(), 2);
assert_eq!(spec.sightline_zones[0].name, "bar-counter");
assert_eq!(spec.sightline_zones[0].radius, 4);
assert_eq!(spec.sightline_zones[1].name, "corner-booth");
assert_eq!(spec.sightline_zones[1].radius, 2);
assert_eq!(spec.privacy_level, PrivacyLevel::SemiPrivate);
assert_eq!(spec.traffic_pattern, TrafficPattern::Destination);
}
/// D-025 scale assertion: 15-40 visual tiles = 30-80 sim tiles (D-066).
#[test]
fn space_spec_d025_tile_count_range() {
let spec = SpaceSpec {
tile_count_min: 30,
tile_count_max: 80,
sightline_zones: vec![],
privacy_level: PrivacyLevel::Public,
traffic_pattern: TrafficPattern::Destination,
};
assert!(spec.validate().is_ok(), "D-025 tile range (30-80 sim) must be valid");
}
#[test]
fn space_spec_validation_min_gt_max_fails() {
let spec = SpaceSpec {
tile_count_min: 100,
tile_count_max: 50,
sightline_zones: vec![],
privacy_level: PrivacyLevel::Private,
traffic_pattern: TrafficPattern::Restricted,
};
let result = spec.validate();
assert!(result.is_err(), "min > max must fail validation");
let msg = result.unwrap_err();
assert!(msg.contains("tile_count_min"), "error must mention tile_count_min: {}", msg);
}
#[test]
fn all_privacy_levels_yaml_roundtrip() {
for level in &[PrivacyLevel::Public, PrivacyLevel::SemiPrivate, PrivacyLevel::Private] {
let yaml = serde_yaml::to_string(level).unwrap();
let decoded: PrivacyLevel = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(level, &decoded, "{:?} must survive YAML round-trip", level);
}
}
#[test]
fn all_traffic_patterns_yaml_roundtrip() {
for pattern in &[
TrafficPattern::Thoroughfare,
TrafficPattern::Destination,
TrafficPattern::Restricted,
] {
let yaml = serde_yaml::to_string(pattern).unwrap();
let decoded: TrafficPattern = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(pattern, &decoded, "{:?} must survive YAML round-trip", pattern);
}
}
// ---------------------------------------------------------------------------
// #165: Single-ownership model — TemplateId determinism
// ---------------------------------------------------------------------------
#[test]
fn template_id_fnv1a_stable_across_calls() {
let id = TemplateId::from_seed_and_slug(0, "");
assert_eq!(
id,
TemplateId::from_seed_and_slug(0, ""),
"empty slug + seed 0 must be stable"
);
let id2 = TemplateId::from_seed_and_slug(42, "the-terminal");
assert_eq!(
id2,
TemplateId::from_seed_and_slug(42, "the-terminal"),
"non-empty slug must be stable"
);
}
#[test]
fn template_ownership_component_single_owner_invariant() {
// D-025: NPCs are owned by exactly one template, never reassigned.
let seed = 1u64;
let tid = TemplateId::from_seed_and_slug(seed, "terminal");
let rid = RoleId::new("dock-worker");
let ownership = TemplateOwnership { template_id: tid, role_id: rid.clone() };
assert_eq!(ownership.template_id, tid);
assert_eq!(ownership.role_id, rid);
// Clone (as would happen in save-state) must preserve values.
let cloned = ownership.clone();
assert_eq!(cloned.template_id, ownership.template_id);
assert_eq!(cloned.role_id, ownership.role_id);
}
#[test]
fn template_reference_map_preserves_links_on_unload() {
// D-025: reference links must be preserved when a template is unloaded.
let mut map = TemplateReferenceMap::default();
let tid_a = TemplateId::from_seed_and_slug(1, "template-a");
let tid_b = TemplateId::from_seed_and_slug(1, "template-b");
map.add(TemplateReference {
from_template: tid_a,
to_template: tid_b,
via_role: RoleId::new("ring-contact"),
relationship_metadata: RelationshipKind::Colleague,
});
// Simulate "unload template-a" by cloning (the save path).
let preserved = map.clone();
assert_eq!(preserved.outgoing(tid_a).len(), 1);
assert_eq!(preserved.outgoing(tid_a)[0].to_template, tid_b);
}
#[test]
fn template_reference_map_btreemap_deterministic_ordering() {
// D-010: BTreeMap ensures deterministic iteration order.
let mut map = TemplateReferenceMap::default();
let tid_high = TemplateId(u64::MAX - 1);
let tid_low = TemplateId(1);
map.add(TemplateReference {
from_template: tid_high,
to_template: tid_low,
via_role: RoleId::new("role-a"),
relationship_metadata: RelationshipKind::Colleague,
});
map.add(TemplateReference {
from_template: tid_low,
to_template: tid_high,
via_role: RoleId::new("role-b"),
relationship_metadata: RelationshipKind::Colleague,
});
// Collect all references via all_references() (deterministic BTreeMap order).
let all: Vec<&TemplateReference> = map.all_references().collect();
assert_eq!(all.len(), 2);
// First entry's from_template must be the lower ID (BTreeMap key order).
assert!(
all[0].from_template <= all[1].from_template,
"BTreeMap must iterate in ascending key order"
);
}
// ---------------------------------------------------------------------------
// #106: Triangle definition schema — YAML round-trips
// ---------------------------------------------------------------------------
#[test]
fn triangle_def_yaml_parse_with_computed_id() {
// TriangleId is stored in YAML but computed at world-gen time.
// Authors use 0 as placeholder; runtime overwrites with computed value.
let yaml = r#"
triangle_id: 0
roles:
- "ring-smuggler"
- "dock-worker"
- "operations-manager"
conflict_type: ResourceCompetition
interest_axes:
- Want
- Secret
- Relationships
"#;
let def: TriangleDef = serde_yaml::from_str(yaml).expect("TriangleDef must parse from YAML");
assert_eq!(def.triangle_id, TriangleId(0));
assert_eq!(def.roles[0], RoleId::new("ring-smuggler"));
assert_eq!(def.conflict_type, ConflictType::ResourceCompetition);
assert!(def.relationship_constraints.is_empty());
}
#[test]
fn triangle_def_yaml_parse_with_constraints() {
let yaml = r#"
triangle_id: 0
roles:
- "ring-leader"
- "dock-worker"
- "logistics-manager"
conflict_type: LoyaltyConflict
interest_axes:
- Relationships
- Secret
- Tolerance
relationship_constraints:
- with_role: "dock-worker"
kind: Subordinate
required_trust:
min: -2
max: 2
"#;
let def: TriangleDef =
serde_yaml::from_str(yaml).expect("TriangleDef with constraints must parse");
assert_eq!(def.conflict_type, ConflictType::LoyaltyConflict);
assert_eq!(def.relationship_constraints.len(), 1);
assert_eq!(def.relationship_constraints[0].with_role, RoleId::new("dock-worker"));
assert_eq!(def.relationship_constraints[0].kind, RelationshipKind::Subordinate);
}
#[test]
fn triangle_def_all_conflict_types_yaml_roundtrip() {
let conflict_types = [
ConflictType::ResourceCompetition,
ConflictType::LoyaltyConflict,
ConflictType::SecretExposure,
ConflictType::AuthorityChallenge,
ConflictType::LatentTension,
];
for ct in &conflict_types {
let yaml = serde_yaml::to_string(ct).unwrap();
let decoded: ConflictType = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(ct, &decoded, "{:?} must round-trip", ct);
}
}
#[test]
fn triangle_def_all_npc_axes_yaml_roundtrip() {
let axes = [
NpcAxis::Want,
NpcAxis::Secret,
NpcAxis::Relationships,
NpcAxis::Tolerance,
NpcAxis::Routine,
NpcAxis::InformationInventory,
NpcAxis::Contentment,
NpcAxis::PersonalityTraits,
NpcAxis::TellSystem,
NpcAxis::SkillSet,
];
for axis in &axes {
let yaml = serde_yaml::to_string(axis).unwrap();
let decoded: NpcAxis = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(axis, &decoded, "{:?} must round-trip", axis);
}
}
/// D-087: T1-T5 triangle configuration must be expressible in the schema.
#[test]
fn d087_v01_triangle_configurations_expressible() {
// T1: Kael-Smuggler-Ring (ResourceCompetition, active fork)
let t1 = make_triangle(
["kael-davan", "smuggler", "ring-contact"],
ConflictType::ResourceCompetition,
);
assert!(t1.validate().is_ok(), "T1 must be valid: {:?}", t1.validate());
// T2: Sera-Detective-Commission (SecretExposure, active fork)
let t2 = make_triangle(
["sera-venn", "detective", "commission-inspector"],
ConflictType::SecretExposure,
);
assert!(t2.validate().is_ok(), "T2 must be valid: {:?}", t2.validate());
// T4: Drin-System-Ring (ResourceCompetition, active fork per D-087)
let t4 = make_triangle(
["drin", "ring-system", "dock-supervisor"],
ConflictType::ResourceCompetition,
);
assert!(t4.validate().is_ok(), "T4 must be valid: {:?}", t4.validate());
// T3: passive tension (LatentTension variant per D-087)
let t3 = make_triangle(["naia", "kael-davan", "hael"], ConflictType::LatentTension);
assert!(t3.validate().is_ok(), "T3 passive tension must be valid: {:?}", t3.validate());
// T5: background worried partner (LatentTension variant)
let t5 = make_triangle(
["worried-partner", "ring-member", "neighbor"],
ConflictType::LatentTension,
);
assert!(t5.validate().is_ok(), "T5 passive tension must be valid: {:?}", t5.validate());
}
/// D-089: TriangleDef must not contain cross-triangle cascade state.
#[test]
fn d089_no_cross_triangle_cascade_fields() {
let def = make_triangle(["role-a", "role-b", "role-c"], ConflictType::ResourceCompetition);
let yaml = serde_yaml::to_string(&def).expect("serialize");
assert!(!yaml.contains("cascade"), "no cascade field should appear in serialized TriangleDef");
assert!(!yaml.contains("cross_triangle"), "no cross_triangle field should appear");
assert!(!yaml.contains("triggers"), "no triggers field should appear");
}
// ---------------------------------------------------------------------------
// #165: ECS integration — spawn two templates with cross-references
// ---------------------------------------------------------------------------
#[test]
fn ecs_two_templates_with_cross_references_and_ownerships() {
use bevy_ecs::world::World;
let seed = 999u64;
let tid_terminal = TemplateId::from_seed_and_slug(seed, "terminal-social-site");
let tid_bar = TemplateId::from_seed_and_slug(seed, "last-shift-bar");
let mut world = World::new();
world.init_resource::<TemplateReferenceMap>();
// Spawn 3 NPCs: 2 in terminal, 1 in bar.
let npc_logistics = world
.spawn(TemplateOwnership {
template_id: tid_terminal,
role_id: RoleId::new("logistics-manager"),
})
.id();
let npc_dock = world
.spawn(TemplateOwnership {
template_id: tid_terminal,
role_id: RoleId::new("dock-worker"),
})
.id();
let npc_bar_regular = world
.spawn(TemplateOwnership {
template_id: tid_bar,
role_id: RoleId::new("bar-regular"),
})
.id();
// Add cross-template reference: dock-worker at terminal references bar-regular at bar.
{
let mut ref_map = world.resource_mut::<TemplateReferenceMap>();
ref_map.add(TemplateReference {
from_template: tid_terminal,
to_template: tid_bar,
via_role: RoleId::new("dock-worker"),
relationship_metadata: RelationshipKind::Colleague,
});
}
// Verify all TemplateOwnership components are correct.
let own_logistics = world.get::<TemplateOwnership>(npc_logistics).unwrap();
assert_eq!(
own_logistics.template_id, tid_terminal,
"logistics-manager must be owned by terminal"
);
assert_eq!(own_logistics.role_id, RoleId::new("logistics-manager"));
let own_dock = world.get::<TemplateOwnership>(npc_dock).unwrap();
assert_eq!(
own_dock.template_id, tid_terminal,
"dock-worker must be owned by terminal"
);
assert_eq!(own_dock.role_id, RoleId::new("dock-worker"));
let own_bar = world.get::<TemplateOwnership>(npc_bar_regular).unwrap();
assert_eq!(own_bar.template_id, tid_bar, "bar-regular must be owned by bar");
// Verify TemplateReferenceMap entries.
let ref_map = world.resource::<TemplateReferenceMap>();
let terminal_refs = ref_map.outgoing(tid_terminal);
assert_eq!(terminal_refs.len(), 1, "terminal should have 1 cross-template reference");
assert_eq!(terminal_refs[0].to_template, tid_bar);
assert_eq!(terminal_refs[0].via_role, RoleId::new("dock-worker"));
// Bar template has no outgoing references.
assert!(
ref_map.outgoing(tid_bar).is_empty(),
"bar template has no outgoing references"
);
}
#[test]
fn template_ownership_survives_clone_for_save_state() {
// D-026: TemplateOwnership must be preserved when tier drops to State-saved.
let tid = TemplateId::from_seed_and_slug(42, "terminal");
let rid = RoleId::new("dock-worker");
let ownership = TemplateOwnership { template_id: tid, role_id: rid.clone() };
let saved = ownership.clone();
assert_eq!(saved, ownership, "TemplateOwnership must survive clone (save path)");
}
// ---------------------------------------------------------------------------
// #159: Full Tier 2 template document — FullTemplateDef
// ---------------------------------------------------------------------------
/// Build a minimal valid FullTemplateDef with two roles and two triangles.
fn minimal_full_template() -> FullTemplateDef {
FullTemplateDef {
slug: "test-site".to_string(),
display_name: "Test Social Site".to_string(),
description: None,
roles: vec![
RoleSchema {
role_id: RoleId::new("manager"),
required_traits: vec![PersonalityTrait::Cautious],
skill_focus: vec![Skill::Persuasion],
relationship_constraints: vec![RelationshipConstraint {
with_role: RoleId::new("worker"),
kind: RelationshipKind::Superior,
required_trust: TrustRange { min: 0, max: 5 },
}],
routine_template: vec![],
},
RoleSchema {
role_id: RoleId::new("worker"),
required_traits: vec![PersonalityTrait::Honest],
skill_focus: vec![Skill::Technical],
relationship_constraints: vec![],
routine_template: vec![],
},
RoleSchema {
role_id: RoleId::new("informant"),
required_traits: vec![PersonalityTrait::Deceptive],
skill_focus: vec![Skill::Stealth],
relationship_constraints: vec![],
routine_template: vec![],
},
],
space: SpaceSpec {
tile_count_min: 30,
tile_count_max: 80,
sightline_zones: vec![SightlineZone {
name: "main-floor".to_string(),
radius: 6,
}],
privacy_level: PrivacyLevel::SemiPrivate,
traffic_pattern: TrafficPattern::Destination,
},
triangles: vec![
TriangleDef {
triangle_id: TriangleId(0),
roles: [
RoleId::new("manager"),
RoleId::new("worker"),
RoleId::new("informant"),
],
conflict_type: ConflictType::ResourceCompetition,
interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships],
relationship_constraints: vec![],
},
TriangleDef {
triangle_id: TriangleId(0),
roles: [
RoleId::new("manager"),
RoleId::new("informant"),
RoleId::new("worker"),
],
conflict_type: ConflictType::LatentTension,
interest_axes: [NpcAxis::Tolerance, NpcAxis::Contentment, NpcAxis::Routine],
relationship_constraints: vec![],
},
],
dialogue_pools: vec![TemplateDialoguePoolRef {
location: "the-hub".to_string(),
roles: vec!["manager".to_string(), "worker".to_string()],
}],
cross_template_links: vec![CrossTemplateLinkSpec {
from_role: RoleId::new("worker"),
to_template_slug: "other-site".to_string(),
relationship: RelationshipKind::Colleague,
}],
}
}
#[test]
fn full_template_def_yaml_roundtrip() {
let template = minimal_full_template();
let yaml = serde_yaml::to_string(&template).expect("serialize FullTemplateDef");
let restored: FullTemplateDef =
serde_yaml::from_str(&yaml).expect("deserialize FullTemplateDef");
assert_eq!(restored.slug, template.slug);
assert_eq!(restored.display_name, template.display_name);
assert_eq!(restored.roles.len(), template.roles.len());
assert_eq!(restored.space.tile_count_min, template.space.tile_count_min);
assert_eq!(restored.triangles.len(), template.triangles.len());
assert_eq!(restored.dialogue_pools.len(), template.dialogue_pools.len());
assert_eq!(restored.cross_template_links.len(), template.cross_template_links.len());
// Role round-trip: traits, constraints, routine entries
let role = &restored.roles[0];
assert_eq!(role.role_id, RoleId::new("manager"));
assert_eq!(role.required_traits[0], PersonalityTrait::Cautious);
assert_eq!(role.relationship_constraints[0].with_role, RoleId::new("worker"));
// Triangle round-trip: roles, conflict type, axes
let tri = &restored.triangles[0];
assert_eq!(tri.conflict_type, ConflictType::ResourceCompetition);
assert_eq!(tri.roles[0], RoleId::new("manager"));
assert_eq!(tri.interest_axes[1], NpcAxis::Secret);
// Dialogue pool round-trip
assert_eq!(restored.dialogue_pools[0].location, "the-hub");
assert_eq!(restored.dialogue_pools[0].roles.len(), 2);
// Cross-template link round-trip
assert_eq!(
restored.cross_template_links[0].from_role,
RoleId::new("worker")
);
assert_eq!(
restored.cross_template_links[0].to_template_slug,
"other-site"
);
}
#[test]
fn full_template_def_validation_passes_for_valid_template() {
let template = minimal_full_template();
assert!(
template.validate().is_ok(),
"minimal valid template must pass: {:?}",
template.validate()
);
}
#[test]
fn full_template_def_validation_rejects_fewer_than_2_triangles() {
let mut template = minimal_full_template();
template.triangles.truncate(1);
let result = template.validate();
assert!(result.is_err(), "fewer than 2 triangles must fail");
assert!(
result.unwrap_err().contains("fewer than 2 triangles"),
"error must mention triangle count"
);
}
#[test]
fn full_template_def_validation_rejects_undefined_triangle_role() {
let mut template = minimal_full_template();
// Replace a triangle role with one not in the roles list
template.triangles[0].roles[2] = RoleId::new("ghost-role");
let result = template.validate();
assert!(result.is_err(), "undefined triangle role must fail validation");
assert!(
result.unwrap_err().contains("ghost-role"),
"error must name the undefined role"
);
}
#[test]
fn full_template_def_validation_rejects_duplicate_role_ids() {
let mut template = minimal_full_template();
template.roles.push(RoleSchema {
role_id: RoleId::new("manager"), // duplicate
required_traits: vec![],
skill_focus: vec![],
relationship_constraints: vec![],
routine_template: vec![],
});
let result = template.validate();
assert!(result.is_err(), "duplicate role_id must fail validation");
}
#[test]
fn full_template_def_optional_fields_default_on_minimal_yaml() {
// description, dialogue_pools, cross_template_links are all optional.
let yaml = r#"
slug: "bare-minimum"
display_name: "Bare Minimum Site"
roles:
- role_id: "alpha"
- role_id: "beta"
- role_id: "gamma"
space:
tile_count_min: 30
tile_count_max: 80
privacy_level: Public
traffic_pattern: Thoroughfare
triangles:
- triangle_id: 0
roles:
- "alpha"
- "beta"
- "gamma"
conflict_type: LatentTension
interest_axes:
- Contentment
- Tolerance
- Routine
- triangle_id: 0
roles:
- "alpha"
- "gamma"
- "beta"
conflict_type: ResourceCompetition
interest_axes:
- Want
- Secret
- Relationships
"#;
let def: FullTemplateDef = serde_yaml::from_str(yaml).expect("minimal YAML must parse");
assert_eq!(def.slug, "bare-minimum");
assert!(def.description.is_none());
assert!(def.dialogue_pools.is_empty());
assert!(def.cross_template_links.is_empty());
assert!(def.validate().is_ok(), "minimal template must validate: {:?}", def.validate());
}
/// Acceptance test: the authored logistics-hub.yaml round-trips through serde_yaml.
///
/// The file lives at `server/data/templates/logistics-hub.yaml`.
/// This test is the canonical acceptance criterion for ticket #159.
#[test]
fn logistics_hub_yaml_roundtrips_cleanly() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/data/templates/logistics-hub.yaml"
);
let raw = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("could not read logistics-hub.yaml: {}", e));
let def: FullTemplateDef = serde_yaml::from_str(&raw)
.unwrap_or_else(|e| panic!("logistics-hub.yaml failed to deserialize: {}", e));
// Structural assertions
assert_eq!(def.slug, "logistics-hub");
assert_eq!(def.roles.len(), 4, "logistics hub must define 4 roles");
assert_eq!(def.triangles.len(), 2, "logistics hub must define 2 triangles");
assert!(!def.dialogue_pools.is_empty(), "dialogue_pools must be present");
assert!(!def.cross_template_links.is_empty(), "cross_template_links must be present");
// Spatial spec assertions (D-025: 30–80 sim tiles)
assert!(def.space.validate().is_ok(), "space spec must validate");
assert_eq!(def.space.tile_count_min, 30);
assert_eq!(def.space.tile_count_max, 80);
// Validation must pass
assert!(
def.validate().is_ok(),
"logistics-hub.yaml must pass full validation: {:?}",
def.validate()
);
// Round-trip: serialize back to YAML then deserialize again
let reserialized = serde_yaml::to_string(&def).expect("re-serialize");
let restored: FullTemplateDef =
serde_yaml::from_str(&reserialized).expect("re-deserialize after round-trip");
assert_eq!(def.slug, restored.slug);
assert_eq!(def.roles.len(), restored.roles.len());
assert_eq!(def.triangles.len(), restored.triangles.len());
assert_eq!(def.dialogue_pools.len(), restored.dialogue_pools.len());
assert_eq!(def.cross_template_links.len(), restored.cross_template_links.len());
}
+10 -10
View File
@@ -13,7 +13,7 @@ use std::collections::BTreeMap;
use bevy_ecs::{schedule::Schedule, world::World};
use settled_reach_server::{
content::template::{
simulation::triangle::{
apply_resolve_triangle, tick_triangle_escalation, ResolveTriangleCommand,
ResolveTriangleQueue, TemplateId, TriangleClassification, TriangleCrisisEventQueue,
TriangleDef, TriangleId, TrianglePhase, TriangleState,
@@ -56,7 +56,7 @@ fn spawn_triangle(
tension: u8,
tension_rate: u8,
phase: TrianglePhase,
role_assignments: BTreeMap<settled_reach_server::content::template::RoleId, StableId>,
role_assignments: BTreeMap<settled_reach_server::simulation::triangle::RoleId, StableId>,
) -> bevy_ecs::entity::Entity {
world
.spawn((
@@ -135,7 +135,7 @@ fn simmering_transitions_to_active_at_expected_minute() {
let npc_b = spawn_npc_with_threshold(&mut world, 2, 25); // lowest
let npc_c = spawn_npc_with_threshold(&mut world, 3, 60);
use settled_reach_server::content::template::RoleId;
use settled_reach_server::simulation::triangle::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("role-a"), npc_a);
assignments.insert(RoleId::new("role-b"), npc_b);
@@ -185,7 +185,7 @@ fn d087_seed_dependent_escalation_timing() {
let npc = spawn_npc_with_threshold(&mut world, 1, 30);
use settled_reach_server::content::template::RoleId;
use settled_reach_server::simulation::triangle::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("r"), npc);
@@ -222,7 +222,7 @@ fn crisis_event_trigger_npc_is_lowest_threshold() {
let npc_high = spawn_npc_with_threshold(&mut world, 1, 50); // high tolerance
let npc_low = spawn_npc_with_threshold(&mut world, 2, 10); // low tolerance — trigger
use settled_reach_server::content::template::RoleId;
use settled_reach_server::simulation::triangle::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("r-high"), npc_high);
assignments.insert(RoleId::new("r-low"), npc_low);
@@ -249,7 +249,7 @@ fn no_crisis_event_below_threshold() {
let mut world = make_escalation_world();
let npc = spawn_npc_with_threshold(&mut world, 1, 100); // high threshold
use settled_reach_server::content::template::RoleId;
use settled_reach_server::simulation::triangle::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("r"), npc);
@@ -521,7 +521,7 @@ fn crisis_events_accumulate_until_drained() {
let npc = spawn_npc_with_threshold(&mut world, 1, 5);
use settled_reach_server::content::template::RoleId;
use settled_reach_server::simulation::triangle::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("r"), npc);
@@ -548,7 +548,7 @@ fn crisis_queue_drain_clears_events() {
let npc = spawn_npc_with_threshold(&mut world, 1, 5);
use settled_reach_server::content::template::RoleId;
use settled_reach_server::simulation::triangle::RoleId;
let mut assignments = BTreeMap::new();
assignments.insert(RoleId::new("r"), npc);
@@ -575,7 +575,7 @@ fn crisis_queue_drain_clears_events() {
/// (D-087) and those defs produce escalatable TriangleState instances.
#[test]
fn d087_all_v01_conflict_types_produce_escalatable_states() {
use settled_reach_server::content::template::{ConflictType, NpcAxis, RoleId};
use settled_reach_server::simulation::triangle::{ConflictType, NpcAxis, RoleId};
let defs = [
("kael-davan", "smuggler", "ring-contact", ConflictType::ResourceCompetition),
@@ -587,7 +587,7 @@ fn d087_all_v01_conflict_types_produce_escalatable_states() {
for (r0, r1, r2, conflict) in &defs {
let roles = [RoleId::new(r0), RoleId::new(r1), RoleId::new(r2)];
let tid = settled_reach_server::content::template::TriangleId::from_seed_and_roles(42, &roles);
let tid = settled_reach_server::simulation::triangle::TriangleId::from_seed_and_roles(42, &roles);
let def = TriangleDef {
triangle_id: tid,
roles: roles.clone(),
+1 -1
View File
@@ -9,7 +9,7 @@
//! `cargo test -p settled-reach-server -- triangle_validation`
use settled_reach_server::{
content::template::{
simulation::triangle::{
generate_cross_template_triangles, generate_intra_template_triangles,
validate_triangle_def, ConflictType, NpcAxis, RelationshipConstraint, RoleId,
TemplateId, TemplateOwnership, TriangleDef, TriangleId, TrianglePhase, TrustRange,