Ники, общий рейтинг (топ-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
+111
View File
@@ -0,0 +1,111 @@
extends Node
# Клиент-сценарий для проверки ников и рейтинга (см. nicks_lb_test.py): выполняет команды из --cmds=a;b;c
# и печатает факты строками «[имя] ФАКТ …». Без игрока и карты, если не вызвана команда scene:hub.
# godot --headless --path <копия> res://tools/test/lb_client.tscn -- --client --name=A --profile=/tmp/a.cfg --cmds="nick:Алиса;map:race;sleep:2;show;quit"
# Команды: wait_connected, nick:<текст>, map:<id>, scene:hub, sleep:<с>, waitghosts:<n>, show, rec:<карта>:<время>,
# recn:<карта>:<сколько>:<время0> (подряд, время каждый раз на 1 с меньше), req:<карта>, reqn:<карта>:<сколько>,
# bad (заведомо неверные submit_record), last:<карта>, quit.
var label := "?"
var cmds: Array = []
var results := 0
var board_updates := 0
func _ready() -> void:
for a in OS.get_cmdline_user_args():
if a.begins_with("--name="): label = a.substr(7)
if a.begins_with("--cmds="): cmds = a.substr(7).split(";")
Net.record_result.connect(func(m, t, r, pb, mr): results += 1; say("ФАКТ РЕЗУЛЬТАТ %s время=%.3f место=%d личный=%s рекорд_карты=%s" % [m, t, r, pb, mr]))
Net.leaderboard_updated.connect(func(_m): board_updates += 1)
_run.call_deferred()
func say(text: String) -> void:
print("[%s] %s" % [label, text])
func _wait_until(cond: Callable, timeout := 8.0) -> bool:
var t := 0.0
while not cond.call():
await get_tree().create_timer(0.1).timeout
t += 0.1
if t > timeout:
return false
return true
func _board_text(m: String) -> String:
var b = Net.boards.get(m)
if b == null:
return "нет данных"
var parts := []
for e in b.top:
parts.append("%s:%.3f" % [e[0], e[1]])
return "n=%d место=%d моё=%.3f топ=%s" % [b.top.size(), b.my_rank, b.my_time, "|".join(parts)]
func _run() -> void:
for c in cmds:
var p: PackedStringArray = c.split(":", true, 3)
match p[0]:
"wait_connected":
say("ФАКТ ПОДКЛЮЧЁН %s" % await _wait_until(func(): return Net.connected))
"nick":
Net.set_nick(c.substr(5))
"map":
Net.enter_map(p[1])
say("ФАКТ КАРТА %s готова=%s" % [p[1], await _wait_until(func(): return Net.map_ready)])
"scene":
add_child((load("res://maps/hub/hub.tscn") as PackedScene).instantiate())
say("ФАКТ ХАБ готов=%s" % await _wait_until(func(): return Net.map_ready))
"sleep":
await get_tree().create_timer(float(p[1])).timeout
"waitghosts":
say("ФАКТ ПРИЗРАКИ %s" % await _wait_until(func(): return Net.ghosts.size() >= int(p[1]), 10.0))
await get_tree().create_timer(0.7).timeout
"show":
var gl := []
for id in Net.ghosts:
var g = Net.ghosts[id]
var l: Label3D = g.label
gl.append("%d=%s(%s)" % [id, l.text, Net.nick_of(id)])
var ps := []
for id in Net.nicks:
ps.append("%d=%s" % [id, Net.nicks[id]])
ps.sort()
gl.sort()
say("ФАКТ НИКИ свой=%s ники=%s призраки=%s" % [Net.nick_of(Net.own_id()), ",".join(ps), ",".join(gl)])
"rec":
Net.submit_my_record(p[1], float(p[2]))
await get_tree().create_timer(0.6).timeout
"recn":
for i in int(p[2]):
Net.submit_my_record(p[1], float(p[3]) - i)
await get_tree().create_timer(1.5).timeout
say("ФАКТ ОТВЕТОВ_НА_ЗАПИСИ %d" % results)
"req":
var before := board_updates
Net.request_board(p[1])
await _wait_until(func(): return board_updates > before, 3.0)
say("ФАКТ ТАБЛИЦА %s %s" % [p[1], _board_text(p[1])])
"reqn":
var before := board_updates
for i in int(p[2]):
Net.request_leaderboard.rpc_id(1, p[1])
await get_tree().create_timer(1.5).timeout
say("ФАКТ ОТВЕТОВ_НА_ЗАПРОСЫ %d" % (board_updates - before))
"bad":
var before := results
Net.submit_record.rpc_id(1, "zzz", 60.0) # нет такой карты
Net.submit_record.rpc_id(1, "race", 5.0) # быстрее допустимого
Net.submit_record.rpc_id(1, "race", 99999.0) # дольше допустимого
Net.submit_record.rpc_id(1, "race", NAN)
Net.submit_record.rpc_id(1, "race", -3.0)
Net.submit_record.rpc_id(1, "parkour", 90.0) # карта, на которой мы не находимся
Net.request_leaderboard.rpc_id(1, "zzz")
await get_tree().create_timer(1.5).timeout
say("ФАКТ НЕВЕРНЫЕ_ПРИНЯТЫ %d" % (results - before))
"last":
say("ФАКТ ПОСЛЕДНИЙ %s" % str(Net.last_record.get(p[1])))
"quit":
say("ГОТОВО")
get_tree().quit()
return
say("ГОТОВО")
get_tree().quit()
+1
View File
@@ -0,0 +1 @@
uid://cn2e2m283kvpr
+6
View File
@@ -0,0 +1,6 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://tools/test/lb_client.gd" id="1"]
[node name="T" type="Node"]
script = ExtResource("1")
+131
View File
@@ -0,0 +1,131 @@
extends Node
# Панель ника и таблица лидеров «по существу» (без сети и окна): настоящая карта Race, игрок, Overlay, события клавиш.
# godot --headless --path <проект> res://tools/test/nick_ui_test.tscn -- --client --profile=/tmp/ui_profile.cfg
# Проверяет: игрок двигается; после N панель открыта, а «вперёд» не двигает; R/N/Tab/G в поле — не действия (R не меняет готовность,
# Tab не открывает таблицу), буквы попадают в поле, открывающая клавиша — нет; кириллица и вставка; предел 16 символов;
# Enter сохраняет ник в профиль; после закрытия игрок снова двигается; Esc при первом запуске — пропуск без ника и «больше не спрашивать»;
# Tab открывает/закрывает таблицу, игрок при этом не заблокирован. Выводит «ДА/НЕТ», в конце «UI ПРОЙДЕН / ПРОВАЛЕН».
var ok := true
var level
var p
var rules
func check(cond: bool, text: String) -> void:
print((" ДА " if cond else " НЕТ ") + text)
if not cond:
ok = false
func key(code: int, pressed: bool, unicode := 0) -> void:
var ev := InputEventKey.new()
ev.physical_keycode = code as Key
ev.keycode = code as Key
ev.unicode = unicode
ev.pressed = pressed
Input.parse_input_event(ev)
func tap(code: int, unicode := 0) -> void:
key(code, true, unicode)
await get_tree().process_frame
key(code, false, unicode)
await get_tree().process_frame
func hold_forward(sec: float) -> float:
var start: Vector3 = p.global_position
Input.action_press("move_forward")
await get_tree().create_timer(sec).timeout
Input.action_release("move_forward")
await get_tree().create_timer(0.2).timeout
var d: Vector3 = p.global_position - start
return Vector2(d.x, d.z).length()
func _ready() -> void:
_run.call_deferred()
func _run() -> void:
level = (load("res://maps/race/race.tscn") as PackedScene).instantiate()
add_child(level)
await get_tree().create_timer(1.0).timeout
p = level.get_node("Player")
rules = level.get_node("Rules")
check(Net.nick_asked == false and Net.my_nick == "", "свежий профиль: ник не задан, не спрашивали")
check(Net.my_client_id.length() == 32, "создан client_id из 32 hex-символов (128 бит)")
var mouse_works := Input.mouse_mode == Input.MOUSE_MODE_CAPTURED
var moved := await hold_forward(0.6)
check(moved > 1.0, "без панели игрок бежит вперёд (%.2f м)" % moved)
print("-- открываем панель клавишей N")
await tap(KEY_N, 110)
await get_tree().create_timer(0.2).timeout
check(Overlay.nick_open and Net.input_blocked, "N открыла панель и поставила флаг блокировки")
check(not p.input_frozen, "флаг гонки input_frozen не тронут")
check(Overlay._edit.has_focus() and Overlay._edit.text == "", "поле в фокусе, открывающая клавиша «n» в поле не попала (текст «%s»)" % Overlay._edit.text)
moved = await hold_forward(0.7)
check(moved < 0.05, "при открытой панели «вперёд» не двигает игрока (%.3f м)" % moved)
var was_ready: bool = rules.flags_ready.get(rules.my_id, false)
await tap(KEY_R, 114)
await tap(KEY_N, 110)
await tap(KEY_TAB)
await tap(KEY_G, 103)
check(rules.flags_ready.get(rules.my_id, false) == was_ready, "R в поле не переключает «готов» в гонке")
check(Overlay.nick_open and not Overlay.lb_open, "N и Tab в поле не закрыли панель и не открыли таблицу")
check(Overlay._edit.text == "rng", "R, N, G попали в поле как буквы, Tab — нет (текст «%s»)" % Overlay._edit.text)
Overlay._edit.text = ""
Overlay._edit.insert_text_at_caret("Привет, [b]мир[/b] и ещё длинный хвост") # как вставка из буфера
check(Overlay._edit.text.length() <= 16 and Overlay._edit.text.begins_with("Привет"), "вставка с кириллицей обрезана до 16 символов: «%s»" % Overlay._edit.text)
Overlay._edit.text = ""
await tap(KEY_B, 1080) # «и» с unicode — как набор на русской раскладке
await tap(KEY_K, 1082)
await tap(KEY_SPACE, 32)
await tap(KEY_A, 1103)
check(Overlay._edit.text == "ик я", "набор кириллицы клавишами: «%s»" % Overlay._edit.text)
Overlay._edit.text = " Мир [b]x "
await tap(KEY_ENTER)
await get_tree().create_timer(0.2).timeout
check(not Overlay.nick_open and not Net.input_blocked, "Enter закрыл панель, блокировка снята")
check(Net.my_nick == "Мир [b]x" and Net.nick_of(Net.own_id()) == "Мир [b]x", "ник сохранён и обрезан по краям: «%s»" % Net.my_nick)
var cf := ConfigFile.new()
cf.load("user://profile.cfg" if not _prof().begins_with("--") else _prof().substr(10))
check(cf.get_value("profile", "nick", "") == "Мир [b]x" and cf.get_value("profile", "asked", false) == true and str(cf.get_value("profile", "client_id", "")).length() == 32, "профиль записан в файл (ник, asked, client_id)")
if mouse_works:
check(Input.mouse_mode == Input.MOUSE_MODE_CAPTURED, "после закрытия мышь снова захвачена")
else:
print(" -- режим мыши в headless не хранится: проверка захвата мыши пропущена (смотреть руками)")
moved = await hold_forward(0.6)
check(moved > 1.0, "после закрытия игрок снова бежит (%.2f м)" % moved)
print("-- смена ника: в поле стоит текущий")
await tap(KEY_N, 110)
await get_tree().create_timer(0.2).timeout
check(Overlay.nick_open and Overlay._edit.text == "Мир [b]x" and Overlay._nick_title.text == "Смена ника", "N снова открывает панель, ник в поле, заголовок «Смена ника»")
await tap(KEY_ESCAPE)
await get_tree().create_timer(0.2).timeout
check(not Overlay.nick_open and Net.my_nick == "Мир [b]x", "Esc при смене — отмена, ник прежний")
print("-- первый запуск, Esc: пропуск")
Net.my_nick = ""
Net.nick_asked = false
Overlay.open_nick(true)
await get_tree().create_timer(0.2).timeout
await tap(KEY_ESCAPE)
await get_tree().create_timer(0.2).timeout
check(not Overlay.nick_open and Net.nick_asked and Net.my_nick == "" and Net.nick_of(Net.own_id()).begins_with("Игрок "), "Esc: панель закрыта, больше не спрашиваем, ник «%s»" % Net.nick_of(Net.own_id()))
print("-- таблица лидеров (Tab)")
await tap(KEY_TAB)
check(Overlay.lb_open and not Net.input_blocked, "Tab открывает таблицу, игрок не заблокирован")
moved = await hold_forward(0.5)
check(moved > 0.8, "с таблицей игрок двигается (%.2f м)" % moved)
await tap(KEY_TAB)
check(not Overlay.lb_open, "повторный Tab закрывает")
await tap(KEY_TAB)
await tap(KEY_ESCAPE)
check(not Overlay.lb_open, "Esc закрывает таблицу")
print("\nUI ПРОЙДЕН" if ok else "\nUI ПРОВАЛЕН")
get_tree().quit(0 if ok else 1)
func _prof() -> String:
for a in OS.get_cmdline_user_args():
if a.begins_with("--profile="):
return a
return "user://profile.cfg"
+1
View File
@@ -0,0 +1 @@
uid://pc0m8rg1dhnf
+6
View File
@@ -0,0 +1,6 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://tools/test/nick_ui_test.gd" id="1"]
[node name="T" type="Node"]
script = ExtResource("1")
+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)
+2 -2
View File
@@ -1,7 +1,7 @@
# Проверка карты Parkour «по существу» после прохода через портал с настоящими пакетами.
# Вставь в tools/test/bot.gd ВРЕМЕННОЙ копии проекта (make_test_project.sh) перед строкой print("[%s] t=%4.1f сцена=...")
# и запусти: godot --headless --main-pack <site>/index.pck -- --client --name=T --pack-url=http://127.0.0.1:<порт>/ --tp=2:4.5,1,-8
# Ожидание: скрипт=true, шейдерных_мешей>=1, небо_панорама=true, музыка_играет=true, лазеров_и_ворот=10.
# Ожидание (после рейтинга: таймер=true итоги=true рейтинг_класс=true): скрипт=true, шейдерных_мешей>=1, небо_панорама=true, музыка_играет=true, лазеров_и_ворот=10.
# Если в пакете не хватает файла, вместо этого будут false/0 (а имя сцены всё равно «Parkour»).
var sc := get_tree().current_scene
@@ -19,4 +19,4 @@
areas += 1
var env := sc.find_children("*", "WorldEnvironment", false, false)
var sky_ok: bool = env.size() > 0 and env[0].environment.sky != null and env[0].environment.sky.sky_material is PanoramaSkyMaterial and env[0].environment.sky.sky_material.panorama != null
print(" ПРОВЕРКА Parkour: скрипт=%s шейдерных_мешей=%d небо_панорама=%s музыка_играет=%s лазеров_и_ворот=%d" % [sc.get_script() != null, shaded, sky_ok, music, areas])
print(" ПРОВЕРКА Parkour: скрипт=%s шейдерных_мешей=%d небо_панорама=%s музыка_играет=%s лазеров_и_ворот=%d таймер=%s итоги=%s рейтинг_класс=%s" % [sc.get_script() != null, shaded, sky_ok, music, areas, sc.get("_timer_label") != null, sc.get("_results_panel") != null, LbView.create("parkour") != null])
+105
View File
@@ -0,0 +1,105 @@
extends Node
# Забег Parkour «по существу»: настоящая карта, игрок, сервер (запускает parkour_run_test.py).
# Старт-линия -> таймер идёт, смерть его не останавливает, финиш у вершины, слишком быстрое время сервер не принимает,
# R перезапускает забег, нормальное время (старт сдвинут на 45 с назад — иначе пришлось бы бежать ~минуту) попадает в рейтинг.
var ok := true
var level
var p
func check(cond: bool, text: String) -> void:
print((" ДА " if cond else " НЕТ ") + text)
if not cond:
ok = false
func wait(s: float) -> void:
await get_tree().create_timer(s).timeout
func _ready() -> void:
_run.call_deferred()
func press_r() -> void:
var ev := InputEventKey.new()
ev.physical_keycode = KEY_R
ev.keycode = KEY_R
ev.pressed = true
Input.parse_input_event(ev)
await get_tree().process_frame
ev = ev.duplicate()
ev.pressed = false
Input.parse_input_event(ev)
func _run() -> void:
level = (load("res://maps/parkour/parkour.tscn") as PackedScene).instantiate()
add_child(level)
await wait(0.5)
p = get_tree().get_first_node_in_group("local_player")
check(await _wait_ready(), "подключились к серверу, вошли в комнату parkour")
check(not level.run_active and not level.run_done, "до линии таймер не идёт")
await wait(1.0)
check(not level.run_active, "стоим на старте — таймер не идёт")
p.global_position = Vector3(0, 1.25, -11.0)
p.velocity = Vector3.ZERO
p.rotation.y = 0.0
await wait(0.3)
Input.action_press("move_forward")
Input.action_press("sprint")
var t0 := Time.get_ticks_msec()
while not level.run_active and Time.get_ticks_msec() - t0 < 4000:
await get_tree().physics_frame
Input.action_release("move_forward")
Input.action_release("sprint")
check(level.run_active and p.global_position.z < level.START_LINE_Z + 1.0, "пересекли линию z=-14 -> таймер пошёл (z=%.1f)" % p.global_position.z)
await wait(1.2)
var e1: float = level.run_elapsed()
check(e1 > 1.0 and level._timer_label.text != "00:00.000", "таймер считает: %s" % level._timer_label.text)
p.die()
await wait(3.2)
check(level.run_active and not p.is_dead and level.run_elapsed() > e1 + 2.5, "смерть и возрождение таймер не останавливают (%.1f с)" % level.run_elapsed())
# слишком быстрый финиш: таймер остановится, сервер время не примет
var D = level.Data
p.global_position = D.PORTAL + Vector3(0, 1.4, 3.0)
p.velocity = Vector3.ZERO
await wait(1.0)
check(level.run_done and not level.run_active and level._results_panel.visible, "у вершины финиш: таймер встал на %s, показаны итоги" % level._timer_label.text)
await wait(0.8)
check(not Net.last_record.has("parkour"), "время %.1f с короче допустимого сервер отверг (ответа нет)" % level.run_time)
var n_before: int = Net.boards["parkour"].top.size() if Net.boards.has("parkour") else 0
# R: заново
await press_r()
await wait(0.4)
check(not level.run_active and not level.run_done and level._timer_label.text == "00:00.000" and p.global_position.distance_to(D.START) < 1.0,
"R: телепорт на старт, таймер сброшен")
# второй забег: линия, затем «состарим» старт на 45 с и финишируем
p.global_position = Vector3(0, 1.25, -11.0)
p.velocity = Vector3.ZERO
await wait(0.3)
Input.action_press("move_forward")
Input.action_press("sprint")
t0 = Time.get_ticks_msec()
while not level.run_active and Time.get_ticks_msec() - t0 < 4000:
await get_tree().physics_frame
Input.action_release("move_forward")
Input.action_release("sprint")
check(level.run_active, "после R линия снова запускает таймер")
level.run_start_tick -= 45000
p.global_position = D.PORTAL + Vector3(0, 1.4, 3.0)
p.velocity = Vector3.ZERO
await wait(1.5)
var rec = Net.last_record.get("parkour")
check(rec != null and absf(rec.time - level.run_time) < 0.01 and rec.rank >= 1 and rec.personal, "сервер принял %.2f с: %s" % [level.run_time, str(rec)])
var b = Net.boards.get("parkour")
check(b != null and b.my_rank >= 1 and absf(b.my_time - level.run_time) < 0.01, "запись в рейтинге Parkour: место %s, время %.2f" % [str(b.my_rank) if b else "?", b.my_time if b else 0.0])
check(level._results_panel.visible, "панель итогов с рейтингом показана")
await wait(8.5)
check(not level._results_panel.visible, "итоги скрылись через ~8 с, дальше свободная игра")
print("\nПАРКУР ПРОЙДЕН" if ok else "\nПАРКУР ПРОВАЛЕН")
get_tree().quit(0 if ok else 1)
func _wait_ready() -> bool:
var t := 0.0
while not (Net.connected and Net.map_ready):
await wait(0.1)
t += 0.1
if t > 8.0:
return false
return true
+1
View File
@@ -0,0 +1 @@
uid://cuykf4tuyn0ci
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env python3
"""Забег Parkour по существу на локальном сервере: tools/test/parkour_run_test.py [рабочая_папка=/tmp/parkour_run]"""
import os, shutil, subprocess, sys, time
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/parkour_run")
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)
open(f"{W}/prof.cfg", "w").write('[profile]\nclient_id="%s"\nnick="Бегун"\nasked=true\n' % ("c" * 32))
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=f"{W}/xdg"))
time.sleep(2.5)
r = subprocess.run(["godot", "--headless", "--path", f"{W}/project", "res://tools/test/parkour_run_test.tscn", "--", "--client", f"--profile={W}/prof.cfg"],
capture_output=True, text=True, timeout=240)
server.terminate(); server.wait(); slog.close()
print("\n".join(l for l in r.stdout.splitlines() if l.startswith((" ДА", " НЕТ", "ПАРКУР", "SCRIPT", "Parse"))))
print("сервер:", [l for l in open(f"{W}/server.log").read().splitlines() if "рекорд" in l])
sys.exit(0 if "ПАРКУР ПРОЙДЕН" in r.stdout else 1)
+6
View File
@@ -0,0 +1,6 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://tools/test/parkour_run_test.gd" id="1"]
[node name="T" type="Node"]
script = ExtResource("1")
+44 -1
View File
@@ -6,6 +6,8 @@ extends Node
# --rounds=<сколько кругов пройти> --quit-race-time=<выйти из игры на этой секунде гонки>
# --die-at-gate=<n>: после ворот n умереть (проверка возрождения лицом по трассе)
# --fall-at-gate=<n>: после ворот n упасть в пустоту (проверка срыва ниже ворот)
# --gu1=<план>, --gu2=<план>: сдача в круге 1 / 2. План: «cancel@1,giveup@2» — когда пройдено столько ворот,
# держать G 1,0 с и отпустить (отмена) / держать до сдачи (проверка race_giveup_test.sh)
# Факты печатаются строками «[имя] …»; сверяет их tools/test/race_game_test.sh.
const Data = preload("res://maps/race/race_data.gd")
@@ -36,6 +38,11 @@ var spectator_logged := false
var music_logged := false
var music_cd_logged := false
var freeze_logged := false
var gu_plan := {1: [], 2: []}
var gu_hold := -1.0
var gu_mode := ""
var gu_done := {}
var gu_after_cancel := false
func _ready() -> void:
for a in OS.get_cmdline_user_args():
@@ -46,6 +53,10 @@ func _ready() -> void:
if a.begins_with("--quit-race-time="): quit_race_time = float(a.substr(17))
if a.begins_with("--die-at-gate="): die_gate = int(a.substr(14))
if a.begins_with("--fall-at-gate="): fall_gate = int(a.substr(15))
if a.begins_with("--gu1=") or a.begins_with("--gu2="):
for e in a.substr(6).split(","):
var q := e.split("@")
gu_plan[int(a.substr(4, 1))].append({"mode": q[0], "gate": int(q[1])})
route = JSON.parse_string(FileAccess.get_file_as_string("res://tools/test/race_route.json")).safe
func gp(w: Array) -> Vector3:
@@ -117,7 +128,7 @@ func _physics_process(_delta: float) -> void:
key(k, false)
func _wait() -> void:
for k in ["move_forward", "sprint", "jump"]:
for k in ["move_forward", "sprint", "jump", "giveup"]:
key(k, false)
if rules.waiting_for_race():
if not spectator_logged:
@@ -143,12 +154,44 @@ func _race() -> void:
say("ВЫХОЖУ из игры в разгар гонки (время %.1f)" % rules.race_time())
get_tree().quit()
return
if rules.gave_up.has(rules.my_id):
for k in ["move_forward", "sprint", "jump", "giveup"]:
key(k, false)
if gu_hold >= 0.0: # только что сдались: фиксируем, где мы и заморожены ли
gu_hold = -1.0
var d := Vector2(p.global_position.x - Data.START_SLOTS[0].x, p.global_position.z - Data.START_SLOTS[0].z).length()
say("СДАЛСЯ_БОТ unix=%.3f на_старте=%s заморожен=%s" % [Time.get_unix_time_from_system(), d < 2.0, p.input_frozen])
return
if rules.finish.has(rules.my_id):
for k in ["move_forward", "sprint", "jump"]:
key(k, false)
return
if rules.race_time() < delay:
return
# --- сдача ---
var cur_round := rounds_done + 1
if gu_after_cancel:
gu_after_cancel = false
say("ПОСЛЕ_ОТМЕНЫ hold=%.2f сдался=%s" % [rules._giveup_hold, rules.gave_up.has(rules.my_id)])
if gu_hold < 0.0 and gu_plan.has(cur_round):
for e in gu_plan[cur_round]:
var gk := "%d%s%d" % [cur_round, e.mode, e.gate]
if not gu_done.has(gk) and rules.passed.get(rules.my_id, 0) == e.gate and not p.is_dead:
gu_done[gk] = true
gu_hold = 0.0
gu_mode = e.mode
say("УДЕРЖИВАЮ G (%s) после ворот %d" % [e.mode, e.gate])
if gu_hold >= 0.0:
for k in ["move_forward", "sprint", "jump"]:
key(k, false)
key("giveup", true)
gu_hold += get_physics_process_delta_time()
if gu_mode == "cancel" and gu_hold >= 1.0:
say("ОТМЕНА_УДЕРЖАНИЯ: отпускаю на %.2f с, шкала=%.2f сдался=%s" % [gu_hold, rules._giveup_hold, rules.gave_up.has(rules.my_id)])
key("giveup", false)
gu_hold = -1.0
gu_after_cancel = true
return
# --- проверки возрождения ---
var g: int = rules.passed.get(rules.my_id, 0)
if not special_done and die_gate >= 0 and g == die_gate and not p.is_dead:
+3 -3
View File
@@ -3,7 +3,7 @@
# Пакеты: tools/export_all.sh /tmp/site (НЕ в grid-world), затем (cd /tmp/site && python3 -m http.server 8777 --bind 127.0.0.1 &) и
# godot --headless --main-pack /tmp/site/index.pck -- --client --name=T --pack-url=http://127.0.0.1:8777/ --tp=2:6.75,1,-7.5
# (портал RACE четвёртый в ряду: x = (3 - 1.5) * 4.5 = 6.75). Сервер в этом тесте не нужен.
# Ожидание: скрипт=true, правила=true, шейдерных_мешей>=1, небо_панорама=true, ворот=8, лазеров=2, музыка_в_файле=true.
# Ожидание: скрипт=true, правила=true, шейдерных_мешей>=1, небо_панорама=true, ворот=8, лазеров=2, музыка_в_файле=true, сдача=true.
var sc := get_tree().current_scene
if sc != null and sc.name == "Race" and int(t) % 3 == 0:
@@ -13,6 +13,6 @@
shaded += 1
var env := sc.find_children("*", "WorldEnvironment", false, false)
var sky_ok: bool = env.size() > 0 and env[0].environment.sky != null and env[0].environment.sky.sky_material is PanoramaSkyMaterial and env[0].environment.sky.sky_material.panorama != null
print(" ПРОВЕРКА Race: скрипт=%s правила=%s шейдерных_мешей=%d небо_панорама=%s ворот=%d лазеров=%d музыка_в_файле=%s" % [
print(" ПРОВЕРКА Race: скрипт=%s правила=%s шейдерных_мешей=%d небо_панорама=%s ворот=%d лазеров=%d музыка_в_файле=%s сдача=%s" % [
sc.get_script() != null, sc.get_node_or_null("Rules") != null, shaded, sky_ok, sc._gates.size(), sc._lasers.size(),
ResourceLoader.exists("res://maps/race/audio/music.ogg")])
ResourceLoader.exists("res://maps/race/audio/music.ogg"), sc.get_node("Rules").has_method("_give_up")])
+2 -2
View File
@@ -14,12 +14,12 @@ sed -i 's#^Bot="\*res://tools/test/bot.gd"#Bot="*res://tools/test/bot.gd"\nRaceB
sed -i 's#^config/name="world2"#config/name="world2-racetest"#' "$W/project/project.godot"
(cd "$W/project" && godot --headless --import --path . >/dev/null 2>&1)
godot --headless --path "$W/server" > "$W/server.log" 2>&1 &
XDG_DATA_HOME="$W/xdg" godot --headless --path "$W/server" > "$W/server.log" 2>&1 & # рейтинг сервера — в рабочую папку
SERVER=$!
sleep 2
run() { # имя, остальные аргументы бота
local n="$1"; shift
timeout 420 godot --headless --path "$W/project" res://maps/race/race.tscn -- --client --race-log --race-best="$W/best_$n.cfg" --name="$n" "$@" > "$W/$n.log" 2>&1 &
timeout 420 godot --headless --path "$W/project" res://maps/race/race.tscn -- --client --race-log --race-best="$W/best_$n.cfg" --profile="$W/prof_$n.cfg" --name="$n" "$@" > "$W/$n.log" 2>&1 &
}
run A --expect=2 --rounds=2 --die-at-gate=3
PA=$!
+97
View File
@@ -0,0 +1,97 @@
#!/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)
+108
View File
@@ -0,0 +1,108 @@
extends Node
# Кадры интерфейса ников и рейтинга для проверки глазами (окно мелькнёт; кадры пишет Godot через --write-movie).
# Рейтинг подставляется «из головы» (сервер не нужен). Режимы:
# --mode=hub доски над порталами (кадр ~20), панель ника (~40), таблица с вкладками (~65, ~85)
# --mode=race HUD с индикатором сдачи (кадры 10..25), итоги с рейтингом (~50), таблица Tab поверх (~70)
# --mode=parkour таймер (~20), итоги забега (~40), таблица Tab (~125)
# Запуск (кадры a00000040.png и т. д.):
# godot --path . --resolution 1152x648 --fixed-fps 10 --write-movie /tmp/shots/hub.png --quit-after 100 res://tools/test/ui_tour.tscn -- --client --mode=hub --profile=/tmp/shots/prof.cfg --server-url=ws://127.0.0.1:1
var mode := "hub"
var level
var p
var frame := 0
func fake_board(rank: int, with_me: bool) -> Dictionary:
var names := ["Алиса", "Bob [b]жирный", "Мир", "Zed", "Игрок 417", "Кирилл_К", "NightRunner", "Тень", "ab", "Макс"]
var top := []
for i in 10:
top.append([names[i], 52.318 + i * 1.7321, 0])
return {"top": top, "my_rank": rank, "my_time": 52.318 + (rank - 1) * 1.7321 if rank <= 10 else 71.456, "tick": Time.get_ticks_msec()}
func _ready() -> void:
for a in OS.get_cmdline_user_args():
if a.begins_with("--mode="):
mode = a.substr(7)
Net.my_nick = "Мир"
Net.nick_asked = true
Net.boards["race"] = fake_board(3, true)
Net.boards["parkour"] = fake_board(14, true)
Net.boards["parkour"].top = Net.boards["parkour"].top.slice(0, 10)
Net.boards["metro"] = {"top": [], "my_rank": 0, "my_time": 0.0, "tick": 0}
Net.nicks[111] = "Алиса"
Net.nicks[222] = "Bob [b]жирный"
Net.nicks[333] = "Тень"
var scene: String = {"hub": "res://maps/hub/hub.tscn", "race": "res://maps/race/race.tscn", "parkour": "res://maps/parkour/parkour.tscn"}[mode]
level = (load(scene) as PackedScene).instantiate()
add_child(level)
p = level.get_node("Player")
if mode == "hub":
Net.current_map = "hub"
p.set_physics_process(false)
p.global_position = Vector3(0, 1.2, 0.5)
p.head.rotation.x = deg_to_rad(21)
# доски в хабе: подставляем топ вручную (сервера нет)
level._on_board_updated("race")
level._on_board_updated("parkour")
else:
p.set_physics_process(false)
func _process(_d: float) -> void:
frame += 1
match mode:
"hub": _hub()
"race": _race()
"parkour": _parkour()
func _hub() -> void:
if frame == 35:
Overlay.open_nick(true)
Overlay._edit.text = "Алиса"
Overlay._edit.caret_column = 5
if frame == 55:
Overlay._nick_layer.visible = false # (настоящее закрытие захватило бы мышь окна)
Overlay.nick_open = false
Net.input_blocked = false
if frame == 60:
Overlay.open_lb()
if frame == 80:
Overlay._lb_index = 1
Overlay._lb_refresh()
func _race() -> void:
var rules = level.get_node("Rules")
if frame == 3:
var now := Time.get_ticks_msec()
rules.participants = [rules.my_id]
rules.passed = {rules.my_id: 3}
rules.start_tick = now - 31000
rules.phase = rules.Phase.RACE
Input.action_press("giveup")
if frame == 28:
Input.action_release("giveup")
if frame == 40: # итоги: эксперимент вне _process правил (иначе вымышленные игроки «выйдут»)
rules.set_process(false)
rules.set_physics_process(false)
rules.participants = [111, 222, 333, rules.my_id]
rules.finish = {111: 52.318, rules.my_id: 55.78}
rules.gave_up = {333: true}
rules.passed = {111: 8, 222: 5, 333: 2, rules.my_id: 8}
Net.last_record["race"] = {"time": 55.78, "rank": 3, "personal": true, "map_record": false}
rules._end_race()
rules._timer_label.visible = false
rules._giveup_label.visible = false
if frame == 70:
Overlay.open_lb()
func _parkour() -> void:
if frame == 5:
p.global_position = Vector3(0, 1.25, -20)
level.run_active = true
level.run_start_tick = Time.get_ticks_msec() - 23400
level._hud_timer = 0.0
if frame == 30:
level.run_time = 47.512
level._finish_run()
Net.last_record["parkour"] = {"time": 47.512, "rank": 14, "personal": true, "map_record": false}
level._show_results()
if frame == 125:
Overlay.open_lb()
+1
View File
@@ -0,0 +1 @@
uid://c1i84ofimjgmp
+6
View File
@@ -0,0 +1,6 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://tools/test/ui_tour.gd" id="1"]
[node name="T" type="Node"]
script = ExtResource("1")