extends SceneTree ## J3a (docs/WORLDLETS_DESIGN.md: a server per land): the journey on a server. ## ## A real headless server (the hub, the Valley, with --debug-cheats), Alice ## (this test) and Bob (tests/net_world_bot.gd), who stays in the Valley. ## Alice digs the cliff tunnel's seam and first stretch online (the server ## decides, and her game sees the rock change), props it, goes down, lights ## it. With the tunnel dug through, she walks out of the sea cave's mouth: ## the hub starts the Cove's server, she's sent on there with a pass, and ## arrives on the Cove's shore with her bag, on the same day and hour, while ## Bob carries on in the Valley. Back up the sea cave to the hub; then out ## to the Cove again, leave the game, and join the hub again: she's sent ## straight on to the Cove, where she left off. ## Run: godot --headless --path . --script res://tests/test_net_lands.gd const PORT := 24371 const DIR := "user://test_net" const SRV := DIR + "/server_lands" const BOBS := DIR + "/bob_lands" const SEED := 1337 var _fails := 0 var _finished := false var _pids: Array[int] = [] var Net: Node var WN: Node var PN: Node var A: Node var L: Node var world: Node3D var player: CharacterBody3D var _bob_dir := "" var _cmd := 0 var _toasts: Array[String] = [] var _arrived := "" 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: Engine.max_fps = 60 Net = root.get_node("Net") WN = root.get_node("WorldNet") PN = root.get_node("PlayerNet") A = root.get_node("Actions") L = root.get_node("Lands") A.notify.connect(func(t): _toasts.append(t)) Net.arrived.connect(func(l): _arrived = l) for d in [SRV, BOBS]: _rm_tree(d) # (EMBERWILD_SERVER_BIN=: the hub, and the land servers # it starts, are that build: checks a release does it too.) _spawn(["--server", "--port", str(PORT), "--config", ProjectSettings.globalize_path(SRV + "/server.json"), "--server-name", "Lands Test", "--seed", str(SEED), "--debug-cheats", "--no-creatures"], "", OS.get_environment("EMBERWILD_SERVER_BIN")) await _sleep(4.0) await _body() if world != null and is_instance_valid(world): world.queue_free() await process_frame Net.leave() await _sleep(0.5) for pid in _pids: if OS.is_process_running(pid): OS.kill(pid) check(_finished, "test ran to the end (no script errors)") await _sleep(1.0) if _fails == 0 or OS.get_environment("KEEP_SRV").is_empty(): _rm_tree(SRV) _rm_tree(BOBS) print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails]) quit(1 if _fails > 0 else 0) func _body() -> void: var me = load("res://scripts/net/identity.gd").open(DIR + "/a_lands") me.name = "Alice" Net.identity = me var r := await _join() check(r == "", "Alice joins the hub (%s)" % r) if r != "": return await _load_world() check(world.worldlet_id() == "valley" and world.tunnel != null, "the Valley, with its cliff tunnel, online") var b0 := await _new_bob() check(bool(b0.get("ok", false)), "Bob joins too (%s)" % b0.get("why", "ok")) PN.ask("debug_give", ["antler_pickaxe", 1]) PN.ask("debug_give", ["wood", 10]) PN.ask("debug_give", ["torch_stand", 2]) await _sleep(0.6) _hold("antler_pickaxe") await _sleep(0.3) print("digging the seam, online") var t: Node3D = world.tunnel await _tp(t.seam_out + Vector3(0, 0.2, 0)) var seam: Node3D = t._face check(seam != null and seam.stage == 0, "the seam, as the server sees it") await _dig_face(30) check(t.opened, "dug open: the server says so, and Alice's game has the opening") t.go_in(player) # (asks the server, which moves her) await _until(func(): return world.underground_at(player.global_position), 3.0) check(world.underground_at(player.global_position), "down into the tunnel (the server takes her)") await _dig_face(40) check(t.dug == 1, "dug the first stretch (%d)" % t.dug) t.set_prop(player, 1) await _until(func(): return bool(t.props[1]), 3.0) check(t.props[1], "propped it, online") PN.ask("debug_tunnel", [t.STRETCHES]) await _until(func(): return t.dug == t.STRETCHES, 3.0) check(t.dug == t.STRETCHES and t.props.count(true) >= t.STRETCHES - 1, "(the rest dug by the test's cheat: down to the sea cave)") var slot := _slot("torch_stand") var floor_at: Vector3 = t._mouth(2) + Vector3(0.9, 0, 0.5) # (on the landing, beside the way down) await _tp(floor_at + Vector3(1.0, 0.1, 0)) A.place(player, slot, Transform3D(Basis(), floor_at), world) await _until(func(): return world.all_pieces().any(func(p): return p.item_id == "torch_stand"), 3.0) check(world.all_pieces().any(func(p): return p.item_id == "torch_stand" and world.underground_at(p.global_position)), "a torch stand set down there, for everyone") # (A hut with beds in the Valley, for Bob, later.) var vsite: Vector3 = world.build.debug_find_site(world._spawn + Vector3(0, 0, 14), 3.0) PN.ask("debug_hut", [vsite]) await _until(func(): return _beds().size() >= 1, 8.0) var bob_bed: int = WN.nid(_beds()[0]) if not _beds().is_empty() else 0 var bob_bed_at: Vector3 = _beds()[0].global_position if not _beds().is_empty() else Vector3.ZERO print("out to the Cove") var day0: int = world.day_night.day var stone0: int = player.inventory.count("stone") await _tp(t.cave_mouth + Vector3(0.8, 0.2, 0)) _arrived = "" world.travel("cove", "sea_cave") # (what walking into the mouth does) await _until(func(): return not _arrived.is_empty(), 45.0) check(_arrived == "cove", "the hub starts the Cove's server and sends her on (%s)" % (_arrived if not _arrived.is_empty() else "never arrived: %s" % [_toasts.slice(-3)])) if _arrived != "cove": return check(FileAccess.file_exists(SRV + "/land-cove.log"), "(the Cove's server logs beside server.json)") await _load_world() check(world.worldlet_id() == "cove", "Alice is in the Cove") check(world.sea_cave != null and player.global_position.distance_to(world.sea_cave.front) < 2.0, "on its shore, by the sea cave (%.1f m off)" % player.global_position.distance_to(world.sea_cave.front)) check(player.inventory.count("antler_pickaxe") == 1 and player.inventory.count("stone") == stone0, "with her bag as it was") check(world.day_night.day == day0, "the same day: one clock for every land") check(world.sea_cave.open(), "the sea cave leads back up (the hub says the tunnel's through)") var bob := await _bob({"do": "report"}) check(not bob.has("error") and not bob.is_empty(), "Bob carries on in the Valley, on the hub (%s)" % bob.get("error", "ok")) print("the wreck's sea chest, online") await _tp(world.wreck.front + Vector3(0, 0.1, 0)) _toasts.clear() world.wreck.chest.interact(player) await _until(func(): return player.inventory.count("treasure_map") == 1, 4.0) check(player.inventory.count("treasure_map") == 1, "Alice's share: the server gives her a treasure map") await _until(func(): return world.wreck.had(world.my_id()), 3.0) check(world.wreck.chest.interact_prompt(player).contains("empty"), "and her game knows the chest's empty for her now") check(world.map_marks().any(func(m): return m.kind == "treasure"), "her X is on her map") print("digging up the treasure, online") await _sleep(0.8) var mound: Node3D = world.treasure.mounds.get(world.my_id()) check(mound != null, "her mound shows at her X") if mound != null: await _tp(mound.global_position + Vector3(1.4, 0.2, 0)) _hold("antler_pickaxe") await _sleep(0.3) for _i in range(12): if player.inventory.count("ship_compass") > 0: break PN.ask("debug_stats", [{"stamina": 100.0}]) if is_instance_valid(mound): A.harvest_hit(player, mound, mound.global_position) await _sleep(0.15) await _until(func(): return player.inventory.count("ship_compass") == 1, 4.0) check(player.inventory.count("ship_compass") == 1 and player.inventory.count("cutlass") == 1, "the server digs it up for her: the compass and the cutlass in her bag") await _until(func(): return world.my_id() in world.treasure.dug, 3.0) check(world.my_id() in world.treasure.dug and not world.treasure.mounds.has(world.my_id()), "and her mound's gone") await _across_lands(bob_bed, bob_bed_at) print("back up to the Valley") _arrived = "" await _tp(world.sea_cave.front) world.sea_cave.interact(player) await _until(func(): return not _arrived.is_empty(), 30.0) check(_arrived == "valley", "into the sea cave: back to the hub (%s)" % _arrived) if _arrived != "valley": return await _load_world() check(world.worldlet_id() == "valley" and world.underground_at(player.global_position) and player.global_position.distance_to(world.tunnel.bottom) < 2.5, "arriving at the bottom of the tunnel") print("leave from the Cove, and come back") await _tp(world.tunnel.cave_mouth + Vector3(0.8, 0.2, 0)) _arrived = "" world.travel("cove", "sea_cave") await _until(func(): return not _arrived.is_empty(), 30.0) await _load_world() var spot: Vector3 = world.sea_cave.front + Vector3(4.0, 0.0, 2.0) spot.y = world.terrain.sample_height(spot.x, spot.z) + 0.1 await _tp(spot) world.queue_free() await process_frame world = null Net.leave() await _sleep(1.5) _arrived = "" r = await _join() await _until(func(): return not _arrived.is_empty(), 30.0) check(_arrived == "cove", "joining the hub again sends her straight on to the Cove (%s)" % _arrived) if _arrived == "cove": await _load_world() check(world.worldlet_id() == "cove" and player.global_position.distance_to(spot) < 1.5, "where she left off (%.1f m off)" % player.global_position.distance_to(spot)) print("the Cove's server crashes") var why := {"text": ""} var on_left := func(t): why.text = t Net.left.connect(on_left) var out: Array = [] OS.execute("lsof", ["-nP", "-t", "-iUDP:%d" % (PORT + 1)], out) var pid := String(out[0] if not out.is_empty() else "").strip_edges().get_slice("\n", 0) check(pid.is_valid_int(), "(found the Cove's server: pid %s)" % pid) if pid.is_valid_int(): OS.kill(int(pid)) await _until(func(): return not String(why.text).is_empty(), 15.0) Net.left.disconnect(on_left) check(String(why.text).begins_with("Lost the connection to the Cove"), "Alice is told what happened (%s)" % why.text) world.queue_free() await process_frame world = null _arrived = "" r = await _join() await _until(func(): return not _arrived.is_empty(), 30.0) check(_arrived == "cove", "joining again: the hub starts the Cove's server afresh and sends her back (%s)" % _arrived) if _arrived == "cove": await _load_world() check(world.worldlet_id() == "cove" and player.inventory.count("antler_pickaxe") == 1, "in the Cove, with her things") print("an admin in the Cove kicks someone in the Valley") await _until(func(): return Net.roster.any(func(e): return String(e.name) == "Bob"), 4.0) root.get_node("Chat").say("/kick Bob") await _until(func(): return not Net.roster.any(func(e): return String(e.name) == "Bob"), 6.0) check(not Net.roster.any(func(e): return String(e.name) == "Bob"), "Bob's gone from the server (%s)" % [Net.roster.map(func(e): return e.name)]) _finished = true # --- J3b: chat, the player list, commands and sleep across lands -------------------------- func _across_lands(bob_bed: int, bob_bed_at: Vector3) -> void: var C: Node = root.get_node("Chat") print("chat across lands") await _bob({"do": "say", "text": "hello from the Valley"}) await _until(func(): return C.heard.has("Bob: hello from the Valley"), 4.0) check(C.heard.has("Bob: hello from the Valley"), "Bob in the Valley says hello; Alice hears it in the Cove") await _sleep(1.2) # (chat's flood gap) C.say("hi from the Cove") var heard := false for _i in range(15): var h := await _bob({"do": "heard"}) if (h.get("heard", []) as Array).has("Alice: hi from the Cove"): heard = true break await _sleep(0.2) check(heard, "and Bob hears her back") print("the player list") await _until(func(): return Net.roster.size() >= 2, 4.0) var where := {} for e in Net.roster: where[String(e.name)] = String(e.land) check(where.get("Alice", "") == "cove" and where.get("Bob", "") == "valley", "everyone, and where they are (%s)" % [where]) C.say("/players") await _until(func(): return C.heard.any(func(l): return String(l).begins_with("2 online")), 4.0) var line: String = "" for l in C.heard: if String(l).begins_with("2 online"): line = String(l) check(line.contains("Bob in the Valley") and line.contains("Alice (admin) in the Cove"), "/players from the Cove: %s" % line) C.say("/set wolf_grace_days 5") await _until(func(): return int(Net.server_info.get("wolf_grace_days", 0)) == 5, 4.0) check(int(Net.server_info.get("wolf_grace_days", 0)) == 5, "an admin's /set from the Cove: the hub keeps server.json, and the Cove's rules follow") print("sleep needs everyone, in every land") await _bob({"do": "ask", "what": "debug_time", "args": [world.day_night.day, 21.0]}) await _until(func(): return world.day_night.hours() > 20.5, 15.0) check(world.day_night.hours() > 20.5, "night in the Valley is night in the Cove (%s)" % world.day_night.clock_text()) var day_n: int = world.day_night.day var site: Vector3 = world.build.debug_find_site(player.global_position + Vector3(0, 0, 14), 3.0) PN.ask("debug_hut", [site]) await _until(func(): return _beds().size() >= 1, 8.0) check(not _beds().is_empty() and bob_bed != 0, "beds in the Cove and the Valley") if _beds().is_empty() or bob_bed == 0: return await _bob({"do": "tp", "to": [bob_bed_at.x, bob_bed_at.y + 0.1, bob_bed_at.z + 0.9]}) await _bob({"do": "bed", "nid": bob_bed}) await _bob({"do": "bed", "nid": bob_bed}) await _until(func(): return C.heard.any(func(l): return String(l).begins_with("Bob is in bed")), 4.0) check(C.heard.has("Bob is in bed (1 of 2 asleep)"), "Bob gets into bed in the Valley; Alice hears it in the Cove (%s)" % [C.heard.slice(-2)]) await _sleep(1.0) check(world.day_night.hours() > 20.5, "and the night goes on: Alice is still up, in another land") var mine: Node3D = _beds()[0] await _tp(mine.global_position + Vector3(0, 0.1, 0.9)) _toasts.clear() A.use_bed(player, mine) await _sleep(0.5) A.use_bed(player, mine) await _until(func(): return world.day_night.hours() < 7.0, 5.0) check(world.day_night.hours() >= 5.9 and world.day_night.hours() < 7.0 and world.day_night.day == day_n + 1, "Alice gets into bed in the Cove, and the night passes for both (%s, day %d)" % [world.day_night.clock_text(), world.day_night.day]) var life := await _bob({"do": "life"}) check(float(life.get("hours", 0)) >= 5.9 and float(life.get("hours", 0)) < 7.0, "Bob wakes to morning in the Valley too (%.1f h)" % float(life.get("hours", 0))) func _beds() -> Array: return WN.pieces.values().filter(func(p): return is_instance_valid(p) and p.item_id == "bed") # --- helpers ------------------------------------------------------------------------- ## (Re)loads the world for whichever server we're on now. func _load_world() -> void: if world != null and is_instance_valid(world): world.queue_free() for _i in range(3): await process_frame var r0: int = WN.received world = load("res://scenes/world.tscn").instantiate() root.add_child(world) for _i in range(8): await physics_frame player = world.player player.set_physics_process(false) await _until(func(): return WN.received > r0, 10.0) await _sleep(0.3) func _tp(at: Vector3) -> void: PN.ask("debug_tp", [at]) await _until(func(): return player.global_position.distance_to(at) < 0.5, 3.0) await _sleep(0.2) ## Swings at the tunnel's face (whichever it is now) until it gives. func _dig_face(limit: int) -> void: var t: Node3D = world.tunnel var stage0: int = t.dug if t.opened else -1 for _i in range(limit): var f: Node3D = t._face if f == null or not is_instance_valid(f) or (t.dug if t.opened else -1) != stage0: break PN.ask("debug_stats", [{"stamina": 100.0}]) A.harvest_hit(player, f, f.global_position) await _sleep(0.12) await _until(func(): return (t.dug if t.opened else -1) != stage0, 3.0) await _sleep(0.3) func _slot(id: String) -> int: for i in range(player.inventory.size()): var s = player.inventory.get_slot(i) if s != null and s.id == id: return i return -1 func _hold(id: String) -> void: var i := _slot(id) if i >= 0: player.select(i) PN.ask("select", [i]) func _join() -> String: var state := {"done": false, "reason": ""} var ok := func(): state.done = true var bad := func(why): state.done = true; state.reason = why Net.joined.connect(ok) Net.join_failed.connect(bad) Net.join("127.0.0.1:%d" % PORT) var t := 0.0 while not state.done and t < 25.0: await _sleep(0.05) t += 0.05 Net.joined.disconnect(ok) Net.join_failed.disconnect(bad) return state.reason if state.done else "test timed out" func _new_bob() -> Dictionary: _bob_dir = "%s/b1" % BOBS DirAccess.make_dir_recursive_absolute(_bob_dir) _cmd = 0 _spawn(["--identity", DIR + "/bob_lands_id", "--name", "Bob", "--address", "127.0.0.1:%d" % PORT, "--dir", _bob_dir], "res://tests/net_world_bot.gd") return await _bob_result(0, 30.0) func _bob(cmd: Dictionary) -> Dictionary: _cmd += 1 var f := FileAccess.open("%s/cmd_%d.json" % [_bob_dir, _cmd], FileAccess.WRITE) f.store_string(JSON.stringify(cmd)) f.close() return await _bob_result(_cmd, 20.0) func _bob_result(n: int, limit: float) -> Dictionary: var path := "%s/res_%d.json" % [_bob_dir, n] var t := 0.0 while not FileAccess.file_exists(path) and t < limit: await _sleep(0.05) t += 0.05 await _sleep(0.05) var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) if FileAccess.file_exists(path) else null return d if d is Dictionary else {"error": "Bob gave no answer to command %d" % n} func _spawn(user_args: Array, script: String = "", bin: String = "") -> int: var args := PackedStringArray(["--headless"]) if bin.is_empty(): args.append_array(["--path", ProjectSettings.globalize_path("res://")]) if not script.is_empty(): args.append_array(["--script", script]) args.append("--") for x in user_args: args.append(str(x)) args.append_array(["--exit-with", str(OS.get_process_id())]) var pid := OS.create_process(OS.get_executable_path() if bin.is_empty() else bin, args) _pids.append(pid) return pid func _until(cond: Callable, limit: float) -> bool: var t := 0.0 while not cond.call() and t < limit: await _sleep(0.05) t += 0.05 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)