Close Stage 1: per-peer actor interest, boss room confinement
ci / verify (push) Successful in 46s

The gap Stage 1 left open: snapshots were encoded once and broadcast to every
peer in an instance, so clients were handed enemies the fog then hid. Fog is a
rendering rule -- a modified client draws whatever it holds -- so that was no
defence at all. Snapshots are now encoded per peer, and actors beyond
ACTOR_INTEREST_RADIUS (800u) are never sent. Measured cost of four filtered
encodes against one shared: 95.6us vs 24.9us, or 0.032 ms/tick amortised over
the snapshot interval.

Two asymmetries worth keeping:

- The observer's own player record is never filtered, however far out the
  arithmetic puts it. The client reconciles its prediction against that record,
  so dropping it breaks the player's own movement rather than hiding someone.
- Only bullet SPAWNS are filtered, never despawns. A client told about a bullet
  must always be told it died, or it keeps a phantom.

Bullet spawns use a much wider radius (2200u) than actors, deliberately. An
enemy appearing at the edge of sight is cosmetic; a bullet withheld at spawn
that later flies into view is invisible damage. The floor is longest bullet
travel + fog radius -- 1500 for the Warden's Collapse snipe -- and
test_interest.gd recomputes that from live content, so adding a faster or
longer-lived bullet fails a test instead of producing bullets that wink into
existence.

Also clamped bosses to their room. A no-op today since every boss is
stationary, which is exactly when the invariant is cheap to establish: boss
rooms have no door that locks, so the only thing keeping a fight in the boss
room is the boss, and Stage 5's movement work would otherwise break it quietly.

