mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 18:23:44 +00:00
5bbb319161
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.
99 lines
4.5 KiB
GDScript
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")
|