M2.7: Host Game, backups, launchd/systemd samples, hosting and Tailscale guide

This commit is contained in:
Paul Bailey
2026-11-04 23:19:00 +00:00
parent 2ab79b68c6
commit ab1f417c32
14 changed files with 723 additions and 11 deletions

148
tests/test_host.gd Normal file
View 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)