Files
CosmicClash/Game/tests/cases/test_input_lead_controller.gd
T
Josh Creek 5bbb319161 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.
2026-08-20 13:14:40 +01:00

99 lines
4.5 KiB
GDScript

extends "res://tests/test_case.gd"
const InputLeadController = preload("res://scripts/input_lead_controller.gd")
func test_starts_at_minimum() -> void:
var c := InputLeadController.new()
assert_eq(c.lead, InputLeadController.LEAD_MIN, "initial lead")
func test_unknown_depth_is_a_normal_tick() -> void:
var c := InputLeadController.new()
assert_eq(c.update(-1), 1, "no snapshot info yet -> ordinary +1 seq increment")
assert_eq(c.lead, InputLeadController.LEAD_MIN, "lead unchanged with no info")
func test_healthy_depth_is_a_normal_tick_and_no_immediate_release() -> void:
var c := InputLeadController.new()
for i in 10:
assert_eq(c.update(1), 1, "healthy depth -> ordinary +1 tick %d" % i)
assert_eq(c.lead, InputLeadController.LEAD_MIN, "release needs 2s clean, not 10 ticks")
# §3.3: "on any starve, increase by up to 3 immediately" — but debounced by
# MIN_CHANGE_INTERVAL_TICKS so it isn't literally same-tick.
func test_starve_triggers_fast_attack_after_debounce_floor() -> void:
var c := InputLeadController.new()
var deltas: Array[int] = []
for i in InputLeadController.MIN_CHANGE_INTERVAL_TICKS:
deltas.append(c.update(0))
# Every tick before the debounce floor is an ordinary +1 (no jump yet).
for i in InputLeadController.MIN_CHANGE_INTERVAL_TICKS - 1:
assert_eq(deltas[i], 1, "no lead change before the debounce floor, tick %d" % i)
assert_eq(deltas[InputLeadController.MIN_CHANGE_INTERVAL_TICKS - 1], 4, "attack fires on the debounce-floor tick: +1 ordinary + 3 skip")
assert_eq(c.lead, InputLeadController.LEAD_MIN + 3, "lead jumped by 3")
func test_repeated_starvation_climbs_toward_max_and_clamps() -> void:
var c := InputLeadController.new()
# Enough sustained starvation to trigger several attack steps.
for i in InputLeadController.MIN_CHANGE_INTERVAL_TICKS * 6:
c.update(0)
assert_eq(c.lead, InputLeadController.LEAD_MAX, "clamps at LEAD_MAX under sustained starvation, never exceeds it")
func test_release_requires_both_clean_surplus_and_its_own_interval() -> void:
var c := InputLeadController.new()
# Force lead above minimum first via one attack step.
for i in InputLeadController.MIN_CHANGE_INTERVAL_TICKS:
c.update(0)
var lead_after_attack := c.lead
assert_true(lead_after_attack > InputLeadController.LEAD_MIN, "lead raised above minimum before testing release")
# Fewer than CLEAN_SURPLUS_TICKS of healthy depth: must not release yet.
for i in InputLeadController.CLEAN_SURPLUS_TICKS - 1:
c.update(1)
assert_eq(c.lead, lead_after_attack, "no release before 2s of clean surplus has elapsed")
# One more healthy tick crosses the clean-surplus threshold AND the
# release interval (both are already satisfied by now since the
# debounce timer has been running the whole time) -> releases by 1.
var delta := c.update(1)
assert_eq(delta, 0, "release tick duplicates rather than incrementing seq")
assert_eq(c.lead, lead_after_attack - 1, "lead released by exactly 1")
func test_release_stops_at_minimum() -> void:
var c := InputLeadController.new()
# Never starve — with lead already at LEAD_MIN, sustained health must
# never push it below the floor.
for i in InputLeadController.CLEAN_SURPLUS_TICKS * 3:
var delta := c.update(1)
assert_true(delta == 1, "lead already at minimum, never duplicates a seq trying to release further, tick %d" % i)
assert_eq(c.lead, InputLeadController.LEAD_MIN, "stays at minimum")
func test_starve_resets_clean_surplus_counter() -> void:
var c := InputLeadController.new()
for i in InputLeadController.MIN_CHANGE_INTERVAL_TICKS:
c.update(0) # raise lead above minimum via one attack step
var lead_after_attack := c.lead
# Some, but not all, of a clean surplus window — and well under the
# 30-tick attack debounce floor too, so the interrupting starve below
# can't accidentally retrigger a second attack step of its own.
var partial_clean_ticks := 10
for i in partial_clean_ticks:
c.update(1)
c.update(0) # a lone starve tick, resetting _clean_surplus_ticks
assert_eq(c.lead, lead_after_attack, "the lone starve tick was too soon after the last change to trigger another attack")
# A full clean window from this fresh starting point is required before
# release fires — one tick short must not be enough.
for i in InputLeadController.CLEAN_SURPLUS_TICKS - 1:
c.update(1)
assert_eq(c.lead, lead_after_attack, "the starve interruption forced a fresh 2s clean window, so no release yet")
c.update(1)
assert_eq(c.lead, lead_after_attack - 1, "release finally fires once a full fresh clean window has elapsed since the interruption")