Модели и миграция 0014: groups.nine_rounds_rule, снимок правила в matches,
matches.end_round, match_participants.objectives/worlds. CHECK причины победы
расширяется, только если он в БД есть (старые БД без CHECK таблицу не
пересоздают); пересоздание matches отказывает при PRAGMA foreign_keys=ON,
иначе DROP унёс бы участников каскадом. Бэкфилл: last_standing у завершённых
партий с одним невыбывшим. Проверено на копии dev-БД и на схеме origin/dev
с CHECK: строки, CHECK, FK и индексы на месте, повтор и downgrade работают.
API и валидация: end_round от 1 до лимита раундов партии (9 при хоумруле
и 5+ игроках), у выбывшего миров 0, last_standing ровно при одном невыбывшем —
в завершении и правке (игрока и админа); черновик хранит раунд, цели и миры
без проверки правила. MatchRead отдаёт end_round, снимок правила и max_rounds.
PATCH /groups/{id} принимает nine_rounds_rule вместе с названием. #23
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
227 lines
10 KiB
Python
227 lines
10 KiB
Python
"""Итоги партии для рейтинга (#23): раунд окончания, цели и миры, правило 9 раундов,
|
|
причина «последний выживший». Сервер проверяет диапазоны и явные противоречия."""
|
|
from __future__ import annotations
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match
|
|
|
|
|
|
def _group(client: TestClient, engine, players: int) -> tuple[int, list[int], list[int]]:
|
|
"""Группа со всеми дополнениями и players участниками (первый — вошедший)."""
|
|
me = login(client, "Хозяин")
|
|
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
|
gid = client.post(
|
|
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
|
|
).json()["id"]
|
|
uids = [me["id"]] + [add_group_member(engine, gid, f"Игрок{i}") for i in range(2, players + 1)]
|
|
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
|
return gid, uids, fids
|
|
|
|
|
|
def _start(client: TestClient, gid: int, uids: list[int], fids: list[int]) -> dict:
|
|
r = start_match(
|
|
client, gid, [{"user_id": u, "faction_id": fids[i]} for i, u in enumerate(uids)]
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
return r.json()
|
|
|
|
|
|
def _finish(client: TestClient, mid: int, participants: list[dict], win_reason: str, **extra):
|
|
body = {"participants": participants, "win_reason": win_reason, **extra}
|
|
return client.post(f"/api/matches/{mid}/finish", json=body, headers=csrf_headers(client))
|
|
|
|
|
|
def _set_rule(client: TestClient, gid: int, enabled: bool) -> dict:
|
|
r = client.patch(
|
|
f"/api/groups/{gid}", json={"nine_rounds_rule": enabled}, headers=csrf_headers(client)
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
return r.json()
|
|
|
|
|
|
def test_finish_saves_round_objectives_and_worlds(client: TestClient, engine):
|
|
gid, (a, b), fids = _group(client, engine, 2)
|
|
match = _start(client, gid, [a, b], fids)
|
|
assert match["max_rounds"] == 8 and match["end_round"] is None
|
|
|
|
r = _finish(
|
|
client, match["id"],
|
|
[
|
|
{"user_id": a, "place": 1, "objectives": 2, "worlds": 5},
|
|
{"user_id": b, "place": 2, "objectives": 1}, # миры не указаны — так и остаётся
|
|
],
|
|
"objectives", end_round=6,
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
data = r.json()
|
|
assert data["end_round"] == 6
|
|
parts = {p["user_id"]: p for p in data["participants"]}
|
|
assert (parts[a]["objectives"], parts[a]["worlds"]) == (2, 5)
|
|
assert (parts[b]["objectives"], parts[b]["worlds"]) == (1, None)
|
|
|
|
listed = client.get(f"/api/groups/{gid}/matches").json()["items"][0]["participants"]
|
|
assert {p["user_id"]: p["objectives"] for p in listed} == {a: 2, b: 1}
|
|
|
|
|
|
def test_end_round_limited_by_max_rounds(client: TestClient, engine):
|
|
gid, (a, b), fids = _group(client, engine, 2)
|
|
match = _start(client, gid, [a, b], fids)
|
|
rows = [{"user_id": a, "place": 1}, {"user_id": b, "place": 2}]
|
|
assert _finish(client, match["id"], rows, "objectives", end_round=9).status_code == 422
|
|
assert _finish(client, match["id"], rows, "objectives", end_round=0).status_code == 422
|
|
assert _finish(client, match["id"], rows, "objectives", end_round=8).status_code == 200
|
|
|
|
|
|
def test_nine_rounds_rule_is_snapshotted_for_five_players(client: TestClient, engine):
|
|
gid, uids, fids = _group(client, engine, 5)
|
|
assert _set_rule(client, gid, True)["nine_rounds_rule"] is True
|
|
assert client.get(f"/api/groups/{gid}").json()["name"] == "Группа" # имя не тронуто
|
|
|
|
five = _start(client, gid, uids, fids)
|
|
four = _start(client, gid, uids[:4], fids)
|
|
assert (five["nine_rounds_rule"], five["max_rounds"]) == (True, 9)
|
|
assert (four["nine_rounds_rule"], four["max_rounds"]) == (True, 8) # правило — только с 5
|
|
|
|
# Смена настройки группы не переписывает уже начатую партию.
|
|
_set_rule(client, gid, False)
|
|
assert client.get(f"/api/matches/{five['id']}").json()["max_rounds"] == 9
|
|
|
|
rows = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids)]
|
|
assert _finish(client, five["id"], rows, "objectives", end_round=9).status_code == 200
|
|
rows4 = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids[:4])]
|
|
assert _finish(client, four["id"], rows4, "objectives", end_round=9).status_code == 422
|
|
|
|
|
|
def test_last_standing_required_exactly_when_one_survivor(client: TestClient, engine):
|
|
gid, (a, b, c), fids = _group(client, engine, 3)
|
|
match = _start(client, gid, [a, b, c], fids)
|
|
alone = [
|
|
{"user_id": a, "place": 1},
|
|
{"user_id": b, "eliminated": True},
|
|
{"user_id": c, "eliminated": True},
|
|
]
|
|
r = _finish(client, match["id"], alone, "objectives")
|
|
assert r.status_code == 422 and "последний выживший" in r.json()["error"]["message"]
|
|
|
|
two = [
|
|
{"user_id": a, "place": 1},
|
|
{"user_id": b, "place": 2},
|
|
{"user_id": c, "eliminated": True},
|
|
]
|
|
assert _finish(client, match["id"], two, "last_standing").status_code == 422
|
|
|
|
ok = _finish(client, match["id"], alone, "last_standing")
|
|
assert ok.status_code == 200, ok.text
|
|
assert ok.json()["win_reason"] == "last_standing"
|
|
|
|
|
|
def test_eliminated_player_has_no_worlds(client: TestClient, engine):
|
|
gid, (a, b, c), fids = _group(client, engine, 3)
|
|
match = _start(client, gid, [a, b, c], fids)
|
|
rows = [
|
|
{"user_id": a, "place": 1, "worlds": 7},
|
|
{"user_id": b, "place": 2, "worlds": 4},
|
|
{"user_id": c, "eliminated": True, "worlds": 2},
|
|
]
|
|
assert _finish(client, match["id"], rows, "objectives").status_code == 422
|
|
|
|
rows[2] = {"user_id": c, "eliminated": True, "objectives": 1} # миры не указаны
|
|
r = _finish(client, match["id"], rows, "objectives")
|
|
assert r.status_code == 200, r.text
|
|
parts = {p["user_id"]: p for p in r.json()["participants"]}
|
|
assert (parts[c]["worlds"], parts[c]["objectives"]) == (0, 1)
|
|
|
|
|
|
def test_negative_counts_rejected(client: TestClient, engine):
|
|
gid, (a, b), fids = _group(client, engine, 2)
|
|
match = _start(client, gid, [a, b], fids)
|
|
rows = [{"user_id": a, "place": 1, "objectives": -1}, {"user_id": b, "place": 2}]
|
|
assert _finish(client, match["id"], rows, "objectives").status_code == 422
|
|
|
|
|
|
def _finished(client: TestClient, gid: int, uids: list[int], fids: list[int]) -> dict:
|
|
match = _start(client, gid, uids, fids)
|
|
rows = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids)]
|
|
r = _finish(client, match["id"], rows, "objectives")
|
|
assert r.status_code == 200, r.text
|
|
return r.json()
|
|
|
|
|
|
def _edit_rows(match: dict, **by_user) -> list[dict]:
|
|
rows = []
|
|
for p in match["participants"]:
|
|
row = {"user_id": p["user_id"], "faction_id": p["faction_id"], "place": p["place"]}
|
|
row.update(by_user.get(str(p["user_id"]), {}))
|
|
rows.append(row)
|
|
return rows
|
|
|
|
|
|
def test_edit_checks_last_standing_and_round(client: TestClient, engine):
|
|
gid, (a, b, c), fids = _group(client, engine, 3)
|
|
match = _finished(client, gid, [a, b, c], fids)
|
|
mid = match["id"]
|
|
|
|
def patch(body: dict):
|
|
return client.patch(f"/api/matches/{mid}", json=body, headers=csrf_headers(client))
|
|
|
|
elim = {"place": None, "eliminated": True}
|
|
rows = _edit_rows(match, **{str(b): elim, str(c): elim})
|
|
assert patch({"participants": rows, "win_reason": "worlds"}).status_code == 422
|
|
# Причина не передана — сверяется с записанной («по целям»): тоже противоречие.
|
|
assert patch({"participants": rows}).status_code == 422
|
|
r = patch({"participants": rows, "win_reason": "last_standing", "end_round": 5})
|
|
assert r.status_code == 200, r.text
|
|
assert (r.json()["win_reason"], r.json()["end_round"]) == ("last_standing", 5)
|
|
|
|
# Одна только причина: при одном выжившем вернуть «по целям» нельзя.
|
|
assert patch({"win_reason": "objectives"}).status_code == 422
|
|
# Один только раунд: в пределах лимита — можно, за лимитом — нет.
|
|
assert patch({"end_round": 9}).status_code == 422
|
|
assert patch({"end_round": None}).json()["end_round"] is None
|
|
|
|
|
|
def test_admin_edit_saves_counts(client: TestClient, engine, make_admin):
|
|
gid, (a, b), fids = _group(client, engine, 2)
|
|
match = _finished(client, gid, [a, b], fids)
|
|
make_admin("admin", "secret123")
|
|
assert client.post(
|
|
"/api/admin/auth/login",
|
|
json={"username": "admin", "password": "secret123"},
|
|
headers=csrf_headers(client),
|
|
).status_code == 200
|
|
rows = _edit_rows(match, **{str(a): {"objectives": 2, "worlds": 6}})
|
|
r = client.patch(
|
|
f"/api/admin/matches/{match['id']}",
|
|
json={"participants": rows, "win_reason": "objectives", "end_round": 7},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
parts = {p["user_id"]: p for p in r.json()["participants"]}
|
|
assert (parts[a]["objectives"], parts[a]["worlds"], r.json()["end_round"]) == (2, 6, 7)
|
|
|
|
|
|
def test_draft_keeps_round_and_counts(client: TestClient, engine):
|
|
gid, (a, b), fids = _group(client, engine, 2)
|
|
match = _start(client, gid, [a, b], fids)
|
|
body = {
|
|
"blocks": [[a], [b]],
|
|
"win_reason": "last_standing", # черновик — незаконченный ввод, правило не проверяется
|
|
"end_round": 4,
|
|
"objectives": {str(a): 2},
|
|
"worlds": {str(a): 5, str(b): 3},
|
|
}
|
|
r = client.put(f"/api/matches/{match['id']}/finish-draft", json=body, headers=csrf_headers(client))
|
|
assert r.status_code == 200, r.text
|
|
data = client.get(f"/api/matches/{match['id']}").json()["finish_draft"]["data"]
|
|
assert data["end_round"] == 4
|
|
assert data["objectives"] == {str(a): 2}
|
|
assert data["worlds"] == {str(a): 5, str(b): 3}
|
|
|
|
bad = client.put(
|
|
f"/api/matches/{match['id']}/finish-draft",
|
|
json={"worlds": {str(a): -3}},
|
|
headers=csrf_headers(client),
|
|
)
|
|
assert bad.status_code == 422
|