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")