Files
Battle-for-Tismo/scripts/classes/network.gd
T

497 lines
17 KiB
GDScript

extends Node
## Owns this process's network role: a dedicated authoritative server (started
## with `--server`) or a client that talks to one.
##
## The transport is WebSocket rather than raw UDP so the same server can serve a
## browser client over HTTP(S). A dedicated server listens as a WebSocket server
## (usually behind a reverse proxy that terminates TLS); clients connect to a
## `ws://` or `wss://` URL. On the web the client defaults to the page's own
## origin, so a server and its web client can share one host and port.
##
## Creating a new game connects this process to an authoritative server as its
## first player, who becomes the game's admin and configures it. On the web the
## server is the dedicated instance already serving the page (a browser cannot
## spawn processes); elsewhere a dedicated headless instance is spawned and the
## client connects to that. Admin stays with the player across reconnections,
## because admin is tied to the player's name rather than their peer id.
##
## Joining requires a name and a password. A new name sets its password on its
## first join; a known name must present the same password again, which is how
## a player returns to an ongoing game (and reclaims their civilisation). There
## is no other registration.
##
## Both roles expose this node at /root/Network so the RPCs below resolve on
## either side of the connection.
signal session_started
signal session_failed(message: String)
signal server_disconnected
signal players_updated(players: Dictionary)
signal game_configured
signal game_state_received(state: Dictionary)
signal game_list_received(games: Array)
const DEFAULT_PORT := 27015
## Path the reverse proxy forwards to the game server (e.g. wss://host/ws).
const DEFAULT_PATH := "/ws"
const CONNECT_TIMEOUT := 15.0
const SERVER_SCENE := "res://scenes/server_main.tscn"
const READY_FILE := "user://server_%d.ready"
## Deployed server an exported desktop build talks to. Development builds (the
## editor and debug exports) use the local server instead; web clients always
## use the page's own origin.
const PRODUCTION_SERVER_URL := "wss://tismo.cfpi-fpsi.eu/ws"
## Largest frame the transport accepts. A state snapshot is a single WebSocket
## message that can be far bigger than the 64 KiB WebSocketPeer default (the
## default map is already ~190 KiB), so both ends raise their buffers to match.
const MAX_MESSAGE_BYTES := 16 * 1024 * 1024
## Set by the GameServer node in the server process so incoming order RPCs can
## be handed to it. Null on a client.
var game_server: GameServer = null
var _is_server := false
var _is_admin := false
var _local_player_id := 0
var _players: Dictionary = {}
## Peer id -> player name, for peers that have authenticated this session.
var _player_names: Dictionary = {}
var _admin_peer_id := 0
## Set when the server refuses a login, so the disconnect that follows reports
## the real reason instead of a generic one.
var _login_failure := ""
var _server_process_id := 0
## True while the connection exists only to ask the server for its game list.
## The peer then logs in with join_game_from_lobby instead of on connect.
var _lobby_mode := false
func _ready() -> void:
var args := OS.get_cmdline_user_args()
if args.has("--server"):
_start_server(args)
func is_server() -> bool:
return _is_server
func is_admin() -> bool:
return _is_admin
## True when this process is a client with a live connection to a server. False
## when running standalone, which the world map uses to fall back to local play.
func is_client_connected() -> bool:
return not _is_server and _local_player_id != 0
func get_local_player_id() -> int:
return _local_player_id
func get_players() -> Dictionary:
return _players
func get_admin_peer_id() -> int:
return _admin_peer_id
func get_peer_ids() -> Array:
return _players.keys()
## Registers the authoritative GameServer so RPCs can reach it (server process).
func register_game_server(server: GameServer) -> void:
game_server = server
## Sends the new-game choices to the server. Only the admin (host) may do this.
func send_game_setup(setup: Dictionary) -> void:
if _is_server:
return
_rpc_configure_game.rpc_id(1, setup)
## Sends a gameplay order to the server.
func send_order(order: Dictionary) -> void:
if _is_server:
return
_rpc_submit_order.rpc_id(1, order)
## Pushes a state snapshot to one connected peer (server process only).
func send_game_state(peer_id: int, snapshot: Dictionary) -> void:
if not _is_server or peer_id <= 0:
return
_rpc_game_state.rpc_id(peer_id, snapshot)
## Starts a new game. The web client cannot spawn a server, so it creates the
## game on the dedicated server already serving the page; elsewhere a dedicated
## server process is spawned and the client connects to it.
func create_game() -> Error:
if OS.has_feature("web"):
return join_game_url(default_server_url())
return host_game()
## Spawns a dedicated server process and connects to it as a client.
func host_game() -> Error:
var port := _find_free_port()
var pid := _spawn_server(port)
if pid <= 0:
return ERR_CANT_CREATE
_server_process_id = pid
_connect_when_ready("127.0.0.1", port)
return OK
## Connects to a server given a host and port. `host` may be a bare hostname
## (a `ws://host:port/ws` URL is built) or a full `http(s)://` / `ws(s)://` URL.
func join_game(host: String, port: int = DEFAULT_PORT) -> Error:
return join_game_url(_resolve_url(host, port))
## Connects to an explicit WebSocket URL, e.g. `wss://example.com/ws`, and logs
## in on connect.
func join_game_url(url: String) -> Error:
_lobby_mode = false
return _connect(url)
## Opens a connection only to ask which games the server is running. The peer is
## not logged in; the join screen later calls join_game_from_lobby to enter.
func request_game_list(url: String) -> Error:
_lobby_mode = true
return _connect(url)
## Logs in over the connection opened by request_game_list. Returns an error
## without sending anything when that connection has gone, so the caller can
## start a fresh listing.
func join_game_from_lobby(player_name: String, password: String) -> Error:
if not _connection_is_open():
session_failed.emit("Not connected to the game server.")
return ERR_UNCONFIGURED
_lobby_mode = false
_rpc_login.rpc_id(1, player_name, password)
return OK
func _connect(url: String) -> Error:
var peer := WebSocketMultiplayerPeer.new()
peer.inbound_buffer_size = MAX_MESSAGE_BYTES
peer.outbound_buffer_size = MAX_MESSAGE_BYTES
var error := peer.create_client(url)
if error != OK:
session_failed.emit("Could not reach the game server.")
return error
_install_client_peer(peer)
return OK
func _connection_is_open() -> bool:
var peer := multiplayer.multiplayer_peer
return (
peer != null
and peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED
)
## Default server to join: the page's own origin when running in a browser (so
## the web client and its server can share one host), otherwise a local server
## in development and the deployed host in release builds.
func default_server_url() -> String:
if OS.has_feature("web"):
var origin := str(JavaScriptBridge.eval("window.location.origin"))
if not origin.is_empty() and origin != "null":
return _websocket_url(origin)
return _desktop_server_url(_use_local_server())
## Desktop server address for the given environment. Split out so the choice can
## be tested without depending on how the test binary was built.
func _desktop_server_url(use_local: bool) -> String:
if use_local:
return _websocket_url("http://127.0.0.1:%d" % DEFAULT_PORT)
return PRODUCTION_SERVER_URL
## Development builds talk to localhost; an exported release build talks to the
## deployed server. Web builds never reach this, as they use their own origin.
func _use_local_server() -> bool:
return OS.has_feature("editor") or OS.is_debug_build()
func _resolve_url(host: String, port: int) -> String:
var h := host.strip_edges()
if (
h.begins_with("ws://")
or h.begins_with("wss://")
or h.begins_with("http://")
or h.begins_with("https://")
):
return _websocket_url(h)
return _websocket_url("http://%s:%d" % [h, port])
## Normalises an http(s) origin or ws(s) base into a ws(s) URL ending in
## DEFAULT_PATH unless the URL already carries a path of its own.
func _websocket_url(base: String) -> String:
var url := base.strip_edges().rstrip("/")
if url.begins_with("https://"):
url = "wss://" + url.substr("https://".length())
elif url.begins_with("http://"):
url = "ws://" + url.substr("http://".length())
elif not url.begins_with("ws://") and not url.begins_with("wss://"):
url = "ws://" + url
var after_scheme := url.substr(url.find("://") + 3)
if not after_scheme.contains("/"):
url += DEFAULT_PATH
return url
## Stops the server process this client spawned, if any.
func stop_hosted_server() -> void:
if _server_process_id > 0:
OS.kill(_server_process_id)
_server_process_id = 0
func _start_server(args: PackedStringArray) -> void:
_is_server = true
var port := _int_arg(args, "--port", DEFAULT_PORT)
# Bind locally by default so a reverse proxy is the only public entry point.
# Pass `--bind *` to accept direct connections from other machines.
var bind := _string_arg(args, "--bind", "127.0.0.1")
var peer := WebSocketMultiplayerPeer.new()
peer.inbound_buffer_size = MAX_MESSAGE_BYTES
peer.outbound_buffer_size = MAX_MESSAGE_BYTES
var error := peer.create_server(port, bind)
if error != OK:
push_error("Game server could not listen on %s:%d (error %d)." % [bind, port, error])
get_tree().quit(1)
return
multiplayer.multiplayer_peer = peer
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
_write_ready_file(port)
print("Game server listening on ws://%s:%d%s." % [bind, port, DEFAULT_PATH])
func _connect_when_ready(host: String, port: int) -> void:
var deadline := Time.get_ticks_msec() + int(CONNECT_TIMEOUT * 1000.0)
var ready_path := READY_FILE % port
while Time.get_ticks_msec() < deadline:
if FileAccess.file_exists(ready_path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(ready_path))
join_game(host, port)
return
await get_tree().create_timer(0.2).timeout
session_failed.emit("Timed out waiting for the game server to start.")
func _install_client_peer(peer: WebSocketMultiplayerPeer) -> void:
# The server owns the clock; this client mirrors the time it is sent.
GameClock.auto_advance = false
multiplayer.multiplayer_peer = peer
_connect_client_signals()
## Connects the client callbacks once. They live on the MultiplayerAPI singleton
## shared by every node, so reconnecting a client must not stack duplicates.
func _connect_client_signals() -> void:
if not multiplayer.connected_to_server.is_connected(_on_connected_to_server):
multiplayer.connected_to_server.connect(_on_connected_to_server)
if not multiplayer.connection_failed.is_connected(_on_connection_failed):
multiplayer.connection_failed.connect(_on_connection_failed)
if not multiplayer.server_disconnected.is_connected(_on_server_disconnected):
multiplayer.server_disconnected.connect(_on_server_disconnected)
## A raw connection is not a player yet: it must send credentials via _rpc_login
## before it is added to the roster, receives state or may place orders.
func _on_peer_connected(_id: int) -> void:
pass
func _on_peer_disconnected(id: int) -> void:
_players.erase(id)
_player_names.erase(id)
if id == _admin_peer_id:
# The admin's identity is remembered, so they resume as admin when they
# log back in. Until then the game keeps running under nobody.
_admin_peer_id = 0
_sync_players.rpc(_players)
if game_server:
game_server.on_peer_left(id)
## Admits a peer that supplied valid credentials and announces it to everyone.
func _accept_login(peer_id: int, player_name: String, is_admin: bool) -> void:
_player_names[peer_id] = player_name
if is_admin:
_admin_peer_id = peer_id
_players[peer_id] = {"id": peer_id, "name": player_name, "admin": is_admin}
_assigned.rpc_id(peer_id, peer_id, is_admin, player_name)
_sync_players.rpc(_players)
if game_server:
game_server.on_peer_joined(peer_id, player_name)
func _on_connected_to_server() -> void:
# A lobby connection only asks what is running; the player logs in later.
if _lobby_mode:
_rpc_request_games.rpc_id(1)
return
# Identify ourselves before the server will assign us a peer id or state.
_rpc_login.rpc_id(1, GameConfig.player_name, GameConfig.player_password)
func _on_connection_failed() -> void:
session_failed.emit("Could not connect to the game server.")
func _on_server_disconnected() -> void:
_is_admin = false
if not _login_failure.is_empty():
var reason := _login_failure
_login_failure = ""
session_failed.emit(reason)
return
server_disconnected.emit()
@rpc("authority", "call_remote", "reliable")
func _assigned(player_id: int, is_admin: bool, player_name: String) -> void:
_local_player_id = player_id
_is_admin = is_admin
GameConfig.player_name = player_name
session_started.emit()
@rpc("authority", "call_remote", "reliable")
func _sync_players(players: Dictionary) -> void:
_players = players
players_updated.emit(players)
## Asks the server which games it is running. Sent before login so the join
## screen can show them without credentials.
@rpc("any_peer", "call_remote", "reliable")
func _rpc_request_games() -> void:
if not _is_server or game_server == null:
return
_rpc_game_list.rpc_id(multiplayer.get_remote_sender_id(), game_server.game_list())
@rpc("authority", "call_remote", "reliable")
func _rpc_game_list(games: Array) -> void:
game_list_received.emit(games)
## First message a client sends. The server validates or creates the password
## for that name and either admits the peer or turns it away.
@rpc("any_peer", "call_remote", "reliable")
func _rpc_login(player_name: String, password: String) -> void:
if not _is_server or game_server == null:
return
var peer_id := multiplayer.get_remote_sender_id()
var result := game_server.authenticate(peer_id, player_name, password)
if not bool(result.get("ok", false)):
var reason := str(result.get("reason", "Login failed."))
_login_rejected.rpc_id(peer_id, reason)
if multiplayer.multiplayer_peer is WebSocketMultiplayerPeer:
(multiplayer.multiplayer_peer as WebSocketMultiplayerPeer).disconnect_peer(peer_id)
return
_accept_login(peer_id, str(result["name"]), bool(result["admin"]))
@rpc("authority", "call_remote", "reliable")
func _login_rejected(reason: String) -> void:
_login_failure = reason
if multiplayer.multiplayer_peer is WebSocketMultiplayerPeer:
(multiplayer.multiplayer_peer as WebSocketMultiplayerPeer).close()
# Either this arrives before the disconnect and reports the reason here, or
# _on_server_disconnected has already consumed it. Never report twice.
if _login_failure == reason:
_login_failure = ""
session_failed.emit(reason)
@rpc("any_peer", "call_remote", "reliable")
func _rpc_configure_game(setup: Dictionary) -> void:
if not _is_server or game_server == null:
return
if multiplayer.get_remote_sender_id() != _admin_peer_id:
return
game_server.configure_game(setup)
_rpc_game_configured.rpc()
@rpc("authority", "call_remote", "reliable")
func _rpc_game_configured() -> void:
game_configured.emit()
@rpc("any_peer", "call_remote", "reliable")
func _rpc_submit_order(order: Dictionary) -> void:
if not _is_server or game_server == null:
return
game_server.handle_order(multiplayer.get_remote_sender_id(), order)
@rpc("authority", "call_remote", "reliable")
func _rpc_game_state(state: Dictionary) -> void:
game_state_received.emit(state)
func _find_free_port() -> int:
var tcp := TCPServer.new()
if tcp.listen(0, "127.0.0.1") != OK:
return DEFAULT_PORT
var port := tcp.get_local_port()
tcp.stop()
return port
func _spawn_server(port: int) -> int:
var args := PackedStringArray(["--headless"])
if OS.has_feature("editor"):
args.append_array(["--path", ProjectSettings.globalize_path("res://"), SERVER_SCENE])
args.append_array(["--", "--server", "--port", str(port)])
_forward_profiling_args(args)
return OS.create_process(OS.get_executable_path(), args)
## Passes a profiling interval chosen on the client command line through to the
## spawned server, so one launch profiles both sides of the connection.
func _forward_profiling_args(args: PackedStringArray) -> void:
var own := OS.get_cmdline_user_args()
var index := own.find("--profile-interval")
if index >= 0 and index + 1 < own.size():
args.append_array(["--profile-interval", own[index + 1]])
func _write_ready_file(port: int) -> void:
var file := FileAccess.open(READY_FILE % port, FileAccess.WRITE)
if file:
file.store_string(str(port))
func _int_arg(args: PackedStringArray, name: String, fallback: int) -> int:
var index := args.find(name)
if index >= 0 and index + 1 < args.size():
return int(args[index + 1])
return fallback
func _string_arg(args: PackedStringArray, name: String, fallback: String) -> String:
var index := args.find(name)
if index >= 0 and index + 1 < args.size():
return args[index + 1]
return fallback