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

View File

@@ -1,7 +1,44 @@
# Worldlets design
Status: **W0-W3 and J1 built** (the Cove without its stream yet; the
tunnel single-player only); the rest is planned.
Status: **W0-W3, J1 and J3a built**: the Cove (with its stream), the cliff
tunnel, and the journey on a server (a server per land; chat and the
player list across lands are J3b). The rest is planned.
*As built in J3a (a server per land):*
- `scripts/net/lands.gd` (autoload `Lands`): the server players join is the
hub. `Lands.ensure(land)` starts a land server (`--server --land cove
--port <hub+1..> --hub-port <hub>`, same config and world folder, output
to `land-<land>.log` beside server.json) and waits for it to say it's
listening. Hub and land servers talk over TCP on 127.0.0.1, port hub +
100, one JSON object per line: hello/ready, the player count, the clock
(the hub's, every 10 s: one clock everywhere), how far the tunnel's dug,
one-time passes, "stop", "host_left". A land server stops after 2 minutes
empty, when the hub stops, or if it loses the hub.
- A land server's saves: `Saves.land` makes it load and save its own
worldlet's file (`worldlets/cove.json`) in the hub's world folder.
Characters (`players/<id>.json`) are shared, and say which land they're
in (`land`) and, mid-crossing, where they'll come out (`arrive`).
- Crossing (`PlayerNet.cross`): the server saves the character as heading
there, makes sure the other server's up, and sends the game
`_s2c_travel {land, port, pass}`; the game drops the connection quietly
and joins that port with the pass (`Net.join(address, "", pass)`), checked
after the identity proof: a land server lets nobody in without one. The
world reloads on `Net.arrived`. Joining the hub when your character's in
another land sends you on there; a crossing that fails goes back to the
title. A hosted game: the host crossing doesn't stop the hub; the host
leaving the game from another land stops everything.
- The tunnel online: built on the server and in every game. The server
decides blows on its faces (`"hit"` finds them by uid), props
(`"tunnel_prop"`), going in and out (`"tunnel_go"`: a server teleport;
the move check allows the tunnel only from inside it) and the crossings
(`"cross"`), and tells everyone how it stands (`_s2c_tunnel`; it's in the
shared state a joining player gets, and saved with the Valley).
- `tests/test_net_lands.gd`: Alice digs and props online, lights it,
crosses to the Cove (bag and day kept) while Bob stays in the Valley,
comes back up, then leaves from the Cove and rejoining sends her there.
- Next, J3b: chat, the player list (with where each player is) and admin
commands across lands; sleeping needs everyone in every land. J3c: a land
server crashing, the Host page's extra ports.
*As built in J1:*
- `scripts/world/tunnel.gd`: the seam (an outcrop on the clifftop strip,

View File

@@ -31,6 +31,7 @@ Hints="*res://scripts/core/hints.gd"
Updates="*res://scripts/core/updates.gd"
Saves="*res://scripts/core/saves.gd"
Net="*res://scripts/net/net.gd"
Lands="*res://scripts/net/lands.gd"
WorldNet="*res://scripts/net/world_net.gd"
PlayerNet="*res://scripts/net/player_net.gd"
CreatureNet="*res://scripts/net/creature_net.gd"

View File

@@ -102,8 +102,14 @@ func read_meta(id: String) -> Dictionary:
# saves are unchanged; the others are worldlets/<id>.json. meta.json's
# "worldlet" is where you are now, so that's the one loaded.
## A land server (J3, `--land cove`) runs one worldlet of a server's world:
## this is that worldlet ("" = whatever the world's meta says).
var land := ""
## The worldlet world `id` is being played in now.
func worldlet_of(id: String) -> String:
if not land.is_empty():
return land
return String(read_meta(id).get("worldlet", "valley"))
## The worldlet world `id` began in (its world.json).

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:

View File

@@ -51,6 +51,11 @@ func place(w: Node3D) -> bool:
## Whether the Valley's tunnel has been dug right down to here.
func open() -> bool:
var full: int = preload("res://scripts/world/tunnel.gd").STRETCHES
if not Lands.land.is_empty():
return Lands.tunnel_dug >= full # (a land server: the hub says how far it's dug)
if world.is_online():
return int(Net.world_info.get("tunnel_dug", 0)) >= full # (as the server said when we came in)
if not Saves.has_world():
return false
var s: Dictionary = Saves.load_state(Saves.current_id, to)

View File

@@ -88,7 +88,7 @@ func _ready() -> void:
elif saves != null and saves.has_world():
noise_seed = saves.world_seed()
# (worlds from before worldlets have none: they're the Valley)
worldlet_id = String(saves.read_meta(saves.current_id).get("worldlet", "valley"))
worldlet_id = String(saves.worldlet_of(saves.current_id)) # (a land server's own land, or the world's)
var why := use_worldlet(worldlet_id)
if not why.is_empty():
push_error("[terrain] %s: using the Valley" % why)

View File

@@ -143,8 +143,20 @@ func dig_refusal(stage: int) -> String:
return "It's loose: prop the last stretch first ([E] at its mouth, %d wood)" % PROP_WOOD
return ""
## The face called `uid` (what a player's game asks the server to hit).
func face_by_uid(uid: String) -> Node3D:
return _face if _face != null and is_instance_valid(_face) and String(_face.uid) == uid and not _face.depleted else null
## On a server, everyone playing (and the other lands) hears how the tunnel
## stands after each change.
func _tell_everyone() -> void:
if Net.is_server():
WorldNet.tunnel_state(to_data())
func face_hit(f: Node) -> void:
face_hp = f.hp
if not f.depleted:
_tell_everyone()
func face_broken(f: Node) -> void:
face_hp = FACE_HP
@@ -158,9 +170,14 @@ func face_broken(f: Node) -> void:
else:
Actions.notify.emit("The rock gives. Prop this stretch before you dig on")
_rebuild.call_deferred()
_tell_everyone()
## [E] at the mouth of stretch `k`: sets a timber prop there.
## [E] at the mouth of stretch `k`: sets a timber prop there. (Online, your
## game asks the server, which sets it and tells everyone.)
func set_prop(player: Node, k: int) -> bool:
if world.is_online():
PlayerNet.ask("tunnel_prop", [k])
return true
if k < 1 or k > dug or props[k]:
return false
if player.inventory.count("wood") < PROP_WOOD:
@@ -170,14 +187,22 @@ func set_prop(player: Node, k: int) -> bool:
props[k] = true
Actions.notify.emit("Propped. The next face is safe to dig")
_rebuild.call_deferred()
_tell_everyone()
return true
# --- going in and out ----------------------------------------------------------
## (Online, your game asks the server, which moves you: player_net.gd.)
func go_in(player: Node3D) -> void:
if world.is_online() and player == world.player:
PlayerNet.ask("tunnel_go", ["in"])
return
_move(player, top + Vector3(0, 0.1, 0), Vector3(1, 0, 0))
func go_out(player: Node3D) -> void:
if world.is_online() and player == world.player:
PlayerNet.ask("tunnel_go", ["out"])
return
_move(player, seam_out + Vector3(0, 0.2, 0), -Vector3(seam_at.x, 0.0, seam_at.z).normalized())
func _move(player: Node3D, at: Vector3, facing: Vector3) -> void:
@@ -195,6 +220,7 @@ func to_data() -> Dictionary:
return {"opened": opened, "dug": dug, "face_hp": face_hp, "props": p}
func load_data(d: Dictionary) -> void:
var was := [opened, dug, props.duplicate()]
opened = bool(d.get("opened", false))
dug = clampi(int(d.get("dug", 0)), 0, STRETCHES)
face_hp = float(d.get("face_hp", FACE_HP))
@@ -202,6 +228,11 @@ func load_data(d: Dictionary) -> void:
for k in d.get("props", []):
if int(k) >= 1 and int(k) <= STRETCHES:
props[int(k)] = true
# (Online, a blow on the face only changes how much is left of it: no
# need to rebuild the rock for that.)
if was == [opened, dug, props] and _face != null and is_instance_valid(_face):
_face.hp = clampf(face_hp, 0.5, _face.max_hp)
return
_rebuild()
# --- building it ---------------------------------------------------------------

View File

@@ -366,8 +366,9 @@ func _on_terrain_ready() -> void:
else:
pr.free()
# The journey's ways on (J1): the Valley's cliff tunnel and the Cove's sea
# cave. Single-player for now (J3 brings travel online).
if not server_mode and not menu_mode and not is_online():
# cave. On a server too (J3): the server decides the digging and the
# crossings, and every player's game has the same rock to see and walk.
if not menu_mode:
var tn := preload("res://scripts/world/tunnel.gd").new()
tn.name = "Tunnel"
add_child(tn)
@@ -698,6 +699,7 @@ func _shared_state(harvested: Dictionary, regrow: Dictionary, taken: Array, drop
"bear": bears.save_data() if bears != null else _saved_bear,
"barrow": barrow.save_data() if barrow != null else {},
"stones": stones.save_data() if stones != null else {},
"tunnel": tunnel.to_data() if tunnel != null else {},
}
## The live pieces (placed and not being removed).
@@ -986,7 +988,15 @@ func underground_at(p: Vector3) -> bool:
## at `arrive` ("sea_cave", "tunnel_bottom"). Each worldlet keeps its own
## state file (Saves.state_path), so what you built here waits for you.
func travel(to: String, arrive: String) -> void:
if traveling or not Saves.has_world() or is_online() or server_mode:
if server_mode:
return # (on a server, the crossing is the player's game asking: player_net.gd "cross")
if is_online():
# The server checks, saves your character and sends you on (J3).
if not traveling and Time.get_ticks_msec() > _cross_asked + 3000:
_cross_asked = Time.get_ticks_msec()
PlayerNet.ask("cross", ["tunnel" if arrive == "sea_cave" else "sea_cave"])
return
if traveling or not Saves.has_world():
return
traveling = true
var id := Saves.current_id
@@ -1011,6 +1021,25 @@ func travel(to: String, arrive: String) -> void:
get_tree().change_scene_to_file.call_deferred(scene_file_path if not scene_file_path.is_empty() else "res://scenes/world.tscn")
signal travelled(to: String) ## saved and on the way to another worldlet
var _cross_asked := -100000 ## (online: when we last asked the server to cross)
## Online (J3): the server's sending us on to another land's server.
func _on_net_travelling(to: String) -> void:
traveling = true
player.set_physics_process(false)
if net_sync != null:
net_sync.set_process(false) # (nothing more to tell the land we're leaving,
net_sync.set_physics_process(false) # nor the next one about this one)
if hud != null:
hud.toast("On to the %s..." % Terrain.worldlet_name(to))
hud.fade(func(): pass)
## Online: in at the other land: the world reloads as that land (its terrain
## comes from what that server says, Net.world_info).
func _on_net_arrived(_to: String) -> void:
travelled.emit(_to)
if get_tree().current_scene == self:
get_tree().change_scene_to_file.call_deferred(scene_file_path if not scene_file_path.is_empty() else "res://scenes/world.tscn")
## Back in a land you've been to before: its clock comes forward to `to`
## ({day, time}, the clock where you've come from), and everything in it
@@ -1029,20 +1058,30 @@ func catch_up(to: Dictionary) -> float:
return secs
## Just arrived from another worldlet: put you where you came in.
## Where the way in called `at` puts you ("sea_cave", "tunnel_bottom"), or
## INF if this land has no such way in. (A server places crossing players
## here too: player_net.gd.)
func arrival_point(at: String) -> Vector3:
match at:
"sea_cave":
if sea_cave != null:
return sea_cave.front
"tunnel_bottom":
if tunnel != null and tunnel.dug >= tunnel.STRETCHES:
return tunnel.bottom + Vector3(0.8, 0.0, 0.0)
return Vector3.INF
func _arrive(at: String) -> void:
if at.is_empty():
return
var pos := Vector3.INF
var pos := arrival_point(at)
var facing := Vector3.FORWARD
match at:
"sea_cave":
if sea_cave != null:
pos = sea_cave.front
facing = (sea_cave.front - sea_cave.global_position).normalized()
"tunnel_bottom":
if tunnel != null and tunnel.dug >= tunnel.STRETCHES:
pos = tunnel.bottom + Vector3(0.8, 0.0, 0.0)
facing = Vector3(1, 0, 0)
facing = Vector3(1, 0, 0)
if pos == Vector3.INF:
push_warning("[world] nowhere to arrive at '%s': the start instead" % at)
_respawn()
@@ -1228,6 +1267,8 @@ func _go_online() -> void:
load_online_map() # (your map of this server, from your own computer)
Net.left.connect(func(_why = ""): save_online_map(), CONNECT_ONE_SHOT)
Net.left.connect(_on_net_left)
Net.travelling.connect(_on_net_travelling)
Net.arrived.connect(_on_net_arrived)
get_tree().auto_accept_quit = false # leave the server cleanly on window close
# The island's changes come from the server from now on.
props.regrow_enabled = false

View File

@@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then
echo "Importing the project first (fresh copy)..."
godot --headless --path . --import >/dev/null 2>&1
fi
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_host test_tutorial test_tutorial_full test_hints test_updates test_audio test_items_more test_worldlets test_worldlet_pick test_water_bodies test_cove test_tunnel test_map test_terrain_edges test_boundary; do
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_net_lands test_host test_tutorial test_tutorial_full test_hints test_updates test_audio test_items_more test_worldlets test_worldlet_pick test_water_bodies test_cove test_tunnel test_map test_terrain_edges test_boundary; do
extra=""
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
# A suite that waits on a world that failed to load would hang forever,

298
tests/test_net_lands.gd Normal file
View File

@@ -0,0 +1,298 @@
extends SceneTree
## J3a (docs/WORLDLETS_DESIGN.md: a server per land): the journey on a server.
##
## A real headless server (the hub, the Valley, with --debug-cheats), Alice
## (this test) and Bob (tests/net_world_bot.gd), who stays in the Valley.
## Alice digs the cliff tunnel's seam and first stretch online (the server
## decides, and her game sees the rock change), props it, goes down, lights
## it. With the tunnel dug through, she walks out of the sea cave's mouth:
## the hub starts the Cove's server, she's sent on there with a pass, and
## arrives on the Cove's shore with her bag, on the same day and hour, while
## Bob carries on in the Valley. Back up the sea cave to the hub; then out
## to the Cove again, leave the game, and join the hub again: she's sent
## straight on to the Cove, where she left off.
## Run: godot --headless --path . --script res://tests/test_net_lands.gd
const PORT := 24371
const DIR := "user://test_net"
const SRV := DIR + "/server_lands"
const BOBS := DIR + "/bob_lands"
const SEED := 1337
var _fails := 0
var _finished := false
var _pids: Array[int] = []
var Net: Node
var WN: Node
var PN: Node
var A: Node
var L: Node
var world: Node3D
var player: CharacterBody3D
var _bob_dir := ""
var _cmd := 0
var _toasts: Array[String] = []
var _arrived := ""
func check(cond: bool, what: String) -> void:
print((" ok " if cond else " FAIL ") + what)
if not cond:
_fails += 1
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
Engine.max_fps = 60
Net = root.get_node("Net")
WN = root.get_node("WorldNet")
PN = root.get_node("PlayerNet")
A = root.get_node("Actions")
L = root.get_node("Lands")
A.notify.connect(func(t): _toasts.append(t))
Net.arrived.connect(func(l): _arrived = l)
for d in [SRV, BOBS]:
_rm_tree(d)
_spawn(["--server", "--port", str(PORT), "--config", SRV + "/server.json",
"--server-name", "Lands Test", "--seed", str(SEED), "--debug-cheats", "--no-creatures"])
await _sleep(4.0)
await _body()
if world != null and is_instance_valid(world):
world.queue_free()
await process_frame
Net.leave()
await _sleep(0.5)
for pid in _pids:
if OS.is_process_running(pid):
OS.kill(pid)
check(_finished, "test ran to the end (no script errors)")
await _sleep(1.0)
if _fails == 0 or OS.get_environment("KEEP_SRV").is_empty():
_rm_tree(SRV)
_rm_tree(BOBS)
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
quit(1 if _fails > 0 else 0)
func _body() -> void:
var me = load("res://scripts/net/identity.gd").open(DIR + "/a_lands")
me.name = "Alice"
Net.identity = me
var r := await _join()
check(r == "", "Alice joins the hub (%s)" % r)
if r != "":
return
await _load_world()
check(world.worldlet_id() == "valley" and world.tunnel != null, "the Valley, with its cliff tunnel, online")
var b0 := await _new_bob()
check(bool(b0.get("ok", false)), "Bob joins too (%s)" % b0.get("why", "ok"))
PN.ask("debug_give", ["antler_pickaxe", 1])
PN.ask("debug_give", ["wood", 10])
PN.ask("debug_give", ["torch_stand", 2])
await _sleep(0.6)
_hold("antler_pickaxe")
await _sleep(0.3)
print("digging the seam, online")
var t: Node3D = world.tunnel
await _tp(t.seam_out + Vector3(0, 0.2, 0))
var seam: Node3D = t._face
check(seam != null and seam.stage == 0, "the seam, as the server sees it")
await _dig_face(30)
check(t.opened, "dug open: the server says so, and Alice's game has the opening")
t.go_in(player) # (asks the server, which moves her)
await _until(func(): return world.underground_at(player.global_position), 3.0)
check(world.underground_at(player.global_position), "down into the tunnel (the server takes her)")
await _dig_face(40)
check(t.dug == 1, "dug the first stretch (%d)" % t.dug)
t.set_prop(player, 1)
await _until(func(): return bool(t.props[1]), 3.0)
check(t.props[1], "propped it, online")
PN.ask("debug_tunnel", [t.STRETCHES])
await _until(func(): return t.dug == t.STRETCHES, 3.0)
check(t.dug == t.STRETCHES and t.props.count(true) >= t.STRETCHES - 1, "(the rest dug by the test's cheat: down to the sea cave)")
var slot := _slot("torch_stand")
var floor_at: Vector3 = t._mouth(2) + Vector3(0.9, 0, 0.5) # (on the landing, beside the way down)
await _tp(floor_at + Vector3(1.0, 0.1, 0))
A.place(player, slot, Transform3D(Basis(), floor_at), world)
await _until(func(): return world.all_pieces().any(func(p): return p.item_id == "torch_stand"), 3.0)
check(world.all_pieces().any(func(p): return p.item_id == "torch_stand" and world.underground_at(p.global_position)),
"a torch stand set down there, for everyone")
print("out to the Cove")
var day0: int = world.day_night.day
var stone0: int = player.inventory.count("stone")
await _tp(t.cave_mouth + Vector3(0.8, 0.2, 0))
_arrived = ""
world.travel("cove", "sea_cave") # (what walking into the mouth does)
await _until(func(): return not _arrived.is_empty(), 45.0)
check(_arrived == "cove", "the hub starts the Cove's server and sends her on (%s)" % (_arrived if not _arrived.is_empty() else "never arrived: %s" % [_toasts.slice(-3)]))
if _arrived != "cove":
return
check(FileAccess.file_exists(SRV + "/land-cove.log"), "(the Cove's server logs beside server.json)")
await _load_world()
check(world.worldlet_id() == "cove", "Alice is in the Cove")
check(world.sea_cave != null and player.global_position.distance_to(world.sea_cave.front) < 2.0,
"on its shore, by the sea cave (%.1f m off)" % player.global_position.distance_to(world.sea_cave.front))
check(player.inventory.count("antler_pickaxe") == 1 and player.inventory.count("stone") == stone0, "with her bag as it was")
check(world.day_night.day == day0, "the same day: one clock for every land")
check(world.sea_cave.open(), "the sea cave leads back up (the hub says the tunnel's through)")
var bob := await _bob({"do": "report"})
check(not bob.has("error") and not bob.is_empty(), "Bob carries on in the Valley, on the hub (%s)" % bob.get("error", "ok"))
print("back up to the Valley")
_arrived = ""
await _tp(world.sea_cave.front)
world.sea_cave.interact(player)
await _until(func(): return not _arrived.is_empty(), 30.0)
check(_arrived == "valley", "into the sea cave: back to the hub (%s)" % _arrived)
if _arrived != "valley":
return
await _load_world()
check(world.worldlet_id() == "valley" and world.underground_at(player.global_position)
and player.global_position.distance_to(world.tunnel.bottom) < 2.5, "arriving at the bottom of the tunnel")
print("leave from the Cove, and come back")
await _tp(world.tunnel.cave_mouth + Vector3(0.8, 0.2, 0))
_arrived = ""
world.travel("cove", "sea_cave")
await _until(func(): return not _arrived.is_empty(), 30.0)
await _load_world()
var spot: Vector3 = world.sea_cave.front + Vector3(4.0, 0.0, 2.0)
spot.y = world.terrain.sample_height(spot.x, spot.z) + 0.1
await _tp(spot)
world.queue_free()
await process_frame
world = null
Net.leave()
await _sleep(1.5)
_arrived = ""
r = await _join()
await _until(func(): return not _arrived.is_empty(), 30.0)
check(_arrived == "cove", "joining the hub again sends her straight on to the Cove (%s)" % _arrived)
if _arrived == "cove":
await _load_world()
check(world.worldlet_id() == "cove" and player.global_position.distance_to(spot) < 1.5,
"where she left off (%.1f m off)" % player.global_position.distance_to(spot))
_finished = true
# --- helpers -------------------------------------------------------------------------
## (Re)loads the world for whichever server we're on now.
func _load_world() -> void:
if world != null and is_instance_valid(world):
world.queue_free()
for _i in range(3):
await process_frame
var r0: int = WN.received
world = load("res://scenes/world.tscn").instantiate()
root.add_child(world)
for _i in range(8):
await physics_frame
player = world.player
player.set_physics_process(false)
await _until(func(): return WN.received > r0, 10.0)
await _sleep(0.3)
func _tp(at: Vector3) -> void:
PN.ask("debug_tp", [at])
await _until(func(): return player.global_position.distance_to(at) < 0.5, 3.0)
await _sleep(0.2)
## Swings at the tunnel's face (whichever it is now) until it gives.
func _dig_face(limit: int) -> void:
var t: Node3D = world.tunnel
var stage0: int = t.dug if t.opened else -1
for _i in range(limit):
var f: Node3D = t._face
if f == null or not is_instance_valid(f) or (t.dug if t.opened else -1) != stage0:
break
PN.ask("debug_stats", [{"stamina": 100.0}])
A.harvest_hit(player, f, f.global_position)
await _sleep(0.12)
await _until(func(): return (t.dug if t.opened else -1) != stage0, 3.0)
await _sleep(0.3)
func _slot(id: String) -> int:
for i in range(player.inventory.size()):
var s = player.inventory.get_slot(i)
if s != null and s.id == id:
return i
return -1
func _hold(id: String) -> void:
var i := _slot(id)
if i >= 0:
player.select(i)
PN.ask("select", [i])
func _join() -> String:
var state := {"done": false, "reason": ""}
var ok := func(): state.done = true
var bad := func(why): state.done = true; state.reason = why
Net.joined.connect(ok)
Net.join_failed.connect(bad)
Net.join("127.0.0.1:%d" % PORT)
var t := 0.0
while not state.done and t < 25.0:
await _sleep(0.05)
t += 0.05
Net.joined.disconnect(ok)
Net.join_failed.disconnect(bad)
return state.reason if state.done else "test timed out"
func _new_bob() -> Dictionary:
_bob_dir = "%s/b1" % BOBS
DirAccess.make_dir_recursive_absolute(_bob_dir)
_cmd = 0
_spawn(["--identity", DIR + "/bob_lands_id", "--name", "Bob", "--address", "127.0.0.1:%d" % PORT, "--dir", _bob_dir],
"res://tests/net_world_bot.gd")
return await _bob_result(0, 30.0)
func _bob(cmd: Dictionary) -> Dictionary:
_cmd += 1
var f := FileAccess.open("%s/cmd_%d.json" % [_bob_dir, _cmd], FileAccess.WRITE)
f.store_string(JSON.stringify(cmd))
f.close()
return await _bob_result(_cmd, 20.0)
func _bob_result(n: int, limit: float) -> Dictionary:
var path := "%s/res_%d.json" % [_bob_dir, n]
var t := 0.0
while not FileAccess.file_exists(path) and t < limit:
await _sleep(0.05)
t += 0.05
await _sleep(0.05)
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) if FileAccess.file_exists(path) else null
return d if d is Dictionary else {"error": "Bob gave no answer to command %d" % n}
func _spawn(user_args: Array, script: String = "") -> int:
var args := PackedStringArray(["--headless", "--path", ProjectSettings.globalize_path("res://")])
if not script.is_empty():
args.append_array(["--script", script])
args.append("--")
for x in user_args:
args.append(str(x))
args.append_array(["--exit-with", str(OS.get_process_id())])
var pid := OS.create_process(OS.get_executable_path(), args)
_pids.append(pid)
return pid
func _until(cond: Callable, limit: float) -> bool:
var t := 0.0
while not cond.call() and t < limit:
await _sleep(0.05)
t += 0.05
return cond.call()
func _sleep(s: float) -> void:
await create_timer(s).timeout
static func _rm_tree(d: String) -> void:
var da := DirAccess.open(d)
if da == null:
return
for sub in da.get_directories():
_rm_tree(d.path_join(sub))
for f in da.get_files():
da.remove(f)
DirAccess.remove_absolute(d)

View File

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

View File

@@ -235,6 +235,18 @@ func _body() -> void:
check(player.inventory.count("stone") == stone0 and player.inventory.count("antler_pickaxe") == 1, "with your bag as it was")
check(w.day_night.day == day0, "on the same day")
check(w.tunnel == null, "(the Cove has no tunnel of its own: it's the Valley's)")
# Save and quit in the Cove, then load the world again from the menu.
print("save and quit in the Cove")
var spot: Vector3 = w.sea_cave.front + Vector3(5.0, 0.0, 3.0)
spot.y = w.terrain.sample_height(spot.x, spot.z) + 0.1
player.global_position = spot
w.save_now()
await _drop_world(w)
check(S.list_worlds().any(func(m): return String(m.get("worldlet", "")) == "cove"), "the Load list has the world in the Cove")
w = await _load_world()
player = w.player
check(w.worldlet_id() == "cove", "loading it again: still in the Cove")
check(player.global_position.distance_to(spot) < 1.0, "right where you saved (%.1f m off)" % player.global_position.distance_to(spot))
print("back up home")
check(w.sea_cave.open(), "the sea cave leads back up (the tunnel's dug)")

View File

@@ -332,7 +332,7 @@ It doesn't run and ignores fire. If dawn comes, or everyone leaves or dies, it f
## The journey {#journey}
The Valley is only the start. Beyond it lie other lands, each reached a different way, and each with its own ground, weather and creatures. The first is the **Cove**, down at the foot of the sea cliffs. For now the journey is single-player; on a server everyone stays in the Valley.
The Valley is only the start. Beyond it lie other lands, each reached a different way, and each with its own ground, weather and creatures. The first is the **Cove**, down at the foot of the sea cliffs. On a server, players can be in different lands at once: the tunnel and its lights are shared, and walking out to the Cove takes you on to the Cove's own server (started for you, on the server's next UDP port: 24243 if the server's on 24242). Friends playing over the internet need that port forwarded too. Leave from the Cove and you'll be back there next time you join.
### The cliff tunnel