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.
261 lines
14 KiB
GDScript
261 lines
14 KiB
GDScript
extends "res://tests/test_case.gd"
|
|
|
|
const LocalPredictionHistory = preload("res://scripts/local_prediction_history.gd")
|
|
const ShipAction = preload("res://scripts/ship_action.gd")
|
|
const NetBodyState = preload("res://scripts/net_body_state.gd")
|
|
|
|
|
|
func _action(value: float) -> ShipAction:
|
|
var action := ShipAction.new()
|
|
action.thrust = Vector3(0.0, 0.0, value)
|
|
action.rotation = Vector3(value, 0.0, 0.0)
|
|
action.turbo = value > 0.5
|
|
return action
|
|
|
|
|
|
func _state(value: float) -> NetBodyState:
|
|
var state := NetBodyState.new()
|
|
state.position = Vector3(value, value + 1.0, value + 2.0)
|
|
state.rotation = Quaternion(Vector3.UP, value * 0.1)
|
|
state.linear_velocity = Vector3(value * 2.0, 0.0, 0.0)
|
|
state.angular_velocity = Vector3(0.0, value * 3.0, 0.0)
|
|
return state
|
|
|
|
|
|
func test_matches_authoritative_state_at_the_same_sequence() -> void:
|
|
var history := LocalPredictionHistory.new()
|
|
var predicted := _state(2.0)
|
|
history.record(17, _action(0.4), predicted)
|
|
var authoritative := _state(2.0)
|
|
authoritative.position += Vector3(1.0, -2.0, 0.5)
|
|
authoritative.linear_velocity += Vector3(0.25, 0.0, 0.0)
|
|
|
|
var result := history.compare_authoritative(17, authoritative)
|
|
assert_eq(result["status"], "matched", "same tagged sequence matches")
|
|
assert_almost_eq((result["position_error"] as Vector3).x, 1.0, 0.0001, "position delta x")
|
|
assert_almost_eq((result["position_error"] as Vector3).y, -2.0, 0.0001, "position delta y")
|
|
assert_almost_eq(result["position_error_magnitude"], 2.2912878, 0.0001, "position error magnitude")
|
|
assert_almost_eq((result["linear_velocity_error"] as Vector3).x, 0.25, 0.0001, "linear velocity delta")
|
|
assert_eq(history.last_acknowledged_seq, 17, "comparison advances acknowledgement epoch")
|
|
|
|
|
|
func test_record_and_lookup_do_not_alias_action_or_state() -> void:
|
|
var history := LocalPredictionHistory.new()
|
|
var action := _action(0.25)
|
|
var state := _state(3.0)
|
|
history.record(4, action, state)
|
|
action.thrust.z = 9.0
|
|
state.position.x = 99.0
|
|
|
|
var prediction := history.get_prediction(4)
|
|
assert_almost_eq((prediction["action"] as ShipAction).thrust.z, 0.25, 0.0001, "stored action is copied")
|
|
assert_almost_eq((prediction["state"] as NetBodyState).position.x, 3.0, 0.0001, "stored state is copied")
|
|
(prediction["action"] as ShipAction).thrust.z = -5.0
|
|
(prediction["state"] as NetBodyState).position.x = -5.0
|
|
var second_lookup := history.get_prediction(4)
|
|
assert_almost_eq((second_lookup["action"] as ShipAction).thrust.z, 0.25, 0.0001, "lookup action cannot mutate ring")
|
|
assert_almost_eq((second_lookup["state"] as NetBodyState).position.x, 3.0, 0.0001, "lookup state cannot mutate ring")
|
|
|
|
|
|
func test_overwrite_state_keeps_the_original_action() -> void:
|
|
var history := LocalPredictionHistory.new()
|
|
history.record(8, _action(0.25), _state(1.0))
|
|
var corrected := _state(9.0)
|
|
assert_true(history.overwrite_state(8, corrected), "an existing prediction can be backfilled after a snap")
|
|
assert_true(not history.overwrite_state(9, corrected), "backfill never invents an action for an unrecorded sequence")
|
|
var prediction := history.get_prediction(8)
|
|
assert_almost_eq((prediction["action"] as ShipAction).thrust.z, 0.25, 0.0001, "backfill preserves already-sent action")
|
|
assert_almost_eq((prediction["state"] as NetBodyState).position.x, 9.0, 0.0001, "backfill replaces only state")
|
|
|
|
|
|
func test_rebase_carries_a_soft_correction_through_future_history_once() -> void:
|
|
var history := LocalPredictionHistory.new()
|
|
history.record(10, _action(0.1), _state(1.0))
|
|
history.record(11, _action(0.2), _state(2.0))
|
|
history.record(12, _action(0.3), _state(3.0))
|
|
var first_rotation := Quaternion(Vector3.UP, 0.25)
|
|
history.overwrite_state(10, _state(9.0))
|
|
history.rebase_state_range(11, 12, Vector3(0.5, -1.0, 0.25), first_rotation, Vector3(1.0, 0.0, 0.0), Vector3(0.0, 2.0, 0.0))
|
|
var after_first := history.get_prediction(12)
|
|
assert_almost_eq((after_first["state"] as NetBodyState).position.x, 3.5, 0.0001, "first correction reaches the future state")
|
|
assert_almost_eq((after_first["action"] as ShipAction).thrust.z, 0.3, 0.0001, "rebase never alters the paired input")
|
|
|
|
# The next acknowledgement compares to the rebased state, so only its new
|
|
# delta is transported. This trace catches the old bug where the first
|
|
# delta remained in history and was applied again on every snapshot.
|
|
history.rebase_state_range(12, 12, Vector3(-0.2, 0.0, 0.0), Quaternion.IDENTITY, Vector3.ZERO, Vector3.ZERO)
|
|
var after_second := history.get_prediction(12)
|
|
assert_almost_eq((after_second["state"] as NetBodyState).position.x, 3.3, 0.0001, "a second correction applies only its own delta")
|
|
assert_almost_eq((after_second["state"] as NetBodyState).linear_velocity.x, 7.0, 0.0001, "future velocity is rebased with the correction")
|
|
|
|
|
|
func test_slot_tags_reject_wrapped_stale_predictions() -> void:
|
|
var history := LocalPredictionHistory.new()
|
|
history.record(1, _action(0.1), _state(1.0))
|
|
history.record(1 + LocalPredictionHistory.RING_SIZE, _action(0.8), _state(8.0))
|
|
|
|
assert_true(history.get_prediction(1).is_empty(), "old same-index entry is not mistaken for current data")
|
|
assert_eq(history.compare_authoritative(1, _state(1.0))["status"], "missing_evicted", "stale acknowledgement is explicitly evicted")
|
|
assert_eq(history.compare_authoritative(1 + LocalPredictionHistory.RING_SIZE, _state(8.0))["status"], "matched", "current same-index entry still matches")
|
|
|
|
|
|
func test_unacknowledged_overflow_is_explicit_and_keeps_newest_window() -> void:
|
|
var history := LocalPredictionHistory.new()
|
|
for seq in range(1, LocalPredictionHistory.RING_SIZE + 2):
|
|
history.record(seq, _action(float(seq)), _state(float(seq)))
|
|
|
|
assert_true(history.resync_required, "producer outrunning acknowledgements sets explicit resync state")
|
|
assert_eq(history.overflow_count, 1, "one continuous overflow episode is counted once")
|
|
assert_eq(history.compare_authoritative(1, _state(1.0))["status"], "missing_evicted", "oldest unacknowledged prediction was explicitly evicted")
|
|
var newest := history.compare_authoritative(LocalPredictionHistory.RING_SIZE + 1, _state(float(LocalPredictionHistory.RING_SIZE + 1)))
|
|
assert_eq(newest["status"], "matched", "newest prediction remains usable after overflow")
|
|
|
|
|
|
# Records seq..seq+n until the unacknowledged window trips overflow, and
|
|
# returns the newest sequence recorded. Mirrors the real producer, which
|
|
# calls record() once per client physics tick with no acknowledgements
|
|
# arriving during a stall.
|
|
func _stall_until_overflow(history: LocalPredictionHistory, from_seq: int) -> int:
|
|
# Hard-bounded rather than `while not history.resync_required`: assert_true
|
|
# only records a failure, it cannot abort, so an unbounded loop here would
|
|
# hang the whole headless runner instead of failing if the trip condition
|
|
# ever regressed.
|
|
var newest := from_seq - 1
|
|
for seq in range(from_seq, from_seq + 4 * LocalPredictionHistory.RING_SIZE):
|
|
if history.resync_required:
|
|
break
|
|
history.record(seq, _action(float(seq)), _state(float(seq)))
|
|
newest = seq
|
|
assert_true(history.resync_required, "overflow must trip within a bounded number of ticks")
|
|
return newest
|
|
|
|
|
|
func test_resync_required_clears_once_acknowledgements_catch_back_up() -> void:
|
|
var history := LocalPredictionHistory.new()
|
|
var newest := _stall_until_overflow(history, 1)
|
|
assert_true(history.resync_required, "transient stall trips explicit resync state")
|
|
assert_eq(history.overflow_count, 1, "first episode counted once")
|
|
|
|
# An acknowledgement that lands on an already-evicted sequence is not
|
|
# evidence of recovery and must leave the flag alone.
|
|
assert_eq(history.compare_authoritative(1, _state(1.0))["status"], "missing_evicted", "oldest sequence is gone")
|
|
assert_true(history.resync_required, "an evicted comparison does not count as catching up")
|
|
|
|
# A real, current, matched acknowledgement does.
|
|
var recovered := history.compare_authoritative(newest, _state(float(newest)))
|
|
assert_eq(recovered["status"], "matched", "newest prediction still matches")
|
|
assert_true(not history.resync_required, "resync state clears once acknowledgements are flowing again")
|
|
assert_eq(history.overflow_count, 1, "recovery does not retroactively change the episode count")
|
|
|
|
|
|
func test_second_distinct_overflow_episode_is_counted_separately() -> void:
|
|
var history := LocalPredictionHistory.new()
|
|
var first_newest := _stall_until_overflow(history, 1)
|
|
history.compare_authoritative(first_newest, _state(float(first_newest)))
|
|
assert_true(not history.resync_required, "first episode recovered")
|
|
assert_eq(history.overflow_count, 1, "first episode counted")
|
|
|
|
var second_newest := _stall_until_overflow(history, first_newest + 1)
|
|
assert_true(history.resync_required, "a later separate stall trips resync state again")
|
|
assert_eq(history.overflow_count, 2, "a second distinct episode is counted separately, not capped at one")
|
|
|
|
history.compare_authoritative(second_newest, _state(float(second_newest)))
|
|
assert_true(not history.resync_required, "second episode also recovers")
|
|
assert_eq(history.overflow_count, 2, "episode count is cumulative across recoveries")
|
|
|
|
|
|
func test_continuing_stall_does_not_recount_the_same_episode() -> void:
|
|
var history := LocalPredictionHistory.new()
|
|
var newest := _stall_until_overflow(history, 1)
|
|
for seq in range(newest + 1, newest + 1 + LocalPredictionHistory.RING_SIZE):
|
|
history.record(seq, _action(float(seq)), _state(float(seq)))
|
|
assert_true(history.resync_required, "the stall is still in progress")
|
|
assert_eq(history.overflow_count, 1, "one continuous outage stays one episode however long it lasts")
|
|
|
|
|
|
# Sequences are not guaranteed consecutive: InputLeadController.update() can
|
|
# return 0 or up to 1+3, so the client's seq can skip forward and leave a ring
|
|
# slot holding a tag older than newest_recorded_seq - RING_SIZE. get_prediction()
|
|
# reports such a stale-but-untouched slot as "matched", so matching alone must
|
|
# not be enough to clear resync_required while the real backlog is still huge.
|
|
func test_stale_matched_comparison_does_not_clear_a_live_backlog() -> void:
|
|
var history := LocalPredictionHistory.new()
|
|
history.record(1, _action(1.0), _state(1.0))
|
|
var far := LocalPredictionHistory.RING_SIZE + 2 # skips index 1, so seq 1's slot survives
|
|
history.record(far, _action(float(far)), _state(float(far)))
|
|
assert_true(history.resync_required, "the skip outran the acknowledgement window")
|
|
|
|
var stale := history.compare_authoritative(1, _state(1.0))
|
|
assert_eq(stale["status"], "matched", "seq 1's ring slot was never rewritten")
|
|
assert_true(history.resync_required, "a stale match while the backlog is still oversized must not clear resync state")
|
|
|
|
history.compare_authoritative(far, _state(float(far)))
|
|
assert_true(not history.resync_required, "acknowledging the newest sequence does clear it")
|
|
|
|
|
|
# --- Unsimulated (attack-gap) sequences -------------------------------------
|
|
# The input_lead controller's attack path issues and SENDS several sequences
|
|
# for one local physics step. Those skipped sequences are genuinely outstanding
|
|
# — the server will acknowledge them — but the client never computed a
|
|
# post-step state for them. They must be distinguishable from real history
|
|
# loss, because history loss is a hard-snap condition and this is not.
|
|
|
|
func test_unsimulated_gap_is_not_reported_as_missing_history() -> void:
|
|
var history := LocalPredictionHistory.new()
|
|
history.record(10, _action(1.0), _state(1.0))
|
|
history.record_unsimulated(11, _action(1.0))
|
|
history.record_unsimulated(12, _action(1.0))
|
|
history.record(13, _action(2.0), _state(2.0))
|
|
|
|
var gap := history.compare_authoritative(11, _state(9.0))
|
|
assert_eq(gap["status"], "unsimulated_gap", "an issued-but-unsimulated seq reports its own status")
|
|
assert_eq(gap["seq"], 11, "the comparison still identifies the acknowledged sequence")
|
|
assert_true(gap.has("authoritative_state"), "authority is still handed back for diagnostics")
|
|
assert_true(not gap.has("position_error"), "no error can be computed without a predicted state")
|
|
|
|
var real := history.compare_authoritative(13, _state(2.0))
|
|
assert_eq(real["status"], "matched", "a genuinely simulated seq still reconciles normally")
|
|
|
|
|
|
func test_unsimulated_gap_carries_the_action_that_went_on_the_wire() -> void:
|
|
var history := LocalPredictionHistory.new()
|
|
history.record_unsimulated(4, _action(0.75))
|
|
var gap := history.compare_authoritative(4, _state(0.0))
|
|
assert_almost_eq(gap["action"].thrust.z, 0.75, 0.001, "the filled repeat-last action is retained honestly")
|
|
|
|
|
|
func test_unsimulated_slot_refuses_a_fabricated_state() -> void:
|
|
# §4.4 forbids manufacturing history. Writing a state into a slot the client
|
|
# never simulated would do exactly that, so both write paths must decline.
|
|
var history := LocalPredictionHistory.new()
|
|
history.record_unsimulated(7, _action(1.0))
|
|
assert_true(not history.overwrite_state(7, _state(5.0)), "overwrite_state declines an unsimulated slot")
|
|
assert_eq(history.compare_authoritative(7, _state(0.0))["status"], "unsimulated_gap", "the slot stays stateless")
|
|
|
|
history.rebase_state_range(7, 7, Vector3.ONE, Quaternion.IDENTITY, Vector3.ONE, Vector3.ONE)
|
|
assert_eq(history.compare_authoritative(7, _state(0.0))["status"], "unsimulated_gap", "rebase skips it rather than seeding a state")
|
|
|
|
|
|
func test_rebase_skips_unsimulated_entries_without_stopping_at_them() -> void:
|
|
# A gap sitting between two real predictions must not truncate the rebase:
|
|
# the entries after it still describe the pre-correction trajectory.
|
|
var history := LocalPredictionHistory.new()
|
|
history.record(1, _action(1.0), _state(1.0))
|
|
history.record_unsimulated(2, _action(1.0))
|
|
history.record(3, _action(1.0), _state(1.0))
|
|
|
|
history.rebase_state_range(1, 3, Vector3(10.0, 0.0, 0.0), Quaternion.IDENTITY, Vector3.ZERO, Vector3.ZERO)
|
|
var after := history.get_prediction(3)
|
|
assert_almost_eq(after["state"].position.x, _state(1.0).position.x + 10.0, 0.001, "the entry past the gap was still rebased")
|
|
|
|
|
|
func test_unsimulated_gap_still_advances_the_acknowledgement_clock() -> void:
|
|
# The gap sequence IS outstanding, so it must count toward the ring's
|
|
# unacknowledged-capacity bookkeeping exactly as a simulated one does —
|
|
# otherwise a burst of attacks would silently under-report the backlog.
|
|
var history := LocalPredictionHistory.new()
|
|
history.record(1, _action(1.0), _state(1.0))
|
|
history.record_unsimulated(200, _action(1.0))
|
|
assert_eq(history.newest_recorded_seq, 200, "an unsimulated record advances the newest-seq cursor")
|
|
assert_true(history.resync_required, "it also trips the same unacknowledged-capacity guard")
|