1dc1952a3c
ci / verify (push) Successful in 45s
(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.
263 lines
7.7 KiB
GDScript
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)
|