Fix bullet/ship desync; rework death, escape, arrival and hub awareness

(1) Bullets appeared to trail the ship. Two independent causes, measured with
the new tools/diag_prediction.gd rather than guessed at:
  - ServerRuntime ticked before ClientRuntime, so input sampled on frame N was
    not consumed until frame N+1, leaving the drawn ship a constant one tick
    (4.00px at 240 u/s) ahead of the authoritative one that bullets spawn from.
    ClientRuntime now sets process_physics_priority = -10. Gap on a listen
    server: 4.00px -> 0.10px mean, 0.30px worst.
  - PLAYER_MUZZLE_OFFSET was PLAYER_RADIUS + 6 = 12px against a 13px drawn
    ship, so bullets were born inside the sprite. Regression from the previous
    commit's hitbox shrink; it now derives from PLAYER_VISUAL_RADIUS.

(2) No more timed respawn. A downed player stays down until they ask for the
hub (E), which is an ordinary input -- the server has no "revive me" message.

(3) Escape channel 3s -> 1s, and damage no longer cancels it. An interruptible
channel makes killing the process strictly better than using the button, so a
dropped connection now runs the same channel: the player stays in the world as
linkdead, still killable, and is only released once it completes. Instances
refuse to close while a linkdead body is resolving, or a solo drop would delete
it on the next tick and hand the exploit straight back.

(4) Escape opens an in-game menu: return to hub (routed through the same held-
escape channel, not a new message), disconnect, quit.

(5) Server pushes a roster so the hub shows who is online and which dungeon
they are in. Entering a dungeon grants 2s arrival protection -- invulnerable
AND weapons-cold, since invulnerability alone would make the spawn a free
firing position -- flagged in the snapshot and drawn on every protected ship.

(6) Cleared dungeons hold the party 30s (was 5s) with a visible countdown.

(7) The hub's grey circle was a 100k-HP target dummy that read as scenery. Now
drawn as a bullseye so its purpose is legible.

