Fix bullet/ship desync; rework death, escape, arrival and hub awareness
ci / verify (push) Successful in 45s

(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:
Adyrem
2026-09-03 18:43:19 +02:00
parent e2b849c6ef
commit 1dc1952a3c
29 changed files with 903 additions and 79 deletions
+32 -2
View File
@@ -11,6 +11,8 @@ const COL_LOCAL := Color(0.5, 1.0, 0.8)
const COL_REMOTE := Color(0.55, 0.75, 1.0)
const COL_DEAD := Color(0.4, 0.4, 0.45, 0.5)
const COL_PORTAL := Color(0.5, 0.9, 1.0)
## EnemyDef.visual index for the hub's practice target.
const VISUAL_DUMMY := 3
const ENEMY_COLORS := [
Color(0.95, 0.55, 0.55), # drifter
Color(0.85, 0.7, 0.35), # turret
@@ -71,10 +73,25 @@ func _draw_portal() -> void:
func _draw_enemy(e: Dictionary) -> void:
var col: Color = ENEMY_COLORS[clampi(int(e["visual"]), 0, ENEMY_COLORS.size() - 1)]
var r: float = e["radius"]
if int(e["visual"]) == VISUAL_DUMMY:
_draw_target_dummy(e["pos"], r, col)
return
draw_circle(e["pos"], r, Color(col, 0.35))
draw_arc(e["pos"], r, 0.0, TAU, 24, col, 2.0)
## The hub dummy is a shooting-range target, not an enemy. It used to be drawn
## as a plain grey disc with 100k hit points, which read as scenery -- nothing
## about it said "shoot me" and nothing visibly happened when you did. Concentric
## rings make the intent obvious at a glance.
func _draw_target_dummy(pos: Vector2, r: float, col: Color) -> void:
draw_circle(pos, r, Color(col, 0.18))
for i in 3:
var ring := r * (1.0 - 0.3 * float(i))
draw_arc(pos, ring, 0.0, TAU, 24, Color(col, 0.5 + 0.15 * float(i)), 1.5)
draw_circle(pos, r * 0.12, Color(1.0, 0.5, 0.4, 0.9))
func _draw_boss() -> void:
var b := client.boss_state()
if b.is_empty():
@@ -87,10 +104,13 @@ func _draw_boss() -> void:
func _draw_remote_player(p: Dictionary) -> void:
var alive := (int(p["flags"]) & Protocol.F_ALIVE) != 0
var flags := int(p["flags"])
var alive := (flags & Protocol.F_ALIVE) != 0
var col := COL_REMOTE if alive else COL_DEAD
_draw_ship(p["pos"], p["aim"], col, alive)
if (int(p["flags"]) & Protocol.F_ESCAPING) != 0:
if (flags & Protocol.F_SPAWN_GRACE) != 0:
_draw_grace_ring(p["pos"])
if (flags & Protocol.F_ESCAPING) != 0:
_draw_escape_ring(p["pos"], float(p["escape"]), col)
@@ -99,10 +119,20 @@ func _draw_local_player() -> void:
_draw_ship(client.predicted_pos, client.aim, COL_DEAD, false)
return
_draw_ship(client.predicted_pos, client.aim, COL_LOCAL, true)
if client.my_spawn_grace:
_draw_grace_ring(client.predicted_pos)
if client.my_escaping:
_draw_escape_ring(client.predicted_pos, client.my_escape, COL_LOCAL)
## Arrival protection. Drawn on every protected ship, not just your own, so it
## is clear who can currently be shot and who cannot.
func _draw_grace_ring(pos: Vector2) -> void:
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.012)
draw_arc(pos, SimConfig.PLAYER_VISUAL_RADIUS + 5.0, 0.0, TAU, 28,
Color(0.55, 0.85, 1.0, 0.35 + 0.45 * pulse), 2.0)
## Drawn at PLAYER_VISUAL_RADIUS, larger than the PLAYER_RADIUS hitbox actually
## used for hits -- see the comment on those constants in sim_config.gd. The
## mismatch is deliberate, not a placeholder: a bullet can visibly clip the