//! Content scaling test (#500, 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) //! //! Run with: cargo test --test content_scaling -- --nocapture use bevy_app::prelude::*; 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::KnowledgePlugin; 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]; /// 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 } /// 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" ); }