One account is now one session. Two peers signed in as the same account each got their own player built from the SAME character record, so both held a copy of one bag and whichever wrote last won: drop the single potion from both copies and two potions hit the floor. Reconciling two live mirrors of one record cannot be made to work, so the handshake refuses the second connection -- the newcomer, not the incumbent, or claiming an account would be a way to kick anyone off the server. A muzzle offset is a teleport and the bullet pool never sees it: it samples geometry once per tick. Forward that is 19px, but Doubleshot adds up to 45px sideways, wider than a 32px wall plus the 6px hitbox holding you off it. Six Doubleshots, stand flush against a wall, fire along it, and live shots came out the far side. Shots now start from the shooter when geometry stands in the way, which keeps the count honest. Bullet-spawn events carry the firing actor's exact position, and they were filtered by a flat 2900-unit radius against an 800-unit actor cull. A depth-1 map's diagonal is about 2521, so that radius culled nothing: every enemy that fired was pinpointed for every player, through fog, on terrain the client was deliberately not streamed. The test is now per bullet -- can THIS one reach ground you can see -- which keeps the guarantee the wide radius existed for and drops the radar. Found while fixing the above: a rejection never reached the client. The RPC is queued and only leaves on the frame's flush, so kicking in the same call threw the reason away, and _on_server_disconnected overwrote last_error anyway. Both were true of protocol mismatch too, which is the one message a player most needs. Smoke asserts the reason arrives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
187 lines
8.0 KiB
GDScript
187 lines
8.0 KiB
GDScript
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:
|
|
# The player's shot is the longest-travelling bullet in the game once
|
|
# upgrades are involved, so the worst case is measured at the speed ceiling
|
|
# rather than at the base speed. test_interest used to model only static
|
|
# content, which upgrades quietly invalidated.
|
|
var worst := SimConfig.MAX_BULLET_SPEED \
|
|
* float(SimConfig.PLAYER_BULLET_LIFETIME) * SimConfig.TICK_DELTA
|
|
var emitters: Array[BulletEmitter] = []
|
|
for id in Content.ALL_ENEMIES:
|
|
emitters.append_array(Content.enemy(id).emitters)
|
|
for boss_id in Content.ALL_BOSSES:
|
|
for phase in Content.boss(boss_id).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)
|
|
|
|
|
|
## The ceiling is what makes the radius above computable at all. Without it a
|
|
## stack of Snipers would push bullet travel past whatever radius was chosen,
|
|
## and the failure would be invisible damage rather than a failing test.
|
|
func test_no_stack_of_upgrades_can_outrun_the_bullet_radius() -> void:
|
|
var ids: Array[StringName] = []
|
|
for _i in 8:
|
|
ids.append(Upgrades.SNIPER)
|
|
var travel := PlayerStats.build(ids).bullet_speed \
|
|
* float(SimConfig.PLAYER_BULLET_LIFETIME) * SimConfig.TICK_DELTA
|
|
assert_gte(SimConfig.BULLET_INTEREST_RADIUS, travel + SimConfig.FOG_VIEW_RADIUS)
|
|
|
|
|
|
# --- Per-bullet interest ----------------------------------------------------
|
|
# The radius tests above pin the CEILING: no bullet in the game may need more
|
|
# than BULLET_INTEREST_RADIUS. These pin the rule that actually runs, which asks
|
|
# whether a particular bullet can reach ground this player can see. The two are
|
|
# different questions, and only the second one closes the leak: a spawn event
|
|
# carries the firing actor's exact position, so anything sent on the strength of
|
|
# a flat radius is an enemy located for a modified client.
|
|
|
|
func _server() -> ServerRuntime:
|
|
# Not added to the tree on purpose: _ready() would build a lobby and load a
|
|
# character store, and none of that is involved in an interest decision.
|
|
return autofree(ServerRuntime.new()) as ServerRuntime
|
|
|
|
|
|
func test_a_slow_bullet_fired_across_the_map_is_not_announced() -> void:
|
|
var srv := _server()
|
|
var slow := Vector2(120.0, 0.0) # 120 u/s for 60 ticks reaches 120 units
|
|
assert_false(srv._bullet_concerns(Vector2.ZERO, Vector2(2000.0, 0.0),
|
|
slow, 0.0, 60),
|
|
"it expires 1400 units short of anything this player can see")
|
|
|
|
|
|
func test_a_bullet_that_can_reach_visible_ground_is_announced() -> void:
|
|
var srv := _server()
|
|
var fast := Vector2(900.0, 0.0) # 900 u/s for 90 ticks reaches 1350
|
|
assert_true(srv._bullet_concerns(Vector2.ZERO, Vector2(1500.0, 0.0),
|
|
fast, 0.0, 90),
|
|
"1350 of travel plus the fog radius covers 1500, so it must be sent")
|
|
|
|
|
|
func test_the_horizon_is_travel_plus_what_the_player_can_see() -> void:
|
|
var srv := _server()
|
|
# A bullet whose entire travel lands exactly on the edge of sight.
|
|
var speed := 600.0
|
|
var life := 60
|
|
var reach := Movement.bullet_reach(speed, 0.0, life)
|
|
var vel := Vector2(speed, 0.0)
|
|
assert_true(srv._bullet_concerns(Vector2.ZERO,
|
|
Vector2(reach + SimConfig.FOG_VIEW_RADIUS - 1.0, 0.0), vel, 0.0, life))
|
|
assert_false(srv._bullet_concerns(Vector2.ZERO,
|
|
Vector2(reach + SimConfig.FOG_VIEW_RADIUS + 1.0, 0.0), vel, 0.0, life))
|
|
|
|
|
|
func test_acceleration_counts_toward_the_reach() -> void:
|
|
var srv := _server()
|
|
var at := Vector2(1200.0, 0.0)
|
|
var vel := Vector2(200.0, 0.0)
|
|
assert_false(srv._bullet_concerns(Vector2.ZERO, at, vel, 0.0, 90),
|
|
"200 u/s for 1.5s is 300 units; nowhere near")
|
|
assert_true(srv._bullet_concerns(Vector2.ZERO, at, vel, 900.0, 90),
|
|
"the same shot accelerating gets there, so it has to be announced")
|
|
|
|
|
|
func test_no_enemy_in_the_content_leaks_its_position_across_a_dungeon() -> void:
|
|
# The regression this all exists for. A depth-1 map is 2112x1376 units, so
|
|
# its diagonal is about 2521 -- less than BULLET_INTEREST_RADIUS. Under the
|
|
# flat radius every firing enemy anywhere on the floor was announced to
|
|
# everyone, whatever the fog or the actor cull said.
|
|
var srv := _server()
|
|
var diagonal := Vector2(66.0, 43.0).length() * MapGrid.TILE
|
|
assert_lt(diagonal, SimConfig.BULLET_INTEREST_RADIUS,
|
|
"if this stops being true the flat radius was not the bug described here")
|
|
var leaked: Array[String] = []
|
|
for id in Content.ALL_ENEMIES:
|
|
for e in Content.enemy(id).emitters:
|
|
if srv._bullet_concerns(Vector2.ZERO, Vector2(diagonal, 0.0),
|
|
Vector2(e.speed, 0.0), 0.0, e.lifetime):
|
|
leaked.append("%s (%.0f u/s x %d ticks)" % [id, e.speed, e.lifetime])
|
|
assert_eq(leaked, [] as Array[String],
|
|
"an enemy firing at the far corner of the map must not be announced")
|