Files
Cinderwild/tests/test_net_life.gd

413 lines
16 KiB
GDScript

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()])
check(close >= same - 1, "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 < 4:
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.begins_with("You're in bed (1 of 2")), "Alice gets into bed: 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])
check(player.survival.health < 100.0, "Alice's health is down on her screen (%.0f)" % player.survival.health)
# --- helpers --------------------------------------------------------------------
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 _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)