feat(simulation): sprint 9 gauntlet — test infrastructure and first 3 rooms
Add Gauntlet test world with 3 rooms (Inventory Warehouse, Occlusion Corridor, Pause Chamber) + Central Hub, room constants module, room reset trigger mechanism, Layer 3 subprocess integration test, golden file comparison engine and test suite, and content runtime validation. Tickets: #482, #484, #485, #487, #488, #489, #490 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,9 @@
|
||||
//! Tests the full pipeline: discover content → deserialize YAML → spawn ECS entities.
|
||||
//! Uses real content files from content/ directory for structural content,
|
||||
//! and a test fixture for isolated NPC profile spawning.
|
||||
//!
|
||||
//! Also includes runtime validation (#489): boot the full plugin stack with real
|
||||
//! content, tick 10 times over TCP, and assert a valid ObserverSnapshot.
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
@@ -416,3 +419,130 @@ fn spawn_real_content_with_relationships_and_secrets() {
|
||||
.expect("Nils should have Want");
|
||||
assert_eq!(nils_want.primary, npc::WantKind::Power);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test: Runtime validation — boot + tick 10 + snapshot (#489)
|
||||
//
|
||||
// Smoke test for production content. Boots the full plugin stack with real
|
||||
// content over TCP, ticks 10 times, and asserts a valid ObserverSnapshot.
|
||||
// Catches runtime panics from broken entity references, missing components,
|
||||
// or content schema issues that pass YAML validation but fail at tick time.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn content_runtime_boot_tick_10_snapshot() {
|
||||
use settled_reach_server::bridge::framing::{read_framed, write_framed};
|
||||
use settled_reach_server::bridge::tcp::TcpBridge;
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::bridge::{BridgePlugin, BridgeResource};
|
||||
use settled_reach_server::knowledge::KnowledgeGraph;
|
||||
use settled_reach_server::perception::cognitive_delay::CognitiveDelay;
|
||||
use settled_reach_server::perception::vision_cone::Facing;
|
||||
use settled_reach_server::simulation::interaction::NearbyInteractionBuffer;
|
||||
use settled_reach_server::simulation::listening::ListeningFocus;
|
||||
use settled_reach_server::simulation::monologue::{
|
||||
MonologueBuffer, MonologueState, SprintAnomalyQueue,
|
||||
};
|
||||
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown};
|
||||
use std::io::{BufReader, BufWriter};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::thread;
|
||||
|
||||
let root = content_root();
|
||||
if !root.join("content.yaml").exists() {
|
||||
eprintln!("Skipping: content directory not found at {:?}", root);
|
||||
return;
|
||||
}
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
|
||||
let server_addr = listener.local_addr().expect("get local addr");
|
||||
|
||||
// Server thread: full plugin stack with real content
|
||||
let server_handle = thread::spawn(move || {
|
||||
let bridge = TcpBridge::accept_on(listener).expect("accept connection");
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(SimulationPlugin);
|
||||
app.add_plugins(BridgePlugin);
|
||||
app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(settled_reach_server::npc::NpcPlugin);
|
||||
app.insert_resource(ContentConfig {
|
||||
content_root: root,
|
||||
..Default::default()
|
||||
});
|
||||
app.add_plugins(ContentPlugin);
|
||||
app.insert_resource(BridgeResource::new(bridge));
|
||||
app.insert_resource(WalkabilityMap::new(32, 32, 1));
|
||||
|
||||
// Spawn player with all required observer pipeline components
|
||||
let profile = MovementProfile::smuggler();
|
||||
app.world_mut().spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
SprintAnomalyQueue::default(),
|
||||
CognitiveDelay::default(),
|
||||
ListeningFocus::new(TilePosition::new(16, 16, 0)),
|
||||
profile,
|
||||
profile.initial_stance(),
|
||||
PlayerMoveCooldown::default(),
|
||||
));
|
||||
|
||||
// Tick 10 times — any panic here means content has a runtime bug
|
||||
for _ in 0..10 {
|
||||
app.update();
|
||||
}
|
||||
});
|
||||
|
||||
// Client: connect and receive 10 snapshots
|
||||
let stream = TcpStream::connect(server_addr).expect("client connect");
|
||||
let mut reader = BufReader::new(stream.try_clone().expect("clone for reader"));
|
||||
let mut writer = BufWriter::new(stream);
|
||||
|
||||
let mut last_snapshot = None;
|
||||
for tick in 0..10 {
|
||||
let payload = read_framed(&mut reader)
|
||||
.unwrap_or_else(|e| panic!("read error at tick {}: {}", tick, e))
|
||||
.unwrap_or_else(|| panic!("unexpected EOF at tick {}", tick));
|
||||
|
||||
let snapshot: ObserverSnapshot = rmp_serde::from_slice(&payload)
|
||||
.unwrap_or_else(|e| panic!("deserialization error at tick {}: {}", tick, e));
|
||||
|
||||
last_snapshot = Some(snapshot);
|
||||
|
||||
// Send empty input for next tick
|
||||
let empty: Vec<PlayerInput> = vec![];
|
||||
let input_payload = rmp_serde::to_vec(&empty).expect("serialize empty input");
|
||||
if let Err(_) = write_framed(&mut writer, &input_payload) {
|
||||
// Server may have shut down after tick 10 — that's fine
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
drop(reader);
|
||||
drop(writer);
|
||||
|
||||
// Server thread must not have panicked
|
||||
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");
|
||||
assert_eq!(
|
||||
snapshot.version, PROTOCOL_VERSION,
|
||||
"snapshot protocol version mismatch"
|
||||
);
|
||||
// Content-spawned NPCs should be visible (they all spawn at 0,0,0 by default)
|
||||
// The player is at 16,16 — content NPCs are far away but the player entity itself
|
||||
// should always be in the snapshot
|
||||
assert!(
|
||||
!snapshot.entities.is_empty(),
|
||||
"snapshot should contain at least the player entity"
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,399 @@
|
||||
//! Golden file regression test (#485)
|
||||
//!
|
||||
//! Runs a 10-tick deterministic replay, serializes the final ObserverSnapshot
|
||||
//! to JSON, and compares against a committed golden file. Any deviation fails
|
||||
//! the test with a field-level diff.
|
||||
//!
|
||||
//! To regenerate golden files after intentional changes:
|
||||
//! UPDATE_GOLDEN=1 cargo test --test golden_suite
|
||||
//!
|
||||
//! Spec references: D-010 (deterministic simulation), D-030 (testability)
|
||||
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::bridge::{BridgePlugin, SnapshotBuffer};
|
||||
use settled_reach_server::knowledge::registry::EntityRegistry;
|
||||
use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin};
|
||||
use settled_reach_server::npc::relationships::{RelationshipEdge, RelationshipGraph};
|
||||
use settled_reach_server::npc::{
|
||||
Contentment, DailyRoutine, Npc, NpcPlugin, RelationshipKind, RoutineEntry, ToleranceThreshold,
|
||||
Want, WantKind,
|
||||
};
|
||||
use settled_reach_server::perception::cognitive_delay::CognitiveDelay;
|
||||
use settled_reach_server::perception::vision_cone::Facing;
|
||||
use settled_reach_server::simulation::interaction::{Interactable, NearbyInteractionBuffer};
|
||||
use settled_reach_server::simulation::listening::ListeningFocus;
|
||||
use settled_reach_server::simulation::monologue::{
|
||||
MonologueBuffer, MonologueState, SprintAnomalyQueue,
|
||||
};
|
||||
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use settled_reach_server::simulation::path_follow::MovementSpeed;
|
||||
use settled_reach_server::simulation::rng::SimRng;
|
||||
use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown};
|
||||
use settled_reach_server::simulation::time::DayPhase;
|
||||
use settled_reach_server::simulation::SimulationPlugin;
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const GOLDEN_DIR: &str = "tests/golden";
|
||||
const GOLDEN_FILE: &str = "tests/golden/proof_room_tick_10.json";
|
||||
const SEED: u64 = 42;
|
||||
const NUM_TICKS: usize = 10;
|
||||
|
||||
/// Build a deterministic simulation app with the proof room.
|
||||
/// Mirrors the setup in determinism.rs / main.rs.
|
||||
fn build_app(seed: u64) -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(SimulationPlugin);
|
||||
app.add_plugins(BridgePlugin);
|
||||
app.add_plugins(KnowledgePlugin);
|
||||
app.add_plugins(NpcPlugin);
|
||||
app.insert_resource(SimRng::new(seed));
|
||||
app.insert_resource(WalkabilityMap::new(32, 32, 1));
|
||||
|
||||
{
|
||||
let mut wm = app.world_mut().resource_mut::<WalkabilityMap>();
|
||||
wm.set_walkable(&TilePosition::new(16, 14, 0), false);
|
||||
}
|
||||
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let profile = MovementProfile::smuggler();
|
||||
let player = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
SprintAnomalyQueue::default(),
|
||||
CognitiveDelay::default(),
|
||||
ListeningFocus::new(TilePosition::new(16, 16, 0)),
|
||||
profile,
|
||||
profile.initial_stance(),
|
||||
PlayerMoveCooldown::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
let npc1 = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
TilePosition::new(16, 13, 0),
|
||||
Want {
|
||||
primary: WantKind::Wealth,
|
||||
intensity: 6,
|
||||
description: "Wants a bigger share of docking fees".into(),
|
||||
},
|
||||
DailyRoutine {
|
||||
entries: vec![
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Morning,
|
||||
location: TilePosition::new(16, 13, 0),
|
||||
activity: "Prep cargo bay".into(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: TilePosition::new(20, 10, 0),
|
||||
activity: "Unload freight".into(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Evening,
|
||||
location: TilePosition::new(10, 20, 0),
|
||||
activity: "Drink at canteen".into(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Night,
|
||||
location: TilePosition::new(16, 13, 0),
|
||||
activity: "Sleep in bunk".into(),
|
||||
},
|
||||
],
|
||||
description: "Dock worker shift pattern".into(),
|
||||
},
|
||||
Contentment { level: 20 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 30,
|
||||
threshold: 70,
|
||||
},
|
||||
MovementSpeed::new(2),
|
||||
))
|
||||
.id();
|
||||
let npc1_sid = registry.register(npc1);
|
||||
|
||||
let npc2 = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
TilePosition::new(14, 18, 0),
|
||||
Want {
|
||||
primary: WantKind::Knowledge,
|
||||
intensity: 8,
|
||||
description: "Obsessed with pre-Collapse sensor arrays".into(),
|
||||
},
|
||||
DailyRoutine {
|
||||
entries: vec![
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Morning,
|
||||
location: TilePosition::new(14, 18, 0),
|
||||
activity: "Calibrate instruments".into(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: TilePosition::new(22, 22, 0),
|
||||
activity: "Field survey".into(),
|
||||
},
|
||||
],
|
||||
description: "Field tech survey pattern".into(),
|
||||
},
|
||||
Contentment { level: 45 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 10,
|
||||
threshold: 60,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
))
|
||||
.id();
|
||||
let npc2_sid = registry.register(npc2);
|
||||
|
||||
let npc3 = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
TilePosition::new(18, 14, 0),
|
||||
Want {
|
||||
primary: WantKind::Safety,
|
||||
intensity: 4,
|
||||
description: "Wants a quiet shift".into(),
|
||||
},
|
||||
Contentment { level: -5 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 45,
|
||||
threshold: 55,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
let npc3_sid = registry.register(npc3);
|
||||
|
||||
{
|
||||
let mut rel_graph = app.world_mut().resource_mut::<RelationshipGraph>();
|
||||
rel_graph.set_relationship(
|
||||
npc1_sid,
|
||||
npc3_sid,
|
||||
RelationshipEdge {
|
||||
kind: RelationshipKind::Colleague,
|
||||
trust: 3,
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
},
|
||||
);
|
||||
rel_graph.set_relationship(
|
||||
npc3_sid,
|
||||
npc2_sid,
|
||||
RelationshipEdge {
|
||||
kind: RelationshipKind::Rival,
|
||||
trust: -4,
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
app.insert_resource(registry);
|
||||
app
|
||||
}
|
||||
|
||||
/// Standard 10-tick input sequence for golden file tests.
|
||||
/// Matches the first 10 ticks of the determinism test in determinism.rs.
|
||||
fn standard_inputs() -> Vec<Vec<PlayerInput>> {
|
||||
vec![
|
||||
// Tick 0: idle — baseline snapshot
|
||||
vec![],
|
||||
// Tick 1: move north
|
||||
vec![PlayerInput {
|
||||
tick: 1,
|
||||
action: PlayerAction::MoveNorth,
|
||||
}],
|
||||
// Tick 2: idle
|
||||
vec![],
|
||||
// Tick 3: move east
|
||||
vec![PlayerInput {
|
||||
tick: 3,
|
||||
action: PlayerAction::MoveEast,
|
||||
}],
|
||||
// Tick 4: idle
|
||||
vec![],
|
||||
// Tick 5: move north
|
||||
vec![PlayerInput {
|
||||
tick: 5,
|
||||
action: PlayerAction::MoveNorth,
|
||||
}],
|
||||
// Tick 6: stance toggle up
|
||||
vec![PlayerInput {
|
||||
tick: 6,
|
||||
action: PlayerAction::ToggleStanceUp,
|
||||
}],
|
||||
// Tick 7: move north
|
||||
vec![PlayerInput {
|
||||
tick: 7,
|
||||
action: PlayerAction::MoveNorth,
|
||||
}],
|
||||
// Tick 8: pause
|
||||
vec![PlayerInput {
|
||||
tick: 8,
|
||||
action: PlayerAction::Pause,
|
||||
}],
|
||||
// Tick 9: move while paused (should be discarded)
|
||||
vec![PlayerInput {
|
||||
tick: 9,
|
||||
action: PlayerAction::MoveNorth,
|
||||
}],
|
||||
]
|
||||
}
|
||||
|
||||
/// Recursively sort all object keys for deterministic JSON output.
|
||||
fn sort_json_keys(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let sorted: BTreeMap<String, Value> = map
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), sort_json_keys(v)))
|
||||
.collect();
|
||||
Value::Object(sorted.into_iter().collect())
|
||||
}
|
||||
Value::Array(arr) => Value::Array(arr.iter().map(sort_json_keys).collect()),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursive JSON diff — reports all field-level differences with paths.
|
||||
fn diff_json(path: &str, expected: &Value, actual: &Value, diffs: &mut Vec<String>) {
|
||||
match (expected, actual) {
|
||||
(Value::Object(e), Value::Object(a)) => {
|
||||
let mut all_keys: Vec<&String> = e.keys().chain(a.keys()).collect();
|
||||
all_keys.sort();
|
||||
all_keys.dedup();
|
||||
for key in all_keys {
|
||||
let child = if path.is_empty() {
|
||||
format!(".{}", key)
|
||||
} else {
|
||||
format!("{}.{}", path, key)
|
||||
};
|
||||
match (e.get(key), a.get(key)) {
|
||||
(Some(ev), Some(av)) => diff_json(&child, ev, av, diffs),
|
||||
(Some(_), None) => diffs.push(format!("{}: missing in actual", child)),
|
||||
(None, Some(_)) => diffs.push(format!("{}: unexpected in actual", child)),
|
||||
(None, None) => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
(Value::Array(e), Value::Array(a)) => {
|
||||
for i in 0..e.len().max(a.len()) {
|
||||
let child = format!("{}[{}]", path, i);
|
||||
match (e.get(i), a.get(i)) {
|
||||
(Some(ev), Some(av)) => diff_json(&child, ev, av, diffs),
|
||||
(Some(_), None) => diffs.push(format!("{}: missing in actual", child)),
|
||||
(None, Some(_)) => diffs.push(format!("{}: unexpected in actual", child)),
|
||||
(None, None) => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if expected != actual {
|
||||
diffs.push(format!(
|
||||
"{}: expected {}, got {}",
|
||||
path, expected, actual
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_room_tick_10_matches_golden() {
|
||||
let mut app = build_app(SEED);
|
||||
let inputs = standard_inputs();
|
||||
|
||||
assert_eq!(inputs.len(), NUM_TICKS);
|
||||
|
||||
let mut last_snapshot: Option<ObserverSnapshot> = None;
|
||||
|
||||
for tick_inputs in &inputs {
|
||||
{
|
||||
let mut queue = app
|
||||
.world_mut()
|
||||
.resource_mut::<settled_reach_server::simulation::input::InputQueue>();
|
||||
for input in tick_inputs {
|
||||
queue.push(input.clone());
|
||||
}
|
||||
}
|
||||
|
||||
app.update();
|
||||
|
||||
let buffer = app.world().resource::<SnapshotBuffer>();
|
||||
if let Some(snapshot) = &buffer.snapshot {
|
||||
last_snapshot = Some(snapshot.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let snapshot = last_snapshot.expect("no snapshot produced after 10 ticks");
|
||||
|
||||
// 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 golden_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN_FILE);
|
||||
|
||||
// UPDATE_GOLDEN=1 mode: write the golden file and return
|
||||
if std::env::var("UPDATE_GOLDEN").is_ok() {
|
||||
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN_DIR);
|
||||
std::fs::create_dir_all(&dir).expect("create golden directory");
|
||||
std::fs::write(&golden_path, &actual_json).expect("write golden file");
|
||||
eprintln!(
|
||||
"Golden file written: {} ({} bytes)",
|
||||
golden_path.display(),
|
||||
actual_json.len()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal mode: compare against golden file
|
||||
let golden_json = std::fs::read_to_string(&golden_path).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"Golden file not found: {}. Run with UPDATE_GOLDEN=1 to generate.\nError: {}",
|
||||
golden_path.display(),
|
||||
e
|
||||
)
|
||||
});
|
||||
let golden_value: Value =
|
||||
serde_json::from_str(&golden_json).expect("parse golden file as JSON");
|
||||
|
||||
let mut diffs = Vec::new();
|
||||
diff_json("", &golden_value, &actual_sorted, &mut diffs);
|
||||
|
||||
if !diffs.is_empty() {
|
||||
let mut msg = format!(
|
||||
"Golden file mismatch ({} differences):\n",
|
||||
diffs.len()
|
||||
);
|
||||
for diff in &diffs {
|
||||
msg.push_str(&format!(" {}\n", diff));
|
||||
}
|
||||
msg.push_str(&format!(
|
||||
"\nTo update: UPDATE_GOLDEN=1 cargo test --test golden_suite\n\
|
||||
Golden file: {}",
|
||||
golden_path.display()
|
||||
));
|
||||
panic!("{}", msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Layer 3 integration test: real subprocess IPC (D-030)
|
||||
//!
|
||||
//! Spawns the server binary as a child process with --test-mode --port 0,
|
||||
//! parses the LISTENING:{port} handshake from stdout, connects via TCP,
|
||||
//! sends a PlayerInput, and reads back an ObserverSnapshot.
|
||||
//!
|
||||
//! This is the highest-fidelity test layer: no mocks, no in-process bridge.
|
||||
//! The server runs as a separate OS process, exactly as it does in production.
|
||||
//!
|
||||
//! Spec references: D-020 (subprocess IPC), D-030 (Layer 3 integration tests)
|
||||
|
||||
use settled_reach_server::bridge::framing::{read_framed, write_framed};
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use std::io::{BufRead, BufReader, BufWriter};
|
||||
use std::net::TcpStream;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Timeout for the server to emit LISTENING:{port} on stdout.
|
||||
const LISTEN_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Timeout for the client to receive a snapshot after sending input.
|
||||
const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[test]
|
||||
fn server_subprocess_sends_snapshot_on_connect() {
|
||||
// 1. Spawn server binary with --test-mode --port 0
|
||||
let server_bin = env!("CARGO_BIN_EXE_settled-reach-server");
|
||||
let mut child = Command::new(server_bin)
|
||||
.args(["--test-mode", "--port", "0"])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("failed to spawn server binary");
|
||||
|
||||
let stdout = child.stdout.take().expect("stdout not captured");
|
||||
let mut stdout_reader = BufReader::new(stdout);
|
||||
|
||||
// 2. Parse LISTENING:{port} from stdout
|
||||
let port = {
|
||||
let deadline = Instant::now() + LISTEN_TIMEOUT;
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match stdout_reader.read_line(&mut line) {
|
||||
Ok(0) => panic!("server stdout closed before LISTENING signal"),
|
||||
Ok(_) => {
|
||||
let trimmed = line.trim();
|
||||
if let Some(port_str) = trimmed.strip_prefix("LISTENING:") {
|
||||
break port_str
|
||||
.parse::<u16>()
|
||||
.unwrap_or_else(|e| panic!("invalid port '{}': {}", port_str, e));
|
||||
}
|
||||
}
|
||||
Err(e) => panic!("failed to read server stdout: {}", e),
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"timed out waiting for LISTENING signal"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Connect to the server via TCP
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
let stream = TcpStream::connect(&addr)
|
||||
.unwrap_or_else(|e| panic!("failed to connect to server at {}: {}", addr, e));
|
||||
stream
|
||||
.set_read_timeout(Some(SNAPSHOT_TIMEOUT))
|
||||
.expect("set read timeout");
|
||||
|
||||
let mut reader = BufReader::new(stream.try_clone().expect("clone stream for reader"));
|
||||
let mut writer = BufWriter::new(stream);
|
||||
|
||||
// 4. Send one PlayerInput (idle tick 0)
|
||||
let inputs = vec![PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
}];
|
||||
let payload = rmp_serde::to_vec_named(&inputs).expect("serialize PlayerInput");
|
||||
write_framed(&mut writer, &payload).expect("send PlayerInput to server");
|
||||
|
||||
// 5. Read one ObserverSnapshot
|
||||
let response = read_framed(&mut reader)
|
||||
.expect("read snapshot frame")
|
||||
.expect("server closed connection before sending snapshot");
|
||||
let snapshot: ObserverSnapshot =
|
||||
rmp_serde::from_slice(&response).expect("deserialize ObserverSnapshot");
|
||||
|
||||
// 6. Assert protocol correctness (D-020)
|
||||
assert_eq!(
|
||||
snapshot.version, PROTOCOL_VERSION,
|
||||
"protocol version mismatch: got {}, expected {}",
|
||||
snapshot.version, PROTOCOL_VERSION
|
||||
);
|
||||
assert!(
|
||||
snapshot.entities.len() > 0,
|
||||
"snapshot should contain at least one entity (the player), got 0"
|
||||
);
|
||||
|
||||
// The proof room has a player + NPCs. Verify the player entity exists.
|
||||
let has_player = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.any(|e| matches!(e.kind, EntityKind::Player));
|
||||
assert!(has_player, "snapshot must contain a Player entity");
|
||||
|
||||
// 7. Clean up: drop connection so the server exits its game loop
|
||||
drop(reader);
|
||||
drop(writer);
|
||||
|
||||
// Wait for child to exit (with timeout)
|
||||
let exit_deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_status)) => break,
|
||||
Ok(None) => {
|
||||
if Instant::now() > exit_deadline {
|
||||
child.kill().ok();
|
||||
child.wait().ok();
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error waiting for server process: {}", e);
|
||||
child.kill().ok();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user