//! Runtime validation: boot full plugin stack with real content, tick 10 //! times over TCP, assert valid ObserverSnapshot (#489). //! //! Separated from content_loading.rs (structural loading tests) per //! architectural review — TCP runtime tests have different failure modes //! and timeout characteristics. use bevy_app::prelude::*; use std::net::{TcpListener, TcpStream}; use std::path::PathBuf; use std::sync::{Arc, Barrier}; use std::thread; use std::time::Duration; 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::content::{ContentConfig, ContentPlugin}; use settled_reach_server::knowledge::registry::EntityRegistry; 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 settled_reach_server::simulation::SimulationPlugin; fn content_root() -> PathBuf { let manifest_dir = env!("CARGO_MANIFEST_DIR"); PathBuf::from(manifest_dir).join("../content") } /// 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 std::io::{BufReader, BufWriter}; 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"); // Barrier keeps the server thread alive until the client has finished // reading all snapshots, preventing a TCP RST race under parallel execution. let barrier = Arc::new(Barrier::new(2)); let server_barrier = barrier.clone(); // 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(); let mut registry = EntityRegistry::new(0); 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); app.insert_resource(registry); // Tick 10 times — any panic here means content has a runtime bug for _ in 0..10 { app.update(); } // Wait for client to finish reading before dropping the TCP socket server_barrier.wait(); }); // Client: connect with read timeout and receive 10 snapshots let stream = TcpStream::connect(server_addr).expect("client connect"); stream .set_read_timeout(Some(Duration::from_secs(10))) .expect("set read timeout"); 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 = vec![]; let input_payload = rmp_serde::to_vec(&empty).expect("serialize empty input"); if write_framed(&mut writer, &input_payload).is_err() { // Server may have shut down after tick 10 — that's fine break; } } drop(reader); drop(writer); // Signal server thread that client is done reading barrier.wait(); // 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" ); }