J3c: a land server stopping tells its players and is started afresh when they rejoin; the extra ports to forward on the Host page, the hosting message, the server log and HOSTING.md

This commit is contained in:
Paul Bailey
2026-11-07 00:08:05 +00:00
parent 86ebe07899
commit f9b138b304
9 changed files with 108 additions and 12 deletions

View File

@@ -113,15 +113,22 @@ To check it works: `tailscale ping <server name>` from the friend's
computer. computer.
### Port forwarding ### Port forwarding
On your router, forward **UDP 24242** to the server computer's local On your router, forward **UDP 24242 and 24243** to the server computer's
address (give that computer a fixed local address first). Friends join local address (give that computer a fixed local address first). Friends
with your public IP address. It needs no extra software, but your home IP join with your public IP address. It needs no extra software, but your
and that port are public, so set a `password`. home IP and those ports are public, so set a `password`.
Why two: each land of the journey runs as its own server on the next port
up (24243 is the Cove's, when the server's on 24242). The server players
join (the Valley's) starts it when someone first goes there and stops it
when it's been empty a couple of minutes; everyone still joins on 24242.
Host Game and the server's log both say which ports to forward. With
Tailscale, or on the same Wi-Fi, there's nothing to do.
### A cloud server ### A cloud server
Any small Linux server works (it needs little: a couple of GB of memory). Any small Linux server works (it needs little: a couple of GB of memory).
Install Godot, clone the game, use the systemd file, and open UDP 24242 in Install Godot, clone the game, use the systemd file, and open UDP 24242
the provider's firewall. and 24243 in the provider's firewall.
## Troubleshooting ## Troubleshooting
- **"Different game version":** the server and the player are on different - **"Different game version":** the server and the player are on different
@@ -132,6 +139,11 @@ the provider's firewall.
- **"Port 24242 is already in use":** another server is running on this - **"Port 24242 is already in use":** another server is running on this
computer (a dedicated one, or a Host Game that didn't stop). Stop it, or computer (a dedicated one, or a Host Game that didn't stop). Stop it, or
host on another port. host on another port.
- **"Lost the connection to the Cove":** that land's server stopped (it
logs to `land-cove.log` beside `server.json`). Join again: the server
starts it afresh and sends you back there.
- **Stuck going on to the Cove from the internet:** UDP 24243 isn't
forwarded (see above).
- **The server says the game's scripts didn't load:** it's a fresh copy - **The server says the game's scripts didn't load:** it's a fresh copy
that hasn't been imported, or Godot is older than 4.7. `run_server.sh` that hasn't been imported, or Godot is older than 4.7. `run_server.sh`
handles both; see `docs/SETUP_ON_NEW_MACHINE.md`. handles both; see `docs/SETUP_ON_NEW_MACHINE.md`.

View File

