M2.7: Host Game, backups, launchd/systemd samples, hosting and Tailscale guide
This commit is contained in:
136
docs/HOSTING.md
Normal file
136
docs/HOSTING.md
Normal file
@@ -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 <name>`.
|
||||
|
||||
**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 <name>`) |
|
||||
| `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/<world>/backups/<date_time>/`:
|
||||
- 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/<world>/`, 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 <server name>` 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`.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -8,14 +8,14 @@ extends Node
|
||||
##
|
||||
## Anyone: /help /players /rules /friend <name> /unfriend <name> /friends
|
||||
## Admins: /kick <name> /ban <name> /unban <name> /admin <name> /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 <rule> <value> /say <message> /release-name <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 <name> /unfriend <name> /friends" +
|
||||
(" · admin: /kick /ban /unban /admin /save /time 08:00 /weather <state|auto> /set <rule> <value> /say /release-name" if _is_admin(peer) else ""))
|
||||
(" · admin: /kick /ban /unban /admin /save /backup /backups /time 08:00 /weather <state|auto> /set <rule> <value> /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()):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -25,6 +25,7 @@ const DEFAULTS := {
|
||||
"difficulty": "normal",
|
||||
"autosave_minutes": 2,
|
||||
"backups_kept": 10,
|
||||
"backup_hours": 6,
|
||||
"admins": [],
|
||||
"allowlist": [],
|
||||
"banned": [],
|
||||
|
||||
@@ -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/<world>/backups/<yyyy-mm-dd_hh-mm-ss>/: 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
50
server/com.emberwild.server.plist
Normal file
50
server/com.emberwild.server.plist
Normal file
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Emberwild dedicated server as a launchd agent (macOS): starts when you log
|
||||
in, and restarts if it stops.
|
||||
|
||||
1. Edit the two paths below (the game folder, and your user name in the log
|
||||
path) if yours differ.
|
||||
2. Copy it: cp server/com.emberwild.server.plist ~/Library/LaunchAgents/
|
||||
3. Start it: launchctl load -w ~/Library/LaunchAgents/com.emberwild.server.plist
|
||||
Stop it: launchctl unload ~/Library/LaunchAgents/com.emberwild.server.plist
|
||||
Log: tail -f ~/Library/Logs/emberwild-server.log
|
||||
|
||||
server.json and the world are in
|
||||
~/Library/Application Support/Godot/app_userdata/Emberwild/server/
|
||||
For admin commands, join the game and use chat (/help), or edit server.json
|
||||
and stop/start it. (For a console, run server/run_server.sh in Terminal
|
||||
instead.)
|
||||
-->
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.emberwild.server</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/Users/home/valheimlike/server/run_server.sh</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>/Users/home/valheimlike</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<!-- launchd's PATH is minimal: point at Godot directly. -->
|
||||
<key>GODOT</key>
|
||||
<string>/Applications/Godot.app/Contents/MacOS/Godot</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<dict>
|
||||
<key>SuccessfulExit</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>15</integer>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/Users/home/Library/Logs/emberwild-server.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Users/home/Library/Logs/emberwild-server.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
36
server/emberwild-server.service
Normal file
36
server/emberwild-server.service
Normal file
@@ -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
|
||||
@@ -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,
|
||||
|
||||
148
tests/test_host.gd
Normal file
148
tests/test_host.gd
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user