Files
CosmicClash/Game/tests/networked_match_test_hooks.gd
T
Josh Creek 39a41c016c feat(multiplayer): Phase 2 server-authoritative simulation, dumb client
Implements tasks 2.1-2.7: NetworkedMatch spawns a deterministic slot
layout from the lobby roster, the server drives each connected peer's
ship via RLShipController fed by decoded client input and broadcasts
60Hz snapshots, and the client renders everything (including its own
ship) from a per-body NetInterpolator with no local prediction yet.
Dual-time remote entities split collider updates (present-time, for
correct contacts) from $Visual updates (interp-delayed, for smoothness).
Camera/HUD wiring and remote engine-flame VFX fell out of the existing
Ship API for free once snapshots were flowing.

Three real bugs found and fixed while getting a two-process test
green: an RPC method named _input collided with Node's built-in
_input virtual and broke the whole MatchSim autoload from loading;
networked_match.gd never called NetworkManager.poll(), so nothing
sent via RPC in this scene reached the wire despite Phase 1's manual
polling being wired up everywhere else; and a match_config
request/response fallback (added to close a startup race) could
double-deliver once polling was fixed, requiring an idempotency guard.

Verified with tests/networked_match_smoke: a real headless two-process
host+client run shows the client rendering 31m of server-authoritative
movement from a held forward-thrust input, with thrust_z=1.0 confirmed
on the interpolated snapshot mid-drive and camera/HUD both wired.
Full Phase 1 regression suite re-run clean alongside it.

Task 2.8 (net_sim.gd latency/jitter/loss decorator) is not yet done;
Phase 2's own gate needs it before it's fully met.
2026-08-20 08:42:13 +01:00

117 lines
5.4 KiB
GDScript

extends Node
# Test-only helper (tests/networked_match_smoke.gd). Not a project autoload
# — production code never references this. Same reason as
# tests/lobby_test_hooks.gd: networked_match.tscn is loaded via
# change_scene_to_file(), which frees whatever node initiated the load, so
# a driver can't keep orchestrating from a node that just got freed. The
# smoke test add_child()s this directly under get_tree().root instead (a
# sibling of current_scene, not a descendant of it), so it survives the swap.
#
# Uses preload(), not the bare `NetworkedMatch` class_name, and leaves
# `match_scene` itself untyped (Node) throughout — same global-script-class-
# cache-timing reason as tests/test_case.gd, plus every member access off an
# untyped Node returns Variant, which then needs explicit `: Type`
# annotations wherever `:=` would otherwise fail to infer one.
const NetworkedMatchScript = preload("res://scripts/networked_match.gd")
func _is_networked_match(node: Node) -> bool:
return node != null and node.get_script() == NetworkedMatchScript
func run_host_check(lifetime_seconds: float) -> void:
await get_tree().create_timer(lifetime_seconds * 0.4).timeout
var match_scene := get_tree().current_scene
var ok := _is_networked_match(match_scene)
var ship_count := 0
var ball_ok := false
var arena_name := "null"
if ok:
ship_count = match_scene.ships.size()
ball_ok = is_instance_valid(match_scene.ball)
if match_scene.arena:
arena_name = match_scene.arena.name
print("SMOKE INFO: host is_networked_match=%s ship_count=%d ball_ok=%s arena=%s" % [
str(ok), ship_count, str(ball_ok), arena_name
])
var success := ok and ship_count == 1 and ball_ok
print("SMOKE %s: host spawn check (ship_count=%d, ball_ok=%s)" % ["PASS" if success else "FAIL", ship_count, str(ball_ok)])
await get_tree().create_timer(lifetime_seconds * 0.6).timeout
if _is_networked_match(match_scene) and not match_scene.ships.is_empty():
var ship: Ship = match_scene.ships[0]
print("SMOKE INFO: host ship final position=%s (spawned, driven by client input if any arrived)" % str(ship.global_position))
NetworkManager.shutdown()
get_tree().quit(0 if success else 1)
func run_client_check(settle_seconds: float, drive_seconds: float) -> void:
await get_tree().create_timer(settle_seconds).timeout
var match_scene := get_tree().current_scene
if not _is_networked_match(match_scene):
print("SMOKE FAIL: current_scene is not NetworkedMatch after %.1fs" % settle_seconds)
get_tree().quit(1)
return
var slots_ok: bool = match_scene._slots.size() == 1
var ball_ok: bool = is_instance_valid(match_scene.ball)
var my_slot = match_scene._my_slot
var my_slot_ok: bool = my_slot != null and is_instance_valid(my_slot.ship)
var camera_ok: bool = is_instance_valid(match_scene._camera_rig)
var hud_ok: bool = is_instance_valid(match_scene.hud)
var start_position := Vector3.ZERO
if my_slot_ok:
start_position = my_slot.ship.visual.global_position
print("SMOKE INFO: client slots_ok=%s ball_ok=%s my_slot_ok=%s camera_ok=%s hud_ok=%s start_pos=%s" % [
str(slots_ok), str(ball_ok), str(my_slot_ok), str(camera_ok), str(hud_ok), str(start_position)
])
if not (slots_ok and ball_ok and my_slot_ok and camera_ok and hud_ok):
print("SMOKE FAIL: spawn/wiring check failed")
get_tree().quit(1)
return
# Drive forward thrust (a real, held key state — exercises the actual
# client input path, not a synthetic RPC call) and confirm the ship
# the CLIENT renders (its interpolated $Visual, not a raw snapshot
# value) actually moved — proving input reached the server, the server
# applied real thruster force, broadcast it back, and the client's
# interpolator produced smooth motion from it.
Input.action_press("move_forward")
await get_tree().create_timer(drive_seconds * 0.5).timeout
# Task 2.6: the server-computed thrust_z it broadcast in the snapshot
# should have reached this client's interpolator and be readable off
# the latest sample — this is what set_visual_action's engine-flame
# wiring actually reads, so it's the real thing to check, not just
# "the ship physically moved" (which 2.6 doesn't claim on its own).
var latest_state = my_slot.interpolator.latest()
var thrust_z_ok: bool = latest_state != null and latest_state.thrust_z > 0.5
print("SMOKE INFO: mid-drive thrust_z=%.2f (expect >0.5 while holding forward)" % (latest_state.thrust_z if latest_state != null else -1.0))
await get_tree().create_timer(drive_seconds * 0.5).timeout
Input.action_release("move_forward")
var end_position: Vector3 = my_slot.ship.visual.global_position
var moved := start_position.distance_to(end_position)
print("SMOKE INFO: client ship moved %.2fm (start=%s end=%s) while holding forward thrust for %.1fs" % [
moved, str(start_position), str(end_position), drive_seconds
])
# thrust_power 150 / mass 5 = 30 m/s^2 nominal acceleration (see ship.gd) —
# over 2s even with drag/ramp-up this should clear a couple of metres.
# A generous, not-tuned-to-the-decimal bound: this is a wiring smoke
# test, not a physics-accuracy test (net_codec's own tests already cover
# quantisation precision).
var success := moved > 1.0 and thrust_z_ok
print("SMOKE %s: client observed %.2fm of server-authoritative movement via interpolation, thrust_z_ok=%s" % [
"PASS" if success else "FAIL", moved, str(thrust_z_ok)
])
await get_tree().create_timer(0.3).timeout
NetworkManager.shutdown()
get_tree().quit(0 if success else 1)