mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
ff725e1ffa
"Spectate now, take the slot at the next kickoff" was a print statement. The server logged it and never acted; on the client, _is_spectator was assigned once in _on_match_config_received and never revisited - and that handler returns early whenever _slots is non-empty, so no rebroadcast could promote an in-match spectator. The reconnect path only worked because a returning player is a fresh process. Server: late joiners are queued in arrival order and the queue is drained from _begin_kickoff, before the reset transforms are read, so a promoted player's ship is placed by that same kickoff and the controller swap lands on an already-frozen body. A slot is available only once its player has gone AND their 30s reservation has lapsed - §6.4 outranks §6.3, since taking a reserved slot would quietly break the reconnect promise. _abort_if_abandoned now counts a waiting spectator as somebody present, or the one person queued for the slot that just opened is dumped to the lobby at the moment they were about to get it. Client: new broadcast slot_assigned (reliable, channel 0). Broadcast because every client holds its own slot list and one naming the wrong peer keeps flying somebody else's ship as a remote body; reliable because no per-snapshot field would re-converge a client that missed it. The promoted client undoes what made the body remote - fresh interpolator, physics interpolation back on, offsets cleared - and deliberately does not unfreeze, clearing _local_prediction_ready so the next snapshot teleports it to a real authoritative pose first. The controller-attach block moved to _take_local_ownership rather than being copied. New --role=host-latejoin/--role=client-latejoin and --slot-reservation-seconds=. Verified 4/4 both sides: queued, NOT promoted merely because the reservation lapsed, takes the slot at the kickoff, same ship instance, and both peers independently measure ~45.7m under its input. Control with a 90s reservation: kickoff fires, nothing is promoted, the slot still reads the departed player's name.
1364 lines
76 KiB
GDScript
1364 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.
|
|
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)
|