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:
@@ -1,7 +1,44 @@
|
|||||||
# Worldlets design
|
# Worldlets design
|
||||||
|
|
||||||
Status: **W0-W3 and J1 built** (the Cove without its stream yet; the
|
Status: **W0-W3, J1 and J3a built**: the Cove (with its stream), the cliff
|
||||||
tunnel single-player only); the rest is planned.
|
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:*
|
*As built in J1:*
|
||||||
- `scripts/world/tunnel.gd`: the seam (an outcrop on the clifftop strip,
|
- `scripts/world/tunnel.gd`: the seam (an outcrop on the clifftop strip,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ Hints="*res://scripts/core/hints.gd"
|
|||||||
Updates="*res://scripts/core/updates.gd"
|
Updates="*res://scripts/core/updates.gd"
|
||||||
Saves="*res://scripts/core/saves.gd"
|
Saves="*res://scripts/core/saves.gd"
|
||||||
Net="*res://scripts/net/net.gd"
|
Net="*res://scripts/net/net.gd"
|
||||||
|
Lands="*res://scripts/net/lands.gd"
|
||||||
WorldNet="*res://scripts/net/world_net.gd"
|
WorldNet="*res://scripts/net/world_net.gd"
|
||||||
PlayerNet="*res://scripts/net/player_net.gd"
|
PlayerNet="*res://scripts/net/player_net.gd"
|
||||||
CreatureNet="*res://scripts/net/creature_net.gd"
|
CreatureNet="*res://scripts/net/creature_net.gd"
|
||||||
|
|||||||
@@ -102,8 +102,14 @@ func read_meta(id: String) -> Dictionary:
|
|||||||
# saves are unchanged; the others are worldlets/<id>.json. meta.json's
|
# saves are unchanged; the others are worldlets/<id>.json. meta.json's
|
||||||
# "worldlet" is where you are now, so that's the one loaded.
|
# "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.
|
## The worldlet world `id` is being played in now.
|
||||||
func worldlet_of(id: String) -> String:
|
func worldlet_of(id: String) -> String:
|
||||||
|
if not land.is_empty():
|
||||||
|
return land
|
||||||
return String(read_meta(id).get("worldlet", "valley"))
|
return String(read_meta(id).get("worldlet", "valley"))
|
||||||
|
|
||||||
## The worldlet world `id` began in (its world.json).
|
## The worldlet world `id` began in (its world.json).
|
||||||
|
|||||||
375
scripts/net/lands.gd
Normal file
375
scripts/net/lands.gd
Normal 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
1
scripts/net/lands.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cl2iw0a328ja8
|
||||||
@@ -191,6 +191,10 @@ func start_server(config_path: String, overrides: Dictionary = {}) -> String:
|
|||||||
if overrides.has(flag):
|
if overrides.has(flag):
|
||||||
var v: Variant = overrides[flag]
|
var v: Variant = overrides[flag]
|
||||||
config.overrides[map[flag]] = int(v) if map[flag] in ["port", "max_players", "seed"] else String(v)
|
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
|
# The shared island (world_net.gd): the same terrain and scenery every
|
||||||
# client generates from the seed, plus everything players changed.
|
# client generates from the seed, plus everything players changed.
|
||||||
_world = WorldNet.open_server_world(config)
|
_world = WorldNet.open_server_world(config)
|
||||||
@@ -211,8 +215,10 @@ func start_server(config_path: String, overrides: Dictionary = {}) -> String:
|
|||||||
players.clear()
|
players.clear()
|
||||||
_pending.clear()
|
_pending.clear()
|
||||||
_set_state("server")
|
_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 "",
|
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)])
|
ProjectSettings.globalize_path(config.path)])
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -231,6 +237,8 @@ func stop() -> void:
|
|||||||
local_peer = 0
|
local_peer = 0
|
||||||
if _world != null:
|
if _world != null:
|
||||||
WorldNet.close_server_world() # saves it first
|
WorldNet.close_server_world() # saves it first
|
||||||
|
Lands.server_stopped() # (and the hub's land servers with it)
|
||||||
|
Saves.land = ""
|
||||||
_world = null
|
_world = null
|
||||||
_terrain = null
|
_terrain = null
|
||||||
if state != "offline":
|
if state != "offline":
|
||||||
@@ -252,7 +260,8 @@ func _on_peer_disconnected(id: int) -> void:
|
|||||||
_s2c_player_left.rpc(id)
|
_s2c_player_left.rpc(id)
|
||||||
PlayerNet.server_leave(id) # saves their character
|
PlayerNet.server_leave(id) # saves their character
|
||||||
WorldNet.peer_left(id)
|
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()
|
_host_left.call_deferred()
|
||||||
players_changed.emit()
|
players_changed.emit()
|
||||||
|
|
||||||
@@ -282,10 +291,12 @@ func _process(delta: float) -> void:
|
|||||||
## it every few seconds.
|
## it every few seconds.
|
||||||
func _tick_clock(delta: float) -> void:
|
func _tick_clock(delta: float) -> void:
|
||||||
_clock_left -= delta
|
_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
|
_clock_left = CLOCK_INTERVAL
|
||||||
for peer in players:
|
for peer in players:
|
||||||
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
|
_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) ------------------------------------------
|
# --- hosting from the title screen (M2.7) ------------------------------------------
|
||||||
# "Host Game" runs a dedicated server as a separate, windowless copy of the
|
# "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:
|
func _host_left() -> void:
|
||||||
print("[server] the host left: saving and stopping")
|
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()
|
get_tree().quit()
|
||||||
|
|
||||||
# --- admin (chat.gd) ---------------------------------------------------------------
|
# --- admin (chat.gd) ---------------------------------------------------------------
|
||||||
@@ -456,11 +469,58 @@ func _s2c_weather(state: String) -> void:
|
|||||||
|
|
||||||
signal weather_changed(state: String)
|
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).
|
## Everyone gets the clock now (after a night's sleep, an admin's /time).
|
||||||
func push_clock() -> void:
|
func push_clock() -> void:
|
||||||
_clock_left = CLOCK_INTERVAL
|
_clock_left = CLOCK_INTERVAL
|
||||||
for peer in players:
|
for peer in players:
|
||||||
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
|
_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.
|
## Where a player is, as far as the server knows.
|
||||||
func player_pos(peer: int) -> Vector3:
|
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]
|
return "too fast (%.0f m in %.2f s)" % [flat, dt]
|
||||||
elif to.y - from.y > MAX_RISE * dt + MOVE_SLACK:
|
elif to.y - from.y > MAX_RISE * dt + MOVE_SLACK:
|
||||||
return "rising too fast"
|
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):
|
if not _terrain.inside_boundary(to.x, to.z, -3.0):
|
||||||
return "outside the island"
|
return "outside the island"
|
||||||
if to.y < _terrain.sample_height(to.x, to.z) - 1.5:
|
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" % [
|
_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."])
|
Updates.version, PROTOCOL, theirs, "Update the game to join." if theirs < PROTOCOL else "The server needs updating."])
|
||||||
return
|
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"))
|
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")
|
_reject(peer, "Wrong password")
|
||||||
return
|
return
|
||||||
var nonce := Crypto.new().generate_random_bytes(32)
|
var nonce := Crypto.new().generate_random_bytes(32)
|
||||||
@@ -619,6 +687,23 @@ func _c2s_proof(sig: PackedByteArray) -> void:
|
|||||||
if not why.is_empty():
|
if not why.is_empty():
|
||||||
_reject(peer, why)
|
_reject(peer, why)
|
||||||
return
|
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)
|
_pending.erase(peer)
|
||||||
config.reserve_name(name, id)
|
config.reserve_name(name, id)
|
||||||
if config.data.admins.is_empty():
|
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.)
|
# (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,
|
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,
|
"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})
|
_s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world})
|
||||||
for other in players:
|
for other in players:
|
||||||
if other != peer:
|
if other != peer:
|
||||||
@@ -683,7 +769,12 @@ static func parse_address(a: String) -> Array:
|
|||||||
return [a, DEFAULT_PORT]
|
return [a, DEFAULT_PORT]
|
||||||
|
|
||||||
## Connects to a server. The outcome arrives as `joined` or `join_failed`.
|
## 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":
|
if state != "offline":
|
||||||
stop()
|
stop()
|
||||||
var me := get_identity()
|
var me := get_identity()
|
||||||
@@ -705,11 +796,13 @@ func join(address: String, password: String = "") -> void:
|
|||||||
_reject_reason = ""
|
_reject_reason = ""
|
||||||
_pending_password = password
|
_pending_password = password
|
||||||
_join_left = JOIN_TIMEOUT
|
_join_left = JOIN_TIMEOUT
|
||||||
me.last_server = address.strip_edges()
|
if pass_.is_empty():
|
||||||
me.save_profile()
|
me.last_server = address.strip_edges() # (always the hub's: rejoining sends you on)
|
||||||
|
me.save_profile()
|
||||||
_set_state("connecting")
|
_set_state("connecting")
|
||||||
|
|
||||||
var _pending_password := ""
|
var _pending_password := ""
|
||||||
|
var _pending_pass := ""
|
||||||
|
|
||||||
func leave() -> void:
|
func leave() -> void:
|
||||||
var was := state
|
var was := state
|
||||||
@@ -722,7 +815,7 @@ func leave() -> void:
|
|||||||
func _on_connected() -> void:
|
func _on_connected() -> void:
|
||||||
var me := get_identity()
|
var me := get_identity()
|
||||||
_c2s_hello.rpc_id(1, {"protocol": debug_protocol if debug_protocol != 0 else PROTOCOL, "name": me.name,
|
_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")
|
@rpc("authority", "call_remote", "reliable")
|
||||||
func _s2c_challenge(nonce: PackedByteArray) -> void:
|
func _s2c_challenge(nonce: PackedByteArray) -> void:
|
||||||
@@ -745,6 +838,10 @@ func _s2c_welcome(d: Dictionary) -> void:
|
|||||||
_set_state("connected")
|
_set_state("connected")
|
||||||
joined.emit()
|
joined.emit()
|
||||||
players_changed.emit()
|
players_changed.emit()
|
||||||
|
if not _travel.is_empty():
|
||||||
|
var l := String(_travel.land)
|
||||||
|
_travel = {}
|
||||||
|
arrived.emit(l)
|
||||||
|
|
||||||
@rpc("authority", "call_remote", "reliable")
|
@rpc("authority", "call_remote", "reliable")
|
||||||
func _s2c_rejected(reason: String) -> void:
|
func _s2c_rejected(reason: String) -> void:
|
||||||
@@ -807,10 +904,17 @@ func _run_queue(q: Array) -> void:
|
|||||||
f.call()
|
f.call()
|
||||||
|
|
||||||
func _fail(reason: String) -> void:
|
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()
|
stop()
|
||||||
last_reason = reason
|
last_reason = reason
|
||||||
join_failed.emit(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:
|
func _on_connection_failed() -> void:
|
||||||
_fail("Couldn't reach the server")
|
_fail("Couldn't reach the server")
|
||||||
@@ -818,6 +922,10 @@ func _on_connection_failed() -> void:
|
|||||||
func _on_server_disconnected() -> void:
|
func _on_server_disconnected() -> void:
|
||||||
var was := state
|
var was := state
|
||||||
var reason := _reject_reason
|
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_hosting()
|
||||||
stop()
|
stop()
|
||||||
if was == "connecting":
|
if was == "connecting":
|
||||||
|
|||||||
@@ -79,7 +79,15 @@ func server_join(peer: int, id: String, display: String) -> Vector3:
|
|||||||
p.load_data(d)
|
p.load_data(d)
|
||||||
var t: Node3D = WorldNet.world.terrain
|
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
|
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
|
at = pos
|
||||||
print("[server] %s's character loaded" % display)
|
print("[server] %s's character loaded" % display)
|
||||||
if at != Vector3.INF:
|
if at != Vector3.INF:
|
||||||
@@ -127,10 +135,54 @@ func _save(p: Node3D) -> void:
|
|||||||
if f == null:
|
if f == null:
|
||||||
printerr("[server] can't save %s's character" % p.display)
|
printerr("[server] can't save %s's character" % p.display)
|
||||||
return
|
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()
|
f.close()
|
||||||
DirAccess.rename_absolute(tmp, path)
|
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:
|
static func _read(path: String) -> Dictionary:
|
||||||
if not FileAccess.file_exists(path):
|
if not FileAccess.file_exists(path):
|
||||||
return {}
|
return {}
|
||||||
@@ -300,8 +352,42 @@ func _run(p: Node3D, what: String, a: Array) -> void:
|
|||||||
"hit":
|
"hit":
|
||||||
var n: Node3D = w.props.resource_nodes.get(_s(a, 0))
|
var n: Node3D = w.props.resource_nodes.get(_s(a, 0))
|
||||||
var at: Vector3 = _v(a, 1)
|
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)
|
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":
|
"pickup":
|
||||||
var key := _s(a, 0)
|
var key := _s(a, 0)
|
||||||
var pk: Node3D = WorldNet.find_pickup(key)
|
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.day_night.apply(true)
|
||||||
w.weather.snap()
|
w.weather.snap()
|
||||||
Net.push_clock()
|
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":
|
"debug_tp":
|
||||||
var to := _v(a, 0)
|
var to := _v(a, 0)
|
||||||
if cheats and to.is_finite():
|
if cheats and to.is_finite():
|
||||||
|
|||||||
@@ -369,6 +369,20 @@ func _s2c_node(uid: String, hp: float, point: Vector3, from: Vector3) -> void:
|
|||||||
func stones_state(d: Dictionary) -> void:
|
func stones_state(d: Dictionary) -> void:
|
||||||
_all("_s2c_stones", [d])
|
_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")
|
@rpc("authority", "call_remote", "reliable")
|
||||||
func _s2c_stones(d: Dictionary) -> void:
|
func _s2c_stones(d: Dictionary) -> void:
|
||||||
if not is_client() or world.stones == null:
|
if not is_client() or world.stones == null:
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ func place(w: Node3D) -> bool:
|
|||||||
|
|
||||||
## Whether the Valley's tunnel has been dug right down to here.
|
## Whether the Valley's tunnel has been dug right down to here.
|
||||||
func open() -> bool:
|
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():
|
if not Saves.has_world():
|
||||||
return false
|
return false
|
||||||
var s: Dictionary = Saves.load_state(Saves.current_id, to)
|
var s: Dictionary = Saves.load_state(Saves.current_id, to)
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func _ready() -> void:
|
|||||||
elif saves != null and saves.has_world():
|
elif saves != null and saves.has_world():
|
||||||
noise_seed = saves.world_seed()
|
noise_seed = saves.world_seed()
|
||||||
# (worlds from before worldlets have none: they're the Valley)
|
# (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)
|
var why := use_worldlet(worldlet_id)
|
||||||
if not why.is_empty():
|
if not why.is_empty():
|
||||||
push_error("[terrain] %s: using the Valley" % why)
|
push_error("[terrain] %s: using the Valley" % why)
|
||||||
|
|||||||
@@ -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 "It's loose: prop the last stretch first ([E] at its mouth, %d wood)" % PROP_WOOD
|
||||||
return ""
|
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:
|
func face_hit(f: Node) -> void:
|
||||||
face_hp = f.hp
|
face_hp = f.hp
|
||||||
|
if not f.depleted:
|
||||||
|
_tell_everyone()
|
||||||
|
|
||||||
func face_broken(f: Node) -> void:
|
func face_broken(f: Node) -> void:
|
||||||
face_hp = FACE_HP
|
face_hp = FACE_HP
|
||||||
@@ -158,9 +170,14 @@ func face_broken(f: Node) -> void:
|
|||||||
else:
|
else:
|
||||||
Actions.notify.emit("The rock gives. Prop this stretch before you dig on")
|
Actions.notify.emit("The rock gives. Prop this stretch before you dig on")
|
||||||
_rebuild.call_deferred()
|
_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:
|
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]:
|
if k < 1 or k > dug or props[k]:
|
||||||
return false
|
return false
|
||||||
if player.inventory.count("wood") < PROP_WOOD:
|
if player.inventory.count("wood") < PROP_WOOD:
|
||||||
@@ -170,14 +187,22 @@ func set_prop(player: Node, k: int) -> bool:
|
|||||||
props[k] = true
|
props[k] = true
|
||||||
Actions.notify.emit("Propped. The next face is safe to dig")
|
Actions.notify.emit("Propped. The next face is safe to dig")
|
||||||
_rebuild.call_deferred()
|
_rebuild.call_deferred()
|
||||||
|
_tell_everyone()
|
||||||
return true
|
return true
|
||||||
|
|
||||||
# --- going in and out ----------------------------------------------------------
|
# --- going in and out ----------------------------------------------------------
|
||||||
|
|
||||||
|
## (Online, your game asks the server, which moves you: player_net.gd.)
|
||||||
func go_in(player: Node3D) -> void:
|
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))
|
_move(player, top + Vector3(0, 0.1, 0), Vector3(1, 0, 0))
|
||||||
|
|
||||||
func go_out(player: Node3D) -> void:
|
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())
|
_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:
|
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}
|
return {"opened": opened, "dug": dug, "face_hp": face_hp, "props": p}
|
||||||
|
|
||||||
func load_data(d: Dictionary) -> void:
|
func load_data(d: Dictionary) -> void:
|
||||||
|
var was := [opened, dug, props.duplicate()]
|
||||||
opened = bool(d.get("opened", false))
|
opened = bool(d.get("opened", false))
|
||||||
dug = clampi(int(d.get("dug", 0)), 0, STRETCHES)
|
dug = clampi(int(d.get("dug", 0)), 0, STRETCHES)
|
||||||
face_hp = float(d.get("face_hp", FACE_HP))
|
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", []):
|
for k in d.get("props", []):
|
||||||
if int(k) >= 1 and int(k) <= STRETCHES:
|
if int(k) >= 1 and int(k) <= STRETCHES:
|
||||||
props[int(k)] = true
|
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()
|
_rebuild()
|
||||||
|
|
||||||
# --- building it ---------------------------------------------------------------
|
# --- building it ---------------------------------------------------------------
|
||||||
|
|||||||
@@ -366,8 +366,9 @@ func _on_terrain_ready() -> void:
|
|||||||
else:
|
else:
|
||||||
pr.free()
|
pr.free()
|
||||||
# The journey's ways on (J1): the Valley's cliff tunnel and the Cove's sea
|
# 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).
|
# cave. On a server too (J3): the server decides the digging and the
|
||||||
if not server_mode and not menu_mode and not is_online():
|
# 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()
|
var tn := preload("res://scripts/world/tunnel.gd").new()
|
||||||
tn.name = "Tunnel"
|
tn.name = "Tunnel"
|
||||||
add_child(tn)
|
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,
|
"bear": bears.save_data() if bears != null else _saved_bear,
|
||||||
"barrow": barrow.save_data() if barrow != null else {},
|
"barrow": barrow.save_data() if barrow != null else {},
|
||||||
"stones": stones.save_data() if stones != 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).
|
## 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
|
## at `arrive` ("sea_cave", "tunnel_bottom"). Each worldlet keeps its own
|
||||||
## state file (Saves.state_path), so what you built here waits for you.
|
## state file (Saves.state_path), so what you built here waits for you.
|
||||||
func travel(to: String, arrive: String) -> void:
|
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
|
return
|
||||||
traveling = true
|
traveling = true
|
||||||
var id := Saves.current_id
|
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")
|
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
|
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`
|
## 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
|
## ({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
|
return secs
|
||||||
|
|
||||||
## Just arrived from another worldlet: put you where you came in.
|
## 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:
|
func _arrive(at: String) -> void:
|
||||||
if at.is_empty():
|
if at.is_empty():
|
||||||
return
|
return
|
||||||
var pos := Vector3.INF
|
var pos := arrival_point(at)
|
||||||
var facing := Vector3.FORWARD
|
var facing := Vector3.FORWARD
|
||||||
match at:
|
match at:
|
||||||
"sea_cave":
|
"sea_cave":
|
||||||
if sea_cave != null:
|
if sea_cave != null:
|
||||||
pos = sea_cave.front
|
|
||||||
facing = (sea_cave.front - sea_cave.global_position).normalized()
|
facing = (sea_cave.front - sea_cave.global_position).normalized()
|
||||||
"tunnel_bottom":
|
"tunnel_bottom":
|
||||||
if tunnel != null and tunnel.dug >= tunnel.STRETCHES:
|
facing = Vector3(1, 0, 0)
|
||||||
pos = tunnel.bottom + Vector3(0.8, 0.0, 0.0)
|
|
||||||
facing = Vector3(1, 0, 0)
|
|
||||||
if pos == Vector3.INF:
|
if pos == Vector3.INF:
|
||||||
push_warning("[world] nowhere to arrive at '%s': the start instead" % at)
|
push_warning("[world] nowhere to arrive at '%s': the start instead" % at)
|
||||||
_respawn()
|
_respawn()
|
||||||
@@ -1228,6 +1267,8 @@ func _go_online() -> void:
|
|||||||
load_online_map() # (your map of this server, from your own computer)
|
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(func(_why = ""): save_online_map(), CONNECT_ONE_SHOT)
|
||||||
Net.left.connect(_on_net_left)
|
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
|
get_tree().auto_accept_quit = false # leave the server cleanly on window close
|
||||||
# The island's changes come from the server from now on.
|
# The island's changes come from the server from now on.
|
||||||
props.regrow_enabled = false
|
props.regrow_enabled = false
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then
|
|||||||
echo "Importing the project first (fresh copy)..."
|
echo "Importing the project first (fresh copy)..."
|
||||||
godot --headless --path . --import >/dev/null 2>&1
|
godot --headless --path . --import >/dev/null 2>&1
|
||||||
fi
|
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=""
|
extra=""
|
||||||
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
|
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
|
||||||
# A suite that waits on a world that failed to load would hang forever,
|
# A suite that waits on a world that failed to load would hang forever,
|
||||||
|
|||||||
298
tests/test_net_lands.gd
Normal file
298
tests/test_net_lands.gd
Normal 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)
|
||||||
1
tests/test_net_lands.gd.uid
Normal file
1
tests/test_net_lands.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dh6t6lxjmhj8m
|
||||||
@@ -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(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.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)")
|
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")
|
print("back up home")
|
||||||
check(w.sea_cave.open(), "the sea cave leads back up (the tunnel's dug)")
|
check(w.sea_cave.open(), "the sea cave leads back up (the tunnel's dug)")
|
||||||
|
|||||||
@@ -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 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
|
### The cliff tunnel
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user