mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
b43ad207c1
multiplayer-next.md was a 1662-line mix of standing architecture spec and task-completion tracking, most of which was dense per-task DONE evidence for finished Phases 0-6. Split it: - MULTIPLAYER_SPEC.md (new): the locked architecture decisions, wire format, server-side input handling, prediction/reconciliation, latency/frame-rate budget, and match lifecycle state machine - standing design reference, not task-tracked. - multiplayer-next.md (trimmed 1662 -> ~370 lines): only outstanding work remains - §0 status, §7 Phase 7/8 task tables condensed to "what's left" per task, §8-11 reference material (refactoring notes, gotchas, testing, flagged items). Phases 0-6 collapsed to a pointer at git history instead of ~500 lines of DONE evidence. Also: - Repointed every `multiplayer-next.md §N` code comment (N 1-6) across Game/scripts, Game/tools and Game/tests to MULTIPLAYER_SPEC.md, since those sections moved. Task-number references (`task N.N`, §7-11) correctly still point at multiplayer-next.md. - Updated CLAUDE.md's doc index and docs/TECH_STACK.md's spec-section citations to match. - TODO.md: added a "what's left to actually finish multiplayer (human-actionable)" checklist pulled from multiplayer-next.md §0 and docs/MATCHMAKING.md - things that need a person (hardware, a design decision, a Steam App ID, hands on a controller), not more agent code.
279 lines
14 KiB
GDScript
279 lines
14 KiB
GDScript
extends RefCounted
|
|
|
|
# Local-ship reconciliation policy (MULTIPLAYER_SPEC.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)
|