Рейтинг: раунд, цели, миры, правило 9 раундов и причина last_standing
Модели и миграция 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
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
"""Итоги партии для рейтинга (#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
|
||||
Reference in New Issue
Block a user