extends SceneTree ## P5 online: the Old Stag on a server, fought by three players. Alice (this ## test, with the real world scene) and two bots, Bob and Cara ## (tests/net_world_bot.gd, each its own process with its own world), on a ## real headless server with --debug-cheats (to set the clock, give items, ## move players and hold the stag still). ## ## Checks: each of them can set a key piece in the shared altar; the stag is ## tougher for three; everyone sees the same stag; all three players' hits ## count; when it falls, all three are named, on every player's game; the ## antler goes to whoever picks it up first. ## Run: godot --headless --path . --script res://tests/test_net_boss.gd const PORT := 24352 const DIR := "user://test_net" const SRV := DIR + "/server_boss" const BOTS := {"Bob": DIR + "/bob_boss", "Cara": DIR + "/cara_boss"} const SEED := 777 var _fails := 0 var _finished := false var _pids: Array[int] = [] var Net: Node var WN: Node var PN: Node var CN: Node var A: Node var world: Node3D var player: CharacterBody3D var _cmd := {"Bob": 0, "Cara": 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") CN = root.get_node("CreatureNet") A = root.get_node("Actions") A.notify.connect(func(t): _toasts.append(t)) # (the server's messages arrive as toasts too) _rm_tree(SRV) for n in BOTS: _rm_tree(BOTS[n]) DirAccess.make_dir_recursive_absolute(BOTS[n]) # (No wildlife: nothing else should join the fight. Day 2: the wolves # haven't come yet.) _spawn(["--server", "--port", str(PORT), "--config", SRV + "/server.json", "--server-name", "Boss Test", "--seed", str(SEED), "--debug-cheats", "--no-creatures"]) 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) for n in BOTS: _rm_tree(BOTS[n]) 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_boss") me.name = "Alice" Net.identity = me var r := await _join() check(r == "", "Alice joins (%s)" % r) if r != "": return 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 >= 1, 10.0) for n in BOTS: _spawn(["--identity", DIR + "/" + n.to_lower() + "_boss_id", "--name", n, "--address", "127.0.0.1:%d" % PORT, "--dir", BOTS[n]], "res://tests/net_world_bot.gd") for n in BOTS: var b0 := await _bot_result(n, 0, 40.0) check(bool(b0.get("ok", false)), "%s joins (%s)" % [n, b0.get("why", "ok")]) if not bool(b0.get("ok", false)): return await _keys() await _call() await _fight() await _prize() world.queue_free() await process_frame Net.leave() _finished = true var c := Vector3.ZERO ## the stones' centre func _ground(p: Vector3) -> Vector3: return Vector3(p.x, world.terrain.sample_height(p.x, p.z) + 0.05, p.z) func _keys() -> void: print("three players, three key pieces") check(world.stones.found, "Alice's game has the same standing stones") c = world.stones.center() PN.ask("debug_time", [2, 22.0]) await _tp(_ground(c + Vector3(2.2, 0, 0))) await _bot("Bob", {"do": "tp", "to": _v(_ground(c + Vector3(-2.2, 0, 0)))}) await _bot("Cara", {"do": "tp", "to": _v(_ground(c + Vector3(0, 0, 2.2)))}) PN.ask("debug_clear", []) await _give("barrow_key", 1) _toasts.clear() check(A.use_stones(player, world.stones), "Alice sets hers") await _until(func(): return world.stones.keys == 1, 3.0) check(world.stones.keys == 1 and _toast_has("1 of 3"), "the altar has one (%d; %s)" % [world.stones.keys, _toasts]) for n in BOTS: await _bot(n, {"do": "ask", "what": "debug_clear", "args": []}) await _bot(n, {"do": "ask", "what": "debug_give", "args": ["barrow_key", 1]}) await _bot(n, {"do": "ask", "what": "stones", "args": []}) await _until(func(): return world.stones.keys == 3, 4.0) check(world.stones.keys == 3, "then Bob and Cara set theirs: Alice sees all three") var bs := await _bot("Bob", {"do": "stones"}) check(int(bs.get("keys", -1)) == 3, "and so does Bob") func _call() -> void: print("calling it for three") _toasts.clear() check(A.use_stones(player, world.stones), "Alice calls the Old Stag") var found := {"s": null} await _until(func(): for cid in CN.puppets: if CN.puppets[cid].kind == "old_stag" and not CN.puppets[cid].is_dead(): found.s = CN.puppets[cid] return found.s != null, 6.0) var s: Node3D = found.s check(s != null, "it appears on Alice's screen (from the server)") if s == null: return stag_cid = int(s.get_meta("cid")) check(absf(s.hp - 1980.0) < 0.5, "with 1980 health: 60%% more for each of the two extra players (%.0f)" % s.hp) check(_toast_has("Old Stag has come"), "everyone's told (%s)" % [_toasts]) var life := await _bot("Cara", {"do": "life"}) var seen: Dictionary = (life.get("puppets", {}) as Dictionary).get(str(stag_cid), {}) check(not seen.is_empty() and seen.kind == "old_stag" and _p(seen.pos).distance_to(s.global_position) < 4.0, "Cara sees the same stag, in the same place") var stag_cid := -1 func _stag() -> Node3D: var s: Variant = CN.puppets.get(stag_cid) return s if is_instance_valid(s) else null func _fight() -> void: print("three players hit it") var s := _stag() if s == null: return PN.ask("debug_boss", ["hold", 60.0]) await _sleep(0.5) s = _stag() var at: Vector3 = s.global_position await _tp(_ground(at + Vector3(2.4, 0, 0))) await _bot("Bob", {"do": "tp", "to": _v(_ground(at + Vector3(-2.4, 0, 0)))}) await _bot("Cara", {"do": "tp", "to": _v(_ground(at + Vector3(0, 0, 2.4)))}) PN.ask("debug_clear", []) var slot := await _give("iron_spear", 1) player.select(slot) PN.ask("debug_stats", [{"health": 100.0, "stamina": 100.0}]) await _sleep(0.4) var hp0: float = s.hp check(A.melee(player, s), "Alice strikes with an iron spear") await _until(func(): return _stag() != null and _stag().hp < hp0 - 1.0, 3.0) check(_stag() != null and absf(hp0 - _stag().hp - 39.0) < 1.0, "39 (26, half as much again while it's stunned): %.0f" % (hp0 - _stag().hp)) for n in BOTS: await _bot(n, {"do": "ask", "what": "debug_clear", "args": []}) await _bot(n, {"do": "ask", "what": "debug_give", "args": ["iron_spear", 1]}) await _bot(n, {"do": "ask", "what": "debug_stats", "args": [{"stamina": 100.0}]}) await _bot(n, {"do": "ask", "what": "melee", "args": [stag_cid]}) await _until(func(): return _stag() != null and _stag().hp < hp0 - 116.0, 3.0) check(_stag() != null and absf(hp0 - _stag().hp - 117.0) < 1.5, "Bob's and Cara's blows count too: 117 in all (%.0f)" % (hp0 - _stag().hp)) func _prize() -> void: print("it falls") var s := _stag() if s == null: return var at: Vector3 = s.global_position _toasts.clear() PN.ask("debug_boss", ["hp", 5.0]) await _sleep(0.3) await _bot("Cara", {"do": "ask", "what": "debug_stats", "args": [{"stamina": 100.0}]}) await _bot("Cara", {"do": "ask", "what": "melee", "args": [stag_cid]}) await _until(func(): return _toast_has("has fallen"), 4.0) check(_toast_has("Beaten by Alice, Bob and Cara"), "everyone who fought it is named: %s" % [_toasts]) await _until(func(): return world.stones.beaten, 3.0) check(world.stones.beaten and world.stones.beaten_by == "Alice, Bob and Cara" and world.stones.fighters.size() == 3, "Alice's game remembers it: beaten by \"%s\"" % world.stones.beaten_by) var bs := await _bot("Bob", {"do": "stones"}) check(bool(bs.get("beaten", false)) and String(bs.get("beaten_by", "")) == "Alice, Bob and Cara" and int(bs.get("keys", -1)) == 0, "so does Bob's, and the key pieces are spent") var drops := {"antler": "", "trophy": ""} await _until(func(): for nid in WN.dropped: var d: Node3D = WN.dropped[nid] if is_instance_valid(d) and d.global_position.distance_to(at) < 5.0: if d.item_id == "stag_antler": drops.antler = "#%d" % nid elif d.item_id == "stag_trophy": drops.trophy = "#%d" % nid return not drops.antler.is_empty() and not drops.trophy.is_empty(), 4.0) check(not drops.antler.is_empty() and not drops.trophy.is_empty(), "its antler and trophy lie where it fell, for everyone to see") if drops.antler.is_empty(): return var took := await _bot("Bob", {"do": "take", "key": drops.antler}) var has := false for st in took.get("inv", []): if st != null and String(st.get("id", "")) == "stag_antler": has = true check(has, "Bob gets there first: the antler is his") await _until(func(): return not WN.dropped.has(int(drops.antler.substr(1))), 3.0) check(not WN.dropped.has(int(drops.antler.substr(1))), "and it's gone from the ground for Alice") # --- helpers ----------------------------------------------------------------------- func _toast_has(s: String) -> bool: for t in _toasts: if t.contains(s): return true return false func _tp(at: Vector3) -> void: PN.ask("debug_tp", [at + Vector3(0, 0.05, 0)]) await _until(func(): return player.global_position.distance_to(at) < 1.0, 2.0) player.set_physics_process(false) await _sleep(0.3) 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 _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() 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 _bot(n: String, cmd: Dictionary) -> Dictionary: _cmd[n] += 1 var f := FileAccess.open("%s/cmd_%d.json" % [BOTS[n], _cmd[n]], FileAccess.WRITE) f.store_string(JSON.stringify(cmd)) f.close() return await _bot_result(n, _cmd[n], 20.0) func _bot_result(n: String, i: int, limit: float) -> Dictionary: var path := "%s/res_%d.json" % [BOTS[n], i] 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": "%s gave no answer to command %d" % [n, i], "puppets": {}} 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 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)