mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-13 11:52:03 +00:00
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.
This commit is contained in:
@@ -8,6 +8,9 @@ extends GameMode
|
||||
# everything, including its own ship, from the interpolation buffer; there
|
||||
# is no local prediction yet (that's Phase 4), so every body on the client
|
||||
# is FREEZE_MODE_KINEMATIC and driven entirely by incoming snapshots.
|
||||
# Tasks 4.1/4.2 add the seq-tagged recording and comparison plumbing that
|
||||
# Phase 4 will need (LocalPredictionHistory below), but deliberately stop
|
||||
# short of unfreezing or locally simulating anything — that is task 4.3.
|
||||
#
|
||||
# No HUD/Arena child in networked_match.tscn — both are built in code, once
|
||||
# the arena is actually known (the server picks one; the client learns it
|
||||
@@ -29,6 +32,7 @@ const NetBodyState = preload("res://scripts/net_body_state.gd")
|
||||
const NetInterpolator = preload("res://scripts/net_interpolator.gd")
|
||||
const InputJitterBuffer = preload("res://scripts/input_jitter_buffer.gd")
|
||||
const InputLeadController = preload("res://scripts/input_lead_controller.gd")
|
||||
const LocalPredictionHistory = preload("res://scripts/local_prediction_history.gd")
|
||||
const HUD_SCENE = preload("res://scenes/HUD.tscn")
|
||||
|
||||
# Minimum plausible interpolation delay even on a same-machine/LAN link —
|
||||
@@ -101,6 +105,20 @@ var _input_seq := 0 # client only
|
||||
# 3-packet burst loss still recovers every tick's action via a later
|
||||
# packet's history. Client only.
|
||||
var _input_history: Array[ShipAction] = []
|
||||
var _local_prediction_history := LocalPredictionHistory.new() # client only; 128-entry seq-tagged history (§4.3)
|
||||
# Latest raw result from LocalPredictionHistory.compare_authoritative(). This
|
||||
# pass records and compares only; Phase 4.3 will consume it to choose and
|
||||
# apply the actual reconciliation correction.
|
||||
#
|
||||
# Read its error fields with the caveat documented on
|
||||
# _local_ship_prediction_state(): until task 4.3 unfreezes and locally
|
||||
# simulates the local ship, the "predicted" side of every comparison is an
|
||||
# interpolated past-snapshot pose, not a forward simulation. The
|
||||
# position_error / rotation_error_radians / *_velocity_error numbers
|
||||
# therefore measure interpolation-vs-authoritative drift, and are NOT
|
||||
# prediction error. Expect them to be small and largely uninformative, and
|
||||
# do not calibrate any snap/blend threshold against them yet.
|
||||
var _last_local_prediction_comparison: Dictionary = {}
|
||||
var _last_received_snapshot_tick := 0 # client only: echoed back as ack_snapshot_tick
|
||||
var _input_lead_controller := InputLeadController.new() # client only (§3.3)
|
||||
var _last_known_input_buffer_depth := -1 # client only: -1 = no snapshot with this field yet
|
||||
@@ -478,6 +496,12 @@ func _send_local_input() -> void:
|
||||
# one tick of latency recovered).
|
||||
var delta := _input_lead_controller.update(_last_known_input_buffer_depth)
|
||||
_input_seq += delta
|
||||
# Record this tick's (seq, action, local-ship state) triple. action is
|
||||
# sampled exactly once above; record() makes its own copy for the
|
||||
# longer-lived prediction history. See _local_ship_prediction_state() for
|
||||
# what the "state" half does and does not currently mean.
|
||||
if _my_slot != null and is_instance_valid(_my_slot.ship):
|
||||
_local_prediction_history.record(_input_seq, action, _local_ship_prediction_state(_my_slot.ship, action))
|
||||
# Redundancy (§3.1): carry the last MAX_REDUNDANCY ticks' actions,
|
||||
# newest-first, so a burst of up to (MAX_REDUNDANCY - 1) consecutive
|
||||
# packet losses still lets the server recover every dropped tick's
|
||||
@@ -535,6 +559,15 @@ func _on_snapshot_received(decoded: Dictionary) -> void:
|
||||
# own slot's server-side InputJitterBuffer.depth() at send time, which
|
||||
# is exactly what the input_lead control loop (§3.3) needs.
|
||||
_last_known_input_buffer_depth = decoded["input_buffer_depth"]
|
||||
# Compare the server state for this client's own fixed slot against the
|
||||
# entry tagged with the exact input sequence the server applied. Do not
|
||||
# correct the body here yet: this result is intentionally inspection data
|
||||
# for the later snap/blend pass, and (per
|
||||
# _local_ship_prediction_state()) is not yet true prediction error.
|
||||
if _my_slot != null:
|
||||
var my_index := _slots.find(_my_slot)
|
||||
if my_index >= 0 and my_index < bodies.size():
|
||||
_last_local_prediction_comparison = _local_prediction_history.compare_authoritative(decoded["last_input_seq"], bodies[my_index])
|
||||
_update_tick_bias(server_tick)
|
||||
for i in _slots.size():
|
||||
if i < bodies.size():
|
||||
@@ -550,6 +583,69 @@ func _on_snapshot_received(decoded: Dictionary) -> void:
|
||||
_ball_interpolator.add_sample(server_tick, ball_state, reset_gen)
|
||||
|
||||
|
||||
# NOT a prediction yet, despite the name — the name is for task 4.3, which
|
||||
# is what will make it true. Pre-4.3 EVERY ship on the client, including this
|
||||
# client's own, is freeze = true / FREEZE_MODE_KINEMATIC (see _apply_match_config,
|
||||
# which sets that uniformly with no exception for _my_slot) and is moved only
|
||||
# by NetInterpolator transform writes derived from ALREADY-RECEIVED, past
|
||||
# server snapshots. Nothing locally simulates the local ship, and nothing ever
|
||||
# writes linear_velocity/angular_velocity onto it.
|
||||
#
|
||||
# So what this samples is "wherever the interpolator had smoothed the ship to
|
||||
# at packet-send time", NOT "where the action sampled this tick will put the
|
||||
# ship". The consequences for anyone reading the comparison output:
|
||||
# - linear_velocity/angular_velocity here are NOT zero — a first pass at
|
||||
# this comment claimed they were, but FREEZE_MODE_KINEMATIC derives a
|
||||
# body's velocity from its own consecutive transform writes, so these
|
||||
# fields genuinely reflect the interpolator's implied motion (confirmed
|
||||
# live: non-zero, direction-correct velocities while driving). What they
|
||||
# are NOT is the result of locally simulating the sampled action's
|
||||
# thrust/rotation through the ship's own force formulas.
|
||||
# - the resulting position_error / rotation_error_radians measure how far
|
||||
# an interpolated PAST pose (and its implied velocity) sits from the
|
||||
# later-arriving authoritative pose for that sequence. That is
|
||||
# interpolation lag, not prediction error, and on a clean link it will
|
||||
# read small and largely uninformative.
|
||||
# - do not calibrate a snap-vs-blend threshold, or benchmark "prediction
|
||||
# quality", against these numbers.
|
||||
# They only become genuine prediction error once task 4.3's net_ship_predictor.gd
|
||||
# unfreezes the local ship and steps it forward locally (multiplayer-todo.md
|
||||
# §4 / §7 tasks 4.3 and 4.5). The recording/matching plumbing is landed first,
|
||||
# on purpose, so 4.3 has a tested ring to build on.
|
||||
func _local_ship_prediction_state(ship: Ship, action: ShipAction) -> NetBodyState:
|
||||
var state := NetBodyState.new()
|
||||
state.position = ship.global_position
|
||||
state.rotation = ship.global_transform.basis.get_rotation_quaternion()
|
||||
state.linear_velocity = ship.linear_velocity
|
||||
state.angular_velocity = ship.angular_velocity
|
||||
state.frozen = ship.freeze
|
||||
state.turbo = action.turbo
|
||||
state.thrust_z = action.thrust.z
|
||||
state.avel_range = NetCodec.SHIP_AVEL_RANGE
|
||||
return state
|
||||
|
||||
|
||||
# Diagnostic accessor. Same caveat as _local_ship_prediction_state(): the
|
||||
# error fields are interpolation-vs-authoritative drift, not prediction error,
|
||||
# until task 4.3 lands.
|
||||
#
|
||||
# Dictionary.duplicate(true) recurses into Arrays/Dictionaries but copies
|
||||
# Objects (RefCounted included) BY REFERENCE — an adversarial review caught
|
||||
# that this returned a dict sharing its "action"/"predicted_state"/
|
||||
# "authoritative_state" ShipAction/NetBodyState instances with the stored
|
||||
# comparison, so a caller writing through the "copy" silently rewrote
|
||||
# history. ShipAction.copy() and NetBodyState.copy() exist precisely so
|
||||
# callers holding onto one past its own tick copy it (see ship_action.gd's
|
||||
# own comment) — this accessor has to honor that contract itself, not just
|
||||
# assume duplicate(true) does.
|
||||
func get_last_local_prediction_comparison() -> Dictionary:
|
||||
var result := _last_local_prediction_comparison.duplicate(true)
|
||||
for key in ["action", "predicted_state", "authoritative_state"]:
|
||||
if result.has(key):
|
||||
result[key] = result[key].copy()
|
||||
return result
|
||||
|
||||
|
||||
# See the class-level comment above _tick_bias_samples for why this exists.
|
||||
# bias_ms is how much further ahead to_tick(server_time_est) lands than the
|
||||
# server_tick this snapshot actually carries — mostly the server's own
|
||||
|
||||
Reference in New Issue
Block a user