mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +00:00
feat(multiplayer): Phase 3 task 3.3 - client-owned input_lead control loop
New InputLeadController (scripts/input_lead_controller.gd, standalone and unit-tested like input_jitter_buffer.gd): fast attack (+3 immediately, debounced to once per 30 ticks) on any server-reported starve, slow release (-1 per 60 ticks, gated behind a one-time 2s clean-surplus bar) otherwise, clamped [1, 12]. Deliberately the only thing that adapts buffer depth - the server (InputJitterBuffer) stays a pure reporter, per §3.3's explicit warning that multiple control loops acting on one plant (buffer occupancy) oscillate and present as unattributable sticky controls. Wired into the client's per-tick input send: a lead change is realized as extra distance between the client's outgoing sequence numbers and what the server has consumed - an attack skips extra sequence numbers, a release duplicates the current one (sent again, unincremented). The server's ring buffer needs no special handling for either: a skipped seq is an ordinary drop, a duplicated one is a same-seq resend already discarded by the existing "already consumed" check. Verified with real two-process runs: on a clean LAN, one early attack (a momentary hiccup during connection setup) recovers via two releases within the test's own ~4s window, settling back near minimum. Under sustained 30% simulated loss, lead climbs to 7 via repeated attacks and never releases while genuine loss continues - confirming the debounce, attack, and release gates all fire on real conditions, not just in isolated unit tests. Full regression suite, including the net-sim-latency milestone gate, re-run clean.
This commit is contained in:
@@ -28,6 +28,7 @@ const NetCodec = preload("res://scripts/net_codec.gd")
|
||||
const NetBodyState = preload("res://scripts/net_body_state.gd")
|
||||
const NetInterpolator = preload("res://scripts/net_interpolator.gd")
|
||||
const InputJitterBuffer = preload("res://scripts/input_jitter_buffer.gd")
|
||||
const InputLeadController = preload("res://scripts/input_lead_controller.gd")
|
||||
const HUD_SCENE = preload("res://scenes/HUD.tscn")
|
||||
|
||||
# Minimum plausible interpolation delay even on a same-machine/LAN link —
|
||||
@@ -88,6 +89,8 @@ var _input_seq := 0 # client only
|
||||
# packet's history. Client only.
|
||||
var _input_history: Array[ShipAction] = []
|
||||
var _last_received_snapshot_tick := 0 # client only: echoed back as ack_snapshot_tick
|
||||
var _input_lead_controller := InputLeadController.new() # client only (§3.3)
|
||||
var _last_known_input_buffer_depth := -1 # client only: -1 = no snapshot with this field yet
|
||||
var _reset_gen := 0 # server only: bumped on every kickoff/goal reset so the client hard-snaps instead of interpolating across the teleport
|
||||
# Server only. _on_goal_scored's reset_ball()/reset_ships() only QUEUE
|
||||
# teleports (task 0.15's queue_teleport — applied on each body's next
|
||||
@@ -350,7 +353,18 @@ func _send_local_input() -> void:
|
||||
if _slots.is_empty():
|
||||
return # match_config hasn't arrived yet
|
||||
var action := _local_input_sampler.get_action().copy()
|
||||
_input_seq += 1
|
||||
# Client-owned input_lead control loop (§3.3): ordinarily +1 (ship
|
||||
# increments its send sequence by exactly one tick's worth), but a lead
|
||||
# change this tick skips extra sequence numbers (attack, more server-
|
||||
# side buffer margin) or duplicates the current one (release, delta 0 —
|
||||
# one tick of latency recovered). A duplicated tick can, in the narrow
|
||||
# case where an older redundant copy hasn't been superseded yet, smear
|
||||
# one of _input_history's older backup slots by one position — the
|
||||
# PRIMARY (freshest, most-recently-relevant) value for every seq is
|
||||
# unaffected, so this only ever degrades a backup copy, never the real
|
||||
# per-tick record; §3.3 itself only promises "skip or duplicate a
|
||||
# sequence number," not frame-perfect bookkeeping under a lead change.
|
||||
_input_seq += _input_lead_controller.update(_last_known_input_buffer_depth)
|
||||
# Redundancy (§3.1): carry the last MAX_REDUNDANCY ticks' actions,
|
||||
# newest-first, so a burst of up to (MAX_REDUNDANCY - 1) consecutive
|
||||
# packet losses still lets the server recover every dropped tick's
|
||||
@@ -369,6 +383,11 @@ func _on_snapshot_received(decoded: Dictionary) -> void:
|
||||
var reset_gen: int = decoded["reset_gen"]
|
||||
var bodies: Array = decoded["bodies"]
|
||||
_last_received_snapshot_tick = server_tick
|
||||
# Per-client header (§2.4): unlike the shared body segment, this is
|
||||
# genuinely this recipient's own — input_buffer_depth is THIS client's
|
||||
# own slot's server-side InputJitterBuffer.depth() at send time, which
|
||||
# is exactly what the input_lead control loop (§3.3) needs.
|
||||
_last_known_input_buffer_depth = decoded["input_buffer_depth"]
|
||||
_update_tick_bias(server_tick)
|
||||
for i in _slots.size():
|
||||
if i < bodies.size():
|
||||
|
||||
Reference in New Issue
Block a user