Fix permanent input-timing desync; no i-frames; UI respawn; guard dead joins
ci / verify (push) Successful in 45s
ci / verify (push) Successful in 45s
The real cause of the ship/bullet separation, which the previous commit only half-addressed. The server dropped inputs past a lead of 12 while the client only re-synced past 16, so a client whose lead drifted into 13-16 had every input silently rejected while believing its timing was fine. The server coasted on held_input and then stopped; the client kept predicting. The two separated permanently and the reconciler fought it every snapshot -- "shoved around". It needed two independent clocks to drift, hence "only after some time", and nothing in the loop could notice, hence "then persists". The listen-server diagnostic could never reproduce it: one process, one physics tick, lead constant by construction. Two defences: INPUT_MAX_LEAD (40) is now far wider than the client's correction band (3..20), asserted by tests/unit/test_input_lead.gd so narrowing it fails a test; and an ack-stall detector re-syncs when last_input_tick stops advancing, which catches the whole class regardless of cause -- lead alone cannot, because a wrong lead looks normal from the client. diag_prediction.gd now injects a +14 tick drift and exits non-zero unless the gap recovers. Also: - No invulnerability frames. Every bullet that touches a player lands; i-frames made dense patterns safer than sparse ones, which inverts the genre. Measured: a stationary player survives ~13.6s of the Warden's opening phase, ~17.5s drifting. spawn_grace remains the only invulnerable state. - Death is exited with a HUD button, disabled for the first 3s. The lockout is enforced in SimWorld, not just by graying the button -- a client that ignores its own UI still waits. The interact key no longer respawns. - Joining a server that is not there no longer drops the player into an empty lobby they cannot act in. Net.join() only creates an ENet object; the game scene now waits for the server to actually place us in an instance, with an 8s timeout, and headless runs exit non-zero instead of idling. Protocol 2 -> 3. 98 tests; check.sh, test.sh and smoke.sh all pass.
This commit is contained in:
@@ -35,6 +35,13 @@ var my_escape: float = 0.0
|
||||
var my_escaping: bool = false
|
||||
## Arrival protection: invulnerable and unable to shoot.
|
||||
var my_spawn_grace: bool = false
|
||||
## Seconds left before the HUD's return-to-hub button becomes available. The
|
||||
## server enforces the same lockout; this only drives the button's look.
|
||||
var my_respawn_wait: float = 0.0
|
||||
|
||||
## Set by the HUD button, not by a key. Death is deliberately exited through a
|
||||
## deliberate click rather than whatever the player happened to be holding.
|
||||
var request_respawn: 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.
|
||||
@@ -48,6 +55,12 @@ var roster: Array[Dictionary] = []
|
||||
## Protocol.COUNTDOWN_NONE outside that state.
|
||||
var cleared_countdown: int = Protocol.COUNTDOWN_NONE
|
||||
|
||||
## Backstop for input-numbering drift: if the server stops acknowledging new
|
||||
## inputs, our tick numbering has fallen outside its acceptance window and no
|
||||
## amount of waiting fixes it. Counted in snapshots, not ticks.
|
||||
var _last_acked: int = -1
|
||||
var _ack_stall: int = 0
|
||||
|
||||
var snap_prev: Dictionary = {}
|
||||
var snap_curr: Dictionary = {}
|
||||
var _interp: float = 0.0
|
||||
@@ -98,6 +111,11 @@ func _physics_process(delta: float) -> void:
|
||||
func _sample_input() -> InputFrame:
|
||||
if GameOpts.bot_client:
|
||||
return _bot_input()
|
||||
if not my_alive:
|
||||
# Downed: no movement, no fire, and the interact key does nothing. The
|
||||
# only way out is the HUD button, which sets request_respawn.
|
||||
var dead_buttons := InputFrame.BTN_INTERACT if request_respawn else 0
|
||||
return InputFrame.make(input_tick, Vector2.ZERO, aim, dead_buttons)
|
||||
var move := Input.get_vector("move_left", "move_right", "move_up", "move_down")
|
||||
var mouse := get_viewport().get_mouse_position() - get_viewport().get_visible_rect().size * 0.5
|
||||
var to_mouse := mouse - predicted_pos
|
||||
@@ -142,6 +160,17 @@ func on_welcome(peer_id: int) -> void:
|
||||
GameLog.info("client", "welcome, peer id %d" % peer_id)
|
||||
|
||||
|
||||
## Re-number our input stream relative to the server and drop the history that
|
||||
## was numbered under the old scheme -- replaying it would apply inputs the
|
||||
## server never accepted.
|
||||
func _resync_input_tick(server_tick: int, why: String) -> void:
|
||||
input_tick = server_tick + SimConfig.INPUT_TARGET_LEAD
|
||||
pending.clear()
|
||||
_ack_stall = 0
|
||||
_last_acked = -1
|
||||
GameLog.warn("client", "input re-sync: %s" % why)
|
||||
|
||||
|
||||
func on_roster(data: PackedByteArray) -> void:
|
||||
roster = NetCodec.decode_roster(data)
|
||||
hud_dirty.emit()
|
||||
@@ -158,7 +187,9 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
|
||||
predicted_pos = spawn
|
||||
pending.clear()
|
||||
server_tick_est = server_tick
|
||||
input_tick = server_tick + 8
|
||||
input_tick = server_tick + SimConfig.INPUT_TARGET_LEAD
|
||||
_last_acked = -1
|
||||
_ack_stall = 0
|
||||
my_alive = true
|
||||
my_hp = SimConfig.PLAYER_MAX_HP
|
||||
my_escape = 0.0
|
||||
@@ -168,6 +199,8 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
|
||||
# was triggered -- otherwise the synthesised button would keep firing and
|
||||
# bounce the player straight back out of the hub.
|
||||
request_escape = false
|
||||
request_respawn = false
|
||||
my_respawn_wait = 0.0
|
||||
cleared_countdown = Protocol.COUNTDOWN_NONE
|
||||
GameLog.info("client", "entered instance %d (%s)" % [id, Protocol.InstanceKind.keys()[kind]])
|
||||
instance_changed.emit()
|
||||
@@ -187,10 +220,12 @@ func on_snapshot(data: PackedByteArray) -> void:
|
||||
if server_tick_est < tick or server_tick_est > tick + 12:
|
||||
server_tick_est = tick
|
||||
# Keep the client roughly one buffer ahead of the server so inputs arrive
|
||||
# just before they are needed rather than late.
|
||||
# just before they are needed rather than late. The band is deliberately
|
||||
# narrower than the server's acceptance window (SimConfig.INPUT_MAX_LEAD),
|
||||
# so we always correct before the server starts dropping anything.
|
||||
var lead := input_tick - tick
|
||||
if lead < 2 or lead > 16:
|
||||
input_tick = tick + 8
|
||||
if lead < SimConfig.INPUT_LEAD_MIN or lead > SimConfig.INPUT_LEAD_MAX:
|
||||
_resync_input_tick(tick, "lead %d out of band" % lead)
|
||||
|
||||
for rec: Dictionary in snap["players"]:
|
||||
if int(rec["peer"]) == my_peer:
|
||||
@@ -206,9 +241,26 @@ func _reconcile(rec: Dictionary) -> void:
|
||||
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"])
|
||||
my_respawn_wait = float(rec["respawn_wait"])
|
||||
if my_alive:
|
||||
request_respawn = false
|
||||
hud_dirty.emit()
|
||||
|
||||
var acked := int(rec["last_input_tick"])
|
||||
|
||||
# If the server is not consuming anything we send, our numbering is outside
|
||||
# its window; lead alone cannot detect that, because a wrong lead looks
|
||||
# perfectly normal from here. This is what makes the failure self-healing.
|
||||
if acked == _last_acked:
|
||||
_ack_stall += 1
|
||||
if _ack_stall >= SimConfig.INPUT_ACK_STALL_LIMIT:
|
||||
_resync_input_tick(int(snap_curr["tick"]),
|
||||
"server stopped acknowledging input")
|
||||
return
|
||||
else:
|
||||
_last_acked = acked
|
||||
_ack_stall = 0
|
||||
|
||||
while not pending.is_empty() and pending[0].tick <= acked:
|
||||
pending.pop_front()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user