From f9b138b30459f6bed210e3fda7379b80b58dbb83 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Sat, 7 Nov 2026 00:08:05 +0000 Subject: [PATCH] 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 --- docs/HOSTING.md | 24 ++++++++++++++++++------ docs/WORLDLETS_DESIGN.md | 13 +++++++++++-- scripts/net/lands.gd | 35 +++++++++++++++++++++++++++++++++-- scripts/net/net.gd | 12 ++++++++++++ scripts/ui/title_screen.gd | 4 +++- scripts/world/world.gd | 3 +++ tests/test_net_lands.gd | 23 +++++++++++++++++++++++ tests/test_worldlet_pick.gd | 4 ++++ tools/site/guide_sections.md | 2 +- 9 files changed, 108 insertions(+), 12 deletions(-) diff --git a/docs/HOSTING.md b/docs/HOSTING.md index 00fd69d..13dd364 100644 --- a/docs/HOSTING.md +++ b/docs/HOSTING.md @@ -113,15 +113,22 @@ To check it works: `tailscale ping ` from the friend's computer. ### Port forwarding -On your router, forward **UDP 24242** to the server computer's local -address (give that computer a fixed local address first). Friends join -with your public IP address. It needs no extra software, but your home IP -and that port are public, so set a `password`. +On your router, forward **UDP 24242 and 24243** to the server computer's +local address (give that computer a fixed local address first). Friends +join with your public IP address. It needs no extra software, but your +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 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 -the provider's firewall. +Install Godot, clone the game, use the systemd file, and open UDP 24242 +and 24243 in the provider's firewall. ## Troubleshooting - **"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 computer (a dedicated one, or a Host Game that didn't stop). Stop it, or 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 that hasn't been imported, or Godot is older than 4.7. `run_server.sh` handles both; see `docs/SETUP_ON_NEW_MACHINE.md`. diff --git a/docs/WORLDLETS_DESIGN.md b/docs/WORLDLETS_DESIGN.md index 593347a..63c4021 100644 --- a/docs/WORLDLETS_DESIGN.md +++ b/docs/WORLDLETS_DESIGN.md @@ -56,8 +56,17 @@ player list across lands are J3b). The rest is planned. asleep)" in chat. - `tests/test_net_lands.gd` covers it all, Alice in the Cove and Bob in the 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:* - `scripts/world/tunnel.gd`: the seam (an outcrop on the clifftop strip, diff --git a/scripts/net/lands.gd b/scripts/net/lands.gd index f0dc131..1ca0cc7 100644 --- a/scripts/net/lands.gd +++ b/scripts/net/lands.gd @@ -65,6 +65,8 @@ func server_started(args: Dictionary) -> void: 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 + 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. func server_stopped() -> void: @@ -109,13 +111,39 @@ func home() -> String: ## Every land after home, in the order the journey reaches them. 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 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): 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(): + 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) break if nxt.is_empty(): @@ -148,6 +176,9 @@ func ensure(l: String, then: Callable) -> void: if not hub: then.call("this isn't the hub") 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): servers[l].idle = 0.0 then.call("") diff --git a/scripts/net/net.gd b/scripts/net/net.gd index e7cc90b..f6e4f4e 100644 --- a/scripts/net/net.gd +++ b/scripts/net/net.gd @@ -963,6 +963,7 @@ func _on_server_disconnected() -> void: 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) + var land_name := on_land() stop_hosting() stop() if was == "connecting": @@ -970,8 +971,19 @@ func _on_server_disconnected() -> void: join_failed.emit(last_reason) else: 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) +## 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: if what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_PREDELETE: if _peer != null: diff --git a/scripts/ui/title_screen.gd b/scripts/ui/title_screen.gd index a83c12c..27d5c79 100644 --- a/scripts/ui/title_screen.gd +++ b/scripts/ui/title_screen.gd @@ -426,7 +426,9 @@ func _fill_host() -> void: _host_seed_label.text = "Seed" if not existing else "Seed (kept)" _host_seed.text = "" if not existing else str(int(c.data.seed)) _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_btn.disabled = false diff --git a/scripts/world/world.gd b/scripts/world/world.gd index 92126cc..1a1abee 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -1283,6 +1283,9 @@ func _go_online() -> void: if Net.hosting: 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")) + 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: WorldNet.leave_world() diff --git a/tests/test_net_lands.gd b/tests/test_net_lands.gd index d0fa863..676e8c5 100644 --- a/tests/test_net_lands.gd +++ b/tests/test_net_lands.gd @@ -181,6 +181,29 @@ func _body() -> void: 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)) + 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") await _until(func(): return Net.roster.any(func(e): return String(e.name) == "Bob"), 4.0) root.get_node("Chat").say("/kick Bob") diff --git a/tests/test_worldlet_pick.gd b/tests/test_worldlet_pick.gd index 88dfd54..0614a7e 100644 --- a/tests/test_worldlet_pick.gd +++ b/tests/test_worldlet_pick.gd @@ -84,6 +84,10 @@ func _body(S: Node, Net: Node) -> void: await process_frame 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_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. var extra := OptionButton.new() extra.add_item("Valley") diff --git a/tools/site/guide_sections.md b/tools/site/guide_sections.md index f2d3f42..817d18c 100644 --- a/tools/site/guide_sections.md +++ b/tools/site/guide_sections.md @@ -360,7 +360,7 @@ Set a name (3 to 16 letters, digits, spaces, `_` or `-`), tunic colour, hair col ### 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