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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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