extends "res://tests/test_case.gd" const NetShipPredictor = preload("res://scripts/net_ship_predictor.gd") const NetBodyState = preload("res://scripts/net_body_state.gd") const LocalPredictionHistory = preload("res://scripts/local_prediction_history.gd") func _authoritative() -> NetBodyState: return NetBodyState.new() func _matched(position_error: float = 0.0, rotation_error_degrees: float = 0.0) -> Dictionary: return { "status": "matched", "authoritative_state": _authoritative(), "position_error_magnitude": position_error, "rotation_error_degrees": rotation_error_degrees, } func test_soft_correction_within_thresholds() -> void: var decision := NetShipPredictor.decide(_matched(2.0, 60.0), false, false) assert_eq(decision["mode"], "soft", "thresholds are strict greater-than, so exact boundary soft-corrects") assert_eq(decision["reason"], "within_thresholds", "soft decision records why") func test_hard_correction_for_missing_prediction_or_reset() -> void: var missing := NetShipPredictor.decide({"status": "missing_evicted", "authoritative_state": _authoritative()}, false, false) assert_eq(missing["mode"], "hard", "an unavailable same-sequence prediction must snap") assert_eq(missing["reason"], "missing_evicted", "missing cause remains inspectable") var reset := NetShipPredictor.decide(_matched(), false, true) assert_eq(reset["mode"], "hard", "a new reset generation never blends across a teleport") func test_hard_correction_for_flags_or_large_error() -> void: var frozen_state := _matched() (frozen_state["authoritative_state"] as NetBodyState).frozen = true assert_eq(NetShipPredictor.decide(frozen_state, false, false)["reason"], "frozen_mismatch", "authority frozen flag wins over local simulation") assert_eq(NetShipPredictor.decide(_matched(2.01), false, false)["reason"], "position_error", "position beyond 2m snaps") assert_eq(NetShipPredictor.decide(_matched(0.0, 60.01), false, false)["reason"], "rotation_error", "rotation beyond 60 degrees snaps") func test_soft_correction_applies_past_authority_as_a_delta_to_current_pose() -> void: var predicted := _authoritative() predicted.position = Vector3(10.0, 0.0, 0.0) var authoritative := _authoritative() authoritative.position = Vector3(10.5, 0.0, 0.0) var comparison := { "predicted_state": predicted, "authoritative_state": authoritative, } var current := Transform3D(Basis.IDENTITY, Vector3(14.0, 2.0, -3.0)) var corrected := NetShipPredictor.soft_corrected_transform(current, comparison) assert_almost_eq(corrected.origin.x, 14.5, 0.0001, "the correction moves current state by the same-sequence error") assert_almost_eq(corrected.origin.y, 2.0, 0.0001, "unrelated current coordinates are preserved") assert_almost_eq(corrected.origin.z, -3.0, 0.0001, "soft correction does not rewind to the old authoritative pose") func test_missing_history_places_once_then_suppresses_old_acknowledgements() -> void: var predictor := NetShipPredictor.new() var ship := Ship.new() var history := LocalPredictionHistory.new() var missing := {"status": "missing_not_recorded", "seq": 4, "authoritative_state": _authoritative()} assert_eq(predictor.reconcile(missing, ship, 0, 10, history)["mode"], "hard", "first unavailable acknowledgement performs one resync placement") assert_eq(predictor.reconcile(missing, ship, 0, 11, history)["mode"], "suppressed", "older unavailable acknowledgements do not create a snap burst") ship.free() func test_reset_preempts_missing_history_suppression() -> void: var predictor := NetShipPredictor.new() var ship := Ship.new() var history := LocalPredictionHistory.new() var missing := {"status": "missing_not_recorded", "seq": 4, "authoritative_state": _authoritative()} predictor.reconcile(missing, ship, 0, 10, history) assert_eq(predictor.reconcile(missing, ship, 0, 11, history)["mode"], "suppressed", "old missing acknowledgement is suppressed during recovery") var reset_authority := _authoritative() reset_authority.position = Vector3(7.0, 2.0, -3.0) var reset_missing := {"status": "missing_not_recorded", "seq": 5, "authoritative_state": reset_authority} var reset := predictor.reconcile(reset_missing, ship, 1, 12, history) assert_eq(reset["reason"], "reset_gen", "a changed reset generation preempts recovery suppression") ship.free() # An attack's skipped sequence is acknowledged by the server but was never # locally simulated. It is a routine product of this client's own lead control # — not history loss — so it must not be treated as a snap condition. func test_unsimulated_gap_is_skipped_not_snapped() -> void: var decision := NetShipPredictor.decide({"status": "unsimulated_gap", "seq": 5, "authoritative_state": _authoritative()}, false, false) assert_eq(decision["mode"], "skip", "an unsimulated gap is neither soft nor hard") assert_eq(decision["reason"], "unsimulated_gap", "the reason names the real cause") func test_genuine_missing_history_is_still_a_hard_snap() -> void: # The control for the test above: the two statuses must not have been # collapsed together while making gaps benign. for status in ["missing_not_recorded", "missing_evicted"]: var decision := NetShipPredictor.decide({"status": status, "seq": 5, "authoritative_state": _authoritative()}, false, false) assert_eq(decision["mode"], "hard", "%s must still hard-correct" % status) func test_warmup_ack_before_first_prediction_is_skipped() -> void: var history := LocalPredictionHistory.new() var authority := _authoritative() var comparison := history.compare_authoritative(0, authority) assert_eq(comparison["status"], "warmup_not_recorded", "pre-history acknowledgement is startup, not loss") assert_eq(NetShipPredictor.decide(comparison, false, false)["mode"], "skip", "startup acknowledgement must not hard-snap") func test_a_reset_still_wins_over_an_unsimulated_gap() -> void: # Ordering guard: reset_gen is an epoch boundary and outranks everything, # including the new skip path — otherwise a gap landing on the reset # snapshot would silently discard the epoch change. var decision := NetShipPredictor.decide({"status": "unsimulated_gap", "seq": 5, "authoritative_state": _authoritative()}, false, true) assert_eq(decision["mode"], "hard", "reset still takes precedence") assert_eq(decision["reason"], "reset_gen", "and is still attributed to the reset")