From b25156a438094809ba3893def3e1e99f1a7aac17 Mon Sep 17 00:00:00 2001 From: Adyrem Date: Thu, 3 Sep 2026 19:19:33 +0200 Subject: [PATCH] Fix permanent input-timing desync; no i-frames; UI respawn; guard dead joins 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. --- CLAUDE.md | 9 +++ docs/NETCODE.md | 49 +++++++++++++++++ src/core/sim_config.gd | 39 +++++++++++-- src/main.gd | 50 +++++++++++++++++ src/net/client_runtime.gd | 60 ++++++++++++++++++-- src/net/net_codec.gd | 6 +- src/net/protocol.gd | 11 ++-- src/sim/sim_player.gd | 15 +++-- src/sim/sim_world.gd | 11 ++-- src/ui/hud.gd | 37 +++++++++++-- src/ui/main_menu.gd | 25 ++++++--- src/view/game_scene.gd | 7 +++ tests/integration/test_replica_parity.gd | 1 - tests/unit/test_emergency_escape.gd | 3 - tests/unit/test_input_lead.gd | 70 ++++++++++++++++++++++++ tests/unit/test_input_lead.gd.uid | 1 + tests/unit/test_server_authority.gd | 51 ++++++++++++++--- tools/diag_prediction.gd | 29 +++++++++- 18 files changed, 423 insertions(+), 51 deletions(-) create mode 100644 tests/unit/test_input_lead.gd create mode 100644 tests/unit/test_input_lead.gd.uid diff --git a/CLAUDE.md b/CLAUDE.md index 68ee99d..9383bed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,15 @@ ticks in milliseconds with no SceneTree. - **`PLAYER_RADIUS` (hitbox) < `PLAYER_VISUAL_RADIUS` (sprite), and `PLAYER_MUZZLE_OFFSET` derives from the visual one.** Prefer a visible near-miss over an invisible hit; keep the muzzle clear of the sprite. +- **`INPUT_MAX_LEAD` must stay well above `INPUT_LEAD_MAX`.** The server's + input acceptance window has to be wider than the band in which the client + re-syncs its own numbering. Violate it and drifting clocks land in a silent + dead zone where the server rejects everything and the client never notices — + the ship and the authoritative position separate permanently. Pinned by + `tests/unit/test_input_lead.gd`. +- **No i-frames.** Every bullet that touches a player lands; `spawn_grace` is + the only invulnerable state. Do not reintroduce post-hit immunity — it makes + dense patterns safer than sparse ones. - **A disconnect is not an exit.** Dropping in a dungeon keeps the player in the world as `linkdead`, channelling out over the same second the escape costs. Damage must never cancel the escape channel, or quitting beats the button. diff --git a/docs/NETCODE.md b/docs/NETCODE.md index 2692476..51b081b 100644 --- a/docs/NETCODE.md +++ b/docs/NETCODE.md @@ -71,6 +71,50 @@ slow relative to bullet speed, so this reads as latency rather than unfairness. If it becomes a complaint, the fix is to lag-compensate **player bullets against enemies only**, never enemy bullets against players. +## Input timing, and a bug worth remembering + +The client numbers its input frames `INPUT_TARGET_LEAD` ticks ahead of the +server's tick; the server accepts anything within `INPUT_MAX_LEAD` and re-syncs +nothing itself. **These two numbers must be chosen together.** + +They originally were not — the server dropped inputs past a lead of 12, while +the client only corrected itself past 16. A client whose lead drifted into 13–16 +therefore had *every* input silently rejected, while believing its own timing +was fine. The server, receiving nothing, coasted on the last input it had +(`held_input`) and then stopped; the client kept predicting forward. The ship +and the authoritative position separated permanently, the reconciler fought it +every snapshot, and the player got shoved around. It needed two independent +clocks to drift apart, so it appeared only after minutes of play — and never +recovered, because nothing in the loop could notice. + +Two defences now: + +1. `INPUT_MAX_LEAD` (40) is far wider than the client's correction band + (`INPUT_LEAD_MIN` 3 … `INPUT_LEAD_MAX` 20), so the client always re-syncs + long before the server starts refusing anything. `test_input_lead.gd` + asserts that ordering directly, so narrowing the window fails a test rather + than shipping. +2. An **ack-stall detector**: if `last_input_tick` does not advance across + `INPUT_ACK_STALL_LIMIT` snapshots, the client concludes its numbering is + outside the window and hard re-syncs. Lead alone cannot detect this, because + a wrong lead looks perfectly normal from the client's side. This is the + backstop that makes the whole class of failure self-healing regardless of + cause. + +`tools/diag_prediction.gd` injects a +14 tick drift mid-run and asserts the gap +returns to normal; it exits non-zero if it does not. + +## No invulnerability frames + +A hit grants no immunity — every bullet that touches you deals its damage. In a +bullet hell the wall *is* the threat, and i-frames invert that: you are punished +for the first bullet of a pattern and gifted the next thirty, which makes dense +patterns *safer* than sparse ones. Measured cost: a stationary player survives +~13.6s of the Warden's opening phase, ~17.5s while drifting aimlessly. + +Arrival protection (`SPAWN_GRACE_TICKS`) is the sole exception, and it is a +transition, not a combat mechanic. + ## Input validation `SimWorld.queue_input()` is the single audit point. It drops: @@ -81,6 +125,11 @@ enemies only**, never enemy bullets against players. | Stale input | `f.tick < tick - INPUT_MAX_AGE` | | Input claiming the future | `f.tick > tick + INPUT_MAX_LEAD` | | Flood | queue capped at `INPUT_MAX_AGE`, oldest dropped | +| Leaving the hub early after death | `RESPAWN_LOCKOUT_TICKS`, server-side | + +The respawn lockout is worth calling out: the HUD disables its button for the +same three seconds, but that is presentation. A client that ignores its own UI +and holds the bit down still waits, because the check lives in `SimWorld`. Beyond that, the wire format itself constrains the cheat surface: the move vector is two signed bytes at 1/100 precision, and `Movement.sanitize_move()` diff --git a/src/core/sim_config.gd b/src/core/sim_config.gd index 03d43d4..0528fdc 100644 --- a/src/core/sim_config.gd +++ b/src/core/sim_config.gd @@ -42,19 +42,46 @@ const PLAYER_BULLET_SPEED := 620.0 const PLAYER_BULLET_RADIUS := 4.0 const PLAYER_BULLET_LIFETIME := 90 # ticks const PLAYER_BULLET_DAMAGE := 6 -const PLAYER_IFRAMES := 36 # ticks of invulnerability after a hit +## There are deliberately NO invulnerability frames after a hit. In a bullet +## hell the wall of bullets IS the threat, and i-frames turn a dense pattern +## into a single cheap hit -- you get punished for the first bullet and gifted +## the next thirty. Every bullet that touches you lands. Arrival protection +## below is the one exception, and it is a transition, not a combat mechanic. + ## Invulnerable *and* unable to shoot on entering a dungeon, so arriving into a ## live bullet field is survivable. Both halves matter: invulnerability alone ## would make the spawn point a free firing position. const SPAWN_GRACE_TICKS := 120 # 2 seconds +## A downed player cannot leave for the hub until this has elapsed. Enforced +## here rather than only by grey-ing out the button, because a disabled button +## is a suggestion -- the server is the only thing a modified client cannot +## argue with. +const RESPAWN_LOCKOUT_TICKS := 180 # 3 seconds -# --- Anti-cheat guards ------------------------------------------------------ +# --- Input timing ----------------------------------------------------------- +# The client numbers its input frames INPUT_TARGET_LEAD ahead of the server's +# tick and re-syncs whenever its lead leaves [INPUT_LEAD_MIN, INPUT_LEAD_MAX]. +# The server accepts anything within INPUT_MAX_LEAD. +# +# INPUT_MAX_LEAD *must* stay comfortably above INPUT_LEAD_MAX. When it did not +# (12 vs 16), a client whose lead drifted into the gap had every input silently +# rejected while believing its timing was fine -- so the server coasted on a +# stale input and the player's ship and the authoritative position separated +# permanently. Two independent clocks have to drift for it to happen, which is +# why it only showed up minutes in, and never recovered. ## Inputs older than this (relative to the newest accepted) are discarded. const INPUT_MAX_AGE := 30 -## Inputs claiming to be further ahead than this of the server tick are clamped. -const INPUT_MAX_LEAD := 12 -## Hard ceiling on inputs consumed from one peer in a single tick. -const INPUT_MAX_PER_TICK := 4 +## Where the client aims to sit. +const INPUT_TARGET_LEAD := 8 +## The client re-syncs its numbering outside this band. +const INPUT_LEAD_MIN := 3 +const INPUT_LEAD_MAX := 20 +## Inputs claiming to be further ahead than this of the server tick are dropped. +const INPUT_MAX_LEAD := 40 +## Snapshots with no forward progress on last_input_tick before the client +## assumes its numbering is out of the server's window and hard re-syncs. The +## backstop that makes the failure above self-healing whatever its cause. +const INPUT_ACK_STALL_LIMIT := 8 # --- Emergency escape ------------------------------------------------------- const ESCAPE_CHANNEL_TICKS := 60 # 1 second diff --git a/src/main.gd b/src/main.gd index 797c9f0..1fce58c 100644 --- a/src/main.gd +++ b/src/main.gd @@ -5,9 +5,15 @@ extends Node const GAME_SCENE := preload("res://src/game.tscn") +## How long to wait for a server to answer before giving up. ENet's own +## connection_failed can take considerably longer than a player's patience. +const CONNECT_TIMEOUT_SEC := 8.0 + var _menu: Control = null var _game: Node = null var _ticks: int = 0 +var _connecting: bool = false +var _connect_deadline: float = 0.0 func _ready() -> void: @@ -30,6 +36,8 @@ func _ready() -> void: func _physics_process(_delta: float) -> void: + if _connecting and _now() > _connect_deadline: + _abort_connect("no answer from %s:%d" % [GameOpts.host_address, GameOpts.port]) _ticks += 1 if GameOpts.autoquit_ticks > 0 and _ticks >= GameOpts.autoquit_ticks: GameLog.info("main", "autoquit after %d ticks" % _ticks) @@ -59,15 +67,49 @@ func _show_menu() -> void: add_child(_menu) +## Note what this deliberately does NOT do: enter the game. Net.join() only +## means "an ENet client object was created" -- it says nothing about anything +## being at the other end. Showing the world at that point dropped the player +## into an empty lobby they could not act in, because no server had ever +## acknowledged them. The game scene now waits for the server to actually place +## us in an instance. func _join(address: String, port: int) -> void: GameOpts.host_address = address GameOpts.port = port if Net.join(address, port) != OK: _menu_status(Net.last_error) return + _connecting = true + _connect_deadline = _now() + CONNECT_TIMEOUT_SEC + _menu_busy(true) + _menu_status("connecting to %s:%d ..." % [address, port]) + Net.client.instance_changed.connect(_on_session_ready, CONNECT_ONE_SHOT) + + +func _now() -> float: + return float(Time.get_ticks_msec()) / 1000.0 + + +## The server has accepted the handshake and placed us somewhere. Only now is +## there a world worth drawing. +func _on_session_ready() -> void: + _connecting = false _enter_game() +func _abort_connect(reason: String) -> void: + _connecting = false + GameLog.error("main", "connection failed: %s" % reason) + if GameOpts.autojoin: + # Headless/scripted run: there is no menu to fall back to, and silently + # idling would make a broken smoke test look like a passing one. + get_tree().quit(1) + return + Net.shutdown() + _show_menu() + _menu_status(reason) + + ## Listen server: one process runs the authoritative world and a local client ## connected over loopback. The client takes no shortcuts because of it -- it ## still only sends input and still only learns outcomes from snapshots. @@ -100,9 +142,17 @@ func _menu_status(text: String) -> void: _menu.set_status(text) +func _menu_busy(busy: bool) -> void: + if _menu != null: + _menu.set_busy(busy) + + func _on_net_state(state: Net.State) -> void: match state: Net.State.FAILED: + if _connecting: + _abort_connect(Net.last_error) + return _show_menu() _menu_status(Net.last_error) Net.State.OFFLINE: diff --git a/src/net/client_runtime.gd b/src/net/client_runtime.gd index da8c055..ecd99eb 100644 --- a/src/net/client_runtime.gd +++ b/src/net/client_runtime.gd @@ -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() diff --git a/src/net/net_codec.gd b/src/net/net_codec.gd index 7855c7c..1da7f5a 100644 --- a/src/net/net_codec.gd +++ b/src/net/net_codec.gd @@ -27,8 +27,6 @@ static func encode_snapshot(world: SimWorld, var flags := 0 if p.alive: flags |= Protocol.F_ALIVE - if p.iframes > 0: - flags |= Protocol.F_INVULN if p.escape_ticks > 0: flags |= Protocol.F_ESCAPING if p.spawn_grace > 0: @@ -37,6 +35,9 @@ static func encode_snapshot(world: SimWorld, flags |= Protocol.F_LINKDEAD b.put_u8(flags) b.put_u8(clampi(roundi(p.escape_progress() * 255.0), 0, 255)) + # Whole ticks are more than the HUD needs; a byte of tenths of a second + # covers the lockout with room to spare. + b.put_u8(clampi(roundi(float(p.respawn_lockout) / 6.0), 0, 255)) # Echoed so the owning client knows how far to rewind when reconciling. b.put_u32(p.last_input_tick) @@ -85,6 +86,7 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary: "hp": b.get_u16(), "flags": b.get_u8(), "escape": float(b.get_u8()) / 255.0, + "respawn_wait": float(b.get_u8()) / 10.0, "last_input_tick": b.get_u32(), }) diff --git a/src/net/protocol.gd b/src/net/protocol.gd index e6b2e5d..85b6c51 100644 --- a/src/net/protocol.gd +++ b/src/net/protocol.gd @@ -4,8 +4,10 @@ extends RefCounted ## server refuses mismatched clients at handshake rather than desyncing later. ## 2: added spawn-grace flag + cleared countdown to the snapshot, and the -## roster message. -const VERSION := 2 +## roster message. +## 3: dropped the post-hit invulnerability flag, added the respawn-lockout byte +## to each player record. +const VERSION := 3 const DEFAULT_PORT := 27015 const MAX_CLIENTS := 32 @@ -22,10 +24,9 @@ enum InstanceKind { LOBBY, DUNGEON } ## Player flags packed into the snapshot's per-player byte. 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. +## Arrival protection: invulnerable and unable to shoot. The only invulnerable +## state there is -- see the i-frames note in SimConfig. const F_SPAWN_GRACE := 8 ## The peer behind this player has dropped and is being channelled out. const F_LINKDEAD := 16 diff --git a/src/sim/sim_player.gd b/src/sim/sim_player.gd index eaff0e8..9afa836 100644 --- a/src/sim/sim_player.gd +++ b/src/sim/sim_player.gd @@ -9,10 +9,11 @@ var pos := Vector2.ZERO var aim: float = 0.0 var hp: int = SimConfig.PLAYER_MAX_HP var alive: bool = true -var iframes: int = 0 var fire_cooldown: int = 0 ## Ticks of arrival protection left: invulnerable, and unable to shoot. var spawn_grace: int = 0 +## Ticks before a downed player may ask to return to the hub. +var respawn_lockout: int = 0 ## The peer's connection dropped, but the player is deliberately still in the ## world. Held here rather than deleted so a disconnect cannot be used to dodge @@ -37,9 +38,10 @@ func escape_progress() -> float: return clampf(float(escape_ticks) / float(SimConfig.ESCAPE_CHANNEL_TICKS), 0.0, 1.0) -## Invulnerable while arriving, or in the usual post-hit window. +## Arrival protection only -- there is no post-hit invulnerability. See the +## note on SimConfig.SPAWN_GRACE_TICKS. func invulnerable() -> bool: - return spawn_grace > 0 or iframes > 0 + return spawn_grace > 0 ## Arrival protection also locks the gun, so the spawn point is not a free @@ -54,8 +56,13 @@ func reset_for_instance(spawn: Vector2, grace: int = 0) -> void: pos = spawn hp = SimConfig.PLAYER_MAX_HP alive = true - iframes = 0 spawn_grace = grace + respawn_lockout = 0 fire_cooldown = 0 escape_ticks = 0 input_queue.clear() + + +## True once the lockout has run out and the hub is available again. +func can_request_respawn() -> bool: + return not alive and respawn_lockout <= 0 diff --git a/src/sim/sim_world.gd b/src/sim/sim_world.gd index 99986c2..c62a8b2 100644 --- a/src/sim/sim_world.gd +++ b/src/sim/sim_world.gd @@ -149,8 +149,6 @@ func step() -> void: func _step_players() -> void: for p in players.values(): - if p.iframes > 0: - p.iframes -= 1 if p.spawn_grace > 0: p.spawn_grace -= 1 if p.fire_cooldown > 0: @@ -159,10 +157,13 @@ func _step_players() -> void: var frame := _take_input(p) if not p.alive: + if p.respawn_lockout > 0: + p.respawn_lockout -= 1 # No timed respawn: a downed player waits for the hub. Asking to go # is an input like any other, so a dead client cannot be revived by - # anything except its own request reaching the server. - if frame.pressed(InputFrame.BTN_INTERACT) or p.linkdead: + # anything except its own request reaching the server -- and not + # before the lockout expires, however early its UI lets it ask. + if p.can_request_respawn() and (frame.pressed(InputFrame.BTN_INTERACT) or p.linkdead): events.append({"t": SimEvent.Type.RESPAWN_REQUESTED, "peer": p.peer_id}) continue @@ -372,11 +373,11 @@ func _kill_bullet(slot: int) -> void: ## SimConfig.ESCAPE_CHANNEL_TICKS for why that would reward pulling the plug. func _damage_player(p: SimPlayer, amount: int) -> void: p.hp = maxi(p.hp - amount, 0) - p.iframes = SimConfig.PLAYER_IFRAMES events.append({"t": SimEvent.Type.PLAYER_HIT, "peer": p.peer_id, "dmg": amount, "hp": p.hp}) if p.hp <= 0: p.alive = false p.escape_ticks = 0 + p.respawn_lockout = SimConfig.RESPAWN_LOCKOUT_TICKS events.append({"t": SimEvent.Type.PLAYER_DIED, "peer": p.peer_id}) diff --git a/src/ui/hud.gd b/src/ui/hud.gd index 2b01dfc..812d18a 100644 --- a/src/ui/hud.gd +++ b/src/ui/hud.gd @@ -6,9 +6,12 @@ const MARGIN := 24.0 const BAR_W := 260.0 const BAR_H := 16.0 +signal respawn_pressed + var _canvas: Control var _status: Label var _hint: Label +var _respawn_button: Button var _hit_flash: float = 0.0 var client: ClientRuntime = null @@ -38,6 +41,20 @@ func _ready() -> void: _hint.offset_left = MARGIN _hint.offset_top = -56.0 + # A real Button rather than something drawn in _draw_hud: this is the one + # HUD element the player has to actually click, and it starts disabled so + # death is not exited by whatever key happened to be under a finger. + _respawn_button = Button.new() + _respawn_button.custom_minimum_size = Vector2(240.0, 40.0) + _respawn_button.set_anchors_and_offsets_preset(Control.PRESET_CENTER) + _respawn_button.offset_left = -120.0 + _respawn_button.offset_right = 120.0 + _respawn_button.offset_top = 40.0 + _respawn_button.offset_bottom = 80.0 + _respawn_button.pressed.connect(func() -> void: respawn_pressed.emit()) + _respawn_button.visible = false + _canvas.add_child(_respawn_button) + func _make_label(pos: Vector2) -> Label: var l := Label.new() @@ -53,9 +70,23 @@ func _process(delta: float) -> void: _hit_flash = maxf(_hit_flash - delta * 2.5, 0.0) _status.text = _status_text() _hint.text = _hint_text() + _update_respawn_button() _canvas.queue_redraw() +func _update_respawn_button() -> void: + var downed := client != null and not client.my_alive + _respawn_button.visible = downed + if not downed: + return + # Mirrors SimConfig.RESPAWN_LOCKOUT_TICKS, which the server enforces + # independently -- this is presentation, not the rule. + var ready := client.my_respawn_wait <= 0.0 + _respawn_button.disabled = not ready + _respawn_button.text = "Return to hub" if ready \ + else "Return to hub (%.0fs)" % ceil(client.my_respawn_wait) + + func _status_text() -> String: if client == null: return "connecting..." @@ -69,7 +100,7 @@ func _hint_text() -> String: if client == null: return "" if not client.my_alive: - return "DOWN -- press E to return to the hub" + return "DOWN" if client.instance_kind == Protocol.InstanceKind.LOBBY: return "WASD move mouse aim LMB fire E on the ring to enter a dungeon Esc menu" return "WASD move mouse aim LMB fire hold F to return to the hub Esc menu" @@ -106,9 +137,7 @@ func _draw_hud() -> void: var centre := _canvas.size * 0.5 _canvas.draw_string(ThemeDB.fallback_font, centre - Vector2(52.0, 8.0), "DOWN", HORIZONTAL_ALIGNMENT_LEFT, -1, 34, Color(1.0, 0.4, 0.4)) - _canvas.draw_string(ThemeDB.fallback_font, centre - Vector2(118.0, -18.0), - "press E to return to the hub", HORIZONTAL_ALIGNMENT_LEFT, -1, 16, - Color(1.0, 0.75, 0.75)) + if _hit_flash > 0.0: _canvas.draw_rect(Rect2(Vector2.ZERO, _canvas.size), diff --git a/src/ui/main_menu.gd b/src/ui/main_menu.gd index e99d75c..1eae6ac 100644 --- a/src/ui/main_menu.gd +++ b/src/ui/main_menu.gd @@ -9,6 +9,8 @@ var _address: LineEdit var _port: LineEdit var _name: LineEdit var _status: Label +var _join_button: Button +var _host_button: Button func _ready() -> void: @@ -43,15 +45,15 @@ func _ready() -> void: _address = _field(panel, "server address", GameOpts.host_address) _port = _field(panel, "port", str(GameOpts.port)) - var join := Button.new() - join.text = "Join" - join.pressed.connect(_on_join) - panel.add_child(join) + _join_button = Button.new() + _join_button.text = "Join" + _join_button.pressed.connect(_on_join) + panel.add_child(_join_button) - var host := Button.new() - host.text = "Host and play (listen server)" - host.pressed.connect(_on_host) - panel.add_child(host) + _host_button = Button.new() + _host_button.text = "Host and play (listen server)" + _host_button.pressed.connect(_on_host) + panel.add_child(_host_button) var quit := Button.new() quit.text = "Quit" @@ -78,6 +80,13 @@ func set_status(text: String) -> void: _status.text = text +## Locks the connect buttons while an attempt is in flight, so a second click +## cannot start a competing connection over the top of the first. +func set_busy(busy: bool) -> void: + _join_button.disabled = busy + _host_button.disabled = busy + + func _commit_name() -> void: var clean := _name.text.strip_edges() if not clean.is_empty(): diff --git a/src/view/game_scene.gd b/src/view/game_scene.gd index 42b1964..426c40b 100644 --- a/src/view/game_scene.gd +++ b/src/view/game_scene.gd @@ -15,6 +15,7 @@ func _ready() -> void: get_viewport().size_changed.connect(_recentre) menu.return_to_hub_requested.connect(_on_return_to_hub) menu.disconnect_requested.connect(_on_disconnect) + hud.respawn_pressed.connect(_on_respawn_pressed) func _recentre() -> void: @@ -42,5 +43,11 @@ func _on_disconnect() -> void: Net.shutdown() +## Sets the intent bit; the server decides whether the lockout has expired. +func _on_respawn_pressed() -> void: + if _bound != null: + _bound.request_respawn = true + + func _on_local_hit(_damage: int) -> void: hud.flash_hit() diff --git a/tests/integration/test_replica_parity.gd b/tests/integration/test_replica_parity.gd index 92710c3..e4b6aab 100644 --- a/tests/integration/test_replica_parity.gd +++ b/tests/integration/test_replica_parity.gd @@ -81,7 +81,6 @@ func test_bullets_killed_by_a_hit_are_removed_from_the_replica() -> void: server.add_player(1, "tester") # Just below the boss, inside the ring pattern rather than at its centre. server.players[1].pos = Vector2(0.0, 20.0) - server.players[1].iframes = 0 server.spawn_boss(Content.warden()) var hits := 0 for _i in 900: diff --git a/tests/unit/test_emergency_escape.gd b/tests/unit/test_emergency_escape.gd index 6dc1d65..535852e 100644 --- a/tests/unit/test_emergency_escape.gd +++ b/tests/unit/test_emergency_escape.gd @@ -9,7 +9,6 @@ const PEER := 3 func before_each() -> void: world = SimWorld.new(1) world.add_player(PEER, "tester") - world.players[PEER].iframes = 0 func _hold_escape(ticks: int) -> void: @@ -65,7 +64,6 @@ func test_dying_ends_the_channel() -> void: _hold_escape(20) var p: SimPlayer = world.players[PEER] p.hp = 1 - p.iframes = 0 world.pool.spawn(p.pos, Vector2.ZERO, 6.0, 60, 999, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) world.step() @@ -91,7 +89,6 @@ func test_a_linkdead_player_is_still_killable_while_channelling() -> void: var p: SimPlayer = world.players[PEER] p.linkdead = true p.hp = 1 - p.iframes = 0 world.step() world.pool.spawn(p.pos, Vector2.ZERO, 6.0, 60, 999, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) diff --git a/tests/unit/test_input_lead.gd b/tests/unit/test_input_lead.gd new file mode 100644 index 0000000..bf70d11 --- /dev/null +++ b/tests/unit/test_input_lead.gd @@ -0,0 +1,70 @@ +extends GutTest +## The client numbers its input frames a fixed "lead" ahead of the server's +## tick, and the server accepts a window around its own tick. These two numbers +## have to be chosen together. +## +## They were not, and the result was the worst kind of bug: the server silently +## dropped every input from a client whose lead had drifted into the gap between +## "server stops accepting" and "client notices", the client never corrected +## because by its own reckoning nothing was wrong, and the server coasted on a +## stale input forever. It needed two independent clocks to drift apart, so it +## only appeared after a few minutes -- and then never went away. + +var world: SimWorld +const PEER := 5 + + +func before_each() -> void: + world = SimWorld.new(1) + world.add_player(PEER, "tester") + world.tick = 1000 + + +func _queue_at_lead(lead: int) -> void: + var frames: Array[InputFrame] = [ + InputFrame.make(world.tick + lead, Vector2.RIGHT, 0.0, 0)] + world.queue_input(PEER, frames) + + +## The invariant that was violated. The client corrects itself at +## INPUT_LEAD_MAX; the server must keep accepting well past that, or there is a +## band where the server refuses input the client still thinks is fine. +func test_the_server_accepts_every_lead_the_client_tolerates() -> void: + assert_gt(SimConfig.INPUT_MAX_LEAD, SimConfig.INPUT_LEAD_MAX, + "the server's acceptance window must be wider than the client's " + + "correction band, or drift lands in a silent dead zone") + + +func test_inputs_across_the_whole_client_band_are_accepted() -> void: + for lead in range(SimConfig.INPUT_LEAD_MIN, SimConfig.INPUT_LEAD_MAX + 1): + world.players[PEER].input_queue.clear() + world.players[PEER].last_input_tick = 0 + _queue_at_lead(lead) + assert_eq(world.players[PEER].input_queue.size(), 1, + "lead %d is inside the band the client will not correct, so the " % lead + + "server has to accept it") + + +func test_absurd_lead_is_still_rejected() -> void: + _queue_at_lead(SimConfig.INPUT_MAX_LEAD + 1) + assert_eq(world.players[PEER].input_queue.size(), 0, + "the window is wider, not gone -- a client claiming a far-future tick " + + "still buys nothing") + + +## What the drift actually looked like from the server's side: nothing arrives, +## so it coasts on the last input it saw. That is correct behaviour for a brief +## hiccup and catastrophic as a permanent state, which is why the client now +## has a stall detector rather than relying on its lead estimate alone. +func test_a_starved_server_coasts_then_stops() -> void: + var p: SimPlayer = world.players[PEER] + _queue_at_lead(SimConfig.INPUT_TARGET_LEAD) + world.step() + var moved_once: Vector2 = p.pos + assert_ne(moved_once, Vector2.ZERO, "setup: the input should have moved us") + for _i in SimConfig.INPUT_MAX_AGE + 5: + world.step() + var coasted: Vector2 = p.pos + for _i in 60: + world.step() + assert_eq(p.pos, coasted, "coasting has to end, or a silent client drifts forever") diff --git a/tests/unit/test_input_lead.gd.uid b/tests/unit/test_input_lead.gd.uid new file mode 100644 index 0000000..27d6f6a --- /dev/null +++ b/tests/unit/test_input_lead.gd.uid @@ -0,0 +1 @@ +uid://xemxybh1gvvo diff --git a/tests/unit/test_server_authority.gd b/tests/unit/test_server_authority.gd index 13a12b0..fe6f48c 100644 --- a/tests/unit/test_server_authority.gd +++ b/tests/unit/test_server_authority.gd @@ -91,7 +91,6 @@ func test_a_starved_player_eventually_stops_moving() -> void: func test_enemy_bullets_damage_the_player_and_are_consumed() -> void: var p: SimPlayer = world.players[PEER] p.pos = Vector2.ZERO - p.iframes = 0 world.pool.spawn(Vector2(-1.0, 0.0), Vector2.ZERO, 6.0, 60, 25, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) world.step() @@ -99,22 +98,37 @@ func test_enemy_bullets_damage_the_player_and_are_consumed() -> void: assert_eq(world.pool.live_count, 0, "a bullet that hits must be consumed") -func test_invulnerability_frames_stop_a_second_hit() -> void: +## The inverse of what this asserted before. There are no i-frames: in a bullet +## hell the wall is the threat, and letting one hit grant immunity to the rest +## of the wall inverts the whole risk model. +func test_every_bullet_in_a_wall_lands() -> void: var p: SimPlayer = world.players[PEER] p.pos = Vector2.ZERO - p.iframes = 0 - for _i in 2: + for _i in 3: world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) world.step() - assert_eq(p.hp, SimConfig.PLAYER_MAX_HP - 25, "two bullets in one tick is still one hit") + assert_eq(p.hp, SimConfig.PLAYER_MAX_HP - 75, + "three bullets is three hits, not one") + + +func test_consecutive_ticks_both_deal_damage() -> void: + var p: SimPlayer = world.players[PEER] + p.pos = Vector2.ZERO + world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 10, + SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) + world.step() + world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 10, + SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) + world.step() + assert_eq(p.hp, SimConfig.PLAYER_MAX_HP - 20, + "there is no window of immunity after a hit") func test_a_replica_world_never_resolves_a_hit() -> void: world.authoritative = false var p: SimPlayer = world.players[PEER] p.pos = Vector2.ZERO - p.iframes = 0 world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) world.step() @@ -126,7 +140,6 @@ func _kill_player() -> SimPlayer: var p: SimPlayer = world.players[PEER] p.pos = Vector2.ZERO p.hp = 5 - p.iframes = 0 p.spawn_grace = 0 world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) @@ -150,6 +163,7 @@ func test_a_downed_player_stays_down_without_input() -> void: func test_a_downed_player_asking_to_respawn_is_reported_once_per_tick() -> void: _kill_player() + _drive(SimConfig.RESPAWN_LOCKOUT_TICKS) world.drain_events() _drive(1, Vector2.ZERO, InputFrame.BTN_INTERACT) assert_eq(_events_of(SimEvent.Type.RESPAWN_REQUESTED).size(), 1, @@ -159,6 +173,28 @@ func test_a_downed_player_asking_to_respawn_is_reported_once_per_tick() -> void: assert_false(world.players[PEER].alive) +## The HUD disables its button for the same duration, but that is presentation. +## A client that ignores its own UI and spams the bit still has to wait. +func test_the_respawn_lockout_is_enforced_by_the_server() -> void: + _kill_player() + world.drain_events() + _drive(SimConfig.RESPAWN_LOCKOUT_TICKS - 2, Vector2.ZERO, InputFrame.BTN_INTERACT) + assert_eq(_events_of(SimEvent.Type.RESPAWN_REQUESTED).size(), 0, + "holding the request down must not shorten the lockout") + _drive(4, Vector2.ZERO, InputFrame.BTN_INTERACT) + assert_gt(_events_of(SimEvent.Type.RESPAWN_REQUESTED).size(), 0, + "and it must actually open once the lockout expires") + + +func test_the_lockout_is_reported_to_the_client() -> void: + _kill_player() + var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world)) + var wait: float = snap["players"][0]["respawn_wait"] + assert_almost_eq(wait, + float(SimConfig.RESPAWN_LOCKOUT_TICKS) / float(SimConfig.TICK_RATE), 0.3, + "the HUD needs the countdown to render its disabled button") + + func test_respawn_request_never_reaches_the_client() -> void: _kill_player() _drive(1, Vector2.ZERO, InputFrame.BTN_INTERACT) @@ -171,7 +207,6 @@ func test_respawn_request_never_reaches_the_client() -> void: func test_spawn_grace_blocks_damage_and_firing() -> void: var p: SimPlayer = world.players[PEER] p.pos = Vector2.ZERO - p.iframes = 0 p.spawn_grace = 60 world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) diff --git a/tools/diag_prediction.gd b/tools/diag_prediction.gd index bd6adaf..4ff9715 100644 --- a/tools/diag_prediction.gd +++ b/tools/diag_prediction.gd @@ -8,10 +8,19 @@ extends Node ## Runs the real listen-server path (ServerRuntime + ClientRuntime + the actual ## Net loopback), not a mock, so node process order is exactly production's. +## Tick at which to shove the client's input numbering out of sync, simulating +## the clock drift between two independent processes that produced the original +## "ship and bullets separate after a few minutes, permanently" bug. 0 disables. +const DRIFT_AT := 120 +const DRIFT_BY := 14 + var _ticks: int = 0 var _worst: float = 0.0 var _sum: float = 0.0 var _samples: int = 0 +## Measured only after the injected drift, which is the interesting window. +var _worst_after_drift: float = 0.0 +var _drifted: bool = false func _ready() -> void: @@ -42,6 +51,17 @@ func _physics_process(_delta: float) -> void: _sum += delta_px _samples += 1 + if DRIFT_AT > 0 and _ticks == DRIFT_AT: + # Exactly what two drifting clocks do, only instantly. With the old + # constants this landed in the gap between "server stops accepting" and + # "client notices", and the two never re-converged. + cli.input_tick += DRIFT_BY + _drifted = true + print("--- injected +%d tick input drift ---" % DRIFT_BY) + # Give the client a moment to notice before judging it. + if _drifted and _ticks > DRIFT_AT + 90: + _worst_after_drift = maxf(_worst_after_drift, delta_px) + if _ticks % 20 == 0: print("tick=%4d srv_tick=%4d srv_pos=%-22s pred=%-22s gap=%6.2fpx acked=%4d cli_tick=%4d queue=%d" % [ _ticks, inst.world.tick, str(sp.pos.round()), str(cli.predicted_pos.round()), @@ -55,5 +75,12 @@ func _physics_process(_delta: float) -> void: print("muzzle offset = %.1fpx, drawn ship radius = %.1fpx, hitbox = %.1fpx" % [ SimConfig.PLAYER_MUZZLE_OFFSET, SimConfig.PLAYER_VISUAL_RADIUS, SimConfig.PLAYER_RADIUS]) + var ok := true + if _drifted: + print("RECOVERED gap after drift: worst=%.2fpx" % _worst_after_drift) + # One tick of movement is 4px; anything near that means the client + # re-synced. Tens of px means it is stuck, which is the bug. + ok = _worst_after_drift < 12.0 + print("DRIFT_RECOVERY_" + ("PASS" if ok else "FAIL")) Net.shutdown() - get_tree().quit(0) + get_tree().quit(0 if ok else 1)