mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
75f485667b
Closes Phase 4's outstanding action-sequence-correctness invariant, then fixes two server-side bugs an adversarial review of that work uncovered. Server simulation, bot observations, collision resources and tick rate are unchanged: the server_physics_parity trace is byte-for-byte identical to HEAD across 360 ticks including both ships' full observation vectors. 4.11 - prediction history filed under the ISSUING sequence _send_local_input filed each post-step predicted state under the timeline's estimate of the sequence the server would consume this tick, trailing issuance by input_lead. The body had integrated the intent issued under _input_seq, so predicted[S] held "state after the intent from now" while the server's authority for S is "state after action(S)". They agree only while the stick is still. Filing under _input_seq costs nothing: which action the ship uses is decided in LocalNetShipController.get_action() and is untouched. Every prior Phase 4 gate held its input steady, and a steady input cannot falsify a sequence label - the 60s runs honestly reported marker=0/3784. New --exercise-input-transitions role toggles thrust every 6 ticks; it is the only gate that can catch a label regression. Verified non-vacuous: the old label fails it at 50%. 4.12 - issued-but-unsimulated sequences, and the release path An attack (delta > 1) issues and sends several sequences for one local physics step. Those gap sequences had no recorded prediction, so a server ack of one reported missing_not_recorded - indistinguishable from ring loss, costing a teleport and resync suppression several times a minute. They are now recorded stateless via record_unsimulated() and answered with a new "skip" decision mode. Free-flight hard snaps: 25/8/4 -> 0/0/0. A release (delta == 0) re-recorded at the unchanged _input_seq, filing the current intent under a sequence that went out carrying a different action; LocalInputTimeline deliberately refuses to mutate an issued sequence, so the ring contradicted the wire. Recording is now skipped on release ticks. 4.13 - two Phase 3 bugs silently killing player input (a) InputJitterBuffer.consume() advanced last_applied_seq on every tick including a starve. Since ingest() discards seq <= last_applied_seq, one starve on a sequence the client had not sent yet stranded the stream one ahead of arrivals permanently - both sides advancing in lockstep, every honest packet discarded on arrival. The client's own input_lead release is enough to trigger it, so input died for ~30 ticks roughly every 6.5s on a clean LAN. Now only gives up on a sequence once strictly newer data proves it lost. Silent-client stall and ring-overflow resync are unchanged. (b) The seq-range guard bounded incoming seq against highest_ingested_seq, which only advances inside ingest(), which that guard gates. After a ~2s host hitch every packet was rejected forever with no diagnostic (600+ consecutive rejections reproduced via SIGSTOP). Third iteration of this guard; each previous version bounded against a value only the accepted path could advance. Adds an escape after 10 consecutive rejections, which grants an attacker nothing the rate limiter does not already bound. (c) The transitions gate reported PASS at 3.76% while input was completely dead, because suppression stops _record_metrics - a worse outage yields fewer samples and a LOWER rate. Now scales the required sample count with run length and asserts the wire's server_stalled bit. Reverting both fixes makes it fail at samples 292/600, server_stalled=true, input_lead=12. Fixing (a) also explained a residual the review had already traced: 151 of 151 action-marker mismatches were the server repeating a stale action on a starve, not a prediction defect. Marker is now 0.00% in all three conditions (was 1.7-2.5%), and free-flight p99 improved to 0.141/0.168/0.154m from 0.170/0.176/0.184m. Two pre-existing test defects fixed alongside: the ball gate asserted RTT-masking on a link with no RTT (flaked 2 in 5; now asserted only at rtt >= 20ms, 5/5 under latency), and the two-bot CI compared scores across a 3-5s window (now polls the scores the server actually held; note score_changed is emitted only on the client path). QA: 72 unit tests; 60s free-flight at LAN/80+-20ms/5% loss; transition gate in all three; 2.0s and 3.5s host-freeze recovery; ball contact x5; two-bot CI x3; all three abuse roles; net/match_net/clock/lobby smokes. Phase 4 sign-off still pending a human playtest at ~100ms RTT - the milestone asks how it feels, which no gate here answers.
155 lines
7.8 KiB
GDScript
155 lines
7.8 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")
|
|
|
|
|
|
func test_zero_target_treats_an_empty_clean_link_buffer_as_healthy() -> void:
|
|
var c := InputLeadController.new()
|
|
for i in 10:
|
|
assert_eq(c.update(0, 0), 1, "adaptive zero-depth target does not attack on a clean empty buffer")
|
|
assert_eq(c.lead, InputLeadController.LEAD_MIN, "clean-link target preserves the minimum lead")
|
|
|
|
|
|
# §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")
|