Files
settled-reach/server/tests/content_scaling.rs
T
jpmschweitzerandClaude Opus 4.6 258b266f15 fix(simulation): address PR #39 review — 3 warnings + 6 suggestions
Warnings fixed:
- contraband.rs: scan event now always emits even when NPC already
  knows (was skipped by early `continue`). Contract matches doc.
- test_world/mod.rs: ScanEventBuffer added to player spawn bundle
  so check_contraband_scan doesn't silently no-op in gauntlet mode.
- npc/mod.rs → simulation/mod.rs: moved check_contraband_scan
  registration to SimulationPlugin (operates on player inventory and
  snapshot pipeline, consistent with process_talk_interaction).

Suggestions addressed:
- cross_room_transitions.rs T1: clarified standalone position vs
  constants.rs observer position in comment.
- dialogue.rs: Vec<&str> dedup replaced with BTreeSet<&str> for
  deterministic iteration (project convention).
- contraband.rs: added test for multiple simultaneous ScanAuthority
  NPCs in range (564 tests total).
- dialogue.rs: doc-comment on relationship_to_trust explaining
  KnowledgeConfidence ordering and Suspects default.
- cross_room_transitions.rs T5: noted direct KG API usage vs full
  perception system.
- sprint_gauntlet.rs: documented intentional Contentment { level: 0 }.
- content_scaling.rs: noted GAUNTLET_NPC_COUNT is manually maintained.
- contraband.rs: doc-comment on cross-plugin registration rationale.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:23:02 +01:00

524 lines
18 KiB
Rust

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