extends SceneTree ## M2.5: wildlife on a server. The server runs the animals and the wolves; ## both players see the same ones. Hunting (club and bow), butchering, ## fishing and sleeping all happen on the server, and a wolf pack hunts two ## players at once. ## ## A real headless server (with --debug-cheats, so the test can set the ## clock, give items, spawn an animal and move players), Alice (this test, ## with the real world scene) and Bob (tests/net_world_bot.gd, another ## process with his own world, following this test's commands). ## Run: godot --headless --path . --script res://tests/test_net_life.gd const PORT := 24348 const DIR := "user://test_net" const SRV := DIR + "/server6" const BOB := DIR + "/bob_life" 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 CN: Node var A: Node var world: Node3D var player: CharacterBody3D var _cmd := 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)) for d in [SRV, BOB]: _rm_tree(d) DirAccess.make_dir_recursive_absolute(BOB) _spawn(["--server", "--port", str(PORT), "--config", SRV + "/server.json", "--server-name", "Life Test", "--seed", str(SEED), "--debug-cheats"]) 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 _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 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) _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 (%s)" % b0.get("why", "ok")) if not bool(b0.get("ok", false)): return PN.ask("debug_stats", [{"health": 100.0, "hunger": 90.0}]) await _wildlife() await _club() await _bow() await _fishing() await _sleeping() await _wolves() world.queue_free() await process_frame Net.leave() _finished = true # --- the same animals for everyone ------------------------------------------------ func _wildlife() -> void: print("the server's wildlife") await _until(func(): return CN.puppets.size() >= 5, 8.0) check(CN.puppets.size() >= 5, "animals appear around Alice: %d, from the server" % CN.puppets.size()) check(not world.creatures.enabled and world.creatures.alive().is_empty(), "none of them are her game's own") # (Bob may only just have joined: give his first snapshots a moment.) var life := await _bob({"do": "life"}) for _i in range(10): if (life.puppets as Dictionary).size() >= 5: break await _sleep(0.3) life = await _bob({"do": "life"}) var same := 0 var close := 0 for cid in CN.puppets: var mine: Node3D = CN.puppets[cid] var his: Dictionary = life.puppets.get(str(cid), {}) if his.is_empty(): continue same += 1 if _p(his.pos).distance_to(mine.global_position) < 3.0: close += 1 check(same >= mini(CN.puppets.size(), life.puppets.size()) - 1 and same >= 3, "Bob sees the same ones (%d in common of %d / %d)" % [same, CN.puppets.size(), life.puppets.size()]) # (two may be caught mid-dash between updates on a busy machine: the full suite) check(close >= same - 2, "in the same places (%d of %d within 3 m)" % [close, same]) ## A boar on the server, `dist` metres in front of Alice. Returns her view of it. func _boar(dist: float) -> Node3D: var fwd := -player.global_basis.z fwd.y = 0.0 fwd = fwd.normalized() var at := player.global_position + fwd * dist at.y = world.terrain.sample_height(at.x, at.z) var before: Array = CN.puppets.keys() PN.ask("debug_spawn", ["boar", at]) var found := {"c": null} # (lambdas capture locals by value: use a dictionary) await _until(func(): for cid in CN.puppets: if not cid in before and CN.puppets[cid].kind == "boar": found.c = CN.puppets[cid] return true return false, 3.0) return found.c func _club() -> void: print("a club and a boar") var site: Vector3 = world.build.debug_find_site(player.global_position + Vector3(10, 0, 0), 3.0) await _tp(site) await _give("club", 1) player.select(_slot_of("club")) await _sleep(0.3) var boar := await _boar(2.2) check(boar != null, "a boar on the server shows up in front of Alice") if boar == null: return var cid := int(boar.get_meta("cid")) var hurts0: int = PN.hurts var swings := 0 var t := 0.0 while not boar.is_dead() and t < 15.0: var to: Vector3 = boar.global_position - player.global_position player.rotation.y = atan2(-to.x, -to.z) if to.length() > 2.2: await Bot.walk(player, boar.global_position - to.normalized() * 1.6, world) A.melee(player, boar) swings += 1 await _sleep(0.6) t += 0.6 check(boar.is_dead(), "Alice clubs it to death on the server (%d swings)" % swings) check(PN.hurts > hurts0, "it fought back: the server's boar gored her (%d hits)" % (PN.hurts - hurts0)) var life := await _bob({"do": "life"}) var his: Dictionary = life.puppets.get(str(cid), {}) check(String(his.get("state", "")) == "dead", "Bob sees the carcass too") var meat0: int = player.inventory.count("raw_meat") A.butcher(player, boar) await _until(func(): return player.inventory.count("raw_meat") > meat0, 2.0) check(player.inventory.count("raw_meat") >= meat0 + 3 and player.inventory.count("hide") >= 1, "she butchers it: %d meat and %d hide from the server" % [player.inventory.count("raw_meat") - meat0, player.inventory.count("hide")]) await _until(func(): return not CN.puppets.has(cid), 3.0) check(not CN.puppets.has(cid), "and the carcass is gone") func _bow() -> void: print("a bow") PN.ask("debug_stats", [{"health": 100.0, "stamina": 100.0}]) await _give("bow", 1) await _give("arrow", 10) player.select(_slot_of("bow")) await _sleep(0.3) var boar := await _boar(9.0) check(boar != null, "another boar, 9 m off") if boar == null: return var cid := int(boar.get_meta("cid")) var seen0: int = CN.projectiles_seen var bob0: Dictionary = await _bob({"do": "life"}) var shots := 0 while boar.hp >= 60.0 - 0.01 and shots < 6: # (it may wander between shots) var aim: Vector3 = (boar.global_position + Vector3(0, 0.55, 0)) - (player.global_position + Vector3(0, 1.6, 0)) player.rotation.y = atan2(-aim.x, -aim.z) if A.can_draw(player): A.shoot(player, Vector3.ZERO, aim.normalized(), 1.0, world) shots += 1 await _sleep(1.0) check(boar.hp < 60.0, "an arrow hits it (%d shot%s, it's at %.0f HP on Alice's screen)" % [shots, "" if shots == 1 else "s", boar.hp]) check(player.inventory.count("arrow") == 10 - shots, "the server took %d arrow%s from her quiver" % [shots, "" if shots == 1 else "s"]) check(CN.projectiles_seen > seen0, "she saw her arrow fly") var life := await _bob({"do": "life"}) check(int(life.projectiles) > int(bob0.projectiles), "so did Bob") var his: Dictionary = life.puppets.get(str(cid), {}) check(float(his.get("hp", 60.0)) < 60.0, "and on his screen it's hurt too (%.0f HP)" % float(his.get("hp", 60.0))) func _fishing() -> void: print("fishing") var lake: Vector2 = world.terrain.lake_center var c3 := Vector3(lake.x, 0, lake.y) var dir := (player.global_position - c3) dir.y = 0.0 dir = dir.normalized() var shore := c3 for i in range(200): shore = c3 + dir * float(i) if world.terrain.sample_height(shore.x, shore.z) > world.terrain.water_level + 0.4: break shore += dir * 1.0 shore.y = world.terrain.sample_height(shore.x, shore.z) await _tp(shore) player.rotation.y = atan2(dir.x, dir.z) # facing the water await _give("fishing_rod", 1) player.select(_slot_of("fishing_rod")) await _sleep(0.3) var caught := false var tries := 0 while not caught and tries < 3: tries += 1 A.cast_line(player, Vector3.ZERO, -dir + Vector3(0, -0.1, 0), world) await _until(func(): return world.bobber != null and world.bobber.state == "floating", 4.0) if tries == 1: check(world.bobber != null and world.bobber.state == "floating", "Alice casts: the server's float lands on the lake, and she sees it") var life := await _bob({"do": "life"}) check((life.floats as Array).any(func(f): return int(f) == Net.local_peer), "Bob sees her float too") await _until(func(): return world.bobber != null and world.bobber.state == "biting", 25.0) if world.bobber != null and world.bobber.state == "biting": var fish0: int = player.inventory.count("perch") + player.inventory.count("trout") A.reel(player, world) await _until(func(): return player.inventory.count("perch") + player.inventory.count("trout") > fish0, 2.0) caught = player.inventory.count("perch") + player.inventory.count("trout") > fish0 check(caught, "a bite, she reels in, and the server gives her the fish (%d cast%s)" % [tries, "" if tries == 1 else "s"]) player.select(0) await _sleep(0.5) check(world.bobber == null, "the line is in") func _sleeping() -> void: print("sleeping (the server's rule: everyone online in bed)") PN.ask("debug_time", [1, 21.0]) var site: Vector3 = world.build.debug_find_site(player.global_position + Vector3(0, 0, 14), 3.0) var n0: int = WN.pieces.size() PN.ask("debug_hut", [site]) await _until(func(): return _beds().size() >= 2, 8.0) var beds := _beds() check(beds.size() >= 2 and WN.pieces.size() > n0 + 10, "a hut with two beds (%d pieces)" % (WN.pieces.size() - n0)) if beds.size() < 2: return await _tp(beds[0].global_position + Vector3(0, 0.1, 0.9)) await _bob({"do": "tp", "to": _v(beds[1].global_position + Vector3(0, 0.1, 0.9))}) check(world.day_night.hours() > 20.5, "it's night (%s)" % world.day_night.clock_text()) _toasts.clear() A.use_bed(player, beds[0]) await _sleep(0.5) A.use_bed(player, beds[0]) await _sleep(1.0) check(_toasts.any(func(t): return t == "You're in bed: waiting for everyone else to go to bed (1 of 2 asleep)"), "Alice gets into bed: waiting for the others, 1 of 2 asleep (%s)" % [_toasts]) check(world.day_night.hours() > 20.5, "and the night goes on, because Bob's still up") await _bob({"do": "bed", "nid": WN.nid(beds[1])}) await _bob({"do": "bed", "nid": WN.nid(beds[1])}) await _until(func(): return world.day_night.hours() < 7.0, 3.0) check(world.day_night.hours() >= 5.9 and world.day_night.hours() < 7.0 and world.day_night.day == 2, "Bob gets into bed too, and the night passes for both (%s, day %d)" % [world.day_night.clock_text(), world.day_night.day]) await _until(func(): return "Rested" in player.survival.statuses, 2.0) check(_toasts.has("You slept until morning") and "Rested" in player.survival.statuses, "Alice wakes rested") var life := await _bob({"do": "life"}) check(float(life.hours) >= 5.9 and float(life.hours) < 7.0, "Bob's clock says morning too (%.1f h)" % float(life.hours)) func _beds() -> Array: return WN.pieces.values().filter(func(p): return is_instance_valid(p) and p.item_id == "bed") func _wolves() -> void: print("a wolf pack hunts both players") PN.ask("debug_time", [5, 12.0]) # by day the pack moves into the den... await _sleep(1.5) PN.ask("debug_time", [5, 22.0]) # ...and at night it hunts PN.ask("debug_stats", [{"health": 100.0, "warmth": 80.0}]) await _bob({"do": "ask", "what": "debug_stats", "args": [{"health": 100.0, "warmth": 80.0}]}) var den: Node3D = world.den check(den.found, "this island has a den (seed %d)" % SEED) if not den.found: return var out: Vector3 = Basis(Vector3.UP, den.yaw) * Vector3(0, 0, 1) var side := out.cross(Vector3.UP) var a: Vector3 = den.mouth_pos() + out * 16.0 var b: Vector3 = den.mouth_pos() + out * 16.0 + side * 10.0 a.y = world.terrain.sample_height(a.x, a.z) b.y = world.terrain.sample_height(b.x, b.z) await _tp(a) await _bob({"do": "tp", "to": _v(b)}) var a0: int = PN.hurts var life0 := await _bob({"do": "life"}) var b0 := int(life0.hurts) var t := 0.0 var bob_hurts := b0 while t < 45.0 and (PN.hurts == a0 or bob_hurts == b0): await _sleep(1.0) t += 1.0 if int(t) % 3 == 0: var l := await _bob({"do": "life"}) bob_hurts = int(l.hurts) var wolves: Array = CN.puppets.values().filter(func(c): return c.kind == "wolf") check(wolves.size() >= 2, "Alice sees the pack: %d wolves, from the server" % wolves.size()) check(PN.hurts > a0, "the wolves bite Alice (%d)" % (PN.hurts - a0)) check(bob_hurts > b0, "and Bob, 10 m away, at the same time (%d), after %.0f s" % [bob_hurts - b0, t]) if bob_hurts == b0: # (rare, and not reproduced: say what each wolf was doing, for next time) for w in wolves: print(" wolf: %s, %.1f m from Alice, %.1f m from Bob" % [w.state, (w as Node3D).global_position.distance_to(player.global_position), (w as Node3D).global_position.distance_to(b)]) check(player.survival.health < 100.0, "Alice's health is down on her screen (%.0f)" % player.survival.health) # --- helpers -------------------------------------------------------------------- ## Teleports Alice (the server moves her) and makes sure it sticks: a last ## position update her game sent from before the move can reach the server ## after it, look like a jump, and get her moved back ("too fast"). So check ## she's still there a moment later, and go again if not. func _tp(at: Vector3) -> void: for _try in range(3): 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.5) if player.global_position.distance_to(at) < 1.0: return 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 _bob(cmd: Dictionary) -> Dictionary: _cmd += 1 var f := FileAccess.open("%s/cmd_%d.json" % [BOB, _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, 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, "puppets": {}, "floats": [], "projectiles": 0, "hurts": 0, "hours": -1.0} 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)