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:
@@ -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()
|
||||
|
||||
|
||||
+59
-3
@@ -8,10 +8,14 @@ extends RefCounted
|
||||
|
||||
# --- Snapshot ---------------------------------------------------------------
|
||||
|
||||
static func encode_snapshot(world: SimWorld) -> PackedByteArray:
|
||||
## [param cleared_countdown] is whole seconds until a cleared dungeon returns
|
||||
## its party, or Protocol.COUNTDOWN_NONE when that does not apply.
|
||||
static func encode_snapshot(world: SimWorld,
|
||||
cleared_countdown: int = Protocol.COUNTDOWN_NONE) -> PackedByteArray:
|
||||
var b := StreamPeerBuffer.new()
|
||||
b.big_endian = false
|
||||
b.put_u32(world.tick)
|
||||
b.put_u8(clampi(cleared_countdown, 0, Protocol.COUNTDOWN_NONE))
|
||||
|
||||
b.put_u8(mini(world.players.size(), 255))
|
||||
for p in world.players.values():
|
||||
@@ -27,6 +31,10 @@ static func encode_snapshot(world: SimWorld) -> PackedByteArray:
|
||||
flags |= Protocol.F_INVULN
|
||||
if p.escape_ticks > 0:
|
||||
flags |= Protocol.F_ESCAPING
|
||||
if p.spawn_grace > 0:
|
||||
flags |= Protocol.F_SPAWN_GRACE
|
||||
if p.linkdead:
|
||||
flags |= Protocol.F_LINKDEAD
|
||||
b.put_u8(flags)
|
||||
b.put_u8(clampi(roundi(p.escape_progress() * 255.0), 0, 255))
|
||||
# Echoed so the owning client knows how far to rewind when reconciling.
|
||||
@@ -62,7 +70,11 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
|
||||
var b := StreamPeerBuffer.new()
|
||||
b.big_endian = false
|
||||
b.data_array = data
|
||||
var snap := {"tick": b.get_u32(), "players": [], "enemies": [], "boss": null}
|
||||
var snap := {
|
||||
"tick": b.get_u32(),
|
||||
"cleared_countdown": b.get_u8(),
|
||||
"players": [], "enemies": [], "boss": null,
|
||||
}
|
||||
|
||||
var pcount := b.get_u8()
|
||||
for _i in pcount:
|
||||
@@ -99,7 +111,13 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
|
||||
# --- Events -----------------------------------------------------------------
|
||||
|
||||
## Events the client never sees; the instance layer consumes them server-side.
|
||||
const SERVER_ONLY := [SimEvent.Type.PORTAL_USED, SimEvent.Type.ESCAPE_COMPLETED]
|
||||
## These describe a decision the server is about to act on, not an outcome --
|
||||
## leaking them would tell a client about a message it might try to forge.
|
||||
const SERVER_ONLY := [
|
||||
SimEvent.Type.PORTAL_USED,
|
||||
SimEvent.Type.ESCAPE_COMPLETED,
|
||||
SimEvent.Type.RESPAWN_REQUESTED,
|
||||
]
|
||||
|
||||
|
||||
## [param server_tick] rides along so the client can fast-forward a bullet by
|
||||
@@ -232,3 +250,41 @@ static func decode_inputs(data: PackedByteArray) -> Array[InputFrame]:
|
||||
for _i in count:
|
||||
out.append(InputFrame.read(b))
|
||||
return out
|
||||
|
||||
|
||||
# --- Roster -----------------------------------------------------------------
|
||||
# Who is online and where. Low frequency (membership changes only), so it is
|
||||
# the one message that carries strings; everything hot stays fixed-width.
|
||||
|
||||
static func encode_roster(entries: Array[Dictionary]) -> PackedByteArray:
|
||||
var b := StreamPeerBuffer.new()
|
||||
b.big_endian = false
|
||||
b.put_u8(mini(entries.size(), 255))
|
||||
for e in entries:
|
||||
b.put_u32(int(e["peer"]))
|
||||
b.put_utf8_string(String(e["name"]))
|
||||
b.put_u8(int(e["kind"]))
|
||||
b.put_u32(int(e["instance"]))
|
||||
b.put_u8(1 if e["alive"] else 0)
|
||||
return b.data_array
|
||||
|
||||
|
||||
static func decode_roster(data: PackedByteArray) -> Array[Dictionary]:
|
||||
var out: Array[Dictionary] = []
|
||||
if data.size() < 1:
|
||||
return out
|
||||
var b := StreamPeerBuffer.new()
|
||||
b.big_endian = false
|
||||
b.data_array = data
|
||||
var count := b.get_u8()
|
||||
for _i in count:
|
||||
# get_utf8_string reads its own length prefix, so a truncated packet
|
||||
# yields empty strings rather than reading off the end.
|
||||
out.append({
|
||||
"peer": b.get_u32(),
|
||||
"name": b.get_utf8_string(),
|
||||
"kind": b.get_u8(),
|
||||
"instance": b.get_u32(),
|
||||
"alive": b.get_u8() == 1,
|
||||
})
|
||||
return out
|
||||
|
||||
+12
-1
@@ -3,7 +3,9 @@ extends RefCounted
|
||||
## Wire constants. Bump [constant VERSION] whenever a codec layout changes; the
|
||||
## server refuses mismatched clients at handshake rather than desyncing later.
|
||||
|
||||
const VERSION := 1
|
||||
## 2: added spawn-grace flag + cleared countdown to the snapshot, and the
|
||||
## roster message.
|
||||
const VERSION := 2
|
||||
const DEFAULT_PORT := 27015
|
||||
const MAX_CLIENTS := 32
|
||||
|
||||
@@ -22,3 +24,12 @@ enum InstanceKind { LOBBY, DUNGEON }
|
||||
const F_ALIVE := 1
|
||||
const F_INVULN := 2
|
||||
const F_ESCAPING := 4
|
||||
## Arrival protection: invulnerable and unable to shoot. Distinct from F_INVULN
|
||||
## so the client can label the status rather than just tint the ship.
|
||||
const F_SPAWN_GRACE := 8
|
||||
## The peer behind this player has dropped and is being channelled out.
|
||||
const F_LINKDEAD := 16
|
||||
|
||||
## Snapshot's cleared-countdown byte when the instance is not in the cleared
|
||||
## state -- 30s fits in a byte, so 255 is free to mean "not applicable".
|
||||
const COUNTDOWN_NONE := 255
|
||||
|
||||
+97
-18
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user