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, "valley") 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))) check(String(Net.world_info.get("worldlet", "")) == "valley", "in the worldlet picked (%s)" % Net.world_info.get("worldlet", "-")) 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, "cove") 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(String(load("res://scripts/net/server_config.gd").open(DIR + "/server.json").data.worldlet) == "valley", "and the same worldlet (the Valley, not the new one picked)") 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)