Files
CosmicClash/Game/tests/cases/test_net_codec.gd
T
Josh Creek 4533da34e0 feat(multiplayer): Phase 1 transport, connection, and lobby
Lands tasks 1.0-1.8 of multiplayer-todo.md: the pure-function test runner,
net_codec (wire format quantizers/pack-unpack), NetworkManager (ENet
transport, manual polling, min-RTT clock sync), MatchNet (handshake,
protocol/tick-rate gating, roster with team+ready state), lobby.tscn (team
columns, switch team, ready toggle), server_boot.tscn (headless dedicated
server with structured logging and an overrun watchdog), and main_menu.gd's
Host/Join-by-IP UI (connecting overlay, cancel, bounded failure path).

Followed by an adversarial review (Opus subagent) that found and fixed two
real bugs - an unvalidated player_name broadcast that let one client's
oversized name head-of-line-block the reliable channel for everyone, and a
server-side roster leak across a host/re-host cycle - plus three gaps in
the test suite itself where a claim of "verified" wasn't actually backed
by what the test checked. All five two-process smoke tests plus the
pure-function suite are green with the strengthened assertions in place.
2026-08-20 08:18:59 +01:00

170 lines
8.2 KiB
GDScript

extends "res://tests/test_case.gd"
const NetCodec = preload("res://scripts/net_codec.gd")
const ShipAction = preload("res://scripts/ship_action.gd")
const NetBodyState = preload("res://scripts/net_body_state.gd")
const POS_TOL := 0.01 # well under the ~1.95mm quantisation step's rounding
const VEL_TOL := 0.01
const QUAT_TOL := 0.001
const AVEL_TOL := 0.2 # BALL_AVEL_RANGE/127 half-step, scaled through the ship->ball rescale
const THRUST_TOL := 1.0 / 127.0 + 0.001
func _make_action(tx: float, ty: float, tz: float, rx: float, ry: float, rz: float, turbo: bool) -> ShipAction:
var a := ShipAction.new()
a.thrust = Vector3(tx, ty, tz)
a.rotation = Vector3(rx, ry, rz)
a.turbo = turbo
return a
func test_input_header_size_matches_spec() -> void:
assert_eq(NetCodec.INPUT_HEADER_SIZE, 12, "input header size")
assert_eq(NetCodec.INPUT_ENTRY_SIZE, 7, "input entry size")
func test_input_roundtrip_single_entry() -> void:
var actions := [_make_action(1.0, -1.0, 0.5, -0.25, 0.0, 1.0, true)]
var bytes := NetCodec.pack_input(12345, 999, 6000, actions)
assert_eq(bytes.size(), NetCodec.INPUT_HEADER_SIZE + NetCodec.INPUT_ENTRY_SIZE, "1-entry payload size")
var decoded := NetCodec.unpack_input(bytes)
assert_eq(decoded["seq"], 12345, "seq")
assert_eq(decoded["count"], 1, "count")
assert_eq(decoded["ack_snapshot_tick"], 999, "ack_snapshot_tick")
assert_eq(decoded["client_send_ms"], 6000, "client_send_ms")
var a: ShipAction = decoded["actions"][0]
assert_almost_eq(a.thrust.x, 1.0, THRUST_TOL, "thrust.x")
assert_almost_eq(a.thrust.y, -1.0, THRUST_TOL, "thrust.y")
assert_almost_eq(a.thrust.z, 0.5, THRUST_TOL, "thrust.z")
assert_almost_eq(a.rotation.x, -0.25, THRUST_TOL, "rotation.x")
assert_almost_eq(a.rotation.y, 0.0, THRUST_TOL, "rotation.y")
assert_almost_eq(a.rotation.z, 1.0, THRUST_TOL, "rotation.z")
assert_true(a.turbo, "turbo bit")
func test_input_roundtrip_max_redundancy_newest_first() -> void:
var actions := [
_make_action(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, false),
_make_action(0.5, 0.0, 0.0, 0.0, 0.0, 0.0, false),
_make_action(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, false),
_make_action(-1.0, 0.0, 0.0, 0.0, 0.0, 0.0, true),
]
var bytes := NetCodec.pack_input(1, 0, 0, actions)
assert_eq(bytes.size(), NetCodec.INPUT_HEADER_SIZE + 4 * NetCodec.INPUT_ENTRY_SIZE, "4-entry payload size")
var decoded := NetCodec.unpack_input(bytes)
assert_eq(decoded["count"], 4, "count")
var decoded_actions: Array = decoded["actions"]
assert_almost_eq(decoded_actions[0].thrust.x, 1.0, THRUST_TOL, "entry 0 (newest) thrust.x")
assert_almost_eq(decoded_actions[3].thrust.x, -1.0, THRUST_TOL, "entry 3 (oldest) thrust.x")
assert_true(decoded_actions[3].turbo, "entry 3 turbo bit")
assert_true(not decoded_actions[0].turbo, "entry 0 turbo bit unset")
func test_input_redundancy_clamped_to_max() -> void:
var actions := []
for i in 6:
actions.append(_make_action(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, false))
var bytes := NetCodec.pack_input(1, 0, 0, actions)
assert_eq(bytes.size(), NetCodec.INPUT_HEADER_SIZE + NetCodec.MAX_REDUNDANCY * NetCodec.INPUT_ENTRY_SIZE, "clamped to MAX_REDUNDANCY entries")
func test_snapshot_sizes_match_spec() -> void:
assert_eq(NetCodec.SNAPSHOT_CLIENT_HEADER_SIZE, 7, "client header size")
assert_eq(NetCodec.SNAPSHOT_BODY_HEADER_SIZE, 8, "body header size")
assert_eq(NetCodec.SNAPSHOT_BODY_SIZE, 22, "per-body size")
func test_snapshot_roundtrip_seven_bodies() -> void:
var bodies: Array[NetBodyState] = []
for i in 7:
var b := NetBodyState.new()
b.position = Vector3(float(i) * 3.0 - 10.0, 1.0, -float(i) * 2.0)
b.rotation = Quaternion(Vector3.UP, float(i) * 0.3)
b.linear_velocity = Vector3(float(i), 0.0, -float(i) * 0.5)
b.angular_velocity = Vector3(0.1 * i, 0.0, 0.0)
b.frozen = (i % 2 == 0)
b.turbo = (i == 3)
b.thrust_z = -1.0 + 2.0 * float(i) / 6.0
b.stalled = (i == 5)
bodies.append(b)
var segment := NetCodec.pack_snapshot_body_segment(4242, 3, 7, bodies)
assert_eq(segment.size(), NetCodec.SNAPSHOT_BODY_HEADER_SIZE + 7 * NetCodec.SNAPSHOT_BODY_SIZE, "7-body segment size")
var packet := NetCodec.pack_snapshot(555, -2, 1234, segment)
assert_eq(packet.size(), NetCodec.SNAPSHOT_CLIENT_HEADER_SIZE + segment.size(), "full packet size")
assert_eq(packet.size(), 169, "matches multiplayer-todo.md §2.4's 169 B payload figure for 7 bodies")
var decoded := NetCodec.unpack_snapshot(packet)
assert_eq(decoded["last_input_seq"], 555, "last_input_seq")
assert_eq(decoded["input_buffer_depth"], -2, "input_buffer_depth (negative = starved)")
assert_eq(decoded["echo_client_send_ms"], 1234, "echo_client_send_ms")
assert_eq(decoded["server_tick"], 4242, "server_tick")
assert_eq(decoded["match_state"], 3, "match_state")
assert_eq(decoded["reset_gen"], 7, "reset_gen")
var decoded_bodies: Array = decoded["bodies"]
assert_eq(decoded_bodies.size(), 7, "body_count")
for i in 7:
var original: NetBodyState = bodies[i]
var b: NetBodyState = decoded_bodies[i]
assert_almost_eq(b.position.x, original.position.x, POS_TOL, "body %d position.x" % i)
assert_almost_eq(b.position.y, original.position.y, POS_TOL, "body %d position.y" % i)
assert_almost_eq(b.position.z, original.position.z, POS_TOL, "body %d position.z" % i)
assert_almost_eq(b.linear_velocity.x, original.linear_velocity.x, VEL_TOL, "body %d velocity.x" % i)
assert_almost_eq(b.rotation.x, original.rotation.x, QUAT_TOL, "body %d quat.x" % i)
assert_almost_eq(b.rotation.y, original.rotation.y, QUAT_TOL, "body %d quat.y" % i)
assert_almost_eq(b.rotation.z, original.rotation.z, QUAT_TOL, "body %d quat.z" % i)
assert_almost_eq(b.rotation.w, original.rotation.w, QUAT_TOL, "body %d quat.w (sign fold)" % i)
assert_eq(b.frozen, original.frozen, "body %d frozen" % i)
assert_eq(b.turbo, original.turbo, "body %d turbo" % i)
assert_eq(b.stalled, original.stalled, "body %d stalled" % i)
func test_snapshot_quaternion_negative_w_sign_survives() -> void:
# A quaternion whose w component is negative (same rotation as its
# positive-w twin, but exercises the sign-fold bit specifically).
var b := NetBodyState.new()
b.rotation = Quaternion(0.0, 0.0, 0.0, -1.0).normalized()
var segment := NetCodec.pack_snapshot_body_segment(0, 0, 0, [b])
var decoded := NetCodec.unpack_snapshot(NetCodec.pack_snapshot(0, 0, 0, segment))
var out: NetBodyState = decoded["bodies"][0]
assert_true(out.rotation.w < 0.0, "negative w sign must survive the round trip")
func test_thrust_z_bin_quantisation_covers_range() -> void:
assert_eq(NetCodec.quantize_thrust_z_bin(-1.0), 0, "thrust_z -1.0 -> bin 0")
assert_eq(NetCodec.quantize_thrust_z_bin(1.0), NetCodec.THRUST_Z_BIN_MAX, "thrust_z 1.0 -> max bin")
assert_almost_eq(NetCodec.dequantize_thrust_z_bin(0), -1.0, 0.001, "bin 0 -> -1.0")
assert_almost_eq(NetCodec.dequantize_thrust_z_bin(NetCodec.THRUST_Z_BIN_MAX), 1.0, 0.001, "max bin -> 1.0")
func test_ball_angular_velocity_rescale() -> void:
var ball := NetBodyState.new()
ball.avel_range = NetCodec.BALL_AVEL_RANGE
ball.angular_velocity = Vector3(20.0, -15.0, 5.0) # within ±32 rad/s, outside ship's ±4
var segment := NetCodec.pack_snapshot_body_segment(0, 0, 0, [ball])
var decoded := NetCodec.unpack_snapshot(NetCodec.pack_snapshot(0, 0, 0, segment))
var out: NetBodyState = decoded["bodies"][0]
# Decoded at the wrong (ship) range first, per unpack_snapshot's documented contract.
assert_almost_eq(out.angular_velocity.x, 20.0 / NetCodec.BALL_AVEL_RANGE * NetCodec.SHIP_AVEL_RANGE, AVEL_TOL, "undecoded-scale sanity check")
NetCodec.rescale_avel(out, NetCodec.BALL_AVEL_RANGE)
assert_almost_eq(out.angular_velocity.x, 20.0, AVEL_TOL, "rescaled avel.x")
assert_almost_eq(out.angular_velocity.y, -15.0, AVEL_TOL, "rescaled avel.y")
assert_almost_eq(out.angular_velocity.z, 5.0, AVEL_TOL, "rescaled avel.z")
func test_type_version_byte_roundtrip() -> void:
var tv := NetCodec.type_version_byte(NetCodec.PacketType.SNAPSHOT)
assert_eq(NetCodec.packet_type_of(tv), NetCodec.PacketType.SNAPSHOT, "packet type nibble")
assert_eq(NetCodec.protocol_version_of(tv), NetCodec.PROTOCOL_VERSION, "protocol version nibble")
func test_tick_hz_derives_from_sim_constants() -> void:
var SimConstants = preload("res://scripts/sim_constants.gd")
assert_eq(NetCodec.TICK_HZ, SimConstants.TICK_HZ, "NetCodec.TICK_HZ must track SimConstants.TICK_HZ")