Fix bullet/ship desync; rework death, escape, arrival and hub awareness

(1) Bullets appeared to trail the ship. Two independent causes, measured with
the new tools/diag_prediction.gd rather than guessed at:
  - ServerRuntime ticked before ClientRuntime, so input sampled on frame N was
    not consumed until frame N+1, leaving the drawn ship a constant one tick
    (4.00px at 240 u/s) ahead of the authoritative one that bullets spawn from.
    ClientRuntime now sets process_physics_priority = -10. Gap on a listen
    server: 4.00px -> 0.10px mean, 0.30px worst.
  - PLAYER_MUZZLE_OFFSET was PLAYER_RADIUS + 6 = 12px against a 13px drawn
    ship, so bullets were born inside the sprite. Regression from the previous
    commit's hitbox shrink; it now derives from PLAYER_VISUAL_RADIUS.

(2) No more timed respawn. A downed player stays down until they ask for the
hub (E), which is an ordinary input -- the server has no "revive me" message.

(3) Escape channel 3s -> 1s, and damage no longer cancels it. An interruptible
channel makes killing the process strictly better than using the button, so a
dropped connection now runs the same channel: the player stays in the world as
linkdead, still killable, and is only released once it completes. Instances
refuse to close while a linkdead body is resolving, or a solo drop would delete
it on the next tick and hand the exploit straight back.

(4) Escape opens an in-game menu: return to hub (routed through the same held-
escape channel, not a new message), disconnect, quit.

(5) Server pushes a roster so the hub shows who is online and which dungeon
they are in. Entering a dungeon grants 2s arrival protection -- invulnerable
AND weapons-cold, since invulnerability alone would make the spawn a free
firing position -- flagged in the snapshot and drawn on every protected ship.

(6) Cleared dungeons hold the party 30s (was 5s) with a visible countdown.

(7) The hub's grey circle was a 100k-HP target dummy that read as scenery. Now
drawn as a bullseye so its purpose is legible.

