- Ники: профиль 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
98 lines
6.9 KiB
Python
Executable File
98 lines
6.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Игровая проверка сдачи (клавиша G) и рейтинга в гонке: 2 клиента-бота на локальном сервере.
|
||
tools/test/race_giveup_test.py [рабочая_папка=/tmp/race_giveup]
|
||
Круг 1: A на воротах 1 держит G 1,0 с и отпускает (отмена), на воротах 2 сдаётся; B финиширует.
|
||
Ожидание: итоги одинаковы у обоих (B 1-й со временем, A «сдался»), в рейтинг попал только B.
|
||
Круг 2: A сдаётся на воротах 1, B на воротах 2 — все сдались, круг сразу заканчивается, в рейтинге по-прежнему только B.
|
||
Порт 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/race_giveup")
|
||
XDG = os.path.join(W, "xdg")
|
||
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)
|
||
pg = f"{W}/project/project.godot"
|
||
t = open(pg).read()
|
||
t = t.replace('Bot="*res://tools/test/bot.gd"', 'Bot="*res://tools/test/bot.gd"\nRaceBot="*res://tools/test/race_bot.gd"').replace('config/name="world2"', 'config/name="world2-racetest"')
|
||
open(pg, "w").write(t)
|
||
subprocess.run(["godot", "--headless", "--import", "--path", f"{W}/project"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
for n, nick, cid in (("A", "Aa", "a" * 32), ("B", "Bb", "b" * 32)):
|
||
open(f"{W}/prof_{n}.cfg", "w").write(f'[profile]\nclient_id="{cid}"\nnick="{nick}"\nasked=true\n')
|
||
|
||
slog = open(f"{W}/server.log", "w")
|
||
server = subprocess.Popen(["godot", "--headless", "--path", f"{W}/server"], stdout=slog, stderr=subprocess.STDOUT, env=dict(os.environ, XDG_DATA_HOME=XDG))
|
||
time.sleep(2.5)
|
||
def bot(n, *args):
|
||
return subprocess.Popen(["godot", "--headless", "--path", f"{W}/project", "res://maps/race/race.tscn", "--", "--client", "--race-log",
|
||
f"--race-best={W}/best_{n}.cfg", f"--profile={W}/prof_{n}.cfg", f"--name={n}", "--expect=2", "--rounds=2", *args],
|
||
stdout=open(f"{W}/{n}.log", "w"), stderr=subprocess.STDOUT)
|
||
pa = bot("A", "--gu1=cancel@1,giveup@2", "--gu2=giveup@1")
|
||
pb = bot("B", "--delay=2.0", "--gu2=giveup@2")
|
||
try:
|
||
pa.wait(timeout=420); pb.wait(timeout=60)
|
||
except subprocess.TimeoutExpired:
|
||
pa.kill(); pb.kill()
|
||
check(False, "боты не уложились во время")
|
||
time.sleep(1)
|
||
server.terminate(); server.wait(); slog.close()
|
||
L = {n: open(f"{W}/{n}.log", errors="replace").read().splitlines() for n in "AB"}
|
||
sv = open(f"{W}/server.log", errors="replace").read()
|
||
|
||
def tables(lines):
|
||
res, cur = [], []
|
||
for l in lines:
|
||
m = re.search(r"итог: место (\d+) игрок (-?\d+) время (-?[\d.]+) ворот (\d+) сдался=(\w+)", l)
|
||
if m:
|
||
if cur and int(m.group(1)) == 1:
|
||
res.append(cur); cur = []
|
||
cur.append((int(m.group(1)), int(m.group(2)), float(m.group(3)), int(m.group(4)), m.group(5)))
|
||
elif cur and "фаза -> WAIT" in l:
|
||
res.append(cur); cur = []
|
||
if cur: res.append(cur)
|
||
return res
|
||
ta, tb = tables(L["A"]), tables(L["B"])
|
||
print("таблицы A:", ta); print("таблицы B:", tb)
|
||
check(len(ta) == 2 and len(tb) == 2, "оба видели итоги двух кругов")
|
||
check(ta == tb, "итоги одинаковы у A и B в обоих кругах (включая признак «сдался»)")
|
||
if len(ta) == 2:
|
||
r1 = ta[0]
|
||
check(len(r1) == 2 and r1[0][2] > 0 and r1[0][4] == "false" and r1[1][4] == "true" and r1[1][2] < 0,
|
||
"круг 1: первым финишировавший B со временем, вторым сдавшийся A без времени")
|
||
check(len(set(x[1] for x in r1)) == 2 and r1[1][3] >= 2, "A сдался, пройдя %d ворот" % r1[1][3])
|
||
check(all(x[4] == "true" and x[2] < 0 for x in ta[1]) and len(ta[1]) == 2, "круг 2: оба сдались, времени ни у кого нет")
|
||
a = "\n".join(L["A"])
|
||
check("ОТМЕНА_УДЕРЖАНИЯ: отпускаю на" in a and re.search(r"шкала=(0\.\d+|1\.[0-4]\d*) сдался=false", a) is not None, "отмена: G отпущена через 1,0 с (< 1,5 с) — не сдался")
|
||
check(re.search(r"ПОСЛЕ_ОТМЕНЫ hold=0\.00 сдался=false", a) is not None, "после отмены шкала сброшена в 0")
|
||
sd = re.findall(r"СДАЛСЯ_БОТ unix=([\d.]+) на_старте=(\w+) заморожен=(\w+)", a + "\n" + "\n".join(L["B"]))
|
||
check(len(sd) >= 2 and all(x[1] == "true" and x[2] == "false" for x in sd), "после сдачи игрок на стартовой площадке и не заморожен (%d сдач; последний сдавшийся круга не успевает напечатать — круг кончается в тот же кадр)" % len(sd))
|
||
# круг 2: все сдались -> RESULTS в тот же кадр, а не через 45 с
|
||
race_unix = {n: [float(m.group(1)) for l in L[n] for m in [re.search(r"ФАЗА RACE, unix=([\d.]+)", l)] if m] for n in "AB"}
|
||
res_unix = {n: [float(m.group(1)) for l in L[n] for m in [re.search(r"ФАЗА RESULTS, unix=([\d.]+)", l)] if m] for n in "AB"}
|
||
print(" unix RACE:", race_unix, " RESULTS:", res_unix)
|
||
if len(res_unix["A"]) == 2 and len(race_unix["A"]) == 2:
|
||
d = res_unix["A"][1] - race_unix["A"][1]
|
||
check(d < 30.0, "круг 2 (все сдались) закончился через %.1f с после старта, не ждал 45 с" % d)
|
||
imm = False
|
||
for n in "AB":
|
||
for k, l in enumerate(L[n]):
|
||
if "СДАЛСЯ (время гонки" in l and any("фаза -> RESULTS" in x for x in L[n][k + 1:k + 4]):
|
||
imm = True
|
||
check(imm, "последняя сдача сразу привела к итогам (СДАЛСЯ -> фаза RESULTS подряд в логе)")
|
||
rec = re.findall(r"\[рекорд\] race (\S+) ([\d.]+)", sv)
|
||
print(" записи на сервере:", rec)
|
||
check(len(rec) == 1 and rec[0][0] == "Bb", "в рейтинг попала одна запись — B (сдавшийся A не записан)")
|
||
for n in "AB":
|
||
errs = [l for l in L[n] if "SCRIPT ERROR" in l or "Parse Error" in l]
|
||
check(not errs, "в логе %s нет ошибок скриптов %s" % (n, errs[:2]))
|
||
print("\nПРОВЕРКА ПРОЙДЕНА" if ok else "\nПРОВЕРКА ПРОВАЛЕНА")
|
||
sys.exit(0 if ok else 1)
|