Files
Cinderwild/scripts/net/net.gd

834 lines
32 KiB
GDScript

extends Node
## Autoload "Net": the multiplayer layer (docs/MULTIPLAYER_PLAN.md).
##
## M2.1 covers the skeleton: a dedicated server, joining it, the identity
## handshake, name rules and the player list. Gameplay over the network
## follows in M2.2-M2.5; single-player still runs as before.
##
## Server: `godot --headless --path . -- --server [--port 24242]
## [--config user://server/server.json] [--server-name "..."]
## [--max-players 8] [--password ...]`
## Client: Net.join("host:port", password) from the title screen.
##
## Handshake (all reliable ENet over one UDP port):
## client -> server _c2s_hello {protocol, name, appearance, public_key, password}
## server -> client _s2c_challenge nonce (32 random bytes)
## client -> server _c2s_proof signature (nonce signed with the private key)
## server -> client _s2c_welcome {...} or _s2c_rejected reason
## Then the server tells everyone about joins and leaves.
signal state_changed(state: String) ## offline | connecting | connected | server
signal joined ## the server welcomed us
signal join_failed(reason: String)
signal left(reason: String) ## disconnected after joining
signal players_changed
signal player_joined(name: String) ## someone else came in (clients)
signal player_left(name: String)
## Everyone's positions at server time `t`: [[peer, pos, yaw, pitch, anim, held], ...].
signal snapshot(t: float, states: Array)
signal corrected(pos: Vector3) ## the server refused a move: go back here
signal clock(day: int, time: float) ## the server's time of day
const Identity = preload("res://scripts/net/identity.gd")
const ServerConfig = preload("res://scripts/net/server_config.gd")
const PROTOCOL := 15 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3); 11: food spoilage, lightning (P4); 12: the standing stones and the Old Stag (P5); 13: sounds sent to players nearby (sfx.gd); 14: dropping items, any bag row in hand, new items (jar, bedroll, necklace, bear cloak, rug, pemmican); 15: placed lights (candle, torch stand)
const DEFAULT_PORT := 24242
const HANDSHAKE_TIMEOUT := 10.0
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join
const SEND_INTERVAL := 0.05 ## position updates, both ways (20 Hz)
const CLOCK_INTERVAL := 10.0
## Movement checks: sprinting is 8 m/s, so 9.5 plus a little slack for
## packets that arrive bunched together.
const MAX_SPEED := 9.5
const MAX_RISE := 9.0
const MOVE_SLACK := 1.0
const SPAWN_TELEPORT_RADIUS := 25.0 ## a flagged teleport (respawning) must land near the spawn
var state := "offline"
var dedicated := false ## started with --server
var identity: RefCounted ## identity.gd (clients)
var config: RefCounted ## server_config.gd (server)
var server_info: Dictionary = {} ## the server's public settings (clients)
## Everyone on the server: peer id -> {"id", "name", "appearance", "admin"}.
var players: Dictionary = {}
var local_peer := 0
## What the server told us about its world: seed, worldlet, spawn, clock.
var world_info: Dictionary = {}
var last_reason := "" ## why the last join failed or ended (the title shows it)
## Test hooks: simulated network delay on this client, both directions.
var debug_latency_ms := 0.0
var debug_jitter_ms := 0.0
# Server state.
var _world: Node3D ## the shared island (world.gd in server mode)
var _terrain: Node3D ## its terrain, for movement checks
var _spawn := Vector3.ZERO
var _states: Dictionary = {} ## peer -> {"pos", "yaw", "pitch", "anim", "held", "t", "corr_t"}
var server_time := 0.0 ## seconds since the server started
var _send_left := 0.0
var _clock_left := 0.0
# Client delay queues (debug latency): [release_ms, callable].
var _out_q: Array = []
var _in_q: Array = []
## Test hooks (tests/net_bot.gd): sign with the wrong key, or claim another
## protocol version.
var debug_forge := false
var debug_protocol := 0
var debug_skip_name_check := false ## send a bad name anyway: the server must refuse it
var _peer: ENetMultiplayerPeer
var _pending: Dictionary = {} ## server: peer -> {"t": secs, "hello": {}, "nonce": bytes}
var _reject_reason := ""
var _join_left := 0.0
var _args: Dictionary = {}
func _ready() -> void:
_args = parse_args(OS.get_cmdline_user_args())
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
multiplayer.connected_to_server.connect(_on_connected)
multiplayer.connection_failed.connect(_on_connection_failed)
multiplayer.server_disconnected.connect(_on_server_disconnected)
if _args.has("server"):
dedicated = true
_start_dedicated.call_deferred()
# `--exit-with <pid>`: quit when that process is gone (test servers and
# bots, so a crashed test never leaves them running).
if _args.has("exit-with"):
var parent := int(_args["exit-with"])
var t := Timer.new()
t.wait_time = 1.0
t.autostart = true
t.timeout.connect(func():
# (OS.is_process_running only works for our own children.)
if OS.get_name() != "Windows" and OS.execute("kill", ["-0", str(parent)]) != 0:
print("[net] parent process %d is gone, quitting" % parent)
stop()
get_tree().quit())
add_child(t)
## "--a=b", "--a b" and bare "--flag" all work.
static func parse_args(args: PackedStringArray) -> Dictionary:
var out := {}
var i := 0
while i < args.size():
var a: String = args[i]
if a.begins_with("--"):
var k := a.trim_prefix("--")
if k.contains("="):
out[k.get_slice("=", 0)] = k.substr(k.find("=") + 1)
elif i + 1 < args.size() and not args[i + 1].begins_with("--"):
out[k] = args[i + 1]
i += 1
else:
out[k] = true
i += 1
return out
## The local identity, loaded on first use (`--identity <dir>` overrides).
func get_identity() -> RefCounted:
if identity == null:
identity = Identity.open(String(_args.get("identity", Identity.DEFAULT_DIR)))
return identity
func _set_state(s: String) -> void:
state = s
state_changed.emit(s)
func is_server() -> bool:
return state == "server"
# --- server -------------------------------------------------------------------
## Why this Godot can't run the server ("" = it can): too old, or the game's
## scripts failed to load (usually a fresh copy that's never been imported).
func startup_problem() -> String:
var v := Engine.get_version_info()
if v.major < 4 or (v.major == 4 and v.minor < 7):
return "This game needs Godot 4.7 or newer, but this is %s. Download 4.7.2 from https://godotengine.org/download" % v.string
var root: Window = (Engine.get_main_loop() as SceneTree).root
for n in ["ItemDB", "Actions", "Saves"]:
var node := root.get_node_or_null(n)
if node == null or node.get_script() == null:
return "The game's scripts didn't load (see the errors above). If this is a fresh copy of the project, " + \
"import it once first: godot --headless --path . --import (server/run_server.sh does this for you)"
return ""
func _start_dedicated() -> void:
var bad := startup_problem()
if not bad.is_empty():
printerr("[server] Not starting: ", bad)
get_tree().quit(1)
return
var err := start_server(String(_args.get("config", ServerConfig.DEFAULT_PATH)), _args)
if not err.is_empty():
printerr("[server] ", err)
get_tree().quit(1)
return
# A real dedicated server (not one a test or Host Game started) says in
# its log when there's a newer version; server.json "check_updates".
if not _args.has("exit-with") and bool(config.v("check_updates")):
Updates.checked.connect(_on_update_checked, CONNECT_ONE_SHOT)
Updates.check(true)
func _on_update_checked(s: String) -> void:
if s == "available":
print("[server] Emberwild %s is out (protocol %d); this server runs %s (protocol %d). %s" % [
Updates.latest_version(), Updates.latest_protocol(), Updates.version, PROTOCOL,
"Players with the new version can't join until it's updated." if Updates.latest_protocol() != PROTOCOL else ""])
## Opens the server. Returns "" on success, otherwise what went wrong.
func start_server(config_path: String, overrides: Dictionary = {}) -> String:
if state != "offline":
return "Already %s" % state
config = ServerConfig.open(config_path)
var map := {"port": "port", "server-name": "server_name", "max-players": "max_players",
"password": "password", "world": "world", "seed": "seed"}
for flag in map:
if overrides.has(flag):
var v: Variant = overrides[flag]
config.overrides[map[flag]] = int(v) if map[flag] in ["port", "max_players", "seed"] else String(v)
# The shared island (world_net.gd): the same terrain and scenery every
# client generates from the seed, plus everything players changed.
_world = WorldNet.open_server_world(config)
_terrain = _world.terrain
_spawn = _terrain.find_spawn(Vector2.ZERO)
server_time = 0.0
_peer = ENetMultiplayerPeer.new()
var port := int(config.v("port"))
# A few spare slots so a full server can still say "server is full".
var err := _peer.create_server(port, int(config.v("max_players")) + 4)
if err != OK:
WorldNet.close_server_world()
_world = null
_terrain = null
return "Couldn't open UDP port %d (error %d). Is another server already running on it?" % [port, err]
multiplayer.multiplayer_peer = _peer
multiplayer.server_relay = false # clients only ever talk to the server
players.clear()
_pending.clear()
_set_state("server")
print("[server] '%s' listening on UDP %d (max %d players%s). Config: %s" % [config.v("server_name"), port,
int(config.v("max_players")), ", password set" if not String(config.v("password")).is_empty() else "",
ProjectSettings.globalize_path(config.path)])
return ""
func stop() -> void:
if _peer != null:
_peer.close()
_peer = null
multiplayer.multiplayer_peer = null
players.clear()
_pending.clear()
_states.clear()
server_info.clear()
world_info.clear()
_out_q.clear()
_in_q.clear()
local_peer = 0
if _world != null:
WorldNet.close_server_world() # saves it first
_world = null
_terrain = null
if state != "offline":
_set_state("offline")
func _on_peer_connected(id: int) -> void:
if is_server():
_pending[id] = {"t": 0.0}
func _on_peer_disconnected(id: int) -> void:
if not is_server():
return
_pending.erase(id)
_states.erase(id)
if players.has(id):
var p: Dictionary = players[id]
players.erase(id)
print("[server] %s left (%d online)" % [p.name, players.size()])
_s2c_player_left.rpc(id)
PlayerNet.server_leave(id) # saves their character
WorldNet.peer_left(id)
if _hosted_by(String(p.id)):
_host_left.call_deferred()
players_changed.emit()
func _process(delta: float) -> void:
if is_server():
for id in _pending.keys():
_pending[id].t += delta
if _pending[id].t > HANDSHAKE_TIMEOUT:
_reject(id, "Took too long to say hello")
server_time += delta
_tick_clock(delta)
_send_left -= delta
if _send_left <= 0.0:
_send_left = SEND_INTERVAL
_broadcast_snapshot()
return
_host_tick()
if state == "connecting":
_join_left -= delta
if _join_left <= 0.0:
_fail("No answer from the server")
_run_queue(_out_q)
_run_queue(_in_q)
## The world's clock (it runs while anyone's on, or always if the server's
## set not to pause when empty: world_net.gd pauses the world). Players get
## it every few seconds.
func _tick_clock(delta: float) -> void:
_clock_left -= delta
if _clock_left <= 0.0 and not players.is_empty():
_clock_left = CLOCK_INTERVAL
for peer in players:
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
# --- hosting from the title screen (M2.7) ------------------------------------------
# "Host Game" runs a dedicated server as a separate, windowless copy of the
# game on this machine, then joins it like anyone else (so you're its first
# player, and its admin). Its world and settings live in user://host/. Its
# log goes to user://host/server.log. Leaving asks it to save and stop.
signal host_status(text: String, failed: bool)
var host_config := "user://host/server.json" ## (tests use their own)
var hosting := false
var _host_pid := 0
var _host_io: FileAccess
var _host_log: FileAccess
var _host_out := ""
var _host_port := DEFAULT_PORT
## Settings for a hosted game: name, password, port, a rules preset
## (co-op / rivals / adversarial) and the seed and worldlet for a new island.
const PRESETS := {
"co-op": {"pvp": "false", "raiding": "false", "locks": "owner_and_friends", "death_penalty": "grave"},
"rivals": {"pvp": "true", "raiding": "false", "locks": "owner_and_friends", "death_penalty": "grave"},
"adversarial": {"pvp": "true", "raiding": "true", "locks": "owner", "death_penalty": "drop_all"},
}
func host(server_name: String, password: String, port: int, preset: String, seed: int, worldlet: String = "valley") -> void:
if hosting or state != "offline":
return
if _host_pid != 0 and OS.is_process_running(_host_pid):
host_status.emit("The last server is still saving: try again in a moment", true)
return
var c: RefCounted = ServerConfig.open(host_config)
c.set_rule("server_name", server_name)
for k in PRESETS.get(preset, PRESETS["co-op"]):
c.set_rule(k, PRESETS[preset][k])
c.data.password = password
c.data.port = port
var fresh := not FileAccess.file_exists(host_config.get_base_dir().path_join("worlds/server-world/meta.json"))
if fresh: # (an island keeps its seed and worldlet once it's made)
c.data.seed = seed
c.data.worldlet = worldlet
c.save()
_host_port = port
var args := PackedStringArray(["--headless"])
if not OS.has_feature("template"):
args.append_array(["--path", ProjectSettings.globalize_path("res://")]) # (running from the editor build)
args.append_array(["--", "--server", "--host-id", String(get_identity().player_id),
"--config", ProjectSettings.globalize_path(host_config), "--exit-with", str(OS.get_process_id())])
var p: Dictionary = OS.execute_with_pipe(OS.get_executable_path(), args, false)
if p.is_empty():
host_status.emit("Couldn't start the server", true)
return
_host_pid = int(p.pid)
_host_io = p.stdio
_host_out = ""
_host_log = FileAccess.open(host_config.get_base_dir().path_join("server.log"), FileAccess.WRITE)
hosting = true
host_status.emit("Starting the server%s..." % (" (building a new island)" if fresh else ""), false)
## Reads what the hosted server prints (so its output never fills the pipe),
## and joins it once it's listening.
func _host_tick() -> void:
if _host_io == null:
return
var chunk := _host_io.get_buffer(8192)
if not chunk.is_empty():
var s := chunk.get_string_from_utf8()
if _host_log != null:
_host_log.store_string(s)
_host_log.flush()
if hosting and state == "offline":
_host_out += s
if _host_out.contains("listening on UDP"):
_host_out = ""
host_status.emit("Joining your server...", false)
join("127.0.0.1:%d" % _host_port, String(ServerConfig.open(host_config).data.password))
elif _host_out.contains("[server] Couldn't open UDP port") or _host_out.contains("Not starting"):
var why := "Port %d is already in use (another server running?)" % _host_port \
if _host_out.contains("Couldn't open") else "The server couldn't start (see user://host/server.log)"
stop_hosting()
host_status.emit(why, true)
if _host_pid != 0 and not OS.is_process_running(_host_pid):
var was_hosting := hosting
_host_pid = 0
# It's gone: whatever it printed last is still in the pipe.
for _i in range(64):
var rest := _host_io.get_buffer(8192)
if rest.is_empty():
break
if _host_log != null:
_host_log.store_string(rest.get_string_from_utf8())
if _host_log != null:
_host_log.flush()
_host_io = null
hosting = false
if was_hosting and state == "offline":
host_status.emit("The server stopped (see user://host/server.log)", true)
## Stops the hosted server: asks it to save and quit, then makes sure. Its
## output is still read (into the log) until it's gone.
func stop_hosting() -> void:
if not hosting:
return
hosting = false
var pid := _host_pid
if pid != 0:
get_tree().create_timer(6.0).timeout.connect(func():
if OS.is_process_running(pid):
OS.kill(pid))
## The addresses friends can use to reach this machine: the local network,
## and Tailscale (100.x) if it's running.
static func my_addresses() -> Array[String]:
var out: Array[String] = []
for a in IP.get_local_addresses():
if a.contains(":") or a.begins_with("127.") or a.begins_with("169.254."):
continue
out.append(a)
out.sort_custom(func(x, y): return (0 if x.begins_with("100.") else 1) < (0 if y.begins_with("100.") else 1))
return out
## A hosted server (started with --host-id): when the player who hosts it
## leaves, it saves and stops (it lives on their computer).
func _hosted_by(id: String) -> bool:
return is_server() and _args.has("host-id") and String(_args["host-id"]) == id
func _host_left() -> void:
print("[server] the host left: saving and stopping")
stop() # (saves the world and everyone's characters)
get_tree().quit()
# --- admin (chat.gd) ---------------------------------------------------------------
signal rules_changed ## clients: the server's rules changed (/set)
## The peer of the player online called `n` (any case), or 0.
func peer_by_name(n: String) -> int:
for peer in players:
if String(players[peer].name).to_lower() == n.strip_edges().to_lower():
return peer
return 0
## Disconnects a player with a reason (their title screen shows it).
func kick(peer: int, reason: String) -> void:
if players.has(peer):
_reject(peer, reason)
## Everyone gets the rules again (after /set) and the weather override.
func broadcast_rules() -> void:
_world.apply_rules()
for peer in players:
_s2c_rules.rpc_id(peer, config.public_info())
func set_weather(state: String) -> void:
_world.weather.force(state)
for peer in players:
_s2c_weather.rpc_id(peer, state)
@rpc("authority", "call_remote", "reliable")
func _s2c_rules(info: Dictionary) -> void:
server_info = info
rules_changed.emit()
@rpc("authority", "call_remote", "reliable")
func _s2c_weather(state: String) -> void:
world_info.weather = state
weather_changed.emit(state)
signal weather_changed(state: String)
## Everyone gets the clock now (after a night's sleep, an admin's /time).
func push_clock() -> void:
_clock_left = CLOCK_INTERVAL
for peer in players:
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
## Where a player is, as far as the server knows.
func player_pos(peer: int) -> Vector3:
return _states[peer].pos if _states.has(peer) else Vector3.INF
func island_spawn() -> Vector3:
return _spawn
## The server moved a player (respawning): their next moves are checked from here.
func teleport(peer: int, pos: Vector3) -> void:
if _states.has(peer):
_states[peer].pos = pos
_states[peer].t = server_time
func _broadcast_snapshot() -> void:
if _states.is_empty():
return
var arr: Array = []
for peer in _states:
var s: Dictionary = _states[peer]
arr.append([peer, s.pos, s.yaw, s.pitch, s.anim, s.held, s.stamp])
for peer in players:
_s2c_snapshot.rpc_id(peer, server_time, arr)
## Where player number `i` starts: around the island's spawn point.
func _spawn_for(i: int) -> Vector3:
if i == 0:
return _spawn
var a := i * 2.4
return _terrain.find_spawn(Vector2(_spawn.x + cos(a) * 3.0, _spawn.z + sin(a) * 3.0))
## A position update from a player. Accepted if it's possible from their last
## accepted one; otherwise they're sent back there.
@rpc("any_peer", "call_remote", "unreliable_ordered")
func _c2s_state(pos: Vector3, yaw: float, pitch: float, anim: int, held: String, teleported: bool, client_t: float) -> void:
var peer := multiplayer.get_remote_sender_id()
if not is_server() or not _states.has(peer):
return
var s: Dictionary = _states[peer]
# When the move happened, in server time: the client's own clock plus a
# smoothed offset. Stamping with arrival time instead would bunch moves
# into some snapshots and leave others empty, and avatars would surge.
var off := server_time - client_t
if s.off == INF or absf(off - s.off) > 1.0:
s.off = off
else:
s.off = lerpf(s.off, off, 0.05)
var stamp: float = client_t + s.off
if stamp <= s.stamp:
return # older than what we have
var why := move_problem(s.pos, pos, server_time - s.t, teleported)
if not why.is_empty():
if server_time - s.corr_t > 0.5:
s.corr_t = server_time
print("[server] moved %s back (%s)" % [players[peer].name, why])
_s2c_correct.rpc_id(peer, s.pos)
return
var from: Vector3 = s.pos
s.pos = pos
s.yaw = wrapf(yaw, -PI, PI)
s.pitch = clampf(pitch, -1.5, 1.5)
s.anim = clampi(anim, 0, 4)
s.held = PlayerNet.held(peer) # (what's really in their hand, not what they say)
PlayerNet.server_moved(peer, from, pos, s.yaw, s.anim, server_time - s.t)
s.t = server_time
s.stamp = stamp
## Why a move from `from` to `to` over `dt` seconds isn't allowed ("" = fine).
func move_problem(from: Vector3, to: Vector3, dt: float, teleported: bool) -> String:
if not to.is_finite():
return "bad position"
dt = clampf(dt, SEND_INTERVAL, 1.0)
var flat := Vector2(to.x - from.x, to.z - from.z).length()
if teleported:
if Vector2(to.x - _spawn.x, to.z - _spawn.z).length() > SPAWN_TELEPORT_RADIUS:
return "teleport away from the spawn"
elif flat > MAX_SPEED * dt + MOVE_SLACK:
return "too fast (%.0f m in %.2f s)" % [flat, dt]
elif to.y - from.y > MAX_RISE * dt + MOVE_SLACK:
return "rising too fast"
if not _terrain.inside_boundary(to.x, to.z, -3.0):
return "outside the island"
if to.y < _terrain.sample_height(to.x, to.z) - 1.5:
return "underground"
if not teleported and _through_wall(from, to):
return "through a wall"
return ""
## Whether the straight line between two positions crosses a wall, a closed
## door or a gable (at waist height). A genuine crossing hits the wall from
## both sides; someone a wall was built on top of can still step out of it.
func _through_wall(from: Vector3, to: Vector3) -> bool:
if _world == null or from.distance_to(to) < 0.05:
return false
var space: PhysicsDirectSpaceState3D = _world.get_world_3d().direct_space_state
var up := Vector3(0, 0.9, 0)
return _wall_hit(space, from + up, to + up) and _wall_hit(space, to + up, from + up)
func _wall_hit(space: PhysicsDirectSpaceState3D, a: Vector3, b: Vector3) -> bool:
var ex: Array[RID] = []
for _i in range(4):
var rq := PhysicsRayQueryParameters3D.create(a, b, 4, ex) # 4: building pieces
var hit := space.intersect_ray(rq)
if hit.is_empty():
return false
var c: Object = hit.collider
var piece: Node = c as Node
while piece != null and piece.get("kind") == null:
piece = piece.get_parent()
if piece != null and String(piece.kind) in ["wall", "door", "gable", "window", "palisade", "pillar"]:
return true
ex.append(hit.rid) # a floor or roof edge: look past it
return false
func _reject(peer: int, reason: String) -> void:
_pending.erase(peer)
print("[server] rejected peer %d: %s" % [peer, reason])
_s2c_rejected.rpc_id(peer, reason)
# Let the message go out before hanging up.
get_tree().create_timer(0.4).timeout.connect(func():
if _peer != null and is_server():
_peer.disconnect_peer(peer))
@rpc("any_peer", "call_remote", "reliable")
func _c2s_hello(d: Dictionary) -> void:
var peer := multiplayer.get_remote_sender_id()
if not is_server() or not _pending.has(peer) or _pending[peer].has("hello"):
return
if int(d.get("protocol", 0)) != PROTOCOL:
var theirs := int(d.get("protocol", 0))
_reject(peer, "Different game version: this server runs Emberwild %s (protocol %d), you have protocol %d. %s" % [
Updates.version, PROTOCOL, theirs, "Update the game to join." if theirs < PROTOCOL else "The server needs updating."])
return
var pw := String(config.v("password"))
if not pw.is_empty() and String(d.get("password", "")) != pw:
_reject(peer, "Wrong password")
return
var nonce := Crypto.new().generate_random_bytes(32)
_pending[peer].hello = d
_pending[peer].nonce = nonce
_s2c_challenge.rpc_id(peer, nonce)
@rpc("any_peer", "call_remote", "reliable")
func _c2s_proof(sig: PackedByteArray) -> void:
var peer := multiplayer.get_remote_sender_id()
if not is_server() or not _pending.has(peer) or not _pending[peer].has("nonce"):
return
var hello: Dictionary = _pending[peer].hello
var pem := String(hello.get("public_key", ""))
if not Identity.verify(pem, _pending[peer].nonce, sig):
_reject(peer, "Identity check failed")
return
var id := Identity.id_for(pem)
var name := String(hello.get("name", ""))
var why := _admission_problem(id, name)
if not why.is_empty():
_reject(peer, why)
return
_pending.erase(peer)
config.reserve_name(name, id)
if config.data.admins.is_empty():
config.add_admin(id) # first player on a new server (MULTIPLAYER_PLAN.md §12)
print("[server] %s is the first player, so they're now an admin" % name)
var p := {"id": id, "name": name, "appearance": Identity.sanitize_appearance(hello.get("appearance", {})),
"admin": config.is_admin(id)}
# Their character (player_net.gd): back where they left off, or new.
var start: Vector3 = PlayerNet.server_join(peer, id, name)
var yaw: float = PlayerNet.yaw_of(peer)
if start == Vector3.INF:
start = _spawn_for(players.size())
PlayerNet.place_at(peer, start)
_world.creatures.populate_for(start) # some wildlife about them straight away
players[peer] = p
_states[peer] = {"pos": start, "yaw": yaw, "pitch": 0.0, "anim": 0, "held": "", "t": server_time, "corr_t": -10.0,
"stamp": server_time, "off": INF}
print("[server] %s joined (id %s%s, %d/%d online)" % [name, id, ", admin" if p.admin else "",
players.size(), int(config.v("max_players"))])
# (The world's own seed: an existing world keeps the seed it was made with.)
var world := {"seed": int(_terrain.noise_seed), "worldlet": String(_terrain.worldlet_id), "spawn": start, "yaw": yaw,
"island_spawn": _spawn, "day": _world.day_night.day, "time": _world.day_night.time, "server_time": server_time,
"weather": _world.weather.override}
_s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world})
for other in players:
if other != peer:
_s2c_player_joined.rpc_id(other, peer, p)
players_changed.emit()
## Why player `id` calling themselves `name` can't come in ("" = they can).
func _admission_problem(id: String, name: String) -> String:
if id in config.data.banned:
return "You're banned from this server"
if not config.data.allowlist.is_empty() and not id in config.data.allowlist:
return "You're not on this server's allowlist"
for other in players.values():
if other.id == id:
return "You're already connected from another game"
var bad := Identity.name_problem(name)
if not bad.is_empty():
return bad
var owner: String = config.name_owner(name)
if not owner.is_empty() and owner != id:
return "The name '%s' is taken on this server: pick another" % name
if players.size() >= int(config.v("max_players")):
return "The server is full (%d/%d)" % [players.size(), int(config.v("max_players"))]
return ""
# --- client -------------------------------------------------------------------
## Splits "host", "host:port" or "[v6]:port". Returns [host, port].
static func parse_address(a: String) -> Array:
a = a.strip_edges()
if a.begins_with("["):
var close := a.find("]")
var host := a.substr(1, close - 1)
var rest := a.substr(close + 1)
return [host, int(rest.trim_prefix(":")) if rest.begins_with(":") else DEFAULT_PORT]
if a.count(":") == 1:
var port := a.get_slice(":", 1)
return [a.get_slice(":", 0), int(port) if port.is_valid_int() else DEFAULT_PORT]
return [a, DEFAULT_PORT]
## Connects to a server. The outcome arrives as `joined` or `join_failed`.
func join(address: String, password: String = "") -> void:
if state != "offline":
stop()
var me := get_identity()
var bad := Identity.name_problem(me.name)
if not bad.is_empty() and not debug_skip_name_check:
join_failed.emit("Set your name first (%s)" % bad.to_lower())
return
var hp := parse_address(address)
if String(hp[0]).is_empty():
join_failed.emit("Enter a server address")
return
_peer = ENetMultiplayerPeer.new()
var err := _peer.create_client(hp[0], hp[1])
if err != OK:
_peer = null
join_failed.emit("Couldn't start connecting (error %d)" % err)
return
multiplayer.multiplayer_peer = _peer
_reject_reason = ""
_pending_password = password
_join_left = JOIN_TIMEOUT
me.last_server = address.strip_edges()
me.save_profile()
_set_state("connecting")
var _pending_password := ""
func leave() -> void:
var was := state
stop_hosting() # (our own server sees us go, and saves and stops)
stop()
if was == "connected":
last_reason = "You left the server"
left.emit(last_reason)
func _on_connected() -> void:
var me := get_identity()
_c2s_hello.rpc_id(1, {"protocol": debug_protocol if debug_protocol != 0 else PROTOCOL, "name": me.name,
"appearance": me.appearance, "public_key": me.public_pem, "password": _pending_password})
@rpc("authority", "call_remote", "reliable")
func _s2c_challenge(nonce: PackedByteArray) -> void:
var sig: PackedByteArray = get_identity().sign(nonce)
if debug_forge:
var other := CryptoKey.new()
other = Crypto.new().generate_rsa(2048)
sig = Crypto.new().sign(HashingContext.HASH_SHA256, Identity._sha256(nonce), other)
_c2s_proof.rpc_id(1, sig)
@rpc("authority", "call_remote", "reliable")
func _s2c_welcome(d: Dictionary) -> void:
local_peer = int(d.you)
server_info = d.server
world_info = d.get("world", {})
players.clear()
for k in d.players:
players[int(k)] = d.players[k]
last_reason = ""
_set_state("connected")
joined.emit()
players_changed.emit()
@rpc("authority", "call_remote", "reliable")
func _s2c_rejected(reason: String) -> void:
_reject_reason = reason
@rpc("authority", "call_remote", "reliable")
func _s2c_player_joined(peer: int, p: Dictionary) -> void:
players[peer] = p
players_changed.emit()
player_joined.emit(String(p.get("name", "?")))
@rpc("authority", "call_remote", "reliable")
func _s2c_player_left(peer: int) -> void:
if not players.has(peer):
return
var n := String(players[peer].get("name", "?"))
players.erase(peer)
players_changed.emit()
player_left.emit(n)
@rpc("authority", "call_remote", "unreliable_ordered")
func _s2c_snapshot(t: float, states: Array) -> void:
_delayed(_in_q, func(): snapshot.emit(t, states))
@rpc("authority", "call_remote", "reliable")
func _s2c_correct(pos: Vector3) -> void:
_delayed(_in_q, func(): corrected.emit(pos))
@rpc("authority", "call_remote", "reliable")
func _s2c_clock(day: int, time: float) -> void:
world_info.day = day
world_info.time = time
clock.emit(day, time)
## Sends our position to the server (net_sync.gd calls this ~20 times a second).
func send_state(pos: Vector3, yaw: float, pitch: float, anim: int, held: String, teleported: bool = false) -> void:
if state != "connected":
return
var at := Time.get_ticks_usec() / 1000000.0 # when this happened, by our clock
_delayed(_out_q, func():
if state == "connected":
_c2s_state.rpc_id(1, pos, yaw, pitch, anim, held, teleported, at))
## Runs `f` now, or after the simulated delay when testing with latency.
func _delayed(q: Array, f: Callable) -> void:
if debug_latency_ms <= 0.0:
f.call()
return
var half := debug_latency_ms * 0.5 + randf_range(-debug_jitter_ms, debug_jitter_ms) * 0.5
var at := Time.get_ticks_msec() + maxf(half, 0.0)
# Keep the queue in send order (networks rarely reorder within a stream).
if not q.is_empty() and at < q[-1][0]:
at = q[-1][0]
q.append([at, f])
func _run_queue(q: Array) -> void:
var now := Time.get_ticks_msec()
while not q.is_empty() and q[0][0] <= now:
var f: Callable = q.pop_front()[1]
f.call()
func _fail(reason: String) -> void:
stop_hosting() # (hosting and couldn't get in: don't leave the server running)
stop()
last_reason = reason
join_failed.emit(reason)
func _on_connection_failed() -> void:
_fail("Couldn't reach the server")
func _on_server_disconnected() -> void:
var was := state
var reason := _reject_reason
stop_hosting()
stop()
if was == "connecting":
last_reason = reason if not reason.is_empty() else "The server hung up"
join_failed.emit(last_reason)
else:
last_reason = reason if not reason.is_empty() else "The server closed the connection"
left.emit(last_reason)
func _notification(what: int) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_PREDELETE:
if _peer != null:
_peer.close()