From ab1f417c32b4e70aa1edeec13deaa08a15bd847e Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Wed, 4 Nov 2026 23:19:00 +0000 Subject: [PATCH] M2.7: Host Game, backups, launchd/systemd samples, hosting and Tailscale guide --- docs/HOSTING.md | 136 +++++++++++++++++++++++++++ docs/MULTIPLAYER_PLAN.md | 2 +- docs/ROADMAP.md | 1 + docs/SETUP_ON_NEW_MACHINE.md | 4 +- scripts/net/chat.gd | 13 ++- scripts/net/net.gd | 135 +++++++++++++++++++++++++++ scripts/net/server_config.gd | 1 + scripts/net/world_net.gd | 67 ++++++++++++++ scripts/ui/title_screen.gd | 136 ++++++++++++++++++++++++++- scripts/world/world.gd | 3 + server/com.emberwild.server.plist | 50 ++++++++++ server/emberwild-server.service | 36 ++++++++ tests/run_all.sh | 2 +- tests/test_host.gd | 148 ++++++++++++++++++++++++++++++ 14 files changed, 723 insertions(+), 11 deletions(-) create mode 100644 docs/HOSTING.md create mode 100644 server/com.emberwild.server.plist create mode 100644 server/emberwild-server.service create mode 100644 tests/test_host.gd diff --git a/docs/HOSTING.md b/docs/HOSTING.md new file mode 100644 index 0000000..f1196ca --- /dev/null +++ b/docs/HOSTING.md @@ -0,0 +1,136 @@ +# Hosting a game + +Three ways to run a server, from easiest: + +1. **Host Game** on the title screen. A server runs on your computer while + you play, and stops when you leave. Good for an evening with friends. +2. **A dedicated server** on a spare computer (like the home server Mac): + `server/run_server.sh`, or as a service that starts on boot. Good for a + world that's always there. +3. **A small cloud server** (Linux): the same as 2, using the systemd file. + +Whichever you use, friends need to reach your computer on **UDP port +24242** (or the one you pick). On the same Wi-Fi that just works. From +elsewhere, use **Tailscale** (recommended) or port forwarding (below). + +## 1. Host Game (from the title screen) +- Title screen > **Host Game**. Pick a server name, the rules (co-op, + friendly rivals or adversarial), an optional password, the port, and + (the first time) a seed. **Start**. +- The game starts a windowless copy of itself as the server, then joins it. + You're its first player, so you're its **admin** (`T` then `/help`). +- When you're in, a message says the addresses friends can join at: your + local network address (192.168.x.x) and, if Tailscale is running, your + 100.x address. +- **Leaving** (Disconnect, or quitting the game) saves the island and + stops the server. Next time, Host Game carries on with the same island + (the seed field is locked once it exists). +- Its files: `~/Library/Application Support/Godot/app_userdata/Emberwild/host/` + (macOS; `%APPDATA%\Godot\app_userdata\Emberwild\host\` on Windows): + `server.json`, `server.log`, and the world in `worlds/server-world/`. +- macOS may ask once whether Godot may accept incoming network + connections: say **Allow**, or friends can't connect. + +## 2. A dedicated server +On the machine that will run it (it needs Godot 4.7 or newer and a copy of +the game, e.g. a git clone): +```bash +server/run_server.sh # settings from server.json +server/run_server.sh --server-name "Paul's island" --password secret +``` +It checks Godot's version, imports a fresh copy the first time, and starts +the server with no window. **You can type admin commands into its terminal** +(the same as in chat, without the `/`): `players`, `rules`, +`set pvp true`, `time 08:00`, `backup`, `say hello`... Stop it with Ctrl+C. + +The first player to join a new server becomes its admin (so join it +yourself before telling friends the address). More admins: `/admin `. + +**As a service** (starts on boot, restarts if it stops): +- **macOS:** `server/com.emberwild.server.plist`: edit the paths in it, + then `cp server/com.emberwild.server.plist ~/Library/LaunchAgents/` and + `launchctl load -w ~/Library/LaunchAgents/com.emberwild.server.plist`. + It runs while you're logged in (set the Mac to log in automatically for an + always-on server). Log: `~/Library/Logs/emberwild-server.log`. +- **Linux:** `server/emberwild-server.service`: the steps are in the file + (`sudo systemctl enable --now emberwild-server`; logs with + `journalctl -u emberwild-server -f`). + +As a service there's no terminal to type commands into: use chat in game, +or edit `server.json` and restart. + +## Settings and rules +`server.json` (made with defaults on first run; its path is printed at +start-up). Admins can change the rules in game with `/set` (they're saved +to the file). The full list is in `docs/MULTIPLAYER_PLAN.md` §4 and §6; +the ones you'll want first: + +| Setting | Default | | +|---|---|---| +| `server_name`, `motd`, `password` | | what players see; a password keeps strangers out | +| `port` | 24242 | UDP | +| `max_players` | 8 | | +| `pvp`, `raiding` | false, false | can players hurt each other / take down others' builds | +| `locks` | owner_and_friends | who can open your chests and doors (`/friend `) | +| `death_penalty` | grave | or `keep`, or `drop_all` | +| `difficulty` | normal | easy / normal / hard | +| `sleep` | all_online | or `half`, `anyone`, `off` | +| `backups_kept`, `backup_hours` | 10, 6 | see below | +| `allowlist`, `banned`, `admins` | | player ids (use `/ban`, `/admin` rather than editing) | + +## Backups +The server copies the world (the island and every player's character) to +`worlds//backups//`: +- when it starts (the state it's about to load), +- every `backup_hours` while it runs, +- whenever an admin types `/backup` (`/backups` lists them). + +It keeps the newest `backups_kept`. **To restore one:** stop the server, +copy the files from the backup folder (`meta.json`, `world.json` and the +`players` folder) into `worlds//`, replacing what's there, and start +it again. + +The world also saves within 10 seconds of any change and when a player +leaves, so stopping it with Ctrl+C loses at most a few seconds. + +## Letting friends in from outside your home + +### Tailscale (recommended) +Tailscale puts your devices and your friends' on a private network, with +nothing opened to the internet and no router settings. +1. Install Tailscale on the server computer ([tailscale.com/download](https://tailscale.com/download)) + and sign in. +2. Share the server with each friend: in the Tailscale admin console, + **Machines** > the server's **...** menu > **Share**, and send them the + link. (Or invite them to your tailnet.) +3. They install Tailscale, sign in and accept the share. +4. In the game they **Join** with the server's Tailscale address, + `100.x.y.z` (shown in Tailscale, and in the game when you host), or its + machine name (with MagicDNS on), e.g. `home-mac`. + +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`. + +### 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. + +## Troubleshooting +- **"Different game version":** the server and the player are on different + versions of the game. Update both (`git pull`), and restart the server. +- **"No answer from the server":** wrong address, the server isn't running, + or a firewall is in the way (macOS: allow incoming connections for Godot; + Tailscale: is it connected on both ends?). +- **"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. +- **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/MULTIPLAYER_PLAN.md b/docs/MULTIPLAYER_PLAN.md index c59d571..d39baae 100644 --- a/docs/MULTIPLAYER_PLAN.md +++ b/docs/MULTIPLAYER_PLAN.md @@ -363,7 +363,7 @@ player" or "this player": | **M2.4** *(done)* | intents as RPCs; per-player inventory, stats, diet, death and graves; per-player saves and migration | two players craft, eat, die and loot their own graves; an old single-player save loads | | **M2.5** *(done)* | wildlife and wolves server-side, projectiles, fishing, sleeping with the rule | a pack hunts two players at once; arrows hit on both screens | | **M2.6** *(done)* | server rules: PvP, raiding, locks and friends, death penalty, difficulty; admin console and chat | each preset (co-op, rivals, adversarial) behaves as described in §4 | -| **M2.7** | hosting polish: "Host game" button, systemd and launchd samples, backups, a Tailscale how-to in the docs | Paul runs it on the home server, and a friend joins over Tailscale and plays for an hour | +| **M2.7** *(done, see docs/HOSTING.md)* | hosting polish: "Host game" button, systemd and launchd samples, backups, a Tailscale how-to in the docs | Paul runs it on the home server, and a friend joins over Tailscale and plays for an hour | ## 10. Testing - **Headless multi-process tests:** a script starts a headless server and diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2556687..2325383 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -59,6 +59,7 @@ phases M2.0-M2.7). - [x] Per-player inventory, stats, death, graves and saves on the server *(M2.4)* - [x] Animals, wolves, hunting, fishing and sleeping on the server *(M2.5)* - [x] Server rules (PvP, raiding, locks and friends, death penalty, difficulty, wolf grace), chat and admin commands, server console *(M2.6)* +- [x] Host Game from the title screen, backups, launchd and systemd samples, hosting and Tailscale guide (`docs/HOSTING.md`) *(M2.7)* - **Done when:** a friend joins your self-hosted server and you build together. ## M3 — Addon SDK + sandbox · ~2–3 months diff --git a/docs/SETUP_ON_NEW_MACHINE.md b/docs/SETUP_ON_NEW_MACHINE.md index 9c051ba..4447ff2 100644 --- a/docs/SETUP_ON_NEW_MACHINE.md +++ b/docs/SETUP_ON_NEW_MACHINE.md @@ -45,7 +45,9 @@ The shared island is saved in `worlds/server-world/` in that same folder, within 10 seconds of any change (and when someone leaves). Stop it with Ctrl+C. Players join with **Join Game** on the title screen. Over Tailscale, use the server machine's 100.x address or its machine name. See -`docs/MULTIPLAYER_PLAN.md` for all the options. +`docs/MULTIPLAYER_PLAN.md` for all the options, and `docs/HOSTING.md` for +hosting from the title screen, running it as a service, backups and +Tailscale. ## Tests Run everything (fails on any failed check or script error): diff --git a/scripts/net/chat.gd b/scripts/net/chat.gd index 36468db..014334e 100644 --- a/scripts/net/chat.gd +++ b/scripts/net/chat.gd @@ -8,14 +8,14 @@ extends Node ## ## Anyone: /help /players /rules /friend /unfriend /friends ## Admins: /kick /ban /unban /admin /save -## /time 08:00 /weather clear|cloudy|rain|storm|snow|blizzard|auto +## /backup /backups /time 08:00 /weather clear|cloudy|rain|storm|snow|blizzard|auto ## /set /say /release-name signal message(text: String, kind: String) ## client: show this (kind: chat | server | reply) const MAX_LEN := 200 const GAP := 0.4 ## seconds between one player's lines -const ADMIN_ONLY := ["kick", "ban", "unban", "admin", "save", "time", "weather", "set", "say", "release-name"] +const ADMIN_ONLY := ["kick", "ban", "unban", "admin", "save", "backup", "backups", "time", "weather", "set", "say", "release-name"] var _last: Dictionary = {} ## peer -> ticks of their last line var _console: Thread @@ -107,7 +107,7 @@ func run(line: String, peer: int) -> void: match cmd: "help": _reply(peer, "Commands: /players /rules /friend /unfriend /friends" + - (" · admin: /kick /ban /unban /admin /save /time 08:00 /weather /set /say /release-name" if _is_admin(peer) else "")) + (" · admin: /kick /ban /unban /admin /save /backup /backups /time 08:00 /weather /set /say /release-name" if _is_admin(peer) else "")) "players": var names: Array[String] = [] for p in Net.players.values(): @@ -174,6 +174,13 @@ func run(line: String, peer: int) -> void: "save": WorldNet.save() _reply(peer, "Saved") + "backup": + WorldNet.save() + var b: String = WorldNet.backup("by %s" % by) + _reply(peer, "Backed up as %s (%d kept)" % [b, WorldNet.list_backups().size()] if not b.is_empty() else "Nothing to back up yet") + "backups": + var all: Array = WorldNet.list_backups() + _reply(peer, "%d backups, newest last: %s" % [all.size(), ", ".join(PackedStringArray(all.slice(-5)))] if not all.is_empty() else "No backups yet") "time": var hm := arg.split(":") if hm.size() < 1 or not hm[0].is_valid_int() or int(hm[0]) > 23 or (hm.size() > 1 and not hm[1].is_valid_int()): diff --git a/scripts/net/net.gd b/scripts/net/net.gd index 694d985..cff1833 100644 --- a/scripts/net/net.gd +++ b/scripts/net/net.gd @@ -240,6 +240,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)): + _host_left.call_deferred() players_changed.emit() func _process(delta: float) -> void: @@ -255,6 +257,7 @@ func _process(delta: float) -> void: _send_left = SEND_INTERVAL _broadcast_snapshot() return + _host_tick() if state == "connecting": _join_left -= delta if _join_left <= 0.0: @@ -272,6 +275,135 @@ func _tick_clock(delta: float) -> void: for peer in players: _s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time) +# --- hosting from the title screen (M2.7) ------------------------------------------ +# "Host Game" runs a dedicated server as a separate, windowless copy of the +# game on this machine, then joins it like anyone else (so you're its first +# player, and its admin). Its world and settings live in user://host/. Its +# log goes to user://host/server.log. Leaving asks it to save and stop. + +signal host_status(text: String, failed: bool) + +var host_config := "user://host/server.json" ## (tests use their own) +var hosting := false +var _host_pid := 0 +var _host_io: FileAccess +var _host_log: FileAccess +var _host_out := "" +var _host_port := DEFAULT_PORT + +## Settings for a hosted game: name, password, port, a rules preset +## (co-op / rivals / adversarial) and the seed for a new island. +const PRESETS := { + "co-op": {"pvp": "false", "raiding": "false", "locks": "owner_and_friends", "death_penalty": "grave"}, + "rivals": {"pvp": "true", "raiding": "false", "locks": "owner_and_friends", "death_penalty": "grave"}, + "adversarial": {"pvp": "true", "raiding": "true", "locks": "owner", "death_penalty": "drop_all"}, +} + +func host(server_name: String, password: String, port: int, preset: String, seed: int) -> void: + if hosting or state != "offline": + return + if _host_pid != 0 and OS.is_process_running(_host_pid): + host_status.emit("The last server is still saving: try again in a moment", true) + return + var c: RefCounted = ServerConfig.open(host_config) + c.set_rule("server_name", server_name) + for k in PRESETS.get(preset, PRESETS["co-op"]): + c.set_rule(k, PRESETS[preset][k]) + c.data.password = password + c.data.port = port + var fresh := not FileAccess.file_exists(host_config.get_base_dir().path_join("worlds/server-world/meta.json")) + if fresh: + c.data.seed = seed + c.save() + _host_port = port + var args := PackedStringArray(["--headless"]) + if not OS.has_feature("template"): + args.append_array(["--path", ProjectSettings.globalize_path("res://")]) # (running from the editor build) + args.append_array(["--", "--server", "--host-id", String(get_identity().player_id), + "--config", ProjectSettings.globalize_path(host_config), "--exit-with", str(OS.get_process_id())]) + var p: Dictionary = OS.execute_with_pipe(OS.get_executable_path(), args, false) + if p.is_empty(): + host_status.emit("Couldn't start the server", true) + return + _host_pid = int(p.pid) + _host_io = p.stdio + _host_out = "" + _host_log = FileAccess.open(host_config.get_base_dir().path_join("server.log"), FileAccess.WRITE) + hosting = true + host_status.emit("Starting the server%s..." % (" (building a new island)" if fresh else ""), false) + +## Reads what the hosted server prints (so its output never fills the pipe), +## and joins it once it's listening. +func _host_tick() -> void: + if _host_io == null: + return + var chunk := _host_io.get_buffer(8192) + if not chunk.is_empty(): + var s := chunk.get_string_from_utf8() + if _host_log != null: + _host_log.store_string(s) + _host_log.flush() + if hosting and state == "offline": + _host_out += s + if _host_out.contains("listening on UDP"): + _host_out = "" + host_status.emit("Joining your server...", false) + join("127.0.0.1:%d" % _host_port, String(ServerConfig.open(host_config).data.password)) + elif _host_out.contains("[server] Couldn't open UDP port") or _host_out.contains("Not starting"): + var why := "Port %d is already in use (another server running?)" % _host_port \ + if _host_out.contains("Couldn't open") else "The server couldn't start (see user://host/server.log)" + stop_hosting() + host_status.emit(why, true) + if _host_pid != 0 and not OS.is_process_running(_host_pid): + var was_hosting := hosting + _host_pid = 0 + # It's gone: whatever it printed last is still in the pipe. + for _i in range(64): + var rest := _host_io.get_buffer(8192) + if rest.is_empty(): + break + if _host_log != null: + _host_log.store_string(rest.get_string_from_utf8()) + if _host_log != null: + _host_log.flush() + _host_io = null + hosting = false + if was_hosting and state == "offline": + host_status.emit("The server stopped (see user://host/server.log)", true) + +## Stops the hosted server: asks it to save and quit, then makes sure. Its +## output is still read (into the log) until it's gone. +func stop_hosting() -> void: + if not hosting: + return + hosting = false + var pid := _host_pid + if pid != 0: + get_tree().create_timer(6.0).timeout.connect(func(): + if OS.is_process_running(pid): + OS.kill(pid)) + +## The addresses friends can use to reach this machine: the local network, +## and Tailscale (100.x) if it's running. +static func my_addresses() -> Array[String]: + var out: Array[String] = [] + for a in IP.get_local_addresses(): + if a.contains(":") or a.begins_with("127.") or a.begins_with("169.254."): + continue + out.append(a) + out.sort_custom(func(x, y): return (0 if x.begins_with("100.") else 1) < (0 if y.begins_with("100.") else 1)) + return out + +## A hosted server (started with --host-id): when the player who hosts it +## leaves, it saves and stops (it lives on their computer). +func _hosted_by(id: String) -> bool: + return is_server() and _args.has("host-id") and String(_args["host-id"]) == id + +func _host_left() -> void: + print("[server] the host left: saving and stopping") + stop() # (saves the world and everyone's characters) + get_tree().quit() + # --- admin (chat.gd) --------------------------------------------------------------- signal rules_changed ## clients: the server's rules changed (/set) @@ -566,6 +698,7 @@ var _pending_password := "" func leave() -> void: var was := state + stop_hosting() # (our own server sees us go, and saves and stops) stop() if was == "connected": last_reason = "You left the server" @@ -659,6 +792,7 @@ 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) stop() last_reason = reason join_failed.emit(reason) @@ -669,6 +803,7 @@ func _on_connection_failed() -> void: func _on_server_disconnected() -> void: var was := state var reason := _reject_reason + stop_hosting() stop() if was == "connecting": last_reason = reason if not reason.is_empty() else "The server hung up" diff --git a/scripts/net/server_config.gd b/scripts/net/server_config.gd index c415958..5b39ac5 100644 --- a/scripts/net/server_config.gd +++ b/scripts/net/server_config.gd @@ -25,6 +25,7 @@ const DEFAULTS := { "difficulty": "normal", "autosave_minutes": 2, "backups_kept": 10, + "backup_hours": 6, "admins": [], "allowlist": [], "banned": [], diff --git a/scripts/net/world_net.gd b/scripts/net/world_net.gd index ba6a646..1cf9110 100644 --- a/scripts/net/world_net.gd +++ b/scripts/net/world_net.gd @@ -50,6 +50,8 @@ func open_server_world(config: RefCounted) -> Node3D: if id.is_empty(): id = "server-world" Saves.current_id = Saves.open_or_create(id, int(config.v("seed"))) + backup("start-up") # (the state it's about to load, before anyone changes it) + _backup_left = maxf(float(config.v("backup_hours")), 0.25) * 3600.0 var w: Node3D = load("res://scenes/world.tscn").instantiate() w.server_mode = true w.day_length = float(config.v("day_length_minutes")) * 60.0 @@ -97,6 +99,66 @@ func save() -> void: if world != null and is_instance_valid(world) and world.server_mode: world.save_now() # (players too: world.saved) +# --- backups (M2.7) ---------------------------------------------------------------- +# A copy of the world (meta, world and every player's file) in +# worlds//backups//: when the server starts (the +# state it's about to load), every backup_hours while it runs, and on +# /backup. The newest backups_kept are kept. To restore one, stop the server +# and copy its files back up two folders (docs/HOSTING.md). + +var _backup_left := INF + +func world_dir() -> String: + return Saves.root_dir.path_join(Saves.current_id) + +## Makes a backup now. Returns its folder name ("" if there was nothing to back up). +func backup(reason: String) -> String: + var src := world_dir() + if not FileAccess.file_exists(src.path_join("world.json")): + return "" + var stamp := Time.get_datetime_string_from_system(false, true).replace(":", "-").replace(" ", "_") + var dst := src.path_join("backups").path_join(stamp) + var n := 2 + while DirAccess.dir_exists_absolute(dst): + dst = src.path_join("backups").path_join("%s_%d" % [stamp, n]) + n += 1 + DirAccess.make_dir_recursive_absolute(dst.path_join("players")) + for f in ["meta.json", "world.json"]: + if FileAccess.file_exists(src.path_join(f)): + DirAccess.copy_absolute(src.path_join(f), dst.path_join(f)) + var pd := DirAccess.open(src.path_join("players")) + if pd != null: + for f in pd.get_files(): + if f.ends_with(".json"): + DirAccess.copy_absolute(src.path_join("players").path_join(f), dst.path_join("players").path_join(f)) + _prune_backups() + print("[server] backup (%s): %s" % [reason, ProjectSettings.globalize_path(dst)]) + return dst.get_file() + +func list_backups() -> Array: + var d := DirAccess.open(world_dir().path_join("backups")) + if d == null: + return [] + var out: Array = Array(d.get_directories()) + out.sort() + return out + +func _prune_backups() -> void: + var keep := maxi(int(Net.config.v("backups_kept")), 1) + var all := list_backups() + while all.size() > keep: + _rm_tree(world_dir().path_join("backups").path_join(String(all.pop_front()))) + +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) + ## Something changed: save within a few seconds. Ctrl+C kills Godot without ## a chance to save, so this keeps the loss small. func mark_changed() -> void: @@ -139,6 +201,11 @@ func _process(delta: float) -> void: _save_left -= delta if _save_left <= 0.0: save() + _backup_left -= delta + if _backup_left <= 0.0: + _backup_left = maxf(float(Net.config.v("backup_hours")), 0.25) * 3600.0 + save() + backup("every %s h" % Net.config.v("backup_hours")) _blocker_left -= delta if _blocker_left <= 0.0: _blocker_left = 1.0 diff --git a/scripts/ui/title_screen.gd b/scripts/ui/title_screen.gd index 83c3420..0b64352 100644 --- a/scripts/ui/title_screen.gd +++ b/scripts/ui/title_screen.gd @@ -87,8 +87,10 @@ func _ready() -> void: _pages["profile"] = _profile_page() _pages["join"] = _join_page() _pages["lobby"] = _lobby_page() + _pages["host"] = _host_page() for p in _pages.values(): col.add_child(p) + Net.host_status.connect(_on_host_status) Net.joined.connect(_enter_server_world) Net.join_failed.connect(_on_join_failed) Net.left.connect(_on_left) @@ -127,7 +129,7 @@ func _maybe_capture() -> void: var me: RefCounted = Net.get_identity() if me.name.is_empty(): me.name = "Paul" # in memory only, for the pictures - for page in ["main", "new", "load", "profile", "join", "lobby"]: + for page in ["main", "new", "load", "profile", "join", "host", "lobby"]: if page == "lobby": # Sample data: the lobby normally needs a live server. Net.server_info = {"server_name": "Paul's island", "motd": "Welcome! Be nice to the wolves.", @@ -147,7 +149,8 @@ func _unhandled_input(event: InputEvent) -> void: if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not _pages["main"].visible: if _pages["lobby"].visible: Net.leave() - elif Net.state == "connecting": + elif Net.state == "connecting" or Net.hosting: + Net.stop_hosting() Net.stop() _join_msg.text = "" _show("main") @@ -155,16 +158,19 @@ func _unhandled_input(event: InputEvent) -> void: _show("main") func _show(page: String) -> void: - # Joining needs a name: send people to their profile first. - if page == "join" and Identity.name_problem(Net.get_identity().name) != "": - _after_profile = "join" + # Joining (or hosting) needs a name: send people to their profile first. + if page in ["join", "host"] and Identity.name_problem(Net.get_identity().name) != "": + _after_profile = page page = "profile" + if page == "host": + _fill_host() for k in _pages: _pages[k].visible = k == page _armed_delete = "" if page == "profile": _fill_profile() elif page == "join": + _join_shown_at = Time.get_ticks_msec() _join_addr.text = Net.get_identity().last_server _join_btn.disabled = false _join_addr.grab_focus() @@ -199,6 +205,7 @@ func _main_page() -> Control: var lb := _button("Load World", _show.bind("load")) lb.disabled = worlds.is_empty() v.add_child(lb) + v.add_child(_button("Host Game", _show.bind("host"))) v.add_child(_button("Join Game", _show.bind("join"))) var n: String = Net.get_identity().name v.add_child(_button("Profile: %s" % n if not n.is_empty() else "Set Up Your Profile", @@ -315,6 +322,119 @@ func _save_profile() -> void: _rebuild_main() _show(_after_profile) +var _join_shown_at := -100000 + +# --- hosting (M2.7) ------------------------------------------------------------- + +var _host_name: LineEdit +var _host_pw: LineEdit +var _host_port: LineEdit +var _host_seed: LineEdit +var _host_seed_label: Label +var _host_preset: OptionButton +var _host_msg: Label +var _host_btn: Button +const PRESET_IDS := ["co-op", "rivals", "adversarial"] +const PRESET_NAMES := ["Co-op: no PvP, bases safe, friends share", "Friendly rivals: PvP on, bases safe", + "Adversarial: PvP, raiding, locks, drop everything on death"] + +func _host_page() -> Control: + var p := UITheme.panel() + var v := VBoxContainer.new() + v.add_theme_constant_override("separation", 7) + p.add_child(v) + v.add_child(UITheme.label("Host a game", 24)) + v.add_child(UITheme.label("Runs a server on this computer and puts you in it. Friends join with your address.", 13, UITheme.TEXT_DIM)) + v.add_child(UITheme.label("Server name", 15, UITheme.TEXT_DIM)) + _host_name = LineEdit.new() + _host_name.custom_minimum_size = Vector2(420, 38) + _host_name.max_length = 40 + v.add_child(_host_name) + v.add_child(UITheme.label("Rules", 15, UITheme.TEXT_DIM)) + _host_preset = OptionButton.new() + for n in PRESET_NAMES: + _host_preset.add_item(n) + _host_preset.custom_minimum_size = Vector2(420, 36) + v.add_child(_host_preset) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 10) + var c1 := VBoxContainer.new() + c1.add_child(UITheme.label("Password (optional)", 15, UITheme.TEXT_DIM)) + _host_pw = LineEdit.new() + _host_pw.secret = true + _host_pw.custom_minimum_size = Vector2(200, 38) + c1.add_child(_host_pw) + row.add_child(c1) + var c2 := VBoxContainer.new() + c2.add_child(UITheme.label("Port (UDP)", 15, UITheme.TEXT_DIM)) + _host_port = LineEdit.new() + _host_port.custom_minimum_size = Vector2(100, 38) + c2.add_child(_host_port) + row.add_child(c2) + var c3 := VBoxContainer.new() + _host_seed_label = UITheme.label("Seed", 15, UITheme.TEXT_DIM) + c3.add_child(_host_seed_label) + _host_seed = LineEdit.new() + _host_seed.placeholder_text = "random" + _host_seed.custom_minimum_size = Vector2(100, 38) + c3.add_child(_host_seed) + row.add_child(c3) + v.add_child(row) + _host_msg = UITheme.label("", 14, UITheme.TEXT_DIM) + _host_msg.autowrap_mode = TextServer.AUTOWRAP_WORD + _host_msg.custom_minimum_size = Vector2(420, 0) + v.add_child(_host_msg) + var h := HBoxContainer.new() + h.add_theme_constant_override("separation", 10) + _host_btn = _button("Start", _start_host, 140) + h.add_child(_host_btn) + h.add_child(_button("Back", func(): + Net.stop_hosting() + Net.stop() + _show("main"), 120)) + v.add_child(h) + return p + +func _fill_host() -> void: + var c: RefCounted = load("res://scripts/net/server_config.gd").open(Net.host_config) + var n: String = c.data.server_name + _host_name.text = "%s's island" % Net.get_identity().name if n == "Emberwild server" else n + _host_port.text = str(int(c.data.port)) + _host_pw.text = String(c.data.password) + var preset := "co-op" + if bool(c.data.pvp): + preset = "adversarial" if bool(c.data.raiding) else "rivals" + _host_preset.select(PRESET_IDS.find(preset)) + var meta_path: String = Net.host_config.get_base_dir().path_join("worlds/server-world/meta.json") + var existing: bool = FileAccess.file_exists(meta_path) + _host_seed.editable = not existing + _host_seed_label.text = "Seed" if not existing else "Seed (kept)" + _host_seed.text = "" if not existing else str(int(c.data.seed)) + _host_msg.text = "Your hosted island carries on where you left it." if existing else "A new island will be made." + _host_msg.add_theme_color_override("font_color", UITheme.TEXT_DIM) + _host_btn.disabled = false + +func _start_host() -> void: + var port_t := _host_port.text.strip_edges() + if not port_t.is_valid_int() or int(port_t) < 1024 or int(port_t) > 65535: + _on_host_status("The port is a number from 1024 to 65535 (24242 is the usual one)", true) + return + var seed_t := _host_seed.text.strip_edges() + var seed := int(seed_t) if seed_t.is_valid_int() else (seed_t.hash() if not seed_t.is_empty() else randi() % 1000000) + var name := _host_name.text.strip_edges() + if name.is_empty(): + name = "%s's island" % Net.get_identity().name + _host_btn.disabled = true + Net.host(name, _host_pw.text, int(port_t), PRESET_IDS[maxi(_host_preset.selected, 0)], seed) + +func _on_host_status(text: String, failed: bool) -> void: + if not _pages["host"].visible: + return + _host_msg.text = text + _host_msg.add_theme_color_override("font_color", UITheme.BAD if failed else UITheme.TEXT_DIM) + if failed: + _host_btn.disabled = false + func _join_page() -> Control: var p := UITheme.panel() var v := VBoxContainer.new() @@ -350,6 +470,9 @@ func _join_page() -> Control: return p func _connect() -> void: + # (A click or Enter carried over from the page before can't connect.) + if Time.get_ticks_msec() - _join_shown_at < 400: + return if _join_addr.text.strip_edges().is_empty(): _join_msg.text = "Enter the server's address" _join_msg.add_theme_color_override("font_color", UITheme.BAD) @@ -360,6 +483,9 @@ func _connect() -> void: Net.join(_join_addr.text, _join_pw.text) func _on_join_failed(reason: String) -> void: + if _pages["host"].visible: + _on_host_status(reason, true) + return _join_btn.disabled = false _join_msg.text = reason _join_msg.add_theme_color_override("font_color", UITheme.BAD) diff --git a/scripts/world/world.gd b/scripts/world/world.gd index 947f2d9..54c478b 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -756,6 +756,9 @@ func _go_online() -> void: bar.position = Vector2(16, 40) $HUD.add_child(bar) Actions.notify.emit("Connected to %s" % Net.server_info.get("server_name", "the server")) + 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")) func _on_net_left(_reason: String) -> void: WorldNet.leave_world() diff --git a/server/com.emberwild.server.plist b/server/com.emberwild.server.plist new file mode 100644 index 0000000..c6c8310 --- /dev/null +++ b/server/com.emberwild.server.plist @@ -0,0 +1,50 @@ + + + + + + Label + com.emberwild.server + ProgramArguments + + /Users/home/valheimlike/server/run_server.sh + + WorkingDirectory + /Users/home/valheimlike + EnvironmentVariables + + + GODOT + /Applications/Godot.app/Contents/MacOS/Godot + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + ThrottleInterval + 15 + StandardOutPath + /Users/home/Library/Logs/emberwild-server.log + StandardErrorPath + /Users/home/Library/Logs/emberwild-server.log + + diff --git a/server/emberwild-server.service b/server/emberwild-server.service new file mode 100644 index 0000000..c68d504 --- /dev/null +++ b/server/emberwild-server.service @@ -0,0 +1,36 @@ +# Emberwild dedicated server as a systemd service (Linux). +# +# 1. Put the game where it'll live, e.g. /opt/emberwild (a git clone), and +# install Godot 4.7+ as /usr/local/bin/godot. +# 2. Make a user for it: sudo useradd -r -m -d /var/lib/emberwild emberwild +# 3. Copy this file: sudo cp server/emberwild-server.service /etc/systemd/system/ +# and edit User, WorkingDirectory and GODOT below if yours differ. +# 4. Start it, and on boot: sudo systemctl enable --now emberwild-server +# Logs: journalctl -u emberwild-server -f +# Stop / restart: sudo systemctl stop|restart emberwild-server +# +# server.json and the world live in the user's Godot data folder: +# /var/lib/emberwild/.local/share/godot/app_userdata/Emberwild/server/ +# Admin commands: join the game and use chat (/help), or edit server.json and +# restart. (The console needs a terminal, so it isn't used here.) + +[Unit] +Description=Emberwild dedicated server +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=emberwild +WorkingDirectory=/opt/emberwild +Environment=GODOT=/usr/local/bin/godot +# run_server.sh checks the Godot version and imports a fresh copy first. +ExecStart=/opt/emberwild/server/run_server.sh +Restart=on-failure +RestartSec=10 +# The world saves within 10 s of any change, so a stop loses at most that. +KillSignal=SIGINT +TimeoutStopSec=20 + +[Install] +WantedBy=multi-user.target diff --git a/tests/run_all.sh b/tests/run_all.sh index e992339..955c3f2 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then echo "Importing the project first (fresh copy)..." godot --headless --path . --import >/dev/null 2>&1 fi -for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_den_sites test_fishing_diet test_net_lobby test_net_world test_net_shared test_net_life test_net_rules 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_den_sites test_fishing_diet test_net_lobby test_net_world test_net_shared test_net_life test_net_rules test_host test_terrain_edges test_boundary; do extra="" [ "$t" = "test_boundary" ] && extra="--fixed-fps 30" # A suite that waits on a world that failed to load would hang forever, diff --git a/tests/test_host.gd b/tests/test_host.gd new file mode 100644 index 0000000..2ef8509 --- /dev/null +++ b/tests/test_host.gd @@ -0,0 +1,148 @@ +extends SceneTree +## M2.7: hosting from the title screen, and backups. "Host Game" starts a +## server as a windowless copy of the game, joins it (you're its admin), +## and leaving makes it save and stop. Hosting again carries on the same +## island. The server backs the world up at start-up and on /backup, and +## keeps only backups_kept of them. Also: Tailscale/LAN addresses are found. +## Run: godot --headless --path . --script res://tests/test_host.gd + +const DIR := "user://test_net/host" +const PORT := 24351 + +var _fails := 0 +var _finished := false +var Net: Node +var WN: Node +var C: Node +var PN: Node + +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: + Net = root.get_node("Net") + WN = root.get_node("WorldNet") + C = root.get_node("Chat") + PN = root.get_node("PlayerNet") + _rm_tree(DIR) + Net.host_config = DIR + "/server.json" + var me = load("res://scripts/net/identity.gd").open("user://test_net/a") + me.name = "Alice" + Net.identity = me + await _body() + Net.leave() + await _sleep(1.0) + check(_finished, "test ran to the end (no script errors)") + _rm_tree(DIR) + print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails]) + quit(1 if _fails > 0 else 0) + +func _body() -> void: + var statuses: Array[String] = [] + Net.host_status.connect(func(t, _f): statuses.append(t)) + print("hosting") + Net.host("Alice's island", "", PORT, "rivals", 4242) + check(Net.hosting, "Host Game starts a server (%s)" % ", ".join(statuses)) + await _until(func(): return Net.state == "connected", 40.0) + check(Net.state == "connected", "and joins it (%s)" % " -> ".join(statuses)) + if Net.state != "connected": + return + check(String(Net.server_info.get("server_name", "")) == "Alice's island" and bool(Net.server_info.get("pvp", false)), + "with the name and the rules picked (friendly rivals: PvP on)") + check(bool(Net.players.get(Net.local_peer, {}).get("admin", false)), "and Alice is its admin") + check(int(Net.world_info.get("seed", 0)) == 4242, "a new island from the seed given (%d)" % int(Net.world_info.get("seed", 0))) + var addrs: Array[String] = Net.my_addresses() + check(not addrs.is_empty(), "the addresses friends can use: %s" % ", ".join(addrs)) + var log_path := ProjectSettings.globalize_path(DIR + "/server.log") + check(FileAccess.get_file_as_string(DIR + "/server.log").contains("listening on UDP %d" % PORT), "the server's log goes to %s" % log_path) + + print("backups") + check(_backups().is_empty(), "nothing to back up yet on a brand-new island") + # Load a world so there's something to save, change it, then /backup. + var world: Node3D = load("res://scenes/world.tscn").instantiate() + root.add_child(world) + for _i in range(8): + await physics_frame + world.player.set_physics_process(false) + PN.ask("debug_give", ["stone", 3]) # (not allowed: no --debug-cheats on a hosted server) + await _sleep(0.5) + check(world.player.inventory.count("stone") == 0, "a hosted server has no test cheats") + C.say("/backup") + await _until(func(): return _backups().size() == 1, 5.0) + check(_backups().size() == 1, "/backup makes one (%s)" % [_backups()]) + var b0: String = _backups()[0] if not _backups().is_empty() else "" + var bdir := DIR + "/worlds/server-world/backups/" + b0 + check(FileAccess.file_exists(bdir + "/world.json") and FileAccess.file_exists(bdir + "/meta.json"), + "with the world in it") + check(DirAccess.get_files_at(bdir + "/players").size() >= 1, "and the players' characters") + C.say("/set backups_kept 2") + await _sleep(0.4) + check(C.heard.any(func(l): return String(l).begins_with("Can't set 'backups_kept'")), "(backups_kept is set in server.json, not in game)") + for _i in range(3): + await _sleep(1.1) # (one-second timestamps) + C.say("/backup") + await _sleep(1.0) + check(_backups().size() == 4, "four backups now (%d)" % _backups().size()) + world.queue_free() + await process_frame + + print("leaving") + Net.leave() + await _until(func(): return not _server_running(), 8.0) + check(not _server_running(), "leaving stops the server") + # (Its last words are read from the pipe once the process has gone.) + await _until(func(): return FileAccess.get_file_as_string(DIR + "/server.log").contains("the host left"), 3.0) + check(FileAccess.get_file_as_string(DIR + "/server.log").contains("the host left: saving and stopping"), + "after saving (it was asked to, not killed)") + + print("hosting again") + var c = load("res://scripts/net/server_config.gd").open(DIR + "/server.json") + c.data.backups_kept = 3 + c.save() + statuses.clear() + Net.host("Alice's island", "", PORT, "co-op", 999) + await _until(func(): return Net.state == "connected", 40.0) + check(Net.state == "connected", "Host Game again: joined (%s)" % " -> ".join(statuses)) + check(int(Net.world_info.get("seed", 0)) == 4242, "the same island (seed %d, not the new one typed)" % int(Net.world_info.get("seed", 0))) + check(not bool(Net.server_info.get("pvp", true)), "with the rules picked this time (co-op: PvP off)") + check(_backups().size() == 3, "start-up made a backup, and only backups_kept (3) are kept (%d)" % _backups().size()) + _finished = true + +## Whether anything is still listening on the hosted server's port. +func _server_running() -> bool: + var out: Array = [] + OS.execute("lsof", ["-nP", "-iUDP:%d" % PORT], out) + return String(out[0] if not out.is_empty() else "").contains("UDP") + +func _backups() -> Array: + var d := DirAccess.open(DIR + "/worlds/server-world/backups") + if d == null: + return [] + var a: Array = Array(d.get_directories()) + a.sort() + return a + +func _until(cond: Callable, limit: float) -> bool: + var t := 0.0 + while not cond.call() and t < limit: + await _sleep(0.1) + t += 0.1 + 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)