Protocol version 1 -> 2. 91 tests (was 78); smoke.sh gains a bot that is
SIGKILLed mid-dungeon to prove the disconnect path end to end. check.sh,
test.sh and smoke.sh all pass.
This commit is contained in:
2026-09-03 18:43:19 +02:00
parent d9a59fff03
commit 005679f1b5
29 changed files with 903 additions and 79 deletions
+15
View File
@@ -100,6 +100,21 @@ ticks in milliseconds with no SceneTree.
strips comments. Anything load-bearing (the 60 Hz tick) is asserted in code strips comments. Anything load-bearing (the 60 Hz tick) is asserted in code
in `src/main.gd` instead of trusted to `project.godot`. in `src/main.gd` instead of trusted to `project.godot`.
## Non-obvious invariants
- **`ClientRuntime.process_physics_priority = -10`.** The client must sample and
send input before `ServerRuntime` ticks, or a listen server's drawn ship sits
a permanent tick ahead of the authoritative one and bullets trail it. Measure
with `godot --headless --path . res://tools/diag_prediction.tscn` (~0.1px is
healthy, 4px means the ordering broke).
- **`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.
- **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.
See [docs/NETCODE.md](docs/NETCODE.md).
## Adding content ## Adding content
A new enemy or boss is data, never code. Add a builder to A new enemy or boss is data, never code. Add a builder to
+51
View File
@@ -91,6 +91,43 @@ rate as tapping it correctly.
A client that stops sending coasts on its last input for `INPUT_MAX_AGE` ticks A client that stops sending coasts on its last input for `INPUT_MAX_AGE` ticks
and then stops, so a dropped connection does not leave a player sliding. and then stops, so a dropped connection does not leave a player sliding.
## Leaving a run, and why disconnecting is not an escape
The emergency escape is a **one-second server-owned channel** that damage does
*not* interrupt. That combination is deliberate and the two halves depend on
each other.
An interruptible channel sounds like the right kind of risk until you follow it
through: a player about to die under fire can never finish the channel, so
killing the game process becomes strictly better than using the button. The
escape hatch turns into the exploit.
So a dropped connection runs the same channel. On disconnect the peer is removed
from the send list but its player **stays in the world** (`SimPlayer.linkdead`),
treated as holding the escape button down, still fully killable, for the same
second everyone else pays. Only when that resolves is the peer forgotten. Pulling
the plug is therefore never cheaper than pressing the key, and there is no
timing window where it is.
Two details that are easy to get wrong and are covered by tests:
- An instance must not close while a linkdead body is still resolving
(`Instance.has_linkdead()`), or a solo player's drop would delete their body
on the next tick and hand back the exact exploit.
- Dying still ends the channel. That is the one thing the escape cannot beat,
and it is what keeps the second a real risk rather than a formality.
`tools/smoke.sh` SIGKILLs a bot mid-dungeon and asserts the server channels it
out rather than dropping it instantly.
## Arriving in a run
Entering a dungeon grants `SimConfig.SPAWN_GRACE_TICKS` (2s) of **arrival
protection**: invulnerable *and* unable to shoot. Both halves matter —
invulnerability alone would make the spawn point a free firing position. It is
flagged in the snapshot (`Protocol.F_SPAWN_GRACE`) so every client can draw it
on every protected ship, not just its own.
## Prediction and reconciliation ## Prediction and reconciliation
`ClientRuntime` keeps every unacknowledged `InputFrame`. Each snapshot echoes `ClientRuntime` keeps every unacknowledged `InputFrame`. Each snapshot echoes
@@ -99,6 +136,20 @@ frames, takes the server's authoritative position, and replays the rest through
the same `Movement.step_player()` the server used. Divergence over 24 px snaps; the same `Movement.step_player()` the server used. Divergence over 24 px snaps;
smaller errors lerp at 0.3 so ordinary jitter does not read as rubber-banding. smaller errors lerp at 0.3 so ordinary jitter does not read as rubber-banding.
`ClientRuntime` sets `process_physics_priority = -10` so it samples and sends
input *before* `ServerRuntime` ticks. This matters only on a listen server,
where both live in one process: with the default tree order the server ran
first, so input sampled on frame N was not consumed until frame N+1, leaving the
drawn ship a permanent one tick (4px at 240 u/s) ahead of the authoritative one
— and bullets, which spawn at the authoritative position, visibly trailed behind
the ship. `tools/diag_prediction.gd` measures this gap; it is currently ~0.1px
mean, down from a constant 4.00px.
The other half of that fix is `SimConfig.PLAYER_MUZZLE_OFFSET`, derived from the
*visual* radius rather than the hitbox. A remote client still draws its ship a
tick or two ahead of the server, and that margin is what keeps bullets emerging
from the nose rather than the middle of the sprite.
Remote players, enemies and the boss are interpolated between the last two Remote players, enemies and the boss are interpolated between the last two
snapshots. Bullets are not interpolated — they are simulated, and a spawn event snapshots. Bullets are not interpolated — they are simulated, and a spawn event
carries the server tick it was generated at so the client fast-forwards the carries the server tick it was generated at so the client fast-forwards the
+13 -2
View File
@@ -9,9 +9,13 @@
| Simple, predictable enemies | Drifter, Turret, Stalker, plus a lobby dummy. Five movement behaviours, all closed-form. | | Simple, predictable enemies | Drifter, Turret, Stalker, plus a lobby dummy. Five movement behaviours, all closed-form. |
| Stationary boss, adaptable format | The Warden of the Fold: four phases, data-defined. A new boss is one function and zero simulation changes. | | Stationary boss, adaptable format | The Warden of the Fold: four phases, data-defined. A new boss is one function and zero simulation changes. |
| Lobby hub with dungeon entry | Persistent lobby instance, portal, party forming window, dungeons opened on demand. | | Lobby hub with dungeon entry | Persistent lobby instance, portal, party forming window, dungeons opened on demand. |
| Emergency escape | Three-second channel, cancelled by damage or release, server-owned. | | Emergency escape | One-second server-owned channel, cancelled by release but not by damage; a disconnect runs the same channel so quitting is never a cheaper exit. |
| Leaving and arriving | Downed players return to the hub on request (no timed respawn); entering a dungeon grants 2s of invulnerable, weapons-cold arrival protection. |
| Hub awareness | Server-pushed roster showing who is online and which dungeon they are in. |
| In-game menu | Escape opens return-to-hub / disconnect / quit. |
78 tests plus an end-to-end smoke test over a real socket. 91 tests plus an end-to-end smoke test over a real socket, including a bot that
is SIGKILLed mid-dungeon to prove the disconnect path.
## Next, in rough order of value ## Next, in rough order of value
@@ -20,6 +24,13 @@ Rooms, doors, and a `SimWorld` with static geometry — which means adding a
collision representation for walls (segment-vs-circle for players, segment collision representation for walls (segment-vs-circle for players, segment
crossing for bullets) since there is no physics engine to lean on. crossing for bullets) since there is no physics engine to lean on.
**1b. Progression, and what it turns on.** Several things are currently sized
for "no persistence yet" and should be revisited together with it: death sends
you to the hub rather than costing anything, a linkdead body is simply deleted
once it channels out (there is no hub state to put it in), and `peer_names` is
client-supplied and trusted for display. All three are fine now and none of them
are once a character has anything worth losing.
**2. Art and feel.** Everything is drawn with `draw_circle` and a generated dot **2. Art and feel.** Everything is drawn with `draw_circle` and a generated dot
texture. Sprites, hit flashes, screen shake, muzzle flashes, death effects, and texture. Sprites, hit flashes, screen shake, muzzle flashes, death effects, and
sound. None of it touches the simulation — this is entirely `src/view/`. sound. None of it touches the simulation — this is entirely `src/view/`.
+5
View File
@@ -79,6 +79,11 @@ interact={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
] ]
} }
system_menu={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[rendering] [rendering]
+13
View File
@@ -182,6 +182,13 @@ func send_events(peer_id: int, data: PackedByteArray) -> void:
s_events.rpc_id(peer_id, data) s_events.rpc_id(peer_id, data)
func send_roster(peer_id: int, data: PackedByteArray) -> void:
if _is_local(peer_id):
client.on_roster(data)
else:
s_roster.rpc_id(peer_id, data)
func send_reject(peer_id: int, reason: String) -> void: func send_reject(peer_id: int, reason: String) -> void:
if _is_local(peer_id): if _is_local(peer_id):
GameLog.error("net", "local client rejected: %s" % reason) GameLog.error("net", "local client rejected: %s" % reason)
@@ -237,6 +244,12 @@ func s_reject(reason: String) -> void:
_set_state(State.FAILED) _set_state(State.FAILED)
@rpc("authority", "call_remote", "reliable", 1)
func s_roster(data: PackedByteArray) -> void:
if client != null:
client.on_roster(data)
@rpc("authority", "call_remote", "unreliable", 2) @rpc("authority", "call_remote", "unreliable", 2)
func s_snapshot(data: PackedByteArray) -> void: func s_snapshot(data: PackedByteArray) -> void:
if client != null: if client != null:
+22 -4
View File
@@ -29,6 +29,13 @@ const PLAYER_SPEED := 240.0
const PLAYER_RADIUS := 6.0 const PLAYER_RADIUS := 6.0
## Render-only. Used by src/view/, never by anything under src/sim/. ## Render-only. Used by src/view/, never by anything under src/sim/.
const PLAYER_VISUAL_RADIUS := 13.0 const PLAYER_VISUAL_RADIUS := 13.0
## Where a player's bullets are born, measured from the ship's centre. Derived
## from the VISUAL radius rather than the hitbox on purpose: this is the one
## sim number whose whole job is to line up with what the player sees. Keep it
## clear of PLAYER_VISUAL_RADIUS by a few px, or bullets appear to spawn inside
## the ship -- and on a remote client, the ship is also drawn a tick or two
## ahead of the server, so this margin is what absorbs that too.
const PLAYER_MUZZLE_OFFSET := PLAYER_VISUAL_RADIUS + 6.0
const PLAYER_MAX_HP := 100 const PLAYER_MAX_HP := 100
const PLAYER_FIRE_COOLDOWN := 7 # ticks const PLAYER_FIRE_COOLDOWN := 7 # ticks
const PLAYER_BULLET_SPEED := 620.0 const PLAYER_BULLET_SPEED := 620.0
@@ -36,7 +43,10 @@ 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 const PLAYER_IFRAMES := 36 # ticks of invulnerability after a hit
const PLAYER_RESPAWN_DELAY := 180 # ticks ## 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
# --- Anti-cheat guards ------------------------------------------------------ # --- Anti-cheat guards ------------------------------------------------------
## Inputs older than this (relative to the newest accepted) are discarded. ## Inputs older than this (relative to the newest accepted) are discarded.
@@ -47,9 +57,13 @@ const INPUT_MAX_LEAD := 12
const INPUT_MAX_PER_TICK := 4 const INPUT_MAX_PER_TICK := 4
# --- Emergency escape ------------------------------------------------------- # --- Emergency escape -------------------------------------------------------
const ESCAPE_CHANNEL_TICKS := 180 # 3 seconds const ESCAPE_CHANNEL_TICKS := 60 # 1 second
## Taking damage while channelling cancels the escape. ## Taking damage does NOT interrupt the channel. It used to, which sounds like
const ESCAPE_BREAK_ON_DAMAGE := true ## the right kind of risk until you notice the interaction with disconnects: if
## a player under fire cannot escape, quitting the process is strictly better
## than using the button, and the escape hatch becomes the cheese. A dropped
## connection now runs the same one-second channel (see SimPlayer.linkdead),
## which only works if being shot cannot cancel it.
# --- Bullets ---------------------------------------------------------------- # --- Bullets ----------------------------------------------------------------
const MAX_BULLETS := 4096 const MAX_BULLETS := 4096
@@ -67,6 +81,10 @@ const LOBBY_INSTANCE_ID := 1
const DUNGEON_PARTY_MAX := 4 const DUNGEON_PARTY_MAX := 4
## How long a forming dungeon waits for more players before it locks. ## How long a forming dungeon waits for more players before it locks.
const DUNGEON_FORMING_TICKS := 300 const DUNGEON_FORMING_TICKS := 300
## Victory lap: how long a cleared dungeon holds the party before returning
## them to the hub and closing. Once closed (or once it empties), the next
## player through the portal opens a fresh instance.
const DUNGEON_CLEARED_EXIT_TICKS := 1800 # 30 seconds
# --- Portal ----------------------------------------------------------------- # --- Portal -----------------------------------------------------------------
const PORTAL_POS := Vector2(0.0, -220.0) const PORTAL_POS := Vector2(0.0, -220.0)
+5 -1
View File
@@ -1,9 +1,10 @@
[gd_scene load_steps=5 format=3] [gd_scene load_steps=6 format=3]
[ext_resource type="Script" path="res://src/view/game_scene.gd" id="1"] [ext_resource type="Script" path="res://src/view/game_scene.gd" id="1"]
[ext_resource type="Script" path="res://src/view/world_view.gd" id="2"] [ext_resource type="Script" path="res://src/view/world_view.gd" id="2"]
[ext_resource type="Script" path="res://src/view/bullet_renderer.gd" id="3"] [ext_resource type="Script" path="res://src/view/bullet_renderer.gd" id="3"]
[ext_resource type="Script" path="res://src/ui/hud.gd" id="4"] [ext_resource type="Script" path="res://src/ui/hud.gd" id="4"]
[ext_resource type="Script" path="res://src/ui/game_menu.gd" id="5"]
[node name="Game" type="Node2D"] [node name="Game" type="Node2D"]
script = ExtResource("1") script = ExtResource("1")
@@ -17,3 +18,6 @@ script = ExtResource("3")
[node name="HUD" type="CanvasLayer" parent="."] [node name="HUD" type="CanvasLayer" parent="."]
script = ExtResource("4") script = ExtResource("4")
[node name="GameMenu" type="CanvasLayer" parent="."]
script = ExtResource("5")
+36 -1
View File
@@ -49,6 +49,9 @@ static func make_dungeon(instance_id: int, dungeon_seed: int) -> Instance:
inst.seed_value = dungeon_seed inst.seed_value = dungeon_seed
inst.world = SimWorld.new(dungeon_seed) inst.world = SimWorld.new(dungeon_seed)
inst.world.spawn_point = Vector2(0.0, 260.0) inst.world.spawn_point = Vector2(0.0, 260.0)
# Arriving into a fight already in progress needs a moment of protection;
# arriving in the hub does not.
inst.world.spawn_grace_ticks = SimConfig.SPAWN_GRACE_TICKS
inst.boss_id = Content.BOSS_WARDEN inst.boss_id = Content.BOSS_WARDEN
inst.state = State.FORMING inst.state = State.FORMING
if GameOpts.boss_rush: if GameOpts.boss_rush:
@@ -68,10 +71,40 @@ func remove_peer(peer_id: int) -> void:
world.remove_player(peer_id) world.remove_player(peer_id)
## Drop a peer from the send list but leave its player in the world. Used when a
## connection dies: the body stays behind, still killable, channelling its way
## out (see SimPlayer.linkdead), while we stop trying to send to a dead socket.
func detach_peer(peer_id: int) -> void:
peers.erase(peer_id)
var p: SimPlayer = world.players.get(peer_id)
if p != null:
p.linkdead = true
## Peers we still talk to. Linkdead bodies are not counted -- an instance whose
## only occupant has dropped should wind down, not wait on a ghost.
func is_empty() -> bool: func is_empty() -> bool:
return peers.is_empty() return peers.is_empty()
## True while a dropped player is still resolving its escape channel. Keeps the
## instance alive just long enough for that to finish.
func has_linkdead() -> bool:
for p in world.players.values():
if p.linkdead:
return true
return false
## Whole seconds until a cleared dungeon returns its party, or
## Protocol.COUNTDOWN_NONE when that does not apply.
func exit_countdown_seconds() -> int:
if state != State.CLEARED:
return Protocol.COUNTDOWN_NONE
return mini(int(ceil(float(stage_delay) / float(SimConfig.TICK_RATE))),
Protocol.COUNTDOWN_NONE - 1)
func accepts_new_party_member() -> bool: func accepts_new_party_member() -> bool:
return kind == Protocol.InstanceKind.DUNGEON \ return kind == Protocol.InstanceKind.DUNGEON \
and state == State.FORMING \ and state == State.FORMING \
@@ -110,7 +143,9 @@ func _step_dungeon() -> void:
if world.boss != null and world.boss.alive: if world.boss != null and world.boss.alive:
return return
state = State.CLEARED state = State.CLEARED
stage_delay = 300 stage_delay = SimConfig.DUNGEON_CLEARED_EXIT_TICKS
GameLog.info("instance", "instance %d CLEARED, returning party in %ds" % [
id, SimConfig.DUNGEON_CLEARED_EXIT_TICKS / SimConfig.TICK_RATE])
return return
stage += 1 stage += 1
+40 -1
View File
@@ -33,6 +33,20 @@ var my_hp: int = SimConfig.PLAYER_MAX_HP
var my_alive: bool = true var my_alive: bool = true
var my_escape: float = 0.0 var my_escape: float = 0.0
var my_escaping: bool = false var my_escaping: bool = false
## Arrival protection: invulnerable and unable to shoot.
var my_spawn_grace: bool = false
## Set by the in-game menu's "return to hub" -- synthesises a held escape
## button, so the menu route runs the identical server-side channel as the key.
## Cleared when the channel resolves or the player cancels.
var request_escape: bool = false
## Who is online and where, for the hub's player list. Server-pushed.
var roster: Array[Dictionary] = []
## Whole seconds until a cleared dungeon returns the party, or
## Protocol.COUNTDOWN_NONE outside that state.
var cleared_countdown: int = Protocol.COUNTDOWN_NONE
var snap_prev: Dictionary = {} var snap_prev: Dictionary = {}
var snap_curr: Dictionary = {} var snap_curr: Dictionary = {}
@@ -42,6 +56,14 @@ var _bot_tick: int = 0
func _ready() -> void: func _ready() -> void:
world.authoritative = false world.authoritative = false
# Sample and send input BEFORE ServerRuntime ticks. On a listen server both
# live in one process, and default tree order put the server first -- so the
# input sampled on frame N was not consumed until the server's frame N+1,
# leaving the drawn ship a permanent one-tick (4px at 240 u/s) ahead of the
# authoritative one. Bullets, spawned at the authoritative position, visibly
# trailed the ship. Going first closes the gap to zero on a listen server
# and costs a remote client nothing.
process_physics_priority = -10
set_physics_process(true) set_physics_process(true)
@@ -84,7 +106,7 @@ func _sample_input() -> InputFrame:
var buttons := 0 var buttons := 0
if Input.is_action_pressed("fire"): if Input.is_action_pressed("fire"):
buttons |= InputFrame.BTN_FIRE buttons |= InputFrame.BTN_FIRE
if Input.is_action_pressed("emergency_escape"): if Input.is_action_pressed("emergency_escape") or request_escape:
buttons |= InputFrame.BTN_ESCAPE buttons |= InputFrame.BTN_ESCAPE
if Input.is_action_pressed("interact"): if Input.is_action_pressed("interact"):
buttons |= InputFrame.BTN_INTERACT buttons |= InputFrame.BTN_INTERACT
@@ -99,6 +121,10 @@ func _bot_input() -> InputFrame:
var move := Vector2(cos(t * 0.9), sin(t * 1.3)) var move := Vector2(cos(t * 0.9), sin(t * 1.3))
aim = t * 2.1 aim = t * 2.1
var buttons := InputFrame.BTN_FIRE var buttons := InputFrame.BTN_FIRE
if not my_alive:
# Downed bots ask for the hub, same as a player would -- otherwise a bot
# that dies mid-run just lies there and the smoke test stalls.
return InputFrame.make(input_tick, Vector2.ZERO, aim, InputFrame.BTN_INTERACT)
if instance_kind == Protocol.InstanceKind.LOBBY and _bot_tick % 120 < 30: if instance_kind == Protocol.InstanceKind.LOBBY and _bot_tick % 120 < 30:
buttons |= InputFrame.BTN_INTERACT buttons |= InputFrame.BTN_INTERACT
# Walk onto the portal instead of orbiting, or interact never lands. # Walk onto the portal instead of orbiting, or interact never lands.
@@ -116,6 +142,11 @@ func on_welcome(peer_id: int) -> void:
GameLog.info("client", "welcome, peer id %d" % peer_id) GameLog.info("client", "welcome, peer id %d" % peer_id)
func on_roster(data: PackedByteArray) -> void:
roster = NetCodec.decode_roster(data)
hud_dirty.emit()
func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String, func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
spawn: Vector2) -> void: spawn: Vector2) -> void:
instance_id = id instance_id = id
@@ -132,6 +163,12 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
my_hp = SimConfig.PLAYER_MAX_HP my_hp = SimConfig.PLAYER_MAX_HP
my_escape = 0.0 my_escape = 0.0
my_escaping = false my_escaping = false
my_spawn_grace = false
# Arriving somewhere is the natural end of an escape request, however it
# was triggered -- otherwise the synthesised button would keep firing and
# bounce the player straight back out of the hub.
request_escape = false
cleared_countdown = Protocol.COUNTDOWN_NONE
GameLog.info("client", "entered instance %d (%s)" % [id, Protocol.InstanceKind.keys()[kind]]) GameLog.info("client", "entered instance %d (%s)" % [id, Protocol.InstanceKind.keys()[kind]])
instance_changed.emit() instance_changed.emit()
hud_dirty.emit() hud_dirty.emit()
@@ -141,6 +178,7 @@ func on_snapshot(data: PackedByteArray) -> void:
var snap := NetCodec.decode_snapshot(data) var snap := NetCodec.decode_snapshot(data)
if not snap_curr.is_empty() and int(snap["tick"]) <= int(snap_curr["tick"]): if not snap_curr.is_empty() and int(snap["tick"]) <= int(snap_curr["tick"]):
return # stale or duplicate; unreliable channel, newest wins return # stale or duplicate; unreliable channel, newest wins
cleared_countdown = int(snap["cleared_countdown"])
snap_prev = snap_curr snap_prev = snap_curr
snap_curr = snap snap_curr = snap
_interp = 0.0 _interp = 0.0
@@ -166,6 +204,7 @@ func _reconcile(rec: Dictionary) -> void:
my_hp = int(rec["hp"]) my_hp = int(rec["hp"])
my_alive = (int(rec["flags"]) & Protocol.F_ALIVE) != 0 my_alive = (int(rec["flags"]) & Protocol.F_ALIVE) != 0
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_escape = float(rec["escape"]) my_escape = float(rec["escape"])
hud_dirty.emit() hud_dirty.emit()
+59 -3
View File
@@ -8,10 +8,14 @@ extends RefCounted
# --- Snapshot --------------------------------------------------------------- # --- Snapshot ---------------------------------------------------------------
static func encode_snapshot(world: SimWorld) -> PackedByteArray: ## [param cleared_countdown] is whole seconds until a cleared dungeon returns
## its party, or Protocol.COUNTDOWN_NONE when that does not apply.
static func encode_snapshot(world: SimWorld,
cleared_countdown: int = Protocol.COUNTDOWN_NONE) -> PackedByteArray:
var b := StreamPeerBuffer.new() var b := StreamPeerBuffer.new()
b.big_endian = false b.big_endian = false
b.put_u32(world.tick) b.put_u32(world.tick)
b.put_u8(clampi(cleared_countdown, 0, Protocol.COUNTDOWN_NONE))
b.put_u8(mini(world.players.size(), 255)) b.put_u8(mini(world.players.size(), 255))
for p in world.players.values(): for p in world.players.values():
@@ -27,6 +31,10 @@ static func encode_snapshot(world: SimWorld) -> PackedByteArray:
flags |= Protocol.F_INVULN 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:
flags |= Protocol.F_SPAWN_GRACE
if p.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))
# Echoed so the owning client knows how far to rewind when reconciling. # Echoed so the owning client knows how far to rewind when reconciling.
@@ -62,7 +70,11 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
var b := StreamPeerBuffer.new() var b := StreamPeerBuffer.new()
b.big_endian = false b.big_endian = false
b.data_array = data b.data_array = data
var snap := {"tick": b.get_u32(), "players": [], "enemies": [], "boss": null} var snap := {
"tick": b.get_u32(),
"cleared_countdown": b.get_u8(),
"players": [], "enemies": [], "boss": null,
}
var pcount := b.get_u8() var pcount := b.get_u8()
for _i in pcount: for _i in pcount:
@@ -99,7 +111,13 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
# --- Events ----------------------------------------------------------------- # --- Events -----------------------------------------------------------------
## Events the client never sees; the instance layer consumes them server-side. ## Events the client never sees; the instance layer consumes them server-side.
const SERVER_ONLY := [SimEvent.Type.PORTAL_USED, SimEvent.Type.ESCAPE_COMPLETED] ## These describe a decision the server is about to act on, not an outcome --
## leaking them would tell a client about a message it might try to forge.
const SERVER_ONLY := [
SimEvent.Type.PORTAL_USED,
SimEvent.Type.ESCAPE_COMPLETED,
SimEvent.Type.RESPAWN_REQUESTED,
]
## [param server_tick] rides along so the client can fast-forward a bullet by ## [param server_tick] rides along so the client can fast-forward a bullet by
@@ -232,3 +250,41 @@ static func decode_inputs(data: PackedByteArray) -> Array[InputFrame]:
for _i in count: for _i in count:
out.append(InputFrame.read(b)) out.append(InputFrame.read(b))
return out return out
# --- Roster -----------------------------------------------------------------
# Who is online and where. Low frequency (membership changes only), so it is
# the one message that carries strings; everything hot stays fixed-width.
static func encode_roster(entries: Array[Dictionary]) -> PackedByteArray:
var b := StreamPeerBuffer.new()
b.big_endian = false
b.put_u8(mini(entries.size(), 255))
for e in entries:
b.put_u32(int(e["peer"]))
b.put_utf8_string(String(e["name"]))
b.put_u8(int(e["kind"]))
b.put_u32(int(e["instance"]))
b.put_u8(1 if e["alive"] else 0)
return b.data_array
static func decode_roster(data: PackedByteArray) -> Array[Dictionary]:
var out: Array[Dictionary] = []
if data.size() < 1:
return out
var b := StreamPeerBuffer.new()
b.big_endian = false
b.data_array = data
var count := b.get_u8()
for _i in count:
# get_utf8_string reads its own length prefix, so a truncated packet
# yields empty strings rather than reading off the end.
out.append({
"peer": b.get_u32(),
"name": b.get_utf8_string(),
"kind": b.get_u8(),
"instance": b.get_u32(),
"alive": b.get_u8() == 1,
})
return out
+12 -1
View File
@@ -3,7 +3,9 @@ extends RefCounted
## Wire constants. Bump [constant VERSION] whenever a codec layout changes; the ## Wire constants. Bump [constant VERSION] whenever a codec layout changes; the
## server refuses mismatched clients at handshake rather than desyncing later. ## server refuses mismatched clients at handshake rather than desyncing later.
const VERSION := 1 ## 2: added spawn-grace flag + cleared countdown to the snapshot, and the
## roster message.
const VERSION := 2
const DEFAULT_PORT := 27015 const DEFAULT_PORT := 27015
const MAX_CLIENTS := 32 const MAX_CLIENTS := 32
@@ -22,3 +24,12 @@ enum InstanceKind { LOBBY, DUNGEON }
const F_ALIVE := 1 const F_ALIVE := 1
const F_INVULN := 2 const F_INVULN := 2
const F_ESCAPING := 4 const F_ESCAPING := 4
## Arrival protection: invulnerable and unable to shoot. Distinct from F_INVULN
## so the client can label the status rather than just tint the ship.
const F_SPAWN_GRACE := 8
## The peer behind this player has dropped and is being channelled out.
const F_LINKDEAD := 16
## Snapshot's cleared-countdown byte when the instance is not in the cleared
## state -- 30s fits in a byte, so 255 is free to mean "not applicable".
const COUNTDOWN_NONE := 255
+97 -18
View File
@@ -38,13 +38,18 @@ func _physics_process(_delta: float) -> void:
inst.step() inst.step()
_dispatch_events(inst) _dispatch_events(inst)
if send_snapshot and not inst.peers.is_empty(): if send_snapshot and not inst.peers.is_empty():
var snap := NetCodec.encode_snapshot(inst.world) var snap := NetCodec.encode_snapshot(inst.world, inst.exit_countdown_seconds())
for peer in inst.peers: for peer in inst.peers:
Net.send_snapshot(peer, snap) Net.send_snapshot(peer, snap)
if inst.kind == Protocol.InstanceKind.DUNGEON \ if inst.kind != Protocol.InstanceKind.DUNGEON:
and inst.state == Instance.State.CLEARED and inst.stage_delay <= 0: continue
if inst.state == Instance.State.CLEARED and inst.stage_delay <= 0:
closing.append(inst.id) closing.append(inst.id)
elif inst.kind == Protocol.InstanceKind.DUNGEON and inst.is_empty() and inst.age > 60: elif inst.is_empty() and not inst.has_linkdead() and inst.age > 60:
# has_linkdead() matters: without it a solo player could drop, the
# instance would close on the next tick, and their body would
# vanish before finishing the escape channel -- exactly the
# disconnect cheese the channel exists to prevent.
closing.append(inst.id) closing.append(inst.id)
for id in closing: for id in closing:
@@ -52,18 +57,25 @@ func _physics_process(_delta: float) -> void:
## Split the tick's events into the ones clients need and the ones only the ## Split the tick's events into the ones clients need and the ones only the
## server acts on (escape completion, portal use). ## server acts on (escape completion, portal use, respawn requests).
func _dispatch_events(inst: Instance) -> void: func _dispatch_events(inst: Instance) -> void:
var events := inst.world.drain_events() var events := inst.world.drain_events()
if events.is_empty(): if events.is_empty():
return return
var transfers: Array[Dictionary] = [] # Collected and applied after the send below, because a transfer mutates
# inst.peers and would otherwise change the list mid-broadcast.
var to_lobby: Array[int] = []
var to_dungeon: Array[int] = []
for ev in events: for ev in events:
match int(ev["t"]): match int(ev["t"]):
SimEvent.Type.ESCAPE_COMPLETED: SimEvent.Type.ESCAPE_COMPLETED, SimEvent.Type.RESPAWN_REQUESTED:
transfers.append({"peer": int(ev["peer"]), "to_lobby": true}) var peer := int(ev["peer"])
if not to_lobby.has(peer):
to_lobby.append(peer)
SimEvent.Type.PORTAL_USED: SimEvent.Type.PORTAL_USED:
transfers.append({"peer": int(ev["peer"]), "to_lobby": false}) var peer := int(ev["peer"])
if not to_dungeon.has(peer):
to_dungeon.append(peer)
_: _:
pass pass
@@ -71,11 +83,10 @@ func _dispatch_events(inst: Instance) -> void:
for peer in inst.peers: for peer in inst.peers:
Net.send_events(peer, payload) Net.send_events(peer, payload)
for t in transfers: for peer in to_lobby:
if t["to_lobby"]: _send_to_lobby(peer)
_send_to_lobby(int(t["peer"])) for peer in to_dungeon:
else: _send_to_dungeon(peer)
_send_to_dungeon(int(t["peer"]))
# --- Peer lifecycle --------------------------------------------------------- # --- Peer lifecycle ---------------------------------------------------------
@@ -84,13 +95,42 @@ func on_peer_connected(peer_id: int) -> void:
GameLog.info("server", "peer %d connected, awaiting hello" % peer_id) GameLog.info("server", "peer %d connected, awaiting hello" % peer_id)
## A drop inside a dungeon does NOT delete the player. The body is detached
## from the send list but kept in the world, where it channels the same
## one-second escape everyone else does and stays killable the whole time --
## so quitting the process is never a cheaper exit than pressing the button.
## Only once that channel resolves (_release_linkdead) is the peer forgotten.
## A drop in the hub has nothing to dodge, so it is immediate.
func on_peer_disconnected(peer_id: int) -> void: func on_peer_disconnected(peer_id: int) -> void:
var inst := instance_of(peer_id) var inst := instance_of(peer_id)
if inst != null: if inst == null:
inst.remove_peer(peer_id) _forget_peer(peer_id)
return
if inst.kind == Protocol.InstanceKind.DUNGEON:
inst.detach_peer(peer_id)
GameLog.info("server", "peer %d dropped in instance %d, channelling out"
% [peer_id, inst.id])
_broadcast_roster()
return
inst.remove_peer(peer_id)
_forget_peer(peer_id)
GameLog.info("server", "peer %d disconnected" % peer_id)
func _forget_peer(peer_id: int) -> void:
peer_instance.erase(peer_id) peer_instance.erase(peer_id)
peer_names.erase(peer_id) peer_names.erase(peer_id)
GameLog.info("server", "peer %d disconnected" % peer_id) _broadcast_roster()
## The linkdead body finished its channel (or died trying). Either way it leaves
## the dungeon; with no persistence yet, "safely back in the hub" and "gone" are
## the same outcome, so it is simply removed.
func _release_linkdead(peer_id: int, inst: Instance) -> void:
inst.remove_peer(peer_id)
GameLog.info("server", "peer %d released from instance %d after drop"
% [peer_id, inst.id])
_forget_peer(peer_id)
func on_hello(peer_id: int, version: int, display_name: String) -> void: func on_hello(peer_id: int, version: int, display_name: String) -> void:
@@ -109,6 +149,7 @@ func on_hello(peer_id: int, version: int, display_name: String) -> void:
Net.send_welcome(peer_id) Net.send_welcome(peer_id)
_place(peer_id, lobby) _place(peer_id, lobby)
GameLog.info("server", "peer %d joined as '%s'" % [peer_id, clean]) GameLog.info("server", "peer %d joined as '%s'" % [peer_id, clean])
_broadcast_roster()
func on_input(peer_id: int, data: PackedByteArray) -> void: func on_input(peer_id: int, data: PackedByteArray) -> void:
@@ -135,7 +176,8 @@ func _place(peer_id: int, inst: Instance) -> void:
var backlog := _live_bullet_events(inst.world) var backlog := _live_bullet_events(inst.world)
if not backlog.is_empty(): if not backlog.is_empty():
Net.send_events(peer_id, NetCodec.encode_events(inst.world.tick, backlog)) Net.send_events(peer_id, NetCodec.encode_events(inst.world.tick, backlog))
Net.send_snapshot(peer_id, NetCodec.encode_snapshot(inst.world)) Net.send_snapshot(peer_id, NetCodec.encode_snapshot(inst.world,
inst.exit_countdown_seconds()))
func _transfer(peer_id: int, to: Instance) -> void: func _transfer(peer_id: int, to: Instance) -> void:
@@ -143,9 +185,19 @@ func _transfer(peer_id: int, to: Instance) -> void:
if from != null: if from != null:
from.remove_peer(peer_id) from.remove_peer(peer_id)
_place(peer_id, to) _place(peer_id, to)
_broadcast_roster()
## Where everything that ends a dungeon run converges: the escape channel, a
## downed player asking out, and a linkdead body finishing its channel.
func _send_to_lobby(peer_id: int) -> void: func _send_to_lobby(peer_id: int) -> void:
var from := instance_of(peer_id)
if from != null:
var p: SimPlayer = from.world.players.get(peer_id)
if p != null and p.linkdead:
_release_linkdead(peer_id, from)
return
GameLog.info("server", "peer %d escaped to lobby" % peer_id) GameLog.info("server", "peer %d escaped to lobby" % peer_id)
_transfer(peer_id, lobby) _transfer(peer_id, lobby)
@@ -173,6 +225,33 @@ func _close_dungeon(id: int) -> void:
GameLog.info("server", "closed dungeon instance %d" % id) GameLog.info("server", "closed dungeon instance %d" % id)
# --- Roster -----------------------------------------------------------------
## Tell everyone who is online and where they are, so the hub can show that a
## dungeon is already running before you walk into the portal.
##
## Sent on membership changes only, not per tick -- it is the one message that
## carries names, and nothing about it is time critical.
func _broadcast_roster() -> void:
var entries: Array[Dictionary] = []
for peer in peer_names:
var inst := instance_of(peer)
if inst == null:
continue
var p: SimPlayer = inst.world.players.get(peer)
entries.append({
"peer": peer,
"name": peer_names[peer],
"kind": int(inst.kind),
"instance": inst.id,
"alive": p != null and p.alive,
})
var payload := NetCodec.encode_roster(entries)
for inst in instances.values():
for peer in inst.peers:
Net.send_roster(peer, payload)
func _live_bullet_events(world: SimWorld) -> Array[Dictionary]: func _live_bullet_events(world: SimWorld) -> Array[Dictionary]:
var out: Array[Dictionary] = [] var out: Array[Dictionary] = []
for i in world.pool.high_water: for i in world.pool.high_water:
+3
View File
@@ -11,6 +11,9 @@ enum Type {
PLAYER_HIT, ## peer, damage, hp PLAYER_HIT, ## peer, damage, hp
PLAYER_DIED, ## peer PLAYER_DIED, ## peer
PLAYER_RESPAWNED, ## peer, pos PLAYER_RESPAWNED, ## peer, pos
## A downed player asked to be returned to the hub. Server-only: the
## instance layer performs the transfer, the client just sees it arrive.
RESPAWN_REQUESTED, ## peer
ENEMY_HIT, ## id, damage, hp ENEMY_HIT, ## id, damage, hp
ENEMY_DIED, ## id ENEMY_DIED, ## id
BOSS_PHASE, ## phase index BOSS_PHASE, ## phase index
+24 -4
View File
@@ -11,7 +11,14 @@ var hp: int = SimConfig.PLAYER_MAX_HP
var alive: bool = true var alive: bool = true
var iframes: int = 0 var iframes: int = 0
var fire_cooldown: int = 0 var fire_cooldown: int = 0
var respawn_timer: int = 0 ## Ticks of arrival protection left: invulnerable, and unable to shoot.
var spawn_grace: 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
## a dangerous moment: a linkdead player keeps channelling the escape (and stays
## killable) for the same second everyone else would. See ServerRuntime.
var linkdead: bool = false
## Ticks the emergency escape has been held. 0 means not channelling. ## Ticks the emergency escape has been held. 0 means not channelling.
var escape_ticks: int = 0 var escape_ticks: int = 0
@@ -30,12 +37,25 @@ 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)
func reset_for_instance(spawn: Vector2) -> void: ## Invulnerable while arriving, or in the usual post-hit window.
func invulnerable() -> bool:
return spawn_grace > 0 or iframes > 0
## Arrival protection also locks the gun, so the spawn point is not a free
## firing position.
func can_fire() -> bool:
return alive and spawn_grace <= 0 and fire_cooldown == 0
## [param grace] is 0 for the hub (nothing there can shoot) and
## SimConfig.SPAWN_GRACE_TICKS for a dungeon.
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 = SimConfig.PLAYER_IFRAMES iframes = 0
spawn_grace = grace
fire_cooldown = 0 fire_cooldown = 0
respawn_timer = 0
escape_ticks = 0 escape_ticks = 0
input_queue.clear() input_queue.clear()
+27 -18
View File
@@ -28,6 +28,9 @@ var events: Array[Dictionary] = []
## Set on a lobby world so the interact button can open a dungeon. ## Set on a lobby world so the interact button can open a dungeon.
var portal_enabled: bool = false var portal_enabled: bool = false
var spawn_point := Vector2(0.0, 240.0) var spawn_point := Vector2(0.0, 240.0)
## Arrival protection granted to players entering this world. 0 in the hub,
## SimConfig.SPAWN_GRACE_TICKS in a dungeon.
var spawn_grace_ticks: int = 0
var _next_actor_id: int = 1 var _next_actor_id: int = 1
var _ctx := EmitContext.new() var _ctx := EmitContext.new()
@@ -50,7 +53,7 @@ func add_player(peer_id: int, display_name: String) -> SimPlayer:
var p := SimPlayer.new() var p := SimPlayer.new()
p.peer_id = peer_id p.peer_id = peer_id
p.display_name = display_name p.display_name = display_name
p.reset_for_instance(spawn_point) p.reset_for_instance(spawn_point, spawn_grace_ticks)
players[peer_id] = p players[peer_id] = p
return p return p
@@ -148,24 +151,25 @@ func _step_players() -> void:
for p in players.values(): for p in players.values():
if p.iframes > 0: if p.iframes > 0:
p.iframes -= 1 p.iframes -= 1
if p.spawn_grace > 0:
p.spawn_grace -= 1
if p.fire_cooldown > 0: if p.fire_cooldown > 0:
p.fire_cooldown -= 1 p.fire_cooldown -= 1
var frame := _take_input(p)
if not p.alive: if not p.alive:
p.respawn_timer -= 1 # No timed respawn: a downed player waits for the hub. Asking to go
if p.respawn_timer <= 0: # is an input like any other, so a dead client cannot be revived by
p.alive = true # anything except its own request reaching the server.
p.hp = SimConfig.PLAYER_MAX_HP if frame.pressed(InputFrame.BTN_INTERACT) or p.linkdead:
p.pos = spawn_point events.append({"t": SimEvent.Type.RESPAWN_REQUESTED, "peer": p.peer_id})
p.iframes = SimConfig.PLAYER_IFRAMES
events.append({"t": SimEvent.Type.PLAYER_RESPAWNED, "peer": p.peer_id, "pos": p.pos})
continue continue
var frame := _take_input(p)
p.aim = frame.aim p.aim = frame.aim
p.pos = Movement.step_player(p.pos, frame.move, SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF) p.pos = Movement.step_player(p.pos, frame.move, SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
if frame.pressed(InputFrame.BTN_FIRE) and p.fire_cooldown == 0: if frame.pressed(InputFrame.BTN_FIRE) and p.can_fire():
_fire_player_shot(p) _fire_player_shot(p)
_step_escape(p, frame) _step_escape(p, frame)
@@ -196,7 +200,7 @@ func _fire_player_shot(p: SimPlayer) -> void:
p.fire_cooldown = SimConfig.PLAYER_FIRE_COOLDOWN p.fire_cooldown = SimConfig.PLAYER_FIRE_COOLDOWN
var dir := Vector2.RIGHT.rotated(p.aim) var dir := Vector2.RIGHT.rotated(p.aim)
pool.spawn( pool.spawn(
p.pos + dir * (SimConfig.PLAYER_RADIUS + 6.0), p.pos + dir * SimConfig.PLAYER_MUZZLE_OFFSET,
dir * SimConfig.PLAYER_BULLET_SPEED, dir * SimConfig.PLAYER_BULLET_SPEED,
SimConfig.PLAYER_BULLET_RADIUS, SimConfig.PLAYER_BULLET_RADIUS,
SimConfig.PLAYER_BULLET_LIFETIME, SimConfig.PLAYER_BULLET_LIFETIME,
@@ -206,7 +210,10 @@ func _fire_player_shot(p: SimPlayer) -> void:
func _step_escape(p: SimPlayer, frame: InputFrame) -> void: func _step_escape(p: SimPlayer, frame: InputFrame) -> void:
if frame.pressed(InputFrame.BTN_ESCAPE): # A dropped connection is treated as holding the button down. Pulling the
# plug then costs exactly what pressing escape costs -- one second of
# standing there, still killable -- instead of an instant, safe exit.
if p.linkdead or frame.pressed(InputFrame.BTN_ESCAPE):
if p.escape_ticks == 0: if p.escape_ticks == 0:
events.append({"t": SimEvent.Type.ESCAPE_STARTED, "peer": p.peer_id}) events.append({"t": SimEvent.Type.ESCAPE_STARTED, "peer": p.peer_id})
p.escape_ticks += 1 p.escape_ticks += 1
@@ -317,7 +324,10 @@ func _resolve_bullet_hits() -> void:
var br: float = pool.radius[i] var br: float = pool.radius[i]
if pool.team[i] == SimConfig.TEAM_ENEMY: if pool.team[i] == SimConfig.TEAM_ENEMY:
for p in players.values(): for p in players.values():
if not p.alive or p.iframes > 0: # invulnerable() covers arrival grace as well as post-hit
# i-frames -- a player who just walked into a live bullet field
# must not be hit by what was already in the air.
if not p.alive or p.invulnerable():
continue continue
if Movement.circles_overlap(bp, br, p.pos, SimConfig.PLAYER_RADIUS): if Movement.circles_overlap(bp, br, p.pos, SimConfig.PLAYER_RADIUS):
_damage_player(p, pool.damage[i]) _damage_player(p, pool.damage[i])
@@ -346,7 +356,7 @@ func _resolve_contact_damage() -> void:
if not e.alive or e.def.contact_damage <= 0: if not e.alive or e.def.contact_damage <= 0:
continue continue
for p in players.values(): for p in players.values():
if not p.alive or p.iframes > 0: if not p.alive or p.invulnerable():
continue continue
if Movement.circles_overlap(e.pos, e.def.radius, p.pos, SimConfig.PLAYER_RADIUS): if Movement.circles_overlap(e.pos, e.def.radius, p.pos, SimConfig.PLAYER_RADIUS):
_damage_player(p, e.def.contact_damage) _damage_player(p, e.def.contact_damage)
@@ -358,16 +368,15 @@ func _kill_bullet(slot: int) -> void:
pool.despawn(slot) pool.despawn(slot)
## Damage deliberately does NOT interrupt an escape channel -- see the note on
## 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 p.iframes = SimConfig.PLAYER_IFRAMES
if SimConfig.ESCAPE_BREAK_ON_DAMAGE and p.escape_ticks > 0:
p.escape_ticks = 0
events.append({"t": SimEvent.Type.ESCAPE_CANCELLED, "peer": p.peer_id})
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.respawn_timer = SimConfig.PLAYER_RESPAWN_DELAY p.escape_ticks = 0
events.append({"t": SimEvent.Type.PLAYER_DIED, "peer": p.peer_id}) events.append({"t": SimEvent.Type.PLAYER_DIED, "peer": p.peer_id})
+103
View File
@@ -0,0 +1,103 @@
extends CanvasLayer
## In-game system menu, opened with Escape.
##
## Exists because without it there is no way out of a session short of killing
## the process -- and "kill the process" is exactly the behaviour the escape
## channel is designed to discourage, so leaving it as the only exit would work
## against the rest of the design.
signal resumed
signal return_to_hub_requested
signal disconnect_requested
var _panel: VBoxContainer
var _hub_button: Button
var _open: bool = false
func _ready() -> void:
layer = 20
var root := Control.new()
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(root)
# Dim the game behind the menu so it is obvious the world is still running.
var scrim := ColorRect.new()
scrim.color = Color(0.0, 0.0, 0.0, 0.55)
scrim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
scrim.mouse_filter = Control.MOUSE_FILTER_STOP
root.add_child(scrim)
var center := CenterContainer.new()
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.add_child(center)
_panel = VBoxContainer.new()
_panel.custom_minimum_size = Vector2(320.0, 0.0)
_panel.add_theme_constant_override("separation", 10)
center.add_child(_panel)
var title := Label.new()
title.text = "PAUSED"
title.add_theme_font_size_override("font_size", 26)
_panel.add_child(title)
var note := Label.new()
note.text = "The world keeps running. You are not safe here."
note.add_theme_font_size_override("font_size", 12)
note.add_theme_color_override("font_color", Color(1.0, 0.7, 0.5))
_panel.add_child(note)
_hub_button = _button("Return to hub", func() -> void:
return_to_hub_requested.emit()
close())
_button("Resume", func() -> void: close())
_button("Disconnect to menu", func() -> void:
disconnect_requested.emit()
close())
_button("Quit game", func() -> void: get_tree().quit())
visible = false
func _button(text: String, on_press: Callable) -> Button:
var b := Button.new()
b.text = text
b.pressed.connect(on_press)
_panel.add_child(b)
return b
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("system_menu"):
toggle()
get_viewport().set_input_as_handled()
func toggle() -> void:
if _open:
close()
else:
open()
func open() -> void:
_open = true
visible = true
# Nothing is paused -- the server does not stop for one client's menu, so
# neither does the view. Only the mouse is freed.
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
func close() -> void:
_open = false
visible = false
func is_open() -> bool:
return _open
## The hub button is meaningless when you are already in the hub.
func set_in_dungeon(in_dungeon: bool) -> void:
_hub_button.disabled = not in_dungeon
+1
View File
@@ -0,0 +1 @@
uid://6j1nytv113kn
+55 -5
View File
@@ -68,9 +68,11 @@ func _status_text() -> String:
func _hint_text() -> String: func _hint_text() -> String:
if client == null: if client == null:
return "" return ""
if not client.my_alive:
return "DOWN -- press E to return to the hub"
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" 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 escape to the lobby" return "WASD move mouse aim LMB fire hold F to return to the hub Esc menu"
func _draw_hud() -> void: func _draw_hud() -> void:
@@ -89,16 +91,64 @@ func _draw_hud() -> void:
_draw_boss_bar() _draw_boss_bar()
if client.my_spawn_grace:
_canvas.draw_string(ThemeDB.fallback_font,
origin + Vector2(BAR_W + 12.0, BAR_H),
"ARRIVING -- invulnerable, weapons cold",
HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.6, 0.9, 1.0))
_draw_cleared_countdown()
_draw_roster()
if not client.my_alive: if not client.my_alive:
var size := _canvas.size # Centred on the canvas, which is only correct because _canvas actually
_canvas.draw_string(ThemeDB.fallback_font, size * 0.5 - Vector2(70.0, 0.0), # has the viewport's size now -- see the anchor note in _ready().
"DOWN -- respawning", HORIZONTAL_ALIGNMENT_LEFT, -1, 22, Color(1.0, 0.4, 0.4)) 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: if _hit_flash > 0.0:
_canvas.draw_rect(Rect2(Vector2.ZERO, _canvas.size), _canvas.draw_rect(Rect2(Vector2.ZERO, _canvas.size),
Color(1.0, 0.2, 0.25, 0.18 * _hit_flash)) Color(1.0, 0.2, 0.25, 0.18 * _hit_flash))
## Shown after the boss dies, so the victory lap has a visible clock on it.
func _draw_cleared_countdown() -> void:
if client.cleared_countdown >= Protocol.COUNTDOWN_NONE:
return
var text := "DUNGEON CLEARED -- returning to the hub in %ds" % client.cleared_countdown
_canvas.draw_string(ThemeDB.fallback_font,
Vector2(_canvas.size.x * 0.5 - 190.0, _canvas.size.y * 0.5 - 60.0),
text, HORIZONTAL_ALIGNMENT_LEFT, -1, 18, Color(0.6, 1.0, 0.75))
## Who else is online, and whether they are already in a dungeon. Only useful in
## the hub, which is the one place you are deciding whether to go in.
func _draw_roster() -> void:
if client.instance_kind != Protocol.InstanceKind.LOBBY or client.roster.is_empty():
return
var x := _canvas.size.x - 240.0
var y := MARGIN + 4.0
_canvas.draw_string(ThemeDB.fallback_font, Vector2(x, y), "ONLINE",
HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color(0.65, 0.7, 0.8))
y += 20.0
for entry in client.roster:
var in_dungeon: bool = int(entry["kind"]) == Protocol.InstanceKind.DUNGEON
var where := "dungeon %d" % int(entry["instance"]) if in_dungeon else "hub"
var col := Color(1.0, 0.7, 0.45) if in_dungeon else Color(0.7, 0.8, 0.9)
if not entry["alive"]:
col = Color(0.85, 0.4, 0.4)
where += " (down)"
var me := " <- you" if int(entry["peer"]) == client.my_peer else ""
_canvas.draw_string(ThemeDB.fallback_font, Vector2(x, y),
"%s -- %s%s" % [entry["name"], where, me],
HORIZONTAL_ALIGNMENT_LEFT, -1, 13, col)
y += 18.0
func _draw_boss_bar() -> void: func _draw_boss_bar() -> void:
var b := client.boss_state() var b := client.boss_state()
if b.is_empty() or client.boss_def == null: if b.is_empty() or client.boss_def == null:
+17
View File
@@ -5,6 +5,7 @@ extends Node2D
@onready var world_view: Node2D = $WorldView @onready var world_view: Node2D = $WorldView
@onready var hud: CanvasLayer = $HUD @onready var hud: CanvasLayer = $HUD
@onready var menu: CanvasLayer = $GameMenu
var _bound: ClientRuntime = null var _bound: ClientRuntime = null
@@ -12,6 +13,8 @@ var _bound: ClientRuntime = null
func _ready() -> void: func _ready() -> void:
world_view.position = get_viewport_rect().size * 0.5 world_view.position = get_viewport_rect().size * 0.5
get_viewport().size_changed.connect(_recentre) get_viewport().size_changed.connect(_recentre)
menu.return_to_hub_requested.connect(_on_return_to_hub)
menu.disconnect_requested.connect(_on_disconnect)
func _recentre() -> void: func _recentre() -> void:
@@ -23,6 +26,20 @@ func _process(_delta: float) -> void:
_bound = Net.client _bound = Net.client
if _bound != null and not _bound.local_hit.is_connected(_on_local_hit): if _bound != null and not _bound.local_hit.is_connected(_on_local_hit):
_bound.local_hit.connect(_on_local_hit) _bound.local_hit.connect(_on_local_hit)
menu.set_in_dungeon(_bound != null
and _bound.instance_kind == Protocol.InstanceKind.DUNGEON)
## Routed through the same held-escape channel the F key uses, rather than a
## direct "teleport me" message -- the server has no such message, and adding
## one would hand clients an instant, uninterruptible exit.
func _on_return_to_hub() -> void:
if _bound != null:
_bound.request_escape = true
func _on_disconnect() -> void:
Net.shutdown()
func _on_local_hit(_damage: int) -> void: func _on_local_hit(_damage: int) -> void:
+32 -2
View File
@@ -11,6 +11,8 @@ const COL_LOCAL := Color(0.5, 1.0, 0.8)
const COL_REMOTE := Color(0.55, 0.75, 1.0) const COL_REMOTE := Color(0.55, 0.75, 1.0)
const COL_DEAD := Color(0.4, 0.4, 0.45, 0.5) const COL_DEAD := Color(0.4, 0.4, 0.45, 0.5)
const COL_PORTAL := Color(0.5, 0.9, 1.0) const COL_PORTAL := Color(0.5, 0.9, 1.0)
## EnemyDef.visual index for the hub's practice target.
const VISUAL_DUMMY := 3
const ENEMY_COLORS := [ const ENEMY_COLORS := [
Color(0.95, 0.55, 0.55), # drifter Color(0.95, 0.55, 0.55), # drifter
Color(0.85, 0.7, 0.35), # turret Color(0.85, 0.7, 0.35), # turret
@@ -71,10 +73,25 @@ func _draw_portal() -> void:
func _draw_enemy(e: Dictionary) -> void: func _draw_enemy(e: Dictionary) -> void:
var col: Color = ENEMY_COLORS[clampi(int(e["visual"]), 0, ENEMY_COLORS.size() - 1)] var col: Color = ENEMY_COLORS[clampi(int(e["visual"]), 0, ENEMY_COLORS.size() - 1)]
var r: float = e["radius"] var r: float = e["radius"]
if int(e["visual"]) == VISUAL_DUMMY:
_draw_target_dummy(e["pos"], r, col)
return
draw_circle(e["pos"], r, Color(col, 0.35)) draw_circle(e["pos"], r, Color(col, 0.35))
draw_arc(e["pos"], r, 0.0, TAU, 24, col, 2.0) draw_arc(e["pos"], r, 0.0, TAU, 24, col, 2.0)
## The hub dummy is a shooting-range target, not an enemy. It used to be drawn
## as a plain grey disc with 100k hit points, which read as scenery -- nothing
## about it said "shoot me" and nothing visibly happened when you did. Concentric
## rings make the intent obvious at a glance.
func _draw_target_dummy(pos: Vector2, r: float, col: Color) -> void:
draw_circle(pos, r, Color(col, 0.18))
for i in 3:
var ring := r * (1.0 - 0.3 * float(i))
draw_arc(pos, ring, 0.0, TAU, 24, Color(col, 0.5 + 0.15 * float(i)), 1.5)
draw_circle(pos, r * 0.12, Color(1.0, 0.5, 0.4, 0.9))
func _draw_boss() -> void: func _draw_boss() -> void:
var b := client.boss_state() var b := client.boss_state()
if b.is_empty(): if b.is_empty():
@@ -87,10 +104,13 @@ func _draw_boss() -> void:
func _draw_remote_player(p: Dictionary) -> void: func _draw_remote_player(p: Dictionary) -> void:
var alive := (int(p["flags"]) & Protocol.F_ALIVE) != 0 var flags := int(p["flags"])
var alive := (flags & Protocol.F_ALIVE) != 0
var col := COL_REMOTE if alive else COL_DEAD var col := COL_REMOTE if alive else COL_DEAD
_draw_ship(p["pos"], p["aim"], col, alive) _draw_ship(p["pos"], p["aim"], col, alive)
if (int(p["flags"]) & Protocol.F_ESCAPING) != 0: if (flags & Protocol.F_SPAWN_GRACE) != 0:
_draw_grace_ring(p["pos"])
if (flags & Protocol.F_ESCAPING) != 0:
_draw_escape_ring(p["pos"], float(p["escape"]), col) _draw_escape_ring(p["pos"], float(p["escape"]), col)
@@ -99,10 +119,20 @@ func _draw_local_player() -> void:
_draw_ship(client.predicted_pos, client.aim, COL_DEAD, false) _draw_ship(client.predicted_pos, client.aim, COL_DEAD, false)
return return
_draw_ship(client.predicted_pos, client.aim, COL_LOCAL, true) _draw_ship(client.predicted_pos, client.aim, COL_LOCAL, true)
if client.my_spawn_grace:
_draw_grace_ring(client.predicted_pos)
if client.my_escaping: if client.my_escaping:
_draw_escape_ring(client.predicted_pos, client.my_escape, COL_LOCAL) _draw_escape_ring(client.predicted_pos, client.my_escape, COL_LOCAL)
## Arrival protection. Drawn on every protected ship, not just your own, so it
## is clear who can currently be shot and who cannot.
func _draw_grace_ring(pos: Vector2) -> void:
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.012)
draw_arc(pos, SimConfig.PLAYER_VISUAL_RADIUS + 5.0, 0.0, TAU, 28,
Color(0.55, 0.85, 1.0, 0.35 + 0.45 * pulse), 2.0)
## Drawn at PLAYER_VISUAL_RADIUS, larger than the PLAYER_RADIUS hitbox actually ## Drawn at PLAYER_VISUAL_RADIUS, larger than the PLAYER_RADIUS hitbox actually
## used for hits -- see the comment on those constants in sim_config.gd. The ## used for hits -- see the comment on those constants in sim_config.gd. The
## mismatch is deliberate, not a placeholder: a bullet can visibly clip the ## mismatch is deliberate, not a placeholder: a bullet can visibly clip the
+5 -1
View File
@@ -67,9 +67,13 @@ func test_killing_the_boss_clears_the_instance() -> void:
inst.world.boss.alive = false inst.world.boss.alive = false
_step(5) _step(5)
assert_eq(inst.state, Instance.State.CLEARED) assert_eq(inst.state, Instance.State.CLEARED)
assert_almost_eq(inst.exit_countdown_seconds(),
SimConfig.DUNGEON_CLEARED_EXIT_TICKS / SimConfig.TICK_RATE, 1,
"the party gets a visible countdown, not an instant boot")
# The exit timer has to run down, or the party would never be released. # The exit timer has to run down, or the party would never be released.
_step(400) _step(SimConfig.DUNGEON_CLEARED_EXIT_TICKS + 10)
assert_eq(inst.stage_delay, 0) assert_eq(inst.stage_delay, 0)
assert_eq(inst.exit_countdown_seconds(), 0)
func test_the_lobby_has_a_portal_and_no_hostiles() -> void: func test_the_lobby_has_a_portal_and_no_hostiles() -> void:
+54 -7
View File
@@ -33,7 +33,9 @@ func test_escape_takes_the_full_channel_time() -> void:
func test_releasing_the_button_cancels_the_channel() -> void: func test_releasing_the_button_cancels_the_channel() -> void:
_hold_escape(60) # Deliberately short of ESCAPE_CHANNEL_TICKS: hold the full duration and the
# channel completes instead, which is a different test.
_hold_escape(SimConfig.ESCAPE_CHANNEL_TICKS / 2)
assert_gt(world.players[PEER].escape_ticks, 0) assert_gt(world.players[PEER].escape_ticks, 0)
var frames: Array[InputFrame] = [InputFrame.make(world.tick + 1, Vector2.ZERO, 0.0, 0)] var frames: Array[InputFrame] = [InputFrame.make(world.tick + 1, Vector2.ZERO, 0.0, 0)]
world.queue_input(PEER, frames) world.queue_input(PEER, frames)
@@ -42,18 +44,63 @@ func test_releasing_the_button_cancels_the_channel() -> void:
assert_eq(_events_of(SimEvent.Type.ESCAPE_CANCELLED).size(), 1) assert_eq(_events_of(SimEvent.Type.ESCAPE_CANCELLED).size(), 1)
func test_taking_damage_cancels_the_channel() -> void: ## The inverse of what this asserted originally. Interrupting on damage makes
_hold_escape(60) ## quitting the process strictly better than using the button, which turns the
## escape hatch into the exploit -- see SimConfig.ESCAPE_CHANNEL_TICKS.
func test_taking_damage_does_not_cancel_the_channel() -> void:
_hold_escape(20)
var before: int = world.players[PEER].escape_ticks
world.pool.spawn(world.players[PEER].pos, Vector2.ZERO, 6.0, 60, 10, world.pool.spawn(world.players[PEER].pos, Vector2.ZERO, 6.0, 60, 10,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
_hold_escape(1)
assert_lt(world.players[PEER].hp, SimConfig.PLAYER_MAX_HP, "setup: should have been hit")
assert_eq(world.players[PEER].escape_ticks, before + 1,
"the channel must keep running while under fire")
assert_eq(_events_of(SimEvent.Type.ESCAPE_CANCELLED).size(), 0)
## Dying is still an interruption -- it is the one thing the escape cannot beat,
## which is what keeps the one-second channel a real risk.
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() world.step()
assert_eq(world.players[PEER].escape_ticks, 0, assert_false(p.alive)
"escaping under fire has to be a real risk, not a free exit") assert_eq(p.escape_ticks, 0)
assert_gt(_events_of(SimEvent.Type.ESCAPE_CANCELLED).size(), 0) assert_eq(_events_of(SimEvent.Type.ESCAPE_COMPLETED).size(), 0)
## A dropped connection channels out on exactly the same timer, so pulling the
## plug is never cheaper than pressing the button.
func test_a_linkdead_player_channels_out_without_input() -> void:
var p: SimPlayer = world.players[PEER]
p.linkdead = true
for _i in SimConfig.ESCAPE_CHANNEL_TICKS - 1:
world.step()
assert_eq(_events_of(SimEvent.Type.ESCAPE_COMPLETED).size(), 0,
"a disconnect must not be an instant exit")
world.step()
assert_eq(_events_of(SimEvent.Type.ESCAPE_COMPLETED).size(), 1)
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)
world.step()
assert_false(p.alive, "the whole point: disconnecting does not grant immunity")
func test_a_cancelled_channel_restarts_from_zero() -> void: func test_a_cancelled_channel_restarts_from_zero() -> void:
_hold_escape(120) _hold_escape(SimConfig.ESCAPE_CHANNEL_TICKS / 2)
var frames: Array[InputFrame] = [InputFrame.make(world.tick + 1, Vector2.ZERO, 0.0, 0)] var frames: Array[InputFrame] = [InputFrame.make(world.tick + 1, Vector2.ZERO, 0.0, 0)]
world.queue_input(PEER, frames) world.queue_input(PEER, frames)
world.step() world.step()
+58 -2
View File
@@ -11,7 +11,7 @@ func before_each() -> void:
p.pos = Vector2(120.5, -64.25) p.pos = Vector2(120.5, -64.25)
p.aim = 1.25 p.aim = 1.25
p.hp = 73 p.hp = 73
p.escape_ticks = 90 p.escape_ticks = 30
p.last_input_tick = 555 p.last_input_tick = 555
world.spawn_enemy(Content.turret(), Vector2(-200.0, 100.0)) world.spawn_enemy(Content.turret(), Vector2(-200.0, 100.0))
world.spawn_boss(Content.warden()) world.spawn_boss(Content.warden())
@@ -30,7 +30,7 @@ func test_snapshot_round_trips_player_state() -> void:
assert_eq(int(rec["last_input_tick"]), 555) assert_eq(int(rec["last_input_tick"]), 555)
assert_true((int(rec["flags"]) & Protocol.F_ALIVE) != 0) assert_true((int(rec["flags"]) & Protocol.F_ALIVE) != 0)
assert_true((int(rec["flags"]) & Protocol.F_ESCAPING) != 0) assert_true((int(rec["flags"]) & Protocol.F_ESCAPING) != 0)
assert_almost_eq(float(rec["escape"]), 90.0 / float(SimConfig.ESCAPE_CHANNEL_TICKS), 0.01) assert_almost_eq(float(rec["escape"]), 30.0 / float(SimConfig.ESCAPE_CHANNEL_TICKS), 0.01)
func test_snapshot_carries_enemy_radius_for_late_joiners() -> void: func test_snapshot_carries_enemy_radius_for_late_joiners() -> void:
@@ -105,3 +105,59 @@ func test_truncated_input_packet_is_rejected_not_read_past() -> void:
func test_empty_input_packet_is_safe() -> void: func test_empty_input_packet_is_safe() -> void:
assert_eq(NetCodec.decode_inputs(PackedByteArray()).size(), 0) assert_eq(NetCodec.decode_inputs(PackedByteArray()).size(), 0)
# --- Roster -----------------------------------------------------------------
func test_roster_round_trips() -> void:
var entries: Array[Dictionary] = [
{"peer": 7, "name": "ada", "kind": Protocol.InstanceKind.LOBBY,
"instance": 1, "alive": true},
{"peer": 9, "name": "grace", "kind": Protocol.InstanceKind.DUNGEON,
"instance": 4, "alive": false},
]
var out := NetCodec.decode_roster(NetCodec.encode_roster(entries))
assert_eq(out.size(), 2)
assert_eq(int(out[0]["peer"]), 7)
assert_eq(String(out[0]["name"]), "ada")
assert_eq(int(out[0]["kind"]), Protocol.InstanceKind.LOBBY)
assert_true(out[0]["alive"])
assert_eq(String(out[1]["name"]), "grace")
assert_eq(int(out[1]["kind"]), Protocol.InstanceKind.DUNGEON,
"the hub needs to know someone is already inside")
assert_eq(int(out[1]["instance"]), 4)
assert_false(out[1]["alive"])
func test_roster_survives_unicode_names() -> void:
var entries: Array[Dictionary] = [
{"peer": 1, "name": "ゆき", "kind": 0, "instance": 1, "alive": true}]
var out := NetCodec.decode_roster(NetCodec.encode_roster(entries))
assert_eq(String(out[0]["name"]), "ゆき")
func test_empty_roster_is_safe() -> void:
assert_eq(NetCodec.decode_roster(PackedByteArray()).size(), 0)
assert_eq(NetCodec.decode_roster(NetCodec.encode_roster([] as Array[Dictionary])).size(), 0)
# --- Snapshot extras --------------------------------------------------------
func test_snapshot_carries_spawn_grace_and_linkdead_flags() -> void:
var p: SimPlayer = world.players[42]
p.spawn_grace = 30
p.linkdead = true
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
var flags := int(snap["players"][0]["flags"])
assert_true((flags & Protocol.F_SPAWN_GRACE) != 0)
assert_true((flags & Protocol.F_LINKDEAD) != 0)
func test_cleared_countdown_round_trips() -> void:
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world, 17))
assert_eq(int(snap["cleared_countdown"]), 17)
func test_countdown_defaults_to_not_applicable() -> void:
var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world))
assert_eq(int(snap["cleared_countdown"]), Protocol.COUNTDOWN_NONE)
+59 -6
View File
@@ -122,17 +122,70 @@ func test_a_replica_world_never_resolves_a_hit() -> void:
"only the server decides damage; a client replica must never apply it") "only the server decides damage; a client replica must never apply it")
func test_player_death_and_respawn() -> void: 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.iframes = 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)
world.step() world.step()
assert_false(p.alive) assert_false(p.alive, "setup: the player should be down")
for _i in SimConfig.PLAYER_RESPAWN_DELAY + 1: return p
func _events_of(type: int) -> Array:
return world.events.filter(func(e: Dictionary) -> bool: return int(e["t"]) == type)
func test_a_downed_player_stays_down_without_input() -> void:
var p := _kill_player()
world.drain_events()
for _i in 600:
world.step() world.step()
assert_true(p.alive) assert_false(p.alive, "there is no timed respawn -- death waits for the player")
assert_eq(p.hp, SimConfig.PLAYER_MAX_HP) assert_eq(_events_of(SimEvent.Type.RESPAWN_REQUESTED).size(), 0)
assert_eq(p.pos, world.spawn_point)
func test_a_downed_player_asking_to_respawn_is_reported_once_per_tick() -> void:
_kill_player()
world.drain_events()
_drive(1, Vector2.ZERO, InputFrame.BTN_INTERACT)
assert_eq(_events_of(SimEvent.Type.RESPAWN_REQUESTED).size(), 1,
"the request is an event for the instance layer, not a local revive")
# Crucially the world does NOT revive the player itself: only the server's
# instance layer can, by moving them to the hub.
assert_false(world.players[PEER].alive)
func test_respawn_request_never_reaches_the_client() -> void:
_kill_player()
_drive(1, Vector2.ZERO, InputFrame.BTN_INTERACT)
var packet := NetCodec.decode_events(NetCodec.encode_events(world.tick, world.events))
for ev: Dictionary in packet["events"]:
assert_ne(int(ev["t"]), SimEvent.Type.RESPAWN_REQUESTED,
"where a dead player goes is the server's decision")
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)
_drive(1, Vector2.ZERO, InputFrame.BTN_FIRE)
assert_eq(p.hp, SimConfig.PLAYER_MAX_HP, "arrival protection must absorb the hit")
assert_eq(world.pool.live_count, 1,
"only the enemy bullet: a protected player cannot shoot either")
func test_spawn_grace_expires() -> void:
var p: SimPlayer = world.players[PEER]
p.spawn_grace = 5
_drive(6)
assert_eq(p.spawn_grace, 0)
assert_false(p.invulnerable())
_drive(1, Vector2.ZERO, InputFrame.BTN_FIRE)
assert_eq(world.pool.live_count, 1, "the gun comes back once grace ends")
+59
View File
@@ -0,0 +1,59 @@
extends Node
## Measures the gap between where the client *draws* the local player and where
## the server actually has it -- the number that decides whether bullets appear
## to leave the ship's nose or trail out of its back.
##
## godot --headless --path . res://tools/diag_prediction.tscn
##
## Runs the real listen-server path (ServerRuntime + ClientRuntime + the actual
## Net loopback), not a mock, so node process order is exactly production's.
var _ticks: int = 0
var _worst: float = 0.0
var _sum: float = 0.0
var _samples: int = 0
func _ready() -> void:
GameOpts.bot_client = true
GameOpts.player_name = "diag"
if Net.host(27300) != OK:
push_error("could not host")
get_tree().quit(1)
return
Net.start_local_client()
func _physics_process(_delta: float) -> void:
_ticks += 1
var srv := Net.server
var cli := Net.client
if srv == null or cli == null:
return
var inst := srv.instance_of(Net.LOCAL_PEER)
if inst == null or not inst.world.players.has(Net.LOCAL_PEER):
return
var sp: SimPlayer = inst.world.players[Net.LOCAL_PEER]
var delta_px := sp.pos.distance_to(cli.predicted_pos)
# Skip the first few ticks while the handshake settles.
if _ticks > 20:
_worst = maxf(_worst, delta_px)
_sum += delta_px
_samples += 1
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()),
delta_px, sp.last_input_tick, cli.input_tick, sp.input_queue.size()])
if _ticks >= 300:
print("---")
print("GAP worst=%.2fpx mean=%.2fpx over %d samples" % [_worst, _sum / maxf(_samples, 1), _samples])
print("player speed is %.0f u/s = %.2f px per tick" % [
SimConfig.PLAYER_SPEED, SimConfig.PLAYER_SPEED * SimConfig.TICK_DELTA])
print("muzzle offset = %.1fpx, drawn ship radius = %.1fpx, hitbox = %.1fpx" % [
SimConfig.PLAYER_MUZZLE_OFFSET, SimConfig.PLAYER_VISUAL_RADIUS,
SimConfig.PLAYER_RADIUS])
Net.shutdown()
get_tree().quit(0)
+1
View File
@@ -0,0 +1 @@
uid://bcsb8u10uk0o1
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tools/diag_prediction.gd" id="1"]
[node name="DiagPrediction" type="Node"]
script = ExtResource("1")
+1
View File
@@ -19,6 +19,7 @@ func _init() -> void:
_action("fire", [_mouse(MOUSE_BUTTON_LEFT), _key(KEY_SPACE)]) _action("fire", [_mouse(MOUSE_BUTTON_LEFT), _key(KEY_SPACE)])
_action("emergency_escape", [_key(KEY_F)]) _action("emergency_escape", [_key(KEY_F)])
_action("interact", [_key(KEY_E)]) _action("interact", [_key(KEY_E)])
_action("system_menu", [_key(KEY_ESCAPE)])
var err := ProjectSettings.save() var err := ProjectSettings.save()
print("input map written, err=%d" % err) print("input map written, err=%d" % err)
quit(0 if err == OK else 1) quit(0 if err == OK else 1)
+30 -3
View File
@@ -17,8 +17,11 @@ SERVER_TICKS="${SERVER_TICKS:-2000}"
CLIENT_TICKS="${CLIENT_TICKS:-1700}" CLIENT_TICKS="${CLIENT_TICKS:-1700}"
OUT="$(mktemp -d -t transcience-smoke-XXXXXX)" OUT="$(mktemp -d -t transcience-smoke-XXXXXX)"
PIDS=()
cleanup() { cleanup() {
kill %1 %2 %3 2>/dev/null for pid in "${PIDS[@]:-}"; do
kill "$pid" 2>/dev/null
done
wait 2>/dev/null wait 2>/dev/null
if [[ "${KEEP:-0}" == "1" ]]; then if [[ "${KEEP:-0}" == "1" ]]; then
echo "logs kept in $OUT" echo "logs kept in $OUT"
@@ -31,6 +34,7 @@ trap cleanup EXIT
echo "smoke: server on port $PORT, logs in $OUT" echo "smoke: server on port $PORT, logs in $OUT"
"$GODOT" --headless --path . -- --server --port "$PORT" --autoquit "$SERVER_TICKS" \ "$GODOT" --headless --path . -- --server --port "$PORT" --autoquit "$SERVER_TICKS" \
> "$OUT/server.log" 2>&1 & > "$OUT/server.log" 2>&1 &
PIDS+=($!)
for _ in $(seq 1 60); do for _ in $(seq 1 60); do
grep -q "SERVER_READY" "$OUT/server.log" 2>/dev/null && break grep -q "SERVER_READY" "$OUT/server.log" 2>/dev/null && break
@@ -43,11 +47,31 @@ fi
for n in 1 2; do for n in 1 2; do
"$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \ "$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \
--name "bot$n" --autoquit "$CLIENT_TICKS" > "$OUT/bot$n.log" 2>&1 & --name "bot$n" --autoquit "$CLIENT_TICKS" > "$OUT/bot$n.log" 2>&1 &
PIDS+=($!)
sleep 0.4 sleep 0.4
done done
wait %2 %3 2>/dev/null # A third bot that gets SIGKILLed the moment it is inside a dungeon. This is the
wait %1 2>/dev/null # anti-disconnect-cheese path: the server must keep its body in the world,
# channel it out over the same one second the escape button costs, and only then
# forget the peer -- never delete it instantly on socket close.
"$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \
--name "dropbot" --autoquit "$CLIENT_TICKS" > "$OUT/dropbot.log" 2>&1 &
DROP_PID=$!
for _ in $(seq 1 80); do
grep -q "entered instance .*DUNGEON" "$OUT/dropbot.log" 2>/dev/null && break
sleep 0.25
done
if grep -q "entered instance .*DUNGEON" "$OUT/dropbot.log" 2>/dev/null; then
# Reaped here so bash does not print its own "Killed" job-control line; the
# kill is the point of the test, not a failure.
{ kill -9 "$DROP_PID"; wait "$DROP_PID"; } 2>/dev/null || true
else
echo " WARN dropbot never reached a dungeon; drop assertions will fail"
PIDS+=("$DROP_PID")
fi
wait 2>/dev/null
fails=0 fails=0
check() { # check <label> <file> <pattern> check() { # check <label> <file> <pattern>
@@ -77,6 +101,9 @@ check "a dungeon instance opened" "$OUT/server.log" "opened dungeon instance"
check "emergency escape completed" "$OUT/server.log" "escaped to lobby" check "emergency escape completed" "$OUT/server.log" "escaped to lobby"
check "bot1 reached a dungeon" "$OUT/bot1.log" "entered instance .*DUNGEON" check "bot1 reached a dungeon" "$OUT/bot1.log" "entered instance .*DUNGEON"
check "bot1 returned to the lobby" "$OUT/bot1.log" "entered instance .*LOBBY" check "bot1 returned to the lobby" "$OUT/bot1.log" "entered instance .*LOBBY"
check "a hard drop is channelled, not instant" \
"$OUT/server.log" "dropped in instance [0-9]+, channelling out"
check "the dropped body is released" "$OUT/server.log" "released from instance [0-9]+ after drop"
refute "no server script errors" "$OUT/server.log" "SCRIPT ERROR|Parse Error|USER ERROR" refute "no server script errors" "$OUT/server.log" "SCRIPT ERROR|Parse Error|USER ERROR"
refute "no client script errors" "$OUT/bot1.log" "SCRIPT ERROR|Parse Error|USER ERROR" refute "no client script errors" "$OUT/bot1.log" "SCRIPT ERROR|Parse Error|USER ERROR"