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

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

View File

@@ -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":