427 lines
16 KiB
GDScript
427 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()])
|
|
# (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)
|