mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 17:03:43 +00:00
feat(multiplayer): Phase 3 tasks 3.1/3.2/3.5 - input redundancy + server jitter buffer
Client now sends the last 4 ticks' actions per packet (newest-first, already-supported by net_codec's wire format from Phase 1) instead of a single action with no redundancy. Server gains a real per-slot ring buffer (new InputJitterBuffer class, scripts/input_jitter_buffer.gd) that consumes exactly one sequence number per physics tick: repeats the last action on a starve, zeroes only after a sustained 500ms stall, and reports real input_buffer_depth/last_input_seq/echo_client_send_ms in every snapshot instead of the hardcoded zeros Phase 2 shipped with. InputJitterBuffer is a standalone, scene-free RefCounted (same pattern as net_codec.gd/net_interpolator.gd) specifically so it's unit-testable against scripted arrival traces (tests/cases/test_input_jitter_buffer.gd): sequential consumption, redundancy surviving a 3-packet burst loss (3.1's own acceptance criterion), starvation repeat-then-zero timing, stale/ reordered packet handling, buffered-depth reporting, and ring-wraparound slot-tagging safety. One real bug found wiring this into a live match: the server's ring buffer started counting its own "expected sequence" from 0 the instant a player's slot was created - well before that player's first real packet could possibly have arrived (connection handshake, arena/ship spawn all take real time first). Since both sides only ever advance monotonically with no resync mechanism, that gap between the server's arbitrary local counter and the client's actual from-1 sequence numbers never closed, so the ship simply never received the client's input (0m movement in a two-process test). Fixed by seeding the buffer's expected-sequence counter from the client's own numbering on first real ingest, rather than assuming a shared from-zero baseline. Verified with real two-process runs: clean baseline movement restored, zero starvation observed under 25% random simulated input loss (well above what redundancy-4 needs to fully absorb), and correct starve-then- stall behaviour confirmed under 100% loss as a sanity check that the mechanism isn't a silent no-op. Full regression suite, including the net-sim-latency milestone gate, re-run clean.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
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) purely via starvation, with nothing re-ingested — every
|
||||
# ring slot's stored seq is now far behind "expected" at each step, so
|
||||
# none of them should ever be misread as valid.
|
||||
for i in InputJitterBuffer.RING_SIZE * 2:
|
||||
buf.consume()
|
||||
assert_eq(buf.last_applied_seq, InputJitterBuffer.RING_SIZE * 2, "advanced purely by starvation")
|
||||
assert_true(buf.stalled, "long starvation run ends stalled")
|
||||
|
||||
# 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")
|
||||
Reference in New Issue
Block a user