ajhahn.de
← Theria
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