J3a: the journey on a server. A server per land (Lands: the hub starts land servers on demand over a private link), the cliff tunnel online, crossings with one-time passes, shared characters that remember their land, one clock from the hub; test_net_lands

This commit is contained in:
Paul Bailey
2026-11-06 23:14:11 +00:00
parent 6544862885
commit 85bbed249b
17 changed files with 1050 additions and 28 deletions

375
scripts/net/lands.gd Normal file
View File

@@ -0,0 +1,375 @@
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", ""))
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)
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()
## 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:
_last_players = n
send_hub({"t": "players", "n": n})
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)
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())
## 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

1
scripts/net/lands.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://cl2iw0a328ja8

View File

@@ -191,6 +191,10 @@ func start_server(config_path: String, overrides: Dictionary = {}) -> String:
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)
# A land server (J3, lands.gd) runs another land of the hub's world.
Saves.land = String(overrides.get("land", ""))
if not Saves.land.is_empty():
config.overrides["port"] = int(overrides.get("port", config.v("port")))
# 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)
@@ -211,8 +215,10 @@ func start_server(config_path: String, overrides: Dictionary = {}) -> String:
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,
Lands.server_started(overrides)
print("[server] '%s' listening on UDP %d (max %d players%s%s). Config: %s" % [config.v("server_name"), port,
int(config.v("max_players")), ", password set" if not String(config.v("password")).is_empty() else "",
", the %s (a land server)" % Saves.land if not Saves.land.is_empty() else "",
ProjectSettings.globalize_path(config.path)])
return ""
@@ -231,6 +237,8 @@ func stop() -> void:
local_peer = 0
if _world != null:
WorldNet.close_server_world() # saves it first
Lands.server_stopped() # (and the hub's land servers with it)
Saves.land = ""
_world = null
_terrain = null
if state != "offline":
@@ -252,7 +260,8 @@ func _on_peer_disconnected(id: int) -> void:
_s2c_player_left.rpc(id)
PlayerNet.server_leave(id) # saves their character
WorldNet.peer_left(id)
if _hosted_by(String(p.id)):
# (The host crossing to another land hasn't left: only leaving the game does.)
if _hosted_by(String(p.id)) and PlayerNet.land_of(String(p.id)) in ["", Lands.here()]:
_host_left.call_deferred()
players_changed.emit()
@@ -282,10 +291,12 @@ func _process(delta: float) -> void:
## it every few seconds.
func _tick_clock(delta: float) -> void:
_clock_left -= delta
if _clock_left <= 0.0 and not players.is_empty():
if _clock_left <= 0.0 and (not players.is_empty() or not Lands.servers.is_empty()):
_clock_left = CLOCK_INTERVAL
for peer in players:
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
if Lands.hub:
Lands.push_clock(_world.day_night.day, _world.day_night.time) # (one clock for every land)
# --- hosting from the title screen (M2.7) ------------------------------------------
# "Host Game" runs a dedicated server as a separate, windowless copy of the
@@ -414,7 +425,9 @@ func _hosted_by(id: String) -> bool:
func _host_left() -> void:
print("[server] the host left: saving and stopping")
stop() # (saves the world and everyone's characters)
if not Lands.land.is_empty():
Lands.send_hub({"t": "host_left"}) # (they left from another land: the hub stops too)
stop() # (saves the world and everyone's characters, and stops the other lands)
get_tree().quit()
# --- admin (chat.gd) ---------------------------------------------------------------
@@ -456,11 +469,58 @@ func _s2c_weather(state: String) -> void:
signal weather_changed(state: String)
# --- crossing to another land (J3, lands.gd) ------------------------------------------
signal travelling(land: String) ## client: on our way to another land's server
signal arrived(land: String) ## client: in, at the other land (the world reloads)
var _travel: Dictionary = {} ## client: {"land", "address", "pass"} while crossing
var _hub_address := "" ## client: the address we joined (the hub's), for the host part
## Server: sends player `peer`'s game on to land `l` (its server's port, and
## the pass that lets them in), then lets them go.
func send_travel(peer: int, l: String, pass_: String) -> void:
_s2c_travel.rpc_id(peer, {"land": l, "port": Lands.port_for(l), "pass": pass_})
# (Their game goes on its own; this only tidies up one that doesn't.)
get_tree().create_timer(3.0).timeout.connect(func():
if _peer != null and is_server() and (players.has(peer) or _pending.has(peer)):
_peer.disconnect_peer(peer))
@rpc("authority", "call_remote", "reliable")
func _s2c_travel(d: Dictionary) -> void:
var host := String(parse_address(_hub_address if not _hub_address.is_empty() else get_identity().last_server)[0])
if host.is_empty():
host = "127.0.0.1"
_travel = {"land": String(d.land), "address": "%s:%d" % [host, int(d.port)], "pass": String(d.pass)}
print("[net] on to the %s (%s)" % [d.land, _travel.address])
travelling.emit(String(d.land))
var pw := _pending_password
_quiet_stop()
join(String(_travel.address), pw, String(_travel.pass))
## Closes the connection without telling anyone we left (a crossing).
func _quiet_stop() -> void:
if _peer != null:
_peer.close()
_peer = null
multiplayer.multiplayer_peer = null
players.clear()
_states.clear()
_out_q.clear()
_in_q.clear()
local_peer = 0
_set_state("offline")
func is_travelling() -> bool:
return not _travel.is_empty()
## 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)
if Lands.hub:
Lands.push_clock(_world.day_night.day, _world.day_night.time)
## Where a player is, as far as the server knows.
func player_pos(peer: int) -> Vector3:
@@ -541,6 +601,12 @@ func move_problem(from: Vector3, to: Vector3, dt: float, teleported: bool) -> St
return "too fast (%.0f m in %.2f s)" % [flat, dt]
elif to.y - from.y > MAX_RISE * dt + MOVE_SLACK:
return "rising too fast"
# (Down the cliff tunnel, J1: its own space under the island, where its
# rock is the ground. The way in and out is a server teleport.)
if _world != null and _world.underground_at(to):
return "" if teleported or _world.underground_at(from) else "into the tunnel without going in"
if _world != null and _world.underground_at(from):
return "out of the tunnel without climbing out"
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:
@@ -594,8 +660,10 @@ func _c2s_hello(d: Dictionary) -> void:
_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
# (A crossing to another land comes with a one-time pass instead, checked
# once they've proved who they are: _c2s_proof.)
var pw := String(config.v("password"))
if not pw.is_empty() and String(d.get("password", "")) != pw:
if not pw.is_empty() and String(d.get("password", "")) != pw and String(d.get("pass", "")).is_empty():
_reject(peer, "Wrong password")
return
var nonce := Crypto.new().generate_random_bytes(32)
@@ -619,6 +687,23 @@ func _c2s_proof(sig: PackedByteArray) -> void:
if not why.is_empty():
_reject(peer, why)
return
# Lands (J3): a land server only lets in someone crossing with a pass the
# hub gave them; a pass anywhere must be theirs and fresh. And on the
# hub, a character who's in another land is sent straight on there.
var pass_ := String(hello.get("pass", ""))
if not pass_.is_empty():
if not Lands.take_pass(pass_, id):
_reject(peer, "That way in has expired: join the server again")
return
elif not Lands.land.is_empty():
_reject(peer, "Join this server's main address (this is one of its other lands)")
return
elif Lands.hub:
var away: String = PlayerNet.land_of(id)
if not away.is_empty() and away != Lands.home():
_pending.erase(peer)
PlayerNet.send_on(peer, id, away)
return
_pending.erase(peer)
config.reserve_name(name, id)
if config.data.admins.is_empty():
@@ -641,7 +726,8 @@ func _c2s_proof(sig: PackedByteArray) -> void:
# (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}
"weather": _world.weather.override,
"tunnel_dug": Lands.tunnel_dug if not Lands.land.is_empty() else Lands._tunnel_dug_here()}
_s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world})
for other in players:
if other != peer:
@@ -683,7 +769,12 @@ static func parse_address(a: String) -> Array:
return [a, DEFAULT_PORT]
## Connects to a server. The outcome arrives as `joined` or `join_failed`.
func join(address: String, password: String = "") -> void:
## `pass_` is a one-time pass from a crossing to another land (J3).
func join(address: String, password: String = "", pass_: String = "") -> void:
_pending_pass = pass_
if pass_.is_empty():
_hub_address = address.strip_edges()
_travel = {}
if state != "offline":
stop()
var me := get_identity()
@@ -705,11 +796,13 @@ func join(address: String, password: String = "") -> void:
_reject_reason = ""
_pending_password = password
_join_left = JOIN_TIMEOUT
me.last_server = address.strip_edges()
me.save_profile()
if pass_.is_empty():
me.last_server = address.strip_edges() # (always the hub's: rejoining sends you on)
me.save_profile()
_set_state("connecting")
var _pending_password := ""
var _pending_pass := ""
func leave() -> void:
var was := state
@@ -722,7 +815,7 @@ func leave() -> void:
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})
"appearance": me.appearance, "public_key": me.public_pem, "password": _pending_password, "pass": _pending_pass})
@rpc("authority", "call_remote", "reliable")
func _s2c_challenge(nonce: PackedByteArray) -> void:
@@ -745,6 +838,10 @@ func _s2c_welcome(d: Dictionary) -> void:
_set_state("connected")
joined.emit()
players_changed.emit()
if not _travel.is_empty():
var l := String(_travel.land)
_travel = {}
arrived.emit(l)
@rpc("authority", "call_remote", "reliable")
func _s2c_rejected(reason: String) -> void:
@@ -807,10 +904,17 @@ func _run_queue(q: Array) -> void:
f.call()
func _fail(reason: String) -> void:
stop_hosting() # (hosting and couldn't get in: don't leave the server running)
var crossing := not _travel.is_empty()
_travel = {}
if not crossing:
stop_hosting() # (hosting and couldn't get in: don't leave the server running)
stop()
last_reason = reason
join_failed.emit(reason)
if crossing:
# (The world we were in is still on screen: it goes back to the title.)
last_reason = "Couldn't get through to the next land (%s). Join again to carry on." % reason
left.emit(last_reason)
func _on_connection_failed() -> void:
_fail("Couldn't reach the server")
@@ -818,6 +922,10 @@ func _on_connection_failed() -> void:
func _on_server_disconnected() -> void:
var was := state
var reason := _reject_reason
if not _travel.is_empty():
if was == "connecting":
_fail(reason if not reason.is_empty() else "the server hung up") # (the next land wouldn't have us)
return # (the land we're leaving letting us go)
stop_hosting()
stop()
if was == "connecting":

