mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
117 lines
6.2 KiB
GDScript
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")
|