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