Files
Josh Creek b43ad207c1 docs(multiplayer): split spec into MULTIPLAYER_SPEC.md, trim task doc to outstanding work
multiplayer-next.md was a 1662-line mix of standing architecture spec
and task-completion tracking, most of which was dense per-task DONE
evidence for finished Phases 0-6. Split it:

- MULTIPLAYER_SPEC.md (new): the locked architecture decisions, wire
  format, server-side input handling, prediction/reconciliation,
  latency/frame-rate budget, and match lifecycle state machine -
  standing design reference, not task-tracked.
- multiplayer-next.md (trimmed 1662 -> ~370 lines): only outstanding
  work remains - §0 status, §7 Phase 7/8 task tables condensed to
  "what's left" per task, §8-11 reference material (refactoring notes,
  gotchas, testing, flagged items). Phases 0-6 collapsed to a pointer
  at git history instead of ~500 lines of DONE evidence.

Also:
- Repointed every `multiplayer-next.md §N` code comment (N 1-6) across
  Game/scripts, Game/tools and Game/tests to MULTIPLAYER_SPEC.md, since
  those sections moved. Task-number references (`task N.N`, §7-11)
  correctly still point at multiplayer-next.md.
- Updated CLAUDE.md's doc index and docs/TECH_STACK.md's spec-section
  citations to match.
- TODO.md: added a "what's left to actually finish multiplayer
  (human-actionable)" checklist pulled from multiplayer-next.md §0 and
  docs/MATCHMAKING.md - things that need a person (hardware, a design
  decision, a Steam App ID, hands on a controller), not more agent code.
2026-09-04 22:43:13 +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_SPEC.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")