Fix all Clippy warnings across the server codebase (2411 insertions, 1341 deletions). Raise type-complexity-threshold to 750 and too-many-arguments to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server now passes `cargo clippy -- --deny warnings` cleanly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
320 lines
12 KiB
Rust
320 lines
12 KiB
Rust
//! 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),
|
|
debug_response: None,
|
|
sim_errors: vec![SimError {
|
|
kind: SimErrorKind::ProtocolError,
|
|
message: "bad frame".into(),
|
|
tick: 10,
|
|
}],
|
|
current_ticker: None,
|
|
settings_response: None,
|
|
};
|
|
|
|
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");
|
|
}
|