GDScript 183 lines
extends GutTest
## The N3 remote-entity interpolation invariants, exercised without any networking.
##
## SnapshotInterpolator buffers authoritative snapshots stamped with their arrival
## time and renders remote entities a delay in the past, interpolating between the
## two snapshots that bracket the render time. These tests pin the buffer and the
## sampling math: the linear blend at the midpoint, the no-extrapolation clamps at
## both ends, spawn/death handling, duplicate-tick rejection, and the buffer cap —
## all pure, so the round trip is checked headlessly like the protocol and sim cores.
func test_an_empty_buffer_samples_to_null() -> void:
var interp := SnapshotInterpolator.new()
assert_false(interp.has_data(), "a fresh interpolator holds nothing")
assert_null(interp.sample(0.0), "with no snapshots there is nothing to render")
func test_a_lone_snapshot_is_returned_as_is() -> void:
var interp := SnapshotInterpolator.new()
interp.push(_state(1, {7: Vector2(100.0, 200.0)}), 0.0)
# With only one snapshot there is no pair to blend, so any render time yields it.
var sampled := interp.sample(-50.0)
assert_eq(sampled.get_entity(7).position, Vector2(100.0, 200.0))
func test_midpoint_render_time_lerps_position_halfway() -> void:
var interp := SnapshotInterpolator.new()
interp.push(_state(1, {7: Vector2(0.0, 0.0)}), 0.0)
interp.push(_state(2, {7: Vector2(100.0, 40.0)}), 100.0)
# Render exactly between the two arrival times -> alpha 0.5 -> the midpoint.
var sampled := interp.sample(50.0)
assert_eq(sampled.get_entity(7).position, Vector2(50.0, 20.0))
func test_a_render_time_before_the_oldest_clamps_to_it() -> void:
var interp := SnapshotInterpolator.new()
interp.push(_state(1, {7: Vector2(0.0, 0.0)}), 100.0)
interp.push(_state(2, {7: Vector2(100.0, 0.0)}), 200.0)
# Earlier than anything buffered: clamp to the oldest, never extrapolate backwards.
var sampled := interp.sample(0.0)
assert_eq(sampled.get_entity(7).position, Vector2(0.0, 0.0))
func test_a_render_time_past_the_newest_clamps_to_it() -> void:
var interp := SnapshotInterpolator.new()
interp.push(_state(1, {7: Vector2(0.0, 0.0)}), 0.0)
interp.push(_state(2, {7: Vector2(100.0, 0.0)}), 100.0)
# Later than anything buffered: clamp to the newest. Remote entities are never
# extrapolated ahead — guessing their future only snaps back when truth arrives.
var sampled := interp.sample(500.0)
assert_eq(sampled.get_entity(7).position, Vector2(100.0, 0.0))
func test_a_repeated_or_stale_tick_is_ignored() -> void:
var interp := SnapshotInterpolator.new()
interp.push(_state(2, {7: Vector2(0.0, 0.0)}), 0.0)
interp.push(_state(2, {7: Vector2(999.0, 0.0)}), 100.0) # same tick -> dropped
interp.push(_state(1, {7: Vector2(888.0, 0.0)}), 200.0) # older tick -> dropped
# Only the first snapshot was kept, so sampling still returns its position.
assert_eq(interp.sample(150.0).get_entity(7).position, Vector2(0.0, 0.0))
func test_an_entity_that_spawned_after_appears_at_its_own_position() -> void:
var interp := SnapshotInterpolator.new()
interp.push(_state(1, {7: Vector2(0.0, 0.0)}), 0.0)
interp.push(_state(2, {7: Vector2(10.0, 0.0), 9: Vector2(60.0, 0.0)}), 100.0)
# Entity 9 exists only in the newer snapshot -> nothing to lerp from, so it
# renders at its newer position rather than being blended toward the origin.
var sampled := interp.sample(50.0)
assert_not_null(sampled.get_entity(9), "the newly spawned entity is present")
assert_eq(sampled.get_entity(9).position, Vector2(60.0, 0.0))
func test_an_entity_that_died_before_the_newer_snapshot_is_dropped() -> void:
var interp := SnapshotInterpolator.new()
interp.push(_state(1, {7: Vector2(0.0, 0.0), 9: Vector2(60.0, 0.0)}), 0.0)
interp.push(_state(2, {7: Vector2(10.0, 0.0)}), 100.0)
# Entity 9 is gone in the newer snapshot, so the render (targeting the newer
# one) omits it — a dead unit disappears, it does not linger interpolating.
var sampled := interp.sample(50.0)
assert_null(sampled.get_entity(9), "the entity absent from the newer snapshot is dropped")
func test_non_position_fields_come_from_the_newer_snapshot() -> void:
var interp := SnapshotInterpolator.new()
var older := _state(1, {7: Vector2(0.0, 0.0)})
older.get_entity(7).hp = 100
var newer := _state(2, {7: Vector2(100.0, 0.0)})
newer.get_entity(7).hp = 40
interp.push(older, 0.0)
interp.push(newer, 100.0)
# Position blends, but hp (and every other field) is the fresher truth, not a blend.
var sampled := interp.sample(50.0)
assert_eq(sampled.get_entity(7).position, Vector2(50.0, 0.0), "position is blended")
assert_eq(sampled.get_entity(7).hp, 40, "hp is taken whole from the newer snapshot")
func test_the_buffer_is_capped_to_its_limit() -> void:
var interp := SnapshotInterpolator.new()
var count := SnapshotInterpolator.BUFFER_LIMIT + 5
for tick in range(1, count + 1):
interp.push(_state(tick, {7: Vector2(float(tick), 0.0)}), float(tick))
# The oldest snapshots are evicted, so a render time before the surviving window
# clamps to the oldest retained snapshot, not the long-discarded tick 1.
var oldest_kept := float(count - SnapshotInterpolator.BUFFER_LIMIT + 1)
assert_eq(interp.sample(0.0).get_entity(7).position, Vector2(oldest_kept, 0.0))
# --- adaptive render delay --------------------------------------------------
func test_delay_floors_at_min_on_even_arrivals() -> void:
var interp := SnapshotInterpolator.new()
# Snapshots arriving evenly at the 60 Hz interval carry almost no jitter, so the
# delay sits on its floor rather than buffering latency it does not need.
_push_arrivals(interp, _repeat(16.0, 10))
assert_almost_eq(interp.target_delay_ms(), SnapshotInterpolator.MIN_DELAY_MS, 0.001)
func test_delay_attacks_to_cover_a_large_gap() -> void:
var interp := SnapshotInterpolator.new()
# A single late snapshot opens a 200 ms gap; the delay snaps up at once to cover
# it (worst gap + margin), so the next late packet is already absorbed.
_push_arrivals(interp, [16.0, 16.0, 16.0, 200.0])
var expected := 200.0 + SnapshotInterpolator.GAP_MARGIN_MS
assert_almost_eq(interp.target_delay_ms(), expected, 0.001)
func test_delay_caps_at_max() -> void:
var interp := SnapshotInterpolator.new()
# A pathological gap must not buy unbounded latency — the delay clamps to the cap.
_push_arrivals(interp, [16.0, 16.0, 1000.0])
assert_almost_eq(interp.target_delay_ms(), SnapshotInterpolator.MAX_DELAY_MS, 0.001)
func test_delay_releases_slowly_after_jitter_subsides() -> void:
var interp := SnapshotInterpolator.new()
# A 200 ms gap drives the delay to its peak; once even arrivals resume the gap
# leaves the recent window and the delay eases back down — gradually, not snapping
# to the floor (which would warp remote-unit motion), but well below the peak.
var peak := 200.0 + SnapshotInterpolator.GAP_MARGIN_MS # the delay right after the gap
var intervals := [16.0, 16.0, 200.0]
intervals.append_array(_repeat(16.0, 20))
_push_arrivals(interp, intervals)
assert_lt(interp.target_delay_ms(), peak, "the delay relaxes once jitter subsides")
assert_gt(
interp.target_delay_ms(),
SnapshotInterpolator.MIN_DELAY_MS,
"but eases down slowly rather than snapping to the floor",
)
# --- helpers ----------------------------------------------------------------
## Pushes a stream of snapshots whose inter-arrival times are `intervals`
## (milliseconds). The first arrives at time 0; time and tick advance per interval.
func _push_arrivals(interp: SnapshotInterpolator, intervals: Array) -> void:
var time := 0.0
var tick := 1
interp.push(_state(tick, {7: Vector2(float(tick), 0.0)}), time)
for dt: float in intervals:
time += dt
tick += 1
interp.push(_state(tick, {7: Vector2(float(tick), 0.0)}), time)
## An array of `value` repeated `count` times.
func _repeat(value: float, count: int) -> Array:
var out: Array = []
for _i in count:
out.append(value)
return out
## A snapshot at `tick` whose entities are the given `id -> position` pairs.
func _state(tick: int, positions: Dictionary) -> SimState:
var state := SimState.new()
state.tick = tick
for id in positions:
state.add_entity(SimEntity.new(id, 1, positions[id], 0.0))
return state