504 lines
18 KiB
GDScript
504 lines
18 KiB
GDScript
extends Node
|
|
## Autoload "Lands" (J3, docs/WORLDLETS_DESIGN.md: a server per land).
|
|
##
|
|
## A server's world can span several lands (worldlets). The server players
|
|
## join, on the usual port, runs the world's home land (the Valley) and is the
|
|
## **hub**. When someone first heads for another land, the hub starts a
|
|
## **land server** for it: the same game, headless, on the next port (24243
|
|
## for the Cove, and so on), with `--land cove`. It shares the hub's config
|
|
## and world folder (worlds/<world>/: that land's state is
|
|
## worldlets/<land>.json, and characters are in players/, shared by all), so
|
|
## a character saved by one is loaded by the next. A land server stops a
|
|
## couple of minutes after its last player leaves, and whenever the hub
|
|
## stops.
|
|
##
|
|
## Hub and land servers talk over a private TCP link on 127.0.0.1 (one JSON
|
|
## object per line), never seen from outside. J3a carries: the land's
|
|
## "ready" (listening), its player count, the hub's clock, one-time passes
|
|
## for players crossing over, and asks about the tunnel.
|
|
##
|
|
## A crossing (PlayerNet, server side): the player's character is saved with
|
|
## where they're heading; the hub (or the land server, back the other way)
|
|
## makes a one-time pass, makes sure the other server is up, and sends the
|
|
## player's game `{port, pass, land}`. The game reconnects there with the
|
|
## pass (Net.join(..., pass)), and that server lets them straight in.
|
|
|
|
signal land_ready(land: String) ## hub: a land server is listening
|
|
signal hub_message(msg: Dictionary) ## land server: something from the hub
|
|
|
|
const LINK_PORT_OFFSET := 100 ## the hub's private link: its game port + 100 (24342)
|
|
const IDLE_STOP_SECONDS := 120.0 ## a land server with nobody on stops after this
|
|
const PASS_SECONDS := 60.0 ## a pass not used by then is no good
|
|
|
|
var hub := false ## this server is the hub
|
|
var land := "" ## this server is a land server, running this land
|
|
var hub_port := 0 ## (land server) the hub's game port
|
|
|
|
## Hub: the land servers it's running. land -> {"pid", "port", "io", "conn", "players", "ready", "idle"}
|
|
var servers: Dictionary = {}
|
|
var _link: TCPServer
|
|
var _unclaimed: Array = [] ## links not yet saying which land they are
|
|
## One-time passes, wherever a player is arriving: pass -> {"id", "t", "land"}
|
|
var passes: Dictionary = {}
|
|
|
|
## Land server: the link to the hub.
|
|
var _hub: StreamPeerTCP
|
|
var _hub_link: Dictionary = {} ## {"conn": _hub, "buf": the partial line}
|
|
|
|
# --- setup (called by Net.start_server) ------------------------------------------------
|
|
|
|
## Called once a server is listening. `args` are the command-line flags.
|
|
func server_started(args: Dictionary) -> void:
|
|
land = String(args.get("land", ""))
|
|
roster = []
|
|
if not Net.players_changed.is_connected(rebuild_roster):
|
|
Net.players_changed.connect(rebuild_roster)
|
|
if not land.is_empty():
|
|
hub_port = int(args.get("hub-port", Net.DEFAULT_PORT))
|
|
_hub = StreamPeerTCP.new()
|
|
_hub.connect_to_host("127.0.0.1", hub_port + LINK_PORT_OFFSET)
|
|
_hub_link = {"conn": _hub, "buf": ""}
|
|
return
|
|
hub = true
|
|
_link = TCPServer.new()
|
|
var err := _link.listen(game_port() + LINK_PORT_OFFSET, "127.0.0.1")
|
|
if err != OK:
|
|
push_warning("[lands] couldn't open the private link on %d (error %d): no other lands this run" % [game_port() + LINK_PORT_OFFSET, err])
|
|
_link = null
|
|
|
|
## Called as a server stops: stops every land server it started.
|
|
func server_stopped() -> void:
|
|
for l in servers:
|
|
_send_land(l, {"t": "stop"})
|
|
var pids: Array = servers.values().map(func(s): return int(s.pid))
|
|
if not pids.is_empty():
|
|
get_tree().create_timer(6.0).timeout.connect(func():
|
|
for pid in pids:
|
|
if pid != 0 and OS.is_process_running(pid):
|
|
OS.kill(pid))
|
|
servers.clear()
|
|
_unclaimed.clear()
|
|
if _link != null:
|
|
_link.stop()
|
|
_link = null
|
|
if _hub != null:
|
|
_hub.disconnect_from_host()
|
|
_hub = null
|
|
hub = false
|
|
land = ""
|
|
passes.clear()
|
|
|
|
func game_port() -> int:
|
|
return int(Net.config.v("port")) if Net.config != null else Net.DEFAULT_PORT
|
|
|
|
## The worldlet this server runs.
|
|
func here() -> String:
|
|
return land if not land.is_empty() else String(Net.config.v("worldlet")) if Net.config != null else "valley"
|
|
|
|
## The port land `l` runs on: the hub's, then one up for each land after it
|
|
## in the journey's order (data/worldlets: next).
|
|
func port_for(l: String) -> int:
|
|
if l == home():
|
|
return hub_port if not land.is_empty() else game_port()
|
|
var base := hub_port if not land.is_empty() else game_port()
|
|
return base + 1 + maxi(_order().find(l), 0)
|
|
|
|
## The world's home land (the hub's).
|
|
func home() -> String:
|
|
return String(Net.config.v("worldlet")) if Net.config != null else "valley"
|
|
|
|
## Every land after home, in the order the journey reaches them.
|
|
func _order() -> Array:
|
|
var out: Array = []
|
|
var at := home()
|
|
for _i in range(16):
|
|
var d: Dictionary = load("res://scripts/world/terrain.gd").load_worldlet(at)
|
|
var nxt := ""
|
|
for c in d.get("next", []):
|
|
if c is Dictionary and not String(c.get("to", "")) in out and String(c.get("to", "")) != home():
|
|
nxt = String(c.to)
|
|
break
|
|
if nxt.is_empty():
|
|
break
|
|
out.append(nxt)
|
|
at = nxt
|
|
return out
|
|
|
|
# --- passes -------------------------------------------------------------------------
|
|
|
|
## A one-time pass for player `id` to come into land `l`.
|
|
func make_pass(id: String, l: String) -> String:
|
|
var p := Crypto.new().generate_random_bytes(16).hex_encode()
|
|
passes[p] = {"id": id, "t": 0.0, "land": l}
|
|
return p
|
|
|
|
## Whether `p` lets player `id` in here (it's used up either way).
|
|
func take_pass(p: String, id: String) -> bool:
|
|
if not passes.has(p):
|
|
return false
|
|
var e: Dictionary = passes[p]
|
|
passes.erase(p)
|
|
return String(e.id) == id and float(e.t) < PASS_SECONDS
|
|
|
|
# --- the hub ------------------------------------------------------------------------
|
|
|
|
## Makes sure land `l` has a server (starting one if not). Calls `then`
|
|
## (with "" or what went wrong) once it's listening.
|
|
func ensure(l: String, then: Callable) -> void:
|
|
if not hub:
|
|
then.call("this isn't the hub")
|
|
return
|
|
if servers.has(l) and bool(servers[l].ready):
|
|
servers[l].idle = 0.0
|
|
then.call("")
|
|
return
|
|
if not servers.has(l):
|
|
var why := _start(l)
|
|
if not why.is_empty():
|
|
then.call(why)
|
|
return
|
|
var waiting: Callable
|
|
waiting = func(which: String):
|
|
if which == l:
|
|
land_ready.disconnect(waiting)
|
|
then.call("")
|
|
land_ready.connect(waiting)
|
|
|
|
func _start(l: String) -> String:
|
|
var args := PackedStringArray(["--headless"])
|
|
if not OS.has_feature("template"):
|
|
args.append_array(["--path", ProjectSettings.globalize_path("res://")])
|
|
args.append_array(["--", "--server", "--land", l, "--port", str(port_for(l)), "--hub-port", str(game_port()),
|
|
"--config", ProjectSettings.globalize_path(Net.config.path), "--exit-with", str(OS.get_process_id())])
|
|
for k in ["no-creatures", "debug-cheats"]:
|
|
if Net._args.has(k):
|
|
args.append("--" + k)
|
|
if Net._args.has("host-id"):
|
|
args.append_array(["--host-id", String(Net._args["host-id"])]) # (a hosted game: the host leaving stops it all)
|
|
var p: Dictionary = OS.execute_with_pipe(OS.get_executable_path(), args, false)
|
|
if p.is_empty():
|
|
return "couldn't start the %s's server" % l
|
|
var log := FileAccess.open(Net.config.path.get_base_dir().path_join("land-%s.log" % l), FileAccess.WRITE)
|
|
servers[l] = {"pid": int(p.pid), "port": port_for(l), "io": p.stdio, "log": log, "conn": null,
|
|
"players": 0, "ready": false, "idle": 0.0}
|
|
print("[lands] starting the %s's server on UDP %d" % [l, port_for(l)])
|
|
return ""
|
|
|
|
func _process(delta: float) -> void:
|
|
for p in passes.keys():
|
|
passes[p].t += delta
|
|
if float(passes[p].t) > PASS_SECONDS:
|
|
passes.erase(p)
|
|
if hub:
|
|
_hub_tick(delta)
|
|
elif not land.is_empty():
|
|
_land_tick()
|
|
|
|
func _hub_tick(delta: float) -> void:
|
|
if _link != null:
|
|
while _link.is_connection_available():
|
|
var c := _link.take_connection()
|
|
_unclaimed.append({"conn": c, "buf": ""})
|
|
for u in _unclaimed.duplicate():
|
|
var claimed := ""
|
|
for msg in _read(u):
|
|
if claimed.is_empty():
|
|
if String(msg.get("t", "")) == "hello" and servers.has(String(msg.land)):
|
|
claimed = String(msg.land)
|
|
servers[claimed].conn = u.conn
|
|
_unclaimed.erase(u)
|
|
else:
|
|
_from_land(claimed, msg) # (what came in the same read: "ready", most likely)
|
|
if not claimed.is_empty():
|
|
servers[claimed].buf = u.buf
|
|
for l in servers.keys():
|
|
var s: Dictionary = servers[l]
|
|
_drain_output(s)
|
|
if int(s.pid) != 0 and not OS.is_process_running(int(s.pid)):
|
|
print("[lands] the %s's server stopped" % l)
|
|
_drain_output(s)
|
|
servers.erase(l)
|
|
rebuild_roster()
|
|
PlayerNet.beds_changed()
|
|
continue
|
|
if s.conn != null:
|
|
for msg in _read(s):
|
|
_from_land(l, msg)
|
|
s.idle = float(s.idle) + delta if int(s.players) == 0 and bool(s.ready) else 0.0
|
|
if float(s.idle) > IDLE_STOP_SECONDS:
|
|
print("[lands] nobody in the %s for %d s: stopping its server" % [l, int(IDLE_STOP_SECONDS)])
|
|
_send_land(l, {"t": "stop"})
|
|
s.idle = -INF # (asked once)
|
|
|
|
## A land server's output goes to land-<land>.log beside server.json.
|
|
func _drain_output(s: Dictionary) -> void:
|
|
if s.io == null:
|
|
return
|
|
for _i in range(16):
|
|
var chunk: PackedByteArray = s.io.get_buffer(8192)
|
|
if chunk.is_empty():
|
|
break
|
|
if s.log != null:
|
|
s.log.store_string(chunk.get_string_from_utf8())
|
|
s.log.flush()
|
|
|
|
func _from_land(l: String, msg: Dictionary) -> void:
|
|
match String(msg.get("t", "")):
|
|
"ready":
|
|
servers[l].ready = true
|
|
_send_land(l, {"t": "clock", "day": Net._world.day_night.day, "time": Net._world.day_night.time})
|
|
_send_land(l, {"t": "tunnel", "dug": _tunnel_dug_here()})
|
|
land_ready.emit(l)
|
|
"players":
|
|
servers[l].players = int(msg.n)
|
|
"pass":
|
|
# A player there is coming back here: let them in with this pass.
|
|
passes[String(msg.pass)] = {"id": String(msg.id), "t": 0.0, "land": home()}
|
|
"tunnel?":
|
|
_send_land(l, {"t": "tunnel", "dug": _tunnel_dug_here()})
|
|
"host_left":
|
|
Net._host_left.call_deferred()
|
|
"roster":
|
|
servers[l].list = msg.get("list", [])
|
|
rebuild_roster()
|
|
"line":
|
|
# Chat from that land: everyone here, and every other land.
|
|
Chat._all(String(msg.text), String(msg.kind), false)
|
|
for other in servers:
|
|
if other != l:
|
|
_send_land(other, msg)
|
|
"cmd":
|
|
Chat.run_remote(String(msg.line), {"id": String(msg.id), "name": String(msg.name), "land": l, "peer": int(msg.peer)})
|
|
"beds":
|
|
servers[l].beds = [int(msg.alive), int(msg.asleep)]
|
|
# Tell the lands the running total (minus their own), so each can
|
|
# say "N of M asleep", then see if the night passes.
|
|
_push_bed_totals()
|
|
PlayerNet.beds_changed()
|
|
|
|
## Sends `msg` to land `l`'s server.
|
|
func _send_land(l: String, msg: Dictionary) -> void:
|
|
if not servers.has(l) or servers[l].conn == null:
|
|
return
|
|
var c: StreamPeerTCP = servers[l].conn
|
|
c.poll()
|
|
if c.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
|
c.put_data((JSON.stringify(msg) + "\n").to_utf8_buffer())
|
|
|
|
## Hub: tells every land how far the cliff tunnel's dug (the Cove's sea
|
|
## cave leads up it once it's through).
|
|
func push_tunnel(dug: int) -> void:
|
|
for l in servers:
|
|
_send_land(l, {"t": "tunnel", "dug": dug})
|
|
|
|
func _tunnel_dug_here() -> int:
|
|
var t: Node3D = Net._world.tunnel if Net._world != null else null
|
|
return int(t.dug) if t != null else 0
|
|
|
|
## Hub: tells every land the clock (so there's one clock everywhere).
|
|
func push_clock(day: int, time: float) -> void:
|
|
for l in servers:
|
|
_send_land(l, {"t": "clock", "day": day, "time": time})
|
|
|
|
## Hub: a pass for player `id` to go to land `l` (the land server learns it).
|
|
func pass_to(l: String, id: String) -> String:
|
|
var p := make_pass(id, l)
|
|
_send_land(l, {"t": "pass", "pass": p, "id": id})
|
|
passes.erase(p) # (it's the land server's to take, not ours)
|
|
return p
|
|
|
|
# --- a land server --------------------------------------------------------------------
|
|
|
|
var _said_hello := false
|
|
var _last_players := -1
|
|
var tunnel_dug := -1 ## (land server) how far the hub's tunnel is dug, last asked
|
|
|
|
func _land_tick() -> void:
|
|
if _hub == null:
|
|
return
|
|
_hub.poll()
|
|
var st := _hub.get_status()
|
|
if st == StreamPeerTCP.STATUS_CONNECTED:
|
|
if not _said_hello:
|
|
_said_hello = true
|
|
send_hub({"t": "hello", "land": land})
|
|
send_hub({"t": "ready"})
|
|
var n := Net.players.size()
|
|
if n != _last_players or _roster_dirty:
|
|
_last_players = n
|
|
_roster_dirty = false
|
|
send_hub({"t": "players", "n": n})
|
|
send_hub({"t": "roster", "list": _here_list()})
|
|
for msg in _read(_hub_link):
|
|
_from_hub(msg)
|
|
elif st == StreamPeerTCP.STATUS_ERROR or (st == StreamPeerTCP.STATUS_NONE and _said_hello):
|
|
print("[lands] lost the hub: saving and stopping")
|
|
_hub = null
|
|
Net.stop()
|
|
get_tree().quit()
|
|
|
|
func _from_hub(msg: Dictionary) -> void:
|
|
match String(msg.get("t", "")):
|
|
"stop":
|
|
print("[lands] the hub asked us to stop: saving")
|
|
_hub = null
|
|
Net.stop()
|
|
get_tree().quit()
|
|
"pass":
|
|
passes[String(msg.pass)] = {"id": String(msg.id), "t": 0.0, "land": land}
|
|
"clock":
|
|
if Net._world != null:
|
|
var dn: Node = Net._world.day_night
|
|
var was_day: int = dn.day
|
|
var was_t: float = dn.time
|
|
var secs: float = ((int(msg.day) - was_day) + (float(msg.time) - was_t)) * dn.day_length
|
|
if secs > 1.0:
|
|
Net._world.skip_time(secs) # (catching up: a slept night, or time nobody was here)
|
|
dn.day = int(msg.day)
|
|
dn.time = float(msg.time)
|
|
dn.apply(true)
|
|
Net.push_clock()
|
|
"tunnel":
|
|
tunnel_dug = int(msg.dug)
|
|
"roster":
|
|
roster = msg.get("list", [])
|
|
Net.push_roster()
|
|
"line":
|
|
Chat._all(String(msg.text), String(msg.kind), false)
|
|
"reply":
|
|
if Net.players.has(int(msg.peer)):
|
|
Chat._s2c_line.rpc_id(int(msg.peer), String(msg.text), "reply")
|
|
"kick":
|
|
var kp: int = Net.peer_by_name(String(msg.name))
|
|
if kp != 0:
|
|
Net.kick(kp, String(msg.reason))
|
|
"config":
|
|
Net.reload_config()
|
|
Net.broadcast_rules()
|
|
"weather":
|
|
Net.set_weather(String(msg.state))
|
|
"save":
|
|
WorldNet.save()
|
|
"night":
|
|
PlayerNet.pass_night_here(float(msg.secs))
|
|
"beds_total":
|
|
PlayerNet._hub_beds = [int(msg.alive), int(msg.asleep)]
|
|
hub_message.emit(msg)
|
|
|
|
func send_hub(msg: Dictionary) -> void:
|
|
if _hub != null and _hub.get_status() == StreamPeerTCP.STATUS_CONNECTED:
|
|
_hub.put_data((JSON.stringify(msg) + "\n").to_utf8_buffer())
|
|
|
|
# --- everyone, everywhere (J3b) ------------------------------------------------------------
|
|
|
|
## Everyone on every land: [{"name", "id", "land", "admin"}]. The hub builds
|
|
## it from its own players and what each land reports; every land and every
|
|
## player's game gets a copy (Net.roster), for the player list and /players.
|
|
var roster: Array = []
|
|
var _roster_dirty := false
|
|
|
|
## The players on this server, as the roster lists them.
|
|
func _here_list() -> Array:
|
|
var out: Array = []
|
|
for p in Net.players.values():
|
|
out.append({"name": String(p.name), "id": String(p.id), "land": here(), "admin": bool(p.get("admin", false))})
|
|
return out
|
|
|
|
## Hub: the roster again (its players changed, or a land's did), sent out.
|
|
func rebuild_roster() -> void:
|
|
if not hub:
|
|
if not land.is_empty():
|
|
_roster_dirty = true # (a land server: tell the hub, next tick)
|
|
return
|
|
var out: Array = _here_list()
|
|
for l in servers:
|
|
for e in servers[l].get("list", []):
|
|
var e2: Dictionary = (e as Dictionary).duplicate()
|
|
e2.land = l
|
|
out.append(e2)
|
|
roster = out
|
|
Net.push_roster()
|
|
for l in servers:
|
|
_send_land(l, {"t": "roster", "list": out})
|
|
|
|
## The land a player called `n` is in ("" = not on anywhere).
|
|
func land_of_name(n: String) -> String:
|
|
for e in roster:
|
|
if String(e.name).to_lower() == n.to_lower():
|
|
return String(e.land)
|
|
return ""
|
|
|
|
## Chat or a server message, from here to every other land.
|
|
func relay_line(text: String, kind: String) -> void:
|
|
if hub:
|
|
for l in servers:
|
|
_send_land(l, {"t": "line", "text": text, "kind": kind})
|
|
elif not land.is_empty():
|
|
send_hub({"t": "line", "text": text, "kind": kind})
|
|
|
|
## Hub: sends `msg` to every land.
|
|
func tell_lands(msg: Dictionary) -> void:
|
|
for l in servers:
|
|
_send_land(l, msg)
|
|
|
|
## Hub: sends `msg` to land `l`.
|
|
func tell_land(l: String, msg: Dictionary) -> void:
|
|
_send_land(l, msg)
|
|
|
|
## Hub: tells each land the alive/asleep everywhere BUT that land, so a land
|
|
## server can show the right "N of M" and know when all are abed.
|
|
func _push_bed_totals() -> void:
|
|
var here_alive: int = PlayerNet._alive().size()
|
|
var here_asleep: int = PlayerNet._alive().filter(func(q): return q.in_bed).size()
|
|
for l in servers:
|
|
var a := here_alive
|
|
var s := here_asleep
|
|
for other in servers:
|
|
if other != l:
|
|
var b: Array = servers[other].get("beds", [0, 0])
|
|
a += int(b[0])
|
|
s += int(b[1])
|
|
_send_land(l, {"t": "beds_total", "alive": a, "asleep": s})
|
|
|
|
## Hub: how many are alive, and how many asleep, in every other land.
|
|
func beds_elsewhere() -> Array:
|
|
var alive := 0
|
|
var asleep := 0
|
|
for l in servers:
|
|
var b: Array = servers[l].get("beds", [0, 0])
|
|
alive += int(b[0])
|
|
asleep += int(b[1])
|
|
return [alive, asleep]
|
|
|
|
## Land server: a pass for player `id` to go back to the hub.
|
|
func pass_home(id: String) -> String:
|
|
var p := Crypto.new().generate_random_bytes(16).hex_encode()
|
|
send_hub({"t": "pass", "pass": p, "id": id})
|
|
return p
|
|
|
|
# --- the line protocol ------------------------------------------------------------------
|
|
|
|
## Whole JSON lines waiting on `link` ({"conn", "buf"}); keeps the rest.
|
|
func _read(link: Dictionary) -> Array:
|
|
var c: StreamPeerTCP = link.conn
|
|
if c == null:
|
|
return []
|
|
c.poll()
|
|
if c.get_status() != StreamPeerTCP.STATUS_CONNECTED:
|
|
return []
|
|
var n := c.get_available_bytes()
|
|
if n > 0:
|
|
var got: Array = c.get_data(n)
|
|
if int(got[0]) == OK:
|
|
link.buf = String(link.get("buf", "")) + (got[1] as PackedByteArray).get_string_from_utf8()
|
|
var out: Array = []
|
|
var buf := String(link.get("buf", ""))
|
|
while buf.contains("\n"):
|
|
var line := buf.get_slice("\n", 0)
|
|
buf = buf.substr(line.length() + 1)
|
|
var m: Variant = JSON.parse_string(line)
|
|
if m is Dictionary:
|
|
out.append(m)
|
|
link.buf = buf
|
|
return out
|