Ники, общий рейтинг (топ-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
This commit is contained in:
2026-10-29 12:09:33 +05:00
co-authored by Claude Sonnet 5.5
parent d38152ea97
commit 666bebfb60
31 changed files with 2014 additions and 44 deletions
+217
View File
@@ -0,0 +1,217 @@
#!/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)