Merge pull request 'Историю партий нельзя править после смены состава группы или набора дополнений (#29)' (#39) from issue-29-edit-finished-match into dev

Reviewed-on: #39
This commit was merged in pull request #39.
This commit is contained in:
2026-09-09 20:18:46 +03:00
4 changed files with 370 additions and 8 deletions
+22 -5
View File
@@ -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:
+142
View File
@@ -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
+23
View File
@@ -8,6 +8,7 @@ import type {
MatchFinish,
MatchFinishDraftData,
MatchRead,
MatchUpdate,
} from "../domain/types";
import { uploadFile } from "../lib/upload";
@@ -88,6 +89,28 @@ export function useFinishMatch() {
});
}
/** Правка завершённой партии участником группы (у админки свой эндпойнт). */
export function useUpdateMatch() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (args: { matchId: number; body: MatchUpdate }): Promise<MatchRead> =>
unwrap(
await api.PATCH("/api/matches/{match_id}", {
params: { path: { match_id: args.matchId } },
body: args.body,
}),
),
onSuccess: (m) => {
qc.setQueryData(qk.match(m.id), m);
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) });
// Места изменились — значит изменились лидерборд, история и профили.
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
},
});
}
export function useDeleteMatch() {
const qc = useQueryClient();
return useMutation({
+183 -3
View File
@@ -19,8 +19,10 @@ import {
useFinishMatch,
useMatch,
useSaveFinishDraft,
useUpdateMatch,
useUploadMatchAttachment,
} from "../hooks/matches";
import { useGroupFactions } from "../hooks/reference";
const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label }));
@@ -31,8 +33,10 @@ export function MatchDetailPage() {
const id = Number.isInteger(parsed) ? parsed : null;
const { data: match, isLoading, refetch } = useMatch(id);
const { data: me } = useMe();
const { data: groupFactions } = useGroupFactions(match?.group_id ?? null);
const finish = useFinishMatch();
const saveDraft = useSaveFinishDraft(id);
const updateMatch = useUpdateMatch();
const del = useDeleteMatch();
const uploadAtt = useUploadMatchAttachment(id ?? 0);
const deleteAtt = useDeleteMatchAttachment(id ?? 0);
@@ -47,6 +51,9 @@ export function MatchDetailPage() {
const [overall, setOverall] = useState("");
const [error, setError] = useState<string | null>(null);
const [confirmRemove, setConfirmRemove] = useState(false);
// Правка завершённой партии: тот же редактор мест, что и при завершении.
const [editing, setEditing] = useState(false);
const [editFactions, setEditFactions] = useState<Record<number, number>>({});
// ─── Совместное заполнение формы ───
// Отложенная запись черновика: он меняется на каждое движение тайла, слать
@@ -181,6 +188,77 @@ export function MatchDetailPage() {
}
};
// Раскладка для правки восстанавливается из сохранённых результатов: одинаковое
// место = ничья, выбывшие уходят отдельным списком.
const startEditing = () => {
const byPlace = new Map<number, number[]>();
for (const p of match.participants.filter((x) => !x.eliminated)) {
const key = p.place ?? 99;
byPlace.set(key, [...(byPlace.get(key) ?? []), p.user_id]);
}
setBlocks([...byPlace.entries()].sort((a, b) => a[0] - b[0]).map(([, ids]) => ids));
setElim(match.participants.filter((p) => p.eliminated).map((p) => p.user_id));
setComments(
Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])),
);
setWinReason((match.win_reason ?? "objectives") as WinReason);
setOverall(match.overall_comment ?? "");
setEditFactions(Object.fromEntries(match.participants.map((p) => [p.user_id, p.faction_id])));
setError(null);
setEditing(true);
};
const cancelEditing = () => {
setEditing(false);
setBlocks(null);
setComments(null);
setError(null);
};
const submitEdit = async () => {
if (!id || !match) return;
setError(null);
const wasRandom = Object.fromEntries(
match.participants.map((p) => [p.user_id, p.was_random]),
);
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
const rowOf = (uid: number, place: number | null, eliminated: boolean) => ({
user_id: uid,
faction_id: editFactions[uid],
place,
eliminated,
was_random: wasRandom[uid] ?? false,
comment: commentOf(uid),
});
let place = 1;
const survivors = finishBlocks.flatMap((ids) => {
const rows = ids.map((uid) => rowOf(uid, place, false));
place += ids.length; // competition ranking: ничья съедает следующие места
return rows;
});
try {
await updateMatch.mutateAsync({
matchId: id,
body: {
participants: [...survivors, ...elim.map((uid) => rowOf(uid, null, true))],
win_reason: winReason,
overall_comment: overall.trim() || null,
expected_version: match.version,
},
});
toast.show("Партия обновлена");
cancelEditing();
} catch (e) {
if (isStale(e)) {
toast.show("Партия изменилась на другом устройстве — обновлено");
refetch();
cancelEditing();
return;
}
setError(e instanceof ApiError ? e.message : "Не удалось сохранить");
}
};
const remove = async () => {
if (!id || !match) return;
try {
@@ -203,6 +281,15 @@ export function MatchDetailPage() {
(a, b) => (a.place ?? 99) - (b.place ?? 99),
);
// К доступным группе фракциям добавляем те, что уже стоят в партии: дополнение
// могли отключить после игры, но менять её фракцию это мешать не должно.
const factionOptions = [
...(groupFactions ?? []),
...match.participants
.filter((p) => !(groupFactions ?? []).some((f) => f.id === p.faction_id))
.map((p) => ({ id: p.faction_id, code: "", name_ru: p.faction_name, expansion_id: 0 })),
];
return (
<div>
<div className="card">
@@ -226,11 +313,104 @@ export function MatchDetailPage() {
{match.overall_comment && <p className="small" style={{ marginTop: 6 }}>{match.overall_comment}</p>}
</div>
{/* Завершённая партия — правка результатов */}
{!inProgress && editing && (
<>
<div className="card">
<h3>Правка результатов</h3>
<p className="muted small">
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
игрока, чтобы разделить место (ничья).
</p>
<PlaceEditor
players={match.participants}
blocks={finishBlocks}
eliminated={elim}
comments={finishComments}
onChange={(b, e) => {
setBlocks(b);
setElim(e);
}}
onComment={(uid, text) => setComments({ ...finishComments, [uid]: text })}
/>
</div>
<div className="card">
<h3>Фракции</h3>
<div className="stack">
{match.participants.map((p) => (
<div key={p.user_id} className="row" style={{ gap: 8 }}>
<b style={{ flex: "0 0 40%" }}>{p.nickname}</b>
<div style={{ flex: 1 }}>
<PickerSelect
selected={factionOptions.find((f) => f.id === editFactions[p.user_id]) ?? null}
options={factionOptions.filter(
(f) =>
f.id === editFactions[p.user_id] ||
!Object.values(editFactions).includes(f.id),
)}
placeholder="— фракция —"
renderOption={(f) => f.name_ru}
onPick={(f) => setEditFactions({ ...editFactions, [p.user_id]: f.id })}
/>
</div>
</div>
))}
</div>
</div>
<div className="card">
<h3>Причина победы</h3>
<PickerSelect
selected={REASON_OPTIONS.find((o) => o.id === winReason) ?? null}
options={REASON_OPTIONS}
placeholder="— причина —"
renderOption={(o) => o.label}
onPick={(o) => setWinReason(o.id)}
/>
</div>
<div className="card">
<h3>О партии</h3>
<textarea
rows={2}
style={{ resize: "none" }}
placeholder="Комментарий о партии"
value={overall}
onChange={(e) => setOverall(e.target.value)}
/>
</div>
{error && <p className="error-text">{error}</p>}
<div className="row" style={{ gap: 8 }}>
<button
className="btn btn-primary"
style={{ flex: 1 }}
onClick={submitEdit}
disabled={updateMatch.isPending}
>
Сохранить
</button>
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={cancelEditing}>
Отмена
</button>
</div>
</>
)}
{/* Завершённая партия — результаты */}
{!inProgress && (
{!inProgress && !editing && (
<div className="card">
<h3>Результаты</h3>
<div className="stack">
<div className="row-between">
<h3 style={{ margin: 0 }}>Результаты</h3>
{canModify && (
<button className="btn btn-ghost small" onClick={startEditing}>
Редактировать
</button>
)}
</div>
<div className="stack" style={{ marginTop: 10 }}>
{sorted.map((p) => (
<div key={p.user_id}>
<PlayerLink userId={p.user_id} className="row" style={{ gap: 8 }}>