mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
b43ad207c1
multiplayer-next.md was a 1662-line mix of standing architecture spec and task-completion tracking, most of which was dense per-task DONE evidence for finished Phases 0-6. Split it: - MULTIPLAYER_SPEC.md (new): the locked architecture decisions, wire format, server-side input handling, prediction/reconciliation, latency/frame-rate budget, and match lifecycle state machine - standing design reference, not task-tracked. - multiplayer-next.md (trimmed 1662 -> ~370 lines): only outstanding work remains - §0 status, §7 Phase 7/8 task tables condensed to "what's left" per task, §8-11 reference material (refactoring notes, gotchas, testing, flagged items). Phases 0-6 collapsed to a pointer at git history instead of ~500 lines of DONE evidence. Also: - Repointed every `multiplayer-next.md §N` code comment (N 1-6) across Game/scripts, Game/tools and Game/tests to MULTIPLAYER_SPEC.md, since those sections moved. Task-number references (`task N.N`, §7-11) correctly still point at multiplayer-next.md. - Updated CLAUDE.md's doc index and docs/TECH_STACK.md's spec-section citations to match. - TODO.md: added a "what's left to actually finish multiplayer (human-actionable)" checklist pulled from multiplayer-next.md §0 and docs/MATCHMAKING.md - things that need a person (hardware, a design decision, a Steam App ID, hands on a controller), not more agent code.
179 lines
8.3 KiB
GDScript
179 lines
8.3 KiB
GDScript
class_name InputJitterBuffer
|
|
extends RefCounted
|
|
|
|
# Per-player server-side input state (MULTIPLAYER_SPEC.md §3; multiplayer-next.md 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_SPEC.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
|
|
# Highest seq ever seen by ingest(), regardless of whether it's still in the
|
|
# ring — consume()'s only way to tell "the data is gone because the ring
|
|
# overflowed" apart from "the data just hasn't arrived yet". See consume()'s
|
|
# own comment for why this exists: an adversarial review found that without
|
|
# it, a backlog bigger than RING_SIZE (a host stall, or persistent client/
|
|
# server clock drift) permanently zeroed a connected player's input for the
|
|
# rest of the match.
|
|
#
|
|
# Deliberately public (no underscore), same as last_applied_seq: the
|
|
# networked_match.gd caller's seq-range guard (§3.1 step 4) must bound
|
|
# against THIS, not against last_applied_seq. A second adversarial review
|
|
# found that bounding against last_applied_seq caps every accepted seq at
|
|
# last_applied_seq + RING_SIZE, which in turn caps this field at the same
|
|
# ceiling — making the resync condition below (which needs this field to
|
|
# reach expected + RING_SIZE) arithmetically unreachable on the only call
|
|
# path that exists in production. The two fixes looked independent but
|
|
# shared a variable and silently cancelled each other out. highest_ingested
|
|
# tracks the client's own send epoch instead, which the guard can safely
|
|
# let run ahead of a lagging consumer.
|
|
var highest_ingested_seq := -1
|
|
|
|
|
|
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
|
|
if newest_seq > highest_ingested_seq:
|
|
highest_ingested_seq = newest_seq
|
|
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
|
|
|
|
# Ring-overflow resync. A fixed-size ring can only ever hold RING_SIZE
|
|
# ticks of not-yet-consumed data at once — if the caller has fallen
|
|
# further behind the newest data actually arriving than that (a host
|
|
# stall, or persistent client/server clock drift), every tick between
|
|
# "expected" and "highest_ingested_seq - RING_SIZE" has already been
|
|
# irrecoverably overwritten by more recent arrivals landing on the same
|
|
# ring slots. Waiting for it tick-by-tick would starve — and, past
|
|
# STARVE_ZERO_TICKS, zero this player's ship — for the ENTIRE gap even
|
|
# though fresh, real input already exists in the ring right now. An
|
|
# adversarial review found and reproduced this exact failure (a ~0.7s
|
|
# host freeze permanently zeroed a connected player's input for the
|
|
# rest of the match, with no self-recovery). Skip the unrecoverable
|
|
# span and resync directly to what the ring can still actually provide.
|
|
if highest_ingested_seq - expected >= RING_SIZE:
|
|
last_applied_seq = highest_ingested_seq - RING_SIZE
|
|
expected = last_applied_seq + 1
|
|
idx = expected % RING_SIZE
|
|
|
|
if _ring_seq[idx] == expected:
|
|
last_action = _ring_action[idx]
|
|
starved_ticks = 0
|
|
stalled = false
|
|
last_applied_seq = expected
|
|
return last_action
|
|
|
|
# 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
|
|
|
|
# Only GIVE UP on `expected` when strictly newer data has actually
|
|
# arrived, which proves it was lost or reordered rather than merely late.
|
|
#
|
|
# Advancing unconditionally (what this did originally) is catastrophic
|
|
# rather than merely lossy, because ingest() discards anything
|
|
# `seq <= last_applied_seq`. One starve on a sequence the client has not
|
|
# even sent yet leaves 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 for the rest of the match. An
|
|
# adversarial review reproduced exactly that on a clean LAN — the client's
|
|
# own input_lead RELEASE (delta == 0, which deliberately issues no new
|
|
# sequence for one tick) is sufficient to trigger it, so it fired roughly
|
|
# every 6.5s of ordinary play, blacking out input for 30 ticks until the
|
|
# lead controller's debounce allowed a +3 attack to jump the client clear.
|
|
#
|
|
# Holding cannot deadlock: if the client genuinely goes silent,
|
|
# highest_ingested_seq stops moving, starved_ticks still climbs, and the
|
|
# STARVE_ZERO_TICKS zeroing plus `stalled` above still fire on schedule.
|
|
# If it falls far behind instead, the ring-overflow resync above still
|
|
# jumps the cursor forward. Both escape paths are unchanged.
|
|
if highest_ingested_seq > expected:
|
|
last_applied_seq = expected
|
|
return last_action
|