Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5035ee41e6 | ||
|
|
6c35cb04d4 | ||
|
|
63f541d61f |
@@ -315,16 +315,15 @@ def delete_match(
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.OkResponse:
|
||||
group_id = match_service.get_match(session, match_id).group_id # для уведомления
|
||||
# До удаления: каскад унесёт участников вместе с партией.
|
||||
participant_ids = notify.match_participant_ids(session, match_id)
|
||||
match = match_service.get_match(session, match_id)
|
||||
group_id, finished = match.group_id, match.status == "finished" # для уведомления
|
||||
admin_service.delete_match(session, match_id)
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
|
||||
ip=client_ip(request),
|
||||
)
|
||||
session.commit()
|
||||
notify.match_removed(session, match_id, group_id, participant_ids)
|
||||
notify.match_removed(session, match_id, group_id, finished=finished)
|
||||
return s.OkResponse()
|
||||
|
||||
|
||||
|
||||
@@ -352,9 +352,7 @@ def delete_match(
|
||||
match_service.assert_can_modify(session, match, user)
|
||||
match_id_val = match.id
|
||||
group_id_val = match.group_id
|
||||
# Участников читаем до удаления: каскад унесёт их строки вместе с партией,
|
||||
# а событию они нужны, чтобы клиент знал, чьи витрины протухли.
|
||||
participant_ids = notify.match_participant_ids(session, match_id_val) # type: ignore[arg-type]
|
||||
finished = match.status == "finished" # после удаления статус уже не прочитать
|
||||
match_service.delete_match(session, match, expected_version=expected_version)
|
||||
audit_service.record(
|
||||
session,
|
||||
@@ -367,5 +365,5 @@ def delete_match(
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
notify.match_removed(session, match_id_val, group_id_val, participant_ids) # type: ignore[arg-type]
|
||||
notify.match_removed(session, match_id_val, group_id_val, finished=finished) # type: ignore[arg-type]
|
||||
return s.OkResponse()
|
||||
|
||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.events import hub
|
||||
from app.models import GroupMember, Match, MatchParticipant, User
|
||||
from app.models import GroupMember, Match, User
|
||||
|
||||
|
||||
def _group_member_ids(session: Session, group_id: int) -> list[int]:
|
||||
@@ -17,30 +17,28 @@ def _group_member_ids(session: Session, group_id: int) -> list[int]:
|
||||
)
|
||||
|
||||
|
||||
def match_participant_ids(session: Session, match_id: int) -> list[int]:
|
||||
"""Кто играл в партии. Нужен в событии, чтобы клиент понимал, чьи витрины
|
||||
(история игр, публичный профиль, личная статистика) реально протухли."""
|
||||
return list(
|
||||
session.exec(
|
||||
select(MatchParticipant.user_id).where(MatchParticipant.match_id == match_id)
|
||||
def _ratings_changed(session: Session, notified: list[int]) -> None:
|
||||
"""Рейтинг общий и считается по всей истории (#80): завершённая партия двигает топ,
|
||||
главную, историю и профили всех, кто играл после неё, и страницы других групп.
|
||||
Игрокам вне группы (notified уже знают) — событие без подробностей о партии."""
|
||||
skip = set(notified)
|
||||
ids = [
|
||||
uid
|
||||
for uid in session.exec(
|
||||
select(User.id).where(User.role == "player", User.is_active.is_(True)) # type: ignore[union-attr]
|
||||
).all()
|
||||
)
|
||||
if uid not in skip
|
||||
]
|
||||
hub.publish(ids, {"type": "ratings"})
|
||||
|
||||
|
||||
def match_changed(session: Session, match: Match) -> None:
|
||||
"""Партия изменилась — уведомить всех участников её группы.
|
||||
|
||||
Адресат — вся группа: списки партий и статистика группы меняются у всех. А вот
|
||||
история и профили протухают только у игравших, поэтому их id едут в событии."""
|
||||
hub.publish(
|
||||
_group_member_ids(session, match.group_id),
|
||||
{
|
||||
"type": "match",
|
||||
"match_id": match.id,
|
||||
"group_id": match.group_id,
|
||||
"participant_ids": match_participant_ids(session, match.id), # type: ignore[arg-type]
|
||||
},
|
||||
)
|
||||
"""Партия изменилась — уведомить всех участников её группы, а если она завершена —
|
||||
и остальных игроков (_ratings_changed)."""
|
||||
members = _group_member_ids(session, match.group_id)
|
||||
hub.publish(members, {"type": "match", "match_id": match.id, "group_id": match.group_id})
|
||||
if match.status == "finished":
|
||||
_ratings_changed(session, members)
|
||||
|
||||
|
||||
def match_draft_changed(session: Session, match: Match, actor_id: int) -> None:
|
||||
@@ -53,22 +51,13 @@ def match_draft_changed(session: Session, match: Match, actor_id: int) -> None:
|
||||
hub.publish(ids, {"type": "match_draft", "match_id": match.id, "group_id": match.group_id})
|
||||
|
||||
|
||||
def match_removed(
|
||||
session: Session, match_id: int, group_id: int, participant_ids: list[int] | None = None
|
||||
) -> None:
|
||||
"""Партия удалена — уведомить участников группы (обновить списки).
|
||||
|
||||
participant_ids передаются снаружи: к этому моменту партии уже нет, а её участники
|
||||
ушли каскадом, и собрать их из базы невозможно."""
|
||||
hub.publish(
|
||||
_group_member_ids(session, group_id),
|
||||
{
|
||||
"type": "match",
|
||||
"match_id": match_id,
|
||||
"group_id": group_id,
|
||||
"participant_ids": participant_ids or [],
|
||||
},
|
||||
)
|
||||
def match_removed(session: Session, match_id: int, group_id: int, *, finished: bool) -> None:
|
||||
"""Партия удалена — уведомить участников группы (обновить списки), а если она была
|
||||
завершена — и остальных игроков. Статус передаётся снаружи: партии уже нет."""
|
||||
members = _group_member_ids(session, group_id)
|
||||
hub.publish(members, {"type": "match", "match_id": match_id, "group_id": group_id})
|
||||
if finished:
|
||||
_ratings_changed(session, members)
|
||||
|
||||
|
||||
def group_changed(session: Session, group_id: int, extra_user_ids: list[int] | None = None) -> None:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Событие партии несёт список участников: по нему клиент решает, чьи витрины протухли."""
|
||||
"""Адресаты событий партии. Рейтинг общий (#80): завершённая партия двигает витрины
|
||||
всех игроков, поэтому игроки вне группы получают событие ratings (#88)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -16,20 +17,29 @@ def _capture_events(monkeypatch) -> list[tuple[list[int], dict]]:
|
||||
return published
|
||||
|
||||
|
||||
def _match_events(published: list[tuple[list[int], dict]]) -> list[dict]:
|
||||
return [e for _ids, e in published if e.get("type") == "match"]
|
||||
def _recipients(published: list[tuple[list[int], dict]], kind: str) -> set[int]:
|
||||
return {uid for ids, e in published if e.get("type") == kind for uid in ids}
|
||||
|
||||
|
||||
def test_match_event_carries_participants(client: TestClient, engine, monkeypatch):
|
||||
def _two_groups(client: TestClient, engine) -> tuple[dict, int, int, int, list[int]]:
|
||||
"""Хост и Игрок2 в группе партии, Чужой — только в другой группе хоста."""
|
||||
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"]
|
||||
|
||||
def group(name: str) -> int:
|
||||
return client.post(
|
||||
"/api/groups", json={"name": name, "expansion_ids": exps}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
|
||||
gid, other_gid = group("Группа"), group("Другая")
|
||||
p2 = add_group_member(engine, gid, "Игрок2")
|
||||
# Третий в группе, но НЕ в партии: его история от этой партии не меняется.
|
||||
p3 = add_group_member(engine, gid, "Зритель")
|
||||
outsider = add_group_member(engine, other_gid, "Чужой")
|
||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||
return me, gid, p2, outsider, fids
|
||||
|
||||
|
||||
def test_finished_match_reaches_players_outside_group(client: TestClient, engine, monkeypatch):
|
||||
me, gid, p2, outsider, fids = _two_groups(client, engine)
|
||||
|
||||
published = _capture_events(monkeypatch)
|
||||
started = start_match(
|
||||
@@ -37,36 +47,31 @@ def test_match_event_carries_participants(client: TestClient, engine, monkeypatc
|
||||
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
||||
)
|
||||
assert started.status_code == 200, started.text
|
||||
mid = started.json()["id"]
|
||||
|
||||
ev = _match_events(published)[-1]
|
||||
assert sorted(ev["participant_ids"]) == sorted([me["id"], p2])
|
||||
assert p3 not in ev["participant_ids"]
|
||||
# Незавершённая партия рейтинг не двигает — знать о ней нужно только группе.
|
||||
assert _recipients(published, "match") == {me["id"], p2}
|
||||
assert _recipients(published, "ratings") == set()
|
||||
|
||||
published.clear()
|
||||
fin = finish_match(
|
||||
client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}]
|
||||
client, started.json()["id"], [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}]
|
||||
)
|
||||
assert fin.status_code == 200, fin.text
|
||||
assert sorted(_match_events(published)[-1]["participant_ids"]) == sorted([me["id"], p2])
|
||||
assert _recipients(published, "match") == {me["id"], p2}
|
||||
ratings = _recipients(published, "ratings")
|
||||
assert outsider in ratings
|
||||
assert not ratings & {me["id"], p2} # группа уже получила подробное событие
|
||||
|
||||
|
||||
def test_delete_event_carries_participants(client: TestClient, engine, monkeypatch):
|
||||
"""Удаление — главный случай: строки участников уже уничтожены каскадом.
|
||||
|
||||
Если собирать их после удаления, список всегда окажется пустым, и клиент не
|
||||
обновит историю тем, кто в этой партии играл."""
|
||||
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"]
|
||||
p2 = add_group_member(engine, gid, "Игрок2")
|
||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||
def test_deleting_finished_match_reaches_players_outside_group(
|
||||
client: TestClient, engine, monkeypatch
|
||||
):
|
||||
"""Удаление завершённой партии пересчитывает рейтинг всех, кто играл после неё."""
|
||||
me, gid, p2, outsider, fids = _two_groups(client, engine)
|
||||
mid = start_match(
|
||||
client, gid,
|
||||
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
||||
).json()["id"]
|
||||
finish_match(client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}])
|
||||
|
||||
published = _capture_events(monkeypatch)
|
||||
version = client.get(f"/api/matches/{mid}").json()["version"]
|
||||
@@ -74,4 +79,5 @@ def test_delete_event_carries_participants(client: TestClient, engine, monkeypat
|
||||
f"/api/matches/{mid}", params={"expected_version": version}, headers=csrf_headers(client)
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert sorted(_match_events(published)[-1]["participant_ids"]) == sorted([me["id"], p2])
|
||||
assert _recipients(published, "match") == {me["id"], p2}
|
||||
assert outsider in _recipients(published, "ratings")
|
||||
|
||||
@@ -26,8 +26,9 @@
|
||||
влияет, но слабее темпа.
|
||||
- **Шкала — классический Elo.** Старт 1500, разница 400 пунктов — шансы 10:1. Пример из
|
||||
задачи «60 против 40» в ней — 1600 против 1400.
|
||||
- **Монотонность.** Победитель никогда не теряет рейтинг, последнее место никогда не
|
||||
приносит.
|
||||
- **Монотонность.** Единоличный победитель никогда не теряет рейтинг, единоличное
|
||||
последнее место никогда его не приносит. При общем месте — ничьей за 1-е или нескольких
|
||||
выбывших — пара внутри ничьей может сдвинуть рейтинг в любую сторону (4.9).
|
||||
- **Старая история.** Пересчитывается по тем же формулам: у партий без новых полей
|
||||
признаки берутся нейтральными.
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const qk = {
|
||||
me: ["me"] as const,
|
||||
adminMe: ["adminMe"] as const,
|
||||
@@ -30,10 +32,11 @@ export const qk = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Ключи, которые протухают от любой партии: конкретных участников мы не знаем
|
||||
* (событие приходит на всю группу), поэтому инвалидируем по префиксу. Один
|
||||
* список на SSE-обработчик и на завершение партии — иначе переименование ключа
|
||||
* в этом файле тихо разойдётся с местами, где он написан строкой.
|
||||
* Ключи, которые протухают от любой завершённой партии: рейтинг общий и считается по
|
||||
* всей истории (#80), так что партия двигает топ, историю и профили всех, кто играл
|
||||
* после неё. Поэтому инвалидируем по префиксу. Один список на SSE-обработчик и на
|
||||
* мутации партии — иначе переименование ключа тихо разойдётся с местами, где он
|
||||
* написан строкой.
|
||||
*/
|
||||
export const matchAffectedKeys = [
|
||||
qk.home,
|
||||
@@ -42,3 +45,15 @@ export const matchAffectedKeys = [
|
||||
["userMatches"],
|
||||
["publicProfile"],
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Все рейтинговые витрины, включая статистику любой группы: рейтинг игроков в ней
|
||||
* общий, так что его двигает и партия другой группы (#88). Перезапрашиваются только
|
||||
* открытые на экране запросы, остальные лишь помечаются устаревшими.
|
||||
*/
|
||||
export function invalidateRatingViews(qc: QueryClient) {
|
||||
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
|
||||
qc.invalidateQueries({
|
||||
predicate: (q) => q.queryKey[0] === "group" && q.queryKey[2] === "stats",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Scissors } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
|
||||
import { Avatar } from "./Avatar";
|
||||
import { MAX_COUNT, parseCount } from "../domain/matchCounts";
|
||||
|
||||
export interface PlacePlayer {
|
||||
user_id: number;
|
||||
@@ -241,19 +242,11 @@ export function PlaceEditor({
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={99}
|
||||
max={MAX_COUNT}
|
||||
placeholder="—"
|
||||
disabled={locked}
|
||||
value={locked ? 0 : (counts?.[userId]?.[field] ?? "")}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.trim();
|
||||
const num = Number(raw);
|
||||
onCount?.(
|
||||
userId,
|
||||
field,
|
||||
raw === "" || !Number.isFinite(num) ? null : Math.min(99, Math.max(0, Math.trunc(num))),
|
||||
);
|
||||
}}
|
||||
onChange={(e) => onCount?.(userId, field, parseCount(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Верхняя граница целей и миров — отсечка мусора, та же, что в схеме API (Count). */
|
||||
export const MAX_COUNT = 99;
|
||||
|
||||
/** Поле «цели»/«миры» формы: пустое — не указано (null), иначе целое 0..MAX_COUNT. */
|
||||
export function parseCount(raw: string): number | null {
|
||||
const text = raw.trim();
|
||||
const num = Number(text);
|
||||
if (text === "" || !Number.isFinite(num)) return null;
|
||||
return Math.min(MAX_COUNT, Math.max(0, Math.trunc(num)));
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api, unwrap } from "../api/client";
|
||||
import { matchAffectedKeys, qk } from "../api/queryKeys";
|
||||
import { invalidateRatingViews, qk } from "../api/queryKeys";
|
||||
import type {
|
||||
FactionRead,
|
||||
MatchCreate,
|
||||
@@ -83,8 +83,7 @@ export function useFinishMatch() {
|
||||
onSuccess: (m) => {
|
||||
qc.invalidateQueries({ queryKey: qk.match(m.id) });
|
||||
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 });
|
||||
invalidateRatingViews(qc);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -103,9 +102,8 @@ export function useUpdateMatch() {
|
||||
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 });
|
||||
// Места изменились — значит изменились рейтинги, топ, истории и профили.
|
||||
invalidateRatingViews(qc);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { matchAffectedKeys, qk } from "../api/queryKeys";
|
||||
import { useMe } from "./auth";
|
||||
import { invalidateRatingViews, qk } from "../api/queryKeys";
|
||||
|
||||
interface ServerEvent {
|
||||
type: "match" | "match_draft" | "group" | "invitations" | "notifications" | "announcements";
|
||||
/** ratings — завершённая партия чужой группы сдвинула общий рейтинг (#88). */
|
||||
type:
|
||||
| "match"
|
||||
| "match_draft"
|
||||
| "ratings"
|
||||
| "group"
|
||||
| "invitations"
|
||||
| "notifications"
|
||||
| "announcements";
|
||||
match_id?: number;
|
||||
group_id?: number;
|
||||
/** Кто играл в партии: их история и профили протухли, чужие — нет. */
|
||||
participant_ids?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,11 +24,6 @@ interface ServerEvent {
|
||||
*/
|
||||
export function useServerEvents(enabled: boolean) {
|
||||
const qc = useQueryClient();
|
||||
const { data: me } = useMe();
|
||||
// Свой id — в ref: положив его в зависимости эффекта, мы бы пересоздавали
|
||||
// SSE-соединение каждый раз, когда профиль перезапрашивается.
|
||||
const myId = useRef<number | null>(null);
|
||||
myId.current = me?.id ?? null;
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const base = import.meta.env.VITE_API_BASE_URL || "";
|
||||
@@ -50,26 +50,12 @@ export function useServerEvents(enabled: boolean) {
|
||||
if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) });
|
||||
if (ev.group_id != null) {
|
||||
qc.invalidateQueries({ queryKey: qk.groupMatches(ev.group_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.groupStats(ev.group_id) });
|
||||
}
|
||||
// Общее меняется от любой партии: рейтинг глобальный, и чужая игра двигает топ.
|
||||
qc.invalidateQueries({ queryKey: qk.home });
|
||||
qc.invalidateQueries({ queryKey: qk.leaderboard });
|
||||
if (ev.participant_ids) {
|
||||
// Личные витрины — только у игравших: иначе каждая партия в группе
|
||||
// заставляла бы всех остальных перезапрашивать свою историю.
|
||||
for (const pid of ev.participant_ids) {
|
||||
qc.invalidateQueries({ queryKey: qk.userMatches(pid) });
|
||||
qc.invalidateQueries({ queryKey: qk.publicProfile(pid) });
|
||||
}
|
||||
if (myId.current != null && ev.participant_ids.includes(myId.current)) {
|
||||
qc.invalidateQueries({ queryKey: qk.myStats });
|
||||
}
|
||||
} else {
|
||||
// Событие от бэкенда без списка участников (вкладка открыта до обновления
|
||||
// сервера) — ведём себя как раньше, широко.
|
||||
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
|
||||
}
|
||||
// Рейтинг общий и считается по всей истории: партия двигает топ, историю
|
||||
// и профили всех, кто играл после неё, и страницы других групп.
|
||||
invalidateRatingViews(qc);
|
||||
} else if (ev.type === "ratings") {
|
||||
invalidateRatingViews(qc);
|
||||
} else if (ev.type === "group") {
|
||||
if (ev.group_id != null) {
|
||||
qc.invalidateQueries({ queryKey: qk.group(ev.group_id) });
|
||||
|
||||
@@ -93,7 +93,10 @@ export function HelpPage() {
|
||||
с правилом группы «9 раундов» при 5–6 игроках — 9.
|
||||
</p>
|
||||
<p className="small" style={{ marginBottom: 0 }}>
|
||||
Победитель никогда не теряет рейтинг, а последнее место никогда его не приносит.
|
||||
Единоличный победитель никогда не теряет рейтинг, а единоличное последнее место
|
||||
никогда его не приносит. При общем месте (ничья за 1-е, несколько выбывших)
|
||||
игроки внутри ничьей сравниваются между собой как в ничьей: слабый может
|
||||
получить рейтинг, сильный — потерять.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -52,6 +52,18 @@ const countDict = (counts: Counts, field: CountField): Record<string, number> =>
|
||||
|
||||
const survivorsIn = (blocks: number[][]) => blocks.reduce((sum, ids) => sum + ids.length, 0);
|
||||
|
||||
// Места по раскладке: блоки сверху вниз (competition ranking — ничья съедает следующие
|
||||
// места), затем выбывшие с общим последним местом.
|
||||
const placeRows = (blocks: number[][], eliminated: number[]) => {
|
||||
let place = 1;
|
||||
const survivors = blocks.flatMap((ids) => {
|
||||
const rows = ids.map((uid) => ({ uid, place, eliminated: false }));
|
||||
place += ids.length;
|
||||
return rows;
|
||||
});
|
||||
return [...survivors, ...eliminated.map((uid) => ({ uid, place, eliminated: true }))];
|
||||
};
|
||||
|
||||
export function MatchDetailPage() {
|
||||
const { matchId } = useParams();
|
||||
// Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN.
|
||||
@@ -193,21 +205,14 @@ export function MatchDetailPage() {
|
||||
|
||||
const outcomeSeats = (): OutcomeSeat[] => {
|
||||
const byId = new Map(match.participants.map((p) => [p.user_id, p]));
|
||||
const seat = (uid: number, place: number, eliminated: boolean): OutcomeSeat => ({
|
||||
return placeRows(finishBlocks, elim).map(({ uid, place, eliminated }) => ({
|
||||
userId: uid,
|
||||
nickname: byId.get(uid)?.nickname ?? "",
|
||||
place,
|
||||
eliminated,
|
||||
objectives: finishCounts[uid]?.objectives ?? null,
|
||||
worlds: eliminated ? 0 : (finishCounts[uid]?.worlds ?? null),
|
||||
});
|
||||
let place = 1;
|
||||
const rows = finishBlocks.flatMap((ids) => {
|
||||
const out = ids.map((uid) => seat(uid, place, false));
|
||||
place += ids.length;
|
||||
return out;
|
||||
});
|
||||
return [...rows, ...elim.map((uid) => seat(uid, place, true))];
|
||||
}));
|
||||
};
|
||||
|
||||
const warnings = () =>
|
||||
@@ -218,35 +223,16 @@ export function MatchDetailPage() {
|
||||
maxRounds: match.max_rounds,
|
||||
});
|
||||
|
||||
// Строки результатов для API: места по блокам (competition ranking), затем выбывшие.
|
||||
const resultRows = () => {
|
||||
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
|
||||
const countsFor = (uid: number, eliminated: boolean) => ({
|
||||
objectives: finishCounts[uid]?.objectives ?? null,
|
||||
// У выбывшего миров нет — сервер сам запишет 0.
|
||||
worlds: eliminated ? null : (finishCounts[uid]?.worlds ?? null),
|
||||
});
|
||||
let place = 1;
|
||||
const survivors = finishBlocks.flatMap((ids) => {
|
||||
const rows = ids.map((uid) => ({
|
||||
user_id: uid,
|
||||
place,
|
||||
eliminated: false,
|
||||
comment: commentOf(uid),
|
||||
...countsFor(uid, false),
|
||||
}));
|
||||
place += ids.length; // competition ranking: ничья съедает следующие места
|
||||
return rows;
|
||||
});
|
||||
const eliminated = elim.map((uid) => ({
|
||||
// Строки результатов для API. Место выбывшего и его миры (0) проставит сервер.
|
||||
const resultRows = () =>
|
||||
placeRows(finishBlocks, elim).map(({ uid, place, eliminated }) => ({
|
||||
user_id: uid,
|
||||
place: null,
|
||||
eliminated: true,
|
||||
comment: commentOf(uid),
|
||||
...countsFor(uid, true),
|
||||
place: eliminated ? null : place,
|
||||
eliminated,
|
||||
comment: (finishComments[uid] ?? "").trim() || null,
|
||||
objectives: finishCounts[uid]?.objectives ?? null,
|
||||
worlds: eliminated ? null : (finishCounts[uid]?.worlds ?? null),
|
||||
}));
|
||||
return [...survivors, ...eliminated];
|
||||
};
|
||||
|
||||
const submitFinish = async () => {
|
||||
if (!id || !match) return;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { MatchMedia } from "../../components/MatchMedia";
|
||||
import { Spinner } from "../../components/Spinner";
|
||||
import { Switch } from "../../components/Switch";
|
||||
import { finishWarnings } from "../../domain/finishWarnings";
|
||||
import { MAX_COUNT, parseCount } from "../../domain/matchCounts";
|
||||
import {
|
||||
LAST_STANDING,
|
||||
reasonForSurvivors,
|
||||
@@ -34,12 +35,6 @@ interface Row {
|
||||
worlds: number | null;
|
||||
}
|
||||
|
||||
// Пустое поле — «не указано» (null); иначе целое 0..99.
|
||||
const parseCount = (raw: string): number | null => {
|
||||
const num = Number(raw.trim());
|
||||
return raw.trim() === "" || !Number.isFinite(num) ? null : Math.min(99, Math.max(0, Math.trunc(num)));
|
||||
};
|
||||
|
||||
export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: () => void }) {
|
||||
const { data: match, isLoading } = useAdminMatch(matchId);
|
||||
const { data: factions } = useAdminFactions();
|
||||
@@ -218,7 +213,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
style={{ flex: "0 0 70px" }}
|
||||
type="number"
|
||||
min={0}
|
||||
max={99}
|
||||
max={MAX_COUNT}
|
||||
placeholder="цели"
|
||||
title="Цели на конец партии"
|
||||
value={r.objectives ?? ""}
|
||||
@@ -228,7 +223,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
style={{ flex: "0 0 70px" }}
|
||||
type="number"
|
||||
min={0}
|
||||
max={99}
|
||||
max={MAX_COUNT}
|
||||
placeholder="миры"
|
||||
title="Миры на конец партии"
|
||||
disabled={r.eliminated}
|
||||
|
||||
Reference in New Issue
Block a user