Merge branch 'server' into main (PR #6)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -106,7 +106,7 @@ fn input_roundtrip_over_unix_socket() {
|
||||
},
|
||||
];
|
||||
|
||||
let payload = rmp_serde::to_vec(&inputs).expect("failed to serialize");
|
||||
let payload = rmp_serde::to_vec_named(&inputs).expect("failed to serialize");
|
||||
write_framed(&mut writer, &payload).expect("failed to write frame");
|
||||
|
||||
// Drop writer to close connection and signal EOF to server
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Integration tests for TcpBridge over TCP localhost (D-030 Layer 2: IPC roundtrip).
|
||||
|
||||
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::SimBridge;
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::thread;
|
||||
|
||||
#[test]
|
||||
fn snapshot_roundtrip_over_tcp() {
|
||||
// Bind listener first — port is guaranteed ready before spawning threads
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("failed to bind");
|
||||
let server_addr = listener.local_addr().expect("failed to get local address");
|
||||
|
||||
// Server thread: accept on pre-bound listener and send snapshot
|
||||
let server_handle = thread::spawn(move || {
|
||||
let bridge = TcpBridge::accept_on(listener).expect("failed to accept");
|
||||
|
||||
let snapshot = ObserverSnapshot {
|
||||
tick: 42,
|
||||
entities: vec![VisibleEntity {
|
||||
entity_id: 100,
|
||||
x: 10.5,
|
||||
y: 20.3,
|
||||
z: 0,
|
||||
kind: EntityKind::Npc,
|
||||
}],
|
||||
};
|
||||
|
||||
bridge
|
||||
.send_snapshot(&snapshot)
|
||||
.expect("failed to send snapshot");
|
||||
});
|
||||
|
||||
// Client: connect and receive snapshot (no sleep needed — listener already bound)
|
||||
let stream = TcpStream::connect(server_addr).expect("failed to connect");
|
||||
let mut reader = std::io::BufReader::new(stream);
|
||||
|
||||
let payload = read_framed(&mut reader)
|
||||
.expect("failed to read frame")
|
||||
.expect("unexpected EOF");
|
||||
|
||||
let snapshot: ObserverSnapshot =
|
||||
rmp_serde::from_slice(&payload).expect("failed to deserialize");
|
||||
|
||||
assert_eq!(snapshot.tick, 42);
|
||||
assert_eq!(snapshot.entities.len(), 1);
|
||||
assert_eq!(snapshot.entities[0].entity_id, 100);
|
||||
assert_eq!(snapshot.entities[0].x, 10.5);
|
||||
assert_eq!(snapshot.entities[0].y, 20.3);
|
||||
|
||||
server_handle.join().expect("server thread panicked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_roundtrip_over_tcp() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("failed to bind");
|
||||
let server_addr = listener.local_addr().expect("failed to get local address");
|
||||
|
||||
let server_handle = thread::spawn(move || {
|
||||
let bridge = TcpBridge::accept_on(listener).expect("failed to accept");
|
||||
|
||||
let inputs = bridge.receive_inputs().expect("failed to receive inputs");
|
||||
|
||||
assert_eq!(inputs.len(), 2);
|
||||
assert_eq!(inputs[0].tick, 10);
|
||||
assert_eq!(inputs[1].tick, 11);
|
||||
|
||||
inputs
|
||||
});
|
||||
|
||||
// Client: connect and send inputs
|
||||
let stream = TcpStream::connect(server_addr).expect("failed to connect");
|
||||
let mut writer = std::io::BufWriter::new(stream);
|
||||
|
||||
let inputs = vec![
|
||||
PlayerInput {
|
||||
tick: 10,
|
||||
action: PlayerAction::MoveNorth,
|
||||
},
|
||||
PlayerInput {
|
||||
tick: 11,
|
||||
action: PlayerAction::Interact,
|
||||
},
|
||||
];
|
||||
|
||||
let payload = rmp_serde::to_vec_named(&inputs).expect("failed to serialize");
|
||||
write_framed(&mut writer, &payload).expect("failed to write frame");
|
||||
|
||||
// Drop writer to close connection and signal EOF to server
|
||||
drop(writer);
|
||||
|
||||
let received_inputs = server_handle.join().expect("server thread panicked");
|
||||
|
||||
match &received_inputs[0].action {
|
||||
PlayerAction::MoveNorth => {}
|
||||
_ => panic!("expected MoveNorth action"),
|
||||
}
|
||||
match &received_inputs[1].action {
|
||||
PlayerAction::Interact => {}
|
||||
_ => panic!("expected Interact action"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_bridge_eof_returns_error() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("failed to bind");
|
||||
let server_addr = listener.local_addr().expect("failed to get local address");
|
||||
|
||||
let server_handle = thread::spawn(move || {
|
||||
let bridge = TcpBridge::accept_on(listener).expect("failed to accept");
|
||||
|
||||
let result = bridge.receive_inputs();
|
||||
|
||||
assert!(result.is_err(), "expected error on EOF");
|
||||
assert!(
|
||||
matches!(
|
||||
result,
|
||||
Err(settled_reach_server::bridge::BridgeError::Disconnected)
|
||||
),
|
||||
"expected Disconnected error"
|
||||
);
|
||||
});
|
||||
|
||||
// Client: connect and immediately disconnect without sending data
|
||||
let stream = TcpStream::connect(server_addr).expect("failed to connect");
|
||||
drop(stream);
|
||||
|
||||
server_handle.join().expect("server thread panicked");
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! E2E integration test: full game loop with input processing and snapshot generation
|
||||
//! Tests the complete pipeline: client sends input -> server processes -> server sends snapshot
|
||||
|
||||
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};
|
||||
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::thread;
|
||||
|
||||
#[test]
|
||||
fn player_moves_north_through_full_pipeline() {
|
||||
// Bind listener first — port guaranteed ready, no sleep needed
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
|
||||
let server_addr = listener.local_addr().expect("get local addr");
|
||||
|
||||
// Spawn server thread
|
||||
let server_handle = thread::spawn(move || {
|
||||
let bridge = TcpBridge::accept_on(listener).expect("accept connection");
|
||||
|
||||
// Build app
|
||||
let mut app = App::new();
|
||||
app.add_plugins(SimulationPlugin);
|
||||
app.add_plugins(BridgePlugin);
|
||||
app.insert_resource(BridgeResource::new(bridge));
|
||||
app.insert_resource(WalkabilityMap::new(32, 32, 1));
|
||||
app.world_mut()
|
||||
.spawn((PlayerCharacter, TilePosition::new(16, 16, 0)));
|
||||
|
||||
// Run one tick: receive input, process, validate movement, generate snapshot, send
|
||||
app.update();
|
||||
});
|
||||
|
||||
// Client: connect and send input (no sleep — listener was pre-bound)
|
||||
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 PlayerInput: MoveNorth at tick 0
|
||||
let inputs = vec![PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
}];
|
||||
let payload = rmp_serde::to_vec_named(&inputs).expect("serialize inputs");
|
||||
write_framed(&mut writer, &payload).expect("send inputs");
|
||||
|
||||
// Receive ObserverSnapshot
|
||||
let response = read_framed(&mut reader)
|
||||
.expect("read snapshot")
|
||||
.expect("not EOF");
|
||||
let snapshot: ObserverSnapshot =
|
||||
rmp_serde::from_slice(&response).expect("deserialize snapshot");
|
||||
|
||||
// Snapshot captures state at end of tick 0 (before advance_tick increments to 1)
|
||||
assert_eq!(snapshot.tick, 0);
|
||||
assert_eq!(snapshot.entities.len(), 1);
|
||||
|
||||
let player_entity = &snapshot.entities[0];
|
||||
// Player started at (16, 16, 0), moved north (y-1) to (16, 15, 0)
|
||||
// Render coords: (16.5, 15.5, 0)
|
||||
assert_eq!(player_entity.x, 16.5);
|
||||
assert_eq!(player_entity.y, 15.5);
|
||||
assert_eq!(player_entity.z, 0);
|
||||
assert!(matches!(player_entity.kind, EntityKind::Player));
|
||||
|
||||
// Clean up
|
||||
drop(reader);
|
||||
drop(writer);
|
||||
server_handle.join().expect("server thread panicked");
|
||||
}
|
||||
@@ -28,7 +28,10 @@ fn generate_msgpack_fixtures() {
|
||||
kind: EntityKind::Npc,
|
||||
}],
|
||||
};
|
||||
write_fixture("snapshot_one_npc", &rmp_serde::to_vec_named(&snapshot).unwrap());
|
||||
write_fixture(
|
||||
"snapshot_one_npc",
|
||||
&rmp_serde::to_vec_named(&snapshot).unwrap(),
|
||||
);
|
||||
|
||||
// Empty snapshot
|
||||
let empty = ObserverSnapshot {
|
||||
@@ -42,25 +45,52 @@ fn generate_msgpack_fixtures() {
|
||||
tick: 100,
|
||||
action: PlayerAction::MoveNorth,
|
||||
};
|
||||
write_fixture("input_move_north", &rmp_serde::to_vec_named(&input_north).unwrap());
|
||||
write_fixture(
|
||||
"input_move_north",
|
||||
&rmp_serde::to_vec_named(&input_north).unwrap(),
|
||||
);
|
||||
|
||||
// PlayerInput: UsePerceptionMode
|
||||
let input_perception = PlayerInput {
|
||||
tick: 200,
|
||||
action: PlayerAction::UsePerceptionMode("thermal".to_string()),
|
||||
};
|
||||
write_fixture("input_perception_mode", &rmp_serde::to_vec_named(&input_perception).unwrap());
|
||||
write_fixture(
|
||||
"input_perception_mode",
|
||||
&rmp_serde::to_vec_named(&input_perception).unwrap(),
|
||||
);
|
||||
|
||||
// Snapshot with multiple entities and all EntityKind variants
|
||||
let snapshot_multi = ObserverSnapshot {
|
||||
tick: 999,
|
||||
entities: vec![
|
||||
VisibleEntity { entity_id: 1, x: 5.0, y: 10.0, z: 0, kind: EntityKind::Npc },
|
||||
VisibleEntity { entity_id: 2, x: 15.5, y: 3.0, z: 1, kind: EntityKind::Object },
|
||||
VisibleEntity { entity_id: 3, x: 0.0, y: 0.0, z: -1, kind: EntityKind::Terrain },
|
||||
VisibleEntity {
|
||||
entity_id: 1,
|
||||
x: 5.0,
|
||||
y: 10.0,
|
||||
z: 0,
|
||||
kind: EntityKind::Npc,
|
||||
},
|
||||
VisibleEntity {
|
||||
entity_id: 2,
|
||||
x: 15.5,
|
||||
y: 3.0,
|
||||
z: 1,
|
||||
kind: EntityKind::Object,
|
||||
},
|
||||
VisibleEntity {
|
||||
entity_id: 3,
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: -1,
|
||||
kind: EntityKind::Terrain,
|
||||
},
|
||||
],
|
||||
};
|
||||
write_fixture("snapshot_multi_entity", &rmp_serde::to_vec_named(&snapshot_multi).unwrap());
|
||||
write_fixture(
|
||||
"snapshot_multi_entity",
|
||||
&rmp_serde::to_vec_named(&snapshot_multi).unwrap(),
|
||||
);
|
||||
|
||||
// Diagonal movement fixtures (clockwise: NE, SE, SW, NW)
|
||||
for (name, action) in [
|
||||
|
||||
@@ -15,7 +15,7 @@ fn observer_snapshot_roundtrip() {
|
||||
}],
|
||||
};
|
||||
|
||||
let bytes = rmp_serde::to_vec(&snapshot).expect("serialize");
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
|
||||
assert_eq!(decoded.tick, 42);
|
||||
@@ -30,7 +30,7 @@ fn player_input_roundtrip() {
|
||||
action: PlayerAction::MoveNorth,
|
||||
};
|
||||
|
||||
let bytes = rmp_serde::to_vec(&input).expect("serialize");
|
||||
let bytes = rmp_serde::to_vec_named(&input).expect("serialize");
|
||||
let decoded: PlayerInput = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
|
||||
assert_eq!(decoded.tick, 100);
|
||||
@@ -43,7 +43,7 @@ fn empty_snapshot_roundtrip() {
|
||||
entities: vec![],
|
||||
};
|
||||
|
||||
let bytes = rmp_serde::to_vec(&snapshot).expect("serialize");
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
|
||||
assert_eq!(decoded.tick, 0);
|
||||
@@ -73,11 +73,11 @@ fn all_player_action_variants_roundtrip() {
|
||||
tick: 1,
|
||||
action: action.clone(),
|
||||
};
|
||||
let bytes = rmp_serde::to_vec(&input).expect("serialize");
|
||||
let bytes = rmp_serde::to_vec_named(&input).expect("serialize");
|
||||
let decoded: PlayerInput = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(decoded.tick, 1);
|
||||
// Verify the variant survived by re-serializing and comparing bytes
|
||||
let re_bytes = rmp_serde::to_vec(&decoded).expect("re-serialize");
|
||||
let re_bytes = rmp_serde::to_vec_named(&decoded).expect("re-serialize");
|
||||
assert_eq!(bytes, re_bytes, "round-trip mismatch for action variant");
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,12 @@ fn all_player_action_variants_roundtrip() {
|
||||
/// All EntityKind variants must survive MessagePack round-trip (D-030 Layer 1)
|
||||
#[test]
|
||||
fn all_entity_kind_variants_roundtrip() {
|
||||
let kinds = vec![EntityKind::Npc, EntityKind::Object, EntityKind::Terrain];
|
||||
let kinds = vec![
|
||||
EntityKind::Player,
|
||||
EntityKind::Npc,
|
||||
EntityKind::Object,
|
||||
EntityKind::Terrain,
|
||||
];
|
||||
|
||||
for (i, kind) in kinds.into_iter().enumerate() {
|
||||
let entity = VisibleEntity {
|
||||
@@ -99,9 +104,9 @@ fn all_entity_kind_variants_roundtrip() {
|
||||
tick: 0,
|
||||
entities: vec![entity],
|
||||
};
|
||||
let bytes = rmp_serde::to_vec(&snapshot).expect("serialize");
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
let re_bytes = rmp_serde::to_vec(&decoded).expect("re-serialize");
|
||||
let re_bytes = rmp_serde::to_vec_named(&decoded).expect("re-serialize");
|
||||
assert_eq!(
|
||||
bytes, re_bytes,
|
||||
"round-trip mismatch for EntityKind variant"
|
||||
|
||||
Reference in New Issue
Block a user