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) # task 3.4: MatchSim._recv_input must count malformed packets and disconnect # after MALFORMED_LIMIT_TO_DISCONNECT (20) of them. Calls the RPC directly # with garbage bytes rather than going through networked_match.gd's own # honest encoder — this IS what a hostile custom client sending raw ENet # packets would look like, so bypassing the normal send path is the point, # not a shortcut. func run_malformed_abuse_check() -> void: await get_tree().create_timer(1.0).timeout # A single-element Array, not a plain bool: GDScript lambdas capture # outer local variables BY VALUE at creation time, not by reference, so # `disconnected = true` inside the lambda below would silently mutate # only the lambda's own captured copy — invisible to this function's # own `disconnected` if it were a plain bool. Mutating an Array's # CONTENTS from inside the lambda works because the Array object # itself (not a copy of it) is what got captured. var disconnected := [false] NetworkManager.disconnected_from_server.connect(func() -> void: disconnected[0] = true) for i in 25: MatchSim._recv_input.rpc_id(1, PackedByteArray([1, 2, 3])) # far too short to even hold a header NetworkManager.poll() await get_tree().physics_frame await get_tree().create_timer(1.0).timeout NetworkManager.poll() print("SMOKE %s: 25 malformed packets %s" % [ "PASS" if disconnected[0] else "FAIL", "resulted in disconnect" if disconnected[0] else "did NOT disconnect the abusive peer", ]) get_tree().quit(0 if disconnected[0] else 1) # task 3.4: MatchSim._recv_input must rate-limit and disconnect after # RATE_LIMIT_OVER_BUDGET_SECONDS_TO_DISCONNECT (3) consecutive seconds over # RATE_LIMIT_PACKETS_PER_SEC (110/s). Every packet here is individually # well-formed (a real NetCodec.pack_input payload) — only the SEND RATE is # abusive, confirming the rate limiter fires independently of the malformed- # packet counter, not as a side effect of it. func run_rate_limit_abuse_check() -> void: await get_tree().create_timer(1.0).timeout var disconnected := [false] # see run_malformed_abuse_check's comment on why not a plain bool NetworkManager.disconnected_from_server.connect(func() -> void: disconnected[0] = true) var net_codec := preload("res://scripts/net_codec.gd") var ship_action_script := preload("res://scripts/ship_action.gd") var bytes: PackedByteArray = net_codec.pack_input(1, 0, Time.get_ticks_msec(), [ship_action_script.new()]) var deadline_ms := Time.get_ticks_msec() + 4000 while Time.get_ticks_msec() < deadline_ms and not disconnected[0]: for i in 40: # well above 110/s once summed across a frame's worth of iterations MatchSim._recv_input.rpc_id(1, bytes) NetworkManager.poll() await get_tree().process_frame await get_tree().create_timer(0.5).timeout NetworkManager.poll() print("SMOKE %s: sustained packet flood %s" % [ "PASS" if disconnected[0] else "FAIL", "resulted in disconnect" if disconnected[0] else "did NOT disconnect the abusive peer", ]) get_tree().quit(0 if disconnected[0] else 1)