Files
CosmicClash/Game/tests/cases/test_net_ship_predictor.gd
T
2026-09-01 08:32:44 +01:00

117 lines
6.2 KiB
GDScript

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