Files
transcience/src/autoload/net.gd
T
Adyrem 1dc1952a3c
ci / verify (push) Successful in 45s
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.
2026-09-03 18:43:19 +02:00

263 lines
7.7 KiB
GDScript

extends Node
## Transport and RPC surface. This is the only autoload in the project.
##
## It has to be an autoload because Godot routes an RPC by node path: the sender
## and receiver must agree on where the node lives, and `/root/Net` is the one
## path that exists identically on a dedicated server and on every client.
##
## The naming rule here is load-bearing: `c_*` is client -> server, `s_*` is
## server -> client. The `s_*` methods are annotated "authority", so the engine
## itself drops any attempt by a client to fake one.
signal state_changed(new_state: State)
enum State { OFFLINE, CONNECTING, ONLINE, FAILED }
## Peer id the engine gives the host. On a listen server this is also a real
## player, which is why every send goes through the helpers below.
const LOCAL_PEER := 1
var state: State = State.OFFLINE
var server: ServerRuntime = null
var client: ClientRuntime = null
var last_error: String = ""
func _set_state(s: State) -> void:
state = s
state_changed.emit(s)
# --- Lifecycle --------------------------------------------------------------
func host(port: int) -> Error:
shutdown()
var peer := ENetMultiplayerPeer.new()
var err := peer.create_server(port, Protocol.MAX_CLIENTS, Protocol.CHANNEL_COUNT)
if err != OK:
last_error = "could not bind port %d (error %d)" % [port, err]
GameLog.error("net", last_error)
_set_state(State.FAILED)
return err
multiplayer.multiplayer_peer = peer
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
server = ServerRuntime.new()
server.name = "Server"
add_child(server)
_set_state(State.ONLINE)
GameLog.info("net", "listening on %d" % port)
return OK
func join(address: String, port: int) -> Error:
shutdown()
var peer := ENetMultiplayerPeer.new()
var err := peer.create_client(address, port, Protocol.CHANNEL_COUNT)
if err != OK:
last_error = "could not reach %s:%d (error %d)" % [address, port, err]
GameLog.error("net", last_error)
_set_state(State.FAILED)
return err
multiplayer.multiplayer_peer = peer
multiplayer.connected_to_server.connect(_on_connected)
multiplayer.connection_failed.connect(_on_connect_failed)
multiplayer.server_disconnected.connect(_on_server_disconnected)
client = ClientRuntime.new()
client.name = "Client"
add_child(client)
# Nothing is simulated until the server says hello back; on_welcome turns
# the physics process on.
client.set_physics_process(false)
_set_state(State.CONNECTING)
GameLog.info("net", "connecting to %s:%d" % [address, port])
return OK
func shutdown() -> void:
if multiplayer.multiplayer_peer != null \
and multiplayer.multiplayer_peer is not OfflineMultiplayerPeer:
multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = null
for sig in [multiplayer.peer_connected, multiplayer.peer_disconnected,
multiplayer.connected_to_server, multiplayer.connection_failed,
multiplayer.server_disconnected]:
for c in sig.get_connections():
sig.disconnect(c["callable"])
if server != null:
server.queue_free()
server = null
if client != null:
client.queue_free()
client = null
_set_state(State.OFFLINE)
func kick(peer_id: int) -> void:
if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
(multiplayer.multiplayer_peer as ENetMultiplayerPeer).disconnect_peer(peer_id)
func is_server() -> bool:
return server != null
# --- Transport signals ------------------------------------------------------
func _on_peer_connected(peer_id: int) -> void:
if server != null:
server.on_peer_connected(peer_id)
func _on_peer_disconnected(peer_id: int) -> void:
if server != null:
server.on_peer_disconnected(peer_id)
func _on_connected() -> void:
_set_state(State.ONLINE)
c_hello.rpc_id(1, Protocol.VERSION, GameOpts.player_name)
func _on_connect_failed() -> void:
last_error = "connection refused"
GameLog.error("net", last_error)
_set_state(State.FAILED)
func _on_server_disconnected() -> void:
last_error = "server closed the connection"
GameLog.warn("net", last_error)
shutdown()
## Start a client inside the hosting process. The result is a listen server:
## the host plays through exactly the same code path as a remote player, sending
## input and learning outcomes from snapshots, with the transport short-circuited
## to a function call. No simulation code knows the difference.
func start_local_client() -> void:
if server == null:
GameLog.error("net", "start_local_client called with no server")
return
client = ClientRuntime.new()
client.name = "Client"
add_child(client)
server.on_hello(LOCAL_PEER, Protocol.VERSION, GameOpts.player_name)
func _is_local(peer_id: int) -> bool:
return peer_id == LOCAL_PEER and client != null
# --- Send helpers -----------------------------------------------------------
# Every server -> client message goes through one of these rather than calling
# rpc_id directly, so the listen server's own player is reachable too.
func send_welcome(peer_id: int) -> void:
if _is_local(peer_id):
client.on_welcome(peer_id)
else:
s_welcome.rpc_id(peer_id, peer_id, Protocol.VERSION)
func send_enter_instance(peer_id: int, id: int, kind: int, server_tick: int,
boss_id: String, spawn: Vector2) -> void:
if _is_local(peer_id):
client.on_enter_instance(id, kind, server_tick, boss_id, spawn)
else:
s_enter_instance.rpc_id(peer_id, id, kind, server_tick, boss_id, spawn)
func send_snapshot(peer_id: int, data: PackedByteArray) -> void:
if _is_local(peer_id):
client.on_snapshot(data)
else:
s_snapshot.rpc_id(peer_id, data)
func send_events(peer_id: int, data: PackedByteArray) -> void:
if _is_local(peer_id):
client.on_events(data)
else:
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)
else:
s_reject.rpc_id(peer_id, reason)
func send_input(data: PackedByteArray) -> void:
if server != null:
server.on_input(LOCAL_PEER, data) # listen server: no transport at all
elif state == State.ONLINE and client != null:
c_input.rpc_id(1, data)
# --- Client -> server -------------------------------------------------------
@rpc("any_peer", "call_remote", "reliable", 1)
func c_hello(version: int, display_name: String) -> void:
if server == null:
return
server.on_hello(multiplayer.get_remote_sender_id(), version, display_name)
@rpc("any_peer", "call_remote", "unreliable_ordered", 4)
func c_input(data: PackedByteArray) -> void:
if server == null:
return
server.on_input(multiplayer.get_remote_sender_id(), data)
# --- Server -> client -------------------------------------------------------
@rpc("authority", "call_remote", "reliable", 1)
func s_welcome(peer_id: int, _version: int) -> void:
if client == null:
return
client.on_welcome(peer_id)
client.set_physics_process(true)
@rpc("authority", "call_remote", "reliable", 1)
func s_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
spawn: Vector2) -> void:
if client == null:
return
client.on_enter_instance(id, kind, server_tick, boss_id, spawn)
@rpc("authority", "call_remote", "reliable", 1)
func s_reject(reason: String) -> void:
last_error = reason
GameLog.error("net", "rejected by server: %s" % reason)
_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:
client.on_snapshot(data)
@rpc("authority", "call_remote", "reliable", 3)
func s_events(data: PackedByteArray) -> void:
if client != null:
client.on_events(data)