mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
1368 lines
76 KiB
GDScript
1368 lines
76 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")
|
|
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.
|
|
# The match scene can be freed underneath this — a lost server sends the
|
|
# client back to the main menu (§6.4), same class of teardown as RESULTS.
|
|
if not _is_networked_match(match_scene) or not is_instance_valid(my_slot.ship):
|
|
print("SMOKE INFO: match scene torn down mid-drive (server lost?)")
|
|
NetworkManager.shutdown()
|
|
get_tree().quit(1)
|
|
return
|
|
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.
|
|
# Transport health, printed alongside the quality numbers and asserted
|
|
# separately below. Without this a p95 failure is undiagnosable: "the
|
|
# predictor got worse" and "the client never received the data" look
|
|
# identical in a percentile. An adversarial review hit exactly that — a
|
|
# 3-process run failed at p95 0.688 with roughly a third of snapshots
|
|
# missing, and it could not be told apart from a real regression.
|
|
var snapshot_loss_pct := float(net_stats.get("snapshot_loss_pct", 0.0))
|
|
var snapshot_age_ms := float(net_stats.get("snapshot_age_ms", 0.0))
|
|
print("SMOKE INFO: transport snapshot_loss=%.1f%% snapshot_age=%.1fms rtt=%.1fms" % [
|
|
snapshot_loss_pct, snapshot_age_ms, NetworkManager.rtt_ms
|
|
])
|
|
|
|
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
|
|
# The 0.5/2.0 free-flight bounds belong to --exercise-free-flight and ONLY
|
|
# to it, because that mode is the only one that produces the profile they
|
|
# were calibrated on. _run_free_flight_trace exists precisely because, in
|
|
# its own words, "a straight forward trace reaches the goal/wall in seconds
|
|
# and turns the supposed free-flight QA run into a contact test" — yet the
|
|
# plain role went on asserting the open-volume bounds against whatever
|
|
# free-flight samples that contact-heavy drive happened to leave behind.
|
|
#
|
|
# Measured over 8 plain-role runs on an idle machine: the free-flight
|
|
# cohort ranged from 12 to 257 samples and its p95 from 0.275 to 0.726,
|
|
# failing the 0.5 bound in 3 of 8 — a ~37% flake rate with no defect
|
|
# present. That is the p95 0.688 an adversarial review reported and I first
|
|
# mis-attributed to three-process CPU contention: it reproduces on two
|
|
# processes, on an idle box, with 0.0% snapshot loss. The mechanism is not
|
|
# noise — error near the arena's surface-pull field is genuinely several
|
|
# times higher than in open air (--exercise-free-flight measures 0.084-0.111
|
|
# on the same build) — but a gate that fires a third of the time is worse
|
|
# than no gate, and calibrating one bound for both profiles cannot work.
|
|
#
|
|
# So the plain role asserts the ALL-COHORT percentiles instead. They are
|
|
# always well-sampled (545-696 samples across those same runs, versus a
|
|
# free-flight cohort that can collapse to 12) and much tighter in spread:
|
|
# raw_p95 0.354-0.609, raw_p99 0.362-0.742. The bounds below sit ~2x above
|
|
# the worst observed. A free-flight-cohort regression still cannot hide:
|
|
# free_flight_hard_snaps is asserted in both modes, and anything past 2.0m
|
|
# IS a hard snap by definition.
|
|
#
|
|
# The 100-sample floor is not the plain drive's number (545-696) but
|
|
# --exercise-match-state's: its forced goal suspends prediction for the
|
|
# whole GOAL_PAUSE, so an 8s run yields ~153. Still five times what the
|
|
# old free-flight floor accepted.
|
|
const NEAR_SURFACE_P95 := 1.2
|
|
const NEAR_SURFACE_P99 := 2.0
|
|
var overall_p95: float = float(prediction_stats.get("position_error_p95", INF))
|
|
var overall_p99: float = float(prediction_stats.get("position_error_p99", INF))
|
|
var overall_samples := int(prediction_stats.get("sample_count", 0))
|
|
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 \
|
|
if exercise_free_flight else \
|
|
overall_samples >= 100 \
|
|
and overall_p95 < NEAR_SURFACE_P95 \
|
|
and overall_p99 < NEAR_SURFACE_P99 \
|
|
and raw_rotation_p95 < 5.0 \
|
|
and raw_rotation_p99 < 15.0 \
|
|
and free_flight_hard_snaps == 0
|
|
if not exercise_free_flight and not exercise_input_transitions and not exercise_ball_contact:
|
|
print("SMOKE INFO: near-surface profile — asserting all-cohort p95=%.3f/p99=%.3f (bounds %.1f/%.1f, %d samples); free-flight cohort p95=%.3f/p99=%.3f over %d samples is REPORTED, NOT ASSERTED (see --exercise-free-flight for the calibrated gate)" % [
|
|
overall_p95, overall_p99, NEAR_SURFACE_P95, NEAR_SURFACE_P99, overall_samples,
|
|
raw_quality_p95, raw_quality_p99, quality_samples,
|
|
])
|
|
# 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
|
|
# A run starved of snapshots has not measured prediction quality at all, so
|
|
# say so explicitly instead of blaming the predictor. Deliberately does NOT
|
|
# convert the failure into a pass — a client that cannot receive snapshots
|
|
# is still a failed run, just a differently-diagnosed one.
|
|
if not prediction_quality_ok and snapshot_loss_pct > 20.0:
|
|
print("SMOKE FAIL: transport-starved, not a prediction regression (snapshot_loss=%.1f%%) — check host CPU contention before suspecting the predictor" % snapshot_loss_pct)
|
|
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.
|
|
# hold_after_reclaim_seconds is not padding. The original version ticked 60
|
|
# physics frames (1.0s) after the reclaim and then shut the server down, which
|
|
# meant the reconnecting client — whose own wiring check waits a 2.0s settle
|
|
# before it looks at anything — had its peer torn out from under it every time
|
|
# and reported "current_scene is not NetworkedMatch after 2.0s". The server side
|
|
# passed throughout, so the harness looked green from the only side anyone read.
|
|
# The reconnecting player is half of what §6.4 promises; it gets a real window.
|
|
func run_disconnect_host_check(lifetime_seconds: float, hold_after_reclaim_seconds: float = 8.0) -> 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. It also keeps
|
|
# the server alive long enough for the reconnected client to run its own
|
|
# checks and actually play — see this function's header.
|
|
var reclaim_position := Vector3.ZERO
|
|
if is_instance_valid(match_scene._slots[0].ship):
|
|
reclaim_position = match_scene._slots[0].ship.global_position
|
|
# The reconnected player's input must reach the server and move the ship the
|
|
# server owns. Every other assertion here is about slot bookkeeping and
|
|
# would hold identically for a client whose input pipeline came back dead —
|
|
# which is the failure §6.4's reservation exists to prevent.
|
|
#
|
|
# Both the position and the connection state are sampled WHILE the peer is
|
|
# still connected, not once at the end of the hold. The client finishes its
|
|
# own checks and leaves on its own schedule, so an end-of-hold sample reads
|
|
# a legitimately departed peer and reports "still_connected=false" for a
|
|
# perfectly good run — the same mis-timed sampling a Phase 3 review caught
|
|
# in the CI gate.
|
|
var saw_connected := false
|
|
var last_connected_position := reclaim_position
|
|
var hold_deadline := Time.get_ticks_msec() + int(hold_after_reclaim_seconds * 1000.0)
|
|
while Time.get_ticks_msec() < hold_deadline and _is_networked_match(match_scene):
|
|
if match_scene._slots[0].peer_id in multiplayer.get_peers():
|
|
saw_connected = true
|
|
if is_instance_valid(match_scene._slots[0].ship):
|
|
last_connected_position = match_scene._slots[0].ship.global_position
|
|
await get_tree().physics_frame
|
|
|
|
var still_live := _is_networked_match(match_scene)
|
|
var server_side_movement := Vector2(
|
|
last_connected_position.x - reclaim_position.x,
|
|
last_connected_position.z - reclaim_position.z
|
|
).length()
|
|
var drove_after_reclaim := saw_connected and server_side_movement > 1.0
|
|
print("SMOKE INFO: reconnected player moved %.2fm horizontally server-side while connected, over a %.1fs hold (saw_connected=%s)" % [
|
|
server_side_movement, hold_after_reclaim_seconds, str(saw_connected),
|
|
])
|
|
|
|
var success := saw_disconnect and ship_survived and controller_valid and reserved and reclaimed and same_ship \
|
|
and still_live and drove_after_reclaim 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 still_live=%s drove_after_reclaim=%s)" % [
|
|
"PASS" if success else "FAIL", str(saw_disconnect), str(ship_survived), str(reserved), str(reclaimed), str(same_ship),
|
|
str(still_live), str(drove_after_reclaim),
|
|
])
|
|
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)
|
|
|
|
|
|
# §6.3's "free slot mid-match → spectate now, take the slot at the next
|
|
# kickoff", server side. The sequence this drives: a player leaves, their §6.4
|
|
# reservation lapses (run with --slot-reservation-seconds= small, or this waits
|
|
# 30 real seconds for the interesting moment), a goal is forced to produce a
|
|
# kickoff, and the waiting spectator must be holding the slot afterwards.
|
|
#
|
|
# The forced goal is the same deterministic trick the CI driver and the
|
|
# match-state check use — waiting for two peers to score naturally inside a
|
|
# short run is not something to gate on.
|
|
func run_late_joiner_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
|
|
if match_scene._slots.is_empty():
|
|
print("SMOKE FAIL: host has no slots — the first client never joined")
|
|
NetworkManager.shutdown()
|
|
get_tree().quit(1)
|
|
return
|
|
var original_peer: int = match_scene._slots[0].peer_id
|
|
var original_name: String = match_scene._slots[0].player_name
|
|
var ship_before = match_scene._slots[0].ship
|
|
|
|
# Wait for the seated player to drop.
|
|
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) or not match_scene._slots[0].disconnected:
|
|
print("SMOKE FAIL: the seated player never dropped")
|
|
NetworkManager.shutdown()
|
|
get_tree().quit(1)
|
|
return
|
|
# A spectator must be queued by now, or the rest of this proves nothing.
|
|
var queued: int = match_scene._late_joiners.size()
|
|
|
|
# Then for the reservation to lapse. Until it does, the slot belongs to the
|
|
# player who left — §6.4 outranks §6.3, and taking it early would quietly
|
|
# break the reconnect promise.
|
|
var lapse_deadline := Time.get_ticks_msec() + int(lifetime_seconds * 1000.0) + 32000
|
|
while Time.get_ticks_msec() < lapse_deadline and _is_networked_match(match_scene) \
|
|
and match_scene._slots[0].reserved_until_tick >= 0 \
|
|
and Engine.get_physics_frames() <= match_scene._slots[0].reserved_until_tick:
|
|
await get_tree().physics_frame
|
|
if not _is_networked_match(match_scene):
|
|
print("SMOKE FAIL: match aborted while the spectator was waiting for the slot")
|
|
NetworkManager.shutdown()
|
|
get_tree().quit(1)
|
|
return
|
|
# Nothing may have promoted yet: the reservation lapsing is not a kickoff.
|
|
var promoted_before_kickoff: bool = not match_scene._slots[0].disconnected
|
|
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 produce a kickoff")
|
|
|
|
var promote_deadline := Time.get_ticks_msec() + 10000
|
|
while Time.get_ticks_msec() < promote_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 kickoff could promote anyone")
|
|
NetworkManager.shutdown()
|
|
get_tree().quit(1)
|
|
return
|
|
|
|
var slot = match_scene._slots[0]
|
|
var took_slot: bool = not slot.disconnected and slot.peer_id != original_peer
|
|
var renamed: bool = slot.player_name != original_name and slot.player_name != ""
|
|
var same_ship: bool = is_instance_valid(slot.ship) and slot.ship == ship_before
|
|
var controller_valid: bool = is_instance_valid(slot.controller)
|
|
# Not load-bearing on its own: the queue also empties when a waiting peer
|
|
# gives up and leaves, which is exactly what a control run with a long
|
|
# reservation showed. took_slot plus the name change is the real evidence.
|
|
var queue_drained: bool = match_scene._late_joiners.is_empty()
|
|
print("SMOKE INFO: late joiner queued=%d promoted_before_kickoff=%s took_slot=%s new_name=%s same_ship=%s queue_drained=%s" % [
|
|
queued, str(promoted_before_kickoff), str(took_slot), slot.player_name, str(same_ship), str(queue_drained)
|
|
])
|
|
|
|
# It must be a real seat, not just a relabelled one: hold on and require
|
|
# the new owner's input to move the ship the server owns, sampled while
|
|
# they are still connected.
|
|
var start_position: Vector3 = slot.ship.global_position if is_instance_valid(slot.ship) else Vector3.ZERO
|
|
var last_connected_position := start_position
|
|
var saw_connected := false
|
|
var hold_deadline := Time.get_ticks_msec() + 8000
|
|
while Time.get_ticks_msec() < hold_deadline and _is_networked_match(match_scene):
|
|
if slot.peer_id in multiplayer.get_peers():
|
|
saw_connected = true
|
|
if is_instance_valid(slot.ship):
|
|
last_connected_position = slot.ship.global_position
|
|
await get_tree().physics_frame
|
|
var moved := Vector2(last_connected_position.x - start_position.x, last_connected_position.z - start_position.z).length()
|
|
var drove: bool = saw_connected and moved > 1.0
|
|
|
|
var success := queued > 0 and not promoted_before_kickoff and took_slot and renamed \
|
|
and same_ship and controller_valid and queue_drained and drove
|
|
print("SMOKE %s: late joiner took the vacated slot at the kickoff (queued=%d waited_for_kickoff=%s took_slot=%s same_ship=%s drove=%.2fm)" % [
|
|
"PASS" if success else "FAIL", queued, str(not promoted_before_kickoff), str(took_slot), str(same_ship), moved
|
|
])
|
|
NetworkManager.shutdown()
|
|
get_tree().quit(0 if success else 1)
|
|
|
|
|
|
# The same promotion from the SPECTATOR's side. It must start with no slot,
|
|
# gain one without reloading the scene, and be able to fly it — the client's
|
|
# _is_spectator was assigned once at match_config time and never revisited,
|
|
# so "the server promoted me" and "I can actually play" are separate claims.
|
|
func run_late_joiner_client_check(lifetime_seconds: float) -> void:
|
|
var load_deadline := Time.get_ticks_msec() + 10000
|
|
while Time.get_ticks_msec() < load_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: late joiner never loaded the match scene")
|
|
get_tree().quit(1)
|
|
return
|
|
await get_tree().create_timer(1.0).timeout
|
|
var started_spectating: bool = match_scene._my_slot == null and match_scene._is_spectator
|
|
if not started_spectating:
|
|
print("SMOKE FAIL: late joiner was given a slot immediately — it should spectate until a kickoff (my_slot=%s is_spectator=%s)" % [
|
|
str(match_scene._my_slot != null), str(match_scene._is_spectator)
|
|
])
|
|
NetworkManager.shutdown()
|
|
get_tree().quit(1)
|
|
return
|
|
|
|
var promote_deadline := Time.get_ticks_msec() + int(lifetime_seconds * 1000.0) + 42000
|
|
while Time.get_ticks_msec() < promote_deadline and _is_networked_match(match_scene) and match_scene._my_slot == null:
|
|
await get_tree().physics_frame
|
|
if not _is_networked_match(match_scene):
|
|
print("SMOKE FAIL: match scene torn down before the late joiner was promoted")
|
|
get_tree().quit(1)
|
|
return
|
|
var promoted: bool = match_scene._my_slot != null and not match_scene._is_spectator
|
|
if not promoted:
|
|
print("SMOKE FAIL: late joiner never got a slot (my_slot=%s is_spectator=%s)" % [
|
|
str(match_scene._my_slot != null), str(match_scene._is_spectator)
|
|
])
|
|
NetworkManager.shutdown()
|
|
get_tree().quit(1)
|
|
return
|
|
|
|
# Wait for live play — a promotion lands at a kickoff, so the very next
|
|
# thing is a countdown with every body frozen.
|
|
var live_deadline := Time.get_ticks_msec() + 15000
|
|
while Time.get_ticks_msec() < live_deadline and _is_networked_match(match_scene) and not MatchState.is_live(match_scene.match_state):
|
|
await get_tree().physics_frame
|
|
var my_slot = match_scene._my_slot
|
|
var owns_slot: bool = my_slot != null and my_slot.peer_id == multiplayer.get_unique_id()
|
|
var ship_ok: bool = my_slot != null and is_instance_valid(my_slot.ship)
|
|
# The promoted ship was a REMOTE body a moment ago: frozen kinematic and fed
|
|
# by the interpolator. Promotion deliberately does NOT unfreeze it on the
|
|
# spot — it waits for the first authoritative pose, exactly as a fresh
|
|
# client does — so this WAITS for prediction to start rather than sampling
|
|
# at whichever frame the state happened to go live. Sampling immediately is
|
|
# a race the run loses about half the time, reporting predicting=false on a
|
|
# client that then flew 45m perfectly well.
|
|
# Poll the whole condition, not _local_prediction_ready alone. Unfreezing is
|
|
# QUEUED and applied on the body's own next _integrate_forces (task 0.15),
|
|
# so there is a real window where the state is PLAYING and the flag is set
|
|
# but ship.freeze has not flipped yet — sampling on that frame reported
|
|
# predicting=false for a client that then flew 45m, twice in five runs.
|
|
var predicting := false
|
|
var predict_deadline := Time.get_ticks_msec() + 5000
|
|
while Time.get_ticks_msec() < predict_deadline and _is_networked_match(match_scene):
|
|
predicting = ship_ok and match_scene._local_prediction_ready \
|
|
and not my_slot.ship.freeze and not my_slot.interpolator.has_samples()
|
|
if predicting:
|
|
break
|
|
await get_tree().physics_frame
|
|
var controller_ok: bool = ship_ok and my_slot.ship.controller != null and my_slot.ship.controller.get_parent() == my_slot.ship
|
|
|
|
var start_position: Vector3 = my_slot.ship.global_position if ship_ok else Vector3.ZERO
|
|
Input.action_press("move_forward")
|
|
await get_tree().create_timer(3.0).timeout
|
|
Input.action_release("move_forward")
|
|
if not _is_networked_match(match_scene) or not (ship_ok and is_instance_valid(my_slot.ship)):
|
|
print("SMOKE FAIL: promoted client lost its ship or scene mid-drive")
|
|
get_tree().quit(1)
|
|
return
|
|
var end_position: Vector3 = my_slot.ship.global_position
|
|
var moved := Vector2(end_position.x - start_position.x, end_position.z - start_position.z).length()
|
|
var moved_ok := moved > 1.0
|
|
|
|
print("SMOKE INFO: promotion spectated_first=%s owns_slot=%s ship_ok=%s predicting=%s (ready=%s frozen=%s interp_samples=%s state=%s) controller_ok=%s moved=%.2fm" % [
|
|
str(started_spectating), str(owns_slot), str(ship_ok), str(predicting),
|
|
str(match_scene._local_prediction_ready), str(ship_ok and my_slot.ship.freeze),
|
|
str(ship_ok and my_slot.interpolator.has_samples()), MatchState.to_name(match_scene.match_state),
|
|
str(controller_ok), moved
|
|
])
|
|
var success := started_spectating and promoted and owns_slot and ship_ok and predicting and controller_ok and moved_ok
|
|
print("SMOKE %s: spectator was promoted to player and can fly the slot it inherited (moved=%.2fm)" % [
|
|
"PASS" if success else "FAIL", moved
|
|
])
|
|
NetworkManager.shutdown()
|
|
get_tree().quit(0 if success else 1)
|
|
|
|
|
|
# §6.4's reconnect, graded from the RECONNECTING PLAYER's side. The
|
|
# host-disconnect scenario already asserts the server's bookkeeping — slot
|
|
# reserved, ship kept, reclaimed by name — but every one of those assertions
|
|
# holds identically for a client that came back as a spectator, or came back
|
|
# owning a slot whose input pipeline is dead. Both have happened: a stale
|
|
# _last_match_config made a reconnecting player a spectator, and that bug was
|
|
# visible in this scenario's own logs while it reported PASS.
|
|
#
|
|
# So this asserts what the returning player actually cares about: I am a
|
|
# player and not a spectator, I own a slot with a real ship, the match I
|
|
# rejoined is live with a clock already running (§6.2 step 2's bootstrap — a
|
|
# reconnecting player must not have to wait for the next goal to learn the
|
|
# score), and my input still moves my ship.
|
|
func run_reconnect_client_check(settle_seconds: float, drive_seconds: float) -> void:
|
|
var deadline := Time.get_ticks_msec() + int(maxf(settle_seconds, 2.0) * 1000.0)
|
|
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: reconnecting client never loaded the match scene")
|
|
get_tree().quit(1)
|
|
return
|
|
|
|
# Play may legitimately be paused for a kickoff or a goal when a client
|
|
# rejoins, and a frozen ship cannot be driven — wait for live rather than
|
|
# grading the reconnect on whichever moment it happened to land in.
|
|
var live_deadline := Time.get_ticks_msec() + 15000
|
|
while Time.get_ticks_msec() < live_deadline and _is_networked_match(match_scene) and not MatchState.is_live(match_scene.match_state):
|
|
await get_tree().physics_frame
|
|
if not _is_networked_match(match_scene):
|
|
print("SMOKE FAIL: match scene torn down before the reconnecting client could play")
|
|
get_tree().quit(1)
|
|
return
|
|
|
|
var my_slot = match_scene._my_slot
|
|
var is_player: bool = my_slot != null and not match_scene._is_spectator
|
|
var ship_ok: bool = is_player and is_instance_valid(my_slot.ship)
|
|
var owns_slot: bool = is_player and my_slot.peer_id == multiplayer.get_unique_id()
|
|
# Reported before the drive, not after. Coming back as a spectator is the
|
|
# specific bug this role exists to catch (a stale _last_match_config caused
|
|
# exactly that), and falling through to the drive would report it as the
|
|
# generic "lost its ship mid-drive" — which is what a control run, rejoining
|
|
# while the slot was still occupied, actually printed.
|
|
if not (is_player and ship_ok and owns_slot):
|
|
print("SMOKE FAIL: reconnecting player did NOT reclaim a slot — is_player=%s owns_slot=%s ship_ok=%s (came back as a spectator?)" % [
|
|
str(is_player), str(owns_slot), str(ship_ok)
|
|
])
|
|
NetworkManager.shutdown()
|
|
get_tree().quit(1)
|
|
return
|
|
var state_ok: bool = MatchState.is_live(match_scene.match_state)
|
|
# The bootstrap half (§6.2 step 2). _end_tick stays -1 on a client nobody
|
|
# told about the clock, so this is exactly "did my rejoin carry the live
|
|
# match with it" — a reconnecting player that has to wait for the next goal
|
|
# to learn the clock and score has not really rejoined the match.
|
|
var clock_ok: bool = match_scene._end_tick >= 0
|
|
|
|
var start_position: Vector3 = my_slot.ship.global_position if ship_ok else Vector3.ZERO
|
|
Input.action_press("move_forward")
|
|
await get_tree().create_timer(drive_seconds).timeout
|
|
Input.action_release("move_forward")
|
|
|
|
if not _is_networked_match(match_scene) or not (ship_ok and is_instance_valid(my_slot.ship)):
|
|
print("SMOKE FAIL: reconnecting client lost its ship or scene mid-drive")
|
|
get_tree().quit(1)
|
|
return
|
|
var end_position: Vector3 = my_slot.ship.global_position
|
|
# Horizontal only: forward thrust is a horizontal force, and full 3D
|
|
# distance is satisfiable by gravity alone from the spawn height.
|
|
var moved := Vector2(end_position.x - start_position.x, end_position.z - start_position.z).length()
|
|
var moved_ok := moved > 1.0
|
|
|
|
print("SMOKE INFO: reconnect is_player=%s owns_slot=%s ship_ok=%s state=%s end_tick=%d moved=%.2fm" % [
|
|
str(is_player), str(owns_slot), str(ship_ok), MatchState.to_name(match_scene.match_state),
|
|
match_scene._end_tick, moved,
|
|
])
|
|
var success := is_player and owns_slot and ship_ok and state_ok and clock_ok and moved_ok
|
|
print("SMOKE %s: reconnecting player rejoined as a player and can still drive (player=%s owns_slot=%s clock=%s moved=%.2fm)" % [
|
|
"PASS" if success else "FAIL", str(is_player), str(owns_slot), str(clock_ok), moved,
|
|
])
|
|
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. Sample near the end
|
|
# of the active run: a server begins timing as soon as both peers join,
|
|
# whereas each bot needs to load the match and settle before its input can
|
|
# accumulate meaningful motion. The bots remain connected for an extra
|
|
# three seconds after their active run, leaving a generous live margin.
|
|
var movement_check_delay := maxf(1.0, run_seconds - 0.5)
|
|
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)
|