Files
my-world/tools/test/nicks_lb_test.py
T
aibrashandClaude Sonnet 5.5 666bebfb60 Ники, общий рейтинг (топ-10 по картам), таблица Tab, сдача в гонке, таймер Parkour
- Ники: профиль user://profile.cfg (client_id + ник), панель при первом запуске и по N;
  Net.nick_of(id); ники над призраками, в HUD/итогах гонки и таблицах
- Рейтинг на сервере: лучшее время каждого игрока по картам race/parkour/metro, топ-10,
  user://leaderboard.json (атомарная запись, лимиты, проверка карты/комнаты/времени)
- Таблица лидеров по Tab, доски топ-3 над порталами в хабе, итоги гонки с общим рейтингом
- Гонка: сдача (G, удержание 1,5 с), RACE_GIVEUP; Parkour: таймер, финиш на вершине, R — перезапуск
- Новые RPC (submit_profile, recv_nick, submit_record, recv_record_result, request_leaderboard,
  recv_leaderboard): клиент и сервер выкладываются вместе
- Тесты: nicks_lb_test, race_giveup_test, parkour_run_test, nick_ui_test, ui_tour; README обновлён

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxZo3ekR5m1af437Qzqwc1
2026-10-29 12:09:33 +05:00

218 lines
15 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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": {}, "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)