mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +00:00
feat(multiplayer): Phase 1 transport, connection, and lobby
Lands tasks 1.0-1.8 of multiplayer-todo.md: the pure-function test runner, net_codec (wire format quantizers/pack-unpack), NetworkManager (ENet transport, manual polling, min-RTT clock sync), MatchNet (handshake, protocol/tick-rate gating, roster with team+ready state), lobby.tscn (team columns, switch team, ready toggle), server_boot.tscn (headless dedicated server with structured logging and an overrun watchdog), and main_menu.gd's Host/Join-by-IP UI (connecting overlay, cancel, bounded failure path). Followed by an adversarial review (Opus subagent) that found and fixed two real bugs - an unvalidated player_name broadcast that let one client's oversized name head-of-line-block the reliable channel for everyone, and a server-side roster leak across a host/re-host cycle - plus three gaps in the test suite itself where a claim of "verified" wasn't actually backed by what the test checked. All five two-process smoke tests plus the pure-function suite are green with the strengthened assertions in place.
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
extends Control
|
||||
|
||||
# Lobby (task 1.5): roster list split by team, team swap, ready toggle,
|
||||
# leave. Reads/writes MatchNet.roster — this scene owns no state of its
|
||||
# own, it's a view over the autoload. Reached via main_menu.gd's Host/Join
|
||||
# flow (task 1.7) calling change_scene_to_file("res://scenes/lobby.tscn")
|
||||
# after NetworkManager.host()/join() succeeds — this scene must always be
|
||||
# loaded that way (as the real current_scene), not instantiated as a child
|
||||
# of something else: change_scene_to_file() operates on
|
||||
# get_tree().current_scene, and _on_disconnected_from_server()/_leave()
|
||||
# below call it themselves, which hangs if this scene isn't actually the
|
||||
# tree's current_scene when that happens (see multiplayer-todo.md §9
|
||||
# gotcha 27 — found the hard way while building tests/lobby_smoke.gd).
|
||||
|
||||
@onready var _status_label: Label = %StatusLabel
|
||||
@onready var _team0_list: VBoxContainer = %Team0List
|
||||
@onready var _team1_list: VBoxContainer = %Team1List
|
||||
@onready var _controls_row: HBoxContainer = %ControlsRow
|
||||
@onready var _switch_team_button: Button = %SwitchTeamButton
|
||||
@onready var _ready_button: CheckButton = %ReadyButton
|
||||
@onready var _leave_button: Button = %LeaveButton
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
MatchNet.welcomed.connect(_on_welcomed)
|
||||
MatchNet.player_joined.connect(_on_roster_changed)
|
||||
MatchNet.player_left.connect(_on_roster_changed)
|
||||
MatchNet.player_state_changed.connect(_on_roster_changed)
|
||||
MatchNet.rejected.connect(_on_rejected)
|
||||
NetworkManager.disconnected_from_server.connect(_on_disconnected_from_server)
|
||||
|
||||
# The server process is never a roster member (§1.1 decision 2) — it
|
||||
# gets a read-only view, no team/ready controls to operate on itself.
|
||||
_controls_row.visible = NetworkManager.is_client
|
||||
|
||||
_refresh()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
NetworkManager.poll()
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
NetworkManager.poll()
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
_leave()
|
||||
|
||||
|
||||
func _on_roster_changed(_a = null, _b = null, _c = null) -> void:
|
||||
_refresh()
|
||||
|
||||
|
||||
func _on_welcomed() -> void:
|
||||
_refresh()
|
||||
|
||||
|
||||
func _on_rejected(reason: String) -> void:
|
||||
_status_label.text = "Connection rejected: %s" % reason
|
||||
|
||||
|
||||
func _on_disconnected_from_server() -> void:
|
||||
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||
|
||||
|
||||
func _on_switch_team_pressed() -> void:
|
||||
var my_id := multiplayer.get_unique_id()
|
||||
var info: MatchNet.PlayerInfo = MatchNet.roster.get(my_id)
|
||||
if info == null:
|
||||
return
|
||||
MatchNet.request_set_team((info.team + 1) % MatchNet.TEAM_COUNT)
|
||||
|
||||
|
||||
func _on_ready_toggled(pressed: bool) -> void:
|
||||
MatchNet.request_set_ready(pressed)
|
||||
|
||||
|
||||
func _on_leave_pressed() -> void:
|
||||
_leave()
|
||||
|
||||
|
||||
func _leave() -> void:
|
||||
NetworkManager.shutdown()
|
||||
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
|
||||
|
||||
|
||||
func _refresh() -> void:
|
||||
if NetworkManager.is_server:
|
||||
_status_label.text = "Hosting — %d player(s) connected" % MatchNet.roster.size()
|
||||
elif NetworkManager.is_client:
|
||||
_status_label.text = "Connected" if not MatchNet.roster.is_empty() else "Connecting..."
|
||||
else:
|
||||
_status_label.text = "Not connected"
|
||||
|
||||
for child in _team0_list.get_children():
|
||||
child.queue_free()
|
||||
for child in _team1_list.get_children():
|
||||
child.queue_free()
|
||||
|
||||
var my_id := multiplayer.get_unique_id()
|
||||
var infos: Array = MatchNet.roster.values()
|
||||
infos.sort_custom(func(a: MatchNet.PlayerInfo, b: MatchNet.PlayerInfo) -> bool: return a.peer_id < b.peer_id)
|
||||
for info: MatchNet.PlayerInfo in infos:
|
||||
var row := Label.new()
|
||||
var marker := " (you)" if info.peer_id == my_id else ""
|
||||
var ready_mark := "✓" if info.ready else "…"
|
||||
row.text = "%s %s%s" % [ready_mark, info.player_name, marker]
|
||||
var target_list := _team0_list if info.team == 0 else _team1_list
|
||||
target_list.add_child(row)
|
||||
|
||||
if NetworkManager.is_client:
|
||||
var my_info: MatchNet.PlayerInfo = MatchNet.roster.get(my_id)
|
||||
if my_info != null:
|
||||
_ready_button.set_pressed_no_signal(my_info.ready)
|
||||
@@ -31,6 +31,10 @@ const DIFFICULTIES := [
|
||||
@onready var dev_bot_dropdown: OptionButton = %DevBotDropdown
|
||||
@onready var bot_a_dropdown: OptionButton = %BotADropdown
|
||||
@onready var bot_b_dropdown: OptionButton = %BotBDropdown
|
||||
@onready var join_address_edit: LineEdit = %JoinAddressEdit
|
||||
@onready var multiplayer_error_label: Label = %MultiplayerErrorLabel
|
||||
@onready var connecting_overlay: Control = %ConnectingOverlay
|
||||
@onready var connecting_status_label: Label = %ConnectingStatusLabel
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -46,9 +50,27 @@ func _ready() -> void:
|
||||
_populate_dropdown(dev_bot_dropdown, bots, GameSettings.dev_bot_override_path, true)
|
||||
_populate_dropdown(bot_a_dropdown, bots, GameSettings.spectate_bot_a_path)
|
||||
_populate_dropdown(bot_b_dropdown, bots, GameSettings.spectate_bot_b_path)
|
||||
NetworkManager.connected_to_server.connect(_on_connected_to_server)
|
||||
NetworkManager.connection_failed.connect(_on_connection_failed)
|
||||
$CenterContainer/VBoxContainer/FreePlayButton.grab_focus()
|
||||
|
||||
|
||||
# main_menu.gd's first async flow (task 1.7): Host is synchronous
|
||||
# (NetworkManager.host() either succeeds immediately or fails immediately),
|
||||
# but Join is not — it can take anywhere from a clean local-network round
|
||||
# trip to ENet's own ~5s connect timeout to resolve, so unlike every other
|
||||
# handler in this file (GameSettings.x = y; change_scene_to_file(...)) it
|
||||
# needs a loading state (ConnectingOverlay), a cancel path, and a failure
|
||||
# path that returns the player to a sane, retryable menu state rather than
|
||||
# just hanging with no feedback.
|
||||
func _process(_delta: float) -> void:
|
||||
NetworkManager.poll()
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
NetworkManager.poll()
|
||||
|
||||
|
||||
func _populate_difficulty_dropdown() -> void:
|
||||
difficulty_dropdown.clear()
|
||||
for tier in DIFFICULTIES:
|
||||
@@ -158,3 +180,91 @@ func _on_spectate_pressed() -> void:
|
||||
GameSettings.spectate_bot_a_path = _selected_path(bot_a_dropdown)
|
||||
GameSettings.spectate_bot_b_path = _selected_path(bot_b_dropdown)
|
||||
_leave_to_gameplay("res://scenes/spectate.tscn")
|
||||
|
||||
|
||||
func _on_host_pressed() -> void:
|
||||
_clear_multiplayer_error()
|
||||
var err := NetworkManager.host()
|
||||
if err != OK:
|
||||
_show_multiplayer_error("Could not host: %s" % error_string(err))
|
||||
return
|
||||
_leave_to_lobby()
|
||||
|
||||
|
||||
func _on_join_pressed() -> void:
|
||||
_start_join()
|
||||
|
||||
|
||||
func _on_join_address_submitted(_new_text: String) -> void:
|
||||
_start_join()
|
||||
|
||||
|
||||
# ENet's own give-up-and-fire-connection_failed schedule is not bounded to
|
||||
# anything a menu should make a player wait for — verified empirically
|
||||
# (tests/main_menu_test_hooks.gd's join_refused case) against a genuinely
|
||||
# refused loopback connection: connection_failed never fired within 14s.
|
||||
# This timer is what actually guarantees "connection-refused reaches a sane
|
||||
# UI state" rather than leaving the overlay up indefinitely.
|
||||
const CONNECT_TIMEOUT_SECONDS := 6.0
|
||||
|
||||
var _connect_timeout_token := 0 # bumped on every new attempt/cancel/resolution so a stale timer callback is a no-op
|
||||
|
||||
|
||||
func _start_join() -> void:
|
||||
_clear_multiplayer_error()
|
||||
var address := join_address_edit.text.strip_edges()
|
||||
if address.is_empty():
|
||||
_show_multiplayer_error("Enter an IP address to join")
|
||||
return
|
||||
var err := NetworkManager.join(address)
|
||||
if err != OK:
|
||||
_show_multiplayer_error("Could not join: %s" % error_string(err))
|
||||
return
|
||||
connecting_status_label.text = "Connecting to %s..." % address
|
||||
connecting_overlay.visible = true
|
||||
_connect_timeout_token += 1
|
||||
var my_token := _connect_timeout_token
|
||||
get_tree().create_timer(CONNECT_TIMEOUT_SECONDS).timeout.connect(func(): _on_connect_timeout(my_token))
|
||||
|
||||
|
||||
func _on_connect_timeout(token: int) -> void:
|
||||
if token != _connect_timeout_token or not connecting_overlay.visible:
|
||||
return # a newer attempt (or Cancel, or a real success/failure) already resolved this
|
||||
NetworkManager.shutdown()
|
||||
connecting_overlay.visible = false
|
||||
_show_multiplayer_error("Connection timed out — check the address and that a server is hosting on that port")
|
||||
|
||||
|
||||
func _on_connecting_cancel_pressed() -> void:
|
||||
_connect_timeout_token += 1
|
||||
NetworkManager.shutdown()
|
||||
connecting_overlay.visible = false
|
||||
|
||||
|
||||
func _on_connected_to_server() -> void:
|
||||
if not connecting_overlay.visible:
|
||||
return # e.g. a stray/late signal after Cancel already shut the peer down
|
||||
_connect_timeout_token += 1
|
||||
connecting_overlay.visible = false
|
||||
_leave_to_lobby()
|
||||
|
||||
|
||||
func _on_connection_failed() -> void:
|
||||
if not connecting_overlay.visible:
|
||||
return
|
||||
_connect_timeout_token += 1
|
||||
connecting_overlay.visible = false
|
||||
_show_multiplayer_error("Connection failed — check the address and that a server is hosting on that port")
|
||||
|
||||
|
||||
func _leave_to_lobby() -> void:
|
||||
get_tree().change_scene_to_file("res://scenes/lobby.tscn")
|
||||
|
||||
|
||||
func _show_multiplayer_error(message: String) -> void:
|
||||
multiplayer_error_label.text = message
|
||||
multiplayer_error_label.visible = true
|
||||
|
||||
|
||||
func _clear_multiplayer_error() -> void:
|
||||
multiplayer_error_label.visible = false
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
extends Node
|
||||
|
||||
# Autoload (project.godot [autoload] MatchNet). Handshake + roster layer on
|
||||
# top of NetworkManager's raw transport (§2.5, §1.3 of multiplayer-todo.md).
|
||||
# hello/welcome, strict protocol_version and physics_ticks_per_second
|
||||
# gating, player_joined/player_left, and — since lobby.tscn (task 1.5) needs
|
||||
# somewhere durable to keep it across the lobby→match scene transition —
|
||||
# each player's team and ready state. Slot assignment (fixed spawn index
|
||||
# within a team) is NOT here; that's match spawn's job in Phase 2, derived
|
||||
# from this roster's team field at spawn time, not stored redundantly here.
|
||||
|
||||
const NetCodec = preload("res://scripts/net_codec.gd")
|
||||
const SimConstants = preload("res://scripts/sim_constants.gd")
|
||||
|
||||
signal player_joined(peer_id: int, player_name: String)
|
||||
signal player_left(peer_id: int)
|
||||
signal player_state_changed(peer_id: int, team: int, ready: bool)
|
||||
signal rejected(reason: String) # client-side only: the server refused our hello
|
||||
signal welcomed() # client-side only: our hello was accepted
|
||||
|
||||
const TEAM_COUNT := 2
|
||||
|
||||
# player_name is the one client-supplied value in _hello that gets broadcast
|
||||
# verbatim to every other peer (protocol_version/tick_hz are checked, never
|
||||
# relayed). MAX_INPUT_LENGTH is a reject threshold, checked before touching
|
||||
# the string at all — a legitimate client only ever sends local_player_name,
|
||||
# which the UI already keeps short, so anything past this is a bug or an
|
||||
# attacker, not a real name to truncate politely. Adversarial review found
|
||||
# an unbounded name relayed to every peer head-of-line-blocks the reliable
|
||||
# control channel hard enough that a concurrently-joining client's own
|
||||
# _welcome never arrived — this is what closes that.
|
||||
const MAX_INPUT_LENGTH := 256
|
||||
const MAX_PLAYER_NAME_LENGTH := 24
|
||||
|
||||
|
||||
class PlayerInfo:
|
||||
var peer_id: int
|
||||
var player_name: String
|
||||
var team: int = 0
|
||||
var ready: bool = false
|
||||
|
||||
func _init(p_peer_id: int, p_player_name: String, p_team: int = 0, p_ready: bool = false) -> void:
|
||||
peer_id = p_peer_id
|
||||
player_name = p_player_name
|
||||
team = p_team
|
||||
ready = p_ready
|
||||
|
||||
|
||||
var roster: Dictionary = {} # peer_id (int) -> PlayerInfo. Never contains peer 1 (the server; §1.1 decision 2 — dedicated servers are never a player).
|
||||
var local_player_name := "Player"
|
||||
|
||||
# Test hook (tests/match_net_smoke.gd): set false before connecting to
|
||||
# suppress the automatic real hello, so a test can send a deliberately
|
||||
# mismatched one instead to exercise the rejection path.
|
||||
var _auto_hello := true
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
NetworkManager.client_disconnected.connect(_on_peer_disconnected)
|
||||
NetworkManager.connected_to_server.connect(_on_connected_to_server)
|
||||
NetworkManager.disconnected_from_server.connect(_on_disconnected_from_server)
|
||||
NetworkManager.shutting_down.connect(_on_shutting_down)
|
||||
|
||||
|
||||
func _on_connected_to_server() -> void:
|
||||
roster.clear()
|
||||
if _auto_hello:
|
||||
_hello.rpc_id(1, NetCodec.PROTOCOL_VERSION, SimConstants.TICK_HZ, local_player_name)
|
||||
|
||||
|
||||
func _on_disconnected_from_server() -> void:
|
||||
roster.clear()
|
||||
|
||||
|
||||
# Covers the case _on_disconnected_from_server doesn't: a HOST calling
|
||||
# NetworkManager.shutdown() itself (Leave, or hosting again after already
|
||||
# hosting) never fires disconnected_from_server — that signal only fires
|
||||
# from an incoming multiplayer.server_disconnected event, which a server
|
||||
# never receives about itself. Without this, roster (and every peer's team/
|
||||
# ready state in it) would persist forever across a host/re-host cycle in
|
||||
# the same process.
|
||||
func _on_shutting_down() -> void:
|
||||
roster.clear()
|
||||
|
||||
|
||||
# Server only: a raw ENet disconnect (crash, timeout) that never sent a
|
||||
# proper hello just needs its (possibly absent) roster entry cleaned up.
|
||||
# The normal leave path also goes through here after the server erases it,
|
||||
# guarded by roster.erase()'s own has-check below.
|
||||
func _on_peer_disconnected(peer_id: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
_remove_player(peer_id)
|
||||
|
||||
|
||||
func _remove_player(peer_id: int) -> void:
|
||||
if not roster.has(peer_id):
|
||||
return
|
||||
roster.erase(peer_id)
|
||||
player_left.emit(peer_id)
|
||||
_player_left.rpc(peer_id)
|
||||
|
||||
|
||||
# Balances a new joiner onto whichever team currently has fewer players
|
||||
# (ties go to team 0). Server only.
|
||||
func _pick_balanced_team() -> int:
|
||||
var counts := []
|
||||
counts.resize(TEAM_COUNT)
|
||||
counts.fill(0)
|
||||
for info: PlayerInfo in roster.values():
|
||||
counts[info.team] += 1
|
||||
var best_team := 0
|
||||
for team in range(TEAM_COUNT):
|
||||
if counts[team] < counts[best_team]:
|
||||
best_team = team
|
||||
return best_team
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func _hello(protocol_version: int, tick_hz: int, player_name: String) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
var peer_id := multiplayer.get_remote_sender_id()
|
||||
if roster.has(peer_id):
|
||||
return # duplicate hello from an already-accepted peer; ignore
|
||||
|
||||
if protocol_version != NetCodec.PROTOCOL_VERSION:
|
||||
await _reject(peer_id, "protocol version mismatch: server=%d client=%d" % [NetCodec.PROTOCOL_VERSION, protocol_version])
|
||||
return
|
||||
if tick_hz != SimConstants.TICK_HZ:
|
||||
await _reject(peer_id, "physics tick rate mismatch: server=%d client=%d" % [SimConstants.TICK_HZ, tick_hz])
|
||||
return
|
||||
if player_name.length() > MAX_INPUT_LENGTH:
|
||||
await _reject(peer_id, "player name too long")
|
||||
return
|
||||
var clean_name := _sanitize_player_name(player_name)
|
||||
|
||||
# Tell the new peer about everyone already here before anyone is told
|
||||
# about them, so no client ever observes an unknown peer_id in a
|
||||
# player_joined it didn't get a prior player_joined for.
|
||||
for existing_id: int in roster.keys():
|
||||
var existing: PlayerInfo = roster[existing_id]
|
||||
_player_joined.rpc_id(peer_id, existing_id, existing.player_name, existing.team, existing.ready)
|
||||
|
||||
var team := _pick_balanced_team()
|
||||
roster[peer_id] = PlayerInfo.new(peer_id, clean_name, team, false)
|
||||
player_joined.emit(peer_id, clean_name) # local: the broadcast below is call_remote, never loops back to the server itself
|
||||
_welcome.rpc_id(peer_id)
|
||||
_player_joined.rpc(peer_id, clean_name, team, false) # broadcast, includes the new peer itself
|
||||
|
||||
|
||||
# Strips control/formatting characters (so a name can't corrupt a log line
|
||||
# or blow out UI layout with e.g. embedded newlines) and clamps to display
|
||||
# length. Input is already bounded to MAX_INPUT_LENGTH by the caller before
|
||||
# this runs, so this never iterates an attacker-sized string. static: pure
|
||||
# function of its argument, doesn't touch roster/multiplayer — also lets
|
||||
# tests/cases/test_match_net.gd call it with no Node instantiation.
|
||||
static func _sanitize_player_name(raw: String) -> String:
|
||||
var clean := ""
|
||||
for c in raw:
|
||||
var code := c.unicode_at(0)
|
||||
if code >= 0x20 and code != 0x7F:
|
||||
clean += c
|
||||
clean = clean.strip_edges()
|
||||
if clean.length() > MAX_PLAYER_NAME_LENGTH:
|
||||
clean = clean.substr(0, MAX_PLAYER_NAME_LENGTH)
|
||||
if clean.is_empty():
|
||||
clean = "Player"
|
||||
return clean
|
||||
|
||||
|
||||
func _reject(peer_id: int, reason: String) -> void:
|
||||
_rejected.rpc_id(peer_id, reason)
|
||||
# §9 gotcha 26: a reliable RPC just queued still needs a beat of polling
|
||||
# to actually reach the wire before we pull the connection out from
|
||||
# under it.
|
||||
await get_tree().create_timer(0.3).timeout
|
||||
if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
|
||||
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
|
||||
|
||||
|
||||
# Client-callable requests. Both are fire-and-forget: the authoritative
|
||||
# change comes back through _state_changed once the server applies it, same
|
||||
# as everyone else's — a client never mutates its own roster entry directly.
|
||||
func request_set_team(team: int) -> void:
|
||||
_set_team.rpc_id(1, team)
|
||||
|
||||
|
||||
func request_set_ready(ready: bool) -> void:
|
||||
_set_ready.rpc_id(1, ready)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func _set_team(team: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
var peer_id := multiplayer.get_remote_sender_id()
|
||||
if not roster.has(peer_id) or team < 0 or team >= TEAM_COUNT:
|
||||
return
|
||||
var info: PlayerInfo = roster[peer_id]
|
||||
if info.team == team:
|
||||
return
|
||||
info.team = team
|
||||
info.ready = false # switching teams un-readies — the roster you were ready against just changed
|
||||
player_state_changed.emit(peer_id, info.team, info.ready)
|
||||
_state_changed.rpc(peer_id, info.team, info.ready)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func _set_ready(ready: bool) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
var peer_id := multiplayer.get_remote_sender_id()
|
||||
if not roster.has(peer_id):
|
||||
return
|
||||
var info: PlayerInfo = roster[peer_id]
|
||||
if info.ready == ready:
|
||||
return
|
||||
info.ready = ready
|
||||
player_state_changed.emit(peer_id, info.team, info.ready)
|
||||
_state_changed.rpc(peer_id, info.team, info.ready)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _state_changed(peer_id: int, team: int, ready: bool) -> void:
|
||||
if not roster.has(peer_id):
|
||||
return
|
||||
var info: PlayerInfo = roster[peer_id]
|
||||
info.team = team
|
||||
info.ready = ready
|
||||
player_state_changed.emit(peer_id, team, ready)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _welcome() -> void:
|
||||
welcomed.emit()
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _rejected(reason: String) -> void:
|
||||
rejected.emit(reason)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _player_joined(peer_id: int, player_name: String, team: int, ready: bool) -> void:
|
||||
roster[peer_id] = PlayerInfo.new(peer_id, player_name, team, ready)
|
||||
player_joined.emit(peer_id, player_name)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _player_left(peer_id: int) -> void:
|
||||
if not roster.has(peer_id):
|
||||
return
|
||||
roster.erase(peer_id)
|
||||
player_left.emit(peer_id)
|
||||
@@ -0,0 +1,23 @@
|
||||
extends RefCounted
|
||||
|
||||
# Plain data holder for one body's snapshot state (§2.4 of multiplayer-todo.md).
|
||||
# Deliberately not Ship/Ball themselves, and deliberately not a scene-tree
|
||||
# node — NetCodec's pack/unpack must stay callable from pure-function tests
|
||||
# with no live scene. Phase 2's snapshot writer fills one of these per body
|
||||
# per tick from the real RigidBody3D state; Phase 2's interpolator does the
|
||||
# reverse.
|
||||
#
|
||||
# avel_range must match what the sender quantised with (SHIP_AVEL_RANGE vs
|
||||
# BALL_AVEL_RANGE in net_codec.gd) — it is not carried on the wire, because
|
||||
# slot order already tells both peers which body is which (§1.3: "entities
|
||||
# are addressed by integer slot, never by path").
|
||||
|
||||
var position := Vector3.ZERO
|
||||
var rotation := Quaternion.IDENTITY
|
||||
var linear_velocity := Vector3.ZERO
|
||||
var angular_velocity := Vector3.ZERO
|
||||
var frozen := false
|
||||
var turbo := false
|
||||
var thrust_z := 0.0 # -1..1; re-quantised to a 3-bit bin on the wire
|
||||
var stalled := false
|
||||
var avel_range := 4.0 # NetCodec.SHIP_AVEL_RANGE; set to BALL_AVEL_RANGE for the ball
|
||||
@@ -0,0 +1,295 @@
|
||||
class_name NetCodec
|
||||
|
||||
# Wire-format constants, quantisers, and pack/unpack for the two hot-path
|
||||
# packets (§2 of multiplayer-todo.md). Pure functions only — no networking,
|
||||
# no autoload state — so they're testable head-on by tests/test_runner.tscn
|
||||
# without a live connection.
|
||||
#
|
||||
# Referenced from elsewhere via preload(), not the bare class_name, per the
|
||||
# same global-script-class-cache caveat documented in tests/test_case.gd and
|
||||
# sim_constants.gd.
|
||||
|
||||
const SimConstants = preload("res://scripts/sim_constants.gd")
|
||||
const ShipAction = preload("res://scripts/ship_action.gd")
|
||||
const NetBodyState = preload("res://scripts/net_body_state.gd")
|
||||
|
||||
# --- Protocol ---
|
||||
const PROTOCOL_VERSION := 1
|
||||
const TICK_HZ: int = SimConstants.TICK_HZ
|
||||
|
||||
# --- Channels (logical intent; NetworkManager may need to offset these on
|
||||
# top of ENet's own reserved channels — verify empirically, see §2.1) ---
|
||||
const CHANNEL_CONTROL := 0
|
||||
const CHANNEL_INPUT := 1
|
||||
const CHANNEL_SNAPSHOT := 2
|
||||
|
||||
# --- Packet type/version byte: high nibble = type, low nibble = protocol version ---
|
||||
enum PacketType { INPUT = 0, SNAPSHOT = 1 }
|
||||
|
||||
# --- Input packet (§2.3) ---
|
||||
const MAX_REDUNDANCY := 4
|
||||
# type_version u8 + seq u32 + count u8 + ack_snapshot_tick u32 + client_send_ms u16
|
||||
const INPUT_HEADER_SIZE := 12
|
||||
const INPUT_ENTRY_SIZE := 7 # thrust i8x3 + rotation i8x3 + flags u8
|
||||
const INPUT_FLAG_TURBO := 1 << 0
|
||||
|
||||
# --- Snapshot packet (§2.4) ---
|
||||
# last_input_seq u32 + input_buffer_depth i8 + echo_client_send_ms u16
|
||||
const SNAPSHOT_CLIENT_HEADER_SIZE := 7
|
||||
# type_version u8 + server_tick u32 + match_state u8 + reset_gen u8 + body_count u8
|
||||
const SNAPSHOT_BODY_HEADER_SIZE := 8
|
||||
const SNAPSHOT_BODY_SIZE := 22
|
||||
|
||||
const BODY_FLAG_FROZEN := 1 << 0
|
||||
const BODY_FLAG_TURBO := 1 << 1
|
||||
const BODY_FLAG_THRUST_Z_SHIFT := 2
|
||||
const BODY_FLAG_THRUST_Z_MASK := 0x1C # bits 2-4
|
||||
const BODY_FLAG_STALLED := 1 << 5
|
||||
const BODY_FLAG_QUAT_W_SIGN := 1 << 6
|
||||
|
||||
# --- Quantisation ranges (§2.4 — derived from arena/gameplay constants, not
|
||||
# restated prose; see multiplayer-todo.md for the ArenaBoundary/Ship/Ball
|
||||
# constants these are sized against) ---
|
||||
const POS_RANGE := 64.0 # metres, ±
|
||||
const VEL_RANGE := 64.0 # m/s, ±
|
||||
const QUAT_COMPONENT_RANGE := 1.0
|
||||
const SHIP_AVEL_RANGE := 4.0 # rad/s, ±
|
||||
const BALL_AVEL_RANGE := 32.0 # rad/s, ±
|
||||
|
||||
const I16_MAX := 32767
|
||||
const I8_MAX := 127
|
||||
const THRUST_Z_BIN_MAX := 7 # 3 bits
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Quantisers — pure, reusable, independently testable.
|
||||
# ============================================================
|
||||
|
||||
static func quantize_i16(value: float, range_max: float) -> int:
|
||||
var scaled := clampf(value / range_max, -1.0, 1.0) * I16_MAX
|
||||
return clampi(roundi(scaled), -I16_MAX, I16_MAX)
|
||||
|
||||
static func dequantize_i16(raw: int, range_max: float) -> float:
|
||||
return (float(raw) / I16_MAX) * range_max
|
||||
|
||||
static func quantize_i8(value: float, range_max: float) -> int:
|
||||
var scaled := clampf(value / range_max, -1.0, 1.0) * I8_MAX
|
||||
return clampi(roundi(scaled), -I8_MAX, I8_MAX)
|
||||
|
||||
static func dequantize_i8(raw: int, range_max: float) -> float:
|
||||
return (float(raw) / I8_MAX) * range_max
|
||||
|
||||
static func quantize_thrust_z_bin(thrust_z: float) -> int:
|
||||
var t := clampf((thrust_z + 1.0) * 0.5, 0.0, 1.0)
|
||||
return clampi(roundi(t * THRUST_Z_BIN_MAX), 0, THRUST_Z_BIN_MAX)
|
||||
|
||||
static func dequantize_thrust_z_bin(bin_value: int) -> float:
|
||||
return (float(bin_value) / THRUST_Z_BIN_MAX) * 2.0 - 1.0
|
||||
|
||||
|
||||
static func type_version_byte(type: PacketType) -> int:
|
||||
return ((int(type) & 0x0F) << 4) | (PROTOCOL_VERSION & 0x0F)
|
||||
|
||||
static func packet_type_of(type_version: int) -> int:
|
||||
return (type_version >> 4) & 0x0F
|
||||
|
||||
static func protocol_version_of(type_version: int) -> int:
|
||||
return type_version & 0x0F
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Input packet — client -> server, channel 1 (§2.3)
|
||||
# ============================================================
|
||||
|
||||
# actions: newest-first, 1..MAX_REDUNDANCY ShipAction instances.
|
||||
static func pack_input(seq: int, ack_snapshot_tick: int, client_send_ms: int, actions: Array) -> PackedByteArray:
|
||||
var count: int = clampi(actions.size(), 1, MAX_REDUNDANCY)
|
||||
var buf := StreamPeerBuffer.new()
|
||||
buf.put_u8(type_version_byte(PacketType.INPUT))
|
||||
buf.put_u32(seq)
|
||||
buf.put_u8(count)
|
||||
buf.put_u32(ack_snapshot_tick)
|
||||
buf.put_u16(client_send_ms & 0xFFFF)
|
||||
for i in count:
|
||||
var action: ShipAction = actions[i]
|
||||
buf.put_8(quantize_i8(action.thrust.x, 1.0))
|
||||
buf.put_8(quantize_i8(action.thrust.y, 1.0))
|
||||
buf.put_8(quantize_i8(action.thrust.z, 1.0))
|
||||
buf.put_8(quantize_i8(action.rotation.x, 1.0))
|
||||
buf.put_8(quantize_i8(action.rotation.y, 1.0))
|
||||
buf.put_8(quantize_i8(action.rotation.z, 1.0))
|
||||
var flags := 0
|
||||
if action.turbo:
|
||||
flags |= INPUT_FLAG_TURBO
|
||||
buf.put_u8(flags)
|
||||
return buf.data_array
|
||||
|
||||
|
||||
# Returns a Dictionary: type_version, seq, count, ack_snapshot_tick,
|
||||
# client_send_ms, actions (Array[ShipAction], newest first).
|
||||
static func unpack_input(bytes: PackedByteArray) -> Dictionary:
|
||||
var buf := StreamPeerBuffer.new()
|
||||
buf.data_array = bytes
|
||||
var type_version := buf.get_u8()
|
||||
var seq := buf.get_u32()
|
||||
var count := buf.get_u8()
|
||||
var ack_snapshot_tick := buf.get_u32()
|
||||
var client_send_ms := buf.get_u16()
|
||||
var actions: Array[ShipAction] = []
|
||||
for i in count:
|
||||
var a := ShipAction.new()
|
||||
a.thrust = Vector3(
|
||||
dequantize_i8(buf.get_8(), 1.0),
|
||||
dequantize_i8(buf.get_8(), 1.0),
|
||||
dequantize_i8(buf.get_8(), 1.0)
|
||||
)
|
||||
a.rotation = Vector3(
|
||||
dequantize_i8(buf.get_8(), 1.0),
|
||||
dequantize_i8(buf.get_8(), 1.0),
|
||||
dequantize_i8(buf.get_8(), 1.0)
|
||||
)
|
||||
var flags := buf.get_u8()
|
||||
a.turbo = (flags & INPUT_FLAG_TURBO) != 0
|
||||
actions.append(a)
|
||||
return {
|
||||
"type_version": type_version,
|
||||
"seq": seq,
|
||||
"count": count,
|
||||
"ack_snapshot_tick": ack_snapshot_tick,
|
||||
"client_send_ms": client_send_ms,
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Snapshot packet — server -> client, channel 2 (§2.4)
|
||||
# ============================================================
|
||||
|
||||
# Shared across every peer this tick — build once, reuse (§2.4's stated
|
||||
# intent). Returns type_version + server_tick + match_state + reset_gen +
|
||||
# body_count + body_count * SNAPSHOT_BODY_SIZE bytes.
|
||||
static func pack_snapshot_body_segment(server_tick: int, match_state: int, reset_gen: int, bodies: Array) -> PackedByteArray:
|
||||
var buf := StreamPeerBuffer.new()
|
||||
buf.put_u8(type_version_byte(PacketType.SNAPSHOT))
|
||||
buf.put_u32(server_tick)
|
||||
buf.put_u8(match_state & 0xFF)
|
||||
buf.put_u8(reset_gen & 0xFF)
|
||||
buf.put_u8(bodies.size())
|
||||
for body in bodies:
|
||||
var b: NetBodyState = body
|
||||
buf.put_16(quantize_i16(b.position.x, POS_RANGE))
|
||||
buf.put_16(quantize_i16(b.position.y, POS_RANGE))
|
||||
buf.put_16(quantize_i16(b.position.z, POS_RANGE))
|
||||
buf.put_16(quantize_i16(b.rotation.x, QUAT_COMPONENT_RANGE))
|
||||
buf.put_16(quantize_i16(b.rotation.y, QUAT_COMPONENT_RANGE))
|
||||
buf.put_16(quantize_i16(b.rotation.z, QUAT_COMPONENT_RANGE))
|
||||
buf.put_16(quantize_i16(b.linear_velocity.x, VEL_RANGE))
|
||||
buf.put_16(quantize_i16(b.linear_velocity.y, VEL_RANGE))
|
||||
buf.put_16(quantize_i16(b.linear_velocity.z, VEL_RANGE))
|
||||
buf.put_8(quantize_i8(b.angular_velocity.x, b.avel_range))
|
||||
buf.put_8(quantize_i8(b.angular_velocity.y, b.avel_range))
|
||||
buf.put_8(quantize_i8(b.angular_velocity.z, b.avel_range))
|
||||
var flags := 0
|
||||
if b.frozen:
|
||||
flags |= BODY_FLAG_FROZEN
|
||||
if b.turbo:
|
||||
flags |= BODY_FLAG_TURBO
|
||||
flags |= (quantize_thrust_z_bin(b.thrust_z) << BODY_FLAG_THRUST_Z_SHIFT) & BODY_FLAG_THRUST_Z_MASK
|
||||
if b.stalled:
|
||||
flags |= BODY_FLAG_STALLED
|
||||
if b.rotation.w < 0.0:
|
||||
flags |= BODY_FLAG_QUAT_W_SIGN
|
||||
buf.put_u8(flags)
|
||||
return buf.data_array
|
||||
|
||||
|
||||
static func pack_snapshot_client_header(last_input_seq: int, input_buffer_depth: int, echo_client_send_ms: int) -> PackedByteArray:
|
||||
var buf := StreamPeerBuffer.new()
|
||||
buf.put_u32(last_input_seq)
|
||||
buf.put_8(clampi(input_buffer_depth, -128, 127))
|
||||
buf.put_u16(echo_client_send_ms & 0xFFFF)
|
||||
return buf.data_array
|
||||
|
||||
|
||||
# Convenience: one full per-client packet = per-client header + shared body segment.
|
||||
static func pack_snapshot(last_input_seq: int, input_buffer_depth: int, echo_client_send_ms: int, body_segment: PackedByteArray) -> PackedByteArray:
|
||||
var header := pack_snapshot_client_header(last_input_seq, input_buffer_depth, echo_client_send_ms)
|
||||
var out := PackedByteArray()
|
||||
out.append_array(header)
|
||||
out.append_array(body_segment)
|
||||
return out
|
||||
|
||||
|
||||
# Returns a Dictionary: last_input_seq, input_buffer_depth, echo_client_send_ms,
|
||||
# type_version, server_tick, match_state, reset_gen, bodies (Array[NetBodyState]).
|
||||
static func unpack_snapshot(bytes: PackedByteArray) -> Dictionary:
|
||||
var buf := StreamPeerBuffer.new()
|
||||
buf.data_array = bytes
|
||||
var last_input_seq := buf.get_u32()
|
||||
var input_buffer_depth := buf.get_8()
|
||||
var echo_client_send_ms := buf.get_u16()
|
||||
var type_version := buf.get_u8()
|
||||
var server_tick := buf.get_u32()
|
||||
var match_state := buf.get_u8()
|
||||
var reset_gen := buf.get_u8()
|
||||
var body_count := buf.get_u8()
|
||||
var bodies: Array[NetBodyState] = []
|
||||
for i in body_count:
|
||||
var b := NetBodyState.new()
|
||||
b.position = Vector3(
|
||||
dequantize_i16(buf.get_16(), POS_RANGE),
|
||||
dequantize_i16(buf.get_16(), POS_RANGE),
|
||||
dequantize_i16(buf.get_16(), POS_RANGE)
|
||||
)
|
||||
var qx := dequantize_i16(buf.get_16(), QUAT_COMPONENT_RANGE)
|
||||
var qy := dequantize_i16(buf.get_16(), QUAT_COMPONENT_RANGE)
|
||||
var qz := dequantize_i16(buf.get_16(), QUAT_COMPONENT_RANGE)
|
||||
b.linear_velocity = Vector3(
|
||||
dequantize_i16(buf.get_16(), VEL_RANGE),
|
||||
dequantize_i16(buf.get_16(), VEL_RANGE),
|
||||
dequantize_i16(buf.get_16(), VEL_RANGE)
|
||||
)
|
||||
# avel_range is unknown to the codec at this point (it isn't on the
|
||||
# wire — see net_body_state.gd) — decode at SHIP_AVEL_RANGE and let
|
||||
# the caller, which knows this slot's body kind, rescale if it's the
|
||||
# ball's slot. Storing the raw i8 would avoid this, but every other
|
||||
# field in this struct is already physical units; consistency wins.
|
||||
b.angular_velocity = Vector3(
|
||||
dequantize_i8(buf.get_8(), SHIP_AVEL_RANGE),
|
||||
dequantize_i8(buf.get_8(), SHIP_AVEL_RANGE),
|
||||
dequantize_i8(buf.get_8(), SHIP_AVEL_RANGE)
|
||||
)
|
||||
var flags := buf.get_u8()
|
||||
b.frozen = (flags & BODY_FLAG_FROZEN) != 0
|
||||
b.turbo = (flags & BODY_FLAG_TURBO) != 0
|
||||
var bin_value := (flags & BODY_FLAG_THRUST_Z_MASK) >> BODY_FLAG_THRUST_Z_SHIFT
|
||||
b.thrust_z = dequantize_thrust_z_bin(bin_value)
|
||||
b.stalled = (flags & BODY_FLAG_STALLED) != 0
|
||||
var w_sq := 1.0 - qx * qx - qy * qy - qz * qz
|
||||
var w := sqrt(maxf(w_sq, 0.0))
|
||||
if (flags & BODY_FLAG_QUAT_W_SIGN) != 0:
|
||||
w = -w
|
||||
b.rotation = Quaternion(qx, qy, qz, w)
|
||||
bodies.append(b)
|
||||
return {
|
||||
"last_input_seq": last_input_seq,
|
||||
"input_buffer_depth": input_buffer_depth,
|
||||
"echo_client_send_ms": echo_client_send_ms,
|
||||
"type_version": type_version,
|
||||
"server_tick": server_tick,
|
||||
"match_state": match_state,
|
||||
"reset_gen": reset_gen,
|
||||
"bodies": bodies,
|
||||
}
|
||||
|
||||
|
||||
# Rescales an already-decoded body's angular_velocity from the SHIP_AVEL_RANGE
|
||||
# assumption unpack_snapshot() decoded it with to the range it was actually
|
||||
# quantised at (BALL_AVEL_RANGE for the ball). Call once per non-ship body
|
||||
# immediately after unpack_snapshot(), using slot order to know which.
|
||||
static func rescale_avel(body: NetBodyState, actual_range: float) -> void:
|
||||
if is_equal_approx(actual_range, SHIP_AVEL_RANGE):
|
||||
body.avel_range = actual_range
|
||||
return
|
||||
body.angular_velocity = (body.angular_velocity / SHIP_AVEL_RANGE) * actual_range
|
||||
body.avel_range = actual_range
|
||||
@@ -0,0 +1,43 @@
|
||||
extends CanvasLayer
|
||||
|
||||
# Autoload: toggleable network debug overlay (F4 by default — see
|
||||
# toggle_net_overlay in project.godot's [input]). Read-only against
|
||||
# NetworkManager's clock state (task 1.8). Mirrors perf_overlay.gd's pattern
|
||||
# — headless-guarded, hidden by default, no gameplay-state writes.
|
||||
|
||||
var _label: Label
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if DisplayServer.get_name() == "headless":
|
||||
set_process(false)
|
||||
return
|
||||
layer = 100
|
||||
_label = Label.new()
|
||||
_label.add_theme_font_size_override("font_size", 14)
|
||||
_label.add_theme_color_override("font_color", Color(0.5, 0.8, 1.0))
|
||||
_label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.85))
|
||||
_label.add_theme_constant_override("shadow_offset_x", 1)
|
||||
_label.add_theme_constant_override("shadow_offset_y", 1)
|
||||
_label.position = Vector2(12, 90)
|
||||
_label.visible = false
|
||||
add_child(_label)
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed("toggle_net_overlay") and _label:
|
||||
_label.visible = not _label.visible
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if not _label or not _label.visible:
|
||||
return
|
||||
if NetworkManager.is_server:
|
||||
_label.text = "NET: server, %d peer(s)" % (MatchNet.roster.size())
|
||||
elif NetworkManager.is_client:
|
||||
if NetworkManager.rtt_ms < 0.0:
|
||||
_label.text = "NET: client, connecting (no clock sample yet)"
|
||||
else:
|
||||
_label.text = "NET: client RTT %.1fms clock offset %.1fms" % [NetworkManager.rtt_ms, NetworkManager.clock_offset_ms]
|
||||
else:
|
||||
_label.text = "NET: offline"
|
||||
@@ -0,0 +1,210 @@
|
||||
extends Node
|
||||
|
||||
# Autoload (project.godot [autoload] NetworkManager). Owns the ENet
|
||||
# transport: hosting, joining, shutdown, and connection-state signals. Lives
|
||||
# at a fixed autoload path so RPC NodePaths never depend on which scene is
|
||||
# loaded (§1.3 of multiplayer-todo.md's derived decisions).
|
||||
#
|
||||
# server_relay = false is set the moment a peer exists: the default `true`
|
||||
# lets any client rpc() any other client *through the server*, which this
|
||||
# project's server-authoritative model must never allow — §2.1 calls this
|
||||
# out as the single highest-value one-line security change in the document.
|
||||
#
|
||||
# IMPORTANT, learned the hard way (tests/net_smoke.gd): don't call
|
||||
# shutdown()/close the peer the instant connected_to_server or peer_connected
|
||||
# fires. ENet's connect handshake isn't fully settled on the *other* side the
|
||||
# moment your own side's signal fires — the final ACK still needs a couple
|
||||
# more poll() cycles to actually reach the wire. Closing immediately drops
|
||||
# it and leaves the other side's handshake permanently incomplete (it will
|
||||
# never see peer_connected/connected_to_server at all). Callers that shut
|
||||
# down right after a fresh connection should let a frame or two pass first.
|
||||
#
|
||||
# Manual polling (task 1.3): SceneTree's automatic multiplayer poll runs on
|
||||
# the *idle* frame, so an rpc() issued from _physics_process waits up to a
|
||||
# full frame before it's actually pushed onto the wire — and the return leg
|
||||
# pays the same tax again. set_multiplayer_poll_enabled(false) below turns
|
||||
# that off; every caller that sends or expects to receive on a tight cadence
|
||||
# must now call NetworkManager.poll() itself. The intended placement per
|
||||
# multiplayer-todo.md §7 task 1.3 (client: end of _physics_process after
|
||||
# sending input, plus top of both _process and _physics_process for receive;
|
||||
# server: tick start to drain, tick end to flush) has no real per-tick caller
|
||||
# yet — that lands with the input/snapshot pipeline (tasks 1.4+, Phase 2-3).
|
||||
# Until then, anything driving a connection (tests/net_smoke.gd included)
|
||||
# must poll() every frame itself or nothing will ever be sent or received.
|
||||
|
||||
signal client_connected(peer_id: int)
|
||||
signal client_disconnected(peer_id: int)
|
||||
signal connected_to_server()
|
||||
signal connection_failed()
|
||||
signal disconnected_from_server()
|
||||
signal clock_updated(rtt_ms: float, offset_ms: float)
|
||||
# Fires at the top of every shutdown() call, whether this process was
|
||||
# hosting, joined, or already offline, and regardless of *why* (deliberate
|
||||
# Leave/Cancel, or an incoming disconnect from the other side). Adversarial
|
||||
# review found MatchNet.roster had no path that cleared it when a HOST
|
||||
# stopped hosting — connected_to_server/disconnected_from_server only cover
|
||||
# the client side — so a host -> lobby -> leave -> host-again cycle left a
|
||||
# permanent phantom player. Listeners that need per-role cleanup should
|
||||
# still use the more specific signals above; this one exists so "something
|
||||
# is about to reset the connection, drop anything you were keeping" has
|
||||
# exactly one place to hook regardless of role.
|
||||
signal shutting_down()
|
||||
|
||||
const DEFAULT_PORT := 7777
|
||||
const MAX_CLIENTS := 32
|
||||
|
||||
# Clock (task 1.8, §4.7): client pings the server once a second on the
|
||||
# reliable control channel; clock_offset_ms is the min-RTT sample in a
|
||||
# rolling window, because the lowest-RTT sample has the least queueing
|
||||
# error. get_server_time_estimate_ms() is the thing every later phase
|
||||
# (interpolation delay, tick_offset seeding) actually wants — everything
|
||||
# else here exists to produce it.
|
||||
const PING_INTERVAL_SEC := 1.0
|
||||
const CLOCK_WINDOW_SEC := 5.0
|
||||
|
||||
var is_server := false
|
||||
var is_client := false
|
||||
var _peer: ENetMultiplayerPeer # keep a strong ref alongside multiplayer.multiplayer_peer
|
||||
|
||||
var rtt_ms := -1.0 # min-RTT sample currently in the window; -1 = no sample yet
|
||||
var clock_offset_ms := 0.0 # add to a local Time.get_ticks_msec() reading to estimate the server's clock
|
||||
var _clock_samples: Array[Dictionary] = []
|
||||
var _ping_accum_sec := 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
get_tree().set_multiplayer_poll_enabled(false)
|
||||
multiplayer.peer_connected.connect(_on_peer_connected)
|
||||
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
||||
multiplayer.connected_to_server.connect(_on_connected_to_server)
|
||||
multiplayer.connection_failed.connect(_on_connection_failed)
|
||||
multiplayer.server_disconnected.connect(_on_server_disconnected)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# is_client turns true the instant join() is called, before the ENet
|
||||
# handshake actually completes (or fails) — a slow or refused connect
|
||||
# attempt would otherwise leave this trying to rpc_id() on a peer
|
||||
# that's still CONNECTING (or already failed), which Godot logs as
|
||||
# "Trying to call an RPC via a multiplayer peer which is not
|
||||
# connected." every single frame. Require the real transport state.
|
||||
if not is_client or _peer == null or _peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED:
|
||||
return
|
||||
_ping_accum_sec += delta
|
||||
if _ping_accum_sec >= PING_INTERVAL_SEC:
|
||||
_ping_accum_sec = 0.0
|
||||
_ping.rpc_id(1, Time.get_ticks_msec())
|
||||
|
||||
|
||||
# Estimate of what the server's Time.get_ticks_msec() reads right now.
|
||||
# Meaningless before the first pong lands (clock_offset_ms is 0.0 until then
|
||||
# — callers needing round-trip-confirmed freshness should check rtt_ms >= 0).
|
||||
func get_server_time_estimate_ms() -> float:
|
||||
return float(Time.get_ticks_msec()) + clock_offset_ms
|
||||
|
||||
|
||||
# The single entry point every per-tick caller uses instead of relying on
|
||||
# SceneTree's (now disabled) automatic poll. Safe to call with no peer set —
|
||||
# polling the default OfflineMultiplayerPeer is a no-op.
|
||||
func poll() -> void:
|
||||
multiplayer.poll()
|
||||
|
||||
|
||||
func host(port: int = DEFAULT_PORT, max_clients: int = MAX_CLIENTS) -> Error:
|
||||
shutdown()
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_server(port, max_clients)
|
||||
if err != OK:
|
||||
push_error("NetworkManager.host: create_server failed (%s)" % error_string(err))
|
||||
return err
|
||||
_peer = peer
|
||||
multiplayer.multiplayer_peer = peer
|
||||
multiplayer.server_relay = false
|
||||
is_server = true
|
||||
is_client = false
|
||||
return OK
|
||||
|
||||
|
||||
func join(address: String, port: int = DEFAULT_PORT) -> Error:
|
||||
shutdown()
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_client(address, port)
|
||||
if err != OK:
|
||||
push_error("NetworkManager.join: create_client failed (%s)" % error_string(err))
|
||||
return err
|
||||
_peer = peer
|
||||
multiplayer.multiplayer_peer = peer
|
||||
multiplayer.server_relay = false
|
||||
is_server = false
|
||||
is_client = true
|
||||
return OK
|
||||
|
||||
|
||||
func shutdown() -> void:
|
||||
shutting_down.emit()
|
||||
# MultiplayerAPI's default multiplayer_peer is an OfflineMultiplayerPeer
|
||||
# sentinel, never null — closing that sentinel is a no-op, but assigning
|
||||
# multiplayer_peer = null (rather than a fresh OfflineMultiplayerPeer)
|
||||
# leaves the API in a state distinct from its own default, which is a
|
||||
# known source of confusing follow-on bugs (godotengine/godot#81540).
|
||||
# Always reset to a real OfflineMultiplayerPeer, never raw null.
|
||||
var peer := multiplayer.multiplayer_peer
|
||||
if peer != null and not (peer is OfflineMultiplayerPeer):
|
||||
peer.close()
|
||||
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
|
||||
_peer = null
|
||||
is_server = false
|
||||
is_client = false
|
||||
rtt_ms = -1.0
|
||||
clock_offset_ms = 0.0
|
||||
_clock_samples.clear()
|
||||
_ping_accum_sec = 0.0
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func _ping(client_send_ms: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
_pong.rpc_id(multiplayer.get_remote_sender_id(), client_send_ms, Time.get_ticks_msec())
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _pong(client_send_ms: int, server_now_ms: int) -> void:
|
||||
var now_ms := Time.get_ticks_msec()
|
||||
var sample_rtt := float(now_ms - client_send_ms)
|
||||
var sample_offset := float(server_now_ms) + sample_rtt / 2.0 - float(now_ms)
|
||||
_clock_samples.append({"t": now_ms, "rtt": sample_rtt, "offset": sample_offset})
|
||||
|
||||
var cutoff := now_ms - int(CLOCK_WINDOW_SEC * 1000.0)
|
||||
_clock_samples = _clock_samples.filter(func(s: Dictionary) -> bool: return s["t"] >= cutoff)
|
||||
|
||||
var best: Dictionary = _clock_samples[0]
|
||||
for sample: Dictionary in _clock_samples:
|
||||
if sample["rtt"] < best["rtt"]:
|
||||
best = sample
|
||||
rtt_ms = best["rtt"]
|
||||
clock_offset_ms = best["offset"]
|
||||
clock_updated.emit(rtt_ms, clock_offset_ms)
|
||||
|
||||
|
||||
func _on_peer_connected(peer_id: int) -> void:
|
||||
client_connected.emit(peer_id)
|
||||
|
||||
|
||||
func _on_peer_disconnected(peer_id: int) -> void:
|
||||
client_disconnected.emit(peer_id)
|
||||
|
||||
|
||||
func _on_connected_to_server() -> void:
|
||||
connected_to_server.emit()
|
||||
|
||||
|
||||
func _on_connection_failed() -> void:
|
||||
is_client = false
|
||||
connection_failed.emit()
|
||||
|
||||
|
||||
func _on_server_disconnected() -> void:
|
||||
is_server = false
|
||||
is_client = false
|
||||
disconnected_from_server.emit()
|
||||
@@ -0,0 +1,98 @@
|
||||
extends Node
|
||||
|
||||
# Headless dedicated server entry point (task 1.6). Parses CLI args, hosts
|
||||
# via NetworkManager, logs structured lines, and watches for physics-tick
|
||||
# overrun (§9 gotcha 9: Engine.max_physics_steps_per_frame defaults to 8;
|
||||
# a tick overrunning 16.7ms backs up the accumulator and the next frame
|
||||
# runs multiple ticks, spiking CPU further — worth logging, not just
|
||||
# silently absorbing).
|
||||
#
|
||||
# Run: godot --headless --path Game res://scenes/server_boot.tscn -- --port=7777
|
||||
#
|
||||
# Deliberately does not spawn a match yet — that's Phase 2's networked_match
|
||||
# scene. This is just the process shell: listen, log, idle cheaply.
|
||||
|
||||
const LOG_LEVELS := {"debug": 0, "info": 1, "warn": 2, "error": 3}
|
||||
|
||||
var _boot_ms := 0
|
||||
var _last_physics_frame := 0
|
||||
var _log_level := 1 # info
|
||||
var _watchdog_armed := false # skip the first _process(): engine startup scheduling can batch several physics frames before the first idle frame runs, which isn't a real overrun
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_boot_ms = Time.get_ticks_msec()
|
||||
Engine.max_fps = 60 # a server never renders; this just caps the idle-frame poll rate so it doesn't spin
|
||||
|
||||
var port := NetworkManager.DEFAULT_PORT
|
||||
var max_clients := NetworkManager.MAX_CLIENTS
|
||||
for arg in OS.get_cmdline_user_args():
|
||||
if arg.begins_with("--port="):
|
||||
port = int(arg.substr("--port=".length()))
|
||||
elif arg.begins_with("--max-clients="):
|
||||
max_clients = int(arg.substr("--max-clients=".length()))
|
||||
elif arg.begins_with("--log-level="):
|
||||
var level_name := arg.substr("--log-level=".length())
|
||||
if LOG_LEVELS.has(level_name):
|
||||
_log_level = LOG_LEVELS[level_name]
|
||||
else:
|
||||
_log("error", "bad_log_level", {"given": level_name, "valid": LOG_LEVELS.keys()})
|
||||
get_tree().quit(1)
|
||||
return
|
||||
|
||||
NetworkManager.client_connected.connect(_on_client_connected)
|
||||
NetworkManager.client_disconnected.connect(_on_client_disconnected)
|
||||
MatchNet.player_joined.connect(_on_player_joined)
|
||||
MatchNet.player_left.connect(_on_player_left)
|
||||
|
||||
var err := NetworkManager.host(port, max_clients)
|
||||
if err != OK:
|
||||
_log("error", "server_boot_failed", {"port": port, "error": error_string(err)})
|
||||
get_tree().quit(1)
|
||||
return
|
||||
_log("info", "server_started", {"port": port, "max_clients": max_clients})
|
||||
_last_physics_frame = Engine.get_physics_frames()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
NetworkManager.poll()
|
||||
var current := Engine.get_physics_frames()
|
||||
var steps := current - _last_physics_frame
|
||||
_last_physics_frame = current
|
||||
# §9 gotcha 6: with physics_jitter_fix = 0.0, frames legitimately
|
||||
# alternate between 0 and 2 ticks even on an idle, healthy server —
|
||||
# that's expected quantisation, not backlog. A real overrun is the
|
||||
# accumulator failing to drain back down, i.e. 3+ ticks in one frame.
|
||||
if steps > 2 and _watchdog_armed:
|
||||
_log("warn", "physics_overrun", {"steps": steps})
|
||||
_watchdog_armed = true
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
NetworkManager.poll()
|
||||
|
||||
|
||||
func _on_client_connected(peer_id: int) -> void:
|
||||
_log("debug", "peer_connected", {"peer_id": peer_id})
|
||||
|
||||
|
||||
func _on_client_disconnected(peer_id: int) -> void:
|
||||
_log("debug", "peer_disconnected", {"peer_id": peer_id})
|
||||
|
||||
|
||||
func _on_player_joined(peer_id: int, player_name: String) -> void:
|
||||
_log("info", "player_joined", {"peer_id": peer_id, "name": player_name})
|
||||
|
||||
|
||||
func _on_player_left(peer_id: int) -> void:
|
||||
_log("info", "player_left", {"peer_id": peer_id})
|
||||
|
||||
|
||||
func _log(level: String, event: String, fields: Dictionary) -> void:
|
||||
if LOG_LEVELS.get(level, 1) < _log_level:
|
||||
return
|
||||
var parts := PackedStringArray()
|
||||
for key in fields:
|
||||
parts.append("%s=%s" % [key, str(fields[key])])
|
||||
var elapsed_sec := (Time.get_ticks_msec() - _boot_ms) / 1000.0
|
||||
print("[%.3f] %s %s %s" % [elapsed_sec, level.to_upper(), event, " ".join(parts)])
|
||||
Reference in New Issue
Block a user