Files
CosmicClash/Game/scripts/net_ship_predictor.gd
T
Josh Creek 4fb7ddfecf docs(multiplayer): consolidate tracking into one document
multiplayer-todo.md and multiplayer-next.md tracked overlapping
information in two places. Fold everything into multiplayer-next.md
(architecture decisions, wire format, task breakdown with checkboxes,
gotchas list, testing notes) and delete multiplayer-todo.md. Section
numbers are unchanged, so existing code comments citing them by
section/task number still resolve; update every such reference to
point at the new filename.
2026-09-01 12:32:43 +01:00

279 lines
14 KiB
GDScript

extends RefCounted
# Local-ship reconciliation policy (multiplayer-next.md §4.4). Kept out of
# NetworkedMatch so the decision table is pure-testable; the imperative half
# only writes Ship's existing Jolt-safe queued correction hooks.
const DEFAULT_HARD_POSITION_ERROR := 2.0
const DEFAULT_HARD_ROTATION_ERROR_DEGREES := 60.0
const DEFAULT_MAX_VISUAL_OFFSET := 0.4
const METRIC_SAMPLE_CAPACITY := 3600 # one minute at the 60Hz snapshot rate
const NetBodyState = preload("res://scripts/net_body_state.gd")
const LocalPredictionHistory = preload("res://scripts/local_prediction_history.gd")
var _last_reset_gen := -1 # first snapshot establishes baseline, never resets
var _position_errors: Array[float] = []
var _rotation_errors: Array[float] = []
var _free_flight_position_errors: Array[float] = []
var _free_flight_rotation_errors: Array[float] = []
var _visual_correction_errors: Array[float] = []
var _free_flight_visual_correction_errors: Array[float] = []
var _hard_snap_count := 0
var _decision_count := 0
var _resync_until_seq := -1
var _metrics_started_ms := -1
var hard_position_error := DEFAULT_HARD_POSITION_ERROR
var hard_rotation_error_degrees := DEFAULT_HARD_ROTATION_ERROR_DEGREES
var max_visual_offset := DEFAULT_MAX_VISUAL_OFFSET
var _hard_snap_reasons := {}
var _hard_snap_cohorts := {"free_flight": 0, "contact": 0, "reset": 0, "resync": 0, "unsimulated": 0}
var _cohort_counts := {"free_flight": 0, "contact": 0, "reset": 0, "resync": 0, "unsimulated": 0}
static func decide(comparison: Dictionary, local_frozen: bool, reset_changed: bool, position_threshold: float = DEFAULT_HARD_POSITION_ERROR, rotation_threshold_degrees: float = DEFAULT_HARD_ROTATION_ERROR_DEGREES) -> Dictionary:
var authoritative: NetBodyState = comparison.get("authoritative_state", null)
if reset_changed:
return {"mode": "hard", "reason": "reset_gen"}
# An attack's skipped sequence is issued, sent, and acknowledged, but never
# locally simulated — there is no predicted state to compare and nothing is
# wrong. It is not history loss and must not teleport the ship or arm resync
# suppression: the lead controller produces these during ordinary play, and
# treating them as missing history cost several unnecessary hard snaps a
# minute. Skip the acknowledgement; the next simulated sequence (at most a
# tick or two later, since the server consumes one per tick) reconciles
# normally against real data.
if comparison.get("status", "") == "unsimulated_gap":
return {"mode": "skip", "reason": "unsimulated_gap"}
if comparison.get("status", "") == "warmup_not_recorded":
# Sequence acknowledgements that predate the first local post-step state
# are expected during startup. The initial snapshot already placed the
# body, so there is no correction to apply and no resync to arm.
return {"mode": "skip", "reason": "warmup_not_recorded"}
if comparison.get("status", "missing_not_recorded") != "matched":
return {"mode": "hard", "reason": comparison.get("status", "missing")}
if authoritative == null or authoritative.frozen != local_frozen:
return {"mode": "hard", "reason": "frozen_mismatch"}
if float(comparison["position_error_magnitude"]) > position_threshold:
return {"mode": "hard", "reason": "position_error"}
if float(comparison["rotation_error_degrees"]) > rotation_threshold_degrees:
return {"mode": "hard", "reason": "rotation_error"}
return {"mode": "soft", "reason": "within_thresholds"}
static func soft_corrected_transform(current_transform: Transform3D, comparison: Dictionary) -> Transform3D:
var authoritative: NetBodyState = comparison["authoritative_state"]
var predicted: NetBodyState = comparison["predicted_state"]
var position_delta: Vector3 = authoritative.position - predicted.position
var rotation_delta := Basis(authoritative.rotation.normalized()) * Basis(predicted.rotation.normalized()).inverse()
return Transform3D(
(rotation_delta * current_transform.basis).orthonormalized(),
current_transform.origin + position_delta
)
func reconcile(comparison: Dictionary, ship: Ship, reset_gen: int, current_seq: int, history: LocalPredictionHistory) -> Dictionary:
var reset_changed := _last_reset_gen != -1 and reset_gen != _last_reset_gen
_last_reset_gen = reset_gen
var comparison_seq := int(comparison.get("seq", -1))
# A reset is an epoch boundary, never ordinary stale traffic. It must
# preempt an outstanding missing-history suppression or the first reset
# snapshot could be discarded and every later snapshot share its generation.
if reset_changed:
_resync_until_seq = -1
var reset_decision := decide(comparison, ship.freeze, true, hard_position_error, hard_rotation_error_degrees)
_record_metrics(comparison, reset_decision)
var reset_authority: NetBodyState = comparison.get("authoritative_state", null)
if reset_authority != null:
ship.queue_teleport_with_velocity(Transform3D(Basis(reset_authority.rotation), reset_authority.position), reset_authority.linear_velocity, reset_authority.angular_velocity)
ship.net_visual_offset = Vector3.ZERO
ship.net_visual_rotation_offset = Quaternion.IDENTITY
if is_instance_valid(ship.visual):
ship.visual.position = Vector3.ZERO
ship.visual.basis = Basis.IDENTITY
_resync_until_seq = current_seq + 1
return reset_decision
if _resync_until_seq >= 0:
if comparison.get("status", "") == "matched" and comparison_seq >= _resync_until_seq:
_resync_until_seq = -1
else:
return {"mode": "suppressed", "reason": "awaiting_resync"}
var decision := decide(comparison, ship.freeze, false, hard_position_error, hard_rotation_error_degrees)
_record_metrics(comparison, decision)
if decision["mode"] == "skip":
# Deliberately before the authority write below: a skipped acknowledgement
# leaves the body, the visual offset and _resync_until_seq exactly as they
# were. Nothing about this sequence is unhealthy, so nothing is corrected
# and nothing is suppressed.
return decision
var authoritative: NetBodyState = comparison.get("authoritative_state", null)
if authoritative == null:
return decision
if comparison.get("status", "") == "matched" and decision["reason"] != "reset_gen":
# Transport the same-sequence authority error through current Jolt state
# and retained predictions. This deliberately avoids fake single-body
# replay, which cannot reproduce contact impulses/friction.
var predicted: NetBodyState = comparison["predicted_state"]
var position_delta: Vector3 = comparison["position_error"]
var velocity_error: Vector3 = comparison["linear_velocity_error"]
var angular_velocity_error: Vector3 = comparison["angular_velocity_error"]
var rotation_delta := (authoritative.rotation.normalized() * predicted.rotation.normalized().inverse()).normalized()
history.overwrite_state(int(comparison["seq"]), authoritative)
history.rebase_state_range(int(comparison["seq"]) + 1, current_seq, position_delta, rotation_delta, velocity_error, angular_velocity_error)
var old_basis := ship.global_transform.basis
var corrected_transform := soft_corrected_transform(ship.global_transform, comparison)
# Apply both velocity deltas to the live body atomically with pose. The
# same deltas are transported through retained history above.
ship.queue_teleport_with_velocity(corrected_transform, ship.linear_velocity + velocity_error, ship.angular_velocity + angular_velocity_error)
var position_error: Vector3 = comparison["position_error"]
if decision["mode"] == "soft":
ship.net_visual_offset = (ship.global_transform.basis.inverse() * -position_error).limit_length(max_visual_offset)
# The body rotates in world space. Convert the inverse correction to
# the child visual's local basis so its global orientation is preserved
# through the physical correction (B_old^-1 Δ^-1 B_old).
var local_visual_delta: Basis = old_basis.inverse() * Basis(rotation_delta.inverse()) * old_basis
ship.net_visual_rotation_offset = local_visual_delta.get_rotation_quaternion() * ship.net_visual_rotation_offset
else:
ship.net_visual_offset = Vector3.ZERO
ship.net_visual_rotation_offset = Quaternion.IDENTITY
if is_instance_valid(ship.visual):
ship.visual.position = Vector3.ZERO
ship.visual.basis = Basis.IDENTITY
else:
# Reset/missing state has no trustworthy delta. Place authority once;
# callers must wait for a new matched history entry before correction.
ship.queue_teleport_with_velocity(Transform3D(Basis(authoritative.rotation), authoritative.position), authoritative.linear_velocity, authoritative.angular_velocity)
ship.net_visual_offset = Vector3.ZERO
ship.net_visual_rotation_offset = Quaternion.IDENTITY
if is_instance_valid(ship.visual):
ship.visual.position = Vector3.ZERO
ship.visual.basis = Basis.IDENTITY
# Retain no fabricated future. Once local input history contains a
# newly acknowledged sequence, normal delta reconciliation resumes.
_resync_until_seq = current_seq + 1
return decision
func get_metrics() -> Dictionary:
return {
"sample_count": _position_errors.size(),
"position_error_p50": _percentile(0.50),
"position_error_p95": _percentile(0.95),
"position_error_p99": _percentile(0.99),
"rotation_error_p50": _rotation_percentile(0.50),
"rotation_error_p95": _rotation_percentile(0.95),
"rotation_error_p99": _rotation_percentile(0.99),
"free_flight_sample_count": _free_flight_position_errors.size(),
"free_flight_position_error_p95": _percentile_from(_free_flight_position_errors, 0.95),
"free_flight_position_error_p99": _percentile_from(_free_flight_position_errors, 0.99),
"free_flight_rotation_error_p95": _percentile_from(_free_flight_rotation_errors, 0.95),
"free_flight_rotation_error_p99": _percentile_from(_free_flight_rotation_errors, 0.99),
"visual_correction_p95": _percentile_from(_visual_correction_errors, 0.95),
"visual_correction_p99": _percentile_from(_visual_correction_errors, 0.99),
"free_flight_visual_correction_p95": _percentile_from(_free_flight_visual_correction_errors, 0.95),
"free_flight_visual_correction_p99": _percentile_from(_free_flight_visual_correction_errors, 0.99),
"hard_snap_count": _hard_snap_count,
"hard_snap_rate_per_min": _hard_snap_rate_per_min(),
"hard_snap_reasons": _hard_snap_reasons.duplicate(),
"hard_snap_cohorts": _hard_snap_cohorts.duplicate(),
"cohorts": _cohort_counts.duplicate(),
"position_threshold": hard_position_error,
"rotation_threshold_degrees": hard_rotation_error_degrees,
"max_visual_offset": max_visual_offset,
}
func clear_metrics() -> void:
_position_errors.clear()
_rotation_errors.clear()
_free_flight_position_errors.clear()
_free_flight_rotation_errors.clear()
_visual_correction_errors.clear()
_free_flight_visual_correction_errors.clear()
_hard_snap_count = 0
_decision_count = 0
_resync_until_seq = -1
_metrics_started_ms = -1
_hard_snap_reasons.clear()
_hard_snap_cohorts = {"free_flight": 0, "contact": 0, "reset": 0, "resync": 0, "unsimulated": 0}
_cohort_counts = {"free_flight": 0, "contact": 0, "reset": 0, "resync": 0, "unsimulated": 0}
func _record_metrics(comparison: Dictionary, decision: Dictionary) -> void:
if _metrics_started_ms < 0:
_metrics_started_ms = Time.get_ticks_msec()
_decision_count += 1
var cohort := _cohort_for(comparison, decision)
if decision["mode"] == "hard":
_hard_snap_count += 1
var reason := str(decision.get("reason", "unknown"))
_hard_snap_reasons[reason] = int(_hard_snap_reasons.get(reason, 0)) + 1
_hard_snap_cohorts[cohort] = int(_hard_snap_cohorts.get(cohort, 0)) + 1
_cohort_counts[cohort] = int(_cohort_counts.get(cohort, 0)) + 1
if comparison.get("status", "") == "matched":
# Only same-sequence predictions are quality samples. Recovery events
# still count in their own cohorts/reason ledger, but must not distort
# p95/p99 with an error that cannot honestly be measured.
_position_errors.append(float(comparison["position_error_magnitude"]))
_rotation_errors.append(float(comparison.get("rotation_error_degrees", 0.0)))
if cohort == "free_flight":
_free_flight_position_errors.append(float(comparison["position_error_magnitude"]))
_free_flight_rotation_errors.append(float(comparison.get("rotation_error_degrees", 0.0)))
# The visual offset hides at most max_visual_offset of a soft correction.
# Record the exposed remainder, never the capped hidden component; hard
# corrections are independently gated by their cohort count above.
var visual_error := maxf(0.0, float(comparison.get("position_error_magnitude", 0.0)) - max_visual_offset) if decision["mode"] == "soft" else 0.0
_visual_correction_errors.append(visual_error)
if cohort == "free_flight":
_free_flight_visual_correction_errors.append(visual_error)
if _position_errors.size() > METRIC_SAMPLE_CAPACITY:
_position_errors.pop_front()
if _rotation_errors.size() > METRIC_SAMPLE_CAPACITY:
_rotation_errors.pop_front()
if _free_flight_position_errors.size() > METRIC_SAMPLE_CAPACITY:
_free_flight_position_errors.pop_front()
if _free_flight_rotation_errors.size() > METRIC_SAMPLE_CAPACITY:
_free_flight_rotation_errors.pop_front()
if _visual_correction_errors.size() > METRIC_SAMPLE_CAPACITY:
_visual_correction_errors.pop_front()
if _free_flight_visual_correction_errors.size() > METRIC_SAMPLE_CAPACITY:
_free_flight_visual_correction_errors.pop_front()
func _cohort_for(comparison: Dictionary, decision: Dictionary) -> String:
if decision.get("reason", "") == "reset_gen":
return "reset"
if decision.get("reason", "") == "unsimulated_gap":
# Its own cohort, not free_flight: these carry no error sample, and
# folding them into a quality cohort would silently inflate its count
# with rows that contributed no measurement.
return "unsimulated"
if decision.get("reason", "").begins_with("missing") or _resync_until_seq >= 0:
return "resync"
if comparison.get("contact_window", false):
return "contact"
return "free_flight"
func _hard_snap_rate_per_min() -> float:
if _metrics_started_ms < 0:
return 0.0
var elapsed_seconds := maxf(float(Time.get_ticks_msec() - _metrics_started_ms) / 1000.0, 0.001)
return float(_hard_snap_count) * 60.0 / elapsed_seconds
func _percentile(fraction: float) -> float:
return _percentile_from(_position_errors, fraction)
func _percentile_from(samples: Array[float], fraction: float) -> float:
if samples.is_empty():
return 0.0
var sorted := samples.duplicate()
sorted.sort()
var index := clampi(roundi((sorted.size() - 1) * fraction), 0, sorted.size() - 1)
return sorted[index]
func _rotation_percentile(fraction: float) -> float:
return _percentile_from(_rotation_errors, fraction)