Hoshe re-review (3 items): - content_scaling.rs:185: doc "StableId 0-51" → references constant - content_scaling.rs:256: assertion message "id<=51" → "id <= max_baseline_id" - input.rs: teleport test now asserts WalkAwayRequest + ConfrontationDelivered are cleared (was only checking TalkRequest + ActiveDialogue) Tyre re-review (2 items): - input.rs: same teleport test coverage (overlaps Hoshe #3) - dialogue.rs: process_confrontation_response now inserts RoutineDeviation with DeviationTrigger::Confrontation — symmetric with walk-away path. Test updated to verify deviation is recorded. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
260 lines
8.7 KiB
Rust
260 lines
8.7 KiB
Rust
//! 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::<EntityRegistry>()
|
|
.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::<EntityRegistry>();
|
|
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::<SnapshotBuffer>()
|
|
.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::<SnapshotBuffer>()
|
|
.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<u64> = baseline_snap
|
|
.entities
|
|
.iter()
|
|
.filter(|e| e.entity_id <= max_baseline_id)
|
|
.map(|e| e.entity_id)
|
|
.collect();
|
|
|
|
let scaled_original_ids: Vec<u64> = 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"
|
|
);
|
|
}
|