mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
b43ad207c1
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.
170 lines
8.2 KiB
GDScript
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")
|