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
+38
-4
@@ -13,6 +13,11 @@ signal state_changed(new_state: State)
|
||||
|
||||
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
|
||||
## player, which is why every send goes through the helpers below.
|
||||
const LOCAL_PEER := 1
|
||||
@@ -97,9 +102,32 @@ func shutdown() -> void:
|
||||
_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:
|
||||
if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
|
||||
(multiplayer.multiplayer_peer as ENetMultiplayerPeer).disconnect_peer(peer_id)
|
||||
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)
|
||||
|
||||
|
||||
func is_server() -> bool:
|
||||
@@ -130,8 +158,14 @@ func _on_connect_failed() -> void:
|
||||
|
||||
|
||||
func _on_server_disconnected() -> void:
|
||||
last_error = "server closed the connection"
|
||||
GameLog.warn("net", last_error)
|
||||
# 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"
|
||||
GameLog.warn("net", last_error)
|
||||
shutdown()
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user