J3a: the journey on a server. A server per land (Lands: the hub starts land servers on demand over a private link), the cliff tunnel online, crossings with one-time passes, shared characters that remember their land, one clock from the hub; test_net_lands
This commit is contained in:
298
tests/test_net_lands.gd
Normal file
298
tests/test_net_lands.gd
Normal file
@@ -0,0 +1,298 @@
|
||||
extends SceneTree
|
||||
## J3a (docs/WORLDLETS_DESIGN.md: a server per land): the journey on a server.
|
||||
##
|
||||
## A real headless server (the hub, the Valley, with --debug-cheats), Alice
|
||||
## (this test) and Bob (tests/net_world_bot.gd), who stays in the Valley.
|
||||
## Alice digs the cliff tunnel's seam and first stretch online (the server
|
||||
## decides, and her game sees the rock change), props it, goes down, lights
|
||||
## it. With the tunnel dug through, she walks out of the sea cave's mouth:
|
||||
## the hub starts the Cove's server, she's sent on there with a pass, and
|
||||
## arrives on the Cove's shore with her bag, on the same day and hour, while
|
||||
## Bob carries on in the Valley. Back up the sea cave to the hub; then out
|
||||
## to the Cove again, leave the game, and join the hub again: she's sent
|
||||
## straight on to the Cove, where she left off.
|
||||
## Run: godot --headless --path . --script res://tests/test_net_lands.gd
|
||||
|
||||
const PORT := 24371
|
||||
const DIR := "user://test_net"
|
||||
const SRV := DIR + "/server_lands"
|
||||
const BOBS := DIR + "/bob_lands"
|
||||
const SEED := 1337
|
||||
|
||||
var _fails := 0
|
||||
var _finished := false
|
||||
var _pids: Array[int] = []
|
||||
var Net: Node
|
||||
var WN: Node
|
||||
var PN: Node
|
||||
var A: Node
|
||||
var L: Node
|
||||
var world: Node3D
|
||||
var player: CharacterBody3D
|
||||
var _bob_dir := ""
|
||||
var _cmd := 0
|
||||
var _toasts: Array[String] = []
|
||||
var _arrived := ""
|
||||
|
||||
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")
|
||||
L = root.get_node("Lands")
|
||||
A.notify.connect(func(t): _toasts.append(t))
|
||||
Net.arrived.connect(func(l): _arrived = l)
|
||||
for d in [SRV, BOBS]:
|
||||
_rm_tree(d)
|
||||
_spawn(["--server", "--port", str(PORT), "--config", SRV + "/server.json",
|
||||
"--server-name", "Lands Test", "--seed", str(SEED), "--debug-cheats", "--no-creatures"])
|
||||
await _sleep(4.0)
|
||||
await _body()
|
||||
if world != null and is_instance_valid(world):
|
||||
world.queue_free()
|
||||
await process_frame
|
||||
Net.leave()
|
||||
await _sleep(0.5)
|
||||
for pid in _pids:
|
||||
if OS.is_process_running(pid):
|
||||
OS.kill(pid)
|
||||
check(_finished, "test ran to the end (no script errors)")
|
||||
await _sleep(1.0)
|
||||
if _fails == 0 or OS.get_environment("KEEP_SRV").is_empty():
|
||||
_rm_tree(SRV)
|
||||
_rm_tree(BOBS)
|
||||
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_lands")
|
||||
me.name = "Alice"
|
||||
Net.identity = me
|
||||
var r := await _join()
|
||||
check(r == "", "Alice joins the hub (%s)" % r)
|
||||
if r != "":
|
||||
return
|
||||
await _load_world()
|
||||
check(world.worldlet_id() == "valley" and world.tunnel != null, "the Valley, with its cliff tunnel, online")
|
||||
var b0 := await _new_bob()
|
||||
check(bool(b0.get("ok", false)), "Bob joins too (%s)" % b0.get("why", "ok"))
|
||||
PN.ask("debug_give", ["antler_pickaxe", 1])
|
||||
PN.ask("debug_give", ["wood", 10])
|
||||
PN.ask("debug_give", ["torch_stand", 2])
|
||||
await _sleep(0.6)
|
||||
_hold("antler_pickaxe")
|
||||
await _sleep(0.3)
|
||||
|
||||
print("digging the seam, online")
|
||||
var t: Node3D = world.tunnel
|
||||
await _tp(t.seam_out + Vector3(0, 0.2, 0))
|
||||
var seam: Node3D = t._face
|
||||
check(seam != null and seam.stage == 0, "the seam, as the server sees it")
|
||||
await _dig_face(30)
|
||||
check(t.opened, "dug open: the server says so, and Alice's game has the opening")
|
||||
t.go_in(player) # (asks the server, which moves her)
|
||||
await _until(func(): return world.underground_at(player.global_position), 3.0)
|
||||
check(world.underground_at(player.global_position), "down into the tunnel (the server takes her)")
|
||||
await _dig_face(40)
|
||||
check(t.dug == 1, "dug the first stretch (%d)" % t.dug)
|
||||
t.set_prop(player, 1)
|
||||
await _until(func(): return bool(t.props[1]), 3.0)
|
||||
check(t.props[1], "propped it, online")
|
||||
PN.ask("debug_tunnel", [t.STRETCHES])
|
||||
await _until(func(): return t.dug == t.STRETCHES, 3.0)
|
||||
check(t.dug == t.STRETCHES and t.props.count(true) >= t.STRETCHES - 1, "(the rest dug by the test's cheat: down to the sea cave)")
|
||||
var slot := _slot("torch_stand")
|
||||
var floor_at: Vector3 = t._mouth(2) + Vector3(0.9, 0, 0.5) # (on the landing, beside the way down)
|
||||
await _tp(floor_at + Vector3(1.0, 0.1, 0))
|
||||
A.place(player, slot, Transform3D(Basis(), floor_at), world)
|
||||
await _until(func(): return world.all_pieces().any(func(p): return p.item_id == "torch_stand"), 3.0)
|
||||
check(world.all_pieces().any(func(p): return p.item_id == "torch_stand" and world.underground_at(p.global_position)),
|
||||
"a torch stand set down there, for everyone")
|
||||
|
||||
print("out to the Cove")
|
||||
var day0: int = world.day_night.day
|
||||
var stone0: int = player.inventory.count("stone")
|
||||
await _tp(t.cave_mouth + Vector3(0.8, 0.2, 0))
|
||||
_arrived = ""
|
||||
world.travel("cove", "sea_cave") # (what walking into the mouth does)
|
||||
await _until(func(): return not _arrived.is_empty(), 45.0)
|
||||
check(_arrived == "cove", "the hub starts the Cove's server and sends her on (%s)" % (_arrived if not _arrived.is_empty() else "never arrived: %s" % [_toasts.slice(-3)]))
|
||||
if _arrived != "cove":
|
||||
return
|
||||
check(FileAccess.file_exists(SRV + "/land-cove.log"), "(the Cove's server logs beside server.json)")
|
||||
await _load_world()
|
||||
check(world.worldlet_id() == "cove", "Alice is in the Cove")
|
||||
check(world.sea_cave != null and player.global_position.distance_to(world.sea_cave.front) < 2.0,
|
||||
"on its shore, by the sea cave (%.1f m off)" % player.global_position.distance_to(world.sea_cave.front))
|
||||
check(player.inventory.count("antler_pickaxe") == 1 and player.inventory.count("stone") == stone0, "with her bag as it was")
|
||||
check(world.day_night.day == day0, "the same day: one clock for every land")
|
||||
check(world.sea_cave.open(), "the sea cave leads back up (the hub says the tunnel's through)")
|
||||
var bob := await _bob({"do": "report"})
|
||||
check(not bob.has("error") and not bob.is_empty(), "Bob carries on in the Valley, on the hub (%s)" % bob.get("error", "ok"))
|
||||
|
||||
print("back up to the Valley")
|
||||
_arrived = ""
|
||||
await _tp(world.sea_cave.front)
|
||||
world.sea_cave.interact(player)
|
||||
await _until(func(): return not _arrived.is_empty(), 30.0)
|
||||
check(_arrived == "valley", "into the sea cave: back to the hub (%s)" % _arrived)
|
||||
if _arrived != "valley":
|
||||
return
|
||||
await _load_world()
|
||||
check(world.worldlet_id() == "valley" and world.underground_at(player.global_position)
|
||||
and player.global_position.distance_to(world.tunnel.bottom) < 2.5, "arriving at the bottom of the tunnel")
|
||||
|
||||
print("leave from the Cove, and come back")
|
||||
await _tp(world.tunnel.cave_mouth + Vector3(0.8, 0.2, 0))
|
||||
_arrived = ""
|
||||
world.travel("cove", "sea_cave")
|
||||
await _until(func(): return not _arrived.is_empty(), 30.0)
|
||||
await _load_world()
|
||||
var spot: Vector3 = world.sea_cave.front + Vector3(4.0, 0.0, 2.0)
|
||||
spot.y = world.terrain.sample_height(spot.x, spot.z) + 0.1
|
||||
await _tp(spot)
|
||||
world.queue_free()
|
||||
await process_frame
|
||||
world = null
|
||||
Net.leave()
|
||||
await _sleep(1.5)
|
||||
_arrived = ""
|
||||
r = await _join()
|
||||
await _until(func(): return not _arrived.is_empty(), 30.0)
|
||||
check(_arrived == "cove", "joining the hub again sends her straight on to the Cove (%s)" % _arrived)
|
||||
if _arrived == "cove":
|
||||
await _load_world()
|
||||
check(world.worldlet_id() == "cove" and player.global_position.distance_to(spot) < 1.5,
|
||||
"where she left off (%.1f m off)" % player.global_position.distance_to(spot))
|
||||
_finished = true
|
||||
|
||||
# --- helpers -------------------------------------------------------------------------
|
||||
|
||||
## (Re)loads the world for whichever server we're on now.
|
||||
func _load_world() -> void:
|
||||
if world != null and is_instance_valid(world):
|
||||
world.queue_free()
|
||||
for _i in range(3):
|
||||
await process_frame
|
||||
var r0: int = WN.received
|
||||
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 > r0, 10.0)
|
||||
await _sleep(0.3)
|
||||
|
||||
func _tp(at: Vector3) -> void:
|
||||
PN.ask("debug_tp", [at])
|
||||
await _until(func(): return player.global_position.distance_to(at) < 0.5, 3.0)
|
||||
await _sleep(0.2)
|
||||
|
||||
## Swings at the tunnel's face (whichever it is now) until it gives.
|
||||
func _dig_face(limit: int) -> void:
|
||||
var t: Node3D = world.tunnel
|
||||
var stage0: int = t.dug if t.opened else -1
|
||||
for _i in range(limit):
|
||||
var f: Node3D = t._face
|
||||
if f == null or not is_instance_valid(f) or (t.dug if t.opened else -1) != stage0:
|
||||
break
|
||||
PN.ask("debug_stats", [{"stamina": 100.0}])
|
||||
A.harvest_hit(player, f, f.global_position)
|
||||
await _sleep(0.12)
|
||||
await _until(func(): return (t.dug if t.opened else -1) != stage0, 3.0)
|
||||
await _sleep(0.3)
|
||||
|
||||
func _slot(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 _hold(id: String) -> void:
|
||||
var i := _slot(id)
|
||||
if i >= 0:
|
||||
player.select(i)
|
||||
PN.ask("select", [i])
|
||||
|
||||
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 _new_bob() -> Dictionary:
|
||||
_bob_dir = "%s/b1" % BOBS
|
||||
DirAccess.make_dir_recursive_absolute(_bob_dir)
|
||||
_cmd = 0
|
||||
_spawn(["--identity", DIR + "/bob_lands_id", "--name", "Bob", "--address", "127.0.0.1:%d" % PORT, "--dir", _bob_dir],
|
||||
"res://tests/net_world_bot.gd")
|
||||
return await _bob_result(0, 30.0)
|
||||
|
||||
func _bob(cmd: Dictionary) -> Dictionary:
|
||||
_cmd += 1
|
||||
var f := FileAccess.open("%s/cmd_%d.json" % [_bob_dir, _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_dir, 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}
|
||||
|
||||
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 _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()
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user