Files
CosmicClash/Game/tests/cases/test_input_lead_controller.gd
T
Josh Creek cf73074e27 fix(multiplayer): resolve composition regression from second adversarial review
A second adversarial review of the previous fix commit found two of its
nine fixes silently defeated each other: the seq-range guard (fix for a
MEDIUM epoch-mismatch finding) capped the exact variable the ring-overflow
resync (fix for the original CRITICAL finding) depends on, making the
resync unreachable in production and recreating permanent input death at
a lower failure threshold, reachable via ordinary server tick loss alone.

- CRITICAL: rebind the seq-range guard to InputJitterBuffer's own
  highest_ingested_seq (now public) instead of the consumer-side
  last_applied_seq, so it tracks the client's send epoch rather than a
  value that can lag arbitrarily far behind during a stall.
- HIGH: InputLeadController's release logic still ANDed the old
  `lead > LEAD_MIN` gate onto the new depth-driven condition, so a
  backlog the controller never caused still couldn't drain. Split into
  two independent decisions: the seq-duplicate action follows real
  depth alone; lead's own bookkeeping separately never drops below its
  floor.
- MEDIUM: widen the CI driver's movement/stalled sampling margin
  (run_seconds - 2.0, was - 0.5) and assert the peer is still in
  multiplayer.get_peers() at sample time, since the old margin let the
  check pass on residual starvation grace after a bot had already
  disconnected.
- LOW: measure horizontal-only displacement in the human smoke test's
  movement check — the old 3D-distance bar was beatable by pure
  gravity settling with fully dead input.
- LOW: fix a real "clean stderr" violation (match_net.gd broadcasting
  a departure notice to a peer whose ENet channels are already torn
  down, including a second peer disconnecting in the same poll batch)
  by deferring the notification to the next idle frame.
- Wire the server's per-slot stalled bit into the client debug overlay
  for real — a prior commit message claimed this already reached the
  overlay when only the CI gate actually read it.

Re-verified end-to-end against the real production RPC path (not just
unit tests in isolation, which is how the composition bug got past the
first round): a 2-bot CI match with a 1.5s host SIGSTOP freeze injected
mid-run, well past the 0.6s threshold the review reproduced the bug at,
now recovers cleanly on repeated runs with zero stderr noise.
2026-08-20 18:26:12 +01:00

148 lines
7.4 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 surplus depth (above TARGET_DEPTH):
# must not release yet.
for i in InputLeadController.CLEAN_SURPLUS_TICKS - 1:
c.update(InputLeadController.TARGET_DEPTH + 1)
assert_eq(c.lead, lead_after_attack, "no release before 2s of clean surplus has elapsed")
# One more surplus 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(InputLeadController.TARGET_DEPTH + 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()
# Sustained surplus depth with lead already at LEAD_MIN: `lead` itself
# must never drop below the floor, but release must still fire
# (duplicate a seq) once its own timing conditions are met, since a
# real reported surplus at floor lead is exactly the "backlog this
# controller never caused" case — capping `lead` is cosmetic, it must
# not also block the seq-duplicate action that drains real depth.
var released := false
for i in InputLeadController.CLEAN_SURPLUS_TICKS * 3:
if c.update(InputLeadController.TARGET_DEPTH + 1) == 0:
released = true
assert_eq(c.lead, InputLeadController.LEAD_MIN, "lead never drops below the floor, tick %d" % i)
assert_true(released, "release still fires (duplicates a seq) even though lead itself is pinned 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(InputLeadController.TARGET_DEPTH + 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(InputLeadController.TARGET_DEPTH + 1)
assert_eq(c.lead, lead_after_attack, "the starve interruption forced a fresh 2s clean window, so no release yet")
c.update(InputLeadController.TARGET_DEPTH + 1)
assert_eq(c.lead, lead_after_attack - 1, "release finally fires once a full fresh clean window has elapsed since the interruption")
# A first attempt at fixing this gated the whole release branch on `lead >
# LEAD_MIN` — this controller's own memory of past attacks — so a backlog
# it did NOT itself create (a server hitch, persistent client/server clock
# drift, a burst re-delivery) was never drained: lead stayed at 1 forever
# even while the server kept reporting a deep, real backlog, and — because
# that gate blocked the seq-duplicate action too, not just lead's own
# bookkeeping — the actual buffered depth was never drained either. A
# second adversarial review caught that the depth check added alongside
# it didn't remove the old gate, just sat next to it. This reproduces the
# scenario directly: lead never attacks (depth is never reported as a
# starve, <= 0), yet release must still fire from sustained real surplus
# alone, even while lead itself stays pinned at its floor throughout.
func test_release_drains_a_backlog_it_never_caused_itself() -> void:
var c := InputLeadController.new()
assert_eq(c.lead, InputLeadController.LEAD_MIN, "starts at minimum, never attacked")
# A large, externally-caused surplus (e.g. right after the server's own
# ring-overflow resync) reported for well over 2s — lead never moves
# via attack since depth is never <= 0.
var released_at_floor := false
for i in InputLeadController.CLEAN_SURPLUS_TICKS + InputLeadController.RELEASE_INTERVAL_TICKS:
if c.update(10) == 0:
released_at_floor = true
assert_eq(c.lead, InputLeadController.LEAD_MIN, "lead's own bookkeeping never drops below its floor")
assert_true(released_at_floor, "release still fires (duplicates a seq, actually draining real depth) even while lead is pinned at the floor")
# Raise it above the floor via one real attack, then confirm sustained
# external surplus (not self-caused) still drains it back down.
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, "attack raised lead")
var released := false
for i in InputLeadController.CLEAN_SURPLUS_TICKS + InputLeadController.RELEASE_INTERVAL_TICKS:
if c.update(10) == 0:
released = true
break
assert_true(released, "sustained externally-caused surplus (depth=10) must eventually trigger a release")
assert_true(c.lead < lead_after_attack, "lead actually decreased in response to real depth, not just internal bookkeeping")