Compare commits
5
Commits
7829a49e9d
...
bb5f2a1121
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb5f2a1121 | ||
|
|
63a2cddd5e | ||
|
|
5035ee41e6 | ||
|
|
6c35cb04d4 | ||
|
|
63f541d61f |
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -7,9 +7,10 @@
|
|||||||
E_ab = 1 / (1 + 10^((R_b − R_a) / D)) ожидание по рейтингам ДО партии
|
E_ab = 1 / (1 + 10^((R_b − R_a) / D)) ожидание по рейтингам ДО партии
|
||||||
S_ab = 1 / 0.5 / 0 выше / поровну / ниже
|
S_ab = 1 / 0.5 / 0 выше / поровну / ниже
|
||||||
ΔR_i = K_i · G(N) / (N − 1) · Σ_j M_ij · (S_ij − E_ij)
|
Δ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) — вес размера стола,
|
Пары двух выбывших в сумму не входят (#91), остальные — все. K_i спускается от K_MAX
|
||||||
M — множитель отрыва (темп, цели, миры; близость по типу победы — только у пар
|
у новичка до K_MIN за K_GAMES партий, G(N) — вес размера стола, M — множитель отрыва
|
||||||
с победителем). Недостающий признак партии подставляется типичным и не влияет на M.
|
(темп, цели, миры; близость по типу победы — только у пар с победителем). Недостающий
|
||||||
|
признак партии подставляется типичным и не влияет на M.
|
||||||
|
|
||||||
Модуль — только константы и чистые функции без БД: калибровка на реальных данных —
|
Модуль — только константы и чистые функции без БД: калибровка на реальных данных —
|
||||||
правка констант, пересчёт выполняется сам (рейтинг — функция упорядоченной истории).
|
правка констант, пересчёт выполняется сам (рейтинг — функция упорядоченной истории).
|
||||||
@@ -168,6 +169,10 @@ def rate_match(
|
|||||||
r = {s.user_id: ratings.get(s.user_id, R0) for s in m.seats}
|
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}
|
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):
|
for a, b in combinations(m.seats, 2):
|
||||||
|
if a.eliminated and b.eliminated:
|
||||||
|
# Выбывшие между собой не сравниваются: в этой партии все они проиграли, а миров
|
||||||
|
# у них нет (решение владельца, #91). Нормировка на N − 1 остаётся прежней.
|
||||||
|
continue
|
||||||
if a.place > b.place:
|
if a.place > b.place:
|
||||||
a, b = b, a
|
a, b = b, a
|
||||||
s_ab = 0.5 if a.place == b.place else 1.0
|
s_ab = 0.5 if a.place == b.place else 1.0
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ EXAMPLES = [
|
|||||||
_seat(C, 3, 1, 0, eliminated=True), _seat(D, 3, 0, 0, eliminated=True)),
|
_seat(C, 3, 1, 0, eliminated=True), _seat(D, 3, 0, 0, eliminated=True)),
|
||||||
"objectives", end_round=7,
|
"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)),
|
("6a", dict.fromkeys(range(A, F + 1), 1500), _vets(*range(A, F + 1)),
|
||||||
RatedMatch(SIX, "objectives", end_round=8, nine_rounds_rule=True),
|
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}),
|
{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)
|
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"
|
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 dict(p.closeness) == scoring.CLOSENESS
|
||||||
assert sim.BOARD_TILES == scoring.BOARD_TILES
|
assert sim.BOARD_TILES == scoring.BOARD_TILES
|
||||||
assert not p.autocorr
|
assert not p.autocorr
|
||||||
|
assert p.skip_eliminated_pairs # выбывшие между собой не сравниваются (#91)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("scenario", ["сигнал", "клубы", "рост"])
|
@pytest.mark.parametrize("scenario", ["сигнал", "клубы", "рост"])
|
||||||
@@ -174,8 +192,8 @@ def test_replay_matches_reference_season(sim, scenario, stripped):
|
|||||||
|
|
||||||
|
|
||||||
def test_monotone_and_zero_sum(sim):
|
def test_monotone_and_zero_sum(sim):
|
||||||
"""Победитель без ничьей не теряет, последний без ничьей не получает; при равных K
|
"""Победитель без ничьей не теряет, последний без ничьей и любой выбывший не получают;
|
||||||
сумма изменений за партию — ноль."""
|
при равных K сумма изменений за партию — ноль."""
|
||||||
cfg = sim.SCENARIOS["сигнал"]
|
cfg = sim.SCENARIOS["сигнал"]
|
||||||
_skill, matches = sim.generate_season(cfg["seed"] + 7, 200, True)
|
_skill, matches = sim.generate_season(cfg["seed"] + 7, 200, True)
|
||||||
ids: dict[str, int] = {}
|
ids: dict[str, int] = {}
|
||||||
@@ -188,6 +206,8 @@ def test_monotone_and_zero_sum(sim):
|
|||||||
delta, _ = rate_match({}, veterans, rm)
|
delta, _ = rate_match({}, veterans, rm)
|
||||||
places = [s.place for s in rm.seats]
|
places = [s.place for s in rm.seats]
|
||||||
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:
|
if places.count(s.place) > 1:
|
||||||
continue
|
continue
|
||||||
if s.place == 1:
|
if s.place == 1:
|
||||||
|
|||||||
@@ -26,8 +26,11 @@
|
|||||||
влияет, но слабее темпа.
|
влияет, но слабее темпа.
|
||||||
- **Шкала — классический Elo.** Старт 1500, разница 400 пунктов — шансы 10:1. Пример из
|
- **Шкала — классический Elo.** Старт 1500, разница 400 пунктов — шансы 10:1. Пример из
|
||||||
задачи «60 против 40» в ней — 1600 против 1400.
|
задачи «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. Фактический результат пары
|
### 4.3. Фактический результат пары
|
||||||
|
|
||||||
`S_ab = 1`, если `a` занял место выше `b`; `0.5`, если места равны; `0` — если ниже.
|
`S_ab = 1`, если `a` занял место выше `b`; `0.5`, если места равны; `0` — если ниже.
|
||||||
Выбывшие делят последнее место, как и сейчас (`match_service._resolve_finish_places`).
|
Выбывшие делят последнее место, как и сейчас (`match_service._resolve_finish_places`),
|
||||||
|
но между собой не сравниваются (4.4).
|
||||||
|
|
||||||
### 4.4. Изменение рейтинга
|
### 4.4. Изменение рейтинга
|
||||||
|
|
||||||
@@ -170,6 +174,14 @@ E_{ab} = \frac{1}{1 + 10^{(R_b - R_a)/D}}
|
|||||||
Все рейтинги в формуле — **до** партии. Деление на `N − 1` приводит сумму по соперникам
|
Все рейтинги в формуле — **до** партии. Деление на `N − 1` приводит сумму по соперникам
|
||||||
к «средней дуэли», размер стола затем добавляется явно через `G(N)`.
|
к «средней дуэли», размер стола затем добавляется явно через `G(N)`.
|
||||||
|
|
||||||
|
**Выбывшие между собой не сравниваются** (решение владельца, #91): если `i` и `j` оба
|
||||||
|
выбыли, их пара в сумму не входит — ни `S − E`, ни множитель отрыва. В этой партии они
|
||||||
|
все проиграли, то есть оказались одинаково слабы, а миров у выбывших нет. Пары выбывшего
|
||||||
|
с невыбывшими, в том числе с победителем, считаются как обычно: сила соперников важна.
|
||||||
|
Нормировка на `N − 1` не меняется, поэтому при равных рейтингах результат прежний — такая
|
||||||
|
пара и раньше давала `S − E = 0`. Уходит только перекос, при котором слабый выбывший
|
||||||
|
получал рейтинг за счёт сильных выбывших.
|
||||||
|
|
||||||
### 4.5. Коэффициент K — скорость изменения
|
### 4.5. Коэффициент K — скорость изменения
|
||||||
|
|
||||||
```math
|
```math
|
||||||
@@ -236,7 +248,10 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
### 4.9. Свойства
|
### 4.9. Свойства
|
||||||
|
|
||||||
- **Монотонность.** 1-е место без ничьей даёт `S − E > 0` во всех парах, а `M > 0` — значит,
|
- **Монотонность.** 1-е место без ничьей даёт `S − E > 0` во всех парах, а `M > 0` — значит,
|
||||||
рейтинг растёт. Последнее место без ничьей всегда уменьшает рейтинг.
|
рейтинг растёт. Последнее место без ничьей всегда уменьшает рейтинг. Выбывший — тоже:
|
||||||
|
его пары с выбывшими не считаются, а каждому невыбывшему он проиграл. Внутри ничьей
|
||||||
|
невыбывших `S = 0.5`, и знак `S − E` зависит от рейтингов: сильный игрок, поделивший
|
||||||
|
1-е место со слабым, может потерять.
|
||||||
- **Сумма-ноль.** `M_ab = M_ba`, поэтому при равных K сумма изменений за партию равна нулю
|
- **Сумма-ноль.** `M_ab = M_ba`, поэтому при равных K сумма изменений за партию равна нулю
|
||||||
и рейтинг не раздувается. Когда K разные (новичок и ветеран), сумма не нулевая —
|
и рейтинг не раздувается. Когда K разные (новичок и ветеран), сумма не нулевая —
|
||||||
это сделано намеренно (пример 7). В симуляции среднее по лиге за 300 партий сдвигается
|
это сделано намеренно (пример 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 |
|
| 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–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 |
|
| 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_A = 6.67 · (0.300 + 0.551 + 0.540)` = **+9.27**
|
||||||
- `ΔR_B = 6.67 · (−0.300 + 0.589 + 0.589)` = **+5.85**
|
- `Δ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_C = 6.67 · (−0.551 − 0.589)` = **−7.60**
|
||||||
- `ΔR_D = 6.67 · (−0.540 − 0.589 + 0.027)` = **−7.35**
|
- `ΔR_D = 6.67 · (−0.540 − 0.589)` = **−7.53**
|
||||||
|
|
||||||
Итого: A и B близки друг к другу по целям и мирам, поэтому эта пара весит 0.7. Отрыв обоих
|
Итого: A и B близки друг к другу по целям и мирам, поэтому эта пара весит 0.7. Отрыв обоих
|
||||||
от выбывших огромный — эти пары весят 1.25–1.5. C и D поделили место, но C сильнее по
|
от выбывших огромный — эти пары весят 1.25–1.5. C и D между собой не сравниваются: оба
|
||||||
рейтингу, поэтому немного уступает D.
|
проиграли всем невыбывшим. C теряет чуть больше, потому что от более сильного ждали большего.
|
||||||
|
|
||||||
### Пример 6. Стол на 6 и хоумрул 9 раундов
|
### Пример 6. Стол на 6 и хоумрул 9 раундов
|
||||||
|
|
||||||
@@ -435,7 +450,9 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
### 7.3. Результаты: 200 сезонов на сценарий
|
### 7.3. Результаты: 200 сезонов на сценарий
|
||||||
|
|
||||||
«Без новых полей» — предложенная система на той же истории, но без раунда, целей и миров:
|
«Без новых полей» — предложенная система на той же истории, но без раунда, целей и миров:
|
||||||
так будет считаться история, накопленная до #23.
|
так будет считаться история, накопленная до #23. Строки предложенной системы пересчитаны
|
||||||
|
с правилом «выбывшие между собой не сравниваются» (#91). Оно сдвинуло метрики лишь
|
||||||
|
в третьем знаке, у остальных систем цифры прежние.
|
||||||
|
|
||||||
**Сигнал** (потолок точности 0.6843)
|
**Сигнал** (потолок точности 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 |
|
| 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 |
|
| 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.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.782 | 0.864 | 47.4 | 0.79 | 5.90 |
|
| Предложенная, без новых полей | 0.6701 | 0.2085 | 0.916 | 0.677 | 0.783 | 0.864 | 47.2 | 0.79 | 5.90 |
|
||||||
|
|
||||||
**Клубы** (потолок 0.6308)
|
**Клубы** (потолок 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 |
|
| 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 |
|
| 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.6101** | 0.2336 | **0.645** | **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.6073 | **0.2332** | 0.633 | 0.336 | 0.440 | 0.535 | 103.3 | 0.38 | 6.41 |
|
||||||
|
|
||||||
**Рост** (потолок 0.6864)
|
**Рост** (потолок 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 |
|
| 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 |
|
| 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.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.6 | 0.79 | 5.91 |
|
| Предложенная, без новых полей | 0.6697 | 0.2081 | 0.922 | 0.651 | 0.750 | 0.835 | 47.5 | 0.79 | 5.91 |
|
||||||
|
|
||||||
**Шум** (потолок 0.6894)
|
**Шум** (потолок 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 |
|
| 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 |
|
| 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.6728 | 0.2076 | 0.908 | 0.643 | 0.750 | 0.844 | 48.5 | 0.81 | 5.91 |
|
||||||
| Предложенная, без новых полей | **0.6743** | **0.2070** | **0.919** | **0.667** | **0.775** | **0.856** | 47.4 | 0.78 | 5.88 |
|
| Предложенная, без новых полей | **0.6744** | **0.2070** | **0.919** | **0.667** | **0.776** | **0.856** | 47.3 | 0.78 | 5.88 |
|
||||||
|
|
||||||
Парные разности (среднее ± стандартная ошибка по 200 сезонам):
|
Парные разности (среднее ± стандартная ошибка по 200 сезонам):
|
||||||
|
|
||||||
| Сценарий | Brier: предложенная − чистый Elo | Точность: предложенная − чистый Elo | Точность: предложенная − сейчас |
|
| Сценарий | Brier: предложенная − чистый Elo | Точность: предложенная − чистый Elo | Точность: предложенная − сейчас |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| сигнал | −0.0011 ± 0.0001 | +0.30 ± 0.06 п.п. | +0.37 ± 0.07 п.п. |
|
| сигнал | −0.0011 ± 0.0001 | +0.30 ± 0.06 п.п. | +0.38 ± 0.07 п.п. |
|
||||||
| клубы | −0.0005 ± 0.0002 | +0.38 ± 0.08 п.п. | +0.57 ± 0.10 п.п. |
|
| клубы | −0.0005 ± 0.0002 | +0.38 ± 0.08 п.п. | +0.56 ± 0.10 п.п. |
|
||||||
| рост | −0.0010 ± 0.0001 | +0.39 ± 0.07 п.п. | +0.37 ± 0.06 п.п. |
|
| рост | −0.0010 ± 0.0001 | +0.39 ± 0.06 п.п. | +0.38 ± 0.06 п.п. |
|
||||||
| шум | +0.0006 ± 0.0001 | −0.12 ± 0.06 п.п. | −0.10 ± 0.07 п.п. |
|
| шум | +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): сильные игроки быстрее
|
Рейтинг меньше сжат к середине (наклон 0.95 против 0.81): сильные игроки быстрее
|
||||||
отрываются от середняков.
|
отрываются от середняков.
|
||||||
2. **Сила соперников — главное преимущество Elo над текущей системой.** В «клубах» текущая
|
2. **Сила соперников — главное преимущество Elo над текущей системой.** В «клубах» текущая
|
||||||
система упорядочивает игроков заметно хуже (ρ 0.525 против 0.644): чемпион слабой группы
|
система упорядочивает игроков заметно хуже (ρ 0.525 против 0.645): чемпион слабой группы
|
||||||
у неё стоит рядом с чемпионом сильной.
|
у неё стоит рядом с чемпионом сильной.
|
||||||
3. **Если отрыв — шум**, предложенная система теряет 0.1 п.п. точности и 0.0006 Brier —
|
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 сезонах каждого сценария.
|
`simulate.py --grid` перебирает коэффициенты на **других** 40 сезонах каждого сценария.
|
||||||
Критерий — средний Brier, меньше — лучше. Разница в 0.0001 — примерно граница шума.
|
Критерий — средний Brier, меньше — лучше. Разница в 0.0001 — примерно граница шума.
|
||||||
|
Перебор выполнен до правила «выбывшие между собой не сравниваются» (#91) и не
|
||||||
|
переигрывался: правило сдвигает метрики лишь в третьем знаке (7.3).
|
||||||
|
|
||||||
**Этап 1. K чистого Elo** (все четыре сценария). Спуск K за 20 партий лучше, чем за 10.
|
**Этап 1. K чистого Elo** (все четыре сценария). Спуск K за 20 партий лучше, чем за 10.
|
||||||
Выгоден высокий K новичка и низкий K ветерана.
|
Выгоден высокий K новичка и низкий K ветерана.
|
||||||
@@ -732,6 +751,7 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
| 5 | Затухание за неактивность | **Не добавлять** | без изменений |
|
| 5 | Затухание за неактивность | **Не добавлять** | без изменений |
|
||||||
| 6 | Минимум партий для топа | **Оставить 10** | без изменений: `MIN_GAMES = 10` |
|
| 6 | Минимум партий для топа | **Оставить 10** | без изменений: `MIN_GAMES = 10` |
|
||||||
| 7 | Групповой рейтинг отдельной цепочкой (после внедрения, #80, 2026-09-15) | **Убрать.** Рейтинг единый; на странице группы — показатели по партиям группы, на главной и в профиле — общие | раздел 8, «Расчёт» |
|
| 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, «Калибровка»).
|
после внедрения #23 (раздел 8, «Калибровка»).
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ class Params:
|
|||||||
m_min: float = 0.5
|
m_min: float = 0.5
|
||||||
m_max: float = 2.0
|
m_max: float = 2.0
|
||||||
autocorr: bool = False # поправка на автокорреляцию (см. документ)
|
autocorr: bool = False # поправка на автокорреляцию (см. документ)
|
||||||
|
# Выбывшие между собой не сравниваются: пара двух выбывших не входит в сумму
|
||||||
|
# (решение владельца, #91). У систем для сравнения — как было.
|
||||||
|
skip_eliminated_pairs: bool = False
|
||||||
|
|
||||||
def closeness_for(self, reason: str | None) -> float:
|
def closeness_for(self, reason: str | None) -> float:
|
||||||
return dict(self.closeness).get(reason, 1.0) if reason else 1.0
|
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}
|
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}
|
delta = {s.player: 0.0 for s in m.seats}
|
||||||
for a, b in combinations(m.seats, 2):
|
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:
|
if a.place > b.place:
|
||||||
a, b = b, a
|
a, b = b, a
|
||||||
s_ab = 0.5 if a.place == b.place else 1.0
|
s_ab = 0.5 if a.place == b.place else 1.0
|
||||||
@@ -550,6 +555,7 @@ PROPOSED = Params(
|
|||||||
w_obj=0.5,
|
w_obj=0.5,
|
||||||
w_worlds=0.5,
|
w_worlds=0.5,
|
||||||
closeness=CLOSENESS,
|
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 миров",
|
("4e", "Разгром: 3-й раунд, 2:0 цели, 8:2 миров",
|
||||||
{"A": 1500, "B": 1500}, _vets("A", "B"),
|
{"A": 1500, "B": 1500}, _vets("A", "B"),
|
||||||
Match([Seat("A", 1, 2, 8), Seat("B", 2, 0, 2)], "objectives", round=3)),
|
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"),
|
{"A": 1550, "B": 1500, "C": 1480, "D": 1450}, _vets(*"ABCD"),
|
||||||
Match(
|
Match(
|
||||||
[Seat("A", 1, 4, 8), Seat("B", 2, 3, 7),
|
[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},
|
"4c": {"A": 4.8, "B": -4.8},
|
||||||
"4d": {"A": 3.4, "B": -3.4},
|
"4d": {"A": 3.4, "B": -3.4},
|
||||||
"4e": {"A": 16.0, "B": -16.0},
|
"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},
|
"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},
|
"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},
|
"7": {"A": 32.0, "B": -8.0},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** Верхняя граница целей и миров — отсечка мусора, та же, что в схеме API (Count). */
|
||||||
|
export const MAX_COUNT = 99;
|
||||||
|
|
||||||
|
/** Поле «цели»/«миры» формы: пустое — не указано (null), иначе целое 0..MAX_COUNT. */
|
||||||
|
export function parseCount(raw: string): number | null {
|
||||||
|
const text = raw.trim();
|
||||||
|
const num = Number(text);
|
||||||
|
if (text === "" || !Number.isFinite(num)) return null;
|
||||||
|
return Math.min(MAX_COUNT, Math.max(0, Math.trunc(num)));
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { 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 });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) });
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ export function HelpPage() {
|
|||||||
<p className="small" style={{ marginTop: 10 }}>
|
<p className="small" style={{ marginTop: 10 }}>
|
||||||
где <b>N</b> — число игроков, сумма — по всем соперникам. Поэтому победа над сильным
|
где <b>N</b> — число игроков, сумма — по всем соперникам. Поэтому победа над сильным
|
||||||
приносит больше, чем над слабым, а поражение от слабого отнимает больше. Выбывшие
|
приносит больше, чем над слабым, а поражение от слабого отнимает больше. Выбывшие
|
||||||
делят последнее место.
|
делят последнее место, но между собой не сравниваются: в этой партии все они
|
||||||
|
проиграли, так что пара двух выбывших рейтинг не двигает.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -93,7 +94,9 @@ 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 }}>
|
||||||
Победитель никогда не теряет рейтинг, а последнее место никогда его не приносит.
|
Единоличный победитель никогда не теряет рейтинг, а единоличное последнее место
|
||||||
|
и выбывание никогда его не приносят. Невыбывшие, поделившие место, сравниваются
|
||||||
|
между собой как в ничьей: слабый может получить рейтинг, сильный — потерять.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,18 @@ const countDict = (counts: Counts, field: CountField): Record<string, number> =>
|
|||||||
|
|
||||||
const survivorsIn = (blocks: number[][]) => blocks.reduce((sum, ids) => sum + ids.length, 0);
|
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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user