Merge branch 'server' into main (PR #6)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 21:26:48 +01:00
co-authored by Claude Opus 4.6
14 changed files with 713 additions and 31 deletions
+1 -1
View File
@@ -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
+131
View File
@@ -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");
}
+74
View File
@@ -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");
}
+37 -7
View File
@@ -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 [
+13 -8
View File
@@ -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"