Files
CosmicClash/Game/scripts/local_prediction_history.gd
T
Josh Creek 3d3024ae8a feat(multiplayer): Phase 4 tasks 4.1/4.2 - local prediction history ring
Adds LocalPredictionHistory, a client-owned seq-tagged ring recording
predicted ship state per input sequence, plus wiring in
NetworkedMatch to record predictions on send and compare them against
authoritative snapshots on arrival. Ships stay frozen/interpolated
until 4.3 lands actual correction logic; this round only builds the
comparison machinery and its data.

Includes fixes from two review rounds: resync_required now
self-clears once acknowledgements catch back up (mirrors
InputJitterBuffer's stalled flag), NetBodyState gained a copy()
method to stop diagnostic accessors aliasing ring-owned state, and
corrected comments that had described the local ship as being
force-simulated pre-4.3 when it is still driven by interpolated
transform writes.
2026-08-20 19:29:13 +01:00

174 lines
7.6 KiB
GDScript

class_name LocalPredictionHistory
extends RefCounted
const NetBodyState = preload("res://scripts/net_body_state.gd")
# Client-owned local-ship prediction history (multiplayer-todo.md §4.3).
# This is deliberately independent of NetworkedMatch and the scene tree so
# sequence/ring behaviour can be tested from scripted traces. Each entry is
# tagged with its full sequence number: an old value in a wrapped slot is
# never accepted as a prediction for a newer sequence.
#
# Acknowledge and record are separate producer/consumer clocks. The input
# sender can continue producing while snapshots stop arriving, so record()
# explicitly marks overflow once more than RING_SIZE unacknowledged sequence
# positions exist. It still retains the newest representable window, but
# callers can see that an authoritative resync is required instead of
# mistaking a wrapped overwrite for a valid comparison.
#
# resync_required is a live condition, NOT a latch: compare_authoritative()
# clears it again once acknowledgements have genuinely caught back up (see
# that method). This mirrors input_jitter_buffer.gd's `stalled`, which
# likewise drops back to false the moment a normal tick is consumed again.
# A latched flag would mean one transient ~2s stall anywhere in a match
# permanently pinned every later tick into "needs a hard resync", which is
# exactly the behaviour soft correction exists to avoid — and it would also
# cap overflow_count at 1 forever, since a second episode could never
# observe the flag going false again.
#
# Two things a "matched" result does NOT guarantee, flagged for whoever
# builds task 4.3's actual correction logic on top of this:
#
# 1. A "matched" result can still be reporting stale data. The slot-tag
# equality check in get_prediction() guarantees a match's payload
# genuinely belongs to the queried seq (never wrong-seq data mislabeled
# as right), but nothing in the "matched" status itself says HOW OLD
# that entry is. Under sparse recording (record() is not called with
# strictly consecutive seqs — see the record() comment below), an entry
# from well over RING_SIZE ticks ago can still report "matched" for a
# query landing on its untouched residue. resync_required correctly
# stays true in that case (the span guard below is exact), but the
# comparison payload itself carries no matched_stale/age distinction. A
# caller wanting to reject "matched but ancient" needs to separately
# check newest_recorded_seq - seq itself.
#
# 2. record() can be called twice for the same seq with a DIFFERENT action,
# when input_lead_controller's release path resends a duplicated seq
# (delta == 0) — the later call silently overwrites the ring slot, so
# the stored action becomes whichever of the two calls happened last.
# This matches what the WIRE ends up sending for that seq (the resend
# replaces the redundancy history's front entry — see
# networked_match.gd's _send_local_input), but if the SERVER had
# already consumed the seq from the first packet before the resend
# arrived, the server's applied action and this ring's stored action for
# that same seq can disagree. Narrow (release only fires after 120 ticks
# of sustained surplus depth, when the server is least likely to be
# right on the edge of consuming that exact seq) but real; a future
# replay-based catch-up (task 4.5) built on this history should not
# assume the stored action is provably what the server actually applied.
const RING_SIZE := 128
var _ring_seq: PackedInt32Array = PackedInt32Array()
var _ring_entry: Array = []
var _has_recorded := false
var newest_recorded_seq := -1
var last_acknowledged_seq := 0
var overflow_count := 0
var resync_required := false
func _init() -> void:
_ring_seq.resize(RING_SIZE)
_ring_entry.resize(RING_SIZE)
for i in RING_SIZE:
_ring_seq[i] = -1
# Stores a private copy of both action and state. Returns true when this
# record crossed the unacknowledged-capacity boundary; the caller does not
# need that return today, but it makes the eviction event observable rather
# than silent when reconciliation starts applying corrections in Phase 4.3.
func record(seq: int, action: ShipAction, state: NetBodyState) -> bool:
var overflowed_now := false
if not _has_recorded or seq > newest_recorded_seq:
if seq - last_acknowledged_seq > RING_SIZE:
# Only the LEADING edge of an episode counts: resync_required is
# still true for every subsequent tick of the same stall, and
# counting those would report one outage as hundreds. Because
# compare_authoritative() can now clear the flag, a genuinely
# separate later episode does increment this again.
overflowed_now = not resync_required
resync_required = true
if overflowed_now:
overflow_count += 1
newest_recorded_seq = seq
_has_recorded = true
var idx := posmod(seq, RING_SIZE)
_ring_seq[idx] = seq
_ring_entry[idx] = {
"action": action.copy(),
"state": state.copy(),
}
return overflowed_now
# Returns independent copies so diagnostic/reconciliation consumers cannot
# mutate a retained prediction by accident.
func get_prediction(seq: int) -> Dictionary:
var idx := posmod(seq, RING_SIZE)
if _ring_seq[idx] != seq:
return {}
var entry: Dictionary = _ring_entry[idx]
return {
"seq": seq,
"action": (entry["action"] as ShipAction).copy(),
"state": (entry["state"] as NetBodyState).copy(),
}
# Produces comparison data only. Applying a snap, teleport, velocity delta,
# or visual offset belongs to later Phase 4 tasks.
func compare_authoritative(seq: int, authoritative: NetBodyState) -> Dictionary:
if seq > last_acknowledged_seq:
last_acknowledged_seq = seq
var prediction := get_prediction(seq)
if prediction.is_empty():
return {
"status": _missing_status(seq),
"seq": seq,
"authoritative_state": authoritative.copy(),
}
# A successful match is the only evidence that the acknowledgement clock
# has genuinely caught back up, so it is the only thing allowed to clear
# resync_required — a "missing_evicted"/"missing_not_recorded" result
# proves the opposite, and must leave the flag alone.
#
# The extra span check is not redundant. record() is not guaranteed to be
# called with consecutive sequences: input_lead_controller.update() can
# return 0 or up to 1+3, so the client's seq can skip forward, leaving a
# ring slot holding a tag OLDER than newest_recorded_seq - RING_SIZE
# (its residue was simply never rewritten). get_prediction() would still
# report that as "matched", so matching alone does not imply the
# outstanding window is back within capacity. Gate on the exact inverse
# of record()'s own trip inequality instead, which holds regardless of
# how sparsely sequences were recorded.
if newest_recorded_seq - last_acknowledged_seq <= RING_SIZE:
resync_required = false
var predicted_state: NetBodyState = prediction["state"]
var position_error := authoritative.position - predicted_state.position
var rotation_error_radians := predicted_state.rotation.angle_to(authoritative.rotation)
return {
"status": "matched",
"seq": seq,
"action": prediction["action"],
"predicted_state": predicted_state,
"authoritative_state": authoritative.copy(),
"position_error": position_error,
"position_error_magnitude": position_error.length(),
"rotation_error_radians": rotation_error_radians,
"rotation_error_degrees": rad_to_deg(rotation_error_radians),
"linear_velocity_error": authoritative.linear_velocity - predicted_state.linear_velocity,
"angular_velocity_error": authoritative.angular_velocity - predicted_state.angular_velocity,
}
func _missing_status(seq: int) -> String:
if _has_recorded and seq <= newest_recorded_seq - RING_SIZE:
return "missing_evicted"
return "missing_not_recorded"