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:
@@ -110,6 +110,15 @@ ticks in milliseconds with no SceneTree.
|
|||||||
- **`PLAYER_RADIUS` (hitbox) < `PLAYER_VISUAL_RADIUS` (sprite), and
|
- **`PLAYER_RADIUS` (hitbox) < `PLAYER_VISUAL_RADIUS` (sprite), and
|
||||||
`PLAYER_MUZZLE_OFFSET` derives from the visual one.** Prefer a visible
|
`PLAYER_MUZZLE_OFFSET` derives from the visual one.** Prefer a visible
|
||||||
near-miss over an invisible hit; keep the muzzle clear of the sprite.
|
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
|
- **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.
|
world as `linkdead`, channelling out over the same second the escape costs.
|
||||||
Damage must never cancel the escape channel, or quitting beats the button.
|
Damage must never cancel the escape channel, or quitting beats the button.
|
||||||
|
|||||||
@@ -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
|
If it becomes a complaint, the fix is to lag-compensate **player bullets against
|
||||||
enemies only**, never enemy bullets against players.
|
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
|
## Input validation
|
||||||
|
|
||||||
`SimWorld.queue_input()` is the single audit point. It drops:
|
`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` |
|
| Stale input | `f.tick < tick - INPUT_MAX_AGE` |
|
||||||
| Input claiming the future | `f.tick > tick + INPUT_MAX_LEAD` |
|
| Input claiming the future | `f.tick > tick + INPUT_MAX_LEAD` |
|
||||||
| Flood | queue capped at `INPUT_MAX_AGE`, oldest dropped |
|
| 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
|
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()`
|
vector is two signed bytes at 1/100 precision, and `Movement.sanitize_move()`
|
||||||
|
|||||||
+33
-6
@@ -42,19 +42,46 @@ const PLAYER_BULLET_SPEED := 620.0
|
|||||||
const PLAYER_BULLET_RADIUS := 4.0
|
const PLAYER_BULLET_RADIUS := 4.0
|
||||||
const PLAYER_BULLET_LIFETIME := 90 # ticks
|
const PLAYER_BULLET_LIFETIME := 90 # ticks
|
||||||
const PLAYER_BULLET_DAMAGE := 6
|
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
|
## Invulnerable *and* unable to shoot on entering a dungeon, so arriving into a
|
||||||
## live bullet field is survivable. Both halves matter: invulnerability alone
|
## live bullet field is survivable. Both halves matter: invulnerability alone
|
||||||
## would make the spawn point a free firing position.
|
## would make the spawn point a free firing position.
|
||||||
const SPAWN_GRACE_TICKS := 120 # 2 seconds
|
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.
|
## Inputs older than this (relative to the newest accepted) are discarded.
|
||||||
const INPUT_MAX_AGE := 30
|
const INPUT_MAX_AGE := 30
|
||||||
## Inputs claiming to be further ahead than this of the server tick are clamped.
|
## Where the client aims to sit.
|
||||||
const INPUT_MAX_LEAD := 12
|
const INPUT_TARGET_LEAD := 8
|
||||||
## Hard ceiling on inputs consumed from one peer in a single tick.
|
## The client re-syncs its numbering outside this band.
|
||||||
const INPUT_MAX_PER_TICK := 4
|
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 -------------------------------------------------------
|
# --- Emergency escape -------------------------------------------------------
|
||||||
const ESCAPE_CHANNEL_TICKS := 60 # 1 second
|
const ESCAPE_CHANNEL_TICKS := 60 # 1 second
|
||||||
|
|||||||
+50
@@ -5,9 +5,15 @@ extends Node
|
|||||||
|
|
||||||
const GAME_SCENE := preload("res://src/game.tscn")
|
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 _menu: Control = null
|
||||||
var _game: Node = null
|
var _game: Node = null
|
||||||
var _ticks: int = 0
|
var _ticks: int = 0
|
||||||
|
var _connecting: bool = false
|
||||||
|
var _connect_deadline: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -30,6 +36,8 @@ func _ready() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _physics_process(_delta: float) -> 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
|
_ticks += 1
|
||||||
if GameOpts.autoquit_ticks > 0 and _ticks >= GameOpts.autoquit_ticks:
|
if GameOpts.autoquit_ticks > 0 and _ticks >= GameOpts.autoquit_ticks:
|
||||||
GameLog.info("main", "autoquit after %d ticks" % _ticks)
|
GameLog.info("main", "autoquit after %d ticks" % _ticks)
|
||||||
@@ -59,15 +67,49 @@ func _show_menu() -> void:
|
|||||||
add_child(_menu)
|
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:
|
func _join(address: String, port: int) -> void:
|
||||||
GameOpts.host_address = address
|
GameOpts.host_address = address
|
||||||
GameOpts.port = port
|
GameOpts.port = port
|
||||||
if Net.join(address, port) != OK:
|
if Net.join(address, port) != OK:
|
||||||
_menu_status(Net.last_error)
|
_menu_status(Net.last_error)
|
||||||
return
|
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()
|
_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
|
## Listen server: one process runs the authoritative world and a local client
|
||||||
## connected over loopback. The client takes no shortcuts because of it -- it
|
## connected over loopback. The client takes no shortcuts because of it -- it
|
||||||
## still only sends input and still only learns outcomes from snapshots.
|
## 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)
|
_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:
|
func _on_net_state(state: Net.State) -> void:
|
||||||
match state:
|
match state:
|
||||||
Net.State.FAILED:
|
Net.State.FAILED:
|
||||||
|
if _connecting:
|
||||||
|
_abort_connect(Net.last_error)
|
||||||
|
return
|
||||||
_show_menu()
|
_show_menu()
|
||||||
_menu_status(Net.last_error)
|
_menu_status(Net.last_error)
|
||||||
Net.State.OFFLINE:
|
Net.State.OFFLINE:
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ var my_escape: float = 0.0
|
|||||||
var my_escaping: bool = false
|
var my_escaping: bool = false
|
||||||
## Arrival protection: invulnerable and unable to shoot.
|
## Arrival protection: invulnerable and unable to shoot.
|
||||||
var my_spawn_grace: bool = false
|
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
|
## 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.
|
## 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.
|
## Protocol.COUNTDOWN_NONE outside that state.
|
||||||
var cleared_countdown: int = Protocol.COUNTDOWN_NONE
|
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_prev: Dictionary = {}
|
||||||
var snap_curr: Dictionary = {}
|
var snap_curr: Dictionary = {}
|
||||||
var _interp: float = 0.0
|
var _interp: float = 0.0
|
||||||
@@ -98,6 +111,11 @@ func _physics_process(delta: float) -> void:
|
|||||||
func _sample_input() -> InputFrame:
|
func _sample_input() -> InputFrame:
|
||||||
if GameOpts.bot_client:
|
if GameOpts.bot_client:
|
||||||
return _bot_input()
|
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 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 mouse := get_viewport().get_mouse_position() - get_viewport().get_visible_rect().size * 0.5
|
||||||
var to_mouse := mouse - predicted_pos
|
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)
|
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:
|
func on_roster(data: PackedByteArray) -> void:
|
||||||
roster = NetCodec.decode_roster(data)
|
roster = NetCodec.decode_roster(data)
|
||||||
hud_dirty.emit()
|
hud_dirty.emit()
|
||||||
@@ -158,7 +187,9 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
|
|||||||
predicted_pos = spawn
|
predicted_pos = spawn
|
||||||
pending.clear()
|
pending.clear()
|
||||||
server_tick_est = server_tick
|
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_alive = true
|
||||||
my_hp = SimConfig.PLAYER_MAX_HP
|
my_hp = SimConfig.PLAYER_MAX_HP
|
||||||
my_escape = 0.0
|
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
|
# was triggered -- otherwise the synthesised button would keep firing and
|
||||||
# bounce the player straight back out of the hub.
|
# bounce the player straight back out of the hub.
|
||||||
request_escape = false
|
request_escape = false
|
||||||
|
request_respawn = false
|
||||||
|
my_respawn_wait = 0.0
|
||||||
cleared_countdown = Protocol.COUNTDOWN_NONE
|
cleared_countdown = Protocol.COUNTDOWN_NONE
|
||||||
GameLog.info("client", "entered instance %d (%s)" % [id, Protocol.InstanceKind.keys()[kind]])
|
GameLog.info("client", "entered instance %d (%s)" % [id, Protocol.InstanceKind.keys()[kind]])
|
||||||
instance_changed.emit()
|
instance_changed.emit()
|
||||||
@@ -187,10 +220,12 @@ func on_snapshot(data: PackedByteArray) -> void:
|
|||||||
if server_tick_est < tick or server_tick_est > tick + 12:
|
if server_tick_est < tick or server_tick_est > tick + 12:
|
||||||
server_tick_est = tick
|
server_tick_est = tick
|
||||||
# Keep the client roughly one buffer ahead of the server so inputs arrive
|
# 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
|
var lead := input_tick - tick
|
||||||
if lead < 2 or lead > 16:
|
if lead < SimConfig.INPUT_LEAD_MIN or lead > SimConfig.INPUT_LEAD_MAX:
|
||||||
input_tick = tick + 8
|
_resync_input_tick(tick, "lead %d out of band" % lead)
|
||||||
|
|
||||||
for rec: Dictionary in snap["players"]:
|
for rec: Dictionary in snap["players"]:
|
||||||
if int(rec["peer"]) == my_peer:
|
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_escaping = (int(rec["flags"]) & Protocol.F_ESCAPING) != 0
|
||||||
my_spawn_grace = (int(rec["flags"]) & Protocol.F_SPAWN_GRACE) != 0
|
my_spawn_grace = (int(rec["flags"]) & Protocol.F_SPAWN_GRACE) != 0
|
||||||
my_escape = float(rec["escape"])
|
my_escape = float(rec["escape"])
|
||||||
|
my_respawn_wait = float(rec["respawn_wait"])
|
||||||
|
if my_alive:
|
||||||
|
request_respawn = false
|
||||||
hud_dirty.emit()
|
hud_dirty.emit()
|
||||||
|
|
||||||
var acked := int(rec["last_input_tick"])
|
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:
|
while not pending.is_empty() and pending[0].tick <= acked:
|
||||||
pending.pop_front()
|
pending.pop_front()
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ static func encode_snapshot(world: SimWorld,
|
|||||||
var flags := 0
|
var flags := 0
|
||||||
if p.alive:
|
if p.alive:
|
||||||
flags |= Protocol.F_ALIVE
|
flags |= Protocol.F_ALIVE
|
||||||
if p.iframes > 0:
|
|
||||||
flags |= Protocol.F_INVULN
|
|
||||||
if p.escape_ticks > 0:
|
if p.escape_ticks > 0:
|
||||||
flags |= Protocol.F_ESCAPING
|
flags |= Protocol.F_ESCAPING
|
||||||
if p.spawn_grace > 0:
|
if p.spawn_grace > 0:
|
||||||
@@ -37,6 +35,9 @@ static func encode_snapshot(world: SimWorld,
|
|||||||
flags |= Protocol.F_LINKDEAD
|
flags |= Protocol.F_LINKDEAD
|
||||||
b.put_u8(flags)
|
b.put_u8(flags)
|
||||||
b.put_u8(clampi(roundi(p.escape_progress() * 255.0), 0, 255))
|
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.
|
# Echoed so the owning client knows how far to rewind when reconciling.
|
||||||
b.put_u32(p.last_input_tick)
|
b.put_u32(p.last_input_tick)
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
|
|||||||
"hp": b.get_u16(),
|
"hp": b.get_u16(),
|
||||||
"flags": b.get_u8(),
|
"flags": b.get_u8(),
|
||||||
"escape": float(b.get_u8()) / 255.0,
|
"escape": float(b.get_u8()) / 255.0,
|
||||||
|
"respawn_wait": float(b.get_u8()) / 10.0,
|
||||||
"last_input_tick": b.get_u32(),
|
"last_input_tick": b.get_u32(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+6
-5
@@ -4,8 +4,10 @@ extends RefCounted
|
|||||||
## server refuses mismatched clients at handshake rather than desyncing later.
|
## server refuses mismatched clients at handshake rather than desyncing later.
|
||||||
|
|
||||||
## 2: added spawn-grace flag + cleared countdown to the snapshot, and the
|
## 2: added spawn-grace flag + cleared countdown to the snapshot, and the
|
||||||
## roster message.
|
## roster message.
|
||||||
const VERSION := 2
|
## 3: dropped the post-hit invulnerability flag, added the respawn-lockout byte
|
||||||
|
## to each player record.
|
||||||
|
const VERSION := 3
|
||||||
const DEFAULT_PORT := 27015
|
const DEFAULT_PORT := 27015
|
||||||
const MAX_CLIENTS := 32
|
const MAX_CLIENTS := 32
|
||||||
|
|
||||||
@@ -22,10 +24,9 @@ enum InstanceKind { LOBBY, DUNGEON }
|
|||||||
|
|
||||||
## Player flags packed into the snapshot's per-player byte.
|
## Player flags packed into the snapshot's per-player byte.
|
||||||
const F_ALIVE := 1
|
const F_ALIVE := 1
|
||||||
const F_INVULN := 2
|
|
||||||
const F_ESCAPING := 4
|
const F_ESCAPING := 4
|
||||||
## Arrival protection: invulnerable and unable to shoot. Distinct from F_INVULN
|
## Arrival protection: invulnerable and unable to shoot. The only invulnerable
|
||||||
## so the client can label the status rather than just tint the ship.
|
## state there is -- see the i-frames note in SimConfig.
|
||||||
const F_SPAWN_GRACE := 8
|
const F_SPAWN_GRACE := 8
|
||||||
## The peer behind this player has dropped and is being channelled out.
|
## The peer behind this player has dropped and is being channelled out.
|
||||||
const F_LINKDEAD := 16
|
const F_LINKDEAD := 16
|
||||||
|
|||||||
+11
-4
@@ -9,10 +9,11 @@ var pos := Vector2.ZERO
|
|||||||
var aim: float = 0.0
|
var aim: float = 0.0
|
||||||
var hp: int = SimConfig.PLAYER_MAX_HP
|
var hp: int = SimConfig.PLAYER_MAX_HP
|
||||||
var alive: bool = true
|
var alive: bool = true
|
||||||
var iframes: int = 0
|
|
||||||
var fire_cooldown: int = 0
|
var fire_cooldown: int = 0
|
||||||
## Ticks of arrival protection left: invulnerable, and unable to shoot.
|
## Ticks of arrival protection left: invulnerable, and unable to shoot.
|
||||||
var spawn_grace: int = 0
|
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
|
## 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
|
## 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)
|
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:
|
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
|
## 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
|
pos = spawn
|
||||||
hp = SimConfig.PLAYER_MAX_HP
|
hp = SimConfig.PLAYER_MAX_HP
|
||||||
alive = true
|
alive = true
|
||||||
iframes = 0
|
|
||||||
spawn_grace = grace
|
spawn_grace = grace
|
||||||
|
respawn_lockout = 0
|
||||||
fire_cooldown = 0
|
fire_cooldown = 0
|
||||||
escape_ticks = 0
|
escape_ticks = 0
|
||||||
input_queue.clear()
|
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
|
||||||
|
|||||||
@@ -149,8 +149,6 @@ func step() -> void:
|
|||||||
|
|
||||||
func _step_players() -> void:
|
func _step_players() -> void:
|
||||||
for p in players.values():
|
for p in players.values():
|
||||||
if p.iframes > 0:
|
|
||||||
p.iframes -= 1
|
|
||||||
if p.spawn_grace > 0:
|
if p.spawn_grace > 0:
|
||||||
p.spawn_grace -= 1
|
p.spawn_grace -= 1
|
||||||
if p.fire_cooldown > 0:
|
if p.fire_cooldown > 0:
|
||||||
@@ -159,10 +157,13 @@ func _step_players() -> void:
|
|||||||
var frame := _take_input(p)
|
var frame := _take_input(p)
|
||||||
|
|
||||||
if not p.alive:
|
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
|
# 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
|
# is an input like any other, so a dead client cannot be revived by
|
||||||
# anything except its own request reaching the server.
|
# anything except its own request reaching the server -- and not
|
||||||
if frame.pressed(InputFrame.BTN_INTERACT) or p.linkdead:
|
# 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})
|
events.append({"t": SimEvent.Type.RESPAWN_REQUESTED, "peer": p.peer_id})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -372,11 +373,11 @@ func _kill_bullet(slot: int) -> void:
|
|||||||
## SimConfig.ESCAPE_CHANNEL_TICKS for why that would reward pulling the plug.
|
## SimConfig.ESCAPE_CHANNEL_TICKS for why that would reward pulling the plug.
|
||||||
func _damage_player(p: SimPlayer, amount: int) -> void:
|
func _damage_player(p: SimPlayer, amount: int) -> void:
|
||||||
p.hp = maxi(p.hp - amount, 0)
|
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})
|
events.append({"t": SimEvent.Type.PLAYER_HIT, "peer": p.peer_id, "dmg": amount, "hp": p.hp})
|
||||||
if p.hp <= 0:
|
if p.hp <= 0:
|
||||||
p.alive = false
|
p.alive = false
|
||||||
p.escape_ticks = 0
|
p.escape_ticks = 0
|
||||||
|
p.respawn_lockout = SimConfig.RESPAWN_LOCKOUT_TICKS
|
||||||
events.append({"t": SimEvent.Type.PLAYER_DIED, "peer": p.peer_id})
|
events.append({"t": SimEvent.Type.PLAYER_DIED, "peer": p.peer_id})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+33
-4
@@ -6,9 +6,12 @@ const MARGIN := 24.0
|
|||||||
const BAR_W := 260.0
|
const BAR_W := 260.0
|
||||||
const BAR_H := 16.0
|
const BAR_H := 16.0
|
||||||
|
|
||||||
|
signal respawn_pressed
|
||||||
|
|
||||||
var _canvas: Control
|
var _canvas: Control
|
||||||
var _status: Label
|
var _status: Label
|
||||||
var _hint: Label
|
var _hint: Label
|
||||||
|
var _respawn_button: Button
|
||||||
var _hit_flash: float = 0.0
|
var _hit_flash: float = 0.0
|
||||||
var client: ClientRuntime = null
|
var client: ClientRuntime = null
|
||||||
|
|
||||||
@@ -38,6 +41,20 @@ func _ready() -> void:
|
|||||||
_hint.offset_left = MARGIN
|
_hint.offset_left = MARGIN
|
||||||
_hint.offset_top = -56.0
|
_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:
|
func _make_label(pos: Vector2) -> Label:
|
||||||
var l := Label.new()
|
var l := Label.new()
|
||||||
@@ -53,9 +70,23 @@ func _process(delta: float) -> void:
|
|||||||
_hit_flash = maxf(_hit_flash - delta * 2.5, 0.0)
|
_hit_flash = maxf(_hit_flash - delta * 2.5, 0.0)
|
||||||
_status.text = _status_text()
|
_status.text = _status_text()
|
||||||
_hint.text = _hint_text()
|
_hint.text = _hint_text()
|
||||||
|
_update_respawn_button()
|
||||||
_canvas.queue_redraw()
|
_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:
|
func _status_text() -> String:
|
||||||
if client == null:
|
if client == null:
|
||||||
return "connecting..."
|
return "connecting..."
|
||||||
@@ -69,7 +100,7 @@ func _hint_text() -> String:
|
|||||||
if client == null:
|
if client == null:
|
||||||
return ""
|
return ""
|
||||||
if not client.my_alive:
|
if not client.my_alive:
|
||||||
return "DOWN -- press E to return to the hub"
|
return "DOWN"
|
||||||
if client.instance_kind == Protocol.InstanceKind.LOBBY:
|
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 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"
|
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
|
var centre := _canvas.size * 0.5
|
||||||
_canvas.draw_string(ThemeDB.fallback_font, centre - Vector2(52.0, 8.0),
|
_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))
|
"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:
|
if _hit_flash > 0.0:
|
||||||
_canvas.draw_rect(Rect2(Vector2.ZERO, _canvas.size),
|
_canvas.draw_rect(Rect2(Vector2.ZERO, _canvas.size),
|
||||||
|
|||||||
+17
-8
@@ -9,6 +9,8 @@ var _address: LineEdit
|
|||||||
var _port: LineEdit
|
var _port: LineEdit
|
||||||
var _name: LineEdit
|
var _name: LineEdit
|
||||||
var _status: Label
|
var _status: Label
|
||||||
|
var _join_button: Button
|
||||||
|
var _host_button: Button
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -43,15 +45,15 @@ func _ready() -> void:
|
|||||||
_address = _field(panel, "server address", GameOpts.host_address)
|
_address = _field(panel, "server address", GameOpts.host_address)
|
||||||
_port = _field(panel, "port", str(GameOpts.port))
|
_port = _field(panel, "port", str(GameOpts.port))
|
||||||
|
|
||||||
var join := Button.new()
|
_join_button = Button.new()
|
||||||
join.text = "Join"
|
_join_button.text = "Join"
|
||||||
join.pressed.connect(_on_join)
|
_join_button.pressed.connect(_on_join)
|
||||||
panel.add_child(join)
|
panel.add_child(_join_button)
|
||||||
|
|
||||||
var host := Button.new()
|
_host_button = Button.new()
|
||||||
host.text = "Host and play (listen server)"
|
_host_button.text = "Host and play (listen server)"
|
||||||
host.pressed.connect(_on_host)
|
_host_button.pressed.connect(_on_host)
|
||||||
panel.add_child(host)
|
panel.add_child(_host_button)
|
||||||
|
|
||||||
var quit := Button.new()
|
var quit := Button.new()
|
||||||
quit.text = "Quit"
|
quit.text = "Quit"
|
||||||
@@ -78,6 +80,13 @@ func set_status(text: String) -> void:
|
|||||||
_status.text = text
|
_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:
|
func _commit_name() -> void:
|
||||||
var clean := _name.text.strip_edges()
|
var clean := _name.text.strip_edges()
|
||||||
if not clean.is_empty():
|
if not clean.is_empty():
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ func _ready() -> void:
|
|||||||
get_viewport().size_changed.connect(_recentre)
|
get_viewport().size_changed.connect(_recentre)
|
||||||
menu.return_to_hub_requested.connect(_on_return_to_hub)
|
menu.return_to_hub_requested.connect(_on_return_to_hub)
|
||||||
menu.disconnect_requested.connect(_on_disconnect)
|
menu.disconnect_requested.connect(_on_disconnect)
|
||||||
|
hud.respawn_pressed.connect(_on_respawn_pressed)
|
||||||
|
|
||||||
|
|
||||||
func _recentre() -> void:
|
func _recentre() -> void:
|
||||||
@@ -42,5 +43,11 @@ func _on_disconnect() -> void:
|
|||||||
Net.shutdown()
|
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:
|
func _on_local_hit(_damage: int) -> void:
|
||||||
hud.flash_hit()
|
hud.flash_hit()
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ func test_bullets_killed_by_a_hit_are_removed_from_the_replica() -> void:
|
|||||||
server.add_player(1, "tester")
|
server.add_player(1, "tester")
|
||||||
# Just below the boss, inside the ring pattern rather than at its centre.
|
# 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].pos = Vector2(0.0, 20.0)
|
||||||
server.players[1].iframes = 0
|
|
||||||
server.spawn_boss(Content.warden())
|
server.spawn_boss(Content.warden())
|
||||||
var hits := 0
|
var hits := 0
|
||||||
for _i in 900:
|
for _i in 900:
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ const PEER := 3
|
|||||||
func before_each() -> void:
|
func before_each() -> void:
|
||||||
world = SimWorld.new(1)
|
world = SimWorld.new(1)
|
||||||
world.add_player(PEER, "tester")
|
world.add_player(PEER, "tester")
|
||||||
world.players[PEER].iframes = 0
|
|
||||||
|
|
||||||
|
|
||||||
func _hold_escape(ticks: int) -> void:
|
func _hold_escape(ticks: int) -> void:
|
||||||
@@ -65,7 +64,6 @@ func test_dying_ends_the_channel() -> void:
|
|||||||
_hold_escape(20)
|
_hold_escape(20)
|
||||||
var p: SimPlayer = world.players[PEER]
|
var p: SimPlayer = world.players[PEER]
|
||||||
p.hp = 1
|
p.hp = 1
|
||||||
p.iframes = 0
|
|
||||||
world.pool.spawn(p.pos, Vector2.ZERO, 6.0, 60, 999,
|
world.pool.spawn(p.pos, Vector2.ZERO, 6.0, 60, 999,
|
||||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||||
world.step()
|
world.step()
|
||||||
@@ -91,7 +89,6 @@ func test_a_linkdead_player_is_still_killable_while_channelling() -> void:
|
|||||||
var p: SimPlayer = world.players[PEER]
|
var p: SimPlayer = world.players[PEER]
|
||||||
p.linkdead = true
|
p.linkdead = true
|
||||||
p.hp = 1
|
p.hp = 1
|
||||||
p.iframes = 0
|
|
||||||
world.step()
|
world.step()
|
||||||
world.pool.spawn(p.pos, Vector2.ZERO, 6.0, 60, 999,
|
world.pool.spawn(p.pos, Vector2.ZERO, 6.0, 60, 999,
|
||||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://xemxybh1gvvo
|
||||||
@@ -91,7 +91,6 @@ func test_a_starved_player_eventually_stops_moving() -> void:
|
|||||||
func test_enemy_bullets_damage_the_player_and_are_consumed() -> void:
|
func test_enemy_bullets_damage_the_player_and_are_consumed() -> void:
|
||||||
var p: SimPlayer = world.players[PEER]
|
var p: SimPlayer = world.players[PEER]
|
||||||
p.pos = Vector2.ZERO
|
p.pos = Vector2.ZERO
|
||||||
p.iframes = 0
|
|
||||||
world.pool.spawn(Vector2(-1.0, 0.0), Vector2.ZERO, 6.0, 60, 25,
|
world.pool.spawn(Vector2(-1.0, 0.0), Vector2.ZERO, 6.0, 60, 25,
|
||||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||||
world.step()
|
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")
|
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]
|
var p: SimPlayer = world.players[PEER]
|
||||||
p.pos = Vector2.ZERO
|
p.pos = Vector2.ZERO
|
||||||
p.iframes = 0
|
for _i in 3:
|
||||||
for _i in 2:
|
|
||||||
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25,
|
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25,
|
||||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||||
world.step()
|
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:
|
func test_a_replica_world_never_resolves_a_hit() -> void:
|
||||||
world.authoritative = false
|
world.authoritative = false
|
||||||
var p: SimPlayer = world.players[PEER]
|
var p: SimPlayer = world.players[PEER]
|
||||||
p.pos = Vector2.ZERO
|
p.pos = Vector2.ZERO
|
||||||
p.iframes = 0
|
|
||||||
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25,
|
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25,
|
||||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||||
world.step()
|
world.step()
|
||||||
@@ -126,7 +140,6 @@ func _kill_player() -> SimPlayer:
|
|||||||
var p: SimPlayer = world.players[PEER]
|
var p: SimPlayer = world.players[PEER]
|
||||||
p.pos = Vector2.ZERO
|
p.pos = Vector2.ZERO
|
||||||
p.hp = 5
|
p.hp = 5
|
||||||
p.iframes = 0
|
|
||||||
p.spawn_grace = 0
|
p.spawn_grace = 0
|
||||||
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25,
|
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25,
|
||||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
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:
|
func test_a_downed_player_asking_to_respawn_is_reported_once_per_tick() -> void:
|
||||||
_kill_player()
|
_kill_player()
|
||||||
|
_drive(SimConfig.RESPAWN_LOCKOUT_TICKS)
|
||||||
world.drain_events()
|
world.drain_events()
|
||||||
_drive(1, Vector2.ZERO, InputFrame.BTN_INTERACT)
|
_drive(1, Vector2.ZERO, InputFrame.BTN_INTERACT)
|
||||||
assert_eq(_events_of(SimEvent.Type.RESPAWN_REQUESTED).size(), 1,
|
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)
|
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:
|
func test_respawn_request_never_reaches_the_client() -> void:
|
||||||
_kill_player()
|
_kill_player()
|
||||||
_drive(1, Vector2.ZERO, InputFrame.BTN_INTERACT)
|
_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:
|
func test_spawn_grace_blocks_damage_and_firing() -> void:
|
||||||
var p: SimPlayer = world.players[PEER]
|
var p: SimPlayer = world.players[PEER]
|
||||||
p.pos = Vector2.ZERO
|
p.pos = Vector2.ZERO
|
||||||
p.iframes = 0
|
|
||||||
p.spawn_grace = 60
|
p.spawn_grace = 60
|
||||||
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25,
|
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25,
|
||||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||||
|
|||||||
@@ -8,10 +8,19 @@ extends Node
|
|||||||
## Runs the real listen-server path (ServerRuntime + ClientRuntime + the actual
|
## Runs the real listen-server path (ServerRuntime + ClientRuntime + the actual
|
||||||
## Net loopback), not a mock, so node process order is exactly production's.
|
## 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 _ticks: int = 0
|
||||||
var _worst: float = 0.0
|
var _worst: float = 0.0
|
||||||
var _sum: float = 0.0
|
var _sum: float = 0.0
|
||||||
var _samples: int = 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:
|
func _ready() -> void:
|
||||||
@@ -42,6 +51,17 @@ func _physics_process(_delta: float) -> void:
|
|||||||
_sum += delta_px
|
_sum += delta_px
|
||||||
_samples += 1
|
_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:
|
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" % [
|
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()),
|
_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" % [
|
print("muzzle offset = %.1fpx, drawn ship radius = %.1fpx, hitbox = %.1fpx" % [
|
||||||
SimConfig.PLAYER_MUZZLE_OFFSET, SimConfig.PLAYER_VISUAL_RADIUS,
|
SimConfig.PLAYER_MUZZLE_OFFSET, SimConfig.PLAYER_VISUAL_RADIUS,
|
||||||
SimConfig.PLAYER_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()
|
Net.shutdown()
|
||||||
get_tree().quit(0)
|
get_tree().quit(0 if ok else 1)
|
||||||
|
|||||||
Reference in New Issue
Block a user