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
+51
View File
@@ -91,6 +91,43 @@ rate as tapping it correctly.
A client that stops sending coasts on its last input for `INPUT_MAX_AGE` ticks
and then stops, so a dropped connection does not leave a player sliding.
## Leaving a run, and why disconnecting is not an escape
The emergency escape is a **one-second server-owned channel** that damage does
*not* interrupt. That combination is deliberate and the two halves depend on
each other.
An interruptible channel sounds like the right kind of risk until you follow it
through: a player about to die under fire can never finish the channel, so
killing the game process becomes strictly better than using the button. The
escape hatch turns into the exploit.
So a dropped connection runs the same channel. On disconnect the peer is removed
from the send list but its player **stays in the world** (`SimPlayer.linkdead`),
treated as holding the escape button down, still fully killable, for the same
second everyone else pays. Only when that resolves is the peer forgotten. Pulling
the plug is therefore never cheaper than pressing the key, and there is no
timing window where it is.
Two details that are easy to get wrong and are covered by tests:
- An instance must not close while a linkdead body is still resolving
(`Instance.has_linkdead()`), or a solo player's drop would delete their body
on the next tick and hand back the exact exploit.
- Dying still ends the channel. That is the one thing the escape cannot beat,
and it is what keeps the second a real risk rather than a formality.
`tools/smoke.sh` SIGKILLs a bot mid-dungeon and asserts the server channels it
out rather than dropping it instantly.
## Arriving in a run
Entering a dungeon grants `SimConfig.SPAWN_GRACE_TICKS` (2s) of **arrival
protection**: invulnerable *and* unable to shoot. Both halves matter —
invulnerability alone would make the spawn point a free firing position. It is
flagged in the snapshot (`Protocol.F_SPAWN_GRACE`) so every client can draw it
on every protected ship, not just its own.
## Prediction and reconciliation
`ClientRuntime` keeps every unacknowledged `InputFrame`. Each snapshot echoes
@@ -99,6 +136,20 @@ frames, takes the server's authoritative position, and replays the rest through
the same `Movement.step_player()` the server used. Divergence over 24 px snaps;
smaller errors lerp at 0.3 so ordinary jitter does not read as rubber-banding.
`ClientRuntime` sets `process_physics_priority = -10` so it samples and sends
input *before* `ServerRuntime` ticks. This matters only on a listen server,
where both live in one process: with the default tree order the server ran
first, so input sampled on frame N was not consumed until frame N+1, leaving the
drawn ship a permanent one tick (4px at 240 u/s) ahead of the authoritative one
— and bullets, which spawn at the authoritative position, visibly trailed behind
the ship. `tools/diag_prediction.gd` measures this gap; it is currently ~0.1px
mean, down from a constant 4.00px.
The other half of that fix is `SimConfig.PLAYER_MUZZLE_OFFSET`, derived from the
*visual* radius rather than the hitbox. A remote client still draws its ship a
tick or two ahead of the server, and that margin is what keeps bullets emerging
from the nose rather than the middle of the sprite.
Remote players, enemies and the boss are interpolated between the last two
snapshots. Bullets are not interpolated — they are simulated, and a spawn event
carries the server tick it was generated at so the client fast-forwards the