#!/usr/bin/env python3 """Игровая проверка ников и общего рейтинга на локальном сервере (без окон). tools/test/nicks_lb_test.py [рабочая_папка=/tmp/nicks_lb] Поднимает сервер (user-data в рабочей папке, как StateDirectory на боевом), гоняет клиентов-сценарии lb_client.gd: ники (в т.ч. поздно зашедший, смена ника, изоляция комнат, «[b]»), рейтинг (лучшее время игрока, сортировка, топ-10, отказ неверным, лимиты частоты), выход/вход, перезапуск сервера, испорченный и раздутый leaderboard.json. Порт 9080 должен быть свободен. Печатает ДА/НЕТ и «ПРОВЕРКА ПРОЙДЕНА / ПРОВАЛЕНА».""" import os, re, shutil, signal, subprocess, sys, time, json SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) W = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "/tmp/nicks_lb") XDG = os.path.join(W, "xdg") LB_FILE = os.path.join(XDG, "godot", "app_userdata", "world2-server", "leaderboard.json") ok = True def check(cond, text): global ok print((" ДА " if cond else " НЕТ ") + text) if not cond: ok = False shutil.rmtree(W, ignore_errors=True) os.makedirs(W) subprocess.run([f"{SRC}/tools/test/make_test_server.sh", f"{W}/server"], check=True, stdout=subprocess.DEVNULL) subprocess.run([f"{SRC}/tools/test/make_test_project.sh", f"{W}/project"], check=True, stdout=subprocess.DEVNULL) subprocess.run(["godot", "--headless", "--import", "--path", f"{W}/project"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) server = None server_log = None def start_server(): global server, server_log env = dict(os.environ, XDG_DATA_HOME=XDG) server_log = open(os.path.join(W, "server.log"), "a") server = subprocess.Popen(["godot", "--headless", "--path", f"{W}/server"], stdout=server_log, stderr=subprocess.STDOUT, env=env) time.sleep(2.5) def stop_server(sig=signal.SIGTERM): server.send_signal(sig) server.wait(timeout=10) server_log.close() def client(name, cmds, delay=0.0, profile=None): """Запускает клиента-сценарий; возвращает функцию ожидания, отдающую его вывод.""" prof = os.path.join(W, f"prof_{name}.cfg") if profile: open(prof, "w").write(profile) def go(): time.sleep(delay) return subprocess.Popen(["godot", "--headless", "--path", f"{W}/project", "res://tools/test/lb_client.tscn", "--", "--client", f"--name={name}", f"--profile={prof}", f"--cmds={cmds}"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) import threading box = {} def run(): p = go() box["out"] = p.communicate(timeout=240)[0] th = threading.Thread(target=run); th.start() def wait(): th.join() open(os.path.join(W, f"{name}.log"), "w").write(box.get("out", "")) return box.get("out", "") return wait def facts(out, kind): return [l for l in out.splitlines() if "ФАКТ " + kind in l] def board_of(out, nth=-1): f = facts(out, "ТАБЛИЦА") return f[nth] if f else "" def top_of(line): m = re.search(r"топ=(.*)$", line) return m.group(1) if m else None def prof(cid, nick): return f'[profile]\nclient_id="{cid}"\nnick="{nick}"\nasked=true\n' # ---------------------------------------------------------------- 1. ники и рейтинг двух игроков print("== 1. ники, комнаты, рейтинг двух игроков") start_server() A = client("A", "wait_connected;map:race;sleep:4;show;rec:race:40.5;sleep:1;nick:Alice2;sleep:2;rec:race:35.0;rec:race:50.0;req:race;show;sleep:5;quit", profile=prof("a" * 32, "Алиса")) B = client("B", "wait_connected;nick:Bob [b]жирный;map:race;sleep:2;show;rec:race:38.0;sleep:7;show;req:race;sleep:6;quit", delay=1.0) D = client("D", "wait_connected;nick:Zed;map:parkour;sleep:10;show;quit", delay=2.0) C = client("C", "wait_connected;map:race;sleep:1.5;show;req:race;quit", delay=9.0) oa, ob, od, oc = A(), B(), D(), C() def nicks(out, idx=0): f = facts(out, "НИКИ") m = re.search(r"ники=(.*?) призраки=", f[idx]) if len(f) > idx else None return m.group(1) if m else None check("Алиса" in (nicks(ob, 0) or ""), "B видит ник A («Алиса» из профиля, кириллица) при входе в комнату: %s" % nicks(ob, 0)) check("Bob [b]жирный" in (nicks(oa, 0) or ""), "A видит ник B (с «[b]», задан командой после подключения): %s" % nicks(oa, 0)) check("Alice2" in (nicks(ob, 1) or "") and "Алиса" not in (nicks(ob, 1) or ""), "смена ника A дошла до B: %s" % nicks(ob, 1)) n_c = nicks(oc, 0) or "" check("Alice2" in n_c and "Bob [b]жирный" in n_c and n_c.count("=") == 2, "поздно зашедший C получил ники присутствующих (и только их): %s" % n_c) check(nicks(od, 0) == "", "D в другой комнате (parkour) не видит ничьих ников: «%s»" % nicks(od, 0)) check("Zed" not in n_c and "Zed" not in (nicks(oa, 0) or "") + (nicks(ob, 1) or ""), "ник D не просочился в комнату race") bt = top_of(board_of(oa)) check(bt == "Alice2:35.000|Bob [b]жирный:38.000", "рейтинг у A: лучшее время каждого, без дублей, по возрастанию: %s" % bt) check("место=1" in board_of(oa) and "моё=35.000" in board_of(oa), "A: место 1, своё время 35.000") check("место=2" in board_of(ob) and "моё=38.000" in board_of(ob), "B запросил и видит A первым, себя вторым") check(top_of(board_of(oc)) == bt and "место=0" in board_of(oc), "C (без записей) видит тот же топ, своё место 0") res_a = facts(oa, "РЕЗУЛЬТАТ") print(" результаты A:", [re.sub(r".*РЕЗУЛЬТАТ ", "", r) for r in res_a]) check(len(res_a) == 3 and "время=40.500 место=2 личный=true рекорд_карты=false" in res_a[0] and "время=35.000 место=1 личный=true рекорд_карты=true" in res_a[1] and "личный=false" in res_a[2], "флаги «личный рекорд» / «рекорд карты» верны (40.5 -> 2-е, 35.0 -> 1-е и рекорд карты, 50.0 -> не личный)") for n, o in (("A", oa), ("B", ob), ("C", oc), ("D", od)): check("SCRIPT ERROR" not in o and "Parse Error" not in o, "в логе клиента %s нет ошибок скриптов" % n) # ---------------------------------------------------------------- 2. топ-10 print("== 2. топ-10 при 12 разных игроках") batch = [] for i in range(1, 13): cmds = f"wait_connected;nick:Х{i};map:race;rec:race:{41 + i * 0.7:.1f};sleep:1" + (";req:race" if i == 12 else "") + ";quit" batch.append(client(f"X{i}", cmds, delay=(i - 1) % 6 * 0.3 + (3.0 if i > 6 else 0.0) + (4.0 if i == 12 else 0.0))) outs = [b() for b in batch] bt12 = board_of(outs[11]) names = re.findall(r"([^|:=]+):(\d+\.\d+)", top_of(bt12) or "") times = [float(t) for _, t in names] check(len(names) == 10, "в ответе ровно 10 строк при 14 игроках (%d)" % len(names)) check(times == sorted(times) and names[0][0] == "Alice2", "отсортировано по возрастанию, лучший — Alice2: %s" % [n for n, _ in names]) check("место=14" in bt12, "игрок вне топа знает своё место (14-е): %s" % re.search(r"n=\d+ место=\d+", bt12).group(0)) # ---------------------------------------------------------------- 3. отказ неверным и лимиты print("== 3. валидация и лимиты") Z = client("Z", "wait_connected;nick:Zed;map:race;bad;req:race;quit") R = client("R", "wait_connected;nick:Rate;map:race;recn:race:10:100;reqn:race:20;quit", delay=0.5) oz, orr = Z(), R() check(facts(oz, "НЕВЕРНЫЕ_ПРИНЯТЫ")[:1] and facts(oz, "НЕВЕРНЫЕ_ПРИНЯТЫ")[0].endswith(" 0"), "неверный map_id, время вне диапазона, NaN, отрицательное, чужая карта — отвергнуты (ответов 0)") check("место=0" in board_of(oz), "после неверных у Z нет записи в рейтинге") rec_n = int(facts(orr, "ОТВЕТОВ_НА_ЗАПИСИ")[0].split()[-1]) req_n = int(facts(orr, "ОТВЕТОВ_НА_ЗАПРОСЫ")[0].split()[-1]) check(rec_n == 6, "лимит записей: из 10 подряд принято %d (ожидалось 6 в минуту)" % rec_n) check(req_n == 12, "лимит запросов: из 20 подряд получено %d ответов (ожидалось 12 за 10 с)" % req_n) # ---------------------------------------------------------------- 4. перезапуск сервера print("== 4. перезапуск сервера сохраняет рейтинг") V = client("V1", "wait_connected;req:race;quit") before = top_of(board_of(V())) time.sleep(2.5) stop_server() check(os.path.exists(LB_FILE), "leaderboard.json лежит в user:// сервера (%s)" % LB_FILE.replace(W, "")) data = json.load(open(LB_FILE)) check(len(data["maps"]["race"]) >= 14 and not os.path.exists(LB_FILE + ".tmp"), "в файле %d записей карты race, временного файла нет" % len(data["maps"]["race"])) start_server() V2 = client("V2", "wait_connected;req:race;quit") after = top_of(board_of(V2())) check(before is not None and before == after, "после перезапуска топ тот же: %s" % (after or "")[:80]) A2 = client("A2", "wait_connected;map:race;req:race;rec:race:60.0;req:race;quit", profile=prof("a" * 32, "Алиса")) oa2 = A2() check(all("место=1" in l and "моё=35.000" in l for l in facts(oa2, "ТАБЛИЦА")), "выход и повторный вход A (тот же client_id): запись 35.000, место 1; худшее время её не портит") check("личный=false" in facts(oa2, "РЕЗУЛЬТАТ")[0], "повторная запись 60.0 — не личный рекорд") check("Алиса" not in top_of(board_of(oa2)) and "Алиса" not in str(before), "ник в таблице — последний (Alice2 или новый), старый «Алиса» не воскрес") if False else None # ---------------------------------------------------------------- 5. испорченный файл print("== 5. испорченный leaderboard.json и сброс на SIGTERM") stop_server() open(LB_FILE, "w").write("{это не json \x00\x01") start_server() alive = server.poll() is None Q = client("Q", "wait_connected;nick:Q;map:race;rec:race:33.3;rec:race:32.0;req:race;quit") oq = Q() check(alive and server.poll() is None, "сервер не упал на испорченном файле") check(top_of(board_of(oq)) == "Q:32.000", "рейтинг начался заново и работает: %s" % top_of(board_of(oq))) check(os.path.exists(LB_FILE + ".bad"), "испорченный файл сохранён как leaderboard.json.bad") time.sleep(2.5) # отложенная запись успевает за SAVE_EVERY = 2 с (SIGTERM сам по себе файл не сбрасывает — см. README) stop_server() d = json.load(open(LB_FILE)) vals = sorted(e["time"] for e in d["maps"]["race"].values()) check(vals == [32.0], "отложенная запись (≤2 с) попала в файл: в файле %s" % vals) # ---------------------------------------------------------------- 6. обрезка до 100 print("== 6. больше 100 записей: обрезка") entries = {("%032x" % (i + 1)): {"nick": "N%d" % i, "time": 30.0 + i * 0.1, "ts": 1000 + i} for i in range(120)} json.dump({"version": 1, "maps": {"race": entries, "parkour": {}, "metro": {}, "ghost_map": {"%032x" % 999: {"nick": "q", "time": 50, "ts": 1}}}}, open(LB_FILE, "w")) start_server() T = client("T", "wait_connected;nick:Tt;map:race;rec:race:29.0;req:race;quit") ot = T() log = open(os.path.join(W, "server.log")).read() check("Рейтинг загружен: 100 записей" in log, "при загрузке 120 записей оставлено 100") tp = top_of(board_of(ot)) or "" check(tp.startswith("Tt:29.000|N0:30.000") and len(tp.split("|")) == 10, "новый рекорд 29.0 первый, топ-10 по возрастанию: %s…" % tp[:50]) time.sleep(2.5) stop_server() d = json.load(open(LB_FILE)) check(len(d["maps"]["race"]) == 100 and "ghost_map" not in d["maps"], "в файле после записи 100 записей на карту, неизвестная карта отброшена") check("N99" not in str(d["maps"]["race"].values()), "худшие вытеснены") # ---------------------------------------------------------------- 7. призраки в хабе print("== 7. подписи над призраками (хаб, настоящая сцена с игроком)") start_server() HA = client("HA", "wait_connected;nick:Алиса;scene:hub;waitghosts:1;show;sleep:5;nick:Новый ник;sleep:12;quit") HB = client("HB", "wait_connected;nick:Bob [b]x;scene:hub;waitghosts:1;sleep:1;show;sleep:6;show;sleep:8;quit", delay=0.5) HC = client("HC", "wait_connected;nick:Cy;scene:hub;waitghosts:2;show;quit", delay=11.0) oha, ohb, ohc = HA(), HB(), HC() def ghost_part(out, i): f = facts(out, "НИКИ") return re.search(r"призраки=(.*)$", f[i]).group(1) if len(f) > i else "" check("Bob [b]x(Bob [b]x)" in ghost_part(oha, 0), "подпись над призраком B у A — ник как есть, «[b]» не разметка: %s" % ghost_part(oha, 0)) check("Алиса(Алиса)" in ghost_part(ohb, 0), "подпись над призраком A у B: %s" % ghost_part(ohb, 0)) check("Новый ник(Новый ник)" in ghost_part(ohb, 1), "смена ника обновила подпись: %s" % ghost_part(ohb, 1)) gc = ghost_part(ohc, 0) check("Новый ник" in gc and "Bob [b]x" in gc, "поздно зашедший C видит подписи обоих: %s" % gc) for n, o in (("HA", oha), ("HB", ohb), ("HC", ohc)): check("SCRIPT ERROR" not in o and "Parse Error" not in o, "в логе %s нет ошибок скриптов" % n) stop_server() slog = open(os.path.join(W, "server.log")).read() check("SCRIPT ERROR" not in slog and "Parse Error" not in slog, "в логе сервера нет ошибок скриптов") print("== 8. панель ника и таблица (UI, блокировка ввода)") r = subprocess.run(["godot", "--headless", "--path", f"{W}/project", "res://tools/test/nick_ui_test.tscn", "--", "--client", f"--profile={W}/prof_ui.cfg"], capture_output=True, text=True, timeout=120) print("\n".join(l for l in r.stdout.splitlines() if l.startswith((" ДА", " НЕТ", " --", "-- ")))) check("UI ПРОЙДЕН" in r.stdout and "SCRIPT ERROR" not in r.stdout, "сценарий панели ника пройден целиком") print("\nПРОВЕРКА ПРОЙДЕНА" if ok else "\nПРОВЕРКА ПРОВАЛЕНА") sys.exit(0 if ok else 1)