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.
306 lines
13 KiB
GDScript
306 lines
13 KiB
GDScript
class_name LocalPredictionHistory
|
|
extends RefCounted
|
|
|
|
const NetBodyState = preload("res://scripts/net_body_state.gd")
|
|
|
|
# Client-owned local-ship prediction history (MULTIPLAYER_SPEC.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. HISTORICAL, now fixed — kept because the reasoning still constrains
|
|
# callers. record() used to be called twice for the same seq with a
|
|
# DIFFERENT action on the release path (delta == 0), the later call
|
|
# silently overwriting the slot. That was wrong, not merely imprecise:
|
|
# LocalInputTimeline.issue() deliberately does NOT mutate _actions[seq]
|
|
# for an already-issued sequence ("may be in flight or consumed"), so
|
|
# the overwrite made this ring contradict the wire — it claimed an
|
|
# action for S that was never sent for S. networked_match.gd now skips
|
|
# recording entirely on a release tick, leaving the original (correct)
|
|
# predicted[S] in place. Callers must keep it that way: an already-
|
|
# recorded sequence's ACTION is immutable here, exactly as it is in the
|
|
# timeline. Only overwrite_state()/rebase_state_range() may revise an
|
|
# entry, and only its state.
|
|
#
|
|
# 3. A sequence can be ISSUED without ever being locally SIMULATED. The
|
|
# input_lead controller's attack path (delta > 1) skips sequence numbers
|
|
# to buy server-side buffer margin: those gap sequences are filled with
|
|
# repeat-last actions and sent, but the client took exactly ONE physics
|
|
# step that tick, so no post-step state exists for them. They are
|
|
# recorded via record_unsimulated() and report "unsimulated_gap" rather
|
|
# than "missing_not_recorded" — a routine consequence of this client's
|
|
# own lead control, NOT evidence of history loss, and specifically not a
|
|
# hard-snap condition. Distinguishing them matters: treating them as
|
|
# missing history teleported the ship and armed resync suppression
|
|
# several times a minute during ordinary play.
|
|
|
|
const RING_SIZE := 128
|
|
|
|
var _ring_seq: PackedInt32Array = PackedInt32Array()
|
|
var _ring_entry: Array = []
|
|
var _has_recorded := false
|
|
var _first_recorded_seq := -1
|
|
|
|
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
|
|
|
|
|
|
# A reset starts a new authoritative epoch. Retained inputs/states describe
|
|
# the old world and must never be compared to the new kickoff state.
|
|
func begin_epoch() -> void:
|
|
for i in RING_SIZE:
|
|
_ring_seq[i] = -1
|
|
_ring_entry[i] = null
|
|
_has_recorded = false
|
|
_first_recorded_seq = -1
|
|
newest_recorded_seq = -1
|
|
last_acknowledged_seq = 0
|
|
resync_required = false
|
|
|
|
|
|
# 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, contact_window: bool = false) -> bool:
|
|
var overflowed_now := false
|
|
if not _has_recorded or seq > newest_recorded_seq:
|
|
if not _has_recorded:
|
|
_first_recorded_seq = 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(),
|
|
"contact_window": contact_window,
|
|
"unsimulated": false,
|
|
}
|
|
return overflowed_now
|
|
|
|
|
|
# Records a sequence that was issued and sent but never locally simulated —
|
|
# an attack's skipped sequence numbers (see note 3 in this file's header).
|
|
# It advances the same newest/overflow bookkeeping record() does, because the
|
|
# sequence genuinely is outstanding and the server will genuinely acknowledge
|
|
# it; only the post-step state is absent, because the client never computed
|
|
# one. Deliberately carries the action anyway: it is what went on the wire, so
|
|
# a caller diagnosing an acknowledgement still has the honest command, and
|
|
# nothing here has to invent a state to keep the ring dense.
|
|
func record_unsimulated(seq: int, action: ShipAction) -> bool:
|
|
var overflowed_now := false
|
|
if not _has_recorded or seq > newest_recorded_seq:
|
|
if not _has_recorded:
|
|
_first_recorded_seq = seq
|
|
if seq - last_acknowledged_seq > RING_SIZE:
|
|
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": null,
|
|
"contact_window": false,
|
|
"unsimulated": true,
|
|
}
|
|
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]
|
|
if bool(entry.get("unsimulated", false)):
|
|
# No state to hand back — see note 3. Callers must check this flag
|
|
# before touching "state"; it is null, not a zeroed NetBodyState,
|
|
# specifically so a caller that forgets fails loudly instead of
|
|
# silently comparing against the origin.
|
|
return {
|
|
"seq": seq,
|
|
"action": (entry["action"] as ShipAction).copy(),
|
|
"state": null,
|
|
"contact_window": false,
|
|
"unsimulated": true,
|
|
}
|
|
return {
|
|
"seq": seq,
|
|
"action": (entry["action"] as ShipAction).copy(),
|
|
"state": (entry["state"] as NetBodyState).copy(),
|
|
"contact_window": bool(entry.get("contact_window", false)),
|
|
"unsimulated": false,
|
|
}
|
|
|
|
|
|
# Reconciliation changes the state paired with already-sent input, never the
|
|
# input itself. This is deliberately a no-op for an absent/skipped sequence:
|
|
# input-lead control permits sparse sequence numbers, so there is no honest
|
|
# action to invent for such a slot.
|
|
func overwrite_state(seq: int, state: NetBodyState) -> bool:
|
|
var idx := posmod(seq, RING_SIZE)
|
|
if _ring_seq[idx] != seq:
|
|
return false
|
|
var entry: Dictionary = _ring_entry[idx]
|
|
if bool(entry.get("unsimulated", false)):
|
|
# Writing a state here would manufacture a local prediction for a
|
|
# sequence this client never simulated, which is exactly the fabricated
|
|
# history §4.4 forbids. The slot stays stateless.
|
|
return false
|
|
entry["state"] = state.copy()
|
|
return true
|
|
|
|
|
|
func overwrite_state_range(from_seq: int, to_seq: int, state: NetBodyState) -> void:
|
|
for seq in range(from_seq, to_seq + 1):
|
|
overwrite_state(seq, state)
|
|
|
|
|
|
# Carries an authoritative same-sequence correction through the retained
|
|
# future. This is intentionally a transport operation, not a synthetic
|
|
# physics replay: the live Jolt body has already advanced through the real
|
|
# contact world, and a soft correction must not leave its later comparisons
|
|
# describing the old trajectory.
|
|
func rebase_state_range(from_seq: int, to_seq: int, position_delta: Vector3, rotation_delta: Quaternion, linear_velocity_delta: Vector3, angular_velocity_delta: Vector3) -> void:
|
|
for seq in range(from_seq, to_seq + 1):
|
|
var prediction := get_prediction(seq)
|
|
if prediction.is_empty() or bool(prediction.get("unsimulated", false)):
|
|
continue
|
|
var state: NetBodyState = prediction["state"]
|
|
state.position += position_delta
|
|
state.rotation = (rotation_delta * state.rotation).normalized()
|
|
state.linear_velocity += linear_velocity_delta
|
|
state.angular_velocity += angular_velocity_delta
|
|
overwrite_state(seq, state)
|
|
|
|
|
|
# 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
|
|
|
|
if bool(prediction.get("unsimulated", false)):
|
|
# Reaching this sequence at all proves the acknowledgement clock is
|
|
# healthy — the entry is present and correctly tagged — so the
|
|
# resync_required clear above still applies. There is simply nothing
|
|
# to compare, because the client never simulated this sequence.
|
|
return {
|
|
"status": "unsimulated_gap",
|
|
"seq": seq,
|
|
"action": prediction["action"],
|
|
"authoritative_state": authoritative.copy(),
|
|
}
|
|
|
|
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,
|
|
"contact_window": bool(prediction.get("contact_window", false)),
|
|
}
|
|
|
|
|
|
func _missing_status(seq: int) -> String:
|
|
# The server starts its acknowledgement clock at sequence 0, while the
|
|
# first local post-step prediction is normally sequence 1. This is a normal
|
|
# startup boundary, not a lost ring entry and must not trigger a hard snap.
|
|
if not _has_recorded or seq < _first_recorded_seq:
|
|
return "warmup_not_recorded"
|
|
if _has_recorded and seq <= newest_recorded_seq - RING_SIZE:
|
|
return "missing_evicted"
|
|
return "missing_not_recorded"
|