test(simulation): cross-room transition scenarios T1-T8 + test suite expansion (#506)
Cross-room transition tests (server/tests/cross_room_transitions.rs): - T1: sprint suppresses interaction buffer, restores on Walk (D-055) - T2: CarriedBy survives room transition — no TilePosition leak (D-065) - T3: pause mid-corridor discards movement, Unpause resumes (D-031) - T4: KnowledgeGraph persists across player position change (D-041) - T5: entity knowledge downgrades Direct→KnowsDetails on LOS exit (D-060) - T6: eavesdrop cut immediately on first movement out of corner (D-071) - T7: confrontation verb disappears on retreat beyond MID_RANGE=5 (D-057/D-070) - T8: Sprint blocks eavesdrop accumulation, Careful enables it (D-055+D-071) All 8 tests pass. Test suite grows from 545 → 563 (18 tests added across sprint). Tests use direct ECS World + Schedule pattern; T3 uses full App + SimulationPlugin. Test suite expansion: - content_scaling.rs: max_npc_pack_behavioral_regression + stress tests (#513) - golden/proof_room_tick_10.json: updated golden file for gauntlet world changes - golden_suite.rs, serialization.rs, bridge_ipc.rs, bridge_tcp.rs: adapted to new world entity count and wire types - gen_fixtures.rs, perf_bench.rs, content_runtime.rs: minor test adaptations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
-1
@@ -978,7 +978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.9"
|
||||
version = "0.1.10"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
|
||||
@@ -60,6 +60,7 @@ fn snapshot_roundtrip_over_unix_socket() {
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
};
|
||||
|
||||
bridge
|
||||
|
||||
@@ -46,6 +46,7 @@ fn snapshot_roundtrip_over_tcp() {
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
};
|
||||
|
||||
bridge
|
||||
|
||||
@@ -142,9 +142,9 @@ fn content_runtime_boot_tick_10_snapshot() {
|
||||
barrier.wait();
|
||||
|
||||
// Server thread must not have panicked
|
||||
server_handle
|
||||
.join()
|
||||
.expect("server thread panicked — content triggered a runtime error during tick processing");
|
||||
server_handle.join().expect(
|
||||
"server thread panicked — content triggered a runtime error during tick processing",
|
||||
);
|
||||
|
||||
// Validate final snapshot
|
||||
let snapshot = last_snapshot.expect("should have received at least one snapshot");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Content scaling test (#500, D-026).
|
||||
//! 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
|
||||
@@ -6,15 +6,22 @@
|
||||
//! 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::KnowledgePlugin;
|
||||
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;
|
||||
@@ -30,6 +37,21 @@ 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.
|
||||
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();
|
||||
@@ -106,6 +128,25 @@ fn count_entities(app: &App) -> usize {
|
||||
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")]
|
||||
@@ -144,7 +185,10 @@ fn scaling_tick_timing_within_budget() {
|
||||
results.push((extra_count, total_entities, avg_ms));
|
||||
}
|
||||
|
||||
eprintln!("\n=== Content Scaling Results (D-026: {}ms budget) ===", MAX_TICK_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 {
|
||||
@@ -215,7 +259,10 @@ fn extra_npcs_dont_affect_baseline_behavior() {
|
||||
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");
|
||||
assert_eq!(
|
||||
baseline_snap.tick, scaled_snap.tick,
|
||||
"tick count should match"
|
||||
);
|
||||
|
||||
// Same game time
|
||||
assert_eq!(
|
||||
@@ -224,8 +271,14 @@ fn extra_npcs_dont_affect_baseline_behavior() {
|
||||
);
|
||||
|
||||
// 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);
|
||||
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");
|
||||
|
||||
@@ -257,3 +310,211 @@ fn extra_npcs_dont_affect_baseline_behavior() {
|
||||
"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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
//! Cross-room transition test scenarios (T1-T8)
|
||||
//!
|
||||
//! Sprint 11 (#506) — system-combination tests at room boundaries.
|
||||
//! Each test exercises a bug class that emerges when two subsystems interact
|
||||
//! across a coordinate boundary (simulated by player position change).
|
||||
//!
|
||||
//! Tests use the ECS world setup pattern with direct system schedule execution
|
||||
//! — no ad-hoc test harness per sprint requirement.
|
||||
//!
|
||||
//! Decision refs:
|
||||
//! D-055 — sprint suppresses interaction buffer (T1, T8)
|
||||
//! D-065 — 9-slot inventory, CarriedBy component (T2)
|
||||
//! D-031 — pause/unpause, TickRate guard (T3)
|
||||
//! D-041 — KnowledgeGraph persistence across transitions (T4, T5)
|
||||
//! D-060 — cognitive delay, entity recognition persistence (T5)
|
||||
//! D-071 — ListeningFocus eavesdrop positioning (T6, T8)
|
||||
//! D-070 — confrontation as cognitive vulnerability, verb range (T7)
|
||||
//! D-057 — verb computation, interaction range transitions (T7)
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use bevy_ecs::schedule::Schedule;
|
||||
use settled_reach_server::bridge::types::{MovementStance, VerbKind};
|
||||
use settled_reach_server::knowledge::registry::EntityRegistry;
|
||||
use settled_reach_server::knowledge::KnowledgeGraph;
|
||||
use settled_reach_server::npc::Npc;
|
||||
use settled_reach_server::simulation::interaction::{
|
||||
compute_nearby_interactions, Interactable, NearbyInteractionBuffer, ObjectType,
|
||||
};
|
||||
use settled_reach_server::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
|
||||
use settled_reach_server::simulation::listening::{
|
||||
update_listening_focus, ListeningFocus, EAVESDROP_THRESHOLD, EAVESDROP_THRESHOLD_CAREFUL,
|
||||
};
|
||||
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use settled_reach_server::simulation::stance::Stance;
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// Minimal world with EntityRegistry (no App — single-system schedule tests).
|
||||
fn setup_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world
|
||||
}
|
||||
|
||||
fn run_interaction_system(world: &mut World) {
|
||||
let mut sched = Schedule::default();
|
||||
sched.add_systems(compute_nearby_interactions);
|
||||
sched.run(world);
|
||||
}
|
||||
|
||||
fn run_listening_system(world: &mut World) {
|
||||
let mut sched = Schedule::default();
|
||||
sched.add_systems(update_listening_focus);
|
||||
sched.run(world);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// T1: Sprint Exit — buffer clears during sprint, restores on Walk
|
||||
// D-055, Sprint Gauntlet room
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// T1 — Sprint Exit.
|
||||
///
|
||||
/// Player at Sprint Gauntlet observer position (4, 12 absolute) with Walk
|
||||
/// stance. A Readable sign at (6, 12) is within CLOSE_RANGE (distance 2).
|
||||
///
|
||||
/// During Walk: sign appears in interaction buffer.
|
||||
/// During Sprint: buffer is empty (D-055 suppression).
|
||||
/// After stance returns to Walk: buffer repopulates within one compute cycle.
|
||||
///
|
||||
/// This covers the cross-room exit behaviour: player sprinting out of the
|
||||
/// Sprint Gauntlet loses all interaction context while in sprint.
|
||||
#[test]
|
||||
fn t1_sprint_suppresses_buffer_and_restores_on_walk() {
|
||||
let mut world = setup_world();
|
||||
|
||||
// Player at Sprint Gauntlet observer absolute position (ORIGIN_X=0, ORIGIN_Y=2,
|
||||
// rel observer (4,10) → abs (4,12)), Walk stance.
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(4, 12, 0),
|
||||
NearbyInteractionBuffer::default(),
|
||||
Stance(MovementStance::Walk),
|
||||
))
|
||||
.id();
|
||||
|
||||
// Readable sign at (6, 12) abs — distance 2 from player (CLOSE_RANGE=2).
|
||||
// Mirrors sprint_gauntlet.rs sign entity (StableId 56).
|
||||
let sign = world
|
||||
.spawn((
|
||||
TilePosition::new(6, 12, 0),
|
||||
Interactable,
|
||||
ObjectType::Readable,
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(sign);
|
||||
|
||||
// --- Walk: sign appears in buffer ---
|
||||
run_interaction_system(&mut world);
|
||||
let interactions = world
|
||||
.get_mut::<NearbyInteractionBuffer>(player)
|
||||
.unwrap()
|
||||
.take();
|
||||
assert!(
|
||||
!interactions.is_empty(),
|
||||
"T1 Walk: sign at distance 2 must appear in interaction buffer"
|
||||
);
|
||||
|
||||
// --- Sprint: buffer suppressed (D-055) ---
|
||||
world.get_mut::<Stance>(player).unwrap().0 = MovementStance::Sprint;
|
||||
run_interaction_system(&mut world);
|
||||
let interactions = world
|
||||
.get_mut::<NearbyInteractionBuffer>(player)
|
||||
.unwrap()
|
||||
.take();
|
||||
assert!(
|
||||
interactions.is_empty(),
|
||||
"T1 Sprint: interaction buffer must be empty (D-055 sprint suppression)"
|
||||
);
|
||||
|
||||
// --- Walk again: buffer repopulates within one compute cycle ---
|
||||
world.get_mut::<Stance>(player).unwrap().0 = MovementStance::Walk;
|
||||
run_interaction_system(&mut world);
|
||||
let interactions = world
|
||||
.get_mut::<NearbyInteractionBuffer>(player)
|
||||
.unwrap()
|
||||
.take();
|
||||
assert!(
|
||||
!interactions.is_empty(),
|
||||
"T1 Walk after Sprint: interaction buffer must repopulate"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// T2: Inventory Carry — CarriedBy survives room transition
|
||||
// D-065, Inventory Warehouse → Crowd Plaza
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// T2 — Inventory Interact.
|
||||
///
|
||||
/// Player picks up an item (CarriedBy set, TilePosition removed).
|
||||
/// Player moves to a new coordinate region (simulates room transition).
|
||||
///
|
||||
/// Asserts: CarriedBy still references player, item has no TilePosition.
|
||||
/// The information boundary (D-010 principle 2) holds across coordinates:
|
||||
/// a carried item is never "in" the new room until explicitly placed.
|
||||
#[test]
|
||||
fn t2_carried_item_survives_room_transition() {
|
||||
let mut world = setup_world();
|
||||
|
||||
// Player at Inventory Warehouse observer position (abs 17, 54).
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(17, 54, 0)))
|
||||
.id();
|
||||
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
// Item already in inventory (no TilePosition — it has been taken).
|
||||
let item = world
|
||||
.spawn((
|
||||
CarriedBy(player_sid),
|
||||
ItemName("Manifest Copy".into()),
|
||||
InventorySlot(0),
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(item);
|
||||
|
||||
// Pre-transition invariants.
|
||||
assert!(
|
||||
world.get::<TilePosition>(item).is_none(),
|
||||
"T2 pre: carried item must not have TilePosition"
|
||||
);
|
||||
assert_eq!(
|
||||
world.get::<CarriedBy>(item).unwrap().0,
|
||||
player_sid,
|
||||
"T2 pre: CarriedBy must reference player"
|
||||
);
|
||||
|
||||
// Simulate room transition: player moves to Crowd Plaza observer position.
|
||||
*world.get_mut::<TilePosition>(player).unwrap() = TilePosition::new(96, 94, 0);
|
||||
|
||||
// Post-transition: item state is unchanged by the player's position change.
|
||||
assert!(
|
||||
world.get::<TilePosition>(item).is_none(),
|
||||
"T2 post: item must still have no TilePosition (still carried)"
|
||||
);
|
||||
assert_eq!(
|
||||
world.get::<CarriedBy>(item).unwrap().0,
|
||||
player_sid,
|
||||
"T2 post: CarriedBy must still reference player after movement"
|
||||
);
|
||||
assert_eq!(
|
||||
world.get::<InventorySlot>(item).unwrap().0,
|
||||
0,
|
||||
"T2 post: InventorySlot must be unchanged after room transition"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// T3: Pause Anywhere — mid-corridor pause discards movement
|
||||
// D-031
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// T3 — Pause Anywhere.
|
||||
///
|
||||
/// Player at a corridor position between two rooms (corridor-N midpoint
|
||||
/// between Hub and Fog Theater, ~abs (50, 39)).
|
||||
/// Pause → movement discarded. Unpause → movement accepted.
|
||||
///
|
||||
/// Tests that pause state is position-agnostic: the pause guard fires
|
||||
/// regardless of whether the player is inside a room or between rooms.
|
||||
#[test]
|
||||
fn t3_pause_mid_corridor_discards_movement_and_resumes() {
|
||||
use bevy_app::prelude::*;
|
||||
use settled_reach_server::bridge::types::{PlayerAction, PlayerInput};
|
||||
use settled_reach_server::simulation::input::InputQueue;
|
||||
use settled_reach_server::simulation::time::{SimulationTime, TickRate};
|
||||
use settled_reach_server::simulation::SimulationPlugin;
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(SimulationPlugin);
|
||||
// 200×200 walkability map covers the full gauntlet coordinate space.
|
||||
app.insert_resource(WalkabilityMap::new(200, 200, 1));
|
||||
|
||||
// Player at corridor-N midpoint (between Hub at y≈46 and Fog Theater at y≈2).
|
||||
let player = app
|
||||
.world_mut()
|
||||
.spawn((PlayerCharacter, TilePosition::new(50, 39, 0)))
|
||||
.id();
|
||||
|
||||
// --- Step 1: Pause (tick 0) ---
|
||||
app.world_mut()
|
||||
.resource_mut::<InputQueue>()
|
||||
.push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Pause,
|
||||
});
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world().resource::<SimulationTime>().tick_rate,
|
||||
TickRate::Paused,
|
||||
"T3 step 1: game must be paused"
|
||||
);
|
||||
// Paused — advance_tick does not fire; tick stays at 0.
|
||||
assert_eq!(app.world().resource::<SimulationTime>().tick, 0);
|
||||
|
||||
// --- Step 2: MoveNorth while paused (tick 0) — must be discarded ---
|
||||
app.world_mut()
|
||||
.resource_mut::<InputQueue>()
|
||||
.push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
*app.world().get::<TilePosition>(player).unwrap(),
|
||||
TilePosition::new(50, 39, 0),
|
||||
"T3 step 2: player position must be unchanged while paused"
|
||||
);
|
||||
|
||||
// --- Step 3: Unpause (tick 0) ---
|
||||
app.world_mut()
|
||||
.resource_mut::<InputQueue>()
|
||||
.push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Unpause,
|
||||
});
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world().resource::<SimulationTime>().tick_rate,
|
||||
TickRate::Full,
|
||||
"T3 step 3: game must be running after Unpause"
|
||||
);
|
||||
// advance_tick fires for the first time (Full rate): tick 0 → 1.
|
||||
assert_eq!(app.world().resource::<SimulationTime>().tick, 1);
|
||||
|
||||
// --- Step 4: MoveNorth after unpause (tick 1) — must be accepted ---
|
||||
app.world_mut()
|
||||
.resource_mut::<InputQueue>()
|
||||
.push(PlayerInput {
|
||||
tick: 1,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
*app.world().get::<TilePosition>(player).unwrap(),
|
||||
TilePosition::new(50, 38, 0),
|
||||
"T3 step 4: player must move north (y-1) after unpause"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// T4: Knowledge Graph Persistence across room transition
|
||||
// D-041
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// T4 — Knowledge State Persistence.
|
||||
///
|
||||
/// Player observes NPC in Dialogue Room (adds KG entry at Direct confidence).
|
||||
/// Player moves to Hub (simulates room transition).
|
||||
///
|
||||
/// Asserts: KG entry persists. The KnowledgeGraph component is not cleared
|
||||
/// or invalidated by a change in player TilePosition.
|
||||
#[test]
|
||||
fn t4_knowledge_graph_survives_room_transition() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(50, 114, 0), // Dialogue Room observer position
|
||||
KnowledgeGraph::new(),
|
||||
))
|
||||
.id();
|
||||
|
||||
// NPC in Dialogue Room (npc_stranger at abs ~(40, 112)).
|
||||
let npc = world.spawn(TilePosition::new(40, 112, 0)).id();
|
||||
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
// Player observes NPC — adds KG entry at Direct confidence.
|
||||
world
|
||||
.get_mut::<KnowledgeGraph>(player)
|
||||
.unwrap()
|
||||
.observe_entity(npc_sid, TilePosition::new(40, 112, 0), 0);
|
||||
|
||||
assert!(
|
||||
world
|
||||
.get::<KnowledgeGraph>(player)
|
||||
.unwrap()
|
||||
.knows_entity(&npc_sid),
|
||||
"T4 pre: player must know NPC before room transition"
|
||||
);
|
||||
|
||||
// Simulate room transition: player moves to Hub observer position.
|
||||
*world.get_mut::<TilePosition>(player).unwrap() = TilePosition::new(50, 58, 0);
|
||||
|
||||
assert!(
|
||||
world
|
||||
.get::<KnowledgeGraph>(player)
|
||||
.unwrap()
|
||||
.knows_entity(&npc_sid),
|
||||
"T4 post: KG entry must persist after player moves to Hub"
|
||||
);
|
||||
assert_eq!(
|
||||
world
|
||||
.get::<KnowledgeGraph>(player)
|
||||
.unwrap()
|
||||
.entity_count(),
|
||||
1,
|
||||
"T4 post: exactly 1 KG entry after room transition"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// T5: Entity Knowledge Downgrades on LOS Exit (Fog Carry-Over)
|
||||
// D-041, D-060
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// T5 — Fog Carry-Over.
|
||||
///
|
||||
/// Player observes NPC in Fog Theater at Direct confidence.
|
||||
/// Player moves to Hub (NPC now out of LOS). Observation system simulates
|
||||
/// the confidence downgrade: Direct → KnowsDetails.
|
||||
///
|
||||
/// Asserts: KG entry persists (entity is remembered, not erased).
|
||||
/// Server-side "fog carry-over" means previously-seen entities remain in
|
||||
/// the KG at reduced confidence so the client can render a "last seen"
|
||||
/// fog state rather than a clean erasure.
|
||||
#[test]
|
||||
fn t5_entity_knowledge_downgrades_on_los_exit_not_erased() {
|
||||
use settled_reach_server::knowledge::types::KnowledgeConfidence;
|
||||
|
||||
let mut world = setup_world();
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(56, 18, 0), // Fog Theater observer position
|
||||
KnowledgeGraph::new(),
|
||||
))
|
||||
.id();
|
||||
|
||||
// NPC in Fog Theater (npc_fog_near at abs (38, 16)).
|
||||
let npc = world.spawn(TilePosition::new(38, 16, 0)).id();
|
||||
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
// Player observes NPC — Direct confidence.
|
||||
world
|
||||
.get_mut::<KnowledgeGraph>(player)
|
||||
.unwrap()
|
||||
.observe_entity(npc_sid, TilePosition::new(38, 16, 0), 0);
|
||||
|
||||
assert_eq!(
|
||||
world
|
||||
.get::<KnowledgeGraph>(player)
|
||||
.unwrap()
|
||||
.confidence_of(&npc_sid),
|
||||
Some(KnowledgeConfidence::Direct),
|
||||
"T5 pre: NPC must be at Direct confidence while player is in Fog Theater"
|
||||
);
|
||||
|
||||
// Player moves to Hub — NPC is now out of LOS.
|
||||
*world.get_mut::<TilePosition>(player).unwrap() = TilePosition::new(50, 58, 0);
|
||||
|
||||
// Observation system downgrades confidence (entity left LOS).
|
||||
world
|
||||
.get_mut::<KnowledgeGraph>(player)
|
||||
.unwrap()
|
||||
.observe_entity_leaving_los(&npc_sid, 1);
|
||||
|
||||
let kg = world.get::<KnowledgeGraph>(player).unwrap();
|
||||
assert!(
|
||||
kg.knows_entity(&npc_sid),
|
||||
"T5 post: KG entry must persist after player leaves the room (fog carry-over)"
|
||||
);
|
||||
assert_eq!(
|
||||
kg.confidence_of(&npc_sid),
|
||||
Some(KnowledgeConfidence::KnowsDetails),
|
||||
"T5 post: confidence must downgrade from Direct to KnowsDetails on LOS exit"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// T6: Eavesdrop Cut on Player Movement
|
||||
// D-071, Eavesdrop Alcove
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// T6 — Eavesdrop Cut on Transition.
|
||||
///
|
||||
/// Player is stationary at the Eavesdrop Alcove corner position
|
||||
/// (abs 78, 36) with an active eavesdrop_target. Player moves one step
|
||||
/// south (leaving the eavesdrop position). Asserts: stationary_ticks resets
|
||||
/// to 0 and eavesdrop_target clears.
|
||||
///
|
||||
/// This prevents eavesdrop state leaking when the player walks out of the
|
||||
/// Eavesdrop Alcove: the very first movement cuts the focus.
|
||||
#[test]
|
||||
fn t6_eavesdrop_cut_on_player_movement() {
|
||||
let mut world = setup_world();
|
||||
|
||||
// NPC speaker A from Eavesdrop Alcove (StableId 58, abs 80, 32).
|
||||
let speaker_a = world.spawn(TilePosition::new(80, 32, 0)).id();
|
||||
let speaker_a_sid = world.resource_mut::<EntityRegistry>().register(speaker_a);
|
||||
|
||||
// Player at eavesdrop corner position with active eavesdrop focus.
|
||||
let corner_pos = TilePosition::new(78, 36, 0);
|
||||
let mut focus = ListeningFocus::new(corner_pos);
|
||||
focus.stationary_ticks = EAVESDROP_THRESHOLD + 10;
|
||||
focus.eavesdrop_target = Some(speaker_a_sid);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
corner_pos,
|
||||
focus,
|
||||
Stance(MovementStance::Careful), // Careful stance for eavesdrop
|
||||
))
|
||||
.id();
|
||||
|
||||
// Pre-move: eavesdrop is active.
|
||||
{
|
||||
let f = world.get::<ListeningFocus>(player).unwrap();
|
||||
assert!(
|
||||
f.eavesdrop_target.is_some(),
|
||||
"T6 pre: eavesdrop_target must be set before movement"
|
||||
);
|
||||
assert!(
|
||||
f.stationary_ticks > EAVESDROP_THRESHOLD,
|
||||
"T6 pre: stationary_ticks must exceed threshold"
|
||||
);
|
||||
}
|
||||
|
||||
// Player moves one step south (leaving eavesdrop corner).
|
||||
*world.get_mut::<TilePosition>(player).unwrap() = TilePosition::new(78, 37, 0);
|
||||
run_listening_system(&mut world);
|
||||
|
||||
// Eavesdrop must be cut.
|
||||
let f = world.get::<ListeningFocus>(player).unwrap();
|
||||
assert_eq!(
|
||||
f.stationary_ticks, 0,
|
||||
"T6 post: stationary_ticks must reset to 0 on movement"
|
||||
);
|
||||
assert!(
|
||||
f.eavesdrop_target.is_none(),
|
||||
"T6 post: eavesdrop_target must clear when player leaves eavesdrop position"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// T7: Confrontation Interrupt on Room Exit
|
||||
// D-070, D-057, Confrontation Stage
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// T7 — Confrontation Interrupt.
|
||||
///
|
||||
/// Player at CLOSE_RANGE (distance 2) from NPC target in Confrontation Stage:
|
||||
/// Talk verb is available (confrontation is possible at this range).
|
||||
/// Player retreats to observer position (94, 20) — distance 8, beyond MID_RANGE=5:
|
||||
/// all NPC verbs disappear from the interaction buffer.
|
||||
///
|
||||
/// This models the confrontation "interrupt" when the player moves away —
|
||||
/// the verb set changes, ending the potential confrontation.
|
||||
#[test]
|
||||
fn t7_confrontation_verb_disappears_on_retreat_beyond_mid_range() {
|
||||
let mut world = setup_world();
|
||||
|
||||
// NPC target at Confrontation Stage absolute position (94, 12).
|
||||
let npc = world
|
||||
.spawn((Npc, TilePosition::new(94, 12, 0), Interactable))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
// Step 1: Player at (94, 14) — distance 2 from NPC (CLOSE_RANGE=2).
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(94, 14, 0),
|
||||
NearbyInteractionBuffer::default(),
|
||||
Stance(MovementStance::Walk),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_interaction_system(&mut world);
|
||||
let interactions = world
|
||||
.get_mut::<NearbyInteractionBuffer>(player)
|
||||
.unwrap()
|
||||
.take();
|
||||
assert_eq!(
|
||||
interactions.len(),
|
||||
1,
|
||||
"T7 close: NPC at distance 2 must appear in interaction buffer"
|
||||
);
|
||||
assert!(
|
||||
interactions[0].verbs.iter().any(|v| v.kind == VerbKind::Talk),
|
||||
"T7 close: Talk must be available at CLOSE_RANGE (confrontation possible)"
|
||||
);
|
||||
|
||||
// Step 2: Player retreats to observer position (94, 20) — distance 8.
|
||||
// MID_RANGE = 5; distance 8 is fully out of range.
|
||||
*world.get_mut::<TilePosition>(player).unwrap() = TilePosition::new(94, 20, 0);
|
||||
|
||||
run_interaction_system(&mut world);
|
||||
let interactions = world
|
||||
.get_mut::<NearbyInteractionBuffer>(player)
|
||||
.unwrap()
|
||||
.take();
|
||||
assert!(
|
||||
interactions.is_empty(),
|
||||
"T7 retreat: NPC at distance 8 must not appear in buffer (beyond MID_RANGE=5)"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// T8: Sprint Blocks Eavesdrop Accumulation (D-055 + D-071)
|
||||
// Sprint Gauntlet → Eavesdrop Alcove transition
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// T8 — Sprint + Eavesdrop Cross-System Interaction.
|
||||
///
|
||||
/// Sprint stance (D-055 high-alert) prevents stationary_ticks from
|
||||
/// accumulating in ListeningFocus (D-071), so eavesdrop cannot activate
|
||||
/// while the player is sprinting.
|
||||
///
|
||||
/// Scenario: Player sprints through Sprint Gauntlet — 50 ticks stationary
|
||||
/// in Sprint stance → stationary_ticks stays at 0. Player then moves to
|
||||
/// Eavesdrop Alcove and switches to Careful stance. After
|
||||
/// EAVESDROP_THRESHOLD_CAREFUL stationary ticks, the threshold is met.
|
||||
///
|
||||
/// This catches the cross-system bug: stale sprint state leaking into the
|
||||
/// eavesdrop counter if the Sprint check in update_listening_focus is absent.
|
||||
#[test]
|
||||
fn t8_sprint_blocks_eavesdrop_then_careful_enables_accumulation() {
|
||||
let mut world = setup_world();
|
||||
|
||||
// Player starts at Sprint Gauntlet observer position with Sprint stance.
|
||||
let sprint_pos = TilePosition::new(4, 12, 0); // abs (4,12)
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
sprint_pos,
|
||||
ListeningFocus::new(sprint_pos),
|
||||
Stance(MovementStance::Sprint),
|
||||
))
|
||||
.id();
|
||||
|
||||
// 50 stationary ticks at Sprint — counter must not accumulate.
|
||||
for _ in 0..50 {
|
||||
run_listening_system(&mut world);
|
||||
}
|
||||
assert_eq!(
|
||||
world.get::<ListeningFocus>(player).unwrap().stationary_ticks,
|
||||
0,
|
||||
"T8 sprint: Sprint must block stationary_ticks (50 ticks at sprint, still 0)"
|
||||
);
|
||||
|
||||
// Transition: player moves to Eavesdrop Alcove, switches to Careful stance.
|
||||
let alcove_pos = TilePosition::new(78, 36, 0);
|
||||
*world.get_mut::<TilePosition>(player).unwrap() = alcove_pos;
|
||||
world.get_mut::<Stance>(player).unwrap().0 = MovementStance::Careful;
|
||||
|
||||
// One tick to register the movement: system detects position change,
|
||||
// resets stationary_ticks to 0, and updates last_position to alcove_pos.
|
||||
run_listening_system(&mut world);
|
||||
assert_eq!(
|
||||
world.get::<ListeningFocus>(player).unwrap().stationary_ticks,
|
||||
0,
|
||||
"T8 transition: movement tick must reset stationary_ticks to 0"
|
||||
);
|
||||
|
||||
// EAVESDROP_THRESHOLD_CAREFUL stationary ticks in Careful stance.
|
||||
for _ in 0..EAVESDROP_THRESHOLD_CAREFUL {
|
||||
run_listening_system(&mut world);
|
||||
}
|
||||
{
|
||||
let f = world.get::<ListeningFocus>(player).unwrap();
|
||||
assert_eq!(
|
||||
f.stationary_ticks, EAVESDROP_THRESHOLD_CAREFUL,
|
||||
"T8 careful: stationary_ticks must accumulate cleanly after stance change"
|
||||
);
|
||||
assert!(
|
||||
f.stationary_ticks >= EAVESDROP_THRESHOLD_CAREFUL,
|
||||
"T8 careful: stationary_ticks must meet Careful eavesdrop threshold"
|
||||
);
|
||||
}
|
||||
|
||||
// Verify D-071 invariant: Careful threshold is strictly less than normal.
|
||||
assert!(
|
||||
EAVESDROP_THRESHOLD_CAREFUL < EAVESDROP_THRESHOLD,
|
||||
"T8: Careful threshold must be < normal threshold (D-071 invariant)"
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +207,7 @@ fn generate_msgpack_fixtures() {
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
};
|
||||
write_fixture(
|
||||
"snapshot_v2_full",
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"player_facing": "North",
|
||||
"player_inventory": [],
|
||||
"player_stance": "Sprint",
|
||||
"scan_events": [],
|
||||
"tick": 8,
|
||||
"version": 9,
|
||||
"visible_tiles": [
|
||||
|
||||
@@ -308,10 +308,7 @@ fn diff_json(path: &str, expected: &Value, actual: &Value, diffs: &mut Vec<Strin
|
||||
}
|
||||
_ => {
|
||||
if expected != actual {
|
||||
diffs.push(format!(
|
||||
"{}: expected {}, got {}",
|
||||
path, expected, actual
|
||||
));
|
||||
diffs.push(format!("{}: expected {}, got {}", path, expected, actual));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,8 +346,7 @@ fn proof_room_tick_10_matches_golden() {
|
||||
// Serialize to sorted JSON for deterministic comparison
|
||||
let actual_value: Value = serde_json::to_value(&snapshot).expect("serialize to JSON");
|
||||
let actual_sorted = sort_json_keys(&actual_value);
|
||||
let actual_json =
|
||||
serde_json::to_string_pretty(&actual_sorted).expect("format JSON") + "\n";
|
||||
let actual_json = serde_json::to_string_pretty(&actual_sorted).expect("format JSON") + "\n";
|
||||
|
||||
let golden_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN_FILE);
|
||||
|
||||
@@ -382,10 +378,7 @@ fn proof_room_tick_10_matches_golden() {
|
||||
diff_json("", &golden_value, &actual_sorted, &mut diffs);
|
||||
|
||||
if !diffs.is_empty() {
|
||||
let mut msg = format!(
|
||||
"Golden file mismatch ({} differences):\n",
|
||||
diffs.len()
|
||||
);
|
||||
let mut msg = format!("Golden file mismatch ({} differences):\n", diffs.len());
|
||||
for diff in &diffs {
|
||||
msg.push_str(&format!(" {}\n", diff));
|
||||
}
|
||||
|
||||
@@ -215,8 +215,5 @@ fn perf_tick_timing() {
|
||||
},
|
||||
});
|
||||
|
||||
println!(
|
||||
"PERF_RESULT:{}",
|
||||
serde_json::to_string(&result).unwrap()
|
||||
);
|
||||
println!("PERF_RESULT:{}", serde_json::to_string(&result).unwrap());
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +253,7 @@ fn snapshot_v2_fields_roundtrip() {
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
};
|
||||
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
@@ -345,6 +347,7 @@ fn all_facing_direction_variants_roundtrip() {
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
@@ -1040,18 +1043,16 @@ fn gdscript_generated_fixtures_deserialize() {
|
||||
let bytes = fs::read(&path).unwrap_or_else(|_| panic!("read fixture {}", name));
|
||||
|
||||
if name.starts_with("input_batch") {
|
||||
let inputs: Vec<PlayerInput> = rmp_serde::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
panic!("deserialize GDScript batch fixture {}: {}", name, e)
|
||||
});
|
||||
let inputs: Vec<PlayerInput> = rmp_serde::from_slice(&bytes)
|
||||
.unwrap_or_else(|e| panic!("deserialize GDScript batch fixture {}: {}", name, e));
|
||||
assert!(
|
||||
!inputs.is_empty(),
|
||||
"batch fixture {} should not be empty",
|
||||
name
|
||||
);
|
||||
} else if name.starts_with("input_") || name.starts_with("boundary_tick_") {
|
||||
let input: PlayerInput = rmp_serde::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
panic!("deserialize GDScript input fixture {}: {}", name, e)
|
||||
});
|
||||
let input: PlayerInput = rmp_serde::from_slice(&bytes)
|
||||
.unwrap_or_else(|e| panic!("deserialize GDScript input fixture {}: {}", name, e));
|
||||
// Verify specific fixtures for extra confidence
|
||||
match name.as_str() {
|
||||
"input_move_north" => {
|
||||
@@ -1078,10 +1079,7 @@ fn gdscript_generated_fixtures_deserialize() {
|
||||
assert_eq!(input.tick, 65536, "int_32 asymmetry: tick=65536");
|
||||
}
|
||||
"boundary_tick_2147483647" => {
|
||||
assert_eq!(
|
||||
input.tick, 2147483647,
|
||||
"int_32 asymmetry: tick=2^31-1"
|
||||
);
|
||||
assert_eq!(input.tick, 2147483647, "int_32 asymmetry: tick=2^31-1");
|
||||
}
|
||||
_ => {} // Other fixtures: deserialization success is sufficient
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user