//! Integration tests for tell escalation on triangle activation (#589, D-024 axis 9). //! //! Covers: //! - D-027 criterion 4: RoutineDeviation tell must be observable after activation //! - D-024 axis 9: tell system is a simulation output, not authored content //! - #589: escalate_tells_on_activation system inserts RoutineDeviation on triangle NPCs //! //! Test structure: //! - Layer 1 (pure): verify RoutineDeviation dominates all other tells (already //! covered by unit tests in tell_state.rs, regression guards here) //! - Layer 2 (ECS world): verify activation event → RoutineDeviation insertion (pending #589) //! - Layer 2 (ECS world): verify expired RoutineDeviation is removed (pending #589) //! //! Pending tests are marked #[ignore] — they compile against the current API but //! will fail until escalate_tells_on_activation is registered in StorytellerPlugin. use bevy_app::prelude::*; use bevy_ecs::prelude::*; use settled_reach_server::{ npc::mood::MoodState, npc::{ tell_state::{DerivedTellState, TellCategory}, Contentment, DeviationTrigger, Npc, RoutineDeviation, Secret, SecretSeverity, ToleranceThreshold, }, simulation::tier::ActiveSim, }; // --------------------------------------------------------------------------- // Layer 1 regression: RoutineDeviation component presence → RoutineDeviation tell // (These pass today; guard against future tell priority regressions) // --------------------------------------------------------------------------- /// Build a minimal ECS world with one NPC and run derive_tell_state. fn make_tell_world_with_deviation(deviation: Option) -> (World, Entity) { let mut world = World::new(); let mut npc = world.spawn(( Npc, ActiveSim, Secret { description: "minor".into(), severity: SecretSeverity::Minor, known_by: vec![], }, ToleranceThreshold { current_stress: 0, threshold: 50, }, Contentment { level: 0 }, MoodState { mood: settled_reach_server::npc::mood::NpcMood::Neutral, changed_tick: 0, }, DerivedTellState::default(), )); let entity = if let Some(dev) = deviation { npc.insert(dev).id() } else { npc.id() }; (world, entity) } #[test] fn routine_deviation_component_produces_deviation_tell_via_system() { // Layer 1 regression: verify that inserting RoutineDeviation on an NPC and running // the derive_tell_state system produces TellCategory::RoutineDeviation. // This guards against priority regressions in derive_tell_state (D-027 criterion 4). let (mut world, entity) = make_tell_world_with_deviation(Some(RoutineDeviation { trigger: DeviationTrigger::WalkAway, tick: 0, expires_at_tick: 300, })); let mut schedule = Schedule::default(); schedule.add_systems(settled_reach_server::npc::tell_state::derive_tell_state); schedule.run(&mut world); let tell = world.get::(entity).unwrap(); assert_eq!( tell.category, Some(TellCategory::RoutineDeviation), "NPC with RoutineDeviation component must produce RoutineDeviation tell (D-027 criterion 4)" ); } #[test] fn no_deviation_component_does_not_produce_deviation_tell() { // Layer 1 regression: absence of RoutineDeviation must not produce deviation tell. let (mut world, entity) = make_tell_world_with_deviation(None); let mut schedule = Schedule::default(); schedule.add_systems(settled_reach_server::npc::tell_state::derive_tell_state); schedule.run(&mut world); let tell = world.get::(entity).unwrap(); assert_ne!( tell.category, Some(TellCategory::RoutineDeviation), "NPC without RoutineDeviation must not produce RoutineDeviation tell" ); } // --------------------------------------------------------------------------- // Layer 2: activation event → RoutineDeviation insertion (pending #589) // --------------------------------------------------------------------------- /// Set up a minimal gauntlet-based app with storyteller plugin running. #[cfg(feature = "gauntlet")] fn build_storyteller_app() -> App { use settled_reach_server::{ bridge::types::CharacterArchetype, simulation::SimulationPlugin, test_world, }; let mut app = App::new(); app.add_plugins(SimulationPlugin); test_world::setup_gauntlet(&mut app, CharacterArchetype::default()); app } /// Retrieve the first NPC entity visible in the gauntlet (used to fabricate test events). #[cfg(feature = "gauntlet")] fn first_npc_entity(app: &mut App) -> Entity { use settled_reach_server::npc::Npc; let mut q = app.world_mut().query_filtered::>(); q.iter(app.world()) .next() .expect("gauntlet must have at least one NPC") } #[cfg(feature = "gauntlet")] #[test] #[ignore = "pending escalate_tells_on_activation system (#589)"] 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::simulation::triangle::TriangleId; use settled_reach_server::storyteller::{TriangleActivatedEvent, TriangleActivatedQueue}; let mut app = build_storyteller_app(); // Run one tick so the world is fully initialized before we inject app.update(); let anchor = first_npc_entity(&mut app); { let mut queue = app.world_mut().resource_mut::(); queue.push(TriangleActivatedEvent { triangle_id: TriangleId::from_seed_and_slug(42, "test-escalation"), tick: 1, anchor_entity: anchor, anchor_score: 25.0, }); } // Run one tick — escalate_tells_on_activation should fire app.update(); let deviation = app.world().get::(anchor); assert!( deviation.is_some(), "Anchor NPC must have RoutineDeviation after TriangleActivated event (#589, D-024 axis 9)" ); } #[cfg(feature = "gauntlet")] #[test] #[ignore = "pending escalate_tells_on_activation system (#589)"] 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::simulation::triangle::TriangleId; use settled_reach_server::storyteller::{TriangleActivatedEvent, TriangleActivatedQueue}; let mut app = build_storyteller_app(); app.update(); // initialize let anchor = first_npc_entity(&mut app); { let mut queue = app.world_mut().resource_mut::(); queue.push(TriangleActivatedEvent { triangle_id: TriangleId::from_seed_and_slug(42, "test-tell"), tick: 1, anchor_entity: anchor, anchor_score: 25.0, }); } app.update(); // activation tick app.update(); // derive_tell_state tick let tell = app.world().get::(anchor); assert_eq!( tell.map(|t| t.category), Some(Some(TellCategory::RoutineDeviation)), "After triangle activation, anchor NPC's tell must be RoutineDeviation (D-027 criterion 4)" ); } #[cfg(feature = "gauntlet")] #[test] #[ignore = "pending expires_at_tick field and removal system (#589)"] fn routine_deviation_expires_after_duration() { // After TELL_ESCALATION_DURATION_TICKS ticks, RoutineDeviation must be removed // by the expiry system. This verifies the component doesn't persist forever. // // 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::simulation::triangle::TriangleId; use settled_reach_server::storyteller::{TriangleActivatedEvent, TriangleActivatedQueue}; // NOTE: TELL_ESCALATION_DURATION_TICKS constant (= 300) expected in storyteller module. // This test will need updating once the constant is public. let mut app = build_storyteller_app(); app.update(); // initialize let anchor = first_npc_entity(&mut app); { let mut queue = app.world_mut().resource_mut::(); queue.push(TriangleActivatedEvent { triangle_id: TriangleId::from_seed_and_slug(42, "test-expiry"), tick: 1, anchor_entity: anchor, anchor_score: 25.0, }); } // Run enough ticks to trigger expiry (301 > TELL_ESCALATION_DURATION_TICKS = 300). // Note: 302 updates is acceptable for a unit test; if this becomes a perf concern // when un-ignored, consider advancing SimulationTime directly instead of looping. for _ in 0..302 { app.update(); } let deviation = app.world().get::(anchor); assert!( deviation.is_none(), "RoutineDeviation must be removed after TELL_ESCALATION_DURATION_TICKS ticks (#589). \ Persistent deviation would flag NPCs permanently — breaking the tell signal over time." ); }