diff --git a/server/tests/triangle_escalation.rs b/server/tests/triangle_escalation.rs new file mode 100644 index 000000000..78007c317 --- /dev/null +++ b/server/tests/triangle_escalation.rs @@ -0,0 +1,613 @@ +//! 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::{ + content::template::{ + apply_resolve_triangle, tick_triangle_escalation, ResolveTriangleCommand, + ResolveTriangleQueue, TemplateId, TriangleCrisisEventQueue, TriangleDef, TriangleId, + TrianglePhase, TriangleState, + }, + knowledge::{registry::EntityRegistry, types::StableId, StableEntityId}, + npc::ToleranceThreshold, + 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), + }, + )) + .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::content::template::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::content::template::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::content::template::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::content::template::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), + }) + .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), + }) + .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), + }) + .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), + }) + .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), + }) + .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), + }) + .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::content::template::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::content::template::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::content::template::{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::content::template::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), + }; + assert_eq!( + state.phase, + TrianglePhase::Simmering, + "{:?} triangle must start Simmering", + conflict + ); + } +}