diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 4e393e1..ab63947 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -498,6 +498,9 @@ class MatchListItem(BaseModel): overall_comment: str | None = None created_by: int participants: list[MatchListParticipant] = [] + # Изменение общего рейтинга владельца истории за эту партию (один знак после запятой). + # Заполняется только в истории игрока (GET /users/{id}/matches); в списке группы — None. + rating_delta: float | None = None class MatchList(BaseModel): diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index 605e190..83f936e 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -355,8 +355,12 @@ def _participants_by_match(session: Session, match_ids: list[int]) -> dict[int, return out -def _match_items(session: Session, matches) -> list[dict]: - """Элементы списка партий (общее для списка группы и истории игрока).""" +def _match_items( + session: Session, matches, rating_deltas: dict[int, float] | None = None +) -> list[dict]: + """Элементы списка партий (общее для списка группы и истории игрока). + + rating_deltas — {match_id: ΔR} владельца истории; у списка группы их нет.""" by_match = _participants_by_match(session, [m.id for m in matches]) items = [] @@ -375,6 +379,7 @@ def _match_items(session: Session, matches) -> list[dict]: "overall_comment": m.overall_comment, "created_by": m.created_by, "participants": parts, + "rating_delta": None if rating_deltas is None else rating_deltas.get(m.id), } ) return items @@ -408,17 +413,19 @@ def user_match_list( ) -> dict: """История партий игрока: только ЗАВЕРШЁННЫЕ, свежие сверху. - best_only — одна лучшая партия: наибольший прирост рейтинга в общей цепочке + У каждой партии — изменение общего рейтинга игрока за неё (rating_delta, один знак + после запятой). best_only — одна лучшая партия: наибольший прирост рейтинга (учитывает и соперников, и ход партии); при равенстве берём более свежую.""" + history = load_history(session) + rep = replay(history) + mine = _user_seats(history, user_id) + # + 0.0 превращает −0.0 (мелкий минус, округлённый до нуля) в обычный ноль. + deltas = {m.id: round(rep.delta[(m.id, user_id)], 1) + 0.0 for m, _s in mine} if best_only: - history = load_history(session) - rep = replay(history) - candidates = [ - (rep.delta[(m.id, user_id)], m.played_at, m.id) for m, _s in _user_seats(history, user_id) - ] + candidates = [(rep.delta[(m.id, user_id)], m.played_at, m.id) for m, _s in mine] matches = [session.get(Match, max(candidates)[2])] if candidates else [] return { - "items": _match_items(session, matches), + "items": _match_items(session, matches, deltas), "total": len(matches), "limit": 1, "offset": 0, @@ -440,7 +447,7 @@ def user_match_list( .limit(limit) ).all() return { - "items": _match_items(session, matches), + "items": _match_items(session, matches, deltas), "total": total, "limit": limit, "offset": offset, diff --git a/backend/tests/test_rating_stats.py b/backend/tests/test_rating_stats.py index 050f2a9..9fe572b 100644 --- a/backend/tests/test_rating_stats.py +++ b/backend/tests/test_rating_stats.py @@ -138,3 +138,30 @@ def test_best_match_is_biggest_rating_gain(client: TestClient, engine): data = client.get(f"/api/users/{me['id']}/matches").json() assert data["total"] == 1 assert data["items"][0]["id"] == first["id"] + + +def test_history_shows_rating_delta_of_its_owner(client: TestClient, engine): + """История игрока несёт изменение его общего рейтинга за каждую партию (#77).""" + me = login(client, "Историк") + gid, fids = _group(client) + b = add_group_member(engine, gid, "Оппонент") + _duel(client, gid, fids, me["id"], b) # новички на 1500: ±32 + _duel(client, gid, fids, b, me["id"]) # реванш: 1468 обыгрывает 1532 + + mine = client.get(f"/api/users/{me['id']}/matches").json()["items"] + theirs = client.get(f"/api/users/{b}/matches").json()["items"] + # Свежие сверху: реванш первым. + assert (mine[1]["rating_delta"], theirs[1]["rating_delta"]) == (32.0, -32.0) + assert mine[0]["rating_delta"] == -theirs[0]["rating_delta"] < 0 + # Изменения складываются в рейтинг (с точностью округления до десятых). + score = _board(client)[me["id"]]["score"] + assert abs(1500 + sum(i["rating_delta"] for i in mine) - score) <= 0.6 + + # В списке партий группы дельты нет — непонятно, чья она была бы. + group_items = client.get(f"/api/groups/{gid}/matches").json()["items"] + assert [i["rating_delta"] for i in group_items] == [None, None] + + # Режим «лучшая партия» тоже её отдаёт. + client.patch("/api/users/me/profile", json={"history_mode": "best"}, headers=csrf_headers(client)) + best = client.get(f"/api/users/{me['id']}/matches").json()["items"] + assert best[0]["rating_delta"] == 32.0 diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 427a5eb..19f9d8f 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -1888,6 +1888,8 @@ export interface components { * @default [] */ participants: components["schemas"]["MatchListParticipant"][]; + /** Rating Delta */ + rating_delta?: number | null; }; /** MatchListParticipant */ MatchListParticipant: { diff --git a/frontend/src/components/MatchHistory.tsx b/frontend/src/components/MatchHistory.tsx index 5d0b8f3..2e5bec5 100644 --- a/frontend/src/components/MatchHistory.tsx +++ b/frontend/src/components/MatchHistory.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import { formatDate, formatDuration } from "../domain/format"; import type { MatchListItem } from "../domain/types"; import { MatchListView } from "./MatchList"; +import { RatingDelta } from "./RatingDelta"; /** * История партий игрока в профиле. Подробный режим — тот же список, что у группы @@ -53,29 +54,33 @@ export function MatchHistory({ return ( ); })} diff --git a/frontend/src/components/MatchList.tsx b/frontend/src/components/MatchList.tsx index 014a31e..23a5745 100644 --- a/frontend/src/components/MatchList.tsx +++ b/frontend/src/components/MatchList.tsx @@ -3,10 +3,12 @@ import { useNavigate } from "react-router-dom"; import { formatDate, formatDuration } from "../domain/format"; import { winReasonLabel } from "../domain/winReasons"; import type { MatchListItem } from "../domain/types"; +import { RatingDelta } from "./RatingDelta"; /** Список ЗАВЕРШЁННЫХ партий. Незавершённые сюда не попадают: группа отдаёт их * отдельным блоком (InProgressMatches), а история профиля приходит с бэкенда уже - * отфильтрованной по status="finished". */ + * отфильтрованной по status="finished". В истории профиля у партии есть rating_delta + * владельца — она идёт последней строкой; у списка группы её нет. */ export function MatchListView({ items }: { items: MatchListItem[] }) { const navigate = useNavigate(); if (items.length === 0) return
Партий пока нет.
; @@ -48,6 +50,7 @@ export function MatchListView({ items }: { items: MatchListItem[] }) { ))} + {m.rating_delta != null && } ); })} diff --git a/frontend/src/components/RatingDelta.tsx b/frontend/src/components/RatingDelta.tsx new file mode 100644 index 0000000..7df4da6 --- /dev/null +++ b/frontend/src/components/RatingDelta.tsx @@ -0,0 +1,13 @@ +import { formatRatingDelta } from "../domain/format"; + +/** Строка карточки истории: сколько общего рейтинга владелец профиля получил или + * потерял за партию. Рост — зелёным, падение — красным, ноль — без цвета. */ +export function RatingDelta({ delta }: { delta: number }) { + const text = formatRatingDelta(delta); + const tone = text.startsWith("+") ? "rating-up" : text.startsWith("−") ? "rating-down" : undefined; + return ( +
+ Рейтинг: {text} +
+ ); +} diff --git a/frontend/src/domain/format.ts b/frontend/src/domain/format.ts index f8cecd9..ecf6e39 100644 --- a/frontend/src/domain/format.ts +++ b/frontend/src/domain/format.ts @@ -18,6 +18,14 @@ export function plural(n: number, one: string, few: string, many: string): strin return many; } +// Изменение рейтинга за партию: всегда со знаком и одним знаком после запятой +// («+12.3», «−4.1», «0.0»); минус — типографский, как в справке. +export function formatRatingDelta(delta: number): string { + const abs = Math.abs(delta).toFixed(1); + if (abs === "0.0") return "0.0"; + return `${delta > 0 ? "+" : "−"}${abs}`; +} + export function formatDuration(minutes: number | null | undefined): string { if (minutes == null) return "—"; const h = Math.floor(minutes / 60); diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 9c5c269..8d9cac1 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -103,6 +103,11 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); } .error-text { color: var(--danger); font-size: 13px; } +/* Изменение рейтинга за партию в истории профиля */ +.rating-delta { color: var(--text-muted); margin-top: 6px; } +.rating-up { color: var(--success); } +.rating-down { color: var(--danger); } + /* Формула в справке */ .formula { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;