diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index d6d968e..56c8811 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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() diff --git a/backend/app/routers/matches.py b/backend/app/routers/matches.py index c49b65e..ea6f970 100644 --- a/backend/app/routers/matches.py +++ b/backend/app/routers/matches.py @@ -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() diff --git a/backend/app/services/notify.py b/backend/app/services/notify.py index 2dad33c..d15f9f2 100644 --- a/backend/app/services/notify.py +++ b/backend/app/services/notify.py @@ -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: diff --git a/backend/app/services/scoring.py b/backend/app/services/scoring.py index f6c23f5..f347f5b 100644 --- a/backend/app/services/scoring.py +++ b/backend/app/services/scoring.py @@ -7,9 +7,10 @@ E_ab = 1 / (1 + 10^((R_b − R_a) / D)) ожидание по рейтингам ДО партии S_ab = 1 / 0.5 / 0 выше / поровну / ниже ΔR_i = K_i · G(N) / (N − 1) · Σ_j M_ij · (S_ij − E_ij) -K_i спускается от K_MAX у новичка до K_MIN за K_GAMES партий, G(N) — вес размера стола, -M — множитель отрыва (темп, цели, миры; близость по типу победы — только у пар -с победителем). Недостающий признак партии подставляется типичным и не влияет на M. +Пары двух выбывших в сумму не входят (#91), остальные — все. K_i спускается от K_MAX +у новичка до K_MIN за K_GAMES партий, G(N) — вес размера стола, M — множитель отрыва +(темп, цели, миры; близость по типу победы — только у пар с победителем). Недостающий +признак партии подставляется типичным и не влияет на M. Модуль — только константы и чистые функции без БД: калибровка на реальных данных — правка констант, пересчёт выполняется сам (рейтинг — функция упорядоченной истории). @@ -168,6 +169,10 @@ def rate_match( r = {s.user_id: ratings.get(s.user_id, R0) for s in m.seats} k = {s.user_id: k_factor(games.get(s.user_id, 0)) for s in m.seats} for a, b in combinations(m.seats, 2): + if a.eliminated and b.eliminated: + # Выбывшие между собой не сравниваются: в этой партии все они проиграли, а миров + # у них нет (решение владельца, #91). Нормировка на N − 1 остаётся прежней. + continue if a.place > b.place: a, b = b, a s_ab = 0.5 if a.place == b.place else 1.0 diff --git a/backend/tests/test_events_payload.py b/backend/tests/test_events_payload.py index 63bd3db..72a6198 100644 --- a/backend/tests/test_events_payload.py +++ b/backend/tests/test_events_payload.py @@ -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") diff --git a/backend/tests/test_rating_examples.py b/backend/tests/test_rating_examples.py index 38e286f..3885dd3 100644 --- a/backend/tests/test_rating_examples.py +++ b/backend/tests/test_rating_examples.py @@ -68,7 +68,7 @@ EXAMPLES = [ _seat(C, 3, 1, 0, eliminated=True), _seat(D, 3, 0, 0, eliminated=True)), "objectives", end_round=7, ), - {A: 9.27, B: 5.85, C: -7.78, D: -7.35}), + {A: 9.27, B: 5.85, C: -7.6, D: -7.53}), ("6a", dict.fromkeys(range(A, F + 1), 1500), _vets(*range(A, F + 1)), RatedMatch(SIX, "objectives", end_round=8, nine_rounds_rule=True), {A: 12.0, B: 7.2, C: 2.4, D: -2.4, E: -7.2, F: -12.0}), @@ -101,6 +101,23 @@ def test_last_standing_counts_full_objective_gap(): assert standing - ordinary == pytest.approx(16 * 0.5 * 0.5) +def test_eliminated_are_not_compared_with_each_other(): + """Выбывшие между собой не сравниваются (#91): слабый выбывший среди сильных не + получает рейтинг, а рейтинги прочих выбывших на его изменение не влияют.""" + six = range(A, F + 1) + seats = (_seat(A, 1),) + tuple(_seat(u, 2, eliminated=True) for u in six if u != A) + match = RatedMatch(seats, "last_standing") + strong = {**dict.fromkeys(six, 1800), F: 1200} + delta = rate_match(strong, _vets(*six), match)[0] + assert delta[F] < 0 + # Сильные выбывшие → слабые: у F и у победителя ничего не меняется от этого. + weak = {**dict.fromkeys(six, 1200), A: 1800} + again = rate_match(weak, _vets(*six), match)[0] + assert again[F] == pytest.approx(delta[F]) + # Победителю сила соперников по-прежнему важна: против слабых он получает меньше. + assert again[A] < delta[A] + + # ─── Сверка с эталоном ─────────────────────────────────────────────────────── SIMULATE = Path(__file__).resolve().parents[2] / "docs" / "rating" / "simulate.py" @@ -147,6 +164,7 @@ def test_constants_match_reference(sim): assert dict(p.closeness) == scoring.CLOSENESS assert sim.BOARD_TILES == scoring.BOARD_TILES assert not p.autocorr + assert p.skip_eliminated_pairs # выбывшие между собой не сравниваются (#91) @pytest.mark.parametrize("scenario", ["сигнал", "клубы", "рост"]) @@ -174,8 +192,8 @@ def test_replay_matches_reference_season(sim, scenario, stripped): def test_monotone_and_zero_sum(sim): - """Победитель без ничьей не теряет, последний без ничьей не получает; при равных K - сумма изменений за партию — ноль.""" + """Победитель без ничьей не теряет, последний без ничьей и любой выбывший не получают; + при равных K сумма изменений за партию — ноль.""" cfg = sim.SCENARIOS["сигнал"] _skill, matches = sim.generate_season(cfg["seed"] + 7, 200, True) ids: dict[str, int] = {} @@ -188,6 +206,8 @@ def test_monotone_and_zero_sum(sim): delta, _ = rate_match({}, veterans, rm) places = [s.place for s in rm.seats] for s in rm.seats: + if s.eliminated: + assert delta[s.user_id] < 0 if places.count(s.place) > 1: continue if s.place == 1: diff --git a/docs/rating/rating-system.md b/docs/rating/rating-system.md index d803733..c1a738c 100644 --- a/docs/rating/rating-system.md +++ b/docs/rating/rating-system.md @@ -26,8 +26,11 @@ влияет, но слабее темпа. - **Шкала — классический Elo.** Старт 1500, разница 400 пунктов — шансы 10:1. Пример из задачи «60 против 40» в ней — 1600 против 1400. -- **Монотонность.** Победитель никогда не теряет рейтинг, последнее место никогда не - приносит. +- **Выбывшие между собой не сравниваются.** Все они проиграли, а миров у них нет: пара двух + выбывших в расчёт не входит. Сила соперников при этом важна, как и прежде (4.4). +- **Монотонность.** Единоличный победитель и выбывшие всегда движутся в свою сторону: первый + не теряет рейтинг, вторые его не получают. Внутри ничьей невыбывших — за 1-е или + последнее место — пара может сдвинуть рейтинг в любую сторону (4.9). - **Старая история.** Пересчитывается по тем же формулам: у партий без новых полей признаки берутся нейтральными. @@ -159,7 +162,8 @@ E_{ab} = \frac{1}{1 + 10^{(R_b - R_a)/D}} ### 4.3. Фактический результат пары `S_ab = 1`, если `a` занял место выше `b`; `0.5`, если места равны; `0` — если ниже. -Выбывшие делят последнее место, как и сейчас (`match_service._resolve_finish_places`). +Выбывшие делят последнее место, как и сейчас (`match_service._resolve_finish_places`), +но между собой не сравниваются (4.4). ### 4.4. Изменение рейтинга @@ -170,6 +174,14 @@ E_{ab} = \frac{1}{1 + 10^{(R_b - R_a)/D}} Все рейтинги в формуле — **до** партии. Деление на `N − 1` приводит сумму по соперникам к «средней дуэли», размер стола затем добавляется явно через `G(N)`. +**Выбывшие между собой не сравниваются** (решение владельца, #91): если `i` и `j` оба +выбыли, их пара в сумму не входит — ни `S − E`, ни множитель отрыва. В этой партии они +все проиграли, то есть оказались одинаково слабы, а миров у выбывших нет. Пары выбывшего +с невыбывшими, в том числе с победителем, считаются как обычно: сила соперников важна. +Нормировка на `N − 1` не меняется, поэтому при равных рейтингах результат прежний — такая +пара и раньше давала `S − E = 0`. Уходит только перекос, при котором слабый выбывший +получал рейтинг за счёт сильных выбывших. + ### 4.5. Коэффициент K — скорость изменения ```math @@ -236,7 +248,10 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab} ### 4.9. Свойства - **Монотонность.** 1-е место без ничьей даёт `S − E > 0` во всех парах, а `M > 0` — значит, - рейтинг растёт. Последнее место без ничьей всегда уменьшает рейтинг. + рейтинг растёт. Последнее место без ничьей всегда уменьшает рейтинг. Выбывший — тоже: + его пары с выбывшими не считаются, а каждому невыбывшему он проиграл. Внутри ничьей + невыбывших `S = 0.5`, и знак `S − E` зависит от рейтингов: сильный игрок, поделивший + 1-е место со слабым, может потерять. - **Сумма-ноль.** `M_ab = M_ba`, поэтому при равных K сумма изменений за партию равна нулю и рейтинг не раздувается. Когда K разные (новичок и ветеран), сумма не нулевая — это сделано намеренно (пример 7). В симуляции среднее по лиге за 300 партий сдвигается @@ -352,16 +367,16 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab} | A–D | 1 | 0.640 | 1 | 1 | 1 + 0.25 + 0.25 = **1.500** | 0.540 | | B–C | 1 | 0.529 | 2/4 | 1 | 1 + 0 + 0.25 = **1.250** | 0.589 | | B–D | 1 | 0.571 | 3/4 | 1 | **1.375** | 0.589 | -| C–D | 0.5 | 0.543 | 1/4 (по модулю) | 0 | 1 − 0.125 − 0.25 = **0.625** | −0.027 | +| C–D | — | — | — | — | оба выбыли — пара не считается (4.4) | 0 | - `ΔR_A = 6.67 · (0.300 + 0.551 + 0.540)` = **+9.27** - `ΔR_B = 6.67 · (−0.300 + 0.589 + 0.589)` = **+5.85** -- `ΔR_C = 6.67 · (−0.551 − 0.589 − 0.027)` = **−7.78** -- `ΔR_D = 6.67 · (−0.540 − 0.589 + 0.027)` = **−7.35** +- `ΔR_C = 6.67 · (−0.551 − 0.589)` = **−7.60** +- `ΔR_D = 6.67 · (−0.540 − 0.589)` = **−7.53** Итого: A и B близки друг к другу по целям и мирам, поэтому эта пара весит 0.7. Отрыв обоих -от выбывших огромный — эти пары весят 1.25–1.5. C и D поделили место, но C сильнее по -рейтингу, поэтому немного уступает D. +от выбывших огромный — эти пары весят 1.25–1.5. C и D между собой не сравниваются: оба +проиграли всем невыбывшим. C теряет чуть больше, потому что от более сильного ждали большего. ### Пример 6. Стол на 6 и хоумрул 9 раундов @@ -435,7 +450,9 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab} ### 7.3. Результаты: 200 сезонов на сценарий «Без новых полей» — предложенная система на той же истории, но без раунда, целей и миров: -так будет считаться история, накопленная до #23. +так будет считаться история, накопленная до #23. Строки предложенной системы пересчитаны +с правилом «выбывшие между собой не сравниваются» (#91). Оно сдвинуло метрики лишь +в третьем знаке, у остальных систем цифры прежние. **Сигнал** (потолок точности 0.6843) @@ -443,8 +460,8 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab} |---|---|---|---|---|---|---|---|---|---| | League Points (сейчас) | 0.6683 | — | 0.913 | 0.660 | 0.769 | 0.854 | — | — | 6.91 | | Elo, чистый | 0.6691 | 0.2089 | 0.911 | 0.656 | 0.770 | 0.852 | 47.4 | 0.81 | 5.24 | -| **Предложенная** | **0.6721** | **0.2077** | **0.929** | **0.711** | **0.807** | **0.881** | **41.9** | **0.95** | 5.95 | -| Предложенная, без новых полей | 0.6701 | 0.2085 | 0.916 | 0.677 | 0.782 | 0.864 | 47.4 | 0.79 | 5.90 | +| **Предложенная** | **0.6721** | **0.2077** | **0.929** | **0.712** | **0.807** | **0.881** | **41.9** | **0.95** | 5.95 | +| Предложенная, без новых полей | 0.6701 | 0.2085 | 0.916 | 0.677 | 0.783 | 0.864 | 47.2 | 0.79 | 5.90 | **Клубы** (потолок 0.6308) @@ -452,8 +469,8 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab} |---|---|---|---|---|---|---|---|---|---| | League Points (сейчас) | 0.6045 | — | 0.525 | 0.303 | 0.391 | 0.463 | — | — | 8.06 | | Elo, чистый | 0.6063 | 0.2341 | 0.632 | 0.326 | 0.440 | 0.535 | 102.6 | 0.40 | 5.71 | -| **Предложенная** | **0.6102** | 0.2336 | **0.644** | **0.356** | **0.463** | **0.555** | **100.3** | **0.47** | 6.53 | -| Предложенная, без новых полей | 0.6074 | **0.2332** | 0.633 | 0.336 | 0.440 | 0.534 | 103.3 | 0.38 | 6.41 | +| **Предложенная** | **0.6101** | 0.2336 | **0.645** | **0.356** | **0.463** | **0.555** | **100.3** | **0.47** | 6.53 | +| Предложенная, без новых полей | 0.6073 | **0.2332** | 0.633 | 0.336 | 0.440 | 0.535 | 103.3 | 0.38 | 6.41 | **Рост** (потолок 0.6864) @@ -461,8 +478,8 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab} |---|---|---|---|---|---|---|---|---|---| | League Points (сейчас) | 0.6688 | — | 0.915 | 0.631 | 0.732 | 0.824 | — | — | 6.91 | | Elo, чистый | 0.6687 | 0.2086 | 0.916 | 0.632 | 0.733 | 0.825 | 48.0 | 0.81 | 5.25 | -| **Предложенная** | **0.6726** | **0.2076** | **0.929** | **0.685** | **0.774** | **0.851** | **42.7** | **0.95** | 5.96 | -| Предложенная, без новых полей | 0.6697 | 0.2081 | 0.922 | 0.651 | 0.750 | 0.835 | 47.6 | 0.79 | 5.91 | +| **Предложенная** | **0.6726** | **0.2076** | **0.929** | **0.685** | **0.775** | **0.850** | **42.7** | **0.95** | 5.96 | +| Предложенная, без новых полей | 0.6697 | 0.2081 | 0.922 | 0.651 | 0.750 | 0.835 | 47.5 | 0.79 | 5.91 | **Шум** (потолок 0.6894) @@ -470,16 +487,16 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab} |---|---|---|---|---|---|---|---|---|---| | League Points (сейчас) | 0.6737 | — | 0.916 | 0.656 | 0.761 | 0.848 | — | — | 6.89 | | Elo, чистый | 0.6739 | **0.2070** | 0.917 | 0.659 | 0.765 | 0.851 | **46.1** | **0.82** | 5.23 | -| Предложенная | 0.6727 | 0.2076 | 0.908 | 0.643 | 0.750 | 0.844 | 48.6 | 0.81 | 5.91 | -| Предложенная, без новых полей | **0.6743** | **0.2070** | **0.919** | **0.667** | **0.775** | **0.856** | 47.4 | 0.78 | 5.88 | +| Предложенная | 0.6728 | 0.2076 | 0.908 | 0.643 | 0.750 | 0.844 | 48.5 | 0.81 | 5.91 | +| Предложенная, без новых полей | **0.6744** | **0.2070** | **0.919** | **0.667** | **0.776** | **0.856** | 47.3 | 0.78 | 5.88 | Парные разности (среднее ± стандартная ошибка по 200 сезонам): | Сценарий | Brier: предложенная − чистый Elo | Точность: предложенная − чистый Elo | Точность: предложенная − сейчас | |---|---|---|---| -| сигнал | −0.0011 ± 0.0001 | +0.30 ± 0.06 п.п. | +0.37 ± 0.07 п.п. | -| клубы | −0.0005 ± 0.0002 | +0.38 ± 0.08 п.п. | +0.57 ± 0.10 п.п. | -| рост | −0.0010 ± 0.0001 | +0.39 ± 0.07 п.п. | +0.37 ± 0.06 п.п. | +| сигнал | −0.0011 ± 0.0001 | +0.30 ± 0.06 п.п. | +0.38 ± 0.07 п.п. | +| клубы | −0.0005 ± 0.0002 | +0.38 ± 0.08 п.п. | +0.56 ± 0.10 п.п. | +| рост | −0.0010 ± 0.0001 | +0.39 ± 0.06 п.п. | +0.38 ± 0.06 п.п. | | шум | +0.0006 ± 0.0001 | −0.12 ± 0.06 п.п. | −0.10 ± 0.07 п.п. | Выводы: @@ -490,7 +507,7 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab} Рейтинг меньше сжат к середине (наклон 0.95 против 0.81): сильные игроки быстрее отрываются от середняков. 2. **Сила соперников — главное преимущество Elo над текущей системой.** В «клубах» текущая - система упорядочивает игроков заметно хуже (ρ 0.525 против 0.644): чемпион слабой группы + система упорядочивает игроков заметно хуже (ρ 0.525 против 0.645): чемпион слабой группы у неё стоит рядом с чемпионом сильной. 3. **Если отрыв — шум**, предложенная система теряет 0.1 п.п. точности и 0.0006 Brier — цена лишней волатильности. Это худший из рассмотренных случаев: в остальных сценариях @@ -506,6 +523,8 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab} `simulate.py --grid` перебирает коэффициенты на **других** 40 сезонах каждого сценария. Критерий — средний Brier, меньше — лучше. Разница в 0.0001 — примерно граница шума. +Перебор выполнен до правила «выбывшие между собой не сравниваются» (#91) и не +переигрывался: правило сдвигает метрики лишь в третьем знаке (7.3). **Этап 1. K чистого Elo** (все четыре сценария). Спуск K за 20 партий лучше, чем за 10. Выгоден высокий K новичка и низкий K ветерана. @@ -732,6 +751,7 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab} | 5 | Затухание за неактивность | **Не добавлять** | без изменений | | 6 | Минимум партий для топа | **Оставить 10** | без изменений: `MIN_GAMES = 10` | | 7 | Групповой рейтинг отдельной цепочкой (после внедрения, #80, 2026-09-15) | **Убрать.** Рейтинг единый; на странице группы — показатели по партиям группы, на главной и в профиле — общие | раздел 8, «Расчёт» | +| 8 | Сравнивать ли выбывших между собой (после ревью, #87 → #91, 2026-09-18) | **Нет.** Все они проиграли и одинаково слабы в этой партии, миров у них нет; при расчёте победителя сила соперников по-прежнему важна | 4.3, 4.4, 4.9, пример 5, итоги 7.3 (подбор 7.4 не переигрывался) | Открытых вопросов по предложению не осталось. Калибровка коэффициентов на реальных данных — после внедрения #23 (раздел 8, «Калибровка»). diff --git a/docs/rating/simulate.py b/docs/rating/simulate.py index 7711926..8fa0546 100644 --- a/docs/rating/simulate.py +++ b/docs/rating/simulate.py @@ -93,6 +93,9 @@ class Params: m_min: float = 0.5 m_max: float = 2.0 autocorr: bool = False # поправка на автокорреляцию (см. документ) + # Выбывшие между собой не сравниваются: пара двух выбывших не входит в сумму + # (решение владельца, #91). У систем для сравнения — как было. + skip_eliminated_pairs: bool = False def closeness_for(self, reason: str | None) -> float: return dict(self.closeness).get(reason, 1.0) if reason else 1.0 @@ -181,6 +184,8 @@ def rate_match( k = {s.player: k_factor(games.get(s.player, 0), p) for s in m.seats} delta = {s.player: 0.0 for s in m.seats} for a, b in combinations(m.seats, 2): + if p.skip_eliminated_pairs and a.eliminated and b.eliminated: + continue if a.place > b.place: a, b = b, a s_ab = 0.5 if a.place == b.place else 1.0 @@ -550,6 +555,7 @@ PROPOSED = Params( w_obj=0.5, w_worlds=0.5, closeness=CLOSENESS, + skip_eliminated_pairs=True, ) @@ -594,7 +600,7 @@ def examples() -> list[tuple[str, str, dict, dict, Match]]: ("4e", "Разгром: 3-й раунд, 2:0 цели, 8:2 миров", {"A": 1500, "B": 1500}, _vets("A", "B"), Match([Seat("A", 1, 2, 8), Seat("B", 2, 0, 2)], "objectives", round=3)), - ("5", "Стол на 4: ничья выбывших, раунд 7", + ("5", "Стол на 4: двое выбывших, раунд 7", {"A": 1550, "B": 1500, "C": 1480, "D": 1450}, _vets(*"ABCD"), Match( [Seat("A", 1, 4, 8), Seat("B", 2, 3, 7), @@ -625,7 +631,7 @@ EXPECTED: dict[str, dict[str, float]] = { "4c": {"A": 4.8, "B": -4.8}, "4d": {"A": 3.4, "B": -3.4}, "4e": {"A": 16.0, "B": -16.0}, - "5": {"A": 9.27, "B": 5.85, "C": -7.78, "D": -7.35}, + "5": {"A": 9.27, "B": 5.85, "C": -7.6, "D": -7.53}, "6a": {"A": 12.0, "B": 7.2, "C": 2.4, "D": -2.4, "E": -7.2, "F": -12.0}, "6b": {"A": 10.29, "B": 7.54, "C": 2.74, "D": -2.06, "E": -6.86, "F": -11.66}, "7": {"A": 32.0, "B": -8.0}, diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index eeaa086..239aaed 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -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", + }); +} diff --git a/frontend/src/components/PlaceEditor.tsx b/frontend/src/components/PlaceEditor.tsx index 4fe74a3..b33b2cf 100644 --- a/frontend/src/components/PlaceEditor.tsx +++ b/frontend/src/components/PlaceEditor.tsx @@ -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))} /> ); diff --git a/frontend/src/domain/matchCounts.ts b/frontend/src/domain/matchCounts.ts new file mode 100644 index 0000000..83ed05a --- /dev/null +++ b/frontend/src/domain/matchCounts.ts @@ -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))); +} diff --git a/frontend/src/hooks/matches.ts b/frontend/src/hooks/matches.ts index 8945c2e..d09a415 100644 --- a/frontend/src/hooks/matches.ts +++ b/frontend/src/hooks/matches.ts @@ -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); }, }); } diff --git a/frontend/src/hooks/useServerEvents.ts b/frontend/src/hooks/useServerEvents.ts index e07c63f..d125d91 100644 --- a/frontend/src/hooks/useServerEvents.ts +++ b/frontend/src/hooks/useServerEvents.ts @@ -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(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) }); diff --git a/frontend/src/pages/HelpPage.tsx b/frontend/src/pages/HelpPage.tsx index be864ea..e93ce49 100644 --- a/frontend/src/pages/HelpPage.tsx +++ b/frontend/src/pages/HelpPage.tsx @@ -54,7 +54,8 @@ export function HelpPage() {

где N — число игроков, сумма — по всем соперникам. Поэтому победа над сильным приносит больше, чем над слабым, а поражение от слабого отнимает больше. Выбывшие - делят последнее место. + делят последнее место, но между собой не сравниваются: в этой партии все они + проиграли, так что пара двух выбывших рейтинг не двигает.

@@ -93,7 +94,9 @@ export function HelpPage() { с правилом группы «9 раундов» при 5–6 игроках — 9.

- Победитель никогда не теряет рейтинг, а последнее место никогда его не приносит. + Единоличный победитель никогда не теряет рейтинг, а единоличное последнее место + и выбывание никогда его не приносят. Невыбывшие, поделившие место, сравниваются + между собой как в ничьей: слабый может получить рейтинг, сильный — потерять.

diff --git a/frontend/src/pages/MatchDetailPage.tsx b/frontend/src/pages/MatchDetailPage.tsx index a9ef368..304c6bc 100644 --- a/frontend/src/pages/MatchDetailPage.tsx +++ b/frontend/src/pages/MatchDetailPage.tsx @@ -52,6 +52,18 @@ const countDict = (counts: Counts, field: CountField): Record => 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; diff --git a/frontend/src/pages/admin/AdminMatchEdit.tsx b/frontend/src/pages/admin/AdminMatchEdit.tsx index afe0aba..74c53b7 100644 --- a/frontend/src/pages/admin/AdminMatchEdit.tsx +++ b/frontend/src/pages/admin/AdminMatchEdit.tsx @@ -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}