Files
Emberwild/tests/test_net_rules.gd

424 lines
16 KiB
GDScript

extends SceneTree
## M2.6: server rules, chat and admin commands. Two players on a dedicated
## server: chat; who may use and take down whose things (locks, friends,
## raiding); PvP off and on; what dying costs (drop_all, keep); the clock
## and the weather; kicking, banning and unbanning.
##
## A real headless server (with --debug-cheats), Alice (this test: the first
## player, so the admin) and Bob (tests/net_world_bot.gd).
## Run: godot --headless --path . --script res://tests/test_net_rules.gd
const PORT := 24349
const DIR := "user://test_net"
const SRV := DIR + "/server7"
const BOBS := DIR + "/bob_rules"
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 A: Node
var C: Node
var world: Node3D
var player: CharacterBody3D
var _bob_dir := ""
var _cmd := 0
var _bobs := 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")
A = root.get_node("Actions")
C = root.get_node("Chat")
A.notify.connect(func(t): _toasts.append(t))
for d in [SRV, BOBS]:
_rm_tree(d)
_spawn(["--server", "--port", str(PORT), "--config", SRV + "/server.json",
"--server-name", "Rules 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)
_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")
me.name = "Alice"
Net.identity = me
var r := await _join()
check(r == "", "Alice joins, the first player, so she's the admin (%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)
var b0 := await _new_bob()
check(bool(b0.get("ok", false)), "Bob joins (%s)" % b0.get("why", "ok"))
if not bool(b0.get("ok", false)):
return
await _chat()
await _locks()
await _pvp()
await _death()
await _clock_and_weather()
await _kick_and_ban()
world.queue_free()
await process_frame
Net.leave()
_finished = true
# --- chat ------------------------------------------------------------------------
func _chat() -> void:
print("chat")
C.say("hello everyone")
var h := await _bob_heard("Alice: hello everyone")
check(h, "Alice says hello: Bob sees 'Alice: hello everyone'")
await _bob({"do": "say", "text": "hi Alice"})
await _until(func(): return C.heard.has("Bob: hi Alice"), 2.0)
check(C.heard.has("Bob: hi Alice"), "and Alice sees Bob's answer")
await _bob({"do": "say", "text": "/rules"})
check(await _bob_heard_prefix("PvP off · raiding off · locks owner_and_friends"), "Bob asks for /rules: co-op by default (PvP off, raiding off, locks for owner and friends)")
await _bob({"do": "say", "text": "/set pvp true"})
check(await _bob_heard("Only admins can use /set"), "Bob isn't an admin: he can't /set")
# --- locks, friends, raiding ---------------------------------------------------------
func _locks() -> void:
print("locks and friends")
var site: Vector3 = world.build.debug_find_site(player.global_position + Vector3(8, 0, 0), 3.0)
await _tp(site + Vector3(-2.5, 0, 0))
var chest := await _place("chest", site)
var door := await _place("wood_door", site + Vector3(0, 0, 4))
check(chest != null and door != null, "Alice places a chest and a door")
if chest == null or door == null:
return
check(String(chest.get_meta("owner_name", "")) == "Alice", "they're hers (the server says so, and everyone's game knows)")
var ss: int = await _give("stone", 6)
A.transfer(player, player.inventory, ss, chest.inventory, chest)
await _until(func(): return chest.inventory.count("stone") == 6, 2.0)
await _bob({"do": "tp", "to": _v(site + Vector3(2.0, 0, 0))})
var took := await _bob({"do": "chest_take", "nid": WN.nid(chest), "slot": 0})
check(_count(took.get("inv", []), "stone") == 0 and chest.inventory.count("stone") == 6, "Bob can't take from Alice's chest (locked)")
await _bob({"do": "ask", "what": "door", "args": [WN.nid(door)]})
await _sleep(0.3)
check(not door.open, "or open her door")
await _bob({"do": "ask", "what": "dismantle", "args": [WN.nid(door)]})
await _sleep(0.3)
check(WN.pieces.has(WN.nid(door)), "or take it down (raiding is off)")
C.say("/friend Bob")
check(await _bob_heard("Alice added you as a friend: you can use their doors, chests and beds"), "Alice makes Bob a friend")
took = await _bob({"do": "chest_take", "nid": WN.nid(chest), "slot": 0})
check(_count(took.get("inv", []), "stone") == 6, "now he can take from her chest")
await _bob({"do": "ask", "what": "door", "args": [WN.nid(door)]})
await _until(func(): return door.open, 2.0)
check(door.open, "and open her door")
C.say("/unfriend Bob")
await _sleep(0.4)
await _bob({"do": "ask", "what": "door", "args": [WN.nid(door)]})
await _sleep(0.4)
check(door.open, "unfriended: the door stays as it was")
print("raiding")
C.say("/set raiding true")
check(await _bob_heard("Alice set raiding to true"), "Alice turns raiding on (everyone's told)")
var dn: int = WN.nid(door)
await _bob({"do": "ask", "what": "dismantle", "args": [dn]})
await _until(func(): return not WN.pieces.has(dn), 2.0)
check(not WN.pieces.has(dn), "now Bob can take her door down")
C.say("/set raiding false")
await _sleep(0.4)
## Places `id` on the ground at `at` (build pieces snap) through Actions.
func _place(id: String, at: Vector3) -> Node3D:
var def: Dictionary = root.get_node("ItemDB").get_item(id)
var xf := Transform3D(Basis(), Vector3(at.x, world.terrain.sample_height(at.x, at.z), at.z))
if def.place.has("kind"):
var hit := {"position": xf.origin, "normal": Vector3.UP, "collider": world.terrain.get_node("TerrainBody")}
var res: Dictionary = world.build.placement(def, hit, 0.0)
if not res.ok:
print(" (can't place %s: %s)" % [id, res.reason])
return null
xf = res.xf
var slot: int = await _give(id, 1)
var before: Array = WN.pieces.keys()
A.place(player, slot, xf, world)
var found := {"p": null}
await _until(func():
for k in WN.pieces:
if not k in before and WN.pieces[k].item_id == id:
found.p = WN.pieces[k]
return true
return false, 3.0)
return found.p
# --- PvP ---------------------------------------------------------------------------
func _pvp() -> void:
print("PvP")
PN.ask("debug_stats", [{"health": 100.0}])
await _bob({"do": "ask", "what": "debug_give", "args": ["club", 1]})
await _bob({"do": "ask", "what": "debug_stats", "args": [{"stamina": 100.0}]})
var near := player.global_position + Vector3(1.4, 0, 0)
near.y = world.terrain.sample_height(near.x, near.z)
await _bob({"do": "tp", "to": _v(near)})
var slot := await _bob_slot("club")
await _bob({"do": "ask", "what": "select", "args": [slot]})
await _until(func(): return player.survival.health >= 99.0, 2.0)
var h0: float = player.survival.health
var hurts0: int = PN.hurts
await _bob({"do": "ask", "what": "hit_player", "args": [Net.local_peer]})
await _sleep(0.6)
check(PN.hurts == hurts0 and player.survival.health >= h0 - 0.5, "with PvP off, Bob's club does nothing to Alice")
C.say("/set pvp true")
await _until(func(): return bool(Net.server_info.get("pvp", false)), 2.0)
check(bool(Net.server_info.get("pvp", false)), "Alice turns PvP on, and every game hears the new rules")
await _bob({"do": "ask", "what": "hit_player", "args": [Net.local_peer]})
await _until(func(): return PN.hurts > hurts0, 2.0)
await _sleep(0.3)
check(PN.hurts > hurts0 and player.survival.health < h0 - 5.0, "now his club hurts her (health %.0f)" % player.survival.health)
# An arrow, too.
await _bob({"do": "ask", "what": "debug_give", "args": ["bow", 1]})
await _bob({"do": "ask", "what": "debug_give", "args": ["arrow", 3]})
var far := player.global_position + Vector3(7.0, 0, 0)
far.y = world.terrain.sample_height(far.x, far.z)
await _bob({"do": "tp", "to": _v(far)})
await _bob({"do": "ask", "what": "select", "args": [await _bob_slot("bow")]})
var h1: float = player.survival.health
var aim: Vector3 = (player.global_position + Vector3(0, 1.0, 0)) - (far + Vector3(0, 1.6, 0))
await _bob({"do": "ask", "what": "shoot", "args": [{"v": _v(aim.normalized())}, 1.0]})
await _until(func(): return player.survival.health < h1 - 1.0, 3.0)
check(player.survival.health < h1 - 1.0, "and so does an arrow (health %.0f)" % player.survival.health)
C.say("/set pvp false")
await _sleep(0.4)
# --- death penalties -------------------------------------------------------------------
func _death() -> void:
print("death penalties")
C.say("/set death_penalty drop_all")
await _sleep(0.4)
await _clear()
await _give("stone", 5)
var at := player.global_position
var graves0 := _graves()
var d0: int = WN.dropped.size()
PN.ask("debug_die")
await _until(func(): return WN.dropped.size() > d0, 3.0)
check(_graves() == graves0 and WN.dropped.size() > d0 and player.inventory.count("stone") == 0,
"drop_all: no grave, her stone is lying on the ground where she fell, for anyone")
var near_drop: bool = WN.dropped.values().any(func(d): return is_instance_valid(d) and d.item_id == "stone" and d.global_position.distance_to(at) < 3.0)
check(near_drop, "(right where she died)")
await _until(func(): return not world.dead_waiting, 6.0)
C.say("/set death_penalty keep")
await _sleep(0.4)
await _give("flint", 4)
PN.ask("debug_die")
await _until(func(): return world.dead_waiting, 3.0)
await _until(func(): return not world.dead_waiting, 6.0)
await _sleep(0.4)
check(player.inventory.count("flint") == 4 and _graves() == graves0, "keep: she wakes with her flint, and there's no grave")
C.say("/set death_penalty grave")
await _sleep(0.4)
func _graves() -> int:
return WN.pieces.values().filter(func(p): return is_instance_valid(p) and p.item_id == "grave").size()
# --- clock and weather ---------------------------------------------------------------
func _clock_and_weather() -> void:
print("the clock and the weather")
C.say("/time 22:30")
await _until(func(): return absf(world.day_night.hours() - 22.5) < 0.2, 2.0)
check(absf(world.day_night.hours() - 22.5) < 0.2, "/time 22:30: it's night for Alice (%s)" % world.day_night.clock_text())
var life := await _bob({"do": "life"})
check(absf(float(life.get("hours", 0.0)) - 22.5) < 0.2, "and for Bob (%.2f h)" % float(life.get("hours", 0.0)))
C.say("/weather storm")
await _until(func(): return world.weather.state == "storm", 3.0)
life = await _bob({"do": "life"})
check(world.weather.state == "storm" and String(life.get("weather", "")) == "storm", "/weather storm: a storm for both (Alice %s, Bob %s)" % [world.weather.state, life.get("weather", "?")])
C.say("/weather auto")
await _sleep(0.4)
check(world.weather.override == "", "/weather auto: back to the island's own weather")
# --- kick, ban ---------------------------------------------------------------------------
func _kick_and_ban() -> void:
print("kick and ban")
C.say("/kick Bob")
await _until(func(): return Net.players.values().all(func(p): return p.name != "Bob"), 4.0)
check(Net.players.values().all(func(p): return p.name != "Bob"), "/kick Bob: he's gone")
var b := await _new_bob()
check(bool(b.get("ok", false)), "he can come back")
C.say("/ban Bob")
await _until(func(): return Net.players.values().all(func(p): return p.name != "Bob"), 4.0)
check(Net.players.values().all(func(p): return p.name != "Bob"), "/ban Bob: gone again")
b = await _new_bob()
check(not bool(b.get("ok", true)) and String(b.get("why", "")).contains("banned"), "and can't come back (%s)" % b.get("why", ""))
C.say("/unban Bob")
await _sleep(0.5)
b = await _new_bob()
check(bool(b.get("ok", false)), "/unban Bob: he can join again")
await _bob({"do": "leave"})
# --- helpers --------------------------------------------------------------------
## A fresh Bob (his own command folder; the same identity every time).
func _new_bob() -> Dictionary:
_bobs += 1
_bob_dir = "%s/b%d" % [BOBS, _bobs]
DirAccess.make_dir_recursive_absolute(_bob_dir)
_cmd = 0
_spawn(["--identity", DIR + "/bob", "--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_heard(line: String) -> bool:
for _i in range(15):
var h := await _bob({"do": "heard"})
if (h.get("heard", []) as Array).has(line):
return true
await _sleep(0.2)
return false
func _bob_heard_prefix(start: String) -> bool:
for _i in range(15):
var h := await _bob({"do": "heard"})
if (h.get("heard", []) as Array).any(func(l): return String(l).begins_with(start)):
return true
await _sleep(0.2)
return false
func _bob_slot(id: String) -> int:
var rep := await _bob({"do": "report"})
var inv: Array = rep.get("inv", [])
for i in range(inv.size()):
if inv[i] != null and inv[i].id == id:
return i
return -1
## Teleports Alice and makes sure it sticks (test_net_life.gd's _tp says why).
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 _clear() -> void:
PN.ask("debug_clear")
await _until(func(): return player.inventory.slots.all(func(s): return s == null), 3.0)
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 _count(inv: Array, id: String) -> int:
var n := 0
for s in inv:
if s != null and s.id == id:
n += int(s.count)
return n
static func _v(p: Vector3) -> Array:
return [p.x, p.y, p.z]
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, "heard": [], "inv": [], "pieces": {}}
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)