Files
CosmicClash/Game/tests/cases/test_local_prediction_history.gd
T
Josh Creek 3d3024ae8a feat(multiplayer): Phase 4 tasks 4.1/4.2 - local prediction history ring
Adds LocalPredictionHistory, a client-owned seq-tagged ring recording
predicted ship state per input sequence, plus wiring in
NetworkedMatch to record predictions on send and compare them against
authoritative snapshots on arrival. Ships stay frozen/interpolated
until 4.3 lands actual correction logic; this round only builds the
comparison machinery and its data.

Includes fixes from two review rounds: resync_required now
self-clears once acknowledgements catch back up (mirrors
InputJitterBuffer's stalled flag), NetBodyState gained a copy()
method to stop diagnostic accessors aliasing ring-owned state, and
corrected comments that had described the local ship as being
force-simulated pre-4.3 when it is still driven by interpolated
transform writes.
2026-08-20 19:29:13 +01:00

162 lines
8.5 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_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")