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
+354 -18

No files matched your search

+35 -1
View File
@@ -13,6 +13,11 @@ signal state_changed(new_state: State)
enum State { OFFLINE, CONNECTING, ONLINE, FAILED } enum State { OFFLINE, CONNECTING, ONLINE, FAILED }
## How long a refused peer is left connected so its rejection can actually go
## out. Long enough to survive a frame's flush and the round trip, short enough
## that nobody notices; see [method reject_and_kick].
const REJECT_LINGER := 0.5
## Peer id the engine gives the host. On a listen server this is also a real ## Peer id the engine gives the host. On a listen server this is also a real
## player, which is why every send goes through the helpers below. ## player, which is why every send goes through the helpers below.
const LOCAL_PEER := 1 const LOCAL_PEER := 1
@@ -97,8 +102,31 @@ func shutdown() -> void:
_set_state(State.OFFLINE) _set_state(State.OFFLINE)
## Tell a peer why it is not welcome, then close the socket.
##
## The kick is DEFERRED, which is the whole point of this existing. An RPC is
## queued and only leaves on the frame's flush, so disconnecting in the same
## call throws the reason away with the packet -- a client turned away for a
## protocol mismatch was told only that the server closed the connection. The
## peer can do nothing during the wait: it has no account bound, and every
## message that needs one already refuses it.
func reject_and_kick(peer_id: int, reason: String) -> void:
send_reject(peer_id, reason)
if _is_local(peer_id):
return
get_tree().create_timer(REJECT_LINGER).timeout.connect(
func() -> void: kick(peer_id))
func kick(peer_id: int) -> void: func kick(peer_id: int) -> void:
if multiplayer.multiplayer_peer is ENetMultiplayerPeer: if multiplayer.multiplayer_peer is not ENetMultiplayerPeer:
return
# The peer may have hung up first. A rejected client closes its own socket
# as soon as it reads the reason, and reject_and_kick's timer lands after
# that -- asking ENet to disconnect someone already gone is an engine error,
# not a quiet no-op.
if not multiplayer.get_peers().has(peer_id):
return
(multiplayer.multiplayer_peer as ENetMultiplayerPeer).disconnect_peer(peer_id) (multiplayer.multiplayer_peer as ENetMultiplayerPeer).disconnect_peer(peer_id)
@@ -130,6 +158,12 @@ func _on_connect_failed() -> void:
func _on_server_disconnected() -> void: func _on_server_disconnected() -> void:
# A rejection lands immediately before the socket closes, and it is the
# useful half of the pair: "protocol mismatch: server 9, client 8" is
# actionable and "server closed the connection" is not. Overwriting it here
# made every reject reason in the game unreachable -- the reason was sent,
# decoded, stored, and then clobbered a frame later by this line.
if state != State.FAILED:
last_error = "server closed the connection" last_error = "server closed the connection"
GameLog.warn("net", last_error) GameLog.warn("net", last_error)
shutdown() shutdown()
+12
View File
@@ -37,6 +37,18 @@ static func outside_map(p: Vector2, map: MapGrid) -> bool:
return not map.world_rect().grow(SimConfig.BULLET_CULL_MARGIN).has_point(p) return not map.world_rect().grow(SimConfig.BULLET_CULL_MARGIN).has_point(p)
## The furthest a bullet with these parameters could travel before it expires.
##
## Used to decide whether a spawn is worth telling a client about. Deliberately
## an OVER-estimate: acceleration is integrated at face value while a negative
## one is ignored, and a turning bullet curves, so the straight-line answer is
## always at least the real path. Over-estimating costs a packet; under-
## estimating is a bullet that arrives invisible.
static func bullet_reach(speed: float, accel: float, life_ticks: int) -> float:
var t := float(maxi(life_ticks, 0)) * SimConfig.TICK_DELTA
return maxf(speed, 0.0) * t + 0.5 * maxf(accel, 0.0) * t * t
## Outside the map, or stopped by geometry. ## Outside the map, or stopped by geometry.
static func bullet_stopped(p: Vector2, map: MapGrid) -> bool: static func bullet_stopped(p: Vector2, map: MapGrid) -> bool:
if map == null: if map == null:
+71 -12
View File
@@ -206,14 +206,29 @@ func on_hello(peer_id: int, version: int, ticket: PackedByteArray) -> void:
return # a second hello from the same peer is either a bug or an attack return # a second hello from the same peer is either a bug or an attack
if version != Protocol.VERSION: if version != Protocol.VERSION:
GameLog.warn("server", "peer %d protocol %d != %d, rejecting" % [peer_id, version, Protocol.VERSION]) GameLog.warn("server", "peer %d protocol %d != %d, rejecting" % [peer_id, version, Protocol.VERSION])
Net.send_reject(peer_id, "protocol mismatch: server %d, client %d" % [Protocol.VERSION, version]) Net.reject_and_kick(peer_id,
Net.kick(peer_id) "protocol mismatch: server %d, client %d" % [Protocol.VERSION, version])
return return
var account := Net.auth.validate(ticket) var account := Net.auth.validate(ticket)
if account == AuthProvider.NO_ACCOUNT: if account == AuthProvider.NO_ACCOUNT:
GameLog.warn("server", "peer %d failed authentication" % peer_id) GameLog.warn("server", "peer %d failed authentication" % peer_id)
Net.send_reject(peer_id, "authentication failed") Net.reject_and_kick(peer_id, "authentication failed")
Net.kick(peer_id) return
# One account, one session. Two peers on one account each get their own
# SimPlayer built from the SAME character record, so both hold a copy of the
# same bag and whichever writes last wins -- drop the one potion from both
# copies and two potions hit the floor. Refusing the second connection is the
# only fix that closes it; reconciling two live mirrors of one record after
# the fact cannot be made to work.
#
# The NEW peer is refused rather than the old one kicked, deliberately: with
# an auth provider anyone can spoof, "kick whoever holds this account" would
# be a way to disconnect any player on the server at will.
var held_by := peer_for_account(account)
if held_by != 0:
GameLog.warn("server", "peer %d claimed account %d, already held by peer %d"
% [peer_id, account, held_by])
Net.reject_and_kick(peer_id, "that account is already playing")
return return
peer_accounts[peer_id] = account peer_accounts[peer_id] = account
Net.send_welcome(peer_id) Net.send_welcome(peer_id)
@@ -374,6 +389,22 @@ func instance_of(peer_id: int) -> Instance:
return instances.get(id) return instances.get(id)
## The peer currently signed in as [param account], or 0 for none.
##
## A peer that dropped inside a dungeon still counts while its linkdead body is
## channelling out, which is the point: reconnecting during that second would
## put the same character in the world twice -- once as the body still being
## shot at, once fresh in the hub -- and a death on the abandoned body would
## then retire the character being played.
func peer_for_account(account: int) -> int:
if account == AuthProvider.NO_ACCOUNT:
return 0
for peer in peer_accounts:
if peer_accounts[peer] == account:
return peer
return 0
# --- Transfers -------------------------------------------------------------- # --- Transfers --------------------------------------------------------------
func _place(peer_id: int, inst: Instance) -> void: func _place(peer_id: int, inst: Instance) -> void:
@@ -655,30 +686,58 @@ func _broadcast_roster() -> void:
Net.send_roster(peer, payload) Net.send_roster(peer, payload)
## Bullet spawns within this peer's interest radius. See ## Bullet spawns this peer could actually be reached by.
## SimConfig.BULLET_INTEREST_RADIUS for why this radius is much wider than the ##
## one used for actors. ## Tested per bullet rather than against a flat radius, and that distinction is
## the whole point. A spawn event carries the firing actor's exact position, so
## a radius wide enough for the fastest shot in the game hands a modified client
## the location of every enemy inside it -- 2900 units, against an 800-unit
## actor cull and a 900-unit map stream. At the depth the game actually runs at
## the map's own diagonal is about 2521, so the flat radius culled NOTHING: an
## enemy firing anywhere on the floor was pinpointed for everyone, through the
## fog, on terrain the client had deliberately not been streamed.
##
## Asking instead whether THIS bullet can reach ground this player can see keeps
## the guarantee the wide radius existed for -- nothing ever arrives invisible --
## while a slow, short-lived shot across the map stops being a free radar ping.
func _spawns_near(inst: Instance, peer_id: int, spawns: Array[Dictionary]) -> Array[Dictionary]: func _spawns_near(inst: Instance, peer_id: int, spawns: Array[Dictionary]) -> Array[Dictionary]:
var p: SimPlayer = inst.world.players.get(peer_id) var p: SimPlayer = inst.world.players.get(peer_id)
if p == null: if p == null:
return spawns return spawns
var cull_sq := SimConfig.BULLET_INTEREST_RADIUS * SimConfig.BULLET_INTEREST_RADIUS
var out: Array[Dictionary] = [] var out: Array[Dictionary] = []
for ev in spawns: for ev in spawns:
if p.pos.distance_squared_to(ev["pos"]) <= cull_sq: if _bullet_concerns(p.pos, ev["pos"], ev["vel"], ev["accel"], ev["life"]):
out.append(ev) out.append(ev)
return out return out
## Whether a bullet born at [param at] could reach ground visible from
## [param eye] before it expires. BULLET_INTEREST_RADIUS stays on as a hard
## ceiling: test_interest.gd proves no bullet in the game needs more than it, so
## it can only ever agree with the per-bullet answer -- but if some future
## content outruns the computation, the failure is a culled bullet rather than
## an unbounded broadcast.
func _bullet_concerns(eye: Vector2, at: Vector2, vel: Vector2, accel: float,
life: int) -> bool:
var horizon := minf(
Movement.bullet_reach(vel.length(), accel, life) + SimConfig.FOG_VIEW_RADIUS,
SimConfig.BULLET_INTEREST_RADIUS)
return eye.distance_squared_to(at) <= horizon * horizon
func _live_bullet_events(world: SimWorld, for_peer: int = 0) -> Array[Dictionary]: func _live_bullet_events(world: SimWorld, for_peer: int = 0) -> Array[Dictionary]:
var out: Array[Dictionary] = [] var out: Array[Dictionary] = []
var observer: SimPlayer = world.players.get(for_peer) if for_peer != 0 else null var observer: SimPlayer = world.players.get(for_peer) if for_peer != 0 else null
var cull_sq := SimConfig.BULLET_INTEREST_RADIUS * SimConfig.BULLET_INTEREST_RADIUS
for i in world.pool.high_water: for i in world.pool.high_water:
if world.pool.alive[i] == 0: if world.pool.alive[i] == 0:
continue continue
if observer != null \ # Same per-bullet question as _spawns_near. The backlog a joiner is sent
and observer.pos.distance_squared_to(world.pool.pos[i]) > cull_sq: # is a snapshot of every live bullet, so a flat radius here leaked the
# position of every firing enemy in the instance the moment you walked
# in -- the one moment you have seen none of the map yet.
if observer != null and not _bullet_concerns(observer.pos,
world.pool.pos[i], world.pool.vel[i], world.pool.accel[i],
world.pool.life[i]):
continue continue
out.append({ out.append({
"t": SimEvent.Type.BULLET_SPAWN, "t": SimEvent.Type.BULLET_SPAWN,
+29 -4
View File
@@ -177,7 +177,31 @@ func slide_circle(pos: Vector2, delta: Vector2, radius: float) -> Vector2:
## Bresenham-style sight test between two world points. Used for fog on the ## Bresenham-style sight test between two world points. Used for fog on the
## client and for aggro on the server, so it has to agree on both. ## client and for aggro on the server, so it has to agree on both.
##
## [param include_end] is false here: the endpoints themselves never block, so
## standing in a doorway, or shooting at something embedded in a wall, still
## resolves.
func has_line_of_sight(from: Vector2, to: Vector2) -> bool: func has_line_of_sight(from: Vector2, to: Vector2) -> bool:
return _walk_clear(from, to, BLOCKS_SIGHT, false)
## Whether a bullet could travel from [param from] to [param to] without
## crossing geometry.
##
## Needed because a muzzle offset is a teleport: [BulletPool] samples position
## once per tick and never sees the jump from a player's centre out to where the
## shot is actually born, so anything that places a bullet away from its shooter
## has to ask this first. Unlike sight, the DESTINATION counts -- a bullet born
## inside a wall is exactly the case being ruled out.
func bullet_path_clear(from: Vector2, to: Vector2) -> bool:
return _walk_clear(from, to, BLOCKS_BULLET, true)
## Shared Bresenham walk behind both tests above, so the two can never disagree
## about which tiles a straight line crosses. [param flags] is one of the
## BLOCKS_* tables; [param include_end] decides whether the destination tile is
## allowed to stop the walk.
func _walk_clear(from: Vector2, to: Vector2, flags: Array, include_end: bool) -> bool:
var a := to_tile(from) var a := to_tile(from)
var b := to_tile(to) var b := to_tile(to)
var dx := absi(b.x - a.x) var dx := absi(b.x - a.x)
@@ -192,12 +216,13 @@ func has_line_of_sight(from: Vector2, to: Vector2) -> bool:
var limit := width + height + 4 var limit := width + height + 4
while steps < limit: while steps < limit:
steps += 1 steps += 1
if x == b.x and y == b.y: var at_end := x == b.x and y == b.y
if at_end and not include_end:
return true return true
# The endpoints themselves never block: standing in a doorway, or if not (x == a.x and y == a.y) and bool(flags[at(x, y)]):
# shooting at something embedded in a wall, must still resolve.
if not (x == a.x and y == a.y) and blocks_sight(x, y):
return false return false
if at_end:
return true
var e2 := 2 * err var e2 := 2 * err
if e2 >= dy: if e2 >= dy:
err += dy err += dy
+13 -1
View File
@@ -317,9 +317,21 @@ func _fire_player_shot(p: SimPlayer) -> void:
forward) forward)
## Place one shot. [param at] is where the muzzle wants it; if geometry stands
## between the shooter and that point the bullet is born on the shooter instead.
##
## A muzzle offset is a teleport, and [BulletPool] never sees it: it samples
## geometry once per tick, so a bullet that starts on the far side of a wall
## simply flies away down the other side. Forward that is only 19px, but
## Doubleshot adds up to 45px SIDEWAYS -- more than a 32px tile is thick, plus
## the 6px hitbox that keeps you off the wall. Six Doubleshots and standing
## flush against a wall was a wallhack reachable with a stock client. Falling
## back to p.pos rather than dropping the shot keeps the shot count honest:
## you fired what the upgrade says you fired, it just left from your centre.
func _spawn_player_bullet(p: SimPlayer, at: Vector2, dir: Vector2) -> void: func _spawn_player_bullet(p: SimPlayer, at: Vector2, dir: Vector2) -> void:
var from := at if map.bullet_path_clear(p.pos, at) else p.pos
var slot := pool.spawn( var slot := pool.spawn(
at, from,
dir * p.stats.bullet_speed, dir * p.stats.bullet_speed,
SimConfig.PLAYER_BULLET_RADIUS, SimConfig.PLAYER_BULLET_RADIUS,
SimConfig.PLAYER_BULLET_LIFETIME, SimConfig.PLAYER_BULLET_LIFETIME,
+72
View File
@@ -112,3 +112,75 @@ func test_no_stack_of_upgrades_can_outrun_the_bullet_radius() -> void:
var travel := PlayerStats.build(ids).bullet_speed \ var travel := PlayerStats.build(ids).bullet_speed \
* float(SimConfig.PLAYER_BULLET_LIFETIME) * SimConfig.TICK_DELTA * float(SimConfig.PLAYER_BULLET_LIFETIME) * SimConfig.TICK_DELTA
assert_gte(SimConfig.BULLET_INTEREST_RADIUS, travel + SimConfig.FOG_VIEW_RADIUS) 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")
+30
View File
@@ -102,3 +102,33 @@ func test_standing_inside_a_wall_can_still_see_out() -> void:
var inside := grid.tile_centre(0, 0) var inside := grid.tile_centre(0, 0)
var outside := grid.tile_centre(1, 1) var outside := grid.tile_centre(1, 1)
assert_true(grid.has_line_of_sight(inside, outside)) assert_true(grid.has_line_of_sight(inside, outside))
## bullet_path_clear differs from has_line_of_sight in two ways that matter, and
## both are easy to get wrong: it consults BLOCKS_BULLET (so a barricade stops
## it and a pit does not), and the destination tile counts.
func test_a_bullet_path_is_blocked_by_what_stops_bullets_not_by_what_stops_sight() -> void:
var a := grid.tile_centre(1, 4)
var b := grid.tile_centre(8, 4)
assert_true(grid.bullet_path_clear(a, b), "clear floor between them")
grid.set_tile(4, 4, MapGrid.Kind.PIT)
assert_true(grid.bullet_path_clear(a, b), "bullets fly over a pit")
grid.set_tile(4, 4, MapGrid.Kind.BARRICADE)
assert_false(grid.bullet_path_clear(a, b),
"you can SEE over a barricade, which is exactly why sight is the wrong test")
assert_true(grid.has_line_of_sight(a, b), "and sight still passes it")
grid.set_tile(4, 4, MapGrid.Kind.WALL)
assert_false(grid.bullet_path_clear(a, b), "a wall stops both")
func test_a_bullet_path_ending_inside_a_wall_is_refused() -> void:
var from := grid.tile_centre(1, 4)
grid.set_tile(2, 4, MapGrid.Kind.WALL)
assert_false(grid.bullet_path_clear(from, grid.tile_centre(2, 4)),
"the destination counts: a muzzle inside a wall is the case this rules out")
# Sight deliberately does the opposite -- shooting at something embedded in
# a wall must still resolve -- so the two must not be collapsed into one.
assert_true(grid.has_line_of_sight(from, grid.tile_centre(2, 4)))
+55
View File
@@ -251,3 +251,58 @@ func test_spawn_grace_expires() -> void:
assert_false(p.invulnerable()) assert_false(p.invulnerable())
_drive(1, Vector2.ZERO, InputFrame.BTN_FIRE) _drive(1, Vector2.ZERO, InputFrame.BTN_FIRE)
assert_eq(world.pool.live_count, 1, "the gun comes back once grace ends") 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")
+37
View File
@@ -55,6 +55,22 @@ for n in 1 2; do
sleep 0.4 sleep 0.4
done done
# A bot that claims an account another bot is already playing. One account is
# one session: two peers on one account each get their own player built from the
# SAME character record, so both hold a copy of one bag and whichever writes last
# wins -- drop the single potion from both copies and two potions hit the floor.
# The handshake has to refuse it, and it has to refuse the NEWCOMER, or claiming
# someone's account becomes a way to kick them off the server.
for _ in $(seq 1 60); do
grep -q "authenticated as account 7001" "$OUT/server.log" 2>/dev/null && break
sleep 0.25
done
"$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \
--name "twinbot" --account 7001 --autoquit 300 \
> "$OUT/twinbot.log" 2>&1 &
PIDS+=($!)
sleep 0.4
# A fourth bot that leaves *politely* -- the same Net.shutdown() the in-game # A fourth bot that leaves *politely* -- the same Net.shutdown() the in-game
# menu's "Disconnect" button calls. The escape channel is keyed on the socket # menu's "Disconnect" button calls. The escape channel is keyed on the socket
# closing, not on how it closed, so a clean exit must be caught exactly like the # closing, not on how it closed, so a clean exit must be caught exactly like the
@@ -118,6 +134,17 @@ no_engine_errors() { # no_engine_errors <label> <file>
fi fi
} }
count() { # count <label> <file> <pattern> <expected>
local hits
hits=$(grep -cE "$3" "$2" || true)
if [[ "$hits" == "$4" ]]; then
echo " ok $1"
else
echo " FAIL $1 (/$3/ matched $hits time(s), expected $4, in $(basename "$2"))"
fails=$((fails + 1))
fi
}
refute() { # refute <label> <file> <pattern> refute() { # refute <label> <file> <pattern>
local hits local hits
hits=$(grep -cE "$3" "$2" || true) hits=$(grep -cE "$3" "$2" || true)
@@ -166,6 +193,16 @@ check "a polite disconnect is also channelled" \
# assert nothing at all. The upgrade-state message DOES cross the wire on every # assert nothing at all. The upgrade-state message DOES cross the wire on every
# login, so a malformed one shows up in the script-error refutations below -- # login, so a malformed one shows up in the script-error refutations below --
# and tools/diag_upgrades.tscn covers the loop itself. # and tools/diag_upgrades.tscn covers the loop itself.
check "a duplicate account is refused" "$OUT/server.log" \
"claimed account 7001, already held by peer"
check "and the newcomer is the one told no" "$OUT/twinbot.log" \
"that account is already playing"
count "the account was only ever seated once" "$OUT/server.log" \
"authenticated as account 7001" 1
# The victim keeps playing: refusing the second connection must not disturb the
# session that already held the account.
check "the original session survives it" "$OUT/bot1.log" "entered instance .*LOBBY"
refute "no server script errors" "$OUT/server.log" "SCRIPT ERROR|Parse Error|USER ERROR" refute "no server script errors" "$OUT/server.log" "SCRIPT ERROR|Parse Error|USER ERROR"
refute "no client script errors" "$OUT/bot1.log" "SCRIPT ERROR|Parse Error|USER ERROR" refute "no client script errors" "$OUT/bot1.log" "SCRIPT ERROR|Parse Error|USER ERROR"
no_engine_errors "no engine errors on the server" "$OUT/server.log" no_engine_errors "no engine errors on the server" "$OUT/server.log"