Партия: правка истории и кнопка «Редактировать» у игрока
Правка завершённой партии проверяла состав теми же правилами, что и создание: участник обязан состоять в группе сейчас, фракция — быть доступной сейчас. После отключения дополнения партию, сыгранную на Тау, было уже не исправить, а после удаления игрока из группы — любую партию с ним. Теперь то, что уже записано в партии, проходит всегда, а новые игроки и фракции по-прежнему берутся только из текущего состава: чинить историю можно, занести в неё постороннего — нет. Создание партии не ослабло. Править завершённую партию умел любой участник группы, но только через API — кнопки не было, и на практике это мог сделать лишь админ через админку. Теперь у блока «Результаты» есть «Редактировать», и правка идёт тем же перетаскиванием, что и завершение: раскладка восстанавливается из сохранённых мест (одинаковое место — ничья, выбывшие отдельно), рядом — фракции, причина победы и комментарии. Сохранение шлёт версию партии, так что устаревшая правка отклоняется как раньше. Фракции вынесены отдельным блоком, а не в PlaceEditor: он занят перетаскиванием, и селекты внутри него — лишний риск. В списке доступных фракций к набору группы добавляются те, что уже стоят в партии, — иначе фракцию из отключённого дополнения нельзя было бы даже оставить как есть. #29 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
This commit is contained in:
@@ -168,16 +168,28 @@ def _validate_roster_basics(
|
||||
group_id: int,
|
||||
user_ids: list[int],
|
||||
faction_ids: list[int],
|
||||
*,
|
||||
keep_user_ids: set[int] | None = None,
|
||||
keep_faction_ids: set[int] | None = None,
|
||||
) -> None:
|
||||
"""Состав партии: размер, отсутствие дублей, принадлежность группе.
|
||||
|
||||
keep_* — то, что уже записано в правимой партии: такие игроки и фракции проходят
|
||||
независимо от текущего состава группы. Иначе отключённое дополнение или ушедший из
|
||||
группы игрок делали бы старую партию неисправимой навсегда."""
|
||||
if len(user_ids) < 2:
|
||||
raise ValidationError("В партии должно быть не менее 2 участников.")
|
||||
if len(user_ids) > MAX_MATCH_PLAYERS:
|
||||
raise ValidationError(f"В партии не может быть больше {MAX_MATCH_PLAYERS} игроков.")
|
||||
if len(set(user_ids)) != len(user_ids) or len(set(faction_ids)) != len(faction_ids):
|
||||
raise DuplicateParticipantError()
|
||||
if not set(user_ids).issubset(_group_member_ids(session, group_id)):
|
||||
allowed_users = _group_member_ids(session, group_id) | (keep_user_ids or set())
|
||||
if not set(user_ids).issubset(allowed_users):
|
||||
raise ValidationError("Все участники должны состоять в группе.")
|
||||
if not set(faction_ids).issubset(group_service.available_faction_ids(session, group_id)):
|
||||
allowed_factions = group_service.available_faction_ids(session, group_id) | (
|
||||
keep_faction_ids or set()
|
||||
)
|
||||
if not set(faction_ids).issubset(allowed_factions):
|
||||
raise FactionNotAvailableError()
|
||||
|
||||
|
||||
@@ -423,18 +435,23 @@ def update_match(
|
||||
match.win_reason = win_reason
|
||||
|
||||
if participants is not None:
|
||||
saved = session.exec(
|
||||
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
|
||||
).all()
|
||||
# Что уже записано в партии, остаётся допустимым: состав группы и набор
|
||||
# дополнений с тех пор могли поменяться, но историю это чинить не мешает.
|
||||
_validate_roster_basics(
|
||||
session,
|
||||
match.group_id,
|
||||
[p.user_id for p in participants],
|
||||
[p.faction_id for p in participants],
|
||||
keep_user_ids={p.user_id for p in saved},
|
||||
keep_faction_ids={p.faction_id for p in saved},
|
||||
)
|
||||
places = _resolve_finish_places(
|
||||
[(p.user_id, p.place, p.eliminated) for p in participants]
|
||||
)
|
||||
for old in session.exec(
|
||||
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
|
||||
).all():
|
||||
for old in saved:
|
||||
session.delete(old)
|
||||
session.flush()
|
||||
for p in participants:
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Правка завершённой партии игроком: история чинится после изменений в группе."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
|
||||
|
||||
|
||||
def _exp_id(client: TestClient, code: str) -> int:
|
||||
return next(e["id"] for e in client.get("/api/expansions").json() if e["code"] == code)
|
||||
|
||||
|
||||
def _played(client: TestClient, engine) -> tuple[dict, int, int, int, dict]:
|
||||
"""Партия «Аня против Бори» в группе с обоими дополнениями."""
|
||||
me = login(client, "Аня")
|
||||
fw, fv = _exp_id(client, "forgotten_worlds"), _exp_id(client, "forsaken_voids")
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": [fw, fv]},
|
||||
headers=csrf_headers(client),
|
||||
).json()["id"]
|
||||
b = add_group_member(engine, gid, "Боря")
|
||||
factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()}
|
||||
mid = create_finished_match(
|
||||
client, gid,
|
||||
[
|
||||
{"user_id": me["id"], "faction_id": factions["orks"], "place": 1},
|
||||
{"user_id": b, "faction_id": factions["tau"], "place": 2},
|
||||
],
|
||||
)["id"]
|
||||
return me, gid, b, mid, factions
|
||||
|
||||
|
||||
def _swap_places(client: TestClient, mid: int, me_id: int, b: int, factions: dict) -> dict:
|
||||
detail = client.get(f"/api/matches/{mid}").json()
|
||||
fid = {p["user_id"]: p["faction_id"] for p in detail["participants"]}
|
||||
return {
|
||||
"participants": [
|
||||
{"user_id": me_id, "faction_id": fid[me_id], "place": 2},
|
||||
{"user_id": b, "faction_id": fid[b], "place": 1},
|
||||
],
|
||||
"expected_version": detail["version"],
|
||||
}
|
||||
|
||||
|
||||
def test_edit_after_expansion_disabled(client: TestClient, engine):
|
||||
"""Дополнение выключили — партия на Тау всё равно правится.
|
||||
|
||||
Регрессия: правка проверяла фракции по ТЕКУЩЕМУ набору группы, и партия,
|
||||
сыгранная на фракции из отключённого дополнения, становилась неисправимой."""
|
||||
me, gid, b, mid, factions = _played(client, engine)
|
||||
|
||||
off = client.put(
|
||||
f"/api/groups/{gid}/expansions",
|
||||
json={"expansion_ids": [_exp_id(client, "forsaken_voids")]},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert off.status_code == 200, off.text
|
||||
|
||||
r = client.patch(
|
||||
f"/api/matches/{mid}", json=_swap_places(client, mid, me["id"], b, factions),
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
places = {p["user_id"]: p["place"] for p in client.get(f"/api/matches/{mid}").json()["participants"]}
|
||||
assert places[b] == 1 and places[me["id"]] == 2
|
||||
|
||||
|
||||
def test_edit_after_player_left_group(client: TestClient, engine):
|
||||
"""Игрока убрали из группы — партия с ним всё равно правится."""
|
||||
me, gid, b, mid, factions = _played(client, engine)
|
||||
|
||||
out = client.delete(f"/api/groups/{gid}/members/{b}", headers=csrf_headers(client))
|
||||
assert out.status_code == 200, out.text
|
||||
|
||||
r = client.patch(
|
||||
f"/api/matches/{mid}", json=_swap_places(client, mid, me["id"], b, factions),
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_edit_rejects_new_outsider_and_unavailable_faction(client: TestClient, engine):
|
||||
"""Послабление — только для того, что уже в партии.
|
||||
|
||||
Вписать нового игрока не из группы или фракцию, которой в партии не было и у
|
||||
группы нет, по-прежнему нельзя: иначе в историю можно занести что угодно."""
|
||||
me, gid, b, mid, factions = _played(client, engine)
|
||||
stranger = client.post(
|
||||
"/api/auth/dev/users", json={"nickname": "Чужак"}, headers=csrf_headers(client)
|
||||
).json()
|
||||
detail = client.get(f"/api/matches/{mid}").json()
|
||||
fid = {p["user_id"]: p["faction_id"] for p in detail["participants"]}
|
||||
|
||||
bad_user = client.patch(
|
||||
f"/api/matches/{mid}",
|
||||
json={
|
||||
"participants": [
|
||||
{"user_id": me["id"], "faction_id": fid[me["id"]], "place": 1},
|
||||
{"user_id": stranger["id"], "faction_id": fid[b], "place": 2},
|
||||
]
|
||||
},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert bad_user.status_code == 422, bad_user.text
|
||||
|
||||
# Выключаем дополнение и пробуем поставить ЕГО фракцию, которой в партии не было.
|
||||
assert client.put(
|
||||
f"/api/groups/{gid}/expansions",
|
||||
json={"expansion_ids": [_exp_id(client, "forsaken_voids")]},
|
||||
headers=csrf_headers(client),
|
||||
).status_code == 200
|
||||
bad_faction = client.patch(
|
||||
f"/api/matches/{mid}",
|
||||
json={
|
||||
"participants": [
|
||||
{"user_id": me["id"], "faction_id": factions["necrons"], "place": 1},
|
||||
{"user_id": b, "faction_id": fid[b], "place": 2},
|
||||
]
|
||||
},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert bad_faction.status_code == 422, bad_faction.text
|
||||
|
||||
|
||||
def test_create_match_still_validated(client: TestClient, engine):
|
||||
"""Создание партии не ослабло: посторонний игрок по-прежнему отклоняется."""
|
||||
me, gid, b, mid, factions = _played(client, engine)
|
||||
stranger = client.post(
|
||||
"/api/auth/dev/users", json={"nickname": "Чужак2"}, headers=csrf_headers(client)
|
||||
).json()
|
||||
r = client.post(
|
||||
"/api/matches",
|
||||
json={
|
||||
"group_id": gid,
|
||||
"participants": [
|
||||
{"user_id": me["id"], "faction_id": factions["orks"]},
|
||||
{"user_id": stranger["id"], "faction_id": factions["eldar"]},
|
||||
],
|
||||
},
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert r.status_code == 422, r.text
|
||||
Reference in New Issue
Block a user