mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
86a597f0f5
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.
114 lines
4.6 KiB
GDScript
114 lines
4.6 KiB
GDScript
class_name InputJitterBuffer
|
|
extends RefCounted
|
|
|
|
# Per-player server-side input state (multiplayer-todo.md §3, task 3.2).
|
|
# Deliberately a standalone RefCounted with no scene/RPC dependency — same
|
|
# reason net_codec.gd and net_interpolator.gd are pure classes — so task
|
|
# 3.5's unit tests can drive it with scripted arrival traces with no live
|
|
# match. NetworkedMatch owns one instance per connected slot and is the only
|
|
# thing that talks to the network layer; this class only knows about
|
|
# sequence numbers and ShipActions.
|
|
#
|
|
# Ring is fixed-size and slot-tagged (§3.1 step 5's "a client can never make
|
|
# the server allocate"): ingest() writes seq % RING_SIZE regardless of how
|
|
# large or malicious seq is, and consume() only ever trusts a slot whose
|
|
# stored seq exactly matches the one it expects — a stale or wrapped-around
|
|
# entry is indistinguishable from an empty one. Range/rate validation of seq
|
|
# against the current server tick is the CALLER's job (task 3.4), not this
|
|
# class's, since only the caller knows the current server tick.
|
|
|
|
const RING_SIZE := 32
|
|
# 500ms at 60Hz (multiplayer-todo.md §3.2's own numbers) — a duration, not a
|
|
# tick-rate-derived constant, so left as a literal rather than pulling in
|
|
# SimConstants for one number.
|
|
const STARVE_ZERO_TICKS := 30
|
|
|
|
var last_applied_seq := -1 # -1: consume() has never been called yet
|
|
var last_action := ShipAction.new()
|
|
var starved_ticks := 0
|
|
var stalled := false
|
|
|
|
var _ring_action: Array = []
|
|
var _ring_seq: PackedInt32Array = PackedInt32Array()
|
|
# True once ingest() has ever been called for real. Consumption is a no-op
|
|
# (no starvation counted, no advancement) until then — see ingest()'s own
|
|
# comment for why an un-seeded buffer would otherwise never converge with
|
|
# what the client is actually sending.
|
|
var _seeded := false
|
|
|
|
|
|
func _init() -> void:
|
|
_ring_action.resize(RING_SIZE)
|
|
_ring_seq.resize(RING_SIZE)
|
|
for i in RING_SIZE:
|
|
_ring_seq[i] = -1
|
|
|
|
|
|
# newest_seq/actions match NetCodec.unpack_input's own "seq"/"actions"
|
|
# fields directly: actions[i] is the action for sequence (newest_seq - i),
|
|
# newest-first. Already-consumed or stale entries are silently discarded
|
|
# (§3.1 step 5) — this is what makes redundant re-delivery of an already-
|
|
# applied tick harmless.
|
|
func ingest(newest_seq: int, actions: Array) -> void:
|
|
if not _seeded:
|
|
# The server starts calling consume() every tick the instant this
|
|
# slot exists — well before this player's first packet has had time
|
|
# to arrive (connection handshake, arena/ship spawn, first
|
|
# _physics_process tick on the client all take real time first). An
|
|
# un-seeded last_applied_seq of -1 would have consume() "expecting"
|
|
# sequence 0, 1, 2, ... via pure starvation the whole time, racing
|
|
# arbitrarily far ahead of whatever the client's own from-1
|
|
# numbering has actually reached by the time real packets show up —
|
|
# and since both sides only ever advance monotonically with no
|
|
# resync mechanism, that gap would never close. Seed to align
|
|
# "expected" with reality the moment real data first exists.
|
|
last_applied_seq = newest_seq - actions.size()
|
|
_seeded = true
|
|
for i in actions.size():
|
|
var seq: int = newest_seq - i
|
|
if seq <= last_applied_seq:
|
|
continue
|
|
var idx := seq % RING_SIZE
|
|
_ring_seq[idx] = seq
|
|
_ring_action[idx] = actions[i]
|
|
|
|
|
|
# Contiguous run of not-yet-applied entries starting right after
|
|
# last_applied_seq — reported as input_buffer_depth in every snapshot
|
|
# (§3.3) and consumed client-side by the input_lead control loop (task 3.3).
|
|
func depth() -> int:
|
|
if not _seeded or last_applied_seq < 0:
|
|
return 0
|
|
var d := 0
|
|
var seq := last_applied_seq + 1
|
|
while d < RING_SIZE and _ring_seq[seq % RING_SIZE] == seq:
|
|
d += 1
|
|
seq += 1
|
|
return d
|
|
|
|
|
|
# Called once per server physics tick, before the step (§3.2). A no-op
|
|
# (returns the zero-initialized last_action, no starvation counted) until
|
|
# this player's first real packet has ever arrived — see ingest()'s comment.
|
|
func consume() -> ShipAction:
|
|
if not _seeded:
|
|
return last_action
|
|
var expected := last_applied_seq + 1
|
|
var idx := expected % RING_SIZE
|
|
if _ring_seq[idx] == expected:
|
|
last_action = _ring_action[idx]
|
|
starved_ticks = 0
|
|
stalled = false
|
|
else:
|
|
# Repeat-last, not zero: inputs are heavily autocorrelated at 60Hz,
|
|
# and the client already predicted with the real input either way,
|
|
# so repeating minimises expected divergence (§3.2). Only zero after
|
|
# a sustained stall, so a disconnecting player's ship doesn't fly
|
|
# into a wall at full throttle forever.
|
|
starved_ticks += 1
|
|
if starved_ticks > STARVE_ZERO_TICKS:
|
|
last_action = ShipAction.new()
|
|
stalled = true
|
|
last_applied_seq = expected
|
|
return last_action
|