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") const BALL_BLEND_ACCEPTANCE_MS := 170 # 150ms contract + one rendered-frame allowance func _is_networked_match(node) -> bool: # is_instance_valid FIRST, and the parameter is untyped for the same # reason: at RESULTS both peers change scene to the lobby (§6.2 step 10), # which frees the match scene while these hooks — deliberately parented # outside it so they survive scene swaps — are still holding a reference. # A typed Node parameter throws on a freed object before the body even # runs, which hung both processes for the full 5-minute timeout. return is_instance_valid(node) and node.get_script() == NetworkedMatchScript func run_host_check(lifetime_seconds: float, force_goal: bool = false) -> 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)]) if force_goal and ok: # Drive a real PLAYING -> GOAL_PAUSE -> WARMUP -> PLAYING cycle so the # client has a goal transition to follow. Teleporting the ball into a # goal is the same deterministic trick the CI driver and Phase 2's # goal-reset-ordering fix both use — two low-skill peers scoring # naturally inside a short run is not reliable enough to gate on. var goals: Array = match_scene.arena.get_goals() if match_scene.arena else [] if is_instance_valid(match_scene.ball) and not goals.is_empty(): match_scene.ball.linear_velocity = Vector3.ZERO match_scene.ball.global_position = goals[0].global_position print("SMOKE INFO: host forced a goal to exercise the GOAL_PAUSE transition") await get_tree().create_timer(lifetime_seconds * 0.6).timeout if not _is_networked_match(match_scene): # The match ended and the server returned itself to the lobby. print("SMOKE PASS: host ran the match to completion and left the match scene") NetworkManager.shutdown() get_tree().quit(0) return if force_goal and _is_networked_match(match_scene): print("SMOKE INFO: host final match_state=%s" % MatchState.to_name(match_scene.match_state)) 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 action=%s (spawned, driven by client input if any arrived)" % [str(ship.global_position), str(ship.get_current_action_copy().thrust)]) NetworkManager.shutdown() get_tree().quit(0 if success else 1) func run_client_check(settle_seconds: float, drive_seconds: float, exercise_ball_contact: bool = false, exercise_free_flight: bool = false, warmup_seconds: float = 0.0, exercise_input_transitions: bool = false, exercise_match_state: bool = false) -> void: # Subscribed BEFORE the settle wait, not after: the server leaves LOADING # and enters WARMUP as soon as _start_server() finishes, and PLAYING 90 # ticks later — both would already be history by the time a post-settle # listener attached, and the test would silently observe nothing. var observed_states: Array[int] = [] var observed_ticks: Array[int] = [] if exercise_match_state: # change_scene_to_file is deferred, and so is this call — current_scene # is still the smoke driver for the first few frames, so connecting # immediately silently observes nothing at all (it did: empty list). # Poll until the real scene exists, bounded so a genuine failure to # load reports as an empty observation rather than hanging. var deadline := Time.get_ticks_msec() + int(settle_seconds * 1000.0) while Time.get_ticks_msec() < deadline and not _is_networked_match(get_tree().current_scene): await get_tree().process_frame var state_scene := get_tree().current_scene if _is_networked_match(state_scene): # Seed with whatever the client has already converged to. The # server may legitimately have reached PLAYING before this client # finished loading — that is the snapshot-byte catch-up path doing # its job, not a missed transition. observed_states.append(state_scene.match_state) observed_ticks.append(state_scene.match_state_since_tick) state_scene.match_state_changed.connect(func(s: int, at_tick: int) -> void: observed_states.append(s) observed_ticks.append(at_tick) ) 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.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 # Bodies are frozen during the kickoff countdown and the goal pause (tasks # 5.3/5.4), so every assertion below — "not frozen", "a controller drives # it", "it moved" — is only meaningful once play is actually live. Before # 5.3 the match was live the instant it loaded and this wait did not exist; # sampling during WARMUP now reports a legitimately frozen ship as a # prediction failure. var live_deadline := Time.get_ticks_msec() + 15000 while Time.get_ticks_msec() < live_deadline and not MatchState.is_live(match_scene.match_state): await get_tree().physics_frame if not MatchState.is_live(match_scene.match_state): print("SMOKE FAIL: match never reached a live state (stuck in %s)" % MatchState.to_name(match_scene.match_state)) get_tree().quit(1) return match_scene._local_ship_predictor.clear_metrics() # 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. if exercise_ball_contact: # Steer at the ball with real input rather than a fixed-heading burst. # This used to be "forward + right for 1.1s", tuned by hand against the # spawn orientation — which task 5.3's kickoff broke, because # reset_ships() applies KICKOFF_YAW_JITTER (task 0.7) and the ship no # longer starts on a known heading. The old burst then flew past the # ball every time (0 contacts in 3/3 runs). Closing the loop on the # actual bearing keeps this exercising the real input path while being # indifferent to how the kickoff happened to orient the ship. await _drive_at_ball(my_slot.ship, match_scene.ball, 8.0) # Leave a >150ms observation window before the normal drive so a # subsequent goal reset cannot mask blend-back. Input.action_release("move_forward") await get_tree().create_timer(0.35).timeout if not exercise_free_flight and not exercise_input_transitions: Input.action_press("move_forward") if warmup_seconds > 0.0: await get_tree().create_timer(warmup_seconds).timeout match_scene._local_ship_predictor.clear_metrics() var free_flight_peak_distance := 0.0 if exercise_input_transitions: # Deliberate action-sequence-label probe. A HELD input cannot falsify # the history's seq labelling: while thrust is constant, "the intent # from this tick" and "the action the server consumes for seq S" carry # the same value whichever seq the state is filed under, so the action # marker reports 0 mismatches under a correct AND an incorrect label. # Only a transition exposes the difference, and it exposes it for # roughly input_lead ticks per edge. Toggle forward thrust on a short # period so the run is mostly edges. await _run_input_transition_trace(drive_seconds) elif exercise_free_flight: # A straight 60-second forward trace reaches the goal/wall in seconds # and turns the supposed free-flight QA run into a contact test. Hover # in the open volume with alternating vertical thrust and yaw instead: # it remains a sustained real thrust/turn/airborne trace without ever # manufacturing a wall or goal contact. free_flight_peak_distance = await _run_free_flight_trace(my_slot.ship, start_position, drive_seconds) else: await get_tree().create_timer(drive_seconds * 0.5).timeout # Phase 4.3: own ship is a genuine unfrozen local simulation. Its slot # intentionally receives no NetInterpolator samples; a controller attached # to the body supplies the one action used by this tick's physics step. # Split into structural and live halves. The structural half holds at every # instant. The freeze/thrust half only means anything while play is live: # tasks 5.3/5.4 freeze the local ship for the kickoff countdown and the # goal pause, and a goal can land anywhere in a drive, so asserting # unconditionally reports a correctly-frozen ship as a prediction failure. var live_now: bool = MatchState.is_live(match_scene.match_state) var structure_ok: bool = my_slot.ship.controller != null \ and my_slot.ship.controller.get_parent() == my_slot.ship \ and not my_slot.interpolator.has_samples() var driving_ok: bool = not live_now or (not my_slot.ship.freeze \ and (my_slot.ship.get_current_action_copy().thrust.z > 0.5 or absf(my_slot.ship.get_current_action_copy().thrust.y) > 0.5)) var local_prediction_ok: bool = structure_ok and driving_ok print("SMOKE INFO: local_prediction=%s state=%s structure_ok=%s driving_ok=%s freeze=%s controller_attached=%s local_interpolator_samples=%s" % [ str(local_prediction_ok), MatchState.to_name(match_scene.match_state), str(structure_ok), str(driving_ok), str(my_slot.ship.freeze), str(my_slot.ship.controller != null and my_slot.ship.controller.get_parent() == my_slot.ship), str(my_slot.interpolator.has_samples()) ]) if not exercise_free_flight and not exercise_input_transitions: await get_tree().create_timer(drive_seconds * 0.5).timeout Input.action_release("move_forward") Input.action_release("move_up") Input.action_release("move_down") Input.action_release("turn_left") Input.action_release("turn_right") if exercise_ball_contact: # Leave enough wall time for the bounded RTT window plus the 150ms # handoff blend to finish before inspecting lifecycle telemetry. await get_tree().create_timer(0.35).timeout # The match can legitimately END during a drive (§6.2 step 10: FULL_TIME -> # RESULTS -> LOBBY tears this scene down). Every assertion below reads the # match scene, so finish on the lifecycle evidence instead of dereferencing # freed objects. if not _is_networked_match(match_scene) or not is_instance_valid(my_slot.ship): var completed_ok := true if exercise_match_state: var seq: Array[String] = [] for s in observed_states: seq.append(MatchState.to_name(s)) completed_ok = MatchState.State.RESULTS in observed_states and MatchState.State.LOBBY in observed_states for i in observed_states.size() - 1: if not MatchState.can_transition(observed_states[i], observed_states[i + 1]): print("SMOKE FAIL: illegal transition %s -> %s" % [seq[i], seq[i + 1]]) completed_ok = false print("SMOKE %s: match ran to completion and returned to the lobby (%s)" % [ "PASS" if completed_ok else "FAIL", " -> ".join(seq) ]) else: print("SMOKE INFO: match scene torn down before the drive finished") await get_tree().create_timer(0.3).timeout NetworkManager.shutdown() get_tree().quit(0 if completed_ok else 1) return var end_position: Vector3 = my_slot.ship.global_position var prediction_stats: Dictionary = match_scene.get_net_debug_stats().get("prediction", {}) var net_stats: Dictionary = match_scene.get_net_debug_stats() print("SMOKE INFO: prediction samples=%s raw_p95=%.3f raw_p99=%.3f free_samples=%s raw_free_p95=%.3f raw_free_p99=%.3f visual_free_p95=%.3f visual_free_p99=%.3f hard_snaps=%s free_hard_snaps=%s rate=%.2f/min hard_reasons=%s cohorts=%s marker=%s/%s replay=%s lead=%s target=%s buffer=%s ball_contacts=%s latest_error=%s latest_velocity_error=%s" % [ str(prediction_stats.get("sample_count", 0)), prediction_stats.get("position_error_p95", 0.0), prediction_stats.get("position_error_p99", 0.0), str(prediction_stats.get("free_flight_sample_count", 0)), prediction_stats.get("free_flight_position_error_p95", 0.0), prediction_stats.get("free_flight_position_error_p99", 0.0), prediction_stats.get("free_flight_visual_correction_p95", 0.0), prediction_stats.get("free_flight_visual_correction_p99", 0.0), str(prediction_stats.get("hard_snap_count", 0)), str(prediction_stats.get("hard_snap_cohorts", {}).get("free_flight", 0)), prediction_stats.get("hard_snap_rate_per_min", 0.0), str(prediction_stats.get("hard_snap_reasons", {})), str(prediction_stats.get("cohorts", {})), str(net_stats.get("action_marker_mismatches", 0)), str(net_stats.get("action_marker_samples", 0)), str(prediction_stats.get("last_replay_count", 0)), str(net_stats.get("input_lead", "-")), str(net_stats.get("input_target_depth", "-")), str(net_stats.get("input_buffer_depth", "-")), str(net_stats.get("ball_prediction_contacts", 0)), str(net_stats.get("latest_prediction_error", Vector3.ZERO)), str(net_stats.get("latest_prediction_velocity_error", Vector3.ZERO)) ]) var moved := start_position.distance_to(end_position) # Horizontal-only (XZ), not full 3D distance: an adversarial review # found a 1.2s window of completely dead input still registers ~1.07m # of pure gravity settling on the Y axis alone (spawn height dropping # to the floor), which sat ABOVE the old moved > 1.0 bar — only # thrust_z_ok caught that failure, not moved. Forward thrust is a # horizontal force (see ship.gd), so measuring XZ displacement can't # be satisfied by gravity alone, regardless of spawn height or timing. var moved_horizontal := Vector2(end_position.x, end_position.z).distance_to(Vector2(start_position.x, start_position.z)) var verification_movement := free_flight_peak_distance if exercise_free_flight else moved_horizontal print("SMOKE INFO: client ship moved %.2fm (%.2fm horizontal) (start=%s end=%s) while holding forward thrust for %.1fs" % [ moved, moved_horizontal, 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). # The contact path intentionally includes a goal/reset in this trace, so # its expected authoritative snaps are reported separately rather than # contaminating the contact-free prediction gate. # The scheduled local timeline now predicts the same command stream the # server consumes, so both the same-sequence raw residual and the exposed # render discontinuity are meaningful free-flight gates. Hard corrections # remain separately gated by cohort. var raw_quality_p95: float = float(prediction_stats.get("free_flight_position_error_p95", INF)) var raw_quality_p99: float = float(prediction_stats.get("free_flight_position_error_p99", INF)) var raw_rotation_p95: float = float(prediction_stats.get("free_flight_rotation_error_p95", INF)) var raw_rotation_p99: float = float(prediction_stats.get("free_flight_rotation_error_p99", INF)) var quality_p95: float = float(prediction_stats.get("free_flight_visual_correction_p95", INF)) var quality_p99: float = float(prediction_stats.get("free_flight_visual_correction_p99", INF)) var quality_samples := int(prediction_stats.get("free_flight_sample_count", 0)) var free_flight_hard_snaps := int(prediction_stats.get("hard_snap_cohorts", {}).get("free_flight", 99)) # The action-sequence-label gate. Every other mode here holds its inputs # steady or near-steady, and a steady input CANNOT falsify the history's # seq labelling: while the commanded action is constant, "the intent from # this tick" and "the action the server consumes for seq S" carry the same # value under a correct and an incorrect label alike, so the action marker # reads 0/N either way. That is precisely how a real mislabelling survived # every earlier Phase 4 gate. Only this mode's forced edges expose it, so # only this mode asserts on the marker. # # Measured separation is wide, not marginal: labelling post-step state at # the server-consumption estimate reported 9.3% mismatch on LAN # (input_lead 1) and 24% at 80±20ms (input_lead 3) — it scales with the # lead, as the mechanism predicts — against 0-1.3% once filed under the # issuing sequence. The residual is seq-delta events (an attack issues # several sequences for one local physics step, a release duplicates one), # which relabelling does not claim to fix. var marker_samples := int(net_stats.get("action_marker_samples", 0)) var marker_mismatches := int(net_stats.get("action_marker_mismatches", 99)) var marker_rate := float(marker_mismatches) / float(maxi(marker_samples, 1)) # The sample floor scales with the run, and that is load-bearing rather than # tidiness. An adversarial review reproduced a total, permanent input # blackout (a 3.5s host freeze) that this gate reported as PASS at 3.76%: # once reconciliation is suppressed, _record_metrics stops being called, so # the marker stops sampling entirely — the WORSE the outage, the FEWER # samples and the LOWER the reported mismatch rate. A flat ">= 200" is # satisfied by the handful of acks either side of the outage. Snapshots ack # at ~60Hz, so require half of nominal and a run this short is provably # still exchanging input for most of its length. var marker_sample_floor := maxi(200, int(drive_seconds * 30.0)) var marker_samples_ok := marker_samples >= marker_sample_floor # Server-side starvation bit, round-tripped over the wire. A client flying # on pure prediction with the server ignoring it satisfies every other # assertion here, because all of them read the CLIENT's own action and # position. var server_stalled := bool(net_stats.get("server_stalled", false)) # 5%, not 3%: the irreducible residual is seq-delta events and it scales # with input_lead, reaching 2.22% at lead 3 under 80±20ms — too close to a # 3% line for a CI gate. Separation from a genuinely mislabelled build is # 10-20x either way (control runs measure 24-50%), so the extra headroom # costs no real detection power. Tighten this only alongside recording # predictions for an attack's filled gap sequences. const MAX_ACTION_MARKER_MISMATCH_RATE := 0.05 var action_label_ok: bool = marker_samples_ok and not server_stalled and marker_rate < MAX_ACTION_MARKER_MISMATCH_RATE var prediction_quality_ok: bool = action_label_ok if exercise_input_transitions else \ prediction_stats.get("hard_snap_count", 99) < 4 if exercise_ball_contact else \ quality_samples >= 30 \ and raw_quality_p95 < 0.5 \ and raw_quality_p99 < 2.0 \ and raw_rotation_p95 < 5.0 \ and raw_rotation_p99 < 15.0 \ and quality_p95 < 0.5 \ and quality_p99 < 2.0 \ and free_flight_hard_snaps == 0 # ball_proxy_moved_before_authority counts ticks where the predicted proxy # had visibly moved BEFORE the next authoritative ball state arrived. That # is only a meaningful — or even achievable — claim when there is real RTT # to mask: snapshots land every ~16.7ms at 60Hz, so on a loopback LAN the # whole pre-authority window is about one physics tick and whether it is # observed is a coin flip on arrival timing. Measured 2 failures in 5 LAN # runs, versus 5/5 passes (count 2-3) at --net-sim-latency=80, with the # same-frame reveal itself correct in every single run either way. # # So require it only when the link actually has latency to hide, and let # the same-frame reveal carry the gate on LAN — that is the real claim # ("your own touches register on contact, not ~RTT later") and it is not # racy. Runs asserting the masking behaviour should pass --net-sim-latency. var rtt_ms := NetworkManager.rtt_ms var rtt_masks_authority := rtt_ms >= 20.0 var proxy_motion_ok: bool = not rtt_masks_authority or int(net_stats.get("ball_proxy_moved_before_authority_count", 0)) > 0 var ball_contact_ok := not exercise_ball_contact or (int(net_stats.get("ball_prediction_contacts", 0)) > 0 \ and int(net_stats.get("ball_contact_frame", -1)) == int(net_stats.get("ball_reveal_frame", -2)) \ and int(net_stats.get("ball_blend_complete_count", 0)) > 0 \ and int(net_stats.get("ball_blend_max_duration_ms", BALL_BLEND_ACCEPTANCE_MS)) <= BALL_BLEND_ACCEPTANCE_MS \ and proxy_motion_ok) # §6.1 task 5.1: the client must FOLLOW the server's machine, not run its # own. Assert three separable things — that transitions arrived at all, # that every consecutive pair is legal per the shared table (so the client # never lands somewhere the server could not have sent it), and that the # specific documented sequence for this scenario was observed. var match_state_ok := true if exercise_match_state: var names: Array[String] = [] for s in observed_states: names.append(MatchState.to_name(s)) for i in observed_states.size() - 1: if not MatchState.can_transition(observed_states[i], observed_states[i + 1]): print("SMOKE FAIL: client observed an illegal transition %s -> %s" % [names[i], names[i + 1]]) match_state_ok = false # Ticks are absolute and monotonic; a transition attributed to an # earlier tick than its predecessor means the at_tick plumbing is wrong. for i in observed_ticks.size() - 1: if observed_ticks[i + 1] < observed_ticks[i]: print("SMOKE FAIL: transition ticks went backwards: %s" % str(observed_ticks)) match_state_ok = false # Which lifecycle path a run takes depends on its own timing: a short # --match-length reaches FULL_TIME before the forced goal lands, a # longer one exercises the goal cycle instead. Assert what the run # actually did rather than hardcoding one shape — but require it did # at least ONE of them, so a match that merely sat in PLAYING the # whole time cannot quietly pass. var reached_playing := MatchState.State.PLAYING in observed_states var saw_goal_pause := MatchState.State.GOAL_PAUSE in observed_states var saw_full_time := MatchState.State.FULL_TIME in observed_states # A goal must lead back to a kickoff, not leave the match parked. var resumed_after_goal := false for i in observed_states.size() - 1: if observed_states[i] == MatchState.State.GOAL_PAUSE and observed_states[i + 1] == MatchState.State.WARMUP: resumed_after_goal = true # Full time must resolve: sudden death on a draw, results otherwise. var full_time_resolved := false for i in observed_states.size() - 1: if observed_states[i] == MatchState.State.FULL_TIME and observed_states[i + 1] in [MatchState.State.OVERTIME_WARMUP, MatchState.State.RESULTS]: full_time_resolved = true var goal_cycle_ok: bool = not saw_goal_pause or resumed_after_goal var full_time_ok: bool = not saw_full_time or full_time_resolved if not (reached_playing and goal_cycle_ok and full_time_ok and (saw_goal_pause or saw_full_time)): match_state_ok = false # The snapshot's match_state byte must carry the real state too, not a # hardcoded 0. Everything above is driven by the reliable state_change # RPC and would pass identically with a dead byte — which is exactly # how Phase 4's mislabelled prediction history survived every gate. # The byte is the only channel a late joiner or a client that missed a # transition has (§6.3), so assert it independently. var wire_state := int(net_stats.get("snapshot_match_state", -1)) var live_state := int(net_stats.get("match_state", -1)) if wire_state != live_state or not MatchState.is_valid(wire_state): print("SMOKE FAIL: snapshot match_state byte is %s but the client is in %s" % [ MatchState.to_name(wire_state), MatchState.to_name(live_state) ]) match_state_ok = false if wire_state == MatchState.State.LOBBY: print("SMOKE FAIL: snapshot match_state byte reads LOBBY (0) mid-match — likely never populated") match_state_ok = false print("SMOKE %s: client followed the server's match state (%s; playing=%s goal_pause=%s resumed=%s full_time=%s resolved=%s ticks=%s)" % [ "PASS" if match_state_ok else "FAIL", " -> ".join(names), str(reached_playing), str(saw_goal_pause), str(resumed_after_goal), str(saw_full_time), str(full_time_resolved), str(observed_ticks), ]) var success := verification_movement > 1.0 and local_prediction_ok and prediction_quality_ok and ball_contact_ok and match_state_ok print("SMOKE %s: client locally predicted %.2fm horizontal, local_prediction_ok=%s prediction_quality_ok=%s" % [ "PASS" if success else "FAIL", moved_horizontal, str(local_prediction_ok), str(prediction_quality_ok) ]) if exercise_input_transitions: print("SMOKE %s: action-sequence labelling under forced input transitions (marker=%d/%d = %.2f%% mismatch, want <%.0f%%; samples %d/%d required; server_stalled=%s; input_lead=%s)" % [ "PASS" if action_label_ok else "FAIL", marker_mismatches, marker_samples, marker_rate * 100.0, MAX_ACTION_MARKER_MISMATCH_RATE * 100.0, marker_samples, marker_sample_floor, str(server_stalled), str(net_stats.get("input_lead", "-")), ]) if exercise_ball_contact: print("SMOKE %s: local dynamic ball proxy registered a same-frame reveal (contact_frame=%s reveal_frame=%s pre_authority_motion=%s hard_handoffs=%s blend_started=%s blends=%s blend_max_ms=%s ends=%s missing_shadow=%s reset_cancels=%s reset_trace=%s)" % [ "PASS" if ball_contact_ok else "FAIL", str(net_stats.get("ball_contact_frame", -1)), str(net_stats.get("ball_reveal_frame", -1)), str(net_stats.get("ball_proxy_moved_before_authority_count", 0)), str(net_stats.get("ball_hard_handoff_count", 0)), str(net_stats.get("ball_blend_started_count", 0)), str(net_stats.get("ball_blend_complete_count", 0)), str(net_stats.get("ball_blend_max_duration_ms", 0)), str(net_stats.get("ball_prediction_window_end_count", 0)), str(net_stats.get("ball_prediction_missing_shadow_count", 0)), str(net_stats.get("ball_prediction_reset_cancel_count", 0)), str(net_stats.get("ball_reset_trace", [])), ]) print("SMOKE INFO: ball pre-authority motion %s (rtt=%.1fms; asserted only at >=20ms, see proxy_motion_ok)" % [ "asserted and met" if rtt_masks_authority else "not asserted on this near-zero-RTT link", rtt_ms, ]) await get_tree().create_timer(0.3).timeout NetworkManager.shutdown() get_tree().quit(0 if success else 1) # Toggles forward thrust every TOGGLE_TICKS physics frames for the requested # duration, then leaves it pressed so the caller's own local_prediction_ok # check still sees a live commanded action. Yaw alternates alongside it purely # to keep the ship from driving straight into a wall and turning a labelling # probe into a contact test. # Samples the server's own score every physics frame for `seconds`, appending # each distinct value. Lets the comparison below check a client's recorded # score against a state the server genuinely passed through, rather than # against whatever it happens to hold seconds later. func _await_recording_score(match_scene, seconds: float, history: Array[String]) -> void: var deadline := Time.get_ticks_msec() + int(seconds * 1000.0) while Time.get_ticks_msec() < deadline: var current := JSON.stringify(match_scene.score) if history[history.size() - 1] != current: history.append(current) await get_tree().physics_frame func _run_input_transition_trace(duration_seconds: float) -> void: const TOGGLE_TICKS := 6 # ~100ms at 60Hz: several edges per second var frames := int(duration_seconds * 60.0) var pressed := false var yaw_left := false for frame in frames: if frame % TOGGLE_TICKS == 0: pressed = not pressed if pressed: Input.action_press("move_forward") else: Input.action_release("move_forward") if frame % (TOGGLE_TICKS * 4) == 0: yaw_left = not yaw_left Input.action_release("turn_right" if yaw_left else "turn_left") Input.action_press("turn_left" if yaw_left else "turn_right") await get_tree().physics_frame Input.action_release("turn_left") Input.action_release("turn_right") Input.action_press("move_forward") await get_tree().physics_frame # Closed-loop steering: yaw toward the ball, thrust once roughly aligned, and # stop as soon as we are close enough that contact is imminent. Uses only real # Input actions, so the client input -> server -> snapshot path under test is # exercised exactly as a player would. func _drive_at_ball(ship: Ship, ball_body: Node3D, timeout_seconds: float) -> void: const ALIGNED_RADIANS := 0.25 var deadline := Time.get_ticks_msec() + int(timeout_seconds * 1000.0) while Time.get_ticks_msec() < deadline: if not is_instance_valid(ship) or not is_instance_valid(ball_body): break var to_ball := ball_body.global_position - ship.global_position if to_ball.length() < 1.2: break # touching distance; momentum carries it the rest of the way # Deliberately keeps steering all the way in rather than breaking off # early and coasting: breaking at 3m let the ship sail past the ball # without ever touching it (0 contacts in 1 run of 3). # Bearing in the ship's own frame: -Z is forward, +X is right. var local := ship.global_transform.basis.inverse() * to_ball var yaw_error := atan2(local.x, -local.z) Input.action_release("turn_left") Input.action_release("turn_right") if absf(yaw_error) > ALIGNED_RADIANS: Input.action_press("turn_right" if yaw_error > 0.0 else "turn_left") # Thrust whenever the ball is anywhere ahead, not only once perfectly # aligned. Cutting thrust to turn made the ship hover and burn the # window without closing distance, which is why this reached the ball # only 2 runs in 3; turning under power converges much faster. if absf(yaw_error) < PI * 0.5: Input.action_press("move_forward") else: Input.action_release("move_forward") # Vertical alignment matters too — the ball sits above the floor and a # ship that is climbing sails straight over it. Input.action_release("move_up") Input.action_release("move_down") if local.y > 1.0: Input.action_press("move_up") elif local.y < -1.0: Input.action_press("move_down") await get_tree().physics_frame Input.action_release("turn_left") Input.action_release("turn_right") Input.action_release("move_up") Input.action_release("move_down") Input.action_press("move_forward") func _run_free_flight_trace(ship: Ship, start_position: Vector3, duration_seconds: float) -> float: var elapsed := 0.0 var peak_distance := 0.0 while elapsed < duration_seconds: Input.action_release("move_down") Input.action_press("move_up") var up_seconds := minf(0.7, duration_seconds - elapsed) await get_tree().create_timer(up_seconds).timeout elapsed += up_seconds peak_distance = maxf(peak_distance, ship.global_position.distance_to(start_position)) if elapsed >= duration_seconds: break Input.action_release("move_up") Input.action_press("move_down") var down_seconds := minf(0.3, duration_seconds - elapsed) await get_tree().create_timer(down_seconds).timeout elapsed += down_seconds peak_distance = maxf(peak_distance, ship.global_position.distance_to(start_position)) return peak_distance # 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. # §6.4 (tasks 5.6/5.7), host side. Watches its own slots across a client's # disconnect and reconnect and asserts the documented contract: the ship is # never despawned, the controller is swapped rather than left dangling, the # slot is reserved by identity, and a returning player gets it back. func run_disconnect_host_check(lifetime_seconds: float) -> void: await get_tree().create_timer(2.0).timeout var match_scene := get_tree().current_scene if not _is_networked_match(match_scene): print("SMOKE FAIL: host scene is not NetworkedMatch") get_tree().quit(1) return var slots_before: int = match_scene._slots.size() if slots_before == 0: print("SMOKE FAIL: host has no slots — the client never made it into the roster") NetworkManager.shutdown() get_tree().quit(1) return var ship_before = match_scene._slots[0].ship var name_before: String = match_scene._slots[0].player_name print("SMOKE INFO: host has %d slot(s), player_name=%s" % [slots_before, name_before]) # Wait for the client to drop. Guarded on the scene still existing: §6.4's # abort can tear the match down underneath this loop. var drop_deadline := Time.get_ticks_msec() + int(lifetime_seconds * 1000.0) while Time.get_ticks_msec() < drop_deadline and _is_networked_match(match_scene) and not match_scene._slots[0].disconnected: await get_tree().physics_frame if not _is_networked_match(match_scene): print("SMOKE FAIL: match aborted during the disconnect window — the reservation should have held it open") NetworkManager.shutdown() get_tree().quit(1) return var saw_disconnect: bool = match_scene._slots[0].disconnected var ship_survived: bool = match_scene._slots.size() == slots_before and is_instance_valid(match_scene._slots[0].ship) and match_scene._slots[0].ship == ship_before var controller_valid: bool = is_instance_valid(match_scene._slots[0].controller) var reserved: bool = match_scene._slots[0].reserved_until_tick > Engine.get_physics_frames() print("SMOKE INFO: after disconnect saw_disconnect=%s ship_survived=%s controller_valid=%s reserved=%s" % [ str(saw_disconnect), str(ship_survived), str(controller_valid), str(reserved) ]) # Then for it to come back and reclaim the slot. var back_deadline := Time.get_ticks_msec() + int(lifetime_seconds * 1000.0) while Time.get_ticks_msec() < back_deadline and _is_networked_match(match_scene) and match_scene._slots[0].disconnected: await get_tree().physics_frame if not _is_networked_match(match_scene): print("SMOKE FAIL: match aborted before the player could reconnect") NetworkManager.shutdown() get_tree().quit(1) return var reclaimed: bool = not match_scene._slots[0].disconnected var same_ship: bool = is_instance_valid(match_scene._slots[0].ship) and match_scene._slots[0].ship == ship_before # Ticking on past the swap proves task 5.7: _physics_process writes # slot.controller.action every tick, so a dangling reference from # set_controller()'s queue_free() would have crashed by now. for i in 60: if not _is_networked_match(match_scene): break await get_tree().physics_frame var success := saw_disconnect and ship_survived and controller_valid and reserved and reclaimed and same_ship and is_instance_valid(match_scene._slots[0].controller) print("SMOKE %s: disconnect kept the ship and the reconnect reclaimed the slot (disconnect=%s ship_kept=%s reserved=%s reclaimed=%s same_ship=%s)" % [ "PASS" if success else "FAIL", str(saw_disconnect), str(ship_survived), str(reserved), str(reclaimed), str(same_ship) ]) NetworkManager.shutdown() get_tree().quit(0 if success else 1) # §6.3 (task 5.8). A peer that joins mid-match with a name nobody reserved is a # spectator: no slot, no ship, but it MUST still receive the snapshot stream # and follow the lifecycle. An adversarial review found spectators received no # snapshots at all, because _broadcast_snapshot unicasts per SLOT. func run_spectator_check(run_seconds: float) -> void: var snapshot_count := [0] MatchSim.snapshot_received.connect(func(_d: Dictionary) -> void: snapshot_count[0] += 1) var deadline := Time.get_ticks_msec() + 10000 while Time.get_ticks_msec() < deadline and not _is_networked_match(get_tree().current_scene): await get_tree().process_frame var match_scene := get_tree().current_scene if not _is_networked_match(match_scene): print("SMOKE FAIL: spectator never loaded the match scene") get_tree().quit(1) return await get_tree().create_timer(run_seconds).timeout if not _is_networked_match(match_scene): print("SMOKE FAIL: match scene torn down during the spectator run") get_tree().quit(1) return var is_spectator: bool = match_scene._my_slot == null var got_snapshots: bool = snapshot_count[0] > int(run_seconds * 20.0) var camera_ok: bool = is_instance_valid(match_scene._camera_rig) var state_ok: bool = MatchState.is_valid(match_scene.match_state) and match_scene.match_state != MatchState.State.LOBBY # Bootstrap: a late joiner must know the live clock, not wait for a goal. var stats: Dictionary = match_scene.get_net_debug_stats() var wire_state := int(stats.get("snapshot_match_state", -1)) var wire_ok: bool = wire_state == int(stats.get("match_state", -2)) # Cycling must be safe and must never hand the camera a non-Ship. match_scene.cycle_spectator_target(1) match_scene.cycle_spectator_target(1) match_scene.cycle_spectator_target(-1) var cycle_ok: bool = is_instance_valid(match_scene._camera_rig) and (match_scene._camera_rig.target == null or match_scene._camera_rig.target is Ship) print("SMOKE INFO: spectator is_spectator=%s snapshots=%d camera_ok=%s state=%s wire_state=%s cycle_ok=%s" % [ str(is_spectator), snapshot_count[0], str(camera_ok), MatchState.to_name(match_scene.match_state), MatchState.to_name(wire_state), str(cycle_ok) ]) var success := is_spectator and got_snapshots and camera_ok and state_ok and wire_ok and cycle_ok print("SMOKE %s: spectator received the snapshot stream and followed the match (snapshots=%d, want > %d)" % [ "PASS" if success else "FAIL", snapshot_count[0], int(run_seconds * 20.0) ]) NetworkManager.shutdown() get_tree().quit(0 if success else 1) 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 a sustained # continuous flood well above RATE_LIMIT_PACKETS_PER_SEC (110/s) via the # leaky-bucket excess accumulator (RATE_LIMIT_EXCESS_PACKETS_TO_DISCONNECT). # 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) # Regression test for a real bug an adversarial review found and this # session fixed: the ORIGINAL rate limiter tracked "N consecutive # over-budget seconds" and hard-reset that streak to 0 on any single clean # window — so a burst-then-idle duty cycle (flood hard, go quiet for one # window, repeat) evaded it indefinitely. Reproduced against the real # MatchSim._recv_input: ~33x the packet budget sustained for 28.5s with # zero disconnect warnings. The fix (a leaky-bucket excess accumulator # that grows by the window's actual total and drains by only one window's # worth of budget, every window) doesn't care how the excess is # distributed in time. This test reproduces the exact attack shape. func run_duty_cycle_flood_abuse_check() -> void: await get_tree().create_timer(1.0).timeout var disconnected := [false] 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()]) const CYCLE_SECONDS := 3.0 const BURST_SECONDS := 0.35 const TEST_SECONDS := 6.0 # the leaky bucket trips within the first cycle; no need for a long soak const TRICKLE_HZ := 60 # legitimate-shaped background rate, well under budget alone var deadline_ms := Time.get_ticks_msec() + int(TEST_SECONDS * 1000.0) var cycle_start_ms := Time.get_ticks_msec() while Time.get_ticks_msec() < deadline_ms and not disconnected[0]: var t_in_cycle := float(Time.get_ticks_msec() - cycle_start_ms) / 1000.0 if t_in_cycle >= CYCLE_SECONDS: cycle_start_ms = Time.get_ticks_msec() t_in_cycle = 0.0 if t_in_cycle < BURST_SECONDS: for i in 200: # a hard burst, far above budget MatchSim._recv_input.rpc_id(1, bytes) else: for i in maxi(1, TRICKLE_HZ / 60): # ~60/s trickle, keeps the window rolling and stays under budget alone 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: duty-cycled flood (burst %.2fs / cycle %.1fs) %s" % [ "PASS" if disconnected[0] else "FAIL", BURST_SECONDS, CYCLE_SECONDS, "resulted in disconnect" if disconnected[0] else "evaded rate limiting entirely", ]) get_tree().quit(0 if disconnected[0] else 1) # task 3.6, host role: waits for both bots' scenes to settle, forces a # deterministic goal (bot-vs-bot scoring isn't reliable enough within a # short CI run to gate on), then compares the server's own final score # against what each client independently wrote to disk (run_ci_client_check # below) — genuine cross-peer agreement, not just "the server thinks so". func run_ci_host_check(run_seconds: float) -> void: await get_tree().create_timer(2.0).timeout var match_scene := get_tree().current_scene if not _is_networked_match(match_scene): print("SMOKE FAIL: host scene is not NetworkedMatch") NetworkManager.shutdown() get_tree().quit(1) return print("SMOKE INFO: host ship_count=%d slot_count=%d" % [match_scene.ships.size(), match_scene._slots.size()]) # Every score the SERVER has actually held, in order. The comparison below # used to check each client's recorded score against the server's score at # READ time — but the clients write their files several seconds earlier # (they wait run_seconds from their own later start, then the host waits # run_seconds + 5 more), so any goal scored in that window failed the run # with both bots agreeing perfectly with each other and only "disagreeing" # with a future they could not have seen. It was latent until the input # blackout fix (§3.2) made the bots effective enough to reliably score a # SECOND goal: reproduced 2 of 3 runs, and each failure had server=2 vs # both clients=1. Cross-peer agreement is the real claim here, so assert # that both clients agree with each other AND that what they saw is a # state the server genuinely passed through. # Polled, not signal-driven: score_changed is emitted only in # _on_score_update_received, i.e. the CLIENT path. The server mutates # `score` directly in _record_goal and never emits, so connecting here # silently recorded nothing but the initial 0-0 (verified — it made all # three runs fail with a one-entry history). var score_history: Array[String] = [JSON.stringify(match_scene.score)] # An adversarial review found this driver's original checks (snapshot # count, a server-FORCED goal's cross-peer score agreement) don't # depend on client input ever reaching the server at all — it kept # reporting PASS with the input pipeline completely dead (verified by # injecting the ring-overflow bug this session's critical fix # addresses, mid-run). Record each ship's starting position now, before # anything moves, so real server-side movement over the run can be # checked directly — the same signal run_client_check already uses for # a human client, applied here per-bot instead of just for "my own ship". var start_positions: Dictionary = {} for slot in match_scene._slots: if is_instance_valid(slot.ship): start_positions[slot.peer_id] = slot.ship.global_position var goals: Array = match_scene.arena.get_goals() if match_scene.arena else [] if is_instance_valid(match_scene.ball) and not goals.is_empty(): match_scene.ball.linear_velocity = Vector3.ZERO match_scene.ball.global_position = goals[0].global_position print("SMOKE INFO: host forced a goal for the cross-peer score agreement check") # Movement/stalled must be checked WHILE clients are still actively # connected and playing, not after their run finishes — a client's own # (legitimate, expected) disconnect at the end of its run naturally # starves its jitter buffer too, which looks identical to the ring- # overflow bug this check exists to catch if sampled too late. A first # attempt used a 0.5s margin (run_seconds - 0.5); a second adversarial # review instrumented multiplayer.get_peers() at sample time and found # it was already EMPTY — both bots had legitimately disconnected before # the sample ran, and the check was only passing on the ~200ms of # residual STARVE_ZERO_TICKS starvation grace, not because it was # genuinely still connected as this print used to claim. Widen the # margin AND assert connectivity directly at sample time, rather than # inferring it from timing, so a future regression in either direction # (margin too tight again, or client run_seconds changing) fails loudly # here instead of silently passing on residual grace. var movement_check_delay := maxf(1.0, run_seconds - 2.0) await _await_recording_score(match_scene, movement_check_delay, score_history) var connected_peers := multiplayer.get_peers() var input_reached_server := true for slot in match_scene._slots: var still_connected: bool = slot.peer_id in connected_peers if not still_connected: input_reached_server = false print("SMOKE FAIL: peer %d already disconnected at movement-sample time (connected_peers=%s) — margin too tight" % [slot.peer_id, str(connected_peers)]) if not is_instance_valid(slot.ship) or not start_positions.has(slot.peer_id): input_reached_server = false print("SMOKE FAIL: peer %d has no valid ship to check movement on" % slot.peer_id) continue var moved: float = start_positions[slot.peer_id].distance_to(slot.ship.global_position) var stalled: bool = slot.jitter_buffer.stalled print("SMOKE INFO: peer %d moved %.2fm server-side (connected=%s), stalled=%s" % [slot.peer_id, moved, str(still_connected), str(stalled)]) if moved <= 0.5 or stalled: input_reached_server = false # Extra buffer beyond run_seconds: clients run for their own run_seconds # measured from THEIR (later) start, so waiting only run_seconds here # would race their score files not being written yet. await _await_recording_score(match_scene, run_seconds + 5.0 - movement_check_delay, score_history) print("SMOKE INFO: host final score=%s (server held: %s)" % [str(match_scene.score), str(score_history)]) var slots_ok: bool = match_scene._slots.size() == 2 var scores_agree := true var scores_seen := 0 var client_scores: Array[String] = [] for slot in match_scene._slots: var path := "/tmp/cosmicclash_ci_score_%d.txt" % slot.peer_id if not FileAccess.file_exists(path): print("SMOKE FAIL: no score file from peer %d at %s" % [slot.peer_id, path]) scores_agree = false continue var f := FileAccess.open(path, FileAccess.READ) var client_score := f.get_as_text() f.close() scores_seen += 1 client_scores.append(client_score) if not score_history.has(client_score): print("SMOKE FAIL: peer %d saw score %s, which the server never held (history %s)" % [slot.peer_id, client_score, str(score_history)]) scores_agree = false # The strong half: two independent peers must have reached the SAME view. for other in client_scores: if other != client_scores[0]: print("SMOKE FAIL: peers disagree with each other: %s" % str(client_scores)) scores_agree = false break var success: bool = slots_ok and scores_agree and scores_seen == 2 and input_reached_server print("SMOKE %s: CI host run (slots_ok=%s scores_agree=%s scores_seen=%d/2 input_reached_server=%s)" % [ "PASS" if success else "FAIL", str(slots_ok), str(scores_agree), scores_seen, str(input_reached_server), ]) NetworkManager.shutdown() get_tree().quit(0 if success else 1) # task 3.6, client-bot role: counts real snapshots received over run_seconds # (proving steady traffic, not just a handshake) and writes this peer's own # final server-authoritative score to a peer-id-keyed file for the host to # compare against the other bot's (run_ci_host_check above). func run_ci_client_check(run_seconds: float) -> void: var snapshot_count := [0] MatchSim.snapshot_received.connect(func(_decoded: Dictionary) -> void: snapshot_count[0] += 1) await get_tree().create_timer(1.0).timeout var match_scene := get_tree().current_scene if not _is_networked_match(match_scene): print("SMOKE FAIL: client-bot scene is not NetworkedMatch") get_tree().quit(1) return await get_tree().create_timer(run_seconds).timeout var slots_ok: bool = not match_scene._slots.is_empty() # 60Hz nominal; generous margin for connection/scene-load settle time # eaten out of run_seconds and for the odd dropped/simulated-lossy tick. var min_expected := int((run_seconds - 2.0) * 30.0) var snapshot_count_ok: bool = snapshot_count[0] >= min_expected var net_stats: Dictionary = match_scene.get_net_debug_stats() var present_time := bool(match_scene.remote_visual_present_time_enabled) var remote_position_p99 := float(net_stats.get("remote_residual_position_p99", INF)) var remote_rotation_p99 := float(net_stats.get("remote_residual_rotation_p99", INF)) var remote_quality_ok := not present_time or (remote_position_p99 < 0.3 and remote_rotation_p99 < 5.0) var my_id := multiplayer.get_unique_id() var score_path := "/tmp/cosmicclash_ci_score_%d.txt" % my_id var f := FileAccess.open(score_path, FileAccess.WRITE) f.store_string(JSON.stringify(match_scene.score)) f.close() print("SMOKE INFO: client-bot snapshot_count=%d (want >= %d) slots_ok=%s final_score=%s remote_present_time=%s residual_p99=%.3fm/%.3fdeg" % [ snapshot_count[0], min_expected, str(slots_ok), str(match_scene.score), str(present_time), remote_position_p99, remote_rotation_p99, ]) var success: bool = slots_ok and snapshot_count_ok and remote_quality_ok print("SMOKE %s: CI client-bot run" % ("PASS" if success else "FAIL")) # The host validates live server-side motion at `run_seconds - 2`. The # first client may have entered its scene before the second one joined, # so it otherwise can finish and disconnect just before that sample. Stay # connected long enough for the host to observe both real input streams. await get_tree().create_timer(3.0).timeout NetworkManager.shutdown() get_tree().quit(0 if success else 1)