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.
121 lines
5.7 KiB
GDScript
121 lines
5.7 KiB
GDScript
class_name InputLeadController
|
|
extends RefCounted
|
|
|
|
# Client-owned input_lead control loop (MULTIPLAYER_SPEC.md §3.3; multiplayer-next.md task 3.3).
|
|
# Standalone RefCounted, same reason as input_jitter_buffer.gd — scene-free
|
|
# so it's directly unit-testable against scripted depth traces.
|
|
#
|
|
# §3.3's own rationale for why this is the CLIENT's job alone, not shared
|
|
# with any server-side adaptation: three control loops acting on one plant
|
|
# (buffer occupancy) with different time constants is a textbook
|
|
# oscillation, and on a jittery link it presents to the player as
|
|
# intermittent sticky controls that are nearly impossible to attribute.
|
|
# The server (InputJitterBuffer, §3.2) only ever reports input_buffer_depth
|
|
# — it does nothing adaptive with it.
|
|
#
|
|
# "Lead" is realized concretely as extra distance between this client's own
|
|
# outgoing sequence numbers and what the server has actually consumed:
|
|
# skipping a sequence number (jumping the client's own seq counter by more
|
|
# than 1 for one tick) buys the server one more tick of buffered depth
|
|
# before it would starve; duplicating one (not incrementing the seq counter
|
|
# for one tick — the same seq gets sent again) narrows that margin by one
|
|
# tick of latency. The server's own ring buffer doesn't need to know this
|
|
# happened: a skipped seq just means "the redundant copies of it never
|
|
# existed, it's an ordinary drop" (already handled), and a duplicated seq
|
|
# is a same-seq resend, already discarded harmlessly once consumed
|
|
# (InputJitterBuffer.ingest()'s "seq <= last_applied_seq" check).
|
|
#
|
|
# Fast attack, slow release — a symmetric ±1-per-N-ticks slew would take
|
|
# two full seconds to absorb a single wifi spike, during which the player
|
|
# steers and the ship does not turn, "the most rage-inducing failure mode
|
|
# in any netcode" per §3.3's own words.
|
|
|
|
const LEAD_MIN := 1
|
|
const LEAD_MAX := 12
|
|
# "Never change it more than once per 30 ticks" (§3.3) — the floor that
|
|
# binds the fast-attack side; slow-release's own 60-tick cadence already
|
|
# exceeds it, so this one constant covers both.
|
|
const MIN_CHANGE_INTERVAL_TICKS := 30
|
|
const RELEASE_INTERVAL_TICKS := 60
|
|
const CLEAN_SURPLUS_TICKS := 120 # 2s at 60Hz
|
|
# §3.3: "target_depth = 1 (16.7 ms), not 2." Release only fires when the
|
|
# server-reported depth is genuinely ABOVE this — see update()'s own
|
|
# comment for why gating on `lead` alone (an adversarial review's original
|
|
# finding here) was wrong.
|
|
const TARGET_DEPTH := 1
|
|
|
|
var lead := LEAD_MIN
|
|
|
|
var _ticks_since_change := 0
|
|
var _clean_surplus_ticks := 0
|
|
|
|
|
|
# Call once per client physics tick with the most recently known server-
|
|
# reported input_buffer_depth for THIS client's own slot (echoed in every
|
|
# snapshot, §3.2) — or -1 if no snapshot carrying that field has arrived
|
|
# yet. Returns the seq delta the caller should add for this tick's
|
|
# outgoing packet: ordinarily 1 (ship normally increments its send
|
|
# sequence by exactly one tick's worth), or 1+N / 0 on a tick where a lead
|
|
# change actually fires (skip N extra / duplicate the current one).
|
|
func update(input_buffer_depth: int, target_depth: int = TARGET_DEPTH) -> int:
|
|
_ticks_since_change += 1
|
|
if input_buffer_depth == -1:
|
|
return 1 # no server depth has arrived yet
|
|
if input_buffer_depth < -1:
|
|
# -1 is an explicit server starvation sentinel, distinct from a
|
|
# healthy zero-depth buffer on an adaptive clean link.
|
|
_clean_surplus_ticks = 0
|
|
if _ticks_since_change >= MIN_CHANGE_INTERVAL_TICKS and lead < LEAD_MAX:
|
|
var starve_lead := mini(lead + 3, LEAD_MAX)
|
|
var starve_delta := starve_lead - lead
|
|
lead = starve_lead
|
|
_ticks_since_change = 0
|
|
return 1 + starve_delta
|
|
return 1
|
|
target_depth = maxi(0, target_depth)
|
|
|
|
if input_buffer_depth <= target_depth - 1:
|
|
# A starve: the server's ring was empty for this player when it
|
|
# built that snapshot. React immediately, not after 2 seconds of
|
|
# evidence like release requires — but still debounced against
|
|
# MIN_CHANGE_INTERVAL_TICKS so a burst of consecutive starve
|
|
# reports doesn't compound into repeated, overlapping jumps.
|
|
_clean_surplus_ticks = 0
|
|
if _ticks_since_change >= MIN_CHANGE_INTERVAL_TICKS and lead < LEAD_MAX:
|
|
var new_lead := mini(lead + 3, LEAD_MAX)
|
|
var delta := new_lead - lead
|
|
lead = new_lead
|
|
_ticks_since_change = 0
|
|
return 1 + delta
|
|
return 1
|
|
|
|
# Release must react to the ACTUAL server-reported depth, not to this
|
|
# controller's own memory of past attacks. A first pass at this fix
|
|
# added the depth check above but left the OLD gate, `lead > LEAD_MIN`,
|
|
# still ANDed onto the final condition below — so a backlog this
|
|
# controller did NOT itself cause (a server hitch, persistent client/
|
|
# server clock drift, a ring resync) still could never be drained:
|
|
# with lead pinned at its starting floor, that clause always failed
|
|
# even while input_buffer_depth sat well above target. A second
|
|
# adversarial review caught it, confirmed by this file's own
|
|
# test_release_drains_a_backlog_it_never_caused_itself, whose original
|
|
# assertion text literally said "lead cannot release below its own
|
|
# floor even under large surplus" as if that were correct.
|
|
#
|
|
# The fix splits the one gate into two separate decisions: whether to
|
|
# duplicate this tick's seq (the only thing that actually narrows real
|
|
# buffered depth) follows the real signal alone, below; whether to
|
|
# keep decrementing `lead`'s own bookkeeping below its documented
|
|
# floor is a separate, cosmetic-only choice made inside that branch.
|
|
if input_buffer_depth > target_depth:
|
|
_clean_surplus_ticks += 1
|
|
else:
|
|
_clean_surplus_ticks = 0
|
|
|
|
if _clean_surplus_ticks >= CLEAN_SURPLUS_TICKS and _ticks_since_change >= RELEASE_INTERVAL_TICKS:
|
|
if lead > LEAD_MIN:
|
|
lead -= 1
|
|
_ticks_since_change = 0
|
|
return 0 # duplicate this tick's seq — one tick of latency recovered
|
|
return 1
|