feat(simulation): error handling and recovery — panic supervision, state hash, structured errors (#85)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Res<BridgeResource>>,
|
||||
mut input_queue: ResMut<crate::simulation::input::InputQueue>,
|
||||
mut running: ResMut<ServerRunning>,
|
||||
handshake: Res<HandshakeState>,
|
||||
mut error_buffer: ResMut<SimErrorBuffer>,
|
||||
time: Option<Res<crate::simulation::time::SimulationTime>>,
|
||||
) {
|
||||
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::<SnapshotBuffer>()
|
||||
.init_resource::<ServerRunning>()
|
||||
.init_resource::<HandshakeState>()
|
||||
.init_resource::<SimErrorBuffer>()
|
||||
.init_resource::<crate::perception::query::VisibilityGeometry>()
|
||||
.init_resource::<crate::perception::query::ActivePerceptionMode>()
|
||||
.add_systems(
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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<TriangleCrisisEventWire>,
|
||||
/// 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<u64>,
|
||||
/// 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<SimError>,
|
||||
}
|
||||
|
||||
/// Game time data for client display (D-031)
|
||||
@@ -714,6 +729,56 @@ impl From<crate::content::template::TriangleCrisisEvent> 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<SimError>,
|
||||
}
|
||||
|
||||
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<SimError> {
|
||||
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 {
|
||||
|
||||
+105
-1
@@ -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::<String>() {
|
||||
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::<ServerRunning>().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::<settled_reach_server::simulation::time::SimulationTime>()
|
||||
.map(|t| t.tick)
|
||||
.unwrap_or(0);
|
||||
|
||||
let bridge = match world.get_resource::<BridgeResource>() {
|
||||
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).
|
||||
///
|
||||
|
||||
@@ -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<TriangleCrisisEventQueue>,
|
||||
sim_rng: Option<Res<SimRng>>,
|
||||
pressure_query: Query<&crate::simulation::pressure::CharacterPressure, With<PlayerCharacter>>,
|
||||
error_buffer: Option<ResMut<SimErrorBuffer>>,
|
||||
npc_count_query: Query<Entity, With<crate::npc::Npc>>,
|
||||
) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::<TrustEventQueue>();
|
||||
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::<ServerRunning>().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::<ServerRunning>().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::<ServerRunning>().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<u8> = 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::<TrustEventQueue>();
|
||||
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<PlayerInput> = 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");
|
||||
}
|
||||
@@ -49,6 +49,8 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> 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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -37,6 +37,8 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> 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");
|
||||
|
||||
Reference in New Issue
Block a user