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:
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user