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