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")