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
+40 -1
View File
@@ -33,6 +33,20 @@ var my_hp: int = SimConfig.PLAYER_MAX_HP
var my_alive: bool = true
var my_escape: float = 0.0
var my_escaping: bool = false
## Arrival protection: invulnerable and unable to shoot.
var my_spawn_grace: bool = false
## Set by the in-game menu's "return to hub" -- synthesises a held escape
## button, so the menu route runs the identical server-side channel as the key.
## Cleared when the channel resolves or the player cancels.
var request_escape: bool = false
## Who is online and where, for the hub's player list. Server-pushed.
var roster: Array[Dictionary] = []
## Whole seconds until a cleared dungeon returns the party, or
## Protocol.COUNTDOWN_NONE outside that state.
var cleared_countdown: int = Protocol.COUNTDOWN_NONE
var snap_prev: Dictionary = {}
var snap_curr: Dictionary = {}
@@ -42,6 +56,14 @@ var _bot_tick: int = 0
func _ready() -> void:
world.authoritative = false
# Sample and send input BEFORE ServerRuntime ticks. On a listen server both
# live in one process, and default tree order put the server first -- so the
# input sampled on frame N was not consumed until the server's frame N+1,
# leaving the drawn ship a permanent one-tick (4px at 240 u/s) ahead of the
# authoritative one. Bullets, spawned at the authoritative position, visibly
# trailed the ship. Going first closes the gap to zero on a listen server
# and costs a remote client nothing.
process_physics_priority = -10
set_physics_process(true)
@@ -84,7 +106,7 @@ func _sample_input() -> InputFrame:
var buttons := 0
if Input.is_action_pressed("fire"):
buttons |= InputFrame.BTN_FIRE
if Input.is_action_pressed("emergency_escape"):
if Input.is_action_pressed("emergency_escape") or request_escape:
buttons |= InputFrame.BTN_ESCAPE
if Input.is_action_pressed("interact"):
buttons |= InputFrame.BTN_INTERACT
@@ -99,6 +121,10 @@ func _bot_input() -> InputFrame:
var move := Vector2(cos(t * 0.9), sin(t * 1.3))
aim = t * 2.1
var buttons := InputFrame.BTN_FIRE
if not my_alive:
# Downed bots ask for the hub, same as a player would -- otherwise a bot
# that dies mid-run just lies there and the smoke test stalls.
return InputFrame.make(input_tick, Vector2.ZERO, aim, InputFrame.BTN_INTERACT)
if instance_kind == Protocol.InstanceKind.LOBBY and _bot_tick % 120 < 30:
buttons |= InputFrame.BTN_INTERACT
# Walk onto the portal instead of orbiting, or interact never lands.
@@ -116,6 +142,11 @@ func on_welcome(peer_id: int) -> void:
GameLog.info("client", "welcome, peer id %d" % peer_id)
func on_roster(data: PackedByteArray) -> void:
roster = NetCodec.decode_roster(data)
hud_dirty.emit()
func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
spawn: Vector2) -> void:
instance_id = id
@@ -132,6 +163,12 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
my_hp = SimConfig.PLAYER_MAX_HP
my_escape = 0.0
my_escaping = false
my_spawn_grace = false
# Arriving somewhere is the natural end of an escape request, however it
# was triggered -- otherwise the synthesised button would keep firing and
# bounce the player straight back out of the hub.
request_escape = false
cleared_countdown = Protocol.COUNTDOWN_NONE
GameLog.info("client", "entered instance %d (%s)" % [id, Protocol.InstanceKind.keys()[kind]])
instance_changed.emit()
hud_dirty.emit()
@@ -141,6 +178,7 @@ func on_snapshot(data: PackedByteArray) -> void:
var snap := NetCodec.decode_snapshot(data)
if not snap_curr.is_empty() and int(snap["tick"]) <= int(snap_curr["tick"]):
return # stale or duplicate; unreliable channel, newest wins
cleared_countdown = int(snap["cleared_countdown"])
snap_prev = snap_curr
snap_curr = snap
_interp = 0.0
@@ -166,6 +204,7 @@ func _reconcile(rec: Dictionary) -> void:
my_hp = int(rec["hp"])
my_alive = (int(rec["flags"]) & Protocol.F_ALIVE) != 0
my_escaping = (int(rec["flags"]) & Protocol.F_ESCAPING) != 0
my_spawn_grace = (int(rec["flags"]) & Protocol.F_SPAWN_GRACE) != 0
my_escape = float(rec["escape"])
hud_dirty.emit()