Files
settled-reach/server/tests/content_scaling.rs
T
jpmschweitzerandClaude Opus 4.6 52eb8bfc81 fix(simulation): address PR #37 re-review — stale strings, test coverage, confrontation symmetry
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>
2026-02-18 18:22:32 +01:00

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"
);
}