@@ -56,8 +56,17 @@ player list across lands are J3b). The rest is planned.
asleep)" in chat. asleep)" in chat.
- `tests/test_net_lands.gd` covers it all, Alice in the Cove and Bob in the - `tests/test_net_lands.gd` covers it all, Alice in the Cove and Bob in the
Valley. Valley.
- Next, J3c: a land server crashing (its players back to the hub), and the
Host page's extra ports. *As built in J3c:*
- A land server that stops (crashed, or killed) tells its players "Lost the
connection to the Cove. Join the server again to carry on there."; the
hub notices it's gone (even mid-join) and joining again starts it afresh
and sends them back (their characters were saved by its autosave).
- The extra ports: `Lands.extra_ports()` / `ports_text()` ("UDP 24242 and
24243 (the Cove)"), on the Host page, in the "You're hosting" message, in
a hub's start-up log, and in docs/HOSTING.md.
- Tested in `test_net_lands.gd` (the Cove's server killed, Alice rejoins)
and `test_worldlet_pick.gd` (the Host page).
*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,

View File

@@ -65,6 +65,8 @@ func server_started(args: Dictionary) -> void:
if err != OK: 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]) 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 _link = null
elif not _order().is_empty():
print("[lands] other lands start on demand. Players over the internet need %s forwarded" % ports_text(home(), game_port()))
## Called as a server stops: stops every land server it started. ## Called as a server stops: stops every land server it started.
func server_stopped() -> void: func server_stopped() -> void:
@@ -109,13 +111,39 @@ func home() -> String:
## Every land after home, in the order the journey reaches them. ## Every land after home, in the order the journey reaches them.
func _order() -> Array: func _order() -> Array:
return journey_from(home())
## The lands a server whose home is `home` and whose port is `base` may run,
## after home: [[land, port]] (24243 for the Cove when the server's 24242).
## Friends playing over the internet need these forwarded too.
static func extra_ports(home_land: String, base: int) -> Array:
var out: Array = [] var out: Array = []
var at := home() var i := 1
for l in journey_from(home_land):
out.append([l, base + i])
i += 1
return out
## "UDP 24242 and 24243 (the Cove)": what to forward for a server on `base`.
static func ports_text(home_land: String, base: int) -> String:
var extra := extra_ports(home_land, base)
if extra.is_empty():
return "UDP %d" % base
var T: Script = load("res://scripts/world/terrain.gd")
var parts: Array[String] = []
for e in extra:
parts.append("%d (the %s)" % [int(e[1]), T.worldlet_name(String(e[0]))])
return "UDP %d and %s" % [base, ", ".join(parts)]
static func journey_from(home_land: String) -> Array:
var out: Array = []
var at := home_land
for _i in range(16): for _i in range(16):
var d: Dictionary = load("res://scripts/world/terrain.gd").load_worldlet(at) var d: Dictionary = load("res://scripts/world/terrain.gd").load_worldlet(at)
var nxt := "" var nxt := ""
for c in d.get("next", []): for c in d.get("next", []):
if c is Dictionary and not String(c.get("to", "")) in out and String(c.get("to", "")) != home(): if c is Dictionary and not String(c.get("to", "")) in out and String(c.get("to", "")) != home_land \
and not load("res://scripts/world/terrain.gd").load_worldlet(String(c.get("to", ""))).is_empty():
nxt = String(c.to) nxt = String(c.to)
break break
if nxt.is_empty(): if nxt.is_empty():
@@ -148,6 +176,9 @@ func ensure(l: String, then: Callable) -> void:
if not hub: if not hub:
then.call("this isn't the hub") then.call("this isn't the hub")
return return
if servers.has(l) and int(servers[l].pid) != 0 and not OS.is_process_running(int(servers[l].pid)):
print("[lands] the %s's server has gone: starting it again" % l)
servers.erase(l) # (crashed, and not noticed yet: start afresh)
if servers.has(l) and bool(servers[l].ready): if servers.has(l) and bool(servers[l].ready):
servers[l].idle = 0.0 servers[l].idle = 0.0
then.call("") then.call("")

View File

@@ -963,6 +963,7 @@ func _on_server_disconnected() -> void:
if was == "connecting": if was == "connecting":
_fail(reason if not reason.is_empty() else "the server hung up") # (the next land wouldn't have us) _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) return # (the land we're leaving letting us go)
var land_name := on_land()
stop_hosting() stop_hosting()
stop() stop()
if was == "connecting": if was == "connecting":
@@ -970,8 +971,19 @@ func _on_server_disconnected() -> void:
join_failed.emit(last_reason) join_failed.emit(last_reason)
else: else:
last_reason = reason if not reason.is_empty() else "The server closed the connection" last_reason = reason if not reason.is_empty() else "The server closed the connection"
if reason.is_empty() and not land_name.is_empty():
# (Another land's server went: the rest of the server's still up,
# and joining again starts that land's server afresh.)
last_reason = "Lost the connection to the %s. Join the server again to carry on there." % \
preload("res://scripts/world/terrain.gd").worldlet_name(land_name)
left.emit(last_reason) left.emit(last_reason)
## Client: the land we're in when it's not the server's home land (we're on
## one of its other lands' servers), else "".
func on_land() -> String:
var here := String(world_info.get("worldlet", ""))
return here if not here.is_empty() and here != String(server_info.get("worldlet", here)) else ""
func _notification(what: int) -> void: func _notification(what: int) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_PREDELETE: if what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_PREDELETE:
if _peer != null: if _peer != null:

View File

@@ -426,7 +426,9 @@ func _fill_host() -> void:
_host_seed_label.text = "Seed" if not existing else "Seed (kept)" _host_seed_label.text = "Seed" if not existing else "Seed (kept)"
_host_seed.text = "" if not existing else str(int(c.data.seed)) _host_seed.text = "" if not existing else str(int(c.data.seed))
_show_host_worldlet(existing, meta_path) _show_host_worldlet(existing, meta_path)
_host_msg.text = "Your hosted island carries on where you left it." if existing else "A new island will be made." _host_msg.text = ("Your hosted island carries on where you left it." if existing else "A new island will be made.") + \
"\nFriends on your network join at your address. Over the internet, forward %s on your router." % \
Lands.ports_text(picked_worldlet(_host_worldlet), int(c.data.port))
_host_msg.add_theme_color_override("font_color", UITheme.TEXT_DIM) _host_msg.add_theme_color_override("font_color", UITheme.TEXT_DIM)
_host_btn.disabled = false _host_btn.disabled = false

View File

