diff --git a/CLAUDE.md b/CLAUDE.md index 14fb0f5..68ee99d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,6 +100,21 @@ ticks in milliseconds with no SceneTree. strips comments. Anything load-bearing (the 60 Hz tick) is asserted in code 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 A new enemy or boss is data, never code. Add a builder to diff --git a/docs/NETCODE.md b/docs/NETCODE.md index 62cb218..2692476 100644 --- a/docs/NETCODE.md +++ b/docs/NETCODE.md @@ -91,6 +91,43 @@ rate as tapping it correctly. 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. +## 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 `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; 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 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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 13317f8..4e3f182 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -9,9 +9,13 @@ | 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. | | 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 @@ -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 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 texture. Sprites, hit flashes, screen shake, muzzle flashes, death effects, and sound. None of it touches the simulation — this is entirely `src/view/`. diff --git a/project.godot b/project.godot index 1ea3541..32ae6c5 100644 --- a/project.godot +++ b/project.godot @@ -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) ] } +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] diff --git a/src/autoload/net.gd b/src/autoload/net.gd index 5aa8180..4176f0d 100644 --- a/src/autoload/net.gd +++ b/src/autoload/net.gd @@ -182,6 +182,13 @@ func send_events(peer_id: int, data: PackedByteArray) -> void: 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: if _is_local(peer_id): GameLog.error("net", "local client rejected: %s" % reason) @@ -237,6 +244,12 @@ func s_reject(reason: String) -> void: _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) func s_snapshot(data: PackedByteArray) -> void: if client != null: diff --git a/src/core/sim_config.gd b/src/core/sim_config.gd index 9b23b46..03d43d4 100644 --- a/src/core/sim_config.gd +++ b/src/core/sim_config.gd @@ -29,6 +29,13 @@ const PLAYER_SPEED := 240.0 const PLAYER_RADIUS := 6.0 ## Render-only. Used by src/view/, never by anything under src/sim/. 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_FIRE_COOLDOWN := 7 # ticks 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_DAMAGE := 6 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 ------------------------------------------------------ ## 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 # --- Emergency escape ------------------------------------------------------- -const ESCAPE_CHANNEL_TICKS := 180 # 3 seconds -## Taking damage while channelling cancels the escape. -const ESCAPE_BREAK_ON_DAMAGE := true +const ESCAPE_CHANNEL_TICKS := 60 # 1 second +## Taking damage does NOT interrupt the channel. It used to, which sounds like +## 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 ---------------------------------------------------------------- const MAX_BULLETS := 4096 @@ -67,6 +81,10 @@ const LOBBY_INSTANCE_ID := 1 const DUNGEON_PARTY_MAX := 4 ## How long a forming dungeon waits for more players before it locks. 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 ----------------------------------------------------------------- const PORTAL_POS := Vector2(0.0, -220.0) diff --git a/src/game.tscn b/src/game.tscn index b32f9cf..a1da2df 100644 --- a/src/game.tscn +++ b/src/game.tscn @@ -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/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/ui/hud.gd" id="4"] +[ext_resource type="Script" path="res://src/ui/game_menu.gd" id="5"] [node name="Game" type="Node2D"] script = ExtResource("1") @@ -17,3 +18,6 @@ script = ExtResource("3") [node name="HUD" type="CanvasLayer" parent="."] script = ExtResource("4") + +[node name="GameMenu" type="CanvasLayer" parent="."] +script = ExtResource("5") diff --git a/src/instances/instance.gd b/src/instances/instance.gd index 8cbd1bf..4742bb3 100644 --- a/src/instances/instance.gd +++ b/src/instances/instance.gd @@ -49,6 +49,9 @@ static func make_dungeon(instance_id: int, dungeon_seed: int) -> Instance: inst.seed_value = dungeon_seed inst.world = SimWorld.new(dungeon_seed) 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.state = State.FORMING if GameOpts.boss_rush: @@ -68,10 +71,40 @@ func remove_peer(peer_id: int) -> void: 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: 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: return kind == Protocol.InstanceKind.DUNGEON \ and state == State.FORMING \ @@ -110,7 +143,9 @@ func _step_dungeon() -> void: if world.boss != null and world.boss.alive: return 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 stage += 1 diff --git a/src/net/client_runtime.gd b/src/net/client_runtime.gd index 54cc3f4..da8c055 100644 --- a/src/net/client_runtime.gd +++ b/src/net/client_runtime.gd @@ -33,6 +33,20 @@ var my_hp: int = SimConfig.PLAYER_MAX_HP var my_alive: bool = true var my_escape: float = 0.0 var my_escaping: bool = false +## Arrival protection: invulnerable and unable to shoot. +var my_spawn_grace: bool = false + +## Set by the in-game menu's "return to hub" -- synthesises a held escape +## button, so the menu route runs the identical server-side channel as the key. +## Cleared when the channel resolves or the player cancels. +var request_escape: bool = false + +## Who is online and where, for the hub's player list. Server-pushed. +var roster: Array[Dictionary] = [] + +## Whole seconds until a cleared dungeon returns the party, or +## Protocol.COUNTDOWN_NONE outside that state. +var cleared_countdown: int = Protocol.COUNTDOWN_NONE var snap_prev: Dictionary = {} var snap_curr: Dictionary = {} @@ -42,6 +56,14 @@ var _bot_tick: int = 0 func _ready() -> void: world.authoritative = false + # Sample and send input BEFORE ServerRuntime ticks. On a listen server both + # live in one process, and default tree order put the server first -- so the + # input sampled on frame N was not consumed until the server's frame N+1, + # leaving the drawn ship a permanent one-tick (4px at 240 u/s) ahead of the + # authoritative one. Bullets, spawned at the authoritative position, visibly + # trailed the ship. Going first closes the gap to zero on a listen server + # and costs a remote client nothing. + process_physics_priority = -10 set_physics_process(true) @@ -84,7 +106,7 @@ func _sample_input() -> InputFrame: var buttons := 0 if Input.is_action_pressed("fire"): buttons |= InputFrame.BTN_FIRE - if Input.is_action_pressed("emergency_escape"): + if Input.is_action_pressed("emergency_escape") or request_escape: buttons |= InputFrame.BTN_ESCAPE if Input.is_action_pressed("interact"): buttons |= InputFrame.BTN_INTERACT @@ -99,6 +121,10 @@ func _bot_input() -> InputFrame: var move := Vector2(cos(t * 0.9), sin(t * 1.3)) aim = t * 2.1 var buttons := InputFrame.BTN_FIRE + if not my_alive: + # Downed bots ask for the hub, same as a player would -- otherwise a bot + # that dies mid-run just lies there and the smoke test stalls. + return InputFrame.make(input_tick, Vector2.ZERO, aim, InputFrame.BTN_INTERACT) if instance_kind == Protocol.InstanceKind.LOBBY and _bot_tick % 120 < 30: buttons |= InputFrame.BTN_INTERACT # Walk onto the portal instead of orbiting, or interact never lands. @@ -116,6 +142,11 @@ func on_welcome(peer_id: int) -> void: GameLog.info("client", "welcome, peer id %d" % peer_id) +func on_roster(data: PackedByteArray) -> void: + roster = NetCodec.decode_roster(data) + hud_dirty.emit() + + func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String, spawn: Vector2) -> void: instance_id = id @@ -132,6 +163,12 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String, my_hp = SimConfig.PLAYER_MAX_HP my_escape = 0.0 my_escaping = false + my_spawn_grace = false + # Arriving somewhere is the natural end of an escape request, however it + # was triggered -- otherwise the synthesised button would keep firing and + # bounce the player straight back out of the hub. + request_escape = false + cleared_countdown = Protocol.COUNTDOWN_NONE GameLog.info("client", "entered instance %d (%s)" % [id, Protocol.InstanceKind.keys()[kind]]) instance_changed.emit() hud_dirty.emit() @@ -141,6 +178,7 @@ func on_snapshot(data: PackedByteArray) -> void: var snap := NetCodec.decode_snapshot(data) if not snap_curr.is_empty() and int(snap["tick"]) <= int(snap_curr["tick"]): return # stale or duplicate; unreliable channel, newest wins + cleared_countdown = int(snap["cleared_countdown"]) snap_prev = snap_curr snap_curr = snap _interp = 0.0 @@ -166,6 +204,7 @@ func _reconcile(rec: Dictionary) -> void: my_hp = int(rec["hp"]) my_alive = (int(rec["flags"]) & Protocol.F_ALIVE) != 0 my_escaping = (int(rec["flags"]) & Protocol.F_ESCAPING) != 0 + my_spawn_grace = (int(rec["flags"]) & Protocol.F_SPAWN_GRACE) != 0 my_escape = float(rec["escape"]) hud_dirty.emit() diff --git a/src/net/net_codec.gd b/src/net/net_codec.gd index e84a213..7855c7c 100644 --- a/src/net/net_codec.gd +++ b/src/net/net_codec.gd @@ -8,10 +8,14 @@ extends RefCounted # --- Snapshot --------------------------------------------------------------- -static func encode_snapshot(world: SimWorld) -> PackedByteArray: +## [param cleared_countdown] is whole seconds until a cleared dungeon returns +## its party, or Protocol.COUNTDOWN_NONE when that does not apply. +static func encode_snapshot(world: SimWorld, + cleared_countdown: int = Protocol.COUNTDOWN_NONE) -> PackedByteArray: var b := StreamPeerBuffer.new() b.big_endian = false b.put_u32(world.tick) + b.put_u8(clampi(cleared_countdown, 0, Protocol.COUNTDOWN_NONE)) b.put_u8(mini(world.players.size(), 255)) for p in world.players.values(): @@ -27,6 +31,10 @@ static func encode_snapshot(world: SimWorld) -> PackedByteArray: flags |= Protocol.F_INVULN if p.escape_ticks > 0: flags |= Protocol.F_ESCAPING + if p.spawn_grace > 0: + flags |= Protocol.F_SPAWN_GRACE + if p.linkdead: + flags |= Protocol.F_LINKDEAD b.put_u8(flags) b.put_u8(clampi(roundi(p.escape_progress() * 255.0), 0, 255)) # Echoed so the owning client knows how far to rewind when reconciling. @@ -62,7 +70,11 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary: var b := StreamPeerBuffer.new() b.big_endian = false b.data_array = data - var snap := {"tick": b.get_u32(), "players": [], "enemies": [], "boss": null} + var snap := { + "tick": b.get_u32(), + "cleared_countdown": b.get_u8(), + "players": [], "enemies": [], "boss": null, + } var pcount := b.get_u8() for _i in pcount: @@ -99,7 +111,13 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary: # --- Events ----------------------------------------------------------------- ## Events the client never sees; the instance layer consumes them server-side. -const SERVER_ONLY := [SimEvent.Type.PORTAL_USED, SimEvent.Type.ESCAPE_COMPLETED] +## These describe a decision the server is about to act on, not an outcome -- +## leaking them would tell a client about a message it might try to forge. +const SERVER_ONLY := [ + SimEvent.Type.PORTAL_USED, + SimEvent.Type.ESCAPE_COMPLETED, + SimEvent.Type.RESPAWN_REQUESTED, +] ## [param server_tick] rides along so the client can fast-forward a bullet by @@ -232,3 +250,41 @@ static func decode_inputs(data: PackedByteArray) -> Array[InputFrame]: for _i in count: out.append(InputFrame.read(b)) return out + + +# --- Roster ----------------------------------------------------------------- +# Who is online and where. Low frequency (membership changes only), so it is +# the one message that carries strings; everything hot stays fixed-width. + +static func encode_roster(entries: Array[Dictionary]) -> PackedByteArray: + var b := StreamPeerBuffer.new() + b.big_endian = false + b.put_u8(mini(entries.size(), 255)) + for e in entries: + b.put_u32(int(e["peer"])) + b.put_utf8_string(String(e["name"])) + b.put_u8(int(e["kind"])) + b.put_u32(int(e["instance"])) + b.put_u8(1 if e["alive"] else 0) + return b.data_array + + +static func decode_roster(data: PackedByteArray) -> Array[Dictionary]: + var out: Array[Dictionary] = [] + if data.size() < 1: + return out + var b := StreamPeerBuffer.new() + b.big_endian = false + b.data_array = data + var count := b.get_u8() + for _i in count: + # get_utf8_string reads its own length prefix, so a truncated packet + # yields empty strings rather than reading off the end. + out.append({ + "peer": b.get_u32(), + "name": b.get_utf8_string(), + "kind": b.get_u8(), + "instance": b.get_u32(), + "alive": b.get_u8() == 1, + }) + return out diff --git a/src/net/protocol.gd b/src/net/protocol.gd index e2be3fe..e6b2e5d 100644 --- a/src/net/protocol.gd +++ b/src/net/protocol.gd @@ -3,7 +3,9 @@ extends RefCounted ## Wire constants. Bump [constant VERSION] whenever a codec layout changes; the ## server refuses mismatched clients at handshake rather than desyncing later. -const VERSION := 1 +## 2: added spawn-grace flag + cleared countdown to the snapshot, and the +## roster message. +const VERSION := 2 const DEFAULT_PORT := 27015 const MAX_CLIENTS := 32 @@ -22,3 +24,12 @@ enum InstanceKind { LOBBY, DUNGEON } const F_ALIVE := 1 const F_INVULN := 2 const F_ESCAPING := 4 +## Arrival protection: invulnerable and unable to shoot. Distinct from F_INVULN +## so the client can label the status rather than just tint the ship. +const F_SPAWN_GRACE := 8 +## The peer behind this player has dropped and is being channelled out. +const F_LINKDEAD := 16 + +## Snapshot's cleared-countdown byte when the instance is not in the cleared +## state -- 30s fits in a byte, so 255 is free to mean "not applicable". +const COUNTDOWN_NONE := 255 diff --git a/src/net/server_runtime.gd b/src/net/server_runtime.gd index 60e36dd..57dd9cf 100644 --- a/src/net/server_runtime.gd +++ b/src/net/server_runtime.gd @@ -38,13 +38,18 @@ func _physics_process(_delta: float) -> void: inst.step() _dispatch_events(inst) if send_snapshot and not inst.peers.is_empty(): - var snap := NetCodec.encode_snapshot(inst.world) + var snap := NetCodec.encode_snapshot(inst.world, inst.exit_countdown_seconds()) for peer in inst.peers: Net.send_snapshot(peer, snap) - if inst.kind == Protocol.InstanceKind.DUNGEON \ - and inst.state == Instance.State.CLEARED and inst.stage_delay <= 0: + if inst.kind != Protocol.InstanceKind.DUNGEON: + continue + if inst.state == Instance.State.CLEARED and inst.stage_delay <= 0: closing.append(inst.id) - elif inst.kind == Protocol.InstanceKind.DUNGEON and inst.is_empty() and inst.age > 60: + elif inst.is_empty() and not inst.has_linkdead() and inst.age > 60: + # has_linkdead() matters: without it a solo player could drop, the + # instance would close on the next tick, and their body would + # vanish before finishing the escape channel -- exactly the + # disconnect cheese the channel exists to prevent. closing.append(inst.id) for id in closing: @@ -52,18 +57,25 @@ func _physics_process(_delta: float) -> void: ## Split the tick's events into the ones clients need and the ones only the -## server acts on (escape completion, portal use). +## server acts on (escape completion, portal use, respawn requests). func _dispatch_events(inst: Instance) -> void: var events := inst.world.drain_events() if events.is_empty(): return - var transfers: Array[Dictionary] = [] + # Collected and applied after the send below, because a transfer mutates + # inst.peers and would otherwise change the list mid-broadcast. + var to_lobby: Array[int] = [] + var to_dungeon: Array[int] = [] for ev in events: match int(ev["t"]): - SimEvent.Type.ESCAPE_COMPLETED: - transfers.append({"peer": int(ev["peer"]), "to_lobby": true}) + SimEvent.Type.ESCAPE_COMPLETED, SimEvent.Type.RESPAWN_REQUESTED: + var peer := int(ev["peer"]) + if not to_lobby.has(peer): + to_lobby.append(peer) SimEvent.Type.PORTAL_USED: - transfers.append({"peer": int(ev["peer"]), "to_lobby": false}) + var peer := int(ev["peer"]) + if not to_dungeon.has(peer): + to_dungeon.append(peer) _: pass @@ -71,11 +83,10 @@ func _dispatch_events(inst: Instance) -> void: for peer in inst.peers: Net.send_events(peer, payload) - for t in transfers: - if t["to_lobby"]: - _send_to_lobby(int(t["peer"])) - else: - _send_to_dungeon(int(t["peer"])) + for peer in to_lobby: + _send_to_lobby(peer) + for peer in to_dungeon: + _send_to_dungeon(peer) # --- Peer lifecycle --------------------------------------------------------- @@ -84,13 +95,42 @@ func on_peer_connected(peer_id: int) -> void: GameLog.info("server", "peer %d connected, awaiting hello" % peer_id) +## A drop inside a dungeon does NOT delete the player. The body is detached +## from the send list but kept in the world, where it channels the same +## one-second escape everyone else does and stays killable the whole time -- +## so quitting the process is never a cheaper exit than pressing the button. +## Only once that channel resolves (_release_linkdead) is the peer forgotten. +## A drop in the hub has nothing to dodge, so it is immediate. func on_peer_disconnected(peer_id: int) -> void: var inst := instance_of(peer_id) - if inst != null: - inst.remove_peer(peer_id) + if inst == null: + _forget_peer(peer_id) + return + if inst.kind == Protocol.InstanceKind.DUNGEON: + inst.detach_peer(peer_id) + GameLog.info("server", "peer %d dropped in instance %d, channelling out" + % [peer_id, inst.id]) + _broadcast_roster() + return + inst.remove_peer(peer_id) + _forget_peer(peer_id) + GameLog.info("server", "peer %d disconnected" % peer_id) + + +func _forget_peer(peer_id: int) -> void: peer_instance.erase(peer_id) peer_names.erase(peer_id) - GameLog.info("server", "peer %d disconnected" % peer_id) + _broadcast_roster() + + +## The linkdead body finished its channel (or died trying). Either way it leaves +## the dungeon; with no persistence yet, "safely back in the hub" and "gone" are +## the same outcome, so it is simply removed. +func _release_linkdead(peer_id: int, inst: Instance) -> void: + inst.remove_peer(peer_id) + GameLog.info("server", "peer %d released from instance %d after drop" + % [peer_id, inst.id]) + _forget_peer(peer_id) func on_hello(peer_id: int, version: int, display_name: String) -> void: @@ -109,6 +149,7 @@ func on_hello(peer_id: int, version: int, display_name: String) -> void: Net.send_welcome(peer_id) _place(peer_id, lobby) GameLog.info("server", "peer %d joined as '%s'" % [peer_id, clean]) + _broadcast_roster() func on_input(peer_id: int, data: PackedByteArray) -> void: @@ -135,7 +176,8 @@ func _place(peer_id: int, inst: Instance) -> void: var backlog := _live_bullet_events(inst.world) if not backlog.is_empty(): Net.send_events(peer_id, NetCodec.encode_events(inst.world.tick, backlog)) - Net.send_snapshot(peer_id, NetCodec.encode_snapshot(inst.world)) + Net.send_snapshot(peer_id, NetCodec.encode_snapshot(inst.world, + inst.exit_countdown_seconds())) func _transfer(peer_id: int, to: Instance) -> void: @@ -143,9 +185,19 @@ func _transfer(peer_id: int, to: Instance) -> void: if from != null: from.remove_peer(peer_id) _place(peer_id, to) + _broadcast_roster() +## Where everything that ends a dungeon run converges: the escape channel, a +## downed player asking out, and a linkdead body finishing its channel. func _send_to_lobby(peer_id: int) -> void: + var from := instance_of(peer_id) + if from != null: + var p: SimPlayer = from.world.players.get(peer_id) + if p != null and p.linkdead: + _release_linkdead(peer_id, from) + return + GameLog.info("server", "peer %d escaped to lobby" % peer_id) _transfer(peer_id, lobby) @@ -173,6 +225,33 @@ func _close_dungeon(id: int) -> void: GameLog.info("server", "closed dungeon instance %d" % id) +# --- Roster ----------------------------------------------------------------- + +## Tell everyone who is online and where they are, so the hub can show that a +## dungeon is already running before you walk into the portal. +## +## Sent on membership changes only, not per tick -- it is the one message that +## carries names, and nothing about it is time critical. +func _broadcast_roster() -> void: + var entries: Array[Dictionary] = [] + for peer in peer_names: + var inst := instance_of(peer) + if inst == null: + continue + var p: SimPlayer = inst.world.players.get(peer) + entries.append({ + "peer": peer, + "name": peer_names[peer], + "kind": int(inst.kind), + "instance": inst.id, + "alive": p != null and p.alive, + }) + var payload := NetCodec.encode_roster(entries) + for inst in instances.values(): + for peer in inst.peers: + Net.send_roster(peer, payload) + + func _live_bullet_events(world: SimWorld) -> Array[Dictionary]: var out: Array[Dictionary] = [] for i in world.pool.high_water: diff --git a/src/sim/sim_events.gd b/src/sim/sim_events.gd index 79cef29..5be3048 100644 --- a/src/sim/sim_events.gd +++ b/src/sim/sim_events.gd @@ -11,6 +11,9 @@ enum Type { PLAYER_HIT, ## peer, damage, hp PLAYER_DIED, ## peer 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_DIED, ## id BOSS_PHASE, ## phase index diff --git a/src/sim/sim_player.gd b/src/sim/sim_player.gd index 7535ef6..eaff0e8 100644 --- a/src/sim/sim_player.gd +++ b/src/sim/sim_player.gd @@ -11,7 +11,14 @@ var hp: int = SimConfig.PLAYER_MAX_HP var alive: bool = true var iframes: 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. 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) -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 hp = SimConfig.PLAYER_MAX_HP alive = true - iframes = SimConfig.PLAYER_IFRAMES + iframes = 0 + spawn_grace = grace fire_cooldown = 0 - respawn_timer = 0 escape_ticks = 0 input_queue.clear() diff --git a/src/sim/sim_world.gd b/src/sim/sim_world.gd index e833afb..99986c2 100644 --- a/src/sim/sim_world.gd +++ b/src/sim/sim_world.gd @@ -28,6 +28,9 @@ var events: Array[Dictionary] = [] ## Set on a lobby world so the interact button can open a dungeon. var portal_enabled: bool = false 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 _ctx := EmitContext.new() @@ -50,7 +53,7 @@ func add_player(peer_id: int, display_name: String) -> SimPlayer: var p := SimPlayer.new() p.peer_id = peer_id p.display_name = display_name - p.reset_for_instance(spawn_point) + p.reset_for_instance(spawn_point, spawn_grace_ticks) players[peer_id] = p return p @@ -148,24 +151,25 @@ func _step_players() -> void: for p in players.values(): if p.iframes > 0: p.iframes -= 1 + if p.spawn_grace > 0: + p.spawn_grace -= 1 if p.fire_cooldown > 0: p.fire_cooldown -= 1 + var frame := _take_input(p) + if not p.alive: - p.respawn_timer -= 1 - if p.respawn_timer <= 0: - p.alive = true - p.hp = SimConfig.PLAYER_MAX_HP - p.pos = spawn_point - p.iframes = SimConfig.PLAYER_IFRAMES - events.append({"t": SimEvent.Type.PLAYER_RESPAWNED, "peer": p.peer_id, "pos": p.pos}) + # No timed respawn: a downed player waits for the hub. Asking to go + # is an input like any other, so a dead client cannot be revived by + # anything except its own request reaching the server. + if frame.pressed(InputFrame.BTN_INTERACT) or p.linkdead: + events.append({"t": SimEvent.Type.RESPAWN_REQUESTED, "peer": p.peer_id}) continue - var frame := _take_input(p) p.aim = frame.aim 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) _step_escape(p, frame) @@ -196,7 +200,7 @@ func _fire_player_shot(p: SimPlayer) -> void: p.fire_cooldown = SimConfig.PLAYER_FIRE_COOLDOWN var dir := Vector2.RIGHT.rotated(p.aim) pool.spawn( - p.pos + dir * (SimConfig.PLAYER_RADIUS + 6.0), + p.pos + dir * SimConfig.PLAYER_MUZZLE_OFFSET, dir * SimConfig.PLAYER_BULLET_SPEED, SimConfig.PLAYER_BULLET_RADIUS, SimConfig.PLAYER_BULLET_LIFETIME, @@ -206,7 +210,10 @@ func _fire_player_shot(p: SimPlayer) -> 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: events.append({"t": SimEvent.Type.ESCAPE_STARTED, "peer": p.peer_id}) p.escape_ticks += 1 @@ -317,7 +324,10 @@ func _resolve_bullet_hits() -> void: var br: float = pool.radius[i] if pool.team[i] == SimConfig.TEAM_ENEMY: 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 if Movement.circles_overlap(bp, br, p.pos, SimConfig.PLAYER_RADIUS): _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: continue for p in players.values(): - if not p.alive or p.iframes > 0: + if not p.alive or p.invulnerable(): continue if Movement.circles_overlap(e.pos, e.def.radius, p.pos, SimConfig.PLAYER_RADIUS): _damage_player(p, e.def.contact_damage) @@ -358,16 +368,15 @@ func _kill_bullet(slot: int) -> void: 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: p.hp = maxi(p.hp - amount, 0) 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}) if p.hp <= 0: 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}) diff --git a/src/ui/game_menu.gd b/src/ui/game_menu.gd new file mode 100644 index 0000000..1f08b7c --- /dev/null +++ b/src/ui/game_menu.gd @@ -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 diff --git a/src/ui/game_menu.gd.uid b/src/ui/game_menu.gd.uid new file mode 100644 index 0000000..3b3b526 --- /dev/null +++ b/src/ui/game_menu.gd.uid @@ -0,0 +1 @@ +uid://6j1nytv113kn diff --git a/src/ui/hud.gd b/src/ui/hud.gd index f334e44..2b01dfc 100644 --- a/src/ui/hud.gd +++ b/src/ui/hud.gd @@ -68,9 +68,11 @@ func _status_text() -> String: func _hint_text() -> String: if client == null: return "" + if not client.my_alive: + return "DOWN -- press E to return to the hub" 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 hold F to escape to the lobby" + return "WASD move mouse aim LMB fire E on the ring to enter a dungeon Esc menu" + return "WASD move mouse aim LMB fire hold F to return to the hub Esc menu" func _draw_hud() -> void: @@ -89,16 +91,64 @@ func _draw_hud() -> void: _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: - var size := _canvas.size - _canvas.draw_string(ThemeDB.fallback_font, size * 0.5 - Vector2(70.0, 0.0), - "DOWN -- respawning", HORIZONTAL_ALIGNMENT_LEFT, -1, 22, Color(1.0, 0.4, 0.4)) + # Centred on the canvas, which is only correct because _canvas actually + # has the viewport's size now -- see the anchor note in _ready(). + var centre := _canvas.size * 0.5 + _canvas.draw_string(ThemeDB.fallback_font, centre - Vector2(52.0, 8.0), + "DOWN", HORIZONTAL_ALIGNMENT_LEFT, -1, 34, Color(1.0, 0.4, 0.4)) + _canvas.draw_string(ThemeDB.fallback_font, centre - Vector2(118.0, -18.0), + "press E to return to the hub", HORIZONTAL_ALIGNMENT_LEFT, -1, 16, + Color(1.0, 0.75, 0.75)) if _hit_flash > 0.0: _canvas.draw_rect(Rect2(Vector2.ZERO, _canvas.size), 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: var b := client.boss_state() if b.is_empty() or client.boss_def == null: diff --git a/src/view/game_scene.gd b/src/view/game_scene.gd index c8724d4..42b1964 100644 --- a/src/view/game_scene.gd +++ b/src/view/game_scene.gd @@ -5,6 +5,7 @@ extends Node2D @onready var world_view: Node2D = $WorldView @onready var hud: CanvasLayer = $HUD +@onready var menu: CanvasLayer = $GameMenu var _bound: ClientRuntime = null @@ -12,6 +13,8 @@ var _bound: ClientRuntime = null func _ready() -> void: world_view.position = get_viewport_rect().size * 0.5 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: @@ -23,6 +26,20 @@ func _process(_delta: float) -> void: _bound = Net.client if _bound != null and not _bound.local_hit.is_connected(_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: diff --git a/src/view/world_view.gd b/src/view/world_view.gd index 33f083d..f02a863 100644 --- a/src/view/world_view.gd +++ b/src/view/world_view.gd @@ -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_DEAD := Color(0.4, 0.4, 0.45, 0.5) 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 := [ Color(0.95, 0.55, 0.55), # drifter Color(0.85, 0.7, 0.35), # turret @@ -71,10 +73,25 @@ func _draw_portal() -> void: func _draw_enemy(e: Dictionary) -> void: var col: Color = ENEMY_COLORS[clampi(int(e["visual"]), 0, ENEMY_COLORS.size() - 1)] 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_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: var b := client.boss_state() if b.is_empty(): @@ -87,10 +104,13 @@ func _draw_boss() -> 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 _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) @@ -99,10 +119,20 @@ func _draw_local_player() -> void: _draw_ship(client.predicted_pos, client.aim, COL_DEAD, false) return _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: _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 ## 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 diff --git a/tests/integration/test_dungeon_flow.gd b/tests/integration/test_dungeon_flow.gd index 9621330..19fa38c 100644 --- a/tests/integration/test_dungeon_flow.gd +++ b/tests/integration/test_dungeon_flow.gd @@ -67,9 +67,13 @@ func test_killing_the_boss_clears_the_instance() -> void: inst.world.boss.alive = false _step(5) 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. - _step(400) + _step(SimConfig.DUNGEON_CLEARED_EXIT_TICKS + 10) assert_eq(inst.stage_delay, 0) + assert_eq(inst.exit_countdown_seconds(), 0) func test_the_lobby_has_a_portal_and_no_hostiles() -> void: diff --git a/tests/unit/test_emergency_escape.gd b/tests/unit/test_emergency_escape.gd index d3bd5a8..6dc1d65 100644 --- a/tests/unit/test_emergency_escape.gd +++ b/tests/unit/test_emergency_escape.gd @@ -33,7 +33,9 @@ func test_escape_takes_the_full_channel_time() -> 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) var frames: Array[InputFrame] = [InputFrame.make(world.tick + 1, Vector2.ZERO, 0.0, 0)] 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) -func test_taking_damage_cancels_the_channel() -> void: - _hold_escape(60) +## The inverse of what this asserted originally. Interrupting on damage makes +## 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, 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() - assert_eq(world.players[PEER].escape_ticks, 0, - "escaping under fire has to be a real risk, not a free exit") - assert_gt(_events_of(SimEvent.Type.ESCAPE_CANCELLED).size(), 0) + assert_false(p.alive) + assert_eq(p.escape_ticks, 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: - _hold_escape(120) + _hold_escape(SimConfig.ESCAPE_CHANNEL_TICKS / 2) var frames: Array[InputFrame] = [InputFrame.make(world.tick + 1, Vector2.ZERO, 0.0, 0)] world.queue_input(PEER, frames) world.step() diff --git a/tests/unit/test_net_codec.gd b/tests/unit/test_net_codec.gd index 2b1665c..d594e2d 100644 --- a/tests/unit/test_net_codec.gd +++ b/tests/unit/test_net_codec.gd @@ -11,7 +11,7 @@ func before_each() -> void: p.pos = Vector2(120.5, -64.25) p.aim = 1.25 p.hp = 73 - p.escape_ticks = 90 + p.escape_ticks = 30 p.last_input_tick = 555 world.spawn_enemy(Content.turret(), Vector2(-200.0, 100.0)) 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_true((int(rec["flags"]) & Protocol.F_ALIVE) != 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: @@ -105,3 +105,59 @@ func test_truncated_input_packet_is_rejected_not_read_past() -> void: func test_empty_input_packet_is_safe() -> void: 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) diff --git a/tests/unit/test_server_authority.gd b/tests/unit/test_server_authority.gd index 0ba5704..13a12b0 100644 --- a/tests/unit/test_server_authority.gd +++ b/tests/unit/test_server_authority.gd @@ -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") -func test_player_death_and_respawn() -> void: +func _kill_player() -> SimPlayer: var p: SimPlayer = world.players[PEER] p.pos = Vector2.ZERO p.hp = 5 p.iframes = 0 + p.spawn_grace = 0 world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 6.0, 60, 25, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) world.step() - assert_false(p.alive) - for _i in SimConfig.PLAYER_RESPAWN_DELAY + 1: + assert_false(p.alive, "setup: the player should be down") + 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() - assert_true(p.alive) - assert_eq(p.hp, SimConfig.PLAYER_MAX_HP) - assert_eq(p.pos, world.spawn_point) + assert_false(p.alive, "there is no timed respawn -- death waits for the player") + assert_eq(_events_of(SimEvent.Type.RESPAWN_REQUESTED).size(), 0) + + +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") diff --git a/tools/diag_prediction.gd b/tools/diag_prediction.gd new file mode 100644 index 0000000..bd6adaf --- /dev/null +++ b/tools/diag_prediction.gd @@ -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) diff --git a/tools/diag_prediction.gd.uid b/tools/diag_prediction.gd.uid new file mode 100644 index 0000000..b6ce99b --- /dev/null +++ b/tools/diag_prediction.gd.uid @@ -0,0 +1 @@ +uid://bcsb8u10uk0o1 diff --git a/tools/diag_prediction.tscn b/tools/diag_prediction.tscn new file mode 100644 index 0000000..03f06d4 --- /dev/null +++ b/tools/diag_prediction.tscn @@ -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") diff --git a/tools/setup_input_map.gd b/tools/setup_input_map.gd index 4ad1b77..a058ea8 100644 --- a/tools/setup_input_map.gd +++ b/tools/setup_input_map.gd @@ -19,6 +19,7 @@ func _init() -> void: _action("fire", [_mouse(MOUSE_BUTTON_LEFT), _key(KEY_SPACE)]) _action("emergency_escape", [_key(KEY_F)]) _action("interact", [_key(KEY_E)]) + _action("system_menu", [_key(KEY_ESCAPE)]) var err := ProjectSettings.save() print("input map written, err=%d" % err) quit(0 if err == OK else 1) diff --git a/tools/smoke.sh b/tools/smoke.sh index 9419db0..f357baf 100755 --- a/tools/smoke.sh +++ b/tools/smoke.sh @@ -17,8 +17,11 @@ SERVER_TICKS="${SERVER_TICKS:-2000}" CLIENT_TICKS="${CLIENT_TICKS:-1700}" OUT="$(mktemp -d -t transcience-smoke-XXXXXX)" +PIDS=() cleanup() { - kill %1 %2 %3 2>/dev/null + for pid in "${PIDS[@]:-}"; do + kill "$pid" 2>/dev/null + done wait 2>/dev/null if [[ "${KEEP:-0}" == "1" ]]; then echo "logs kept in $OUT" @@ -31,6 +34,7 @@ trap cleanup EXIT echo "smoke: server on port $PORT, logs in $OUT" "$GODOT" --headless --path . -- --server --port "$PORT" --autoquit "$SERVER_TICKS" \ > "$OUT/server.log" 2>&1 & +PIDS+=($!) for _ in $(seq 1 60); do grep -q "SERVER_READY" "$OUT/server.log" 2>/dev/null && break @@ -43,11 +47,31 @@ fi for n in 1 2; do "$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \ --name "bot$n" --autoquit "$CLIENT_TICKS" > "$OUT/bot$n.log" 2>&1 & + PIDS+=($!) sleep 0.4 done -wait %2 %3 2>/dev/null -wait %1 2>/dev/null +# A third bot that gets SIGKILLed the moment it is inside a dungeon. This is the +# 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 check() { # check