146 tests (was 137). check.sh, test.sh and smoke.sh pass. Stage 1 has no
partials left; docs/ROADMAP.md now records the three interest radii and the
floor each must respect.
This commit is contained in:
2026-09-03 23:33:32 +02:00
parent 20dd6bc502
commit 7ee6c8e761
9 changed files with 267 additions and 15 deletions
+16
View File
@@ -29,6 +29,22 @@ func test_the_boss_starts_inside_its_own_room() -> void:
"the boss has to start inside the arena it is confined to")
## Boss rooms have no door that locks, so the only thing keeping a boss fight in
## the boss room is the boss itself. Bosses are stationary today, which makes
## this the right moment to pin the invariant -- before movement arrives and
## quietly breaks it.
func test_a_boss_cannot_leave_its_room() -> void:
_step(SimConfig.DUNGEON_FORMING_TICKS + 5)
var boss := inst.world.boss
var room: Rect2 = boss.room
# Shove it far outside, the way a future chase behaviour might.
boss.pos = room.position + room.size + Vector2(2000.0, 2000.0)
inst.step()
assert_true(room.grow(1.0).has_point(boss.pos),
"a boss that can be pushed out of its arena could chase a player who " +
"walked away, and walking away is always allowed")
func test_nothing_spawns_inside_geometry() -> void:
for e in inst.world.enemies.values():
assert_false(inst.world.map.circle_blocked(e.pos, e.def.radius),
+97
View File
@@ -0,0 +1,97 @@
extends GutTest
## Interest management: what the server declines to send.
##
## Fog is a rendering rule and stops nothing — a modified client draws whatever
## it was handed. These tests cover the part that is actually a defence: actors
## and bullet spawns the server never puts on the wire.
var world: SimWorld
const ME := 1
const OTHER := 2
func before_each() -> void:
var map := MapGrid.new(200, 60, MapGrid.Kind.WALL)
map.fill_rect(Rect2i(1, 1, 198, 58), MapGrid.Kind.FLOOR)
map.centre_on_origin()
world = SimWorld.new(1)
world.set_map(map)
var me := world.add_player(ME, "me")
me.pos = Vector2.ZERO
func _snap_for(peer: int) -> Dictionary:
return NetCodec.decode_snapshot(
NetCodec.encode_snapshot(world, Protocol.COUNTDOWN_NONE, peer))
func test_a_distant_enemy_is_not_sent_at_all() -> void:
world.spawn_enemy(Content.turret(), Vector2(SimConfig.ACTOR_INTEREST_RADIUS + 300.0, 0.0))
assert_eq((_snap_for(ME)["enemies"] as Array).size(), 0,
"an enemy across the map must never reach the client")
func test_a_nearby_enemy_is_sent() -> void:
world.spawn_enemy(Content.turret(), Vector2(200.0, 0.0))
assert_eq((_snap_for(ME)["enemies"] as Array).size(), 1)
func test_enemies_are_visible_well_beyond_what_can_be_seen() -> void:
# The interest radius has to exceed the fog radius, or enemies would appear
# out of nowhere at the exact edge of sight.
assert_gt(SimConfig.ACTOR_INTEREST_RADIUS, SimConfig.FOG_VIEW_RADIUS)
world.spawn_enemy(Content.turret(), Vector2(SimConfig.FOG_VIEW_RADIUS + 50.0, 0.0))
assert_eq((_snap_for(ME)["enemies"] as Array).size(), 1,
"just out of sight must still be known, so it does not pop in")
func test_a_distant_player_is_not_sent() -> void:
var them := world.add_player(OTHER, "them")
them.pos = Vector2(SimConfig.ACTOR_INTEREST_RADIUS + 400.0, 0.0)
var peers: Array = _snap_for(ME)["players"]
assert_eq(peers.size(), 1)
assert_eq(int(peers[0]["peer"]), ME)
## The one record that can never be filtered: the client reconciles its own
## prediction against it, so dropping it would break the player's own movement
## rather than merely hiding someone.
func test_your_own_player_is_always_sent_however_far_out_you_are() -> void:
world.players[ME].pos = Vector2(1.0e5, 1.0e5)
var peers: Array = _snap_for(ME)["players"]
assert_eq(peers.size(), 1)
assert_eq(int(peers[0]["peer"]), ME)
func test_a_distant_boss_is_not_sent() -> void:
var boss := world.spawn_boss(Content.warden())
boss.pos = Vector2(SimConfig.ACTOR_INTEREST_RADIUS + 500.0, 0.0)
assert_null(_snap_for(ME)["boss"], "you should not see a boss room you are nowhere near")
boss.pos = Vector2(100.0, 0.0)
assert_not_null(_snap_for(ME)["boss"])
func test_omitting_the_observer_encodes_the_whole_world() -> void:
# Peer 0 means "no filtering" — used by tests and as the safe fallback when
# the observer cannot be found.
world.spawn_enemy(Content.turret(), Vector2(9000.0, 0.0))
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
assert_eq((snap["enemies"] as Array).size(), 1)
## The radius that stops bullets winking into existence, checked against the
## real content rather than guessed. Adding a faster or longer-lived bullet
## should fail here rather than produce invisible damage in play.
func test_the_bullet_radius_covers_the_longest_shot_in_the_game() -> void:
var worst := SimConfig.PLAYER_BULLET_SPEED \
* float(SimConfig.PLAYER_BULLET_LIFETIME) * SimConfig.TICK_DELTA
var emitters: Array[BulletEmitter] = []
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET, Content.ENEMY_STALKER]:
emitters.append_array(Content.enemy(id).emitters)
for phase in Content.warden().phases:
emitters.append_array(phase.emitters)
for e in emitters:
worst = maxf(worst, e.speed * float(e.lifetime) * SimConfig.TICK_DELTA)
assert_gte(SimConfig.BULLET_INTEREST_RADIUS, worst + SimConfig.FOG_VIEW_RADIUS,
"a bullet spawned just outside the radius must not be able to reach " +
"ground the player can see before it expires (worst travel %.0f)" % worst)
+1
View File
@@ -0,0 +1 @@
uid://c156uloui1reg