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 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 _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)