Merge remote-tracking branch 'origin/client'
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -13,6 +13,10 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
- Q-018 through Q-023 — 6 open questions from architecture audit (shadowcasting, entity ID stability, collision resolution, tick overflow, pathfinding cache, debug visualization)
|
||||
- 5 architecture spike workshop briefs (knowledge graph, observer pipeline, NPC AI state machines, save/load, map authoring)
|
||||
- 17 tickets from architecture audit (#339-#355) — 7 Sprint 1 tasks, 5 Sprint 2+ tasks, 5 workshop epics
|
||||
- End-to-end connection test (#81) — GDScript test spawning Rust server, connecting via LocalBridge, sending MoveNorth input, verifying player movement in snapshot response (D-030 Layer 3)
|
||||
- Batch input encoding (Vec\<PlayerInput\> wire format) — Protocol.encode_player_inputs() batches all inputs per tick into one framed message matching server expectations
|
||||
- EntityKind::Player fixture — snapshot_player.msgpack for cross-language testing, multi-entity fixture updated to include all 4 entity kinds
|
||||
- 7 new tests (4 batch encoding, 1 framed batch roundtrip, 1 Player fixture decode, 1 E2E connection), 43 total client tests passing
|
||||
- LocalBridge GDScript TCP transport (#79) — 4-byte big-endian length-prefix framing matching Rust server, StreamPeerTCP wrapper with partial read handling
|
||||
- ServerProcess subprocess manager — spawns/stops Rust server via OS.create_process(), auto-cleanup on destruction
|
||||
- SimBridge live transport integration — _process() polling loop for TCP receive/send, connection state machine (DISCONNECTED → CONNECTING → CONNECTED → ERROR)
|
||||
@@ -119,7 +123,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
- Rejected alternatives R-004 through R-010 documented (pure Bevy, pure Godot, GDExtension, C++ GDExtension, Fyrox, custom framework, protobuf)
|
||||
|
||||
### Fixed
|
||||
<<<<<<< HEAD
|
||||
- create-skill references to nonexistent init_skill.py and package_skill.py scripts
|
||||
=======
|
||||
- SimBridge wire format: inputs now batch-encoded as Vec\<PlayerInput\> array per server protocol (was sending individual inputs per frame)
|
||||
- SimBridge server args: positional address format ("127.0.0.1:9876") matching server CLI, port default corrected to 9876
|
||||
>>>>>>> origin/client
|
||||
- Wire format mismatch: LocalBridge now uses rmp_serde::to_vec_named() (named maps) matching client Protocol.gd expectations
|
||||
- EOF on bridge read now returns BridgeError::Transport for disconnect detection instead of empty Vec
|
||||
- WalkabilityMap rewritten from flat Vec to chunk-based HashMap per D-012 architecture (Tyre review)
|
||||
|
||||
@@ -7,12 +7,12 @@ var state: ConnectionState = ConnectionState.DISCONNECTED
|
||||
var test_mode: bool = true # Enable test mode for development without Rust server
|
||||
var _test_tick: int = 0
|
||||
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
|
||||
var _outbound_buffer: Array[PackedByteArray] = [] # Encoded inputs awaiting transport
|
||||
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
|
||||
|
||||
# Transport layer (non-test mode)
|
||||
var _bridge: LocalBridge = null
|
||||
var _server: ServerProcess = null
|
||||
var server_port: int = 9800
|
||||
var server_port: int = 9876 # Default matches server's default bind address
|
||||
var server_path: String = "" # Path to server binary — set before connect_to_sim()
|
||||
|
||||
# Connection retry state — handles server startup delay (Critical fix #1)
|
||||
@@ -50,7 +50,8 @@ func connect_to_sim() -> void:
|
||||
# Spawn server subprocess
|
||||
if not server_path.is_empty():
|
||||
_server = ServerProcess.new()
|
||||
var pid := _server.start(server_path, ["--port", str(server_port)])
|
||||
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876")
|
||||
var pid := _server.start(server_path, ["127.0.0.1:" + str(server_port)])
|
||||
if pid <= 0:
|
||||
push_error("SimBridge: failed to start server")
|
||||
_set_state(ConnectionState.ERROR)
|
||||
@@ -129,12 +130,19 @@ func _process(delta: float) -> void:
|
||||
while msg.size() > 0:
|
||||
receive_bytes(msg)
|
||||
msg = _bridge.poll_message()
|
||||
# Send: flush outbound buffer through the bridge
|
||||
# Send: batch-encode and flush outbound buffer as one frame (Vec<PlayerInput>).
|
||||
# Inputs are drained before encoding. On encode failure the inputs are
|
||||
# intentionally dropped — re-queuing would retry the same bad data and
|
||||
# the server tick has already advanced, making stale inputs invalid.
|
||||
var outbound := drain_outbound()
|
||||
for payload in outbound:
|
||||
var err := _bridge.send_message(payload)
|
||||
if err != OK:
|
||||
push_error("SimBridge: failed to send message: %s" % error_string(err))
|
||||
if outbound.size() > 0:
|
||||
var encoded := Protocol.encode_player_inputs(outbound)
|
||||
if encoded.size() > 0:
|
||||
var err := _bridge.send_message(encoded)
|
||||
if err != OK:
|
||||
push_error("SimBridge: failed to send message: %s" % error_string(err))
|
||||
else:
|
||||
push_error("SimBridge: failed to batch-encode %d inputs (dropped)" % outbound.size())
|
||||
StreamPeerTCP.STATUS_CONNECTING:
|
||||
pass # Should not happen in CONNECTED state
|
||||
StreamPeerTCP.STATUS_ERROR:
|
||||
@@ -160,11 +168,12 @@ func send_input(player_input: Dictionary) -> Error:
|
||||
# _action_enum_to_wire already emits push_warning for invalid actions
|
||||
return ERR_INVALID_PARAMETER
|
||||
var tick: int = player_input.get("timestamp_msec", 0)
|
||||
var encoded := Protocol.encode_player_input(tick, action_name)
|
||||
if encoded.size() == 0:
|
||||
push_error("SimBridge: failed to encode player input (action=%s)" % action_name)
|
||||
return ERR_CANT_CREATE
|
||||
_outbound_buffer.append(encoded)
|
||||
var entry: Dictionary = { "tick": tick, "action_name": action_name }
|
||||
# Data variants (e.g. UsePerceptionMode) carry payload
|
||||
var action_data: Variant = player_input.get("action_data")
|
||||
if action_data != null:
|
||||
entry["action_data"] = action_data
|
||||
_outbound_buffer.append(entry)
|
||||
return OK
|
||||
|
||||
# Poll for snapshot from simulation.
|
||||
@@ -196,11 +205,11 @@ func receive_bytes(bytes: PackedByteArray) -> void:
|
||||
push_warning("SimBridge: overwriting unconsumed snapshot (tick %s replaced by %s)" % [_last_snapshot.tick, snapshot.tick])
|
||||
_last_snapshot = snapshot
|
||||
|
||||
# Drain the outbound buffer. Returns encoded messages for transport.
|
||||
func drain_outbound() -> Array[PackedByteArray]:
|
||||
var messages = _outbound_buffer.duplicate()
|
||||
# Drain the outbound buffer. Returns raw input entries for batch encoding.
|
||||
func drain_outbound() -> Array[Dictionary]:
|
||||
var inputs = _outbound_buffer.duplicate()
|
||||
_outbound_buffer.clear()
|
||||
return messages
|
||||
return inputs
|
||||
|
||||
# Map InputMapper.Action enum values to wire-format action names (matching Rust PlayerAction).
|
||||
# OPEN_MENU is client-only — no Rust equivalent, not sent over the wire.
|
||||
|
||||
@@ -107,6 +107,32 @@ static func encode_player_input(tick: int, action_name: String, action_data: Var
|
||||
return result.value
|
||||
|
||||
|
||||
## Encode an array of PlayerInputs to MessagePack bytes (Vec<PlayerInput> wire format).
|
||||
## Server expects one framed message per tick containing all inputs as a msgpack array.
|
||||
## Each entry: { "tick": int, "action_name": String, "action_data": Variant (optional) }
|
||||
static func encode_player_inputs(inputs: Array) -> PackedByteArray:
|
||||
var wire_inputs: Array = []
|
||||
for input in inputs:
|
||||
var action_name: String = input["action_name"]
|
||||
var action_data: Variant = input.get("action_data")
|
||||
var action_value: Variant
|
||||
if action_data != null:
|
||||
action_value = { action_name: action_data }
|
||||
else:
|
||||
action_value = action_name
|
||||
wire_inputs.append({
|
||||
"tick": input["tick"],
|
||||
"action": action_value,
|
||||
})
|
||||
|
||||
var result = Messagepack.encode(wire_inputs)
|
||||
if result.status != null:
|
||||
push_error("Protocol: msgpack encode failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
|
||||
return result.value
|
||||
|
||||
|
||||
## Decode a PlayerInput from MessagePack bytes (used in tests / echo scenarios).
|
||||
## Returns { "tick": int, "action": { "variant": String, "data": Variant } } or null.
|
||||
static func decode_player_input(bytes: PackedByteArray) -> Variant:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,120 @@
|
||||
## D-030 Layer 3: End-to-end connection test
|
||||
## Spawns the Rust simulation server, connects via LocalBridge,
|
||||
## sends a MoveNorth input, and verifies the snapshot response.
|
||||
## Requires: server binary built (cargo build in server/)
|
||||
class_name TestE2EConnection
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const CONNECT_TIMEOUT: float = 3.0
|
||||
const RESPONSE_TIMEOUT: float = 5.0
|
||||
const MAX_PORT_ATTEMPTS: int = 5
|
||||
|
||||
var _server_pid: int = -1
|
||||
var _bridge: LocalBridge = null
|
||||
var _test_port: int = 0
|
||||
|
||||
|
||||
func _server_binary_path() -> String:
|
||||
var project_dir := ProjectSettings.globalize_path("res://")
|
||||
return project_dir.path_join("../server/target/debug/settled-reach-server")
|
||||
|
||||
|
||||
## Pick a random high port to avoid conflicts in parallel CI runs.
|
||||
## Range 49152-65535 is the dynamic/ephemeral port range (IANA).
|
||||
static func _random_test_port() -> int:
|
||||
return 49152 + (randi() % (65535 - 49152 + 1))
|
||||
|
||||
|
||||
## Spawn server with port rotation — if the port is in use, the server exits
|
||||
## immediately (bind failure). Detect this and retry with a new random port.
|
||||
func _spawn_server(server_path: String) -> bool:
|
||||
for attempt in range(MAX_PORT_ATTEMPTS):
|
||||
_test_port = _random_test_port()
|
||||
var addr := "127.0.0.1:%d" % _test_port
|
||||
_server_pid = OS.create_process(server_path, [addr])
|
||||
if _server_pid <= 0:
|
||||
continue
|
||||
# Give server time to bind or fail
|
||||
await get_tree().create_timer(0.15).timeout
|
||||
if OS.is_process_running(_server_pid):
|
||||
return true
|
||||
# Server exited — port likely in use, try another
|
||||
_server_pid = -1
|
||||
return false
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
if _bridge != null:
|
||||
_bridge.disconnect_from_server()
|
||||
_bridge = null
|
||||
if _server_pid > 0 and OS.is_process_running(_server_pid):
|
||||
OS.kill(_server_pid)
|
||||
_server_pid = -1
|
||||
|
||||
|
||||
# -- E2E: full round-trip through server binary --------------------------------
|
||||
|
||||
func test_send_input_receive_snapshot() -> void:
|
||||
var server_path := _server_binary_path()
|
||||
if not FileAccess.file_exists(server_path):
|
||||
push_warning("E2E test skipped: server binary not found at %s" % server_path)
|
||||
return
|
||||
|
||||
# Spawn server with port rotation (retries if port is in use)
|
||||
var spawned := await _spawn_server(server_path)
|
||||
assert_bool(spawned).is_true()
|
||||
|
||||
# Connect with retries (server needs time to accept)
|
||||
_bridge = LocalBridge.new()
|
||||
var connected := false
|
||||
var elapsed := 0.0
|
||||
while elapsed < CONNECT_TIMEOUT:
|
||||
if _bridge.get_status() == StreamPeerTCP.STATUS_NONE:
|
||||
_bridge.connect_to_server("127.0.0.1", _test_port)
|
||||
_bridge.poll()
|
||||
if _bridge.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
||||
connected = true
|
||||
break
|
||||
if _bridge.get_status() == StreamPeerTCP.STATUS_ERROR:
|
||||
_bridge.disconnect_from_server()
|
||||
_bridge.reset()
|
||||
await get_tree().create_timer(0.1).timeout
|
||||
elapsed += 0.1
|
||||
|
||||
assert_bool(connected).is_true()
|
||||
|
||||
# Send batch input: MoveNorth at tick 0 (matching game_loop.rs test)
|
||||
var inputs: Array = [{"tick": 0, "action_name": "MoveNorth"}]
|
||||
var encoded := Protocol.encode_player_inputs(inputs)
|
||||
assert_that(encoded.size()).is_greater(0)
|
||||
var send_err := _bridge.send_message(encoded)
|
||||
assert_that(send_err).is_equal(OK)
|
||||
|
||||
# Poll for snapshot response
|
||||
var snapshot_bytes := PackedByteArray()
|
||||
elapsed = 0.0
|
||||
while elapsed < RESPONSE_TIMEOUT:
|
||||
_bridge.poll()
|
||||
snapshot_bytes = _bridge.poll_message()
|
||||
if snapshot_bytes.size() > 0:
|
||||
break
|
||||
await get_tree().create_timer(0.05).timeout
|
||||
elapsed += 0.05
|
||||
|
||||
assert_that(snapshot_bytes.size()).is_greater(0)
|
||||
|
||||
# Decode snapshot
|
||||
var snapshot: Variant = Protocol.decode_snapshot(snapshot_bytes)
|
||||
assert_that(snapshot).is_not_null()
|
||||
|
||||
# Server starts at tick 0, snapshot reflects state after processing
|
||||
assert_that(snapshot.tick).is_equal(0)
|
||||
assert_that(snapshot.entities.size()).is_equal(1)
|
||||
|
||||
# Player started at (16, 16, 0), moved north (y-1) to (16, 15, 0)
|
||||
# Render coords: tile center offset -> (16.5, 15.5, 0)
|
||||
var player: Dictionary = snapshot.entities[0]
|
||||
assert_float(player.x).is_equal_approx(16.5, 0.001)
|
||||
assert_float(player.y).is_equal_approx(15.5, 0.001)
|
||||
assert_that(player.z).is_equal(0)
|
||||
assert_that(player.kind.variant).is_equal("Player")
|
||||
@@ -123,6 +123,28 @@ func test_framed_protocol_input_roundtrip() -> void:
|
||||
assert_that(input.action.variant).is_equal("MoveNorth")
|
||||
|
||||
|
||||
func test_framed_protocol_batch_input_roundtrip() -> void:
|
||||
# Encode a batch of inputs (Vec<PlayerInput>), frame it, decode frame, verify wire format
|
||||
var inputs: Array = [
|
||||
{"tick": 0, "action_name": "MoveNorth"},
|
||||
{"tick": 0, "action_name": "Interact"},
|
||||
]
|
||||
var encoded := Protocol.encode_player_inputs(inputs)
|
||||
assert_that(encoded.size()).is_greater(0)
|
||||
|
||||
var framed := LocalBridge.frame_encode(encoded)
|
||||
var decoded_frame: Variant = LocalBridge.frame_decode(framed)
|
||||
assert_that(decoded_frame).is_not_null()
|
||||
|
||||
# Verify the payload is a valid msgpack array matching server expectations
|
||||
var raw: Variant = Messagepack.decode(decoded_frame.payload)
|
||||
assert_that(raw.status).is_null()
|
||||
assert_that(raw.value is Array).is_true()
|
||||
assert_that(raw.value.size()).is_equal(2)
|
||||
assert_that(raw.value[0]["action"]).is_equal("MoveNorth")
|
||||
assert_that(raw.value[1]["action"]).is_equal("Interact")
|
||||
|
||||
|
||||
# -- Diagonal movement wire mapping --------------------------------------------
|
||||
|
||||
func test_action_enum_to_wire_all_directions_clockwise() -> void:
|
||||
|
||||
@@ -42,33 +42,58 @@ func test_decode_snapshot_empty() -> void:
|
||||
assert_that(snapshot.entities.size()).is_equal(0)
|
||||
|
||||
|
||||
func test_decode_snapshot_player() -> void:
|
||||
var bytes = _load_fixture("snapshot_player")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
|
||||
assert_that(snapshot).is_not_null()
|
||||
assert_that(snapshot.tick).is_equal(1)
|
||||
assert_that(snapshot.entities.size()).is_equal(1)
|
||||
|
||||
var player = snapshot.entities[0]
|
||||
assert_that(player.entity_id).is_equal(100)
|
||||
assert_float(player.x).is_equal_approx(16.5, 0.001)
|
||||
assert_float(player.y).is_equal_approx(16.5, 0.001)
|
||||
assert_that(player.z).is_equal(0)
|
||||
assert_that(player.kind.variant).is_equal("Player")
|
||||
assert_that(player.kind.data).is_null()
|
||||
|
||||
|
||||
func test_decode_snapshot_multi_entity() -> void:
|
||||
var bytes = _load_fixture("snapshot_multi_entity")
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
|
||||
assert_that(snapshot).is_not_null()
|
||||
assert_that(snapshot.tick).is_equal(999)
|
||||
assert_that(snapshot.entities.size()).is_equal(3)
|
||||
assert_that(snapshot.entities.size()).is_equal(4)
|
||||
|
||||
# Player at (16.5, 16.5, 0)
|
||||
var player = snapshot.entities[0]
|
||||
assert_that(player.entity_id).is_equal(1)
|
||||
assert_float(player.x).is_equal_approx(16.5, 0.001)
|
||||
assert_float(player.y).is_equal_approx(16.5, 0.001)
|
||||
assert_that(player.z).is_equal(0)
|
||||
assert_that(player.kind.variant).is_equal("Player")
|
||||
|
||||
# NPC at (5, 10, 0)
|
||||
var npc = snapshot.entities[0]
|
||||
assert_that(npc.entity_id).is_equal(1)
|
||||
var npc = snapshot.entities[1]
|
||||
assert_that(npc.entity_id).is_equal(2)
|
||||
assert_float(npc.x).is_equal_approx(5.0, 0.001)
|
||||
assert_float(npc.y).is_equal_approx(10.0, 0.001)
|
||||
assert_that(npc.z).is_equal(0)
|
||||
assert_that(npc.kind.variant).is_equal("Npc")
|
||||
|
||||
# Object at (15.5, 3, 1)
|
||||
var obj = snapshot.entities[1]
|
||||
assert_that(obj.entity_id).is_equal(2)
|
||||
var obj = snapshot.entities[2]
|
||||
assert_that(obj.entity_id).is_equal(3)
|
||||
assert_float(obj.x).is_equal_approx(15.5, 0.001)
|
||||
assert_float(obj.y).is_equal_approx(3.0, 0.001)
|
||||
assert_that(obj.z).is_equal(1)
|
||||
assert_that(obj.kind.variant).is_equal("Object")
|
||||
|
||||
# Terrain at (0, 0, -1)
|
||||
var terrain = snapshot.entities[2]
|
||||
assert_that(terrain.entity_id).is_equal(3)
|
||||
var terrain = snapshot.entities[3]
|
||||
assert_that(terrain.entity_id).is_equal(4)
|
||||
assert_float(terrain.x).is_equal_approx(0.0, 0.001)
|
||||
assert_float(terrain.y).is_equal_approx(0.0, 0.001)
|
||||
assert_that(terrain.z).is_equal(-1)
|
||||
@@ -159,6 +184,26 @@ func test_decode_snapshot_missing_fields() -> void:
|
||||
assert_that(result).is_null()
|
||||
|
||||
|
||||
func test_decode_snapshot_malformed_entities_counted() -> void:
|
||||
# Snapshot with one valid and one malformed entity — decode_errors should count the bad one
|
||||
var raw := {
|
||||
"tick": 7,
|
||||
"entities": [
|
||||
{"entity_id": 1, "x": 5.0, "y": 10.0, "z": 0, "kind": "Npc"},
|
||||
{"entity_id": 2, "broken": true}, # Missing required fields
|
||||
{"x": 1.0}, # Missing entity_id, y, z, kind
|
||||
],
|
||||
}
|
||||
var encoded: Variant = Messagepack.encode(raw)
|
||||
assert_that(encoded.status).is_null()
|
||||
|
||||
var snapshot: Variant = Protocol.decode_snapshot(encoded.value)
|
||||
assert_that(snapshot).is_not_null()
|
||||
assert_that(snapshot.tick).is_equal(7)
|
||||
assert_that(snapshot.entities.size()).is_equal(1) # Only the valid entity
|
||||
assert_that(snapshot.decode_errors).is_equal(2) # Two malformed entities
|
||||
|
||||
|
||||
func test_decode_player_input_empty_bytes() -> void:
|
||||
var result = Protocol.decode_player_input(PackedByteArray())
|
||||
assert_that(result).is_null()
|
||||
@@ -169,6 +214,78 @@ func test_encode_produces_nonempty_bytes() -> void:
|
||||
assert_that(bytes.size()).is_greater(0)
|
||||
|
||||
|
||||
# -- Batch input encoding (Vec<PlayerInput> wire format) -----------------------
|
||||
|
||||
func test_encode_player_inputs_single() -> void:
|
||||
var inputs: Array = [{"tick": 10, "action_name": "MoveNorth"}]
|
||||
var bytes := Protocol.encode_player_inputs(inputs)
|
||||
assert_that(bytes.size()).is_greater(0)
|
||||
|
||||
# Decode as raw msgpack — should be an array with one element
|
||||
var raw: Variant = Messagepack.decode(bytes)
|
||||
assert_that(raw.status).is_null()
|
||||
assert_that(raw.value is Array).is_true()
|
||||
assert_that(raw.value.size()).is_equal(1)
|
||||
assert_that(raw.value[0]["tick"]).is_equal(10)
|
||||
assert_that(raw.value[0]["action"]).is_equal("MoveNorth")
|
||||
|
||||
|
||||
func test_encode_player_inputs_multiple() -> void:
|
||||
var inputs: Array = [
|
||||
{"tick": 1, "action_name": "MoveNorth"},
|
||||
{"tick": 1, "action_name": "Interact"},
|
||||
{"tick": 2, "action_name": "MoveSouthwest"},
|
||||
]
|
||||
var bytes := Protocol.encode_player_inputs(inputs)
|
||||
assert_that(bytes.size()).is_greater(0)
|
||||
|
||||
var raw: Variant = Messagepack.decode(bytes)
|
||||
assert_that(raw.status).is_null()
|
||||
assert_that(raw.value.size()).is_equal(3)
|
||||
assert_that(raw.value[0]["action"]).is_equal("MoveNorth")
|
||||
assert_that(raw.value[1]["action"]).is_equal("Interact")
|
||||
assert_that(raw.value[2]["action"]).is_equal("MoveSouthwest")
|
||||
|
||||
|
||||
func test_encode_player_inputs_with_data_variant() -> void:
|
||||
var inputs: Array = [
|
||||
{"tick": 5, "action_name": "UsePerceptionMode", "action_data": "thermal"},
|
||||
]
|
||||
var bytes := Protocol.encode_player_inputs(inputs)
|
||||
assert_that(bytes.size()).is_greater(0)
|
||||
|
||||
var raw: Variant = Messagepack.decode(bytes)
|
||||
assert_that(raw.status).is_null()
|
||||
assert_that(raw.value[0]["action"] is Dictionary).is_true()
|
||||
assert_that(raw.value[0]["action"]["UsePerceptionMode"]).is_equal("thermal")
|
||||
|
||||
|
||||
func test_encode_player_inputs_empty() -> void:
|
||||
var inputs: Array = []
|
||||
var bytes := Protocol.encode_player_inputs(inputs)
|
||||
assert_that(bytes.size()).is_greater(0)
|
||||
|
||||
var raw: Variant = Messagepack.decode(bytes)
|
||||
assert_that(raw.status).is_null()
|
||||
assert_that(raw.value is Array).is_true()
|
||||
assert_that(raw.value.size()).is_equal(0)
|
||||
|
||||
|
||||
# -- Batch input fixture (D-030 Layer 1 bidirectional symmetry) ----------------
|
||||
|
||||
func test_decode_batch_input_fixture() -> void:
|
||||
# Rust-generated Vec<PlayerInput> fixture — verifies bidirectional Layer 1 compatibility
|
||||
var bytes = _load_fixture("input_batch_two")
|
||||
var raw: Variant = Messagepack.decode(bytes)
|
||||
assert_that(raw.status).is_null()
|
||||
assert_that(raw.value is Array).is_true()
|
||||
assert_that(raw.value.size()).is_equal(2)
|
||||
assert_that(raw.value[0]["tick"]).is_equal(0)
|
||||
assert_that(raw.value[0]["action"]).is_equal("MoveNorth")
|
||||
assert_that(raw.value[1]["tick"]).is_equal(0)
|
||||
assert_that(raw.value[1]["action"]).is_equal("Interact")
|
||||
|
||||
|
||||
# -- Diagonal movement fixtures (D-030 Layer 1 cross-language) -----------------
|
||||
|
||||
func test_decode_diagonal_fixtures() -> void:
|
||||
|
||||
@@ -60,26 +60,49 @@ fn generate_msgpack_fixtures() {
|
||||
&rmp_serde::to_vec_named(&input_perception).unwrap(),
|
||||
);
|
||||
|
||||
// Snapshot with Player entity (EntityKind::Player added by server team)
|
||||
let snapshot_player = ObserverSnapshot {
|
||||
tick: 1,
|
||||
entities: vec![VisibleEntity {
|
||||
entity_id: 100,
|
||||
x: 16.5,
|
||||
y: 16.5,
|
||||
z: 0,
|
||||
kind: EntityKind::Player,
|
||||
}],
|
||||
};
|
||||
write_fixture(
|
||||
"snapshot_player",
|
||||
&rmp_serde::to_vec_named(&snapshot_player).unwrap(),
|
||||
);
|
||||
|
||||
// Snapshot with multiple entities and all EntityKind variants
|
||||
let snapshot_multi = ObserverSnapshot {
|
||||
tick: 999,
|
||||
entities: vec![
|
||||
VisibleEntity {
|
||||
entity_id: 1,
|
||||
x: 16.5,
|
||||
y: 16.5,
|
||||
z: 0,
|
||||
kind: EntityKind::Player,
|
||||
},
|
||||
VisibleEntity {
|
||||
entity_id: 2,
|
||||
x: 5.0,
|
||||
y: 10.0,
|
||||
z: 0,
|
||||
kind: EntityKind::Npc,
|
||||
},
|
||||
VisibleEntity {
|
||||
entity_id: 2,
|
||||
entity_id: 3,
|
||||
x: 15.5,
|
||||
y: 3.0,
|
||||
z: 1,
|
||||
kind: EntityKind::Object,
|
||||
},
|
||||
VisibleEntity {
|
||||
entity_id: 3,
|
||||
entity_id: 4,
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: -1,
|
||||
@@ -92,6 +115,22 @@ fn generate_msgpack_fixtures() {
|
||||
&rmp_serde::to_vec_named(&snapshot_multi).unwrap(),
|
||||
);
|
||||
|
||||
// Batch input: Vec<PlayerInput> with two actions (D-030 Layer 1 bidirectional symmetry)
|
||||
let input_batch = vec![
|
||||
PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
},
|
||||
PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Interact,
|
||||
},
|
||||
];
|
||||
write_fixture(
|
||||
"input_batch_two",
|
||||
&rmp_serde::to_vec_named(&input_batch).unwrap(),
|
||||
);
|
||||
|
||||
// Diagonal movement fixtures (clockwise: NE, SE, SW, NW)
|
||||
for (name, action) in [
|
||||
("input_move_northeast", PlayerAction::MoveNortheast),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! IPC serialization round-trip tests (D-030 Layer 1: fixture-based).
|
||||
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn observer_snapshot_roundtrip() {
|
||||
@@ -82,6 +83,46 @@ fn all_player_action_variants_roundtrip() {
|
||||
}
|
||||
}
|
||||
|
||||
/// All .msgpack fixtures must deserialize without error (guards against corruption in git).
|
||||
/// Snapshot fixtures deserialize as ObserverSnapshot, input_* as PlayerInput,
|
||||
/// input_batch_* as Vec<PlayerInput>.
|
||||
#[test]
|
||||
fn all_fixtures_deserialize() {
|
||||
let fixture_dir = std::path::Path::new("../client/tests/fixtures/msgpack");
|
||||
assert!(
|
||||
fixture_dir.exists(),
|
||||
"Fixture directory not found: {}",
|
||||
fixture_dir.display()
|
||||
);
|
||||
|
||||
let mut count = 0;
|
||||
for entry in fs::read_dir(fixture_dir).expect("read fixture dir") {
|
||||
let entry = entry.expect("read dir entry");
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("msgpack") {
|
||||
continue;
|
||||
}
|
||||
let name = path.file_stem().unwrap().to_str().unwrap().to_string();
|
||||
let bytes = fs::read(&path).unwrap_or_else(|_| panic!("read fixture {}", name));
|
||||
|
||||
if name.starts_with("snapshot") {
|
||||
rmp_serde::from_slice::<ObserverSnapshot>(&bytes)
|
||||
.unwrap_or_else(|e| panic!("deserialize snapshot fixture {}: {}", name, e));
|
||||
} else if name.starts_with("input_batch") {
|
||||
rmp_serde::from_slice::<Vec<PlayerInput>>(&bytes)
|
||||
.unwrap_or_else(|e| panic!("deserialize batch input fixture {}: {}", name, e));
|
||||
} else if name.starts_with("input") {
|
||||
rmp_serde::from_slice::<PlayerInput>(&bytes)
|
||||
.unwrap_or_else(|e| panic!("deserialize input fixture {}: {}", name, e));
|
||||
} else {
|
||||
panic!("unknown fixture naming convention: {}", name);
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
assert!(count > 0, "no fixtures found");
|
||||
eprintln!("Verified {} fixtures", count);
|
||||
}
|
||||
|
||||
/// All EntityKind variants must survive MessagePack round-trip (D-030 Layer 1)
|
||||
#[test]
|
||||
fn all_entity_kind_variants_roundtrip() {
|
||||
|
||||
Reference in New Issue
Block a user