View File

@@ -79,7 +79,15 @@ func server_join(peer: int, id: String, display: String) -> Vector3:
p.load_data(d)
var t: Node3D = WorldNet.world.terrain
var pos := Vector3(d.pos[0], d.pos[1], d.pos[2]) if d.has("pos") else Vector3.INF
if pos.is_finite() and t.inside_boundary(pos.x, pos.z, 1.0) and pos.y > t.sample_height(pos.x, pos.z) - 1.0:
var here_ := String(d.get("land", Lands.here())) == Lands.here()
if here_ and d.has("arrive"):
# Just crossed over: where the way in comes out (J3).
pos = WorldNet.world.arrival_point(String(d.arrive))
elif not here_:
pos = Vector3.INF # (saved somewhere else: start at this land's start)
var ok_spot: bool = pos.is_finite() and (WorldNet.world.underground_at(pos) \
or (t.inside_boundary(pos.x, pos.z, 1.0) and pos.y > t.sample_height(pos.x, pos.z) - 1.0))
if ok_spot:
at = pos
print("[server] %s's character loaded" % display)
if at != Vector3.INF:
@@ -127,10 +135,54 @@ func _save(p: Node3D) -> void:
if f == null:
printerr("[server] can't save %s's character" % p.display)
return
f.store_string(JSON.stringify(p.to_data(), "\t"))
var d: Dictionary = p.to_data()
# Which land the character's in (J3: characters are shared by every land
# of the world); heading somewhere, it's there they'll turn up next.
d["land"] = String(p.get_meta("heading", Lands.here()))
if p.has_meta("arrive"):
d["arrive"] = String(p.get_meta("arrive"))
f.store_string(JSON.stringify(d, "\t"))
f.close()
DirAccess.rename_absolute(tmp, path)
## Which land player `id`'s character is in ("" = not saved yet, or here).
func land_of(id: String) -> String:
return String(_read(_file(id)).get("land", ""))
## The hub: player `id` (at `peer`, not let in yet) left their character in
## land `l`: start its server if need be and send them on there.
func send_on(peer: int, id: String, l: String) -> void:
print("[server] %s's character is in the %s: sending them on" % [id.substr(0, 8), l])
Lands.ensure(l, func(why: String):
if not why.is_empty():
Net._reject(peer, "The %s isn't open right now (%s)" % [l, why])
return
Net.send_travel(peer, l, Lands.pass_to(l, id)))
## A crossing, on the server (the tunnel's sea cave, the cave back up): the
## character is saved as heading for `to`, arriving at `arrive`, and their
## game is sent on (to a land server, or back to the hub).
func cross(peer: int, to: String, arrive: String) -> void:
var p: Node3D = proxies.get(peer)
if p == null or p.has_meta("heading"):
return
p.set_meta("heading", to)
p.set_meta("arrive", arrive)
_save(p)
var id: String = p.player_id
print("[server] %s crosses to the %s" % [p.display, to])
if to == Lands.home() and not Lands.land.is_empty():
Net.send_travel(peer, to, Lands.pass_home(id))
return
Lands.ensure(to, func(why: String):
if not why.is_empty():
p.remove_meta("heading")
p.remove_meta("arrive")
_save(p)
tell(peer, "The way's blocked for now (%s)" % why)
return
Net.send_travel(peer, to, Lands.pass_to(to, id)))
static func _read(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {}
@@ -300,8 +352,42 @@ func _run(p: Node3D, what: String, a: Array) -> void:
"hit":
var n: Node3D = w.props.resource_nodes.get(_s(a, 0))
var at: Vector3 = _v(a, 1)
if n != null and is_instance_valid(n) and at.is_finite() and Actions.harvest_hit(p, n, at):
if n == null and w.tunnel != null:
var f: Node3D = w.tunnel.face_by_uid(_s(a, 0)) # (the cliff tunnel's seam or face)
if f != null and at.is_finite():
Actions.harvest_hit(p, f, at) # (the tunnel tells everyone: tunnel.gd)
elif n != null and is_instance_valid(n) and at.is_finite() and Actions.harvest_hit(p, n, at):
WorldNet.node_hit(n, at, p.global_position)
"tunnel_prop":
if w.tunnel != null:
w.tunnel.set_prop(p, _i(a, 0))
"tunnel_go":
# Down into the cliff tunnel, or back out at the top: a teleport
# the server makes (the tunnel's under the island), from nearby.
var t: Node3D = w.tunnel
if t == null or not t.opened:
return
var go_in := _s(a, 0) == "in"
var from: Vector3 = t.seam_out if go_in else t.top
if p.global_position.distance_to(from) > 4.5:
return
if go_in:
t.go_in(p)
else:
t.go_out(p)
Net.teleport(p.peer, p.global_position)
_s2c_respawn.rpc_id(p.peer, p.global_position, p.rotation.y, "")
"cross":
# The sea cave's mouth (the Valley), or the sea cave (the Cove).
var where := _s(a, 0)
if where == "tunnel" and w.tunnel != null and w.tunnel.dug >= w.tunnel.STRETCHES \
and p.global_position.distance_to(w.tunnel.cave_mouth) < 4.0:
cross(p.peer, w.tunnel.to, "sea_cave")
elif where == "sea_cave" and w.sea_cave != null and w.sea_cave.open() \
and p.global_position.distance_to(w.sea_cave.front) < 6.0:
cross(p.peer, w.sea_cave.to, "tunnel_bottom")
elif where == "sea_cave" and w.sea_cave != null:
tell(p.peer, w.sea_cave.interact_prompt(p))
"pickup":
var key := _s(a, 0)
var pk: Node3D = WorldNet.find_pickup(key)
@@ -456,6 +542,12 @@ func _run(p: Node3D, what: String, a: Array) -> void:
w.day_night.apply(true)
w.weather.snap()
Net.push_clock()
"debug_tunnel":
# (tests) dig the cliff tunnel to `n` stretches, all propped.
if cheats and w.tunnel != null:
var n := clampi(_i(a, 0), 0, w.tunnel.STRETCHES)
w.tunnel.load_data({"opened": true, "dug": n, "props": range(1, n + 1)})
w.tunnel._tell_everyone()
"debug_tp":
var to := _v(a, 0)
if cheats and to.is_finite():

View File

@@ -369,6 +369,20 @@ func _s2c_node(uid: String, hp: float, point: Vector3, from: Vector3) -> void:
func stones_state(d: Dictionary) -> void:
_all("_s2c_stones", [d])
## The cliff tunnel changed (a blow on its face, a stretch dug, a prop set):
## everyone here sees it, and the other lands hear how far it's dug.
func tunnel_state(d: Dictionary) -> void:
_all("_s2c_tunnel", [d])
mark_changed()
if Lands.hub:
Lands.push_tunnel(int(d.get("dug", 0)))
@rpc("authority", "call_remote", "reliable")
func _s2c_tunnel(d: Dictionary) -> void:
if not is_client() or world.tunnel == null:
return
world.tunnel.load_data(d)
@rpc("authority", "call_remote", "reliable")
func _s2c_stones(d: Dictionary) -> void:
if not is_client() or world.stones == null: