extends SceneTree ## M2.3: the shared world. Two players on a dedicated server see the same ## island change: a tree one of them fells (and its regrowth), pickups and ## dropped items (first come, first served), building pieces (and walking ## into them, for real and by cheating), doors, chests, fires, dismantling, ## and the whole lot surviving a server restart. ## ## M2.4: the server decides. Alice's inventory, crafting, eating and stats ## live on the server; a game that edits its own bag gets nothing for it; ## graves belong to whoever died; characters are saved per player. ## ## A real headless server, plus "Bob" (tests/net_world_bot.gd) as another ## process with his own world, following this test's commands. This test is ## "Alice" with the real world scene. ## Run: godot --headless --path . --script res://tests/test_net_shared.gd const PORT := 24346 const DIR := "user://test_net" const SRV := DIR + "/server5" const BOB := DIR + "/bob_cmds" const SEED := 777 const Bot = preload("res://tests/net_world_bot.gd") var _fails := 0 var _finished := false var _pids: Array[int] = [] var Net: Node var WN: Node var PN: Node var A: Node var world: Node3D var player: CharacterBody3D var _cmd := 0 var _server_pid := 0 var _toasts: Array[String] = [] 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") A.notify.connect(func(t): _toasts.append(t)) for d in [SRV, BOB]: _rm_tree(d) DirAccess.make_dir_recursive_absolute(BOB) _start_server() await _sleep(4.0) await _body() Net.stop() for pid in _pids: if OS.is_process_running(pid): OS.kill(pid) check(_finished, "test ran to the end (no script errors)") _rm_tree(SRV) _rm_tree(BOB) print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails]) quit(1 if _fails > 0 else 0) func _start_server() -> void: # --debug-regrow: things grow back in seconds instead of half an hour. _server_pid = _spawn(["--server", "--port", str(PORT), "--config", SRV + "/server.json", "--server-name", "Shared Test", "--seed", str(SEED), "--debug-regrow", "0.002", "--debug-cheats"]) func _body() -> void: var me = load("res://scripts/net/identity.gd").open(DIR + "/a") me.name = "Alice" Net.identity = me var r := await _join() check(r == "", "Alice joins (%s)" % r) if r != "": return await _load_world() check(WN.received >= 1 and WN.world == world, "and gets the server's island") print("Bob, with his own world") _spawn(["--identity", DIR + "/bob", "--name", "Bob", "--address", "127.0.0.1:%d" % PORT, "--dir", BOB], "res://tests/net_world_bot.gd") var b0 := await _bob_result(0, 30.0) check(bool(b0.get("ok", false)), "Bob joins and loads it too (%s)" % b0.get("why", "ok")) if not bool(b0.get("ok", false)): return await _server_decides() await _trees() await _building() await _containers_and_fires() await _items_on_the_ground() await _restart() _finished = true # --- the server decides ----------------------------------------------------------- func _server_decides() -> void: print("the server decides") await _sleep(1.0) check(not player.survival.is_processing() and player.survival.hunger < 100.0 and player.survival.hunger > 90.0, "Alice's stats run on the server and come to her (hunger %.2f)" % player.survival.hunger) await _give("stick", 2) await _give("flint", 2) A.craft(player, "stone_axe") await _until(func(): return player.inventory.count("stone_axe") == 1, 2.0) check(player.inventory.count("stone_axe") == 1 and player.inventory.count("stick") == 0 and player.inventory.count("flint") == 0, "she crafts a stone axe: the server takes the sticks and flint and gives her the axe") # A modified game puts walls in its own bag and tries to build with them. var fake := 7 player.inventory.set_slot(fake, {"id": "wood_wall", "count": 5}) var n0: int = WN.pieces.size() var front := player.global_position - player.global_basis.z * 3.0 A.place(player, fake, Transform3D(Basis(), Vector3(front.x, world.terrain.sample_height(front.x, front.z), front.z)), world) await _sleep(0.8) check(WN.pieces.size() == n0 and player.inventory.get_slot(fake) == null, "walls a game invents in its own bag build nothing, and the server's copy of her bag replaces it") var bs: int = await _give("berries", 3) PN.ask("debug_stats", [{"hunger": 50.0, "diet": {"plants": 10.0}}]) await _until(func(): return player.survival.hunger < 51.0, 2.0) A.eat(player, bs) await _until(func(): return player.survival.hunger > 55.0, 2.0) check(player.survival.hunger > 55.0 and float(player.survival.diet.plants) > 20.0 and player.inventory.count("berries") == 2, "she eats a berry on the server: hunger %.0f, greens %.0f" % [player.survival.hunger, float(player.survival.diet.plants)]) ## Test-only (the server runs with --debug-cheats): items into Alice's bag on ## the server. Returns the slot they're in. func _give(id: String, n: int) -> int: var before: int = player.inventory.count(id) PN.ask("debug_give", [id, n]) await _until(func(): return player.inventory.count(id) >= before + n, 3.0) return _slot_of(id) func _clear() -> void: PN.ask("debug_clear") await _until(func(): for s in player.inventory.slots: if s != null: return false return true, 3.0) func _slot_of(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 _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() # --- trees -------------------------------------------------------------------- func _trees() -> void: print("felling a tree") var tree := _nearest(world.props.resource_nodes.values().filter(func(n): return n.kind == "tree" and not n.depleted)) var uid: String = tree.uid var to_tree: Vector3 = (tree.global_position - player.global_position) to_tree.y = 0.0 await Bot.walk(player, tree.global_position - to_tree.normalized() * 1.6, world) player.select(_slot_of("stone_axe")) await _sleep(0.2) var wood0: int = player.inventory.count("wood") var hits := 0 var t := 0.0 while not tree.depleted and t < 20.0: if A.harvest_hit(player, tree, tree.global_position + Vector3(0, 1.0, 0)): hits += 1 await _sleep(0.25) t += 0.25 check(tree.depleted, "Alice fells it in %d hits (her world shows it when the server says so)" % hits) await _sleep(0.3) check(player.inventory.count("wood") > wood0, "and the server gives her the wood (%d)" % (player.inventory.count("wood") - wood0)) var axe = player.inventory.get_slot(_slot_of("stone_axe")) check(axe != null and root.get_node("ItemDB").durability(axe) == 60 - hits, "her axe wore down on the server: %d of 60 uses left" % (root.get_node("ItemDB").durability(axe) if axe != null else -1)) var rep := await _bob({"do": "report", "uids": [uid]}) var bn: Variant = rep.nodes.get(uid) check(bn != null and bool(bn.depleted), "Bob sees it fall too") print("regrowth (sped up for the test)") await _sleep(6.0) # (trees take 4.8 s at this speed-up) check(tree.depleted, "it doesn't grow back with Alice standing next to it") var walk_from := Time.get_ticks_msec() await Bot.walk(player, player.global_position + Vector3(14, 0, 0), world) t = 0.0 while tree.depleted and t < 15.0: await _sleep(0.2) t += 0.2 check(not tree.depleted, "once she walks off, it grows back (%.1f s after she set off)" % ((Time.get_ticks_msec() - walk_from) / 1000.0)) rep = await _bob({"do": "report", "uids": [uid]}) bn = rep.nodes.get(uid) check(bn != null and not bool(bn.depleted), "for Bob as well") # --- building ----------------------------------------------------------------- var _wall: Node3D var _door: Node3D func _building() -> void: print("building a wall") var site: Vector3 = world.build.debug_find_site(player.global_position + Vector3(0, 0, 10), 3.0) await Bot.walk(player, site + Vector3(-3.5, 0, 0), world) _wall = await _place("wood_wall", site, 0.0) check(_wall != null, "Alice puts up a wall (the server agrees and tells everyone)") if _wall == null: return var rep := await _bob({"do": "report"}) var seen := _find(rep, "wood_wall") check(not seen.is_empty() and _p(seen.pos).distance_to(_wall.global_position) < 0.01, "Bob has the same wall, in the same place") print("Bob walks into it") var n: Vector3 = _wall.global_basis.z.normalized() var base := _wall.global_position await _bob({"do": "walk", "to": _v(base + n * 2.5)}) var bump := await _bob({"do": "bump", "dir": _v(-n), "dist": 4.0}) check(float(bump.get("moved", 9.0)) < 2.3, "and it stops him (moved %.2f of 4 m)" % float(bump.get("moved", 9.0))) var hop := await _bob({"do": "hop", "to": _v(base - n * 0.6 + Vector3(0, 0.05, 0))}) var hp: Vector3 = _p(hop.get("pos", [0, 0, 0])) check(int(hop.get("corrected", 0)) >= 1 and (hp - base).dot(n) > 0.0, "a game that steps him through anyway is refused by the server, and he's put back on his side") await _dismantle() print("a door") var dsite: Vector3 = world.build.debug_find_site(site + Vector3(0, 0, 8), 2.0) await Bot.walk(player, dsite + Vector3(-2.5, 0, 0), world) _door = await _place("wood_door", dsite, 0.0) check(_door != null, "Alice puts up a door") if _door == null: return A.toggle_door(player, _door) await _sleep(0.6) check(_door.open, "she opens it") rep = await _bob({"do": "report"}) seen = _find(rep, "wood_door") check(not seen.is_empty() and bool(seen.get("open", false)), "and it's open for Bob") ## Places `id` on the ground at `at` through Actions, the way the game does. func _place(id: String, at: Vector3, yaw: float) -> Node3D: var def: Dictionary = root.get_node("ItemDB").get_item(id) var xf := Transform3D(Basis(Vector3.UP, yaw), Vector3(at.x, world.terrain.sample_height(at.x, at.z), at.z)) if def.place.has("kind"): var hit := {"position": xf.origin, "normal": Vector3.UP, "collider": world.terrain.get_node("TerrainBody")} var res: Dictionary = world.build.placement(def, hit, yaw) if not res.ok: print(" (can't place %s: %s)" % [id, res.reason]) return null xf = res.xf var slot: int = await _give(id, 1) var had: int = player.inventory.count(id) var before: Array = WN.pieces.keys() if not A.place(player, slot, xf, world): print(" (Actions.place refused %s)" % id) return null await _until(func(): return player.inventory.count(id) == had - 1, 2.0) check(player.inventory.count(id) == had - 1, "(%s taken from Alice's bag by the server)" % id) var t := 0.0 while t < 3.0: for k in WN.pieces: if not k in before and WN.pieces[k].item_id == id: return WN.pieces[k] await _sleep(0.05) t += 0.05 return null # --- chests and fires ------------------------------------------------------------ var _chest: Node3D var _fire: Node3D func _containers_and_fires() -> void: print("a shared chest") var site: Vector3 = world.build.debug_find_site(player.global_position + Vector3(6, 0, 0), 1.5) await Bot.walk(player, site + Vector3(-2.0, 0, 0), world) _chest = await _place("chest", site, 0.0) check(_chest != null, "Alice places a chest") if _chest == null: return await _clear() var ws: int = await _give("wood", 10) A.transfer(player, player.inventory, ws, _chest.inventory, _chest) await _until(func(): return _chest.inventory.count("wood") == 10, 2.0) check(player.inventory.count("wood") == 0 and _chest.inventory.count("wood") == 10, "she puts 10 wood in (shift-click)") var ss: int = await _give("stone", 3) var cur := {"c": "waiting"} var on_cur := func(c): cur.c = c PN.cursor_changed.connect(on_cur) PN.ask("inv_click", [0, ss, false]) # pick the stone up... PN.ask("inv_click", [WN.nid(_chest), 3, false]) # ...and put it in slot 4 await _until(func(): return _chest.inventory.get_slot(3) != null, 2.0) await _sleep(0.2) PN.cursor_changed.disconnect(on_cur) check(_chest.inventory.get_slot(3) != null and _chest.inventory.get_slot(3).id == "stone" and cur.c == null, "and drags 3 stone into slot 4 by hand (the server does both clicks)") var nid: int = WN.nid(_chest) await _bob({"do": "walk", "to": _v(site + Vector3(1.8, 0, 0))}) var rep := await _bob({"do": "report"}) var seen: Dictionary = rep.pieces.get(str(nid), {}) check(_count(seen.get("inv", []), "wood") == 10 and _count(seen.get("inv", []), "stone") == 3, "Bob sees what's in it") var slot := -1 for i in range(_chest.inventory.size()): var s = _chest.inventory.get_slot(i) if s != null and s.id == "wood": slot = i # (Chests are locked to their owner and friends by default: M2.6.) root.get_node("Chat").say("/friend Bob") await _sleep(0.5) var took := await _bob({"do": "chest_take", "nid": nid, "slot": slot}) check(_count(took.get("inv", []), "wood") == 10, "Bob (her friend) takes the wood out (shift-click)") root.get_node("Chat").say("/unfriend Bob") # (friends can loot each other's graves too) await _sleep(0.3) check(_chest.inventory.count("wood") == 0 and _chest.inventory.count("stone") == 3, "and it's gone from Alice's view of the chest") print("a fire") var fsite: Vector3 = world.build.debug_find_site(site + Vector3(0, 0, -5), 1.5) await Bot.walk(player, fsite + Vector3(-2.0, 0, 0), world) _fire = await _place("campfire", fsite, 0.0) check(_fire != null, "Alice lights a campfire") if _fire == null: return var f0: float = _fire.fuel await _give("wood", 2) A.add_fuel(player, _fire) await _sleep(0.5) check(_fire.fuel > f0 + 30.0, "and feeds it a log (%.0f s -> %.0f s of fuel)" % [f0, _fire.fuel]) rep = await _bob({"do": "report"}) seen = rep.pieces.get(str(WN.nid(_fire)), {}) check(absf(float(seen.get("fuel", -100.0)) - _fire.fuel) < 5.0, "Bob's fire has the same fuel (%.0f s)" % float(seen.get("fuel", -1.0))) print("a smelter") var ssite: Vector3 = world.build.debug_find_site(fsite + Vector3(0, 0, -5), 1.5) await Bot.walk(player, ssite + Vector3(-2.0, 0, 0), world) var sm := await _place("smelter", ssite, 0.0) check(sm != null, "Alice builds a smelter") if sm != null: A.transfer(player, player.inventory, await _give("iron_ore", 1), sm.inventory, sm) await _sleep(0.3) A.transfer(player, player.inventory, await _give("charcoal", 1), sm.inventory, sm) await _until(func(): return sm.inventory.count("charcoal") == 1, 2.0) check(sm.working(), "puts in ore and charcoal: it's smelting") PN.ask("debug_process", [35.0]) await _until(func(): return sm.inventory.count("iron_bar") == 1, 2.0) check(sm.inventory.count("iron_bar") == 1 and sm.inventory.count("iron_ore") == 0, "the server smelts it, and the bar shows up in Alice's view of it") await Bot.walk(player, fsite + Vector3(-2.0, 0, 0), world) await _until(func(): return "Warm" in player.survival.statuses, 2.0) check("Warm" in player.survival.statuses and player.survival.heat > 0.25, "standing by it, the server says she's Warm (heat %.2f)" % player.survival.heat) # --- things on the ground -------------------------------------------------------- func _items_on_the_ground() -> void: print("dropped items") var at := player.global_position + Vector3(1.0, 0.1, 0) PN.ask("debug_drop", ["flint", 2, at]) # (only the server drops things now) var t := 0.0 var key := "" while key.is_empty() and t < 3.0: for k in WN.dropped: if WN.dropped[k].item_id == "flint": key = "#%d" % k await _sleep(0.05) t += 0.05 check(not key.is_empty(), "2 flint dropped on the ground: it's in the shared world") var rep := await _bob({"do": "report"}) check(rep.dropped.any(func(d): return d.item == "flint" and int(d.count) == 2), "Bob sees it") await _bob({"do": "walk", "to": _v(at + Vector3(1.0, 0, 0))}) var SX: Node = root.get_node("Sfx") var heard0: int = SX.count("pickup") var took := await _bob({"do": "take", "key": key}) check(_count(took.get("inv", []), "flint") == 2, "Bob picks it up") await _sleep(0.3) check(SX.count("pickup") > heard0, "Alice hears him pick it up (the server sends the sound to players nearby)") var brep := await _bob({"do": "report"}) check(brep.get("sounds", []).has("pickup"), "and so does Bob") await _sleep(0.3) check(WN.dropped.is_empty() or not WN.dropped.has(int(key.substr(1))), "and it's gone for Alice") print("dropping (Q)") var dr := await _bob({"do": "drop", "item": "flint", "count": 1}) check(dr.get("asked", false) and _count(dr.get("inv", []), "flint") == 1, "Bob drops one flint (shift+Q): the server takes it from his bag") var seen := false t = 0.0 while not seen and t < 3.0: for k in WN.dropped: if WN.dropped[k].item_id == "flint" and int(WN.dropped[k].amount) == 1: seen = true await _sleep(0.05) t += 0.05 check(seen, "and it lands in the shared world: Alice sees it at his feet") print("first come, first served") var pk := _nearest(world.props.pickups.values().filter(func(p): return not p.taken)) await Bot.walk(player, pk.global_position + Vector3(-1.0, 0, 0), world) await _bob({"do": "walk", "to": _v(pk.global_position + Vector3(1.0, 0, 0))}) var id: String = pk.item_id var a0: int = player.inventory.count(id) _bob_send({"do": "take", "key": pk.uid}) # both reach for it at once A.pickup(player, pk) var br := await _bob_wait(_cmd) await _sleep(0.4) var a_got: int = player.inventory.count(id) - a0 var b_got: int = _count(br.get("inv", []), id) - (2 if id == "flint" else 0) check(a_got + b_got == pk.amount and (a_got == 0 or b_got == 0), "only one of them gets the %s (Alice %d, Bob %d)" % [id, a_got, b_got]) print("dying online") await _clear() await _give("stone", 7) await _give("berries", 4) var died_at := player.global_position PN.ask("debug_die") var died_t := Time.get_ticks_msec() var grave: Node3D = null var t2 := 0.0 while grave == null and t2 < 3.0: for p in WN.pieces.values(): if p.item_id == "grave": grave = p await _sleep(0.05) t2 += 0.05 await _sleep(0.3) check(grave != null and grave.global_position.distance_to(died_at) < 3.0 and player.inventory.count("stone") == 0, "Alice dies (on the server): her things go into a grave in the shared world") check(world.dead_waiting and player.global_position.distance_to(died_at) < 1.0, "she stays down on the death screen for a moment") await _until(func(): return not world.dead_waiting, 6.0) var down := (Time.get_ticks_msec() - died_t) / 1000.0 check(not world.dead_waiting and player.global_position.distance_to(died_at) > 3.0 and down >= world.RESPAWN_DELAY - 0.2, "then respawns (after %.1f s), and the server wakes her at the island start" % down) if grave == null: return var gnid: int = WN.nid(grave) var rep2 := await _bob({"do": "report"}) var g: Dictionary = rep2.pieces.get(str(gnid), {}) check(_count(g.get("inv", []), "stone") == 7 and _count(g.get("inv", []), "berries") == 4, "Bob sees the grave, with what she had") await _bob({"do": "walk", "to": _v(grave.global_position + Vector3(-1.5, 0, 0))}) var bt := await _bob({"do": "chest_take", "nid": gnid, "slot": 0}) check(_count(bt.get("inv", []), "stone") == 0 and grave.inventory.count("stone") == 7, "but it's hers: Bob can't take from it") await Bot.walk(player, grave.global_position + Vector3(1.5, 0, 0), world) for i in range(grave.inventory.size()): if is_instance_valid(grave) and grave.inventory.get_slot(i) != null: A.transfer(player, grave.inventory, i, player.inventory, grave) await _sleep(0.15) await _sleep(0.5) check(player.inventory.count("stone") == 7 and player.inventory.count("berries") == 4, "she takes it all back") check(not WN.pieces.has(gnid), "and the empty grave crumbles") rep2 = await _bob({"do": "report"}) check(not rep2.pieces.has(str(gnid)), "for Bob too") # --- dismantling --------------------------------------------------------------- func _dismantle() -> void: if _wall == null: return print("taking the wall down") # (Straight after building it, while she's still beside it: a test walk # back from somewhere else can cut through the door frame, and the # server rightly sends her back.) var nid: int = WN.nid(_wall) var w0: int = player.inventory.count("wood_wall") _toasts.clear() var ok: bool = A.dismantle(player, _wall) check(ok, "Alice dismantles the wall (%.1f m away%s)" % [player.global_position.distance_to(_wall.global_position), "" if ok else ", refused: %s" % [_toasts]]) await _sleep(0.5) check(not WN.pieces.has(nid) and player.inventory.count("wood_wall") == w0 + 1, "it's gone, and back in her bag") var rep := await _bob({"do": "report"}) check(not rep.pieces.has(str(nid)), "gone for Bob too") # --- persistence ------------------------------------------------------------------- func _restart() -> void: print("the server restarts") var expect := {} for k in WN.pieces: expect[k] = WN.pieces[k].item_id var chest_nid: int = WN.nid(_chest) if _chest != null else -1 await _give("flint", 3) # (a change, so the save comes after it) var bag: Array = player.inventory.to_data() var where := player.global_position # Killed outright while everyone's still on (like Ctrl+C, which gives # Godot no chance to save): the server saved within a few seconds of the # last change anyway. await _sleep(WN.SAVE_AFTER + 1.5) check(FileAccess.file_exists(SRV + "/worlds/server-world/world.json"), "the server saved the island soon after the last change") OS.kill(_server_pid) _pids.erase(_server_pid) await _bob({"do": "leave"}) world.queue_free() await process_frame Net.leave() await _sleep(1.0) _start_server() await _sleep(4.0) var r := await _join() check(r == "", "Alice rejoins (%s)" % r) if r != "": return await _load_world() var same: bool = expect.size() == WN.pieces.size() for k in expect: same = same and WN.pieces.has(k) and WN.pieces[k].item_id == expect[k] check(same, "everything built is still there, with the same ids (%d pieces)" % WN.pieces.size()) var c: Node3D = WN.pieces.get(chest_nid) check(c != null and c.inventory.count("stone") == 3 and c.inventory.count("wood") == 0, "the chest still has the 3 stone") var d: Node3D = null for p in WN.pieces.values(): if p.item_id == "wood_door": d = p check(d != null and d.open, "and the door is still open") await _sleep(0.5) check(player.inventory.to_data() == bag, "Alice's bag is just as she left it (her character is saved on the server)") check(player.global_position.distance_to(where) < 2.0, "and she's back where she was (%.1f m off)" % player.global_position.distance_to(where)) world.queue_free() await process_frame Net.leave() # --- helpers -------------------------------------------------------------------- func _load_world() -> void: 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) # the test moves Alice var t := 0.0 while WN.received < 1 and t < 10.0: await _sleep(0.05) t += 0.05 func _nearest(nodes: Array) -> Node3D: var best: Node3D = null for n in nodes: if best == null or n.global_position.distance_to(player.global_position) < best.global_position.distance_to(player.global_position): best = n return best func _find(rep: Dictionary, item: String) -> Dictionary: for k in rep.get("pieces", {}): if rep.pieces[k].item == item: return rep.pieces[k] return {} static func _count(inv: Array, id: String) -> int: var n := 0 for s in inv: if s != null and s.id == id: n += int(s.count) return n static func _v(p: Vector3) -> Array: return [p.x, p.y, p.z] static func _p(a: Array) -> Vector3: return Vector3(a[0], a[1], a[2]) func _bob_send(cmd: Dictionary) -> void: _cmd += 1 var f := FileAccess.open("%s/cmd_%d.json" % [BOB, _cmd], FileAccess.WRITE) f.store_string(JSON.stringify(cmd)) f.close() func _bob(cmd: Dictionary) -> Dictionary: _bob_send(cmd) return await _bob_wait(_cmd) func _bob_wait(n: int) -> Dictionary: return await _bob_result(n, 20.0) func _bob_result(n: int, limit: float) -> Dictionary: var path := "%s/res_%d.json" % [BOB, 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, "nodes": {}, "pieces": {}, "dropped": []} 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 _spawn(user_args: Array, script: String = "") -> int: var args := PackedStringArray(["--headless", "--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(), args) _pids.append(pid) return pid func _sleep(s: float) -> void: await create_timer(s).timeout ## Deletes a folder and everything in it. 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)