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:
1 parent
b8332b697d
commit
ef28970654
9 files changed
+357
-21
No files matched your search
+71
-12
@@ -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
|
||||
if 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.kick(peer_id)
|
||||
Net.reject_and_kick(peer_id,
|
||||
"protocol mismatch: server %d, client %d" % [Protocol.VERSION, version])
|
||||
return
|
||||
var account := Net.auth.validate(ticket)
|
||||
if account == AuthProvider.NO_ACCOUNT:
|
||||
GameLog.warn("server", "peer %d failed authentication" % peer_id)
|
||||
Net.send_reject(peer_id, "authentication failed")
|
||||
Net.kick(peer_id)
|
||||
Net.reject_and_kick(peer_id, "authentication failed")
|
||||
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
|
||||
peer_accounts[peer_id] = account
|
||||
Net.send_welcome(peer_id)
|
||||
@@ -374,6 +389,22 @@ func instance_of(peer_id: int) -> Instance:
|
||||
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 --------------------------------------------------------------
|
||||
|
||||
func _place(peer_id: int, inst: Instance) -> void:
|
||||
@@ -655,30 +686,58 @@ func _broadcast_roster() -> void:
|
||||
Net.send_roster(peer, payload)
|
||||
|
||||
|
||||
## Bullet spawns within this peer's interest radius. See
|
||||
## SimConfig.BULLET_INTEREST_RADIUS for why this radius is much wider than the
|
||||
## one used for actors.
|
||||
## Bullet spawns this peer could actually be reached by.
|
||||
##
|
||||
## 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]:
|
||||
var p: SimPlayer = inst.world.players.get(peer_id)
|
||||
if p == null:
|
||||
return spawns
|
||||
var cull_sq := SimConfig.BULLET_INTEREST_RADIUS * SimConfig.BULLET_INTEREST_RADIUS
|
||||
var out: Array[Dictionary] = []
|
||||
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)
|
||||
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]:
|
||||
var out: Array[Dictionary] = []
|
||||
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:
|
||||
if world.pool.alive[i] == 0:
|
||||
continue
|
||||
if observer != null \
|
||||
and observer.pos.distance_squared_to(world.pool.pos[i]) > cull_sq:
|
||||
# Same per-bullet question as _spawns_near. The backlog a joiner is sent
|
||||
# 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
|
||||
out.append({
|
||||
"t": SimEvent.Type.BULLET_SPAWN,
|
||||
|
||||
Reference in new issue
Block a user