diff --git a/docs/WORLDLETS_DESIGN.md b/docs/WORLDLETS_DESIGN.md index 670e42e..8136097 100644 --- a/docs/WORLDLETS_DESIGN.md +++ b/docs/WORLDLETS_DESIGN.md @@ -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-port `, same config and world folder, output + to `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/.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, diff --git a/project.godot b/project.godot index add9fcb..2b1a586 100644 --- a/project.godot +++ b/project.godot @@ -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" diff --git a/scripts/core/saves.gd b/scripts/core/saves.gd index 9a4ddd9..53ba7d0 100644 --- a/scripts/core/saves.gd +++ b/scripts/core/saves.gd @@ -102,8 +102,14 @@ func read_meta(id: String) -> Dictionary: # saves are unchanged; the others are worldlets/.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). diff --git a/scripts/net/lands.gd b/scripts/net/lands.gd new file mode 100644 index 0000000..3d866a8 --- /dev/null +++ b/scripts/net/lands.gd @@ -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//: 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", "")) + 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-.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 diff --git a/scripts/net/lands.gd.uid b/scripts/net/lands.gd.uid new file mode 100644 index 0000000..18da6fb --- /dev/null +++ b/scripts/net/lands.gd.uid @@ -0,0 +1 @@ +uid://cl2iw0a328ja8 diff --git a/scripts/net/net.gd b/scripts/net/net.gd index 1bf36bb..ffc1dc7 100644 --- a/scripts/net/net.gd +++ b/scripts/net/net.gd @@ -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": diff --git a/scripts/net/player_net.gd b/scripts/net/player_net.gd index 408c516..5f79867 100644 --- a/scripts/net/player_net.gd +++ b/scripts/net/player_net.gd @@ -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(): diff --git a/scripts/net/world_net.gd b/scripts/net/world_net.gd index 402f905..339261a 100644 --- a/scripts/net/world_net.gd +++ b/scripts/net/world_net.gd @@ -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: diff --git a/scripts/world/sea_cave.gd b/scripts/world/sea_cave.gd index cc59026..cc20e74 100644 --- a/scripts/world/sea_cave.gd +++ b/scripts/world/sea_cave.gd @@ -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) diff --git a/scripts/world/terrain.gd b/scripts/world/terrain.gd index 6e786a0..621e173 100644 --- a/scripts/world/terrain.gd +++ b/scripts/world/terrain.gd @@ -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) diff --git a/scripts/world/tunnel.gd b/scripts/world/tunnel.gd index a7909d6..8896fe5 100644 --- a/scripts/world/tunnel.gd +++ b/scripts/world/tunnel.gd @@ -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 --------------------------------------------------------------- diff --git a/scripts/world/world.gd b/scripts/world/world.gd index a2155a1..92126cc 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -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 diff --git a/tests/run_all.sh b/tests/run_all.sh index d429a09..471baeb 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -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, diff --git a/tests/test_net_lands.gd b/tests/test_net_lands.gd new file mode 100644 index 0000000..ff78e13 --- /dev/null +++ b/tests/test_net_lands.gd @@ -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) diff --git a/tests/test_net_lands.gd.uid b/tests/test_net_lands.gd.uid new file mode 100644 index 0000000..6bb5a8d --- /dev/null +++ b/tests/test_net_lands.gd.uid @@ -0,0 +1 @@ +uid://dh6t6lxjmhj8m diff --git a/tests/test_tunnel.gd b/tests/test_tunnel.gd index c44da64..0e2db5d 100644 --- a/tests/test_tunnel.gd +++ b/tests/test_tunnel.gd @@ -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)") diff --git a/tools/site/guide_sections.md b/tools/site/guide_sections.md index dbb8a5e..005f715 100644 --- a/tools/site/guide_sections.md +++ b/tools/site/guide_sections.md @@ -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