Close three holes an unmodified client could walk through

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>
This commit is contained in:
claudeandClaude Opus 5 committed 2026-09-07 17:04:05 +02:00
1 parent b8332b697d
commit ef28970654
9 files changed
+357 -21

No files matched your search

+55
View File
@@ -251,3 +251,58 @@ func test_spawn_grace_expires() -> void:
assert_false(p.invulnerable())
_drive(1, Vector2.ZERO, InputFrame.BTN_FIRE)
assert_eq(world.pool.live_count, 1, "the gun comes back once grace ends")
## A muzzle offset moves a bullet's birthplace away from its shooter, and the
## bullet pool samples geometry once per tick so it never sees that jump. With
## six Doubleshots the sideways offset reaches 45px -- wider than a 32px wall
## plus the 6px hitbox that holds you off it -- so standing flush against a wall
## and firing along it put live shots down the far side. No modified client
## needed, which is what made it worth a test rather than a comment.
func _wall_at_column(column: int) -> SimWorld:
var g := MapGrid.new(10, 6, MapGrid.Kind.FLOOR)
for ty in 6:
g.set_tile(column, ty, MapGrid.Kind.WALL)
var w := SimWorld.new(1)
w.set_map(g)
return w
func _fire_flush_against_the_wall(doubleshots: int) -> Dictionary:
var w := _wall_at_column(4)
var p := w.add_player(PEER, "tester")
var face: float = w.map.origin.x + 4.0 * MapGrid.TILE
p.pos = Vector2(face - SimConfig.PLAYER_RADIUS, w.map.tile_centre(3, 3).y)
p.aim = -PI / 2.0 # along the wall, not into it
p.spawn_grace = 0
p.fire_cooldown = 0
var ids: Array[StringName] = []
for _i in doubleshots:
ids.append(Upgrades.DOUBLESHOT)
p.stats = PlayerStats.build(ids)
w._fire_player_shot(p)
var fired := 0
var past := 0
for i in w.pool.high_water:
if w.pool.alive[i] == 0:
continue
fired += 1
if w.pool.pos[i].x > face:
past += 1
return {"fired": fired, "past_the_face": past}
func test_no_stack_of_upgrades_can_put_a_shot_through_a_wall() -> void:
for doubleshots in [0, 5, 6, 10]:
var r := _fire_flush_against_the_wall(doubleshots)
assert_eq(int(r["past_the_face"]), 0,
"%d Doubleshot(s) put %d shot(s) on the far side of the wall"
% [doubleshots, r["past_the_face"]])
func test_clamping_the_muzzle_does_not_swallow_the_shot() -> void:
# The fix must not silently reduce your firepower for standing near cover:
# a blocked muzzle falls back to the shooter's centre, it does not cancel.
assert_eq(int(_fire_flush_against_the_wall(6)["fired"]), 7,
"six Doubleshots is seven projectiles, wall or no wall")