mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-14 09:42:02 +00:00
3d3024ae8a
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.
162 lines
8.5 KiB
GDScript
162 lines
8.5 KiB
GDScript
extends "res://tests/test_case.gd"
|
|
|
|
const LocalPredictionHistory = preload("res://scripts/local_prediction_history.gd")
|
|
const ShipAction = preload("res://scripts/ship_action.gd")
|
|
const NetBodyState = preload("res://scripts/net_body_state.gd")
|
|
|
|
|
|
func _action(value: float) -> ShipAction:
|
|
var action := ShipAction.new()
|
|
action.thrust = Vector3(0.0, 0.0, value)
|
|
action.rotation = Vector3(value, 0.0, 0.0)
|
|
action.turbo = value > 0.5
|
|
return action
|
|
|
|
|
|
func _state(value: float) -> NetBodyState:
|
|
var state := NetBodyState.new()
|
|
state.position = Vector3(value, value + 1.0, value + 2.0)
|
|
state.rotation = Quaternion(Vector3.UP, value * 0.1)
|
|
state.linear_velocity = Vector3(value * 2.0, 0.0, 0.0)
|
|
state.angular_velocity = Vector3(0.0, value * 3.0, 0.0)
|
|
return state
|
|
|
|
|
|
func test_matches_authoritative_state_at_the_same_sequence() -> void:
|
|
var history := LocalPredictionHistory.new()
|
|
var predicted := _state(2.0)
|
|
history.record(17, _action(0.4), predicted)
|
|
var authoritative := _state(2.0)
|
|
authoritative.position += Vector3(1.0, -2.0, 0.5)
|
|
authoritative.linear_velocity += Vector3(0.25, 0.0, 0.0)
|
|
|
|
var result := history.compare_authoritative(17, authoritative)
|
|
assert_eq(result["status"], "matched", "same tagged sequence matches")
|
|
assert_almost_eq((result["position_error"] as Vector3).x, 1.0, 0.0001, "position delta x")
|
|
assert_almost_eq((result["position_error"] as Vector3).y, -2.0, 0.0001, "position delta y")
|
|
assert_almost_eq(result["position_error_magnitude"], 2.2912878, 0.0001, "position error magnitude")
|
|
assert_almost_eq((result["linear_velocity_error"] as Vector3).x, 0.25, 0.0001, "linear velocity delta")
|
|
assert_eq(history.last_acknowledged_seq, 17, "comparison advances acknowledgement epoch")
|
|
|
|
|
|
func test_record_and_lookup_do_not_alias_action_or_state() -> void:
|
|
var history := LocalPredictionHistory.new()
|
|
var action := _action(0.25)
|
|
var state := _state(3.0)
|
|
history.record(4, action, state)
|
|
action.thrust.z = 9.0
|
|
state.position.x = 99.0
|
|
|
|
var prediction := history.get_prediction(4)
|
|
assert_almost_eq((prediction["action"] as ShipAction).thrust.z, 0.25, 0.0001, "stored action is copied")
|
|
assert_almost_eq((prediction["state"] as NetBodyState).position.x, 3.0, 0.0001, "stored state is copied")
|
|
(prediction["action"] as ShipAction).thrust.z = -5.0
|
|
(prediction["state"] as NetBodyState).position.x = -5.0
|
|
var second_lookup := history.get_prediction(4)
|
|
assert_almost_eq((second_lookup["action"] as ShipAction).thrust.z, 0.25, 0.0001, "lookup action cannot mutate ring")
|
|
assert_almost_eq((second_lookup["state"] as NetBodyState).position.x, 3.0, 0.0001, "lookup state cannot mutate ring")
|
|
|
|
|
|
func test_slot_tags_reject_wrapped_stale_predictions() -> void:
|
|
var history := LocalPredictionHistory.new()
|
|
history.record(1, _action(0.1), _state(1.0))
|
|
history.record(1 + LocalPredictionHistory.RING_SIZE, _action(0.8), _state(8.0))
|
|
|
|
assert_true(history.get_prediction(1).is_empty(), "old same-index entry is not mistaken for current data")
|
|
assert_eq(history.compare_authoritative(1, _state(1.0))["status"], "missing_evicted", "stale acknowledgement is explicitly evicted")
|
|
assert_eq(history.compare_authoritative(1 + LocalPredictionHistory.RING_SIZE, _state(8.0))["status"], "matched", "current same-index entry still matches")
|
|
|
|
|
|
func test_unacknowledged_overflow_is_explicit_and_keeps_newest_window() -> void:
|
|
var history := LocalPredictionHistory.new()
|
|
for seq in range(1, LocalPredictionHistory.RING_SIZE + 2):
|
|
history.record(seq, _action(float(seq)), _state(float(seq)))
|
|
|
|
assert_true(history.resync_required, "producer outrunning acknowledgements sets explicit resync state")
|
|
assert_eq(history.overflow_count, 1, "one continuous overflow episode is counted once")
|
|
assert_eq(history.compare_authoritative(1, _state(1.0))["status"], "missing_evicted", "oldest unacknowledged prediction was explicitly evicted")
|
|
var newest := history.compare_authoritative(LocalPredictionHistory.RING_SIZE + 1, _state(float(LocalPredictionHistory.RING_SIZE + 1)))
|
|
assert_eq(newest["status"], "matched", "newest prediction remains usable after overflow")
|
|
|
|
|
|
# Records seq..seq+n until the unacknowledged window trips overflow, and
|
|
# returns the newest sequence recorded. Mirrors the real producer, which
|
|
# calls record() once per client physics tick with no acknowledgements
|
|
# arriving during a stall.
|
|
func _stall_until_overflow(history: LocalPredictionHistory, from_seq: int) -> int:
|
|
# Hard-bounded rather than `while not history.resync_required`: assert_true
|
|
# only records a failure, it cannot abort, so an unbounded loop here would
|
|
# hang the whole headless runner instead of failing if the trip condition
|
|
# ever regressed.
|
|
var newest := from_seq - 1
|
|
for seq in range(from_seq, from_seq + 4 * LocalPredictionHistory.RING_SIZE):
|
|
if history.resync_required:
|
|
break
|
|
history.record(seq, _action(float(seq)), _state(float(seq)))
|
|
newest = seq
|
|
assert_true(history.resync_required, "overflow must trip within a bounded number of ticks")
|
|
return newest
|
|
|
|
|
|
func test_resync_required_clears_once_acknowledgements_catch_back_up() -> void:
|
|
var history := LocalPredictionHistory.new()
|
|
var newest := _stall_until_overflow(history, 1)
|
|
assert_true(history.resync_required, "transient stall trips explicit resync state")
|
|
assert_eq(history.overflow_count, 1, "first episode counted once")
|
|
|
|
# An acknowledgement that lands on an already-evicted sequence is not
|
|
# evidence of recovery and must leave the flag alone.
|
|
assert_eq(history.compare_authoritative(1, _state(1.0))["status"], "missing_evicted", "oldest sequence is gone")
|
|
assert_true(history.resync_required, "an evicted comparison does not count as catching up")
|
|
|
|
# A real, current, matched acknowledgement does.
|
|
var recovered := history.compare_authoritative(newest, _state(float(newest)))
|
|
assert_eq(recovered["status"], "matched", "newest prediction still matches")
|
|
assert_true(not history.resync_required, "resync state clears once acknowledgements are flowing again")
|
|
assert_eq(history.overflow_count, 1, "recovery does not retroactively change the episode count")
|
|
|
|
|
|
func test_second_distinct_overflow_episode_is_counted_separately() -> void:
|
|
var history := LocalPredictionHistory.new()
|
|
var first_newest := _stall_until_overflow(history, 1)
|
|
history.compare_authoritative(first_newest, _state(float(first_newest)))
|
|
assert_true(not history.resync_required, "first episode recovered")
|
|
assert_eq(history.overflow_count, 1, "first episode counted")
|
|
|
|
var second_newest := _stall_until_overflow(history, first_newest + 1)
|
|
assert_true(history.resync_required, "a later separate stall trips resync state again")
|
|
assert_eq(history.overflow_count, 2, "a second distinct episode is counted separately, not capped at one")
|
|
|
|
history.compare_authoritative(second_newest, _state(float(second_newest)))
|
|
assert_true(not history.resync_required, "second episode also recovers")
|
|
assert_eq(history.overflow_count, 2, "episode count is cumulative across recoveries")
|
|
|
|
|
|
func test_continuing_stall_does_not_recount_the_same_episode() -> void:
|
|
var history := LocalPredictionHistory.new()
|
|
var newest := _stall_until_overflow(history, 1)
|
|
for seq in range(newest + 1, newest + 1 + LocalPredictionHistory.RING_SIZE):
|
|
history.record(seq, _action(float(seq)), _state(float(seq)))
|
|
assert_true(history.resync_required, "the stall is still in progress")
|
|
assert_eq(history.overflow_count, 1, "one continuous outage stays one episode however long it lasts")
|
|
|
|
|
|
# Sequences are not guaranteed consecutive: InputLeadController.update() can
|
|
# return 0 or up to 1+3, so the client's seq can skip forward and leave a ring
|
|
# slot holding a tag older than newest_recorded_seq - RING_SIZE. get_prediction()
|
|
# reports such a stale-but-untouched slot as "matched", so matching alone must
|
|
# not be enough to clear resync_required while the real backlog is still huge.
|
|
func test_stale_matched_comparison_does_not_clear_a_live_backlog() -> void:
|
|
var history := LocalPredictionHistory.new()
|
|
history.record(1, _action(1.0), _state(1.0))
|
|
var far := LocalPredictionHistory.RING_SIZE + 2 # skips index 1, so seq 1's slot survives
|
|
history.record(far, _action(float(far)), _state(float(far)))
|
|
assert_true(history.resync_required, "the skip outran the acknowledgement window")
|
|
|
|
var stale := history.compare_authoritative(1, _state(1.0))
|
|
assert_eq(stale["status"], "matched", "seq 1's ring slot was never rewritten")
|
|
assert_true(history.resync_required, "a stale match while the backlog is still oversized must not clear resync state")
|
|
|
|
history.compare_authoritative(far, _state(float(far)))
|
|
assert_true(not history.resync_required, "acknowledging the newest sequence does clear it")
|