Files
CosmicClash/Game/tests/cases/test_input_jitter_buffer.gd
T
Josh Creek 75f485667b 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.
2026-08-21 09:17:19 +01:00

235 lines
11 KiB
GDScript

extends "res://tests/test_case.gd"
const InputJitterBuffer = preload("res://scripts/input_jitter_buffer.gd")
const ShipAction = preload("res://scripts/ship_action.gd")
func _action(thrust_z: float) -> ShipAction:
var a := ShipAction.new()
a.thrust = Vector3(0.0, 0.0, thrust_z)
return a
func test_sequential_ingest_and_consume() -> void:
var buf := InputJitterBuffer.new()
buf.ingest(0, [_action(0.1)])
assert_almost_eq(buf.consume().thrust.z, 0.1, 0.0001, "tick 0")
buf.ingest(1, [_action(0.2)])
assert_almost_eq(buf.consume().thrust.z, 0.2, 0.0001, "tick 1")
assert_eq(buf.last_applied_seq, 1, "last_applied_seq after 2 ticks")
assert_eq(buf.starved_ticks, 0, "no starvation on a clean sequential stream")
# §3.1's own acceptance criterion: "a 3-packet burst loss produces no
# starvation." Redundancy-4 means a single surviving packet after 3 losses
# still carries all 4 of the most recent ticks' actions.
func test_redundancy_survives_3_packet_burst_loss() -> void:
var buf := InputJitterBuffer.new()
buf.ingest(0, [_action(0.0)])
assert_almost_eq(buf.consume().thrust.z, 0.0, 0.0001, "seq 0")
# Packets for seq 1, 2, 3 are "lost" (never ingested individually) — only
# the seq=4 packet, carrying seq 4,3,2,1 (newest-first, redundancy 4),
# actually arrives.
buf.ingest(4, [_action(0.4), _action(0.3), _action(0.2), _action(0.1)])
assert_almost_eq(buf.consume().thrust.z, 0.1, 0.0001, "seq 1 recovered from redundancy")
assert_eq(buf.starved_ticks, 0, "seq 1 was not a starve")
assert_almost_eq(buf.consume().thrust.z, 0.2, 0.0001, "seq 2 recovered from redundancy")
assert_almost_eq(buf.consume().thrust.z, 0.3, 0.0001, "seq 3 recovered from redundancy")
assert_almost_eq(buf.consume().thrust.z, 0.4, 0.0001, "seq 4 recovered from redundancy")
assert_eq(buf.starved_ticks, 0, "no starvation anywhere across the whole burst-loss window")
func test_starvation_repeats_last_action_then_zeroes_after_500ms() -> void:
var buf := InputJitterBuffer.new()
buf.ingest(0, [_action(0.7)])
buf.consume()
# Nothing else ever arrives — every consume() from here on starves.
for i in InputJitterBuffer.STARVE_ZERO_TICKS:
var a := buf.consume()
assert_almost_eq(a.thrust.z, 0.7, 0.0001, "repeat-last during starve, tick %d" % i)
assert_true(not buf.stalled, "not yet stalled at tick %d" % i)
# One more tick past STARVE_ZERO_TICKS (30 = 500ms at 60Hz) crosses the
# "> 30" threshold and zeroes rather than keeps repeating forever.
var stalled_action := buf.consume()
assert_almost_eq(stalled_action.thrust.z, 0.0, 0.0001, "zeroed after sustained stall")
assert_true(buf.stalled, "stalled flag set after 500ms of starvation")
func test_late_stale_packet_is_discarded_harmlessly() -> void:
var buf := InputJitterBuffer.new()
buf.ingest(5, [_action(0.5)])
buf.consume() # seeded to 4 by ingest() (newest_seq - 1 action), one consume reaches 5
assert_eq(buf.last_applied_seq, 5, "consumed up through seq 5")
# A reordered/duplicated packet for an already-consumed seq arrives late.
buf.ingest(3, [_action(0.3)])
assert_eq(buf.depth(), 0, "a stale packet below last_applied_seq must not appear as buffered depth")
buf.ingest(6, [_action(0.6)])
assert_almost_eq(buf.consume().thrust.z, 0.6, 0.0001, "the genuinely-next seq still consumes correctly")
func test_depth_reports_contiguous_buffered_run() -> void:
var buf := InputJitterBuffer.new()
buf.ingest(0, [_action(0.0)])
buf.consume() # last_applied_seq = 0
assert_eq(buf.depth(), 0, "nothing buffered ahead yet")
buf.ingest(3, [_action(0.3), _action(0.2), _action(0.1)])
assert_eq(buf.depth(), 3, "seq 1,2,3 all buffered and contiguous with last_applied_seq")
# A gap (seq 5 arrives but seq 4 never does) caps depth at the gap, not
# the highest seq seen.
buf.ingest(5, [_action(0.5)])
assert_eq(buf.depth(), 3, "seq 5 sits past a gap at seq 4, so it doesn't extend the contiguous run")
func test_ring_wraparound_does_not_confuse_a_stale_slot_with_a_fresh_one() -> void:
var buf := InputJitterBuffer.new()
buf.ingest(0, [_action(0.0)])
buf.consume()
# Advance last_applied_seq well past one full lap of the ring (32 entries)
# so every stored slot tag is far behind "expected" and none may be
# misread as valid.
#
# This used to drive that purely by starvation with nothing re-ingested.
# It can't any more, and shouldn't: starvation only gives up on a sequence
# once strictly newer data proves it lost, because advancing past a
# sequence the client has not sent yet permanently strands the stream (see
# test_starving_ahead_of_the_client_does_not_permanently_discard_its_input).
# Drive it the way the real failure does instead — the client's epoch runs
# ahead while the intervening packets are lost.
var far := InputJitterBuffer.RING_SIZE * 3
buf.ingest(far, [_action(0.1)])
for i in InputJitterBuffer.RING_SIZE * 2:
buf.consume()
assert_true(buf.last_applied_seq > InputJitterBuffer.RING_SIZE, "advanced past a full lap of the ring")
# Now a fresh packet lands at the seq the ring slot for "expected" was
# LAST used for, one full lap ago — if slot-tagging didn't work, this
# would be misread as already-fresh data from the stale write.
var expected := buf.last_applied_seq + 1
buf.ingest(expected, [_action(0.9)])
var a := buf.consume()
assert_almost_eq(a.thrust.z, 0.9, 0.0001, "correctly reads the fresh same-slot-index seq, not a stale wraparound ghost")
assert_eq(buf.starved_ticks, 0, "starvation clears once fresh data resumes")
# The under-full direction (above) was covered before an adversarial review
# found the OVER-full direction was not: a backlog bigger than RING_SIZE
# (a host stall, or persistent client/server clock drift) made consume()
# starve — and, past STARVE_ZERO_TICKS, zero the player's ship — forever,
# because both last_applied_seq and the client's own seq only ever advance
# with no resync, so the gap never closed even though fresh, real input
# kept arriving the whole time.
func test_ring_overflow_resyncs_to_fresh_data_instead_of_starving_forever() -> void:
var buf := InputJitterBuffer.new()
buf.ingest(0, [_action(0.0)])
buf.consume() # last_applied_seq = 0
# A burst of packets arriving all at once, exactly what poll() delivers
# in one batch once a stalled server resumes — the client kept sending
# normally the whole time (a real packet every tick, last-4 redundancy,
# newest-first), nothing consumed in between. 50 ticks' worth, well
# past one full lap of the 32-entry ring.
for seq in range(1, 51):
var window: Array = []
for k in 4:
window.append(_action(float(seq - k) * 0.01))
buf.ingest(seq, window)
assert_eq(buf.last_applied_seq, 0, "nothing consumed yet, only ingested")
# The gap (50 - 1 = 49) exceeds RING_SIZE (32): everything older than
# "50 - RING_SIZE" has already been irrecoverably overwritten by more
# recent arrivals landing on the same ring slots. A single consume()
# must resync directly to the oldest data the ring can still actually
# provide, not starve through the entire abandoned span.
var a := buf.consume()
var expected_resync_seq := 50 - InputJitterBuffer.RING_SIZE + 1
assert_eq(buf.last_applied_seq, expected_resync_seq, "resynced to exactly RING_SIZE behind the newest data")
assert_almost_eq(a.thrust.z, float(expected_resync_seq) * 0.01, 0.0001, "recovered the resynced tick's real action from the ring, not a stale ghost or a zeroed one")
assert_eq(buf.starved_ticks, 0, "resyncing to real data is not starvation")
assert_true(not buf.stalled, "a recovered player must not be reported as stalled")
# Normal sequential consumption resumes correctly from the resync point.
var next := buf.consume()
assert_almost_eq(next.thrust.z, float(expected_resync_seq + 1) * 0.01, 0.0001, "next tick continues in order from the resync point")
# --- Starvation must not strand the stream (adversarial review, B2) ---------
# consume() used to advance last_applied_seq on EVERY tick including a starve.
# Because ingest() discards anything `seq <= last_applied_seq`, one starve on a
# sequence the client had not sent yet left the server permanently one ahead of
# arrivals: both sides then advance one per tick, the gap never closes, and
# every honest packet is discarded on arrival. Reproduced on a clean LAN — the
# client's own input_lead release (delta == 0, which issues no new sequence for
# one tick) was enough to trigger it, roughly every 6.5s of ordinary play.
func _thrust(value: float) -> ShipAction:
var a := ShipAction.new()
a.thrust = Vector3(0.0, 0.0, value)
return a
func test_starving_ahead_of_the_client_does_not_permanently_discard_its_input() -> void:
var buffer := InputJitterBuffer.new()
buffer.ingest(1, [_thrust(1.0)])
assert_almost_eq(buffer.consume().thrust.z, 1.0, 0.001, "seq 1 applies normally")
# The client issues NO new sequence this tick (an input_lead release), so
# nothing newer than seq 1 exists. The server must keep expecting seq 2
# rather than consuming — and discarding — it.
assert_almost_eq(buffer.consume().thrust.z, 1.0, 0.001, "a starve repeats the last action")
assert_eq(buffer.last_applied_seq, 1, "and does NOT advance past a sequence the client has not sent")
# The client's next real packet must still be accepted and applied.
buffer.ingest(2, [_thrust(-1.0)])
assert_almost_eq(buffer.consume().thrust.z, -1.0, 0.001, "the next honest input is still applied, not discarded")
func test_sustained_release_pattern_does_not_black_out_input() -> void:
# The full B2 shape: client and server both advance one per tick, but the
# client duplicates one sequence (a release). Pre-fix, every packet from
# this point on was discarded and the ship froze for 30 ticks.
var buffer := InputJitterBuffer.new()
buffer.ingest(1, [_thrust(1.0)])
buffer.consume()
buffer.consume() # release tick: server starves
var applied_real_input := 0
for seq in range(2, 40):
buffer.ingest(seq, [_thrust(1.0)])
if absf(buffer.consume().thrust.z - 1.0) < 0.001:
applied_real_input += 1
assert_true(applied_real_input >= 35, "input keeps flowing after a release (applied %d/38)" % applied_real_input)
assert_true(not buffer.stalled, "and the buffer never reports a stall")
func test_a_genuinely_lost_packet_is_still_skipped_rather_than_waited_on() -> void:
# The control for the two tests above: holding must not become "wait
# forever". When strictly newer data has arrived, the missing sequence is
# provably lost or reordered and must be given up on immediately.
var buffer := InputJitterBuffer.new()
buffer.ingest(1, [_thrust(1.0)])
buffer.consume()
buffer.ingest(3, [_thrust(-1.0)]) # seq 2 never arrives; 3 does
buffer.consume() # starves on 2, but 3 is newer -> skip it
assert_eq(buffer.last_applied_seq, 2, "a lost sequence is skipped once newer data exists")
assert_almost_eq(buffer.consume().thrust.z, -1.0, 0.001, "and the newer sequence applies on the next tick")
func test_a_silent_client_still_zeroes_and_stalls_on_schedule() -> void:
# The other control: holding must not defeat the disconnect behaviour.
var buffer := InputJitterBuffer.new()
buffer.ingest(1, [_thrust(1.0)])
buffer.consume()
for i in InputJitterBuffer.STARVE_ZERO_TICKS + 2:
buffer.consume()
assert_true(buffer.stalled, "a silent client still stalls")
assert_almost_eq(buffer.last_action.thrust.z, 0.0, 0.001, "and its ship still stops")