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//: that land's state is ## worldlets/.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 elif not _order().is_empty(): print("[lands] other lands start on demand. Players over the internet need %s forwarded" % ports_text(home(), game_port())) ## 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: return journey_from(home()) ## The lands a server whose home is `home` and whose port is `base` may run, ## after home: [[land, port]] (24243 for the Cove when the server's 24242). ## Friends playing over the internet need these forwarded too. static func extra_ports(home_land: String, base: int) -> Array: var out: Array = [] var i := 1 for l in journey_from(home_land): out.append([l, base + i]) i += 1 return out ## "UDP 24242 and 24243 (the Cove)": what to forward for a server on `base`. static func ports_text(home_land: String, base: int) -> String: var extra := extra_ports(home_land, base) if extra.is_empty(): return "UDP %d" % base var T: Script = load("res://scripts/world/terrain.gd") var parts: Array[String] = [] for e in extra: parts.append("%d (the %s)" % [int(e[1]), T.worldlet_name(String(e[0]))]) return "UDP %d and %s" % [base, ", ".join(parts)] static func journey_from(home_land: String) -> Array: var out: Array = [] var at := home_land 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_land \ and not load("res://scripts/world/terrain.gd").load_worldlet(String(c.get("to", ""))).is_empty(): 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 int(servers[l].pid) != 0 and not OS.is_process_running(int(servers[l].pid)): print("[lands] the %s's server has gone: starting it again" % l) servers.erase(l) # (crashed, and not noticed yet: start afresh) 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-.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