@@ -1283,6 +1283,9 @@ func _go_online() -> void:
if Net.hosting: if Net.hosting:
var at := Net.my_addresses() var at := Net.my_addresses()
Actions.notify.emit("You're hosting. Friends join at %s" % (", ".join(PackedStringArray(at)) if not at.is_empty() else "this computer's address")) Actions.notify.emit("You're hosting. Friends join at %s" % (", ".join(PackedStringArray(at)) if not at.is_empty() else "this computer's address"))
var home_land := String(Net.server_info.get("worldlet", "valley"))
if not Lands.extra_ports(home_land, Net._host_port).is_empty():
Actions.notify.emit("Over the internet, forward %s" % Lands.ports_text(home_land, Net._host_port))
func _on_net_left(_reason: String) -> void: func _on_net_left(_reason: String) -> void:
WorldNet.leave_world() WorldNet.leave_world()

View File

@@ -181,6 +181,29 @@ func _body() -> void:
await _load_world() await _load_world()
check(world.worldlet_id() == "cove" and player.global_position.distance_to(spot) < 1.5, 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)) "where she left off (%.1f m off)" % player.global_position.distance_to(spot))
print("the Cove's server crashes")
var why := {"text": ""}
var on_left := func(t): why.text = t
Net.left.connect(on_left)
var out: Array = []
OS.execute("lsof", ["-nP", "-t", "-iUDP:%d" % (PORT + 1)], out)
var pid := String(out[0] if not out.is_empty() else "").strip_edges().get_slice("\n", 0)
check(pid.is_valid_int(), "(found the Cove's server: pid %s)" % pid)
if pid.is_valid_int():
OS.kill(int(pid))
await _until(func(): return not String(why.text).is_empty(), 15.0)
Net.left.disconnect(on_left)
check(String(why.text).begins_with("Lost the connection to the Cove"), "Alice is told what happened (%s)" % why.text)
world.queue_free()
await process_frame
world = null
_arrived = ""
r = await _join()
await _until(func(): return not _arrived.is_empty(), 30.0)
check(_arrived == "cove", "joining again: the hub starts the Cove's server afresh and sends her back (%s)" % _arrived)
if _arrived == "cove":
await _load_world()
check(world.worldlet_id() == "cove" and player.inventory.count("antler_pickaxe") == 1, "in the Cove, with her things")
print("an admin in the Cove kicks someone in the Valley") print("an admin in the Cove kicks someone in the Valley")
await _until(func(): return Net.roster.any(func(e): return String(e.name) == "Bob"), 4.0) await _until(func(): return Net.roster.any(func(e): return String(e.name) == "Bob"), 4.0)
root.get_node("Chat").say("/kick Bob") root.get_node("Chat").say("/kick Bob")

View File

@@ -84,6 +84,10 @@ func _body(S: Node, Net: Node) -> void:
await process_frame await process_frame
check(hp.disabled and TS.picked_worldlet(hp) == "valley", "an island already made: shows its worldlet, locked") check(hp.disabled and TS.picked_worldlet(hp) == "valley", "an island already made: shows its worldlet, locked")
check(title._host_seed_label.text == "Seed (kept)", "(like its seed)") check(title._host_seed_label.text == "Seed (kept)", "(like its seed)")
check(title._host_msg.text.contains("forward UDP 24242 and 24243 (the Cove)"),
"the Host page says which ports to forward, the Cove's too (%s)" % title._host_msg.text.get_slice("\n", 1))
var L: Node = root.get_node("Lands")
check(L.extra_ports("valley", 24242) == [["cove", 24243]], "(the Valley's lands: the Cove on 24243)")
# A worldlet that isn't a start (an island made elsewhere) is still shown. # A worldlet that isn't a start (an island made elsewhere) is still shown.
var extra := OptionButton.new() var extra := OptionButton.new()
extra.add_item("Valley") extra.add_item("Valley")

View File

@@ -360,7 +360,7 @@ Set a name (3 to 16 letters, digits, spaces, `_` or `-`), tunic colour, hair col
### Hosting ### Hosting
**Host Game** starts a server on your computer and puts you in. Pick a server name, a rules preset, an optional password, the port (24242 by default) and, the first time, a seed. As its first player you're its admin. A message tells you the addresses friends can join at. Leaving saves the island and stops the server; next time it carries on. On a Mac, allow incoming connections if asked. **Host Game** starts a server on your computer and puts you in. Pick a server name, a rules preset, an optional password, the port (24242 by default) and, the first time, a seed. As its first player you're its admin. A message tells you the addresses friends can join at, and the page says which UDP ports to forward for friends over the internet (24242, and 24243 for the Cove's server). Leaving saves the island and stops the server; next time it carries on. On a Mac, allow incoming connections if asked.
### Dedicated servers ### Dedicated servers