//! Integration tests for the triangle escalation system (#250). //! //! Covers the public API from a black-box perspective: //! - D-087: seed-dependent tension rates produce different 30-min arc timings //! - D-089: resolution does not cascade (only targeted triangle changes) //! - D-026: escalation only runs on Active-tier entities //! - D-031: escalation runs once per game-minute (every 10 ticks) //! //! These tests complement the lib unit tests in `src/content/template.rs` //! with integration-level coverage using the public crate API. use std::collections::BTreeMap; use bevy_ecs::{schedule::Schedule, world::World}; use settled_reach_server::{ knowledge::{registry::EntityRegistry, types::StableId, StableEntityId}, npc::ToleranceThreshold, simulation::triangle::{ apply_resolve_triangle, tick_triangle_escalation, ResolveTriangleCommand, ResolveTriangleQueue, TemplateId, TriangleClassification, TriangleCrisisEventQueue, TriangleDef, TriangleId, TrianglePhase, TriangleState, }, simulation::{tier::ActiveSim, time::SimulationTime}, }; // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- /// Minimal world with all resources required by `tick_triangle_escalation`. fn make_escalation_world() -> World { let mut world = World::new(); world.init_resource::(); world.init_resource::(); world.init_resource::(); world } /// Spawn an NPC with a known StableId and ToleranceThreshold. fn spawn_npc_with_threshold(world: &mut World, stable_id_val: u64, threshold: i16) -> StableId { let sid = StableId(stable_id_val); let entity = world .spawn(( ActiveSim, StableEntityId(sid), ToleranceThreshold { current_stress: 0, threshold, }, )) .id(); world .resource_mut::() .register_existing(entity, sid); sid } /// Spawn a triangle entity with the given state (ActiveSim marker included). fn spawn_triangle( world: &mut World, triangle_id: u64, tension: u8, tension_rate: u8, phase: TrianglePhase, role_assignments: BTreeMap, ) -> bevy_ecs::entity::Entity { world .spawn(( ActiveSim, TriangleState { triangle_id: TriangleId(triangle_id), role_assignments, tension, phase, tension_rate, template_id: TemplateId(1), classification: TriangleClassification::default(), }, )) .id() } /// Run the escalation schedule at a specific tick. fn run_at_tick(world: &mut World, schedule: &mut Schedule, tick: u64) { world.resource_mut::().tick = tick; schedule.run(world); } // --------------------------------------------------------------------------- // #250: Escalation happy path // --------------------------------------------------------------------------- /// D-031: escalation runs once per game-minute. 10 ticks = 1 game-minute. /// Tension should only increment on multiples of 10. #[test] fn escalation_only_fires_on_game_minute_boundaries() { let mut world = make_escalation_world(); let entity = spawn_triangle( &mut world, 1, 0, 5, TrianglePhase::Simmering, BTreeMap::new(), ); let mut schedule = Schedule::default(); schedule.add_systems(tick_triangle_escalation); // Ticks 1-9: not a game-minute, tension must not change. for tick in 1..10 { run_at_tick(&mut world, &mut schedule, tick); } assert_eq!( world.get::(entity).unwrap().tension, 0, "tension must not change on sub-minute ticks" ); // Tick 10: first game-minute, tension should increment. run_at_tick(&mut world, &mut schedule, 10); assert_eq!( world.get::(entity).unwrap().tension, 5, "tension must increment at tick 10 (first game-minute)" ); } /// Simmering → Active transition at the expected game-minute. /// /// Known setup: /// - tension_rate = 5, starting tension = 0 /// - Lowest NPC threshold = 25 /// - After 5 game-minutes (50 ticks): tension = 25, not > 25 → Simmering /// - After 6 game-minutes (60 ticks): tension = 30, 30 > 25 → Active #[test] fn simmering_transitions_to_active_at_expected_minute() { let mut world = make_escalation_world(); let npc_a = spawn_npc_with_threshold(&mut world, 1, 40); 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::simulation::triangle::RoleId; let mut assignments = BTreeMap::new(); assignments.insert(RoleId::new("role-a"), npc_a); assignments.insert(RoleId::new("role-b"), npc_b); assignments.insert(RoleId::new("role-c"), npc_c); let entity = spawn_triangle(&mut world, 42, 0, 5, TrianglePhase::Simmering, assignments); let mut schedule = Schedule::default(); schedule.add_systems(tick_triangle_escalation); // Run through 50 ticks (5 game-minutes): should remain Simmering. for tick in 1..=50 { run_at_tick(&mut world, &mut schedule, tick); } let state = world.get::(entity).unwrap(); assert_eq!( state.phase, TrianglePhase::Simmering, "after 5 game-minutes (tension=25), must still be Simmering (not > 25)" ); assert_eq!(state.tension, 25); // Run through tick 60 (6th game-minute): tension becomes 30, > 25 → Active. for tick in 51..=60 { run_at_tick(&mut world, &mut schedule, tick); } let state = world.get::(entity).unwrap(); assert_eq!( state.phase, TrianglePhase::Active, "at tick 60 (tension=30 > threshold=25), must transition to Active" ); assert_eq!(state.tension, 30); } /// D-087: different seeds produce different escalation timings. /// Verify that two triangles with different tension rates escalate at different times. #[test] fn d087_seed_dependent_escalation_timing() { // Triangle A: slower escalation (rate 2) // Triangle B: faster escalation (rate 8) // Both share same NPC threshold (30). // A triggers at: ceil(30 / 2) + 1 = 16th game-minute (tension hits 32 at minute 16) // B triggers at: ceil(30 / 8) + 1 = 5th game-minute (tension hits 32 at minute 4) let mut world = make_escalation_world(); let npc = spawn_npc_with_threshold(&mut world, 1, 30); use settled_reach_server::simulation::triangle::RoleId; let mut assignments = BTreeMap::new(); assignments.insert(RoleId::new("r"), npc); // Spawn as separate triangles. let slow = spawn_triangle( &mut world, 10, 0, 2, TrianglePhase::Simmering, assignments.clone(), ); let fast = spawn_triangle(&mut world, 20, 0, 8, TrianglePhase::Simmering, assignments); let mut schedule = Schedule::default(); schedule.add_systems(tick_triangle_escalation); // Run 40 game-minutes (400 ticks). for tick in 1..=400 { run_at_tick(&mut world, &mut schedule, tick); } // Both should be Active by 400 ticks. assert_eq!( world.get::(slow).unwrap().phase, TrianglePhase::Active ); assert_eq!( world.get::(fast).unwrap().phase, TrianglePhase::Active ); // Fast triangle should have activated earlier (higher tension accumulated faster). let fast_tension = world.get::(fast).unwrap().tension; let slow_tension = world.get::(slow).unwrap().tension; assert!( fast_tension > slow_tension, "fast triangle (rate=8) should have higher tension than slow (rate=2) after equal time" ); } /// The trigger NPC in the crisis event is the one with the lowest threshold. #[test] fn crisis_event_trigger_npc_is_lowest_threshold() { let mut world = make_escalation_world(); 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::simulation::triangle::RoleId; let mut assignments = BTreeMap::new(); assignments.insert(RoleId::new("r-high"), npc_high); assignments.insert(RoleId::new("r-low"), npc_low); // tension_rate = 11 so after 1 game-minute tension = 11 > 10. spawn_triangle(&mut world, 99, 0, 11, TrianglePhase::Simmering, assignments); let mut schedule = Schedule::default(); schedule.add_systems(tick_triangle_escalation); run_at_tick(&mut world, &mut schedule, 10); let queue = world.resource::(); assert_eq!(queue.events.len(), 1, "exactly one crisis event"); assert_eq!( queue.events[0].trigger_npc, npc_low, "trigger NPC must be the one with the lowest threshold" ); assert_eq!( queue.events[0].tick, 10, "crisis tick must match the game-minute" ); } /// No crisis event when tension hasn't exceeded the threshold. #[test] 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::simulation::triangle::RoleId; let mut assignments = BTreeMap::new(); assignments.insert(RoleId::new("r"), npc); spawn_triangle(&mut world, 1, 0, 5, TrianglePhase::Simmering, assignments); let mut schedule = Schedule::default(); schedule.add_systems(tick_triangle_escalation); run_at_tick(&mut world, &mut schedule, 10); let queue = world.resource::(); assert!( queue.is_empty(), "no crisis event when tension (5) < threshold (100)" ); } // --------------------------------------------------------------------------- // #250: Active phase behavior // --------------------------------------------------------------------------- /// Active triangle continues incrementing tension (narrative tracking). /// No additional crisis event emitted. #[test] fn active_triangle_continues_incrementing_no_new_event() { let mut world = make_escalation_world(); spawn_triangle(&mut world, 1, 50, 3, TrianglePhase::Active, BTreeMap::new()); let mut schedule = Schedule::default(); schedule.add_systems(tick_triangle_escalation); run_at_tick(&mut world, &mut schedule, 10); run_at_tick(&mut world, &mut schedule, 20); let queue = world.resource::(); assert!( queue.is_empty(), "no crisis event for already-Active triangle" ); } /// Active triangle tension saturates at u8::MAX (255). #[test] fn active_triangle_tension_saturates_at_u8_max() { let mut world = make_escalation_world(); spawn_triangle( &mut world, 1, 252, 10, TrianglePhase::Active, BTreeMap::new(), ); let mut schedule = Schedule::default(); schedule.add_systems(tick_triangle_escalation); run_at_tick(&mut world, &mut schedule, 10); // First call: 252 + 10 = 262, saturates to 255 let entity = world .query::() .iter(&world) .next() .unwrap(); // Can't query TriangleState after mutable borrow; check via resource // (we verify by spawning directly and checking post-run) let _ = entity; // entity used to ensure spawn worked // Re-run test cleanly let mut world2 = make_escalation_world(); let e2 = spawn_triangle( &mut world2, 2, 254, 50, TrianglePhase::Active, BTreeMap::new(), ); let mut sched2 = Schedule::default(); sched2.add_systems(tick_triangle_escalation); run_at_tick(&mut world2, &mut sched2, 10); let state = world2.get::(e2).unwrap(); assert_eq!(state.tension, 255, "tension saturates at u8::MAX"); } // --------------------------------------------------------------------------- // #250: D-026 tier boundary // --------------------------------------------------------------------------- /// Triangles without ActiveSim marker are NOT escalated (D-026 tier boundary). #[test] fn d026_non_active_tier_triangle_not_escalated() { let mut world = make_escalation_world(); world.resource_mut::().tick = 10; // Spawn WITHOUT ActiveSim. let entity = world .spawn(TriangleState { triangle_id: TriangleId(1), role_assignments: BTreeMap::new(), tension: 10, phase: TrianglePhase::Simmering, tension_rate: 5, template_id: TemplateId(1), classification: TriangleClassification::default(), }) .id(); let mut schedule = Schedule::default(); schedule.add_systems(tick_triangle_escalation); schedule.run(&mut world); assert_eq!( world.get::(entity).unwrap().tension, 10, "D-026: triangle without ActiveSim must not be escalated" ); } /// Dormant triangles are skipped even when in Active tier. #[test] fn dormant_triangle_not_escalated() { let mut world = make_escalation_world(); let entity = spawn_triangle( &mut world, 1, 0, 10, TrianglePhase::Dormant, BTreeMap::new(), ); let mut schedule = Schedule::default(); schedule.add_systems(tick_triangle_escalation); run_at_tick(&mut world, &mut schedule, 10); assert_eq!( world.get::(entity).unwrap().tension, 0, "Dormant triangle must not be escalated" ); } /// Resolved triangles are skipped (D-089: resolution is permanent). #[test] fn resolved_triangle_not_escalated() { let mut world = make_escalation_world(); let entity = spawn_triangle( &mut world, 1, 50, 5, TrianglePhase::Resolved, BTreeMap::new(), ); let mut schedule = Schedule::default(); schedule.add_systems(tick_triangle_escalation); run_at_tick(&mut world, &mut schedule, 10); assert_eq!( world.get::(entity).unwrap().tension, 50, "Resolved triangle must not be escalated (D-089)" ); } // --------------------------------------------------------------------------- // #250: Resolution (D-089) // --------------------------------------------------------------------------- /// ResolveTriangleCommand sets the target triangle to Resolved. #[test] fn resolve_command_sets_phase_to_resolved() { let mut world = World::new(); world.init_resource::(); let entity = world .spawn(TriangleState { triangle_id: TriangleId(100), role_assignments: BTreeMap::new(), tension: 50, phase: TrianglePhase::Active, tension_rate: 3, template_id: TemplateId(1), classification: TriangleClassification::default(), }) .id(); world .resource_mut::() .push(ResolveTriangleCommand(TriangleId(100))); let mut schedule = Schedule::default(); schedule.add_systems(apply_resolve_triangle); schedule.run(&mut world); let state = world.get::(entity).unwrap(); assert_eq!( state.phase, TrianglePhase::Resolved, "resolve command must set phase to Resolved" ); assert_eq!(state.tension, 50, "tension must not change on resolve"); } /// D-089: Resolution does NOT cascade to other triangles. #[test] fn d089_resolve_does_not_cascade() { let mut world = World::new(); world.init_resource::(); let target = world .spawn(TriangleState { triangle_id: TriangleId(100), role_assignments: BTreeMap::new(), tension: 50, phase: TrianglePhase::Active, tension_rate: 3, template_id: TemplateId(1), classification: TriangleClassification::default(), }) .id(); let bystander_a = world .spawn(TriangleState { triangle_id: TriangleId(200), role_assignments: BTreeMap::new(), tension: 20, phase: TrianglePhase::Simmering, tension_rate: 2, template_id: TemplateId(1), classification: TriangleClassification::default(), }) .id(); let bystander_b = world .spawn(TriangleState { triangle_id: TriangleId(300), role_assignments: BTreeMap::new(), tension: 80, phase: TrianglePhase::Active, tension_rate: 4, template_id: TemplateId(1), classification: TriangleClassification::default(), }) .id(); // Resolve only triangle 100. world .resource_mut::() .push(ResolveTriangleCommand(TriangleId(100))); let mut schedule = Schedule::default(); schedule.add_systems(apply_resolve_triangle); schedule.run(&mut world); assert_eq!( world.get::(target).unwrap().phase, TrianglePhase::Resolved ); assert_eq!( world.get::(bystander_a).unwrap().phase, TrianglePhase::Simmering, "D-089: bystander_a must remain Simmering" ); assert_eq!( world.get::(bystander_b).unwrap().phase, TrianglePhase::Active, "D-089: bystander_b must remain Active" ); } /// Resolving the same triangle twice is idempotent. #[test] fn resolve_twice_is_idempotent() { let mut world = World::new(); world.init_resource::(); let entity = world .spawn(TriangleState { triangle_id: TriangleId(100), role_assignments: BTreeMap::new(), tension: 30, phase: TrianglePhase::Active, tension_rate: 1, template_id: TemplateId(1), classification: TriangleClassification::default(), }) .id(); let mut schedule = Schedule::default(); schedule.add_systems(apply_resolve_triangle); world .resource_mut::() .push(ResolveTriangleCommand(TriangleId(100))); schedule.run(&mut world); world .resource_mut::() .push(ResolveTriangleCommand(TriangleId(100))); schedule.run(&mut world); assert_eq!( world.get::(entity).unwrap().phase, TrianglePhase::Resolved, "double-resolve must remain Resolved" ); } // --------------------------------------------------------------------------- // #250: Crisis event queue behavior // --------------------------------------------------------------------------- /// Crisis events accumulate in the queue until drained. #[test] fn crisis_events_accumulate_until_drained() { let mut world = make_escalation_world(); let npc = spawn_npc_with_threshold(&mut world, 1, 5); use settled_reach_server::simulation::triangle::RoleId; let mut assignments = BTreeMap::new(); assignments.insert(RoleId::new("r"), npc); // Two triangles that will both escalate. spawn_triangle( &mut world, 10, 0, 6, TrianglePhase::Simmering, assignments.clone(), ); spawn_triangle(&mut world, 20, 0, 6, TrianglePhase::Simmering, assignments); let mut schedule = Schedule::default(); schedule.add_systems(tick_triangle_escalation); run_at_tick(&mut world, &mut schedule, 10); let queue = world.resource::(); assert_eq!( queue.events.len(), 2, "both triangles should emit crisis events in the same game-minute" ); } /// `TriangleCrisisEventQueue::drain` clears the queue. #[test] fn crisis_queue_drain_clears_events() { let mut world = make_escalation_world(); let npc = spawn_npc_with_threshold(&mut world, 1, 5); use settled_reach_server::simulation::triangle::RoleId; let mut assignments = BTreeMap::new(); assignments.insert(RoleId::new("r"), npc); spawn_triangle(&mut world, 1, 0, 10, TrianglePhase::Simmering, assignments); let mut schedule = Schedule::default(); schedule.add_systems(tick_triangle_escalation); run_at_tick(&mut world, &mut schedule, 10); // Drain the queue. let drained = world.resource_mut::().drain(); assert_eq!(drained.len(), 1, "drain should return the 1 event"); assert!( world.resource::().is_empty(), "queue must be empty after drain" ); } // --------------------------------------------------------------------------- // #250: YAML triangle def → escalation pipeline // --------------------------------------------------------------------------- /// End-to-end: TriangleDef from YAML can describe all 5 v0.1 triangles /// (D-087) and those defs produce escalatable TriangleState instances. #[test] fn d087_all_v01_conflict_types_produce_escalatable_states() { use settled_reach_server::simulation::triangle::{ConflictType, NpcAxis, RoleId}; let defs = [ ( "kael-davan", "smuggler", "ring-contact", ConflictType::ResourceCompetition, ), ( "sera-venn", "detective", "commission-inspector", ConflictType::SecretExposure, ), ("naia", "kael-davan", "hael", ConflictType::LatentTension), ( "drin", "ring-system", "dock-supervisor", ConflictType::ResourceCompetition, ), ( "worried-partner", "ring-member", "neighbor", ConflictType::LatentTension, ), ]; for (r0, r1, r2, conflict) in &defs { let roles = [RoleId::new(r0), RoleId::new(r1), RoleId::new(r2)]; let tid = settled_reach_server::simulation::triangle::TriangleId::from_seed_and_roles(42, &roles); let def = TriangleDef { triangle_id: tid, roles: roles.clone(), conflict_type: *conflict, interest_axes: [NpcAxis::Want, NpcAxis::Secret, NpcAxis::Relationships], relationship_constraints: vec![], }; assert!( def.validate().is_ok(), "D-087 triangle must be valid: {:?}", def.validate() ); // Can construct a TriangleState from the def. let mut assignments = BTreeMap::new(); for role in &roles { assignments.insert(role.clone(), StableId(0)); } let state = TriangleState { triangle_id: tid, role_assignments: assignments, tension: 0, phase: TrianglePhase::Simmering, tension_rate: 3, template_id: TemplateId(1), classification: TriangleClassification::default(), }; assert_eq!( state.phase, TrianglePhase::Simmering, "{:?} triangle must start Simmering", conflict ); } }