- Карта Metro и всё, что с ней связано, удалены по решению владельца (в git она не попадала) - Из net.gd убраны id «metro» (TIMED_MAPS, MAP_TITLES), из README/тестов — упоминания - README: исправлен блок «Структура проекта» (branding/, core/, player/character), запись в журнале о Metro и вынесенных уроках Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxZo3ekR5m1af437Qzqwc1
218 lines
15 KiB
Python
Executable File
218 lines
15 KiB
Python
Executable File
#!/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, "<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": {}, "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)
|