3 Commits
Author SHA1 Message Date
NotBigGhostandClaude Opus 5 5035ee41e6 События: общий рейтинг обновляется у всех, а не только в группе партии
Рейтинг с #80 один на всё приложение: завершённая или удалённая партия двигает
топ, историю и профили всех, кто играл после неё, и страницы других групп.
Событие match по-прежнему идёт группе партии, остальным активным игрокам —
ratings без подробностей о партии. Фронт по обоим сбрасывает все рейтинговые
витрины (invalidateRatingViews); participant_ids больше не нужен. #88

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-18 23:29:37 +03:00
NotBigGhostandClaude Opus 5 6c35cb04d4 Итоги партии: общий разбор целей/миров и один расчёт мест
parseCount и граница 99 — в domain/matchCounts.ts вместо двух копий в PlaceEditor
и AdminMatchEdit. В MatchDetailPage места по раскладке считает placeRows, из него
строятся и предупреждения, и строки для API. Поведение не меняется. #89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-18 23:23:02 +03:00
NotBigGhostandClaude Opus 5 63f541d61f Рейтинг: монотонность — только без ничьей, в справке и документе
Справка и краткое изложение документа обещали, что победитель не теряет рейтинг,
а последнее место его не приносит. Внутри общего места (ничья за 1-е, несколько
выбывших) это не так — раздел 4.9 уже говорил «без ничьей». #87

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