From d31ac1cc70f3575747831266a472a5a9acb93d6f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 27 Feb 2026 17:46:27 +0100 Subject: [PATCH] =?UTF-8?q?feat(simulation):=20error=20handling=20and=20re?= =?UTF-8?q?covery=20=E2=80=94=20panic=20supervision,=20state=20hash,=20str?= =?UTF-8?q?uctured=20errors=20(#85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Protocol v17: add state_hash (desync detection) and sim_errors (structured error reporting) to ObserverSnapshot. Add SimError, SimErrorKind, SimErrorBuffer types. Wrap main loop app.update() in catch_unwind — on panic, send a final SimError snapshot before exit. Report recoverable deserialization errors to client via SimErrorBuffer. Compute per-tick state hash from player position + NPC count + tick. Update all test fixtures and golden files for protocol v17. Co-Authored-By: Claude Opus 4.6 --- .../msgpack/snapshot_boundary_tick_0.msgpack | Bin 397 -> 397 bytes .../snapshot_boundary_tick_127.msgpack | Bin 397 -> 397 bytes .../snapshot_boundary_tick_2b31m1.msgpack | Bin 401 -> 401 bytes .../snapshot_boundary_tick_2b32.msgpack | Bin 405 -> 405 bytes .../snapshot_boundary_tick_32767.msgpack | Bin 399 -> 399 bytes .../fixtures/msgpack/snapshot_empty.msgpack | Bin 397 -> 397 bytes .../fixtures/msgpack/snapshot_full.msgpack | Bin 1257 -> 1257 bytes .../fixtures/msgpack/snapshot_minimal.msgpack | Bin 498 -> 498 bytes .../msgpack/snapshot_multi_entity.msgpack | Bin 802 -> 802 bytes .../fixtures/msgpack/snapshot_one_npc.msgpack | Bin 495 -> 495 bytes .../fixtures/msgpack/snapshot_player.msgpack | Bin 498 -> 498 bytes .../fixtures/msgpack/snapshot_v2_full.msgpack | Bin 662 -> 662 bytes server/src/bridge/mod.rs | 25 +- server/src/bridge/text_renderer.rs | 4 + server/src/bridge/types.rs | 69 +++- server/src/main.rs | 106 +++++- server/src/perception/observer/mod.rs | 25 ++ server/tests/bridge_ipc.rs | 2 + server/tests/bridge_tcp.rs | 2 + server/tests/error_handling.rs | 314 ++++++++++++++++++ server/tests/gen_fixtures.rs | 6 + server/tests/golden/proof_room_tick_10.json | 3 +- server/tests/serialization.rs | 8 +- 23 files changed, 557 insertions(+), 7 deletions(-) create mode 100644 server/tests/error_handling.rs diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack index 717c4d4e1cae9bbe7a606900b4e3c0b226c17a9a..1615131dc8cd3474384450d416281c70affe9ce0 100644 GIT binary patch delta 21 ccmeBW?q%k=#~`}AEVZaOGe1voBTpkE08U>A9RL6T delta 21 ccmeBW?q%k=#~`}AEVZaOGe1vYBTpkE08Uy58~^|S diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack index 6ad1d0cbeaa16984d89a089261d1fdf78afe9fe8..2d4321df5e8bdf8a1aeacc1b3b33369d3a7a1eef 100644 GIT binary patch delta 21 ccmeBW?q%k=#~`}AEVZaOGe1voBTpkE08U>A9RL6T delta 21 ccmeBW?q%k=#~`}AEVZaOGe1vYBTpkE08Uy58~^|S diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack index 142a76a3c75b28fca74423059fef76176de1b9dd..4a8702760c48ee24925be50db70f25ab0ae49b03 100644 GIT binary patch delta 21 ccmbQpJdv5_9)sxevecsD%=|pTjXbT408a-8DF6Tf delta 21 ccmbQpJdv5_9)sxevecsD%=|oojXbT408au3C;$Ke diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack index 81ed70f7c38994bea26ffa324426fc7fc7bd543f..a1c311896794b7e72c4a0ae6362196d5ed06143d 100644 GIT binary patch delta 21 ccmbQrJe8T}9)sxevecsD%=|pTjXa%<08g(6H2?qr delta 21 ccmbQrJe8T}9)sxevecsD%=|oojXa%<08gq1Gynhq diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack index 9f30a44f183335179959a9ce8996430c41eaf246..47762a0df4b2644d2c068b38c8ddd3582bb13ac7 100644 GIT binary patch delta 21 ccmeBY?q}w?#~`}AEVZaOGe1voBTq9U08X<9BLDyZ delta 21 ccmeBY?q}w?#~`}AEVZaOGe1vYBTq9U08Xw4A^-pY diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index 717c4d4e1cae9bbe7a606900b4e3c0b226c17a9a..1615131dc8cd3474384450d416281c70affe9ce0 100644 GIT binary patch delta 21 ccmeBW?q%k=#~`}AEVZaOGe1voBTpkE08U>A9RL6T delta 21 ccmeBW?q%k=#~`}AEVZaOGe1vYBTpkE08Uy58~^|S diff --git a/client/tests/fixtures/msgpack/snapshot_full.msgpack b/client/tests/fixtures/msgpack/snapshot_full.msgpack index 03348d54a8f52bd1ae76138f364e2d6830b7cfe5..d2f5bbd60e1ecc5b99a583fe2f1593f3083c43cd 100644 GIT binary patch delta 21 ccmaFK`I3|89)ra4vecsD%=|pTjXZZ*0A7d)LjV8( delta 21 ccmaFK`I3|89)ra4vecsD%=|oojXZZ*0A7O#LI3~& diff --git a/client/tests/fixtures/msgpack/snapshot_minimal.msgpack b/client/tests/fixtures/msgpack/snapshot_minimal.msgpack index 6bff4d6b3a2d80cd0bda5f195e419377caed0180..fba288bb80c49b70088f2f56648153e7a261d7c0 100644 GIT binary patch delta 21 ccmeyw{E3<89)sxevecsD%=|pTjXcj70bcP4OaK4? delta 21 ccmeyw{E3<89)sxevecsD%=|oojXcj70bc9~O8@`> diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 428f5dacfa0b956dedde5f3d699bd999aba20b3e..dc2acf96a71d9b0eb85a46cfd6165fec08a7d77e 100644 GIT binary patch delta 21 ccmZ3)wup`A9)sxevecsD%=|pTjXYw^08vE- diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index 19bd9d0f9bd5bd4255cea48cb31b65d8e1fc06c6..f83ad48385bed1db51ecde80b86e5efc4354d447 100644 GIT binary patch delta 21 ccmbQnI*pa*9)sxevecsD%=|pTjXYgU08l0eJpcdz delta 21 ccmbQnI*pa*9)sxevecsD%=|oojXYgU08k+ZJOBUy diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index 89ea3c363..a02450ec2 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -91,14 +91,20 @@ impl Default for HandshakeState { } } -/// Receive inputs from bridge and push to InputQueue +/// Receive inputs from bridge and push to InputQueue. +/// Protocol errors (malformed input) are recoverable: the frame is skipped +/// and a SimError is pushed to the SimErrorBuffer for client reporting (#85). pub fn receive_bridge_inputs( bridge: Option>, mut input_queue: ResMut, mut running: ResMut, handshake: Res, + mut error_buffer: ResMut, + time: Option>, ) { let Some(bridge) = bridge else { return }; + let current_tick = time.as_ref().map(|t| t.tick).unwrap_or(0); + match bridge.receive_inputs() { Ok(inputs) => { if !inputs.is_empty() && *handshake == HandshakeState::Pending { @@ -134,8 +140,22 @@ pub fn receive_bridge_inputs( running.0 = false; } Err(BridgeError::DeserializationWithDump(ref msg)) => { - // Recoverable: skip this frame's input, don't shut down + // Recoverable: skip this frame's input, report to client (#85) tracing::error!("Skipping malformed input frame: {}", msg); + error_buffer.push(SimError { + kind: SimErrorKind::ProtocolError, + message: format!("Malformed input frame: {}", msg), + tick: current_tick, + }); + } + Err(ref e @ BridgeError::Deserialization(_)) => { + // Recoverable deserialization error without dump + tracing::error!("Skipping malformed input: {}", e); + error_buffer.push(SimError { + kind: SimErrorKind::ProtocolError, + message: format!("Deserialization error: {}", e), + tick: current_tick, + }); } Err(e) => { tracing::error!("Bridge receive error: {}", e); @@ -191,6 +211,7 @@ impl Plugin for BridgePlugin { app.init_resource::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .add_systems( diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index aabb19d90..1339302b5 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -314,6 +314,8 @@ mod tests { player_knowledge: None, save_result: None, triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], } } @@ -450,6 +452,8 @@ mod tests { player_knowledge: None, save_result: None, triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], }; let text = format_snapshot_text(&snap); assert!(text.contains("Tick 0")); diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index e258ee8c7..f6f780125 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -17,7 +17,7 @@ pub use crate::simulation::time::{DayPhase, TickRate}; /// negotiation is unnecessary. Client should reject snapshots with version != /// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration /// period, then the default is removed once both sides are updated. -pub const PROTOCOL_VERSION: u8 = 16; +pub const PROTOCOL_VERSION: u8 = 17; /// Handshake message sent as the very first framed message after connection (#555). /// Client reads this before entering the normal tick loop and validates @@ -51,10 +51,12 @@ pub struct HandshakeMessage { /// player_knowledge (#264, partial KG dump for journal/knowledge panel). /// v15 adds: save_result (#553, save/load operation result for client confirmation). /// v16 adds: triangle_crisis_events (#250, D-087 triangle escalation for future client rendering). +/// v17 adds: state_hash (#85, desync detection — fast hash of player pos + NPC count + tick), +/// sim_errors (#85, structured error reporting to client). /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { - /// Protocol version for forward compatibility. Current: 16. + /// Protocol version for forward compatibility. Current: 17. pub version: u8, /// Simulation tick when this snapshot was produced pub tick: u64, @@ -157,6 +159,19 @@ pub struct ObserverSnapshot { /// a narrative event or HUD indicator. Empty when no crises occur. #[serde(default)] pub triangle_crisis_events: Vec, + /// Fast hash of key mutable state for desync detection (#85). + /// Hash inputs: player position, NPC count, tick number. + /// Client compares against its own computed hash — mismatch indicates + /// client and server state have diverged. No auto-recovery in v0.1; + /// client logs mismatches for debugging. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_hash: Option, + /// Simulation errors reported this tick (#85). + /// Non-fatal errors (protocol errors, desync) are collected during + /// the tick and sent to the client for logging/display. + /// Empty in normal operation. Client may display a warning toast. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sim_errors: Vec, } /// Game time data for client display (D-031) @@ -714,6 +729,56 @@ impl From for TriangleCrisisEvent } } +/// Structured simulation error for client reporting (#85). +/// +/// Sent inside `ObserverSnapshot.sim_errors` for recoverable errors +/// (protocol errors, desync warnings). For fatal errors (panics), +/// a final snapshot is sent with the error before the server exits. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimError { + /// Error category for client-side handling. + pub kind: SimErrorKind, + /// Human-readable error description. + pub message: String, + /// Tick when the error occurred (0 if unavailable). + pub tick: u64, +} + +/// Categories of simulation errors (#85). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SimErrorKind { + /// Simulation system panic — fatal, server will exit after sending this. + Panic, + /// Protocol/deserialization error — recoverable, server continues. + ProtocolError, + /// Client-server state hash mismatch — informational, no auto-recovery. + DesyncDetected, +} + +/// Buffer for collecting simulation errors during a tick (#85). +/// Drained by `compute_observer_snapshot` into `ObserverSnapshot.sim_errors`. +#[derive(Resource, Debug, Default)] +pub struct SimErrorBuffer { + errors: Vec, +} + +impl SimErrorBuffer { + /// Push a new error into the buffer. + pub fn push(&mut self, error: SimError) { + self.errors.push(error); + } + + /// Drain all buffered errors, returning them and clearing the buffer. + pub fn drain(&mut self) -> Vec { + std::mem::take(&mut self.errors) + } + + /// Check if there are pending errors. + pub fn has_errors(&self) -> bool { + !self.errors.is_empty() + } +} + /// Snapshot buffer resource for staging outgoing ObserverSnapshots #[derive(Resource, Debug, Default)] pub struct SnapshotBuffer { diff --git a/server/src/main.rs b/server/src/main.rs index 61b66fd2d..298a1426e 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -176,11 +176,43 @@ fn main() { // Targets ~20 ticks/sec (2 game-minutes/sec). The TCP bridge uses // non-blocking reads, so without throttling this loop would spin. // Remaining frame budget is available for NPC AI and pathfinding. + // + // Panic supervision (#85): each tick is wrapped in catch_unwind. + // On panic, the server sends a structured SimError to the client + // before shutting down, rather than an abrupt disconnect. let target_frame_time = std::time::Duration::from_millis(50); loop { let frame_start = std::time::Instant::now(); - app.update(); + // Wrap app.update() in catch_unwind to handle system panics (#85). + // AssertUnwindSafe is required because App is not UnwindSafe. + let tick_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + app.update(); + })); + + match tick_result { + Ok(()) => {} + Err(panic_payload) => { + // Extract panic message for error reporting + let panic_msg = if let Some(s) = panic_payload.downcast_ref::<&str>() { + s.to_string() + } else if let Some(s) = panic_payload.downcast_ref::() { + s.clone() + } else { + "unknown panic".to_string() + }; + + tracing::error!("Simulation panic caught: {}", panic_msg); + + // Attempt to send a final SimError snapshot to the client. + // Best-effort: if the bridge is unavailable, we just log and exit. + send_panic_error(&app, &panic_msg); + + tracing::error!("Server shutting down after panic"); + break; + } + } + if !app.world().resource::().0 { break; } @@ -200,6 +232,78 @@ fn main() { tracing::info!("Simulation server shutting down"); } +/// Best-effort: send a final SimError snapshot to the client on panic (#85). +/// +/// Builds a minimal ObserverSnapshot with the panic error and sends it +/// through the bridge. If the bridge is unavailable or sending fails, +/// the error is logged but not fatal (we're already crashing). +fn send_panic_error(app: &App, panic_msg: &str) { + use settled_reach_server::bridge::types::*; + use settled_reach_server::simulation::time::{DayPhase, TickRate}; + + let world = app.world(); + + // Try to read current tick from SimulationTime + let tick = world + .get_resource::() + .map(|t| t.tick) + .unwrap_or(0); + + let bridge = match world.get_resource::() { + Some(b) => b, + None => { + tracing::error!("Cannot send panic error: no BridgeResource"); + return; + } + }; + + // Build a minimal snapshot carrying the panic error + let snapshot = ObserverSnapshot { + version: PROTOCOL_VERSION, + tick, + game_time: GameTime { + day: 0, + time_of_day: 0, + day_phase: DayPhase::Morning, + tick_rate: TickRate::Paused, + }, + player_facing: FacingDirection::North, + player_stance: MovementStance::default(), + player_inventory: vec![], + entities: vec![], + visible_tiles: vec![], + nearby_interactions: vec![], + current_monologue: None, + pending_recognitions: vec![], + dialogue_response: None, + blocked_entities: vec![], + scan_events: vec![], + sound_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], + follow_state: None, + character_pressure: None, + rng_seed: None, + poi_list: vec![], + examine_result: None, + player_knowledge: None, + save_result: None, + triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![SimError { + kind: SimErrorKind::Panic, + message: format!("Simulation panic: {}", panic_msg), + tick, + }], + }; + + if let Err(e) = bridge.send_snapshot(&snapshot) { + tracing::error!("Failed to send panic error to client: {}", e); + } else { + tracing::info!("Sent panic SimError to client at tick {}", tick); + } +} + /// Print bevy_ecs schedule graph and exit. /// Invoked by --dump-schedule CLI flag (#346). /// diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index f1b3de548..2101f6efc 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -9,6 +9,8 @@ use bevy_ecs::prelude::*; use std::collections::BTreeSet; +use std::hash::{Hash, Hasher}; + use crate::bridge::types::*; use crate::knowledge::graph::filter_by_access; use crate::knowledge::types::{AccessRule, KnowledgeState}; @@ -103,6 +105,8 @@ pub fn compute_observer_snapshot( mut crisis_queue: ResMut, sim_rng: Option>, pressure_query: Query<&crate::simulation::pressure::CharacterPressure, With>, + error_buffer: Option>, + npc_count_query: Query>, ) { let Ok(( observer_entity, @@ -396,6 +400,25 @@ pub fn compute_observer_snapshot( .map(TriangleCrisisEventWire::from) .collect(); + // Compute state hash for desync detection (#85). + // Hash inputs: player position (x, y, z), NPC count, tick number. + // Uses DefaultHasher for speed — not cryptographic, just comparison. + let state_hash = { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + time.tick.hash(&mut hasher); + observer_pos.x.hash(&mut hasher); + observer_pos.y.hash(&mut hasher); + observer_pos.z.hash(&mut hasher); + let npc_count = npc_count_query.iter().count() as u64; + npc_count.hash(&mut hasher); + Some(hasher.finish()) + }; + + // Drain sim errors collected this tick (#85) + let sim_errors = error_buffer + .map(|mut buf| buf.drain()) + .unwrap_or_default(); + buffer.snapshot = Some(ObserverSnapshot { version: crate::bridge::types::PROTOCOL_VERSION, tick: time.tick, @@ -424,6 +447,8 @@ pub fn compute_observer_snapshot( player_knowledge, save_result, triangle_crisis_events, + state_hash, + sim_errors, }); } diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index 0c18df4bd..55275814c 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -73,6 +73,8 @@ fn snapshot_roundtrip_over_unix_socket() { player_knowledge: None, save_result: None, triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], }; bridge diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index c7095a7bf..75ad29a3d 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -59,6 +59,8 @@ fn snapshot_roundtrip_over_tcp() { player_knowledge: None, save_result: None, triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], }; bridge diff --git a/server/tests/error_handling.rs b/server/tests/error_handling.rs new file mode 100644 index 000000000..78ec5bc07 --- /dev/null +++ b/server/tests/error_handling.rs @@ -0,0 +1,314 @@ +//! Error handling integration tests (#85). +//! +//! Tests the three error categories: +//! 1. Protocol errors: malformed input → SimError + server continues +//! 2. Desync detection: state_hash field populated in snapshots +//! 3. SimError wire format roundtrip + +use bevy_app::prelude::*; +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, ServerRunning}; +use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin}; +use settled_reach_server::npc::relationships::TrustEventQueue; +use settled_reach_server::simulation::interaction::NearbyInteractionBuffer; +use settled_reach_server::simulation::monologue::{MonologueBuffer, MonologueState}; +use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; +use settled_reach_server::simulation::SimulationPlugin; +use std::io::{BufReader, BufWriter}; +use std::net::{TcpListener, TcpStream}; +use std::sync::{Arc, Barrier}; +use std::thread; + +/// Acceptance test (#85): send a malformed message mid-session, assert the +/// server emits a SimError in the next snapshot and continues running. +/// +/// Uses barriers to synchronize the server and client threads, ensuring +/// the malformed data arrives before the server's receive_inputs call. +#[test] +fn malformed_input_produces_sim_error_and_server_continues() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener"); + let server_addr = listener.local_addr().expect("get local addr"); + + // Barriers for tick synchronization between server and client. + // Each barrier is used once: client signals "data sent", server proceeds to tick. + let barrier_tick1 = Arc::new(Barrier::new(2)); + let barrier_tick2 = Arc::new(Barrier::new(2)); + let barrier_tick3 = Arc::new(Barrier::new(2)); + + let b1_server = Arc::clone(&barrier_tick1); + let b2_server = Arc::clone(&barrier_tick2); + let b3_server = Arc::clone(&barrier_tick3); + + // Server thread + 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(KnowledgePlugin); + app.init_resource::(); + app.insert_resource(BridgeResource::new(bridge)); + app.insert_resource(WalkabilityMap::new(32, 32, 1)); + app.world_mut().spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + MonologueState::default(), + )); + + // Tick 1: wait for client to send valid input, then process + b1_server.wait(); + app.update(); + assert!( + app.world().resource::().0, + "server should be running after tick 1" + ); + + // Tick 2: wait for client to send malformed input, then process + b2_server.wait(); + app.update(); + assert!( + app.world().resource::().0, + "server must continue running after malformed input" + ); + + // Tick 3: wait for client to send valid input, then process + b3_server.wait(); + app.update(); + assert!( + app.world().resource::().0, + "server should still be running after tick 3" + ); + }); + + // Client: connect and interact with synchronization + 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); + + // --- Tick 1: send valid input --- + let valid_inputs = vec![PlayerInput { + tick: 0, + action: PlayerAction::MoveNorth, + }]; + let payload = rmp_serde::to_vec_named(&valid_inputs).expect("serialize"); + write_framed(&mut writer, &payload).expect("send valid input"); + barrier_tick1.wait(); // Signal: valid input sent + + // Read tick 1 snapshot + let snap1_bytes = read_framed(&mut reader) + .expect("read snapshot 1") + .expect("not EOF"); + let snap1: ObserverSnapshot = + rmp_serde::from_slice(&snap1_bytes).expect("deserialize snapshot 1"); + assert!( + snap1.sim_errors.is_empty(), + "no errors expected on tick 1" + ); + + // --- Tick 2: send malformed input (properly framed but garbage payload) --- + let garbage_payload: Vec = vec![0xFF, 0xFE, 0xFD, 0xFC, 0xAB, 0xCD, 0xEF]; + write_framed(&mut writer, &garbage_payload).expect("send malformed input"); + barrier_tick2.wait(); // Signal: malformed input sent + + // Read tick 2 snapshot — should contain SimError + let snap2_bytes = read_framed(&mut reader) + .expect("read snapshot 2") + .expect("not EOF"); + let snap2: ObserverSnapshot = + rmp_serde::from_slice(&snap2_bytes).expect("deserialize snapshot 2"); + assert!( + !snap2.sim_errors.is_empty(), + "sim_errors must contain the protocol error from malformed input" + ); + assert_eq!( + snap2.sim_errors[0].kind, + SimErrorKind::ProtocolError, + "error kind must be ProtocolError" + ); + assert!( + snap2.sim_errors[0].message.contains("Malformed input frame") + || snap2.sim_errors[0].message.contains("Deserialization error"), + "error message should describe the deserialization failure, got: {}", + snap2.sim_errors[0].message, + ); + + // --- Tick 3: send valid input again — server must still work --- + let valid_inputs2 = vec![PlayerInput { + tick: 2, + action: PlayerAction::MoveSouth, + }]; + let payload2 = rmp_serde::to_vec_named(&valid_inputs2).expect("serialize"); + write_framed(&mut writer, &payload2).expect("send valid input after error"); + barrier_tick3.wait(); // Signal: valid input sent + + // Read tick 3 snapshot — no errors, server recovered + let snap3_bytes = read_framed(&mut reader) + .expect("read snapshot 3") + .expect("not EOF"); + let snap3: ObserverSnapshot = + rmp_serde::from_slice(&snap3_bytes).expect("deserialize snapshot 3"); + assert!( + snap3.sim_errors.is_empty(), + "no errors expected on tick 3 — server recovered" + ); + + // Clean up + drop(reader); + drop(writer); + server_handle.join().expect("server thread should not panic"); +} + +/// State hash is populated in every snapshot and is deterministic for same state. +#[test] +fn state_hash_populated_in_snapshot() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener"); + let server_addr = listener.local_addr().expect("get local addr"); + + 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(KnowledgePlugin); + app.init_resource::(); + app.insert_resource(BridgeResource::new(bridge)); + app.insert_resource(WalkabilityMap::new(32, 32, 1)); + app.world_mut().spawn(( + PlayerCharacter, + TilePosition::new(16, 16, 0), + KnowledgeGraph::new(), + NearbyInteractionBuffer::default(), + MonologueBuffer::default(), + MonologueState::default(), + )); + + // Run one tick + app.update(); + }); + + 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); + + // Send empty input batch (no movement) + let inputs: Vec = vec![]; + let payload = rmp_serde::to_vec_named(&inputs).expect("serialize"); + write_framed(&mut writer, &payload).expect("send empty input"); + + // Read snapshot + let snap_bytes = read_framed(&mut reader) + .expect("read snapshot") + .expect("not EOF"); + let snap: ObserverSnapshot = + rmp_serde::from_slice(&snap_bytes).expect("deserialize snapshot"); + + assert!( + snap.state_hash.is_some(), + "state_hash must be populated in snapshot" + ); + assert_ne!( + snap.state_hash.unwrap(), + 0, + "state_hash should be a non-trivial hash value" + ); + + drop(reader); + drop(writer); + server_handle.join().expect("server thread should not panic"); +} + +/// SimError roundtrips through MessagePack serialization. +#[test] +fn sim_error_roundtrip() { + let error = SimError { + kind: SimErrorKind::ProtocolError, + message: "test protocol error".into(), + tick: 42, + }; + + let bytes = rmp_serde::to_vec_named(&error).expect("serialize SimError"); + let decoded: SimError = rmp_serde::from_slice(&bytes).expect("deserialize SimError"); + + assert_eq!(decoded.kind, SimErrorKind::ProtocolError); + assert_eq!(decoded.message, "test protocol error"); + assert_eq!(decoded.tick, 42); +} + +/// SimErrorKind::Panic variant roundtrips. +#[test] +fn sim_error_panic_variant_roundtrip() { + let error = SimError { + kind: SimErrorKind::Panic, + message: "simulation system panicked".into(), + tick: 100, + }; + + let bytes = rmp_serde::to_vec_named(&error).expect("serialize"); + let decoded: SimError = rmp_serde::from_slice(&bytes).expect("deserialize"); + + assert_eq!(decoded.kind, SimErrorKind::Panic); + assert_eq!(decoded.message, "simulation system panicked"); + assert_eq!(decoded.tick, 100); +} + +/// Snapshot with sim_errors populates correctly through serialization. +#[test] +fn snapshot_with_sim_errors_roundtrips() { + use settled_reach_server::simulation::time::{DayPhase, TickRate}; + + let snapshot = ObserverSnapshot { + version: PROTOCOL_VERSION, + tick: 10, + game_time: GameTime { + day: 0, + time_of_day: 0, + day_phase: DayPhase::Morning, + tick_rate: TickRate::Full, + }, + player_facing: FacingDirection::North, + player_stance: MovementStance::default(), + player_inventory: vec![], + entities: vec![], + visible_tiles: vec![], + nearby_interactions: vec![], + current_monologue: None, + pending_recognitions: vec![], + dialogue_response: None, + blocked_entities: vec![], + scan_events: vec![], + sound_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], + follow_state: None, + character_pressure: None, + rng_seed: None, + poi_list: vec![], + examine_result: None, + player_knowledge: None, + save_result: None, + triangle_crisis_events: vec![], + state_hash: Some(0xDEADBEEF), + sim_errors: vec![ + SimError { + kind: SimErrorKind::ProtocolError, + message: "bad frame".into(), + tick: 10, + }, + ], + }; + + let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); + + assert_eq!(decoded.state_hash, Some(0xDEADBEEF)); + assert_eq!(decoded.sim_errors.len(), 1); + assert_eq!(decoded.sim_errors[0].kind, SimErrorKind::ProtocolError); + assert_eq!(decoded.sim_errors[0].message, "bad frame"); +} diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 3968faffc..3582b4398 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -49,6 +49,8 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot player_knowledge: None, save_result: None, triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], } } @@ -241,6 +243,8 @@ fn generate_msgpack_fixtures() { player_knowledge: None, save_result: None, triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], }; write_fixture( "snapshot_v2_full", @@ -403,6 +407,8 @@ fn generate_msgpack_fixtures() { }], }), triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], }; write_fixture( "snapshot_full", diff --git a/server/tests/golden/proof_room_tick_10.json b/server/tests/golden/proof_room_tick_10.json index 257998a14..fbfd23df1 100644 --- a/server/tests/golden/proof_room_tick_10.json +++ b/server/tests/golden/proof_room_tick_10.json @@ -72,9 +72,10 @@ "rng_seed": 42, "scan_events": [], "sound_events": [], + "state_hash": 8423425600886013858, "tick": 8, "triangle_crisis_events": [], - "version": 16, + "version": 17, "visible_tiles": [ { "tile_kind": "Wall", diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 2589ae802..959f93d57 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -37,6 +37,8 @@ fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { player_knowledge: None, save_result: None, triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], } } @@ -296,6 +298,8 @@ fn snapshot_v2_fields_roundtrip() { player_knowledge: None, save_result: None, triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); @@ -350,7 +354,7 @@ fn protocol_version_constant_matches_snapshot() { let snapshot = test_snapshot(0, vec![]); assert_eq!(snapshot.version, PROTOCOL_VERSION); assert_eq!( - PROTOCOL_VERSION, 16, + PROTOCOL_VERSION, 17, "bump this assertion when protocol version changes" ); } @@ -401,6 +405,8 @@ fn all_facing_direction_variants_roundtrip() { player_knowledge: None, save_result: None, triangle_crisis_events: vec![], + state_hash: None, + sim_errors: vec![], }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");