Merge pull request 'Отображение изменения рейтинга (#77)' (#79) from issue-77-history-rating-delta into dev
Reviewed-on: #79
This commit was merged in pull request #79.
This commit is contained in:
@@ -498,6 +498,9 @@ class MatchListItem(BaseModel):
|
|||||||
overall_comment: str | None = None
|
overall_comment: str | None = None
|
||||||
created_by: int
|
created_by: int
|
||||||
participants: list[MatchListParticipant] = []
|
participants: list[MatchListParticipant] = []
|
||||||
|
# Изменение общего рейтинга владельца истории за эту партию (один знак после запятой).
|
||||||
|
# Заполняется только в истории игрока (GET /users/{id}/matches); в списке группы — None.
|
||||||
|
rating_delta: float | None = None
|
||||||
|
|
||||||
|
|
||||||
class MatchList(BaseModel):
|
class MatchList(BaseModel):
|
||||||
|
|||||||
@@ -355,8 +355,12 @@ def _participants_by_match(session: Session, match_ids: list[int]) -> dict[int,
|
|||||||
return out
|
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])
|
by_match = _participants_by_match(session, [m.id for m in matches])
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
@@ -375,6 +379,7 @@ def _match_items(session: Session, matches) -> list[dict]:
|
|||||||
"overall_comment": m.overall_comment,
|
"overall_comment": m.overall_comment,
|
||||||
"created_by": m.created_by,
|
"created_by": m.created_by,
|
||||||
"participants": parts,
|
"participants": parts,
|
||||||
|
"rating_delta": None if rating_deltas is None else rating_deltas.get(m.id),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return items
|
return items
|
||||||
@@ -408,17 +413,19 @@ def user_match_list(
|
|||||||
) -> dict:
|
) -> 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:
|
if best_only:
|
||||||
history = load_history(session)
|
candidates = [(rep.delta[(m.id, user_id)], m.played_at, m.id) for m, _s in mine]
|
||||||
rep = replay(history)
|
|
||||||
candidates = [
|
|
||||||
(rep.delta[(m.id, user_id)], m.played_at, m.id) for m, _s in _user_seats(history, user_id)
|
|
||||||
]
|
|
||||||
matches = [session.get(Match, max(candidates)[2])] if candidates else []
|
matches = [session.get(Match, max(candidates)[2])] if candidates else []
|
||||||
return {
|
return {
|
||||||
"items": _match_items(session, matches),
|
"items": _match_items(session, matches, deltas),
|
||||||
"total": len(matches),
|
"total": len(matches),
|
||||||
"limit": 1,
|
"limit": 1,
|
||||||
"offset": 0,
|
"offset": 0,
|
||||||
@@ -440,7 +447,7 @@ def user_match_list(
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
).all()
|
).all()
|
||||||
return {
|
return {
|
||||||
"items": _match_items(session, matches),
|
"items": _match_items(session, matches, deltas),
|
||||||
"total": total,
|
"total": total,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
|
|||||||
@@ -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()
|
data = client.get(f"/api/users/{me['id']}/matches").json()
|
||||||
assert data["total"] == 1
|
assert data["total"] == 1
|
||||||
assert data["items"][0]["id"] == first["id"]
|
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
|
||||||
|
|||||||
Vendored
+2
@@ -1888,6 +1888,8 @@ export interface components {
|
|||||||
* @default []
|
* @default []
|
||||||
*/
|
*/
|
||||||
participants: components["schemas"]["MatchListParticipant"][];
|
participants: components["schemas"]["MatchListParticipant"][];
|
||||||
|
/** Rating Delta */
|
||||||
|
rating_delta?: number | null;
|
||||||
};
|
};
|
||||||
/** MatchListParticipant */
|
/** MatchListParticipant */
|
||||||
MatchListParticipant: {
|
MatchListParticipant: {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { formatDate, formatDuration } from "../domain/format";
|
import { formatDate, formatDuration } from "../domain/format";
|
||||||
import type { MatchListItem } from "../domain/types";
|
import type { MatchListItem } from "../domain/types";
|
||||||
import { MatchListView } from "./MatchList";
|
import { MatchListView } from "./MatchList";
|
||||||
|
import { RatingDelta } from "./RatingDelta";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* История партий игрока в профиле. Подробный режим — тот же список, что у группы
|
* История партий игрока в профиле. Подробный режим — тот же список, что у группы
|
||||||
@@ -53,29 +54,33 @@ export function MatchHistory({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className="card row-between"
|
className="card"
|
||||||
style={{ margin: 0, width: "100%", textAlign: "left" }}
|
style={{ margin: 0, width: "100%", textAlign: "left" }}
|
||||||
onClick={() => navigate(`/match/${m.id}`)}
|
onClick={() => navigate(`/match/${m.id}`)}
|
||||||
>
|
>
|
||||||
<div className="row" style={{ gap: 10, minWidth: 0 }}>
|
<div className="row-between">
|
||||||
<span
|
<div className="row" style={{ gap: 10, minWidth: 0 }}>
|
||||||
className={
|
<span
|
||||||
"place-badge" +
|
className={
|
||||||
(mine?.place === 1 && !mine?.eliminated ? " first" : "") +
|
"place-badge" +
|
||||||
(mine?.eliminated ? " eliminated" : "")
|
(mine?.place === 1 && !mine?.eliminated ? " first" : "") +
|
||||||
}
|
(mine?.eliminated ? " eliminated" : "")
|
||||||
>
|
}
|
||||||
{mine?.eliminated ? "✕" : (mine?.place ?? "—")}
|
>
|
||||||
</span>
|
{mine?.eliminated ? "✕" : (mine?.place ?? "—")}
|
||||||
<span
|
</span>
|
||||||
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
<span
|
||||||
>
|
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||||
{mine?.faction_name ?? "—"}
|
>
|
||||||
|
{mine?.faction_name ?? "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="muted small" style={{ flexShrink: 0 }}>
|
||||||
|
{formatDate(m.played_at)} · {formatDuration(m.duration_minutes)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="muted small" style={{ flexShrink: 0 }}>
|
{/* Второй строкой — изменение рейтинга владельца профиля за партию. */}
|
||||||
{formatDate(m.played_at)} · {formatDuration(m.duration_minutes)}
|
{m.rating_delta != null && <RatingDelta delta={m.rating_delta} />}
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { formatDate, formatDuration } from "../domain/format";
|
import { formatDate, formatDuration } from "../domain/format";
|
||||||
import { winReasonLabel } from "../domain/winReasons";
|
import { winReasonLabel } from "../domain/winReasons";
|
||||||
import type { MatchListItem } from "../domain/types";
|
import type { MatchListItem } from "../domain/types";
|
||||||
|
import { RatingDelta } from "./RatingDelta";
|
||||||
|
|
||||||
/** Список ЗАВЕРШЁННЫХ партий. Незавершённые сюда не попадают: группа отдаёт их
|
/** Список ЗАВЕРШЁННЫХ партий. Незавершённые сюда не попадают: группа отдаёт их
|
||||||
* отдельным блоком (InProgressMatches), а история профиля приходит с бэкенда уже
|
* отдельным блоком (InProgressMatches), а история профиля приходит с бэкенда уже
|
||||||
* отфильтрованной по status="finished". */
|
* отфильтрованной по status="finished". В истории профиля у партии есть rating_delta
|
||||||
|
* владельца — она идёт последней строкой; у списка группы её нет. */
|
||||||
export function MatchListView({ items }: { items: MatchListItem[] }) {
|
export function MatchListView({ items }: { items: MatchListItem[] }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
if (items.length === 0) return <div className="muted small">Партий пока нет.</div>;
|
if (items.length === 0) return <div className="muted small">Партий пока нет.</div>;
|
||||||
@@ -48,6 +50,7 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{m.rating_delta != null && <RatingDelta delta={m.rating_delta} />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="small rating-delta">
|
||||||
|
Рейтинг: <b className={tone}>{text}</b>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,14 @@ export function plural(n: number, one: string, few: string, many: string): strin
|
|||||||
return many;
|
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 {
|
export function formatDuration(minutes: number | null | undefined): string {
|
||||||
if (minutes == null) return "—";
|
if (minutes == null) return "—";
|
||||||
const h = Math.floor(minutes / 60);
|
const h = Math.floor(minutes / 60);
|
||||||
|
|||||||
@@ -103,6 +103,11 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
|
|
||||||
.error-text { color: var(--danger); font-size: 13px; }
|
.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 {
|
.formula {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
|||||||
Reference in New Issue
Block a user