mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-16 15:32:04 +00:00
feat(multiplayer): Phase 4 prediction correctness + two input-death fixes
Closes Phase 4's outstanding action-sequence-correctness invariant, then fixes two server-side bugs an adversarial review of that work uncovered. Server simulation, bot observations, collision resources and tick rate are unchanged: the server_physics_parity trace is byte-for-byte identical to HEAD across 360 ticks including both ships' full observation vectors. 4.11 - prediction history filed under the ISSUING sequence _send_local_input filed each post-step predicted state under the timeline's estimate of the sequence the server would consume this tick, trailing issuance by input_lead. The body had integrated the intent issued under _input_seq, so predicted[S] held "state after the intent from now" while the server's authority for S is "state after action(S)". They agree only while the stick is still. Filing under _input_seq costs nothing: which action the ship uses is decided in LocalNetShipController.get_action() and is untouched. Every prior Phase 4 gate held its input steady, and a steady input cannot falsify a sequence label - the 60s runs honestly reported marker=0/3784. New --exercise-input-transitions role toggles thrust every 6 ticks; it is the only gate that can catch a label regression. Verified non-vacuous: the old label fails it at 50%. 4.12 - issued-but-unsimulated sequences, and the release path An attack (delta > 1) issues and sends several sequences for one local physics step. Those gap sequences had no recorded prediction, so a server ack of one reported missing_not_recorded - indistinguishable from ring loss, costing a teleport and resync suppression several times a minute. They are now recorded stateless via record_unsimulated() and answered with a new "skip" decision mode. Free-flight hard snaps: 25/8/4 -> 0/0/0. A release (delta == 0) re-recorded at the unchanged _input_seq, filing the current intent under a sequence that went out carrying a different action; LocalInputTimeline deliberately refuses to mutate an issued sequence, so the ring contradicted the wire. Recording is now skipped on release ticks. 4.13 - two Phase 3 bugs silently killing player input (a) InputJitterBuffer.consume() advanced last_applied_seq on every tick including a starve. Since ingest() discards seq <= last_applied_seq, one starve on a sequence the client had not sent yet stranded the stream one ahead of arrivals permanently - both sides advancing in lockstep, every honest packet discarded on arrival. The client's own input_lead release is enough to trigger it, so input died for ~30 ticks roughly every 6.5s on a clean LAN. Now only gives up on a sequence once strictly newer data proves it lost. Silent-client stall and ring-overflow resync are unchanged. (b) The seq-range guard bounded incoming seq against highest_ingested_seq, which only advances inside ingest(), which that guard gates. After a ~2s host hitch every packet was rejected forever with no diagnostic (600+ consecutive rejections reproduced via SIGSTOP). Third iteration of this guard; each previous version bounded against a value only the accepted path could advance. Adds an escape after 10 consecutive rejections, which grants an attacker nothing the rate limiter does not already bound. (c) The transitions gate reported PASS at 3.76% while input was completely dead, because suppression stops _record_metrics - a worse outage yields fewer samples and a LOWER rate. Now scales the required sample count with run length and asserts the wire's server_stalled bit. Reverting both fixes makes it fail at samples 292/600, server_stalled=true, input_lead=12. Fixing (a) also explained a residual the review had already traced: 151 of 151 action-marker mismatches were the server repeating a stale action on a starve, not a prediction defect. Marker is now 0.00% in all three conditions (was 1.7-2.5%), and free-flight p99 improved to 0.141/0.168/0.154m from 0.170/0.176/0.184m. Two pre-existing test defects fixed alongside: the ball gate asserted RTT-masking on a link with no RTT (flaked 2 in 5; now asserted only at rtt >= 20ms, 5/5 under latency), and the two-bot CI compared scores across a 3-5s window (now polls the scores the server actually held; note score_changed is emitted only on the client path). QA: 72 unit tests; 60s free-flight at LAN/80+-20ms/5% loss; transition gate in all three; 2.0s and 3.5s host-freeze recovery; ball contact x5; two-bot CI x3; all three abuse roles; net/match_net/clock/lobby smokes. Phase 4 sign-off still pending a human playtest at ~100ms RTT - the milestone asks how it feels, which no gate here answers.
This commit is contained in:
@@ -15,6 +15,7 @@ extends Node
|
||||
# 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: Node) -> bool:
|
||||
@@ -42,12 +43,12 @@ func run_host_check(lifetime_seconds: float) -> void:
|
||||
await get_tree().create_timer(lifetime_seconds * 0.6).timeout
|
||||
if _is_networked_match(match_scene) and not match_scene.ships.is_empty():
|
||||
var ship: Ship = match_scene.ships[0]
|
||||
print("SMOKE INFO: host ship final position=%s (spawned, driven by client input if any arrived)" % str(ship.global_position))
|
||||
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) -> void:
|
||||
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) -> void:
|
||||
await get_tree().create_timer(settle_seconds).timeout
|
||||
|
||||
var match_scene := get_tree().current_scene
|
||||
@@ -64,7 +65,7 @@ func run_client_check(settle_seconds: float, drive_seconds: float) -> void:
|
||||
var hud_ok: bool = is_instance_valid(match_scene.hud)
|
||||
var start_position := Vector3.ZERO
|
||||
if my_slot_ok:
|
||||
start_position = my_slot.ship.visual.global_position
|
||||
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)
|
||||
@@ -74,6 +75,7 @@ func run_client_check(settle_seconds: float, drive_seconds: float) -> void:
|
||||
print("SMOKE FAIL: spawn/wiring check failed")
|
||||
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
|
||||
@@ -81,22 +83,76 @@ func run_client_check(settle_seconds: float, drive_seconds: float) -> void:
|
||||
# value) actually moved — proving input reached the server, the server
|
||||
# applied real thruster force, broadcast it back, and the client's
|
||||
# interpolator produced smooth motion from it.
|
||||
Input.action_press("move_forward")
|
||||
await get_tree().create_timer(drive_seconds * 0.5).timeout
|
||||
if exercise_ball_contact:
|
||||
# Slot T0/S0 needs a short diagonal burst to reach the centre ball.
|
||||
# Release it immediately and leave a >150ms observation window before
|
||||
# the normal drive, so a subsequent goal reset cannot mask blend-back.
|
||||
Input.action_press("move_forward")
|
||||
Input.action_press("move_right")
|
||||
await get_tree().create_timer(1.1).timeout
|
||||
Input.action_release("move_right")
|
||||
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
|
||||
|
||||
# Task 2.6: the server-computed thrust_z it broadcast in the snapshot
|
||||
# should have reached this client's interpolator and be readable off
|
||||
# the latest sample — this is what set_visual_action's engine-flame
|
||||
# wiring actually reads, so it's the real thing to check, not just
|
||||
# "the ship physically moved" (which 2.6 doesn't claim on its own).
|
||||
var latest_state = my_slot.interpolator.latest()
|
||||
var thrust_z_ok: bool = latest_state != null and latest_state.thrust_z > 0.5
|
||||
print("SMOKE INFO: mid-drive thrust_z=%.2f (expect >0.5 while holding forward)" % (latest_state.thrust_z if latest_state != null else -1.0))
|
||||
# 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.
|
||||
var local_prediction_ok: bool = not my_slot.ship.freeze \
|
||||
and my_slot.ship.controller != null \
|
||||
and my_slot.ship.controller.get_parent() == my_slot.ship \
|
||||
and not my_slot.interpolator.has_samples() \
|
||||
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)
|
||||
print("SMOKE INFO: local_prediction=%s freeze=%s controller_attached=%s local_interpolator_samples=%s" % [
|
||||
str(local_prediction_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())
|
||||
])
|
||||
|
||||
await get_tree().create_timer(drive_seconds * 0.5).timeout
|
||||
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
|
||||
|
||||
var end_position: Vector3 = my_slot.ship.visual.global_position
|
||||
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
|
||||
@@ -106,6 +162,7 @@ func run_client_check(settle_seconds: float, drive_seconds: float) -> void:
|
||||
# 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
|
||||
])
|
||||
@@ -115,15 +172,180 @@ func run_client_check(settle_seconds: float, drive_seconds: float) -> void:
|
||||
# A generous, not-tuned-to-the-decimal bound: this is a wiring smoke
|
||||
# test, not a physics-accuracy test (net_codec's own tests already cover
|
||||
# quantisation precision).
|
||||
var success := moved_horizontal > 1.0 and thrust_z_ok
|
||||
print("SMOKE %s: client observed %.2fm horizontal of server-authoritative movement via interpolation, thrust_z_ok=%s" % [
|
||||
"PASS" if success else "FAIL", moved_horizontal, str(thrust_z_ok)
|
||||
# The contact path intentionally includes a goal/reset in this trace, so
|
||||
# its expected authoritative snaps are reported separately rather than
|
||||
# contaminating the contact-free prediction gate.
|
||||
# The scheduled local timeline now predicts the same command stream the
|
||||
# server consumes, so both the same-sequence raw residual and the exposed
|
||||
# render discontinuity are meaningful free-flight gates. Hard corrections
|
||||
# remain separately gated by cohort.
|
||||
var raw_quality_p95: float = float(prediction_stats.get("free_flight_position_error_p95", INF))
|
||||
var raw_quality_p99: float = float(prediction_stats.get("free_flight_position_error_p99", INF))
|
||||
var raw_rotation_p95: float = float(prediction_stats.get("free_flight_rotation_error_p95", INF))
|
||||
var raw_rotation_p99: float = float(prediction_stats.get("free_flight_rotation_error_p99", INF))
|
||||
var quality_p95: float = float(prediction_stats.get("free_flight_visual_correction_p95", INF))
|
||||
var quality_p99: float = float(prediction_stats.get("free_flight_visual_correction_p99", INF))
|
||||
var quality_samples := int(prediction_stats.get("free_flight_sample_count", 0))
|
||||
var free_flight_hard_snaps := int(prediction_stats.get("hard_snap_cohorts", {}).get("free_flight", 99))
|
||||
# The action-sequence-label gate. Every other mode here holds its inputs
|
||||
# steady or near-steady, and a steady input CANNOT falsify the history's
|
||||
# seq labelling: while the commanded action is constant, "the intent from
|
||||
# this tick" and "the action the server consumes for seq S" carry the same
|
||||
# value under a correct and an incorrect label alike, so the action marker
|
||||
# reads 0/N either way. That is precisely how a real mislabelling survived
|
||||
# every earlier Phase 4 gate. Only this mode's forced edges expose it, so
|
||||
# only this mode asserts on the marker.
|
||||
#
|
||||
# Measured separation is wide, not marginal: labelling post-step state at
|
||||
# the server-consumption estimate reported 9.3% mismatch on LAN
|
||||
# (input_lead 1) and 24% at 80±20ms (input_lead 3) — it scales with the
|
||||
# lead, as the mechanism predicts — against 0-1.3% once filed under the
|
||||
# issuing sequence. The residual is seq-delta events (an attack issues
|
||||
# several sequences for one local physics step, a release duplicates one),
|
||||
# which relabelling does not claim to fix.
|
||||
var marker_samples := int(net_stats.get("action_marker_samples", 0))
|
||||
var marker_mismatches := int(net_stats.get("action_marker_mismatches", 99))
|
||||
var marker_rate := float(marker_mismatches) / float(maxi(marker_samples, 1))
|
||||
# The sample floor scales with the run, and that is load-bearing rather than
|
||||
# tidiness. An adversarial review reproduced a total, permanent input
|
||||
# blackout (a 3.5s host freeze) that this gate reported as PASS at 3.76%:
|
||||
# once reconciliation is suppressed, _record_metrics stops being called, so
|
||||
# the marker stops sampling entirely — the WORSE the outage, the FEWER
|
||||
# samples and the LOWER the reported mismatch rate. A flat ">= 200" is
|
||||
# satisfied by the handful of acks either side of the outage. Snapshots ack
|
||||
# at ~60Hz, so require half of nominal and a run this short is provably
|
||||
# still exchanging input for most of its length.
|
||||
var marker_sample_floor := maxi(200, int(drive_seconds * 30.0))
|
||||
var marker_samples_ok := marker_samples >= marker_sample_floor
|
||||
# Server-side starvation bit, round-tripped over the wire. A client flying
|
||||
# on pure prediction with the server ignoring it satisfies every other
|
||||
# assertion here, because all of them read the CLIENT's own action and
|
||||
# position.
|
||||
var server_stalled := bool(net_stats.get("server_stalled", false))
|
||||
# 5%, not 3%: the irreducible residual is seq-delta events and it scales
|
||||
# with input_lead, reaching 2.22% at lead 3 under 80±20ms — too close to a
|
||||
# 3% line for a CI gate. Separation from a genuinely mislabelled build is
|
||||
# 10-20x either way (control runs measure 24-50%), so the extra headroom
|
||||
# costs no real detection power. Tighten this only alongside recording
|
||||
# predictions for an attack's filled gap sequences.
|
||||
const MAX_ACTION_MARKER_MISMATCH_RATE := 0.05
|
||||
var action_label_ok: bool = marker_samples_ok and not server_stalled and marker_rate < MAX_ACTION_MARKER_MISMATCH_RATE
|
||||
var prediction_quality_ok: bool = action_label_ok if exercise_input_transitions else \
|
||||
prediction_stats.get("hard_snap_count", 99) < 4 if exercise_ball_contact else \
|
||||
quality_samples >= 30 \
|
||||
and raw_quality_p95 < 0.5 \
|
||||
and raw_quality_p99 < 2.0 \
|
||||
and raw_rotation_p95 < 5.0 \
|
||||
and raw_rotation_p99 < 15.0 \
|
||||
and quality_p95 < 0.5 \
|
||||
and quality_p99 < 2.0 \
|
||||
and free_flight_hard_snaps == 0
|
||||
# ball_proxy_moved_before_authority counts ticks where the predicted proxy
|
||||
# had visibly moved BEFORE the next authoritative ball state arrived. That
|
||||
# is only a meaningful — or even achievable — claim when there is real RTT
|
||||
# to mask: snapshots land every ~16.7ms at 60Hz, so on a loopback LAN the
|
||||
# whole pre-authority window is about one physics tick and whether it is
|
||||
# observed is a coin flip on arrival timing. Measured 2 failures in 5 LAN
|
||||
# runs, versus 5/5 passes (count 2-3) at --net-sim-latency=80, with the
|
||||
# same-frame reveal itself correct in every single run either way.
|
||||
#
|
||||
# So require it only when the link actually has latency to hide, and let
|
||||
# the same-frame reveal carry the gate on LAN — that is the real claim
|
||||
# ("your own touches register on contact, not ~RTT later") and it is not
|
||||
# racy. Runs asserting the masking behaviour should pass --net-sim-latency.
|
||||
var rtt_ms := NetworkManager.rtt_ms
|
||||
var rtt_masks_authority := rtt_ms >= 20.0
|
||||
var proxy_motion_ok: bool = not rtt_masks_authority or int(net_stats.get("ball_proxy_moved_before_authority_count", 0)) > 0
|
||||
var ball_contact_ok := not exercise_ball_contact or (int(net_stats.get("ball_prediction_contacts", 0)) > 0 \
|
||||
and int(net_stats.get("ball_contact_frame", -1)) == int(net_stats.get("ball_reveal_frame", -2)) \
|
||||
and int(net_stats.get("ball_blend_complete_count", 0)) > 0 \
|
||||
and int(net_stats.get("ball_blend_max_duration_ms", BALL_BLEND_ACCEPTANCE_MS)) <= BALL_BLEND_ACCEPTANCE_MS \
|
||||
and proxy_motion_ok)
|
||||
var success := verification_movement > 1.0 and local_prediction_ok and prediction_quality_ok and ball_contact_ok
|
||||
print("SMOKE %s: client locally predicted %.2fm horizontal, local_prediction_ok=%s prediction_quality_ok=%s" % [
|
||||
"PASS" if success else "FAIL", moved_horizontal, str(local_prediction_ok), str(prediction_quality_ok)
|
||||
])
|
||||
if exercise_input_transitions:
|
||||
print("SMOKE %s: action-sequence labelling under forced input transitions (marker=%d/%d = %.2f%% mismatch, want <%.0f%%; samples %d/%d required; server_stalled=%s; input_lead=%s)" % [
|
||||
"PASS" if action_label_ok else "FAIL", marker_mismatches, marker_samples, marker_rate * 100.0, MAX_ACTION_MARKER_MISMATCH_RATE * 100.0,
|
||||
marker_samples, marker_sample_floor, str(server_stalled), str(net_stats.get("input_lead", "-")),
|
||||
])
|
||||
if exercise_ball_contact:
|
||||
print("SMOKE %s: local dynamic ball proxy registered a same-frame reveal (contact_frame=%s reveal_frame=%s pre_authority_motion=%s hard_handoffs=%s blend_started=%s blends=%s blend_max_ms=%s ends=%s missing_shadow=%s reset_cancels=%s reset_trace=%s)" % [
|
||||
"PASS" if ball_contact_ok else "FAIL", str(net_stats.get("ball_contact_frame", -1)), str(net_stats.get("ball_reveal_frame", -1)), str(net_stats.get("ball_proxy_moved_before_authority_count", 0)),
|
||||
str(net_stats.get("ball_hard_handoff_count", 0)), str(net_stats.get("ball_blend_started_count", 0)), str(net_stats.get("ball_blend_complete_count", 0)), str(net_stats.get("ball_blend_max_duration_ms", 0)),
|
||||
str(net_stats.get("ball_prediction_window_end_count", 0)), str(net_stats.get("ball_prediction_missing_shadow_count", 0)), str(net_stats.get("ball_prediction_reset_cancel_count", 0)), str(net_stats.get("ball_reset_trace", [])),
|
||||
])
|
||||
print("SMOKE INFO: ball pre-authority motion %s (rtt=%.1fms; asserted only at >=20ms, see proxy_motion_ok)" % [
|
||||
"asserted and met" if rtt_masks_authority else "not asserted on this near-zero-RTT link", rtt_ms,
|
||||
])
|
||||
await get_tree().create_timer(0.3).timeout
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(0 if success else 1)
|
||||
|
||||
|
||||
# Toggles forward thrust every TOGGLE_TICKS physics frames for the requested
|
||||
# duration, then leaves it pressed so the caller's own local_prediction_ok
|
||||
# check still sees a live commanded action. Yaw alternates alongside it purely
|
||||
# to keep the ship from driving straight into a wall and turning a labelling
|
||||
# probe into a contact test.
|
||||
# Samples the server's own score every physics frame for `seconds`, appending
|
||||
# each distinct value. Lets the comparison below check a client's recorded
|
||||
# score against a state the server genuinely passed through, rather than
|
||||
# against whatever it happens to hold seconds later.
|
||||
func _await_recording_score(match_scene, seconds: float, history: Array[String]) -> void:
|
||||
var deadline := Time.get_ticks_msec() + int(seconds * 1000.0)
|
||||
while Time.get_ticks_msec() < deadline:
|
||||
var current := JSON.stringify(match_scene.score)
|
||||
if history[history.size() - 1] != current:
|
||||
history.append(current)
|
||||
await get_tree().physics_frame
|
||||
|
||||
|
||||
func _run_input_transition_trace(duration_seconds: float) -> void:
|
||||
const TOGGLE_TICKS := 6 # ~100ms at 60Hz: several edges per second
|
||||
var frames := int(duration_seconds * 60.0)
|
||||
var pressed := false
|
||||
var yaw_left := false
|
||||
for frame in frames:
|
||||
if frame % TOGGLE_TICKS == 0:
|
||||
pressed = not pressed
|
||||
if pressed:
|
||||
Input.action_press("move_forward")
|
||||
else:
|
||||
Input.action_release("move_forward")
|
||||
if frame % (TOGGLE_TICKS * 4) == 0:
|
||||
yaw_left = not yaw_left
|
||||
Input.action_release("turn_right" if yaw_left else "turn_left")
|
||||
Input.action_press("turn_left" if yaw_left else "turn_right")
|
||||
await get_tree().physics_frame
|
||||
Input.action_release("turn_left")
|
||||
Input.action_release("turn_right")
|
||||
Input.action_press("move_forward")
|
||||
await get_tree().physics_frame
|
||||
|
||||
|
||||
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
|
||||
@@ -252,6 +474,25 @@ func run_ci_host_check(run_seconds: float) -> void:
|
||||
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
|
||||
@@ -288,7 +529,7 @@ func run_ci_host_check(run_seconds: float) -> void:
|
||||
# (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 get_tree().create_timer(movement_check_delay).timeout
|
||||
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:
|
||||
@@ -309,12 +550,13 @@ func run_ci_host_check(run_seconds: float) -> void:
|
||||
# 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 get_tree().create_timer(run_seconds + 5.0 - movement_check_delay).timeout
|
||||
print("SMOKE INFO: host final score=%s" % str(match_scene.score))
|
||||
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):
|
||||
@@ -325,10 +567,16 @@ func run_ci_host_check(run_seconds: float) -> void:
|
||||
var client_score := f.get_as_text()
|
||||
f.close()
|
||||
scores_seen += 1
|
||||
var expected := JSON.stringify(match_scene.score)
|
||||
if client_score != expected:
|
||||
print("SMOKE FAIL: peer %d saw score %s, server has %s" % [slot.peer_id, client_score, expected])
|
||||
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)" % [
|
||||
@@ -360,6 +608,11 @@ func run_ci_client_check(run_seconds: float) -> void:
|
||||
# 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
|
||||
@@ -367,11 +620,15 @@ func run_ci_client_check(run_seconds: float) -> void:
|
||||
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" % [
|
||||
snapshot_count[0], min_expected, str(slots_ok), str(match_scene.score),
|
||||
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
|
||||
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"))
|
||||
await get_tree().create_timer(0.3).timeout
|
||||
# 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)
|
||||
|
||||
Reference in New Issue
Block a user