//! 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); 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::() .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::(); 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 { let registry = app.world().resource::(); let player_entity = registry .to_entity(&StableId(0)) .expect("player entity at StableId 0"); match app.world().get::(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::() .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::() .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 = baseline_snap .entities .iter() .filter(|e| e.entity_id <= max_baseline_id) .map(|e| e.entity_id) .collect(); let scaled_original_ids: Vec = 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 = 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::() .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::() .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 = baseline_snap .entities .iter() .filter(|e| e.entity_id <= max_gauntlet_id) .map(|e| e.entity_id) .collect(); let mut stress_ids: Vec = 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 ); }