Protocol version 1 -> 2. 91 tests (was 78); smoke.sh gains a bot that is
SIGKILLed mid-dungeon to prove the disconnect path end to end. check.sh,
test.sh and smoke.sh all pass.
This commit is contained in:
2026-09-03 18:43:19 +02:00
parent d9a59fff03
commit 005679f1b5
29 changed files with 903 additions and 79 deletions
+97 -18
View File
@@ -38,13 +38,18 @@ func _physics_process(_delta: float) -> void:
inst.step()
_dispatch_events(inst)
if send_snapshot and not inst.peers.is_empty():
var snap := NetCodec.encode_snapshot(inst.world)
var snap := NetCodec.encode_snapshot(inst.world, inst.exit_countdown_seconds())
for peer in inst.peers:
Net.send_snapshot(peer, snap)
if inst.kind == Protocol.InstanceKind.DUNGEON \
and inst.state == Instance.State.CLEARED and inst.stage_delay <= 0:
if inst.kind != Protocol.InstanceKind.DUNGEON:
continue
if inst.state == Instance.State.CLEARED and inst.stage_delay <= 0:
closing.append(inst.id)
elif inst.kind == Protocol.InstanceKind.DUNGEON and inst.is_empty() and inst.age > 60:
elif inst.is_empty() and not inst.has_linkdead() and inst.age > 60:
# has_linkdead() matters: without it a solo player could drop, the
# instance would close on the next tick, and their body would
# vanish before finishing the escape channel -- exactly the
# disconnect cheese the channel exists to prevent.
closing.append(inst.id)
for id in closing:
@@ -52,18 +57,25 @@ func _physics_process(_delta: float) -> void:
## Split the tick's events into the ones clients need and the ones only the
## server acts on (escape completion, portal use).
## server acts on (escape completion, portal use, respawn requests).
func _dispatch_events(inst: Instance) -> void:
var events := inst.world.drain_events()
if events.is_empty():
return
var transfers: Array[Dictionary] = []
# Collected and applied after the send below, because a transfer mutates
# inst.peers and would otherwise change the list mid-broadcast.
var to_lobby: Array[int] = []
var to_dungeon: Array[int] = []
for ev in events:
match int(ev["t"]):
SimEvent.Type.ESCAPE_COMPLETED:
transfers.append({"peer": int(ev["peer"]), "to_lobby": true})
SimEvent.Type.ESCAPE_COMPLETED, SimEvent.Type.RESPAWN_REQUESTED:
var peer := int(ev["peer"])
if not to_lobby.has(peer):
to_lobby.append(peer)
SimEvent.Type.PORTAL_USED:
transfers.append({"peer": int(ev["peer"]), "to_lobby": false})
var peer := int(ev["peer"])
if not to_dungeon.has(peer):
to_dungeon.append(peer)
_:
pass
@@ -71,11 +83,10 @@ func _dispatch_events(inst: Instance) -> void:
for peer in inst.peers:
Net.send_events(peer, payload)
for t in transfers:
if t["to_lobby"]:
_send_to_lobby(int(t["peer"]))
else:
_send_to_dungeon(int(t["peer"]))
for peer in to_lobby:
_send_to_lobby(peer)
for peer in to_dungeon:
_send_to_dungeon(peer)
# --- Peer lifecycle ---------------------------------------------------------
@@ -84,13 +95,42 @@ func on_peer_connected(peer_id: int) -> void:
GameLog.info("server", "peer %d connected, awaiting hello" % peer_id)
## A drop inside a dungeon does NOT delete the player. The body is detached
## from the send list but kept in the world, where it channels the same
## one-second escape everyone else does and stays killable the whole time --
## so quitting the process is never a cheaper exit than pressing the button.
## Only once that channel resolves (_release_linkdead) is the peer forgotten.
## A drop in the hub has nothing to dodge, so it is immediate.
func on_peer_disconnected(peer_id: int) -> void:
var inst := instance_of(peer_id)
if inst != null:
inst.remove_peer(peer_id)
if inst == null:
_forget_peer(peer_id)
return
if inst.kind == Protocol.InstanceKind.DUNGEON:
inst.detach_peer(peer_id)
GameLog.info("server", "peer %d dropped in instance %d, channelling out"
% [peer_id, inst.id])
_broadcast_roster()
return
inst.remove_peer(peer_id)
_forget_peer(peer_id)
GameLog.info("server", "peer %d disconnected" % peer_id)
func _forget_peer(peer_id: int) -> void:
peer_instance.erase(peer_id)
peer_names.erase(peer_id)
GameLog.info("server", "peer %d disconnected" % peer_id)
_broadcast_roster()
## The linkdead body finished its channel (or died trying). Either way it leaves
## the dungeon; with no persistence yet, "safely back in the hub" and "gone" are
## the same outcome, so it is simply removed.
func _release_linkdead(peer_id: int, inst: Instance) -> void:
inst.remove_peer(peer_id)
GameLog.info("server", "peer %d released from instance %d after drop"
% [peer_id, inst.id])
_forget_peer(peer_id)
func on_hello(peer_id: int, version: int, display_name: String) -> void:
@@ -109,6 +149,7 @@ func on_hello(peer_id: int, version: int, display_name: String) -> void:
Net.send_welcome(peer_id)
_place(peer_id, lobby)
GameLog.info("server", "peer %d joined as '%s'" % [peer_id, clean])
_broadcast_roster()
func on_input(peer_id: int, data: PackedByteArray) -> void:
@@ -135,7 +176,8 @@ func _place(peer_id: int, inst: Instance) -> void:
var backlog := _live_bullet_events(inst.world)
if not backlog.is_empty():
Net.send_events(peer_id, NetCodec.encode_events(inst.world.tick, backlog))
Net.send_snapshot(peer_id, NetCodec.encode_snapshot(inst.world))
Net.send_snapshot(peer_id, NetCodec.encode_snapshot(inst.world,
inst.exit_countdown_seconds()))
func _transfer(peer_id: int, to: Instance) -> void:
@@ -143,9 +185,19 @@ func _transfer(peer_id: int, to: Instance) -> void:
if from != null:
from.remove_peer(peer_id)
_place(peer_id, to)
_broadcast_roster()
## Where everything that ends a dungeon run converges: the escape channel, a
## downed player asking out, and a linkdead body finishing its channel.
func _send_to_lobby(peer_id: int) -> void:
var from := instance_of(peer_id)
if from != null:
var p: SimPlayer = from.world.players.get(peer_id)
if p != null and p.linkdead:
_release_linkdead(peer_id, from)
return
GameLog.info("server", "peer %d escaped to lobby" % peer_id)
_transfer(peer_id, lobby)
@@ -173,6 +225,33 @@ func _close_dungeon(id: int) -> void:
GameLog.info("server", "closed dungeon instance %d" % id)
# --- Roster -----------------------------------------------------------------
## Tell everyone who is online and where they are, so the hub can show that a
## dungeon is already running before you walk into the portal.
##
## Sent on membership changes only, not per tick -- it is the one message that
## carries names, and nothing about it is time critical.
func _broadcast_roster() -> void:
var entries: Array[Dictionary] = []
for peer in peer_names:
var inst := instance_of(peer)
if inst == null:
continue
var p: SimPlayer = inst.world.players.get(peer)
entries.append({
"peer": peer,
"name": peer_names[peer],
"kind": int(inst.kind),
"instance": inst.id,
"alive": p != null and p.alive,
})
var payload := NetCodec.encode_roster(entries)
for inst in instances.values():
for peer in inst.peers:
Net.send_roster(peer, payload)
func _live_bullet_events(world: SimWorld) -> Array[Dictionary]:
var out: Array[Dictionary] = []
for i in world.pool.high_water: