Реализация новой рейтинговой системы (#23) #75

Merged
NotBigGhost merged 5 commits from issue-23-rating-system into dev 2026-09-14 23:38:20 +03:00
31 changed files with 2146 additions and 532 deletions
@@ -0,0 +1,130 @@
"""Рейтинг (#23): раунд окончания, цели и миры участников, правило 9 раундов, last_standing.
Идемпотентна: на свежей БД столбцы и новый CHECK создаёт 0001 (create_all из актуальных
моделей) -> меняется только бэкфилл (на пустой БД он ничего не находит); на существующей
БД добавляет столбцы, расширяет CHECK причины победы (если он в БД есть) и проставляет
last_standing.
Столбцы nullable и задним числом не заполняются: NULL — «нет данных», рейтинг
подставляет вместо них типичные значения (docs/rating/rating-system.md, 4.8).
Revision ID: 0014_rating_inputs
Revises: 0013_user_token_version
Create Date: 2026-09-14
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0014_rating_inputs"
down_revision: Union[str, None] = "0013_user_token_version"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
CK_WIN_REASON = "ck_match_win_reason"
OLD_REASONS = "win_reason IS NULL OR win_reason IN ('objectives','worlds','plastic','resources')"
NEW_REASONS = (
"win_reason IS NULL OR win_reason IN "
"('objectives','worlds','plastic','resources','last_standing')"
)
# Причина last_standing ⇔ невыбывший участник ровно один (решение владельца по #22).
BACKFILL_LAST_STANDING = """
UPDATE matches SET win_reason = 'last_standing'
WHERE status = 'finished'
AND (win_reason IS NULL OR win_reason <> 'last_standing')
AND (SELECT COUNT(*) FROM match_participants mp
WHERE mp.match_id = matches.id AND mp.eliminated = 0) = 1
"""
def _columns(insp, table: str) -> set[str]:
return {c["name"] for c in insp.get_columns(table)}
def _win_reason_check(insp) -> str | None:
for ck in insp.get_check_constraints("matches"):
if ck.get("name") == CK_WIN_REASON:
return ck["sqltext"]
return None
def _recreate_matches_with_check(bind, drop_existing: bool, sqltext: str) -> None:
"""Замена CHECK в SQLite = пересоздание таблицы matches (batch copy-and-move).
При включённых внешних ключах DROP старой таблицы выполнил бы неявный DELETE, и
ON DELETE CASCADE унёс бы участников, вложения и черновики. Alembic из CLI работает
без PRAGMA foreign_keys (её включает только движок приложения), а внутри транзакции
PRAGMA не переключить — поэтому не рискуем и отказываемся с понятной ошибкой."""
if bind.exec_driver_sql("PRAGMA foreign_keys").scalar():
raise RuntimeError(
"0014: PRAGMA foreign_keys=ON — пересоздание matches удалило бы участников "
"каскадом. Запускайте миграции через `alembic upgrade head` (CLI)."
)
with op.batch_alter_table("matches", recreate="always") as b:
if drop_existing:
b.drop_constraint(CK_WIN_REASON, type_="check")
b.create_check_constraint(CK_WIN_REASON, sqltext)
def upgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
if "nine_rounds_rule" not in _columns(insp, "groups"):
with op.batch_alter_table("groups") as b:
b.add_column(
sa.Column("nine_rounds_rule", sa.Boolean(), nullable=False, server_default="0")
)
match_cols = _columns(insp, "matches")
with op.batch_alter_table("matches") as b:
if "end_round" not in match_cols:
b.add_column(sa.Column("end_round", sa.Integer(), nullable=True))
if "nine_rounds_rule" not in match_cols:
b.add_column(
sa.Column("nine_rounds_rule", sa.Boolean(), nullable=False, server_default="0")
)
participant_cols = _columns(insp, "match_participants")
with op.batch_alter_table("match_participants") as b:
if "objectives" not in participant_cols:
b.add_column(sa.Column("objectives", sa.Integer(), nullable=True))
if "worlds" not in participant_cols:
b.add_column(sa.Column("worlds", sa.Integer(), nullable=True))
# БД, созданные до появления CHECK в моделях (0001 тогда был старше), ограничения
# не имеют вовсе — last_standing им и так разрешён, пересоздавать таблицу незачем.
check = _win_reason_check(inspect(bind))
if check is not None and "last_standing" not in check:
_recreate_matches_with_check(bind, drop_existing=True, sqltext=NEW_REASONS)
op.execute(BACKFILL_LAST_STANDING)
def downgrade() -> None:
bind = op.get_bind()
# Прежний CHECK не знает last_standing: такие партии теряют признак (раньше их
# записывали «по целям»).
op.execute("UPDATE matches SET win_reason = 'objectives' WHERE win_reason = 'last_standing'")
check = _win_reason_check(inspect(bind))
if check is not None and "last_standing" in check:
_recreate_matches_with_check(bind, drop_existing=True, sqltext=OLD_REASONS)
insp = inspect(bind)
participant_cols = _columns(insp, "match_participants")
with op.batch_alter_table("match_participants") as b:
for name in ("worlds", "objectives"):
if name in participant_cols:
b.drop_column(name)
match_cols = _columns(insp, "matches")
with op.batch_alter_table("matches") as b:
for name in ("nine_rounds_rule", "end_round"):
if name in match_cols:
b.drop_column(name)
if "nine_rounds_rule" in _columns(insp, "groups"):
with op.batch_alter_table("groups") as b:
b.drop_column("nine_rounds_rule")
+20 -1
View File
@@ -195,6 +195,12 @@ class Group(SQLModel, table=True):
sa_column_kwargs={"onupdate": utcnow}, sa_column_kwargs={"onupdate": utcnow},
nullable=False, nullable=False,
) )
# Домашнее правило: при 5–6 игроках играется 9 раундов вместо 8. Партия снимает
# значение при старте (Match.nine_rounds_rule), так что смена галочки историю не трогает.
nine_rounds_rule: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="0"),
)
class GroupMember(SQLModel, table=True): class GroupMember(SQLModel, table=True):
@@ -275,7 +281,8 @@ class Match(SQLModel, table=True):
Index("ix_matches_group_played", "group_id", "played_at"), Index("ix_matches_group_played", "group_id", "played_at"),
CheckConstraint("status IN ('in_progress','finished')", name="ck_match_status"), CheckConstraint("status IN ('in_progress','finished')", name="ck_match_status"),
CheckConstraint( CheckConstraint(
"win_reason IS NULL OR win_reason IN ('objectives','worlds','plastic','resources')", "win_reason IS NULL OR win_reason IN "
"('objectives','worlds','plastic','resources','last_standing')",
name="ck_match_win_reason", name="ck_match_win_reason",
), ),
) )
@@ -295,6 +302,14 @@ class Match(SQLModel, table=True):
finished_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True)) finished_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True))
duration_minutes: int | None = Field(default=None, sa_column=Column(Integer, nullable=True)) duration_minutes: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
win_reason: str | None = Field(default=None, sa_column=Column(String(16), nullable=True)) win_reason: str | None = Field(default=None, sa_column=Column(String(16), nullable=True))
# Раунд, в котором партия закончилась (NULL — не указан). Лимит раундов не хранится:
# он выводится из снимка nine_rounds_rule и числа участников (scoring.max_rounds).
end_round: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
# Снимок Group.nine_rounds_rule на момент старта партии.
nine_rounds_rule: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="0"),
)
player_count: int = Field(sa_column=Column(Integer, nullable=False)) player_count: int = Field(sa_column=Column(Integer, nullable=False))
overall_comment: str | None = Field(sa_column=Column(Text, nullable=True)) overall_comment: str | None = Field(sa_column=Column(Text, nullable=True))
created_by: int = Field( created_by: int = Field(
@@ -340,6 +355,10 @@ class MatchParticipant(SQLModel, table=True):
eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
comment: str | None = Field(sa_column=Column(Text, nullable=True)) comment: str | None = Field(sa_column=Column(Text, nullable=True))
# Итог партии для рейтинга (NULL — не указан): маркеры целей и дружественные миры
# на конец партии. У выбывшего миров 0.
objectives: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
worlds: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
created_at: datetime = Field(default_factory=utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
+4 -16
View File
@@ -13,9 +13,8 @@ from app.core.errors import InvalidCredentialsError, NotFoundError
from app.core.timeutil import iso_utc from app.core.timeutil import iso_utc
from app.db.session import get_session from app.db.session import get_session
from app.models import User from app.models import User
from app.routers.matches import attachment_read, build_match_read from app.routers.matches import attachment_read, build_match_read, participant_inputs
from app.schemas import api as s from app.schemas import api as s
from app.services.match_service import ParticipantInput
from app.services import ( from app.services import (
achievement_service, achievement_service,
admin_service, admin_service,
@@ -243,19 +242,6 @@ def update_match(
admin: User = Depends(get_current_admin), admin: User = Depends(get_current_admin),
) -> s.MatchRead: ) -> s.MatchRead:
match = match_service.get_match(session, match_id) match = match_service.get_match(session, match_id)
participants = None
if body.participants is not None:
participants = [
ParticipantInput(
user_id=p.user_id,
faction_id=p.faction_id,
place=p.place,
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment,
)
for p in body.participants
]
match = match_service.update_match( match = match_service.update_match(
session, session,
match, match,
@@ -264,7 +250,9 @@ def update_match(
overall_comment_set=("overall_comment" in body.model_fields_set), overall_comment_set=("overall_comment" in body.model_fields_set),
win_reason=body.win_reason, win_reason=body.win_reason,
win_reason_set=("win_reason" in body.model_fields_set), win_reason_set=("win_reason" in body.model_fields_set),
participants=participants, end_round=body.end_round,
end_round_set=("end_round" in body.model_fields_set),
participants=participant_inputs(body),
expected_version=body.expected_version, expected_version=body.expected_version,
) )
audit_service.record( audit_service.record(
+7 -2
View File
@@ -33,6 +33,7 @@ def _detail(session: Session, group_id: int, user_id: int) -> s.GroupDetail:
owner_id=group.owner_id, owner_id=group.owner_id,
my_role=member.role, my_role=member.role,
expansion_ids=group_service.group_expansion_ids(session, group_id), expansion_ids=group_service.group_expansion_ids(session, group_id),
nine_rounds_rule=group.nine_rounds_rule,
) )
@@ -79,15 +80,19 @@ def get_group(
@router.patch("/{group_id}", response_model=s.GroupDetail) @router.patch("/{group_id}", response_model=s.GroupDetail)
def rename_group( def update_group(
group_id: int, group_id: int,
body: s.GroupRename, body: s.GroupUpdate,
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> s.GroupDetail: ) -> s.GroupDetail:
"""Название и домашние правила группы; меняются только переданные поля."""
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type] group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
group = group_service.get_group(session, group_id) group = group_service.get_group(session, group_id)
if body.name is not None:
group_service.rename_group(session, group, body.name) group_service.rename_group(session, group, body.name)
if body.nine_rounds_rule is not None:
group_service.set_nine_rounds_rule(session, group, body.nine_rounds_rule)
return _detail(session, group_id, user.id) # type: ignore[arg-type] return _detail(session, group_id, user.id) # type: ignore[arg-type]
+31 -14
View File
@@ -22,10 +22,30 @@ from app.services import (
user_service, user_service,
) )
from app.services.match_service import FinishInput, ParticipantInput, RosterInput from app.services.match_service import FinishInput, ParticipantInput, RosterInput
from app.services.scoring import max_rounds
router = APIRouter(prefix="/matches", tags=["matches"]) router = APIRouter(prefix="/matches", tags=["matches"])
def participant_inputs(body: s.MatchUpdate) -> list[ParticipantInput] | None:
"""Участники правки — общее для игроцкого и админского PATCH."""
if body.participants is None:
return None
return [
ParticipantInput(
user_id=p.user_id,
faction_id=p.faction_id,
place=p.place,
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment,
objectives=p.objectives,
worlds=p.worlds,
)
for p in body.participants
]
def attachment_read(att: MatchAttachment, base: str) -> s.AttachmentRead: def attachment_read(att: MatchAttachment, base: str) -> s.AttachmentRead:
"""AttachmentRead с URL под нужным префиксом (base = '/api/matches/{id}' или """AttachmentRead с URL под нужным префиксом (base = '/api/matches/{id}' или
@@ -56,6 +76,8 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
eliminated=p.eliminated, eliminated=p.eliminated,
was_random=p.was_random, was_random=p.was_random,
comment=p.comment, comment=p.comment,
objectives=p.objectives,
worlds=p.worlds,
avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type] avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type]
) )
for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type] for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type]
@@ -79,6 +101,9 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
finished_at=iso_utc(match.finished_at), finished_at=iso_utc(match.finished_at),
duration_minutes=match.duration_minutes, duration_minutes=match.duration_minutes,
win_reason=match.win_reason, # type: ignore[arg-type] win_reason=match.win_reason, # type: ignore[arg-type]
end_round=match.end_round,
nine_rounds_rule=match.nine_rounds_rule,
max_rounds=max_rounds(match.player_count, match.nine_rounds_rule),
player_count=match.player_count, player_count=match.player_count,
overall_comment=match.overall_comment, overall_comment=match.overall_comment,
created_by=match.created_by, created_by=match.created_by,
@@ -157,6 +182,8 @@ def finish_match(
eliminated=p.eliminated, eliminated=p.eliminated,
comment=p.comment, comment=p.comment,
faction_id=p.faction_id, faction_id=p.faction_id,
objectives=p.objectives,
worlds=p.worlds,
) )
for p in body.participants for p in body.participants
] ]
@@ -165,6 +192,7 @@ def finish_match(
match, match,
finish=finish, finish=finish,
win_reason=body.win_reason, win_reason=body.win_reason,
end_round=body.end_round,
overall_comment=body.overall_comment, overall_comment=body.overall_comment,
overall_comment_set=("overall_comment" in body.model_fields_set), overall_comment_set=("overall_comment" in body.model_fields_set),
expected_version=body.expected_version, expected_version=body.expected_version,
@@ -206,19 +234,6 @@ def update_match(
) -> s.MatchRead: ) -> s.MatchRead:
match = match_service.get_match(session, match_id) match = match_service.get_match(session, match_id)
match_service.assert_can_modify(session, match, user) match_service.assert_can_modify(session, match, user)
participants = None
if body.participants is not None:
participants = [
ParticipantInput(
user_id=p.user_id,
faction_id=p.faction_id,
place=p.place,
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment,
)
for p in body.participants
]
match = match_service.update_match( match = match_service.update_match(
session, session,
match, match,
@@ -227,7 +242,9 @@ def update_match(
overall_comment_set=("overall_comment" in body.model_fields_set), overall_comment_set=("overall_comment" in body.model_fields_set),
win_reason=body.win_reason, win_reason=body.win_reason,
win_reason_set=("win_reason" in body.model_fields_set), win_reason_set=("win_reason" in body.model_fields_set),
participants=participants, end_round=body.end_round,
end_round_set=("end_round" in body.model_fields_set),
participants=participant_inputs(body),
expected_version=body.expected_version, expected_version=body.expected_version,
) )
audit_service.record( audit_service.record(
+39 -6
View File
@@ -2,11 +2,18 @@
from __future__ import annotations from __future__ import annotations
from datetime import date from datetime import date
from typing import Literal from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
WinReason = Literal["objectives", "worlds", "plastic", "resources"] # last_standing — все соперники выбыли. Вручную не выбирается: сервер требует её ровно
# тогда, когда невыбывший участник один (match_service._check_last_standing).
WinReason = Literal["objectives", "worlds", "plastic", "resources", "last_standing"]
# Итоги партии для рейтинга. Верхние границы — только отсечка мусора: правила игры
# ограничивают сильнее, но их проверка — дело предупреждений в форме, а не отказа.
EndRound = Annotated[int, Field(ge=1, le=9)]
Count = Annotated[int, Field(ge=0, le=99)]
# ─── Auth ──────────────────────────────────────────────────────────────────── # ─── Auth ────────────────────────────────────────────────────────────────────
@@ -162,8 +169,10 @@ class GroupCreate(BaseModel):
expansion_ids: list[int] = [] expansion_ids: list[int] = []
class GroupRename(BaseModel): class GroupUpdate(BaseModel):
name: str # Частичная правка: переданные поля меняются, остальные остаются как есть.
name: str | None = None
nine_rounds_rule: bool | None = None
class GroupExpansionsUpdate(BaseModel): class GroupExpansionsUpdate(BaseModel):
@@ -176,6 +185,8 @@ class GroupDetail(BaseModel):
owner_id: int owner_id: int
my_role: str my_role: str
expansion_ids: list[int] = [] expansion_ids: list[int] = []
# Домашнее правило: 9 раундов при 5–6 игроках (снимается в партию при старте).
nine_rounds_rule: bool = False
class MemberRead(BaseModel): class MemberRead(BaseModel):
@@ -252,17 +263,20 @@ class MatchFinishParticipant(BaseModel):
eliminated: bool = False # выбыл из партии → авто-проставится последнее место eliminated: bool = False # выбыл из партии → авто-проставится последнее место
comment: str | None = None comment: str | None = None
faction_id: int | None = None # опц. смена фракции при завершении faction_id: int | None = None # опц. смена фракции при завершении
objectives: Count | None = None # маркеры целей на конец партии (необязательно)
worlds: Count | None = None # дружественные миры на конец партии; у выбывшего 0
class MatchFinish(BaseModel): class MatchFinish(BaseModel):
participants: list[MatchFinishParticipant] participants: list[MatchFinishParticipant]
win_reason: WinReason win_reason: WinReason
end_round: EndRound | None = None # раунд, в котором партия закончилась
overall_comment: str | None = None overall_comment: str | None = None
# Оптимистичная блокировка: версия партии, которую видел клиент (см. MatchRead.version). # Оптимистичная блокировка: версия партии, которую видел клиент (см. MatchRead.version).
expected_version: str | None = None expected_version: str | None = None
# Полный участник (правка завершённой партии админом). # Полный участник (правка результатов завершённой партии).
class ParticipantInput(BaseModel): class ParticipantInput(BaseModel):
user_id: int user_id: int
faction_id: int faction_id: int
@@ -270,12 +284,15 @@ class ParticipantInput(BaseModel):
eliminated: bool = False eliminated: bool = False
was_random: bool = False was_random: bool = False
comment: str | None = None comment: str | None = None
objectives: Count | None = None
worlds: Count | None = None
class MatchUpdate(BaseModel): class MatchUpdate(BaseModel):
played_at: date | None = None played_at: date | None = None
overall_comment: str | None = None overall_comment: str | None = None
win_reason: WinReason | None = None win_reason: WinReason | None = None
end_round: EndRound | None = None
participants: list[ParticipantInput] | None = None participants: list[ParticipantInput] | None = None
expected_version: str | None = None # оптимистичная блокировка expected_version: str | None = None # оптимистичная блокировка
@@ -289,6 +306,8 @@ class MatchParticipantRead(BaseModel):
eliminated: bool = False eliminated: bool = False
was_random: bool was_random: bool
comment: str | None = None comment: str | None = None
objectives: int | None = None
worlds: int | None = None
avatar_url: str | None = None avatar_url: str | None = None
@@ -311,6 +330,10 @@ class MatchFinishDraftData(BaseModel):
comments: dict[str, str] = {} comments: dict[str, str] = {}
win_reason: WinReason | None = None win_reason: WinReason | None = None
overall_comment: str | None = None overall_comment: str | None = None
end_round: EndRound | None = None
# Ключ — user_id строкой (как у comments); незаполненные поля в словарь не попадают.
objectives: dict[str, Count] = {}
worlds: dict[str, Count] = {}
class MatchFinishDraftRead(BaseModel): class MatchFinishDraftRead(BaseModel):
@@ -329,6 +352,11 @@ class MatchRead(BaseModel):
finished_at: str | None = None finished_at: str | None = None
duration_minutes: int | None = None duration_minutes: int | None = None
win_reason: WinReason | None = None win_reason: WinReason | None = None
end_round: int | None = None
# Снимок правила 9 раундов и вычисленный из него лимит раундов этой партии:
# фронт берёт лимит отсюда, а не повторяет правило у себя.
nine_rounds_rule: bool = False
max_rounds: int
player_count: int player_count: int
overall_comment: str | None = None overall_comment: str | None = None
created_by: int created_by: int
@@ -347,7 +375,8 @@ class OverallStats(BaseModel):
wins: int wins: int
win_rate: float win_rate: float
avg_place: float | None = None avg_place: float | None = None
score: float | None = None # Рейтинг (Elo, старт 1500) целым числом; None — игрок ещё не сыграл ни одной партии.
score: int | None = None
class LeaderboardEntry(OverallStats): class LeaderboardEntry(OverallStats):
@@ -388,6 +417,8 @@ class FactionStat(BaseModel):
wins: int wins: int
win_rate: float win_rate: float
avg_place: float | None = None avg_place: float | None = None
# Средний результат относительно ожидания (S − E) × 100 — метрика лучшей/худшей
# фракции: выше нуля — игрок на ней выступает лучше своих рейтинговых шансов.
score: float | None = None score: float | None = None
@@ -451,6 +482,8 @@ class MatchListParticipant(BaseModel):
eliminated: bool = False eliminated: bool = False
was_random: bool was_random: bool
comment: str | None = None comment: str | None = None
objectives: int | None = None
worlds: int | None = None
class MatchListItem(BaseModel): class MatchListItem(BaseModel):
+10
View File
@@ -97,6 +97,16 @@ def rename_group(session: Session, group: Group, name: str) -> Group:
return group return group
def set_nine_rounds_rule(session: Session, group: Group, enabled: bool) -> Group:
"""Хоумрул «9 раундов при 5–6 игроках». Действует на партии, начатые после смены:
уже начатые хранят свой снимок (Match.nine_rounds_rule)."""
group.nine_rounds_rule = enabled
session.add(group)
session.commit()
session.refresh(group)
return group
def set_expansions(session: Session, group: Group, expansion_ids: list[int]) -> Group: def set_expansions(session: Session, group: Group, expansion_ids: list[int]) -> Group:
valid = set(_valid_non_base_expansion_ids(session, expansion_ids)) valid = set(_valid_non_base_expansion_ids(session, expansion_ids))
current = session.exec( current = session.exec(
+124 -18
View File
@@ -19,9 +19,12 @@ from app.core.errors import (
from app.core.timeutil import app_today, iso_utc, utcnow from app.core.timeutil import app_today, iso_utc, utcnow
from app.models import Faction, GroupMember, Match, MatchFinishDraft, MatchParticipant, User from app.models import Faction, GroupMember, Match, MatchFinishDraft, MatchParticipant, User
from app.services import group_service from app.services import group_service
from app.services.scoring import EXTENDED_ROUNDS, max_rounds
MAX_MATCH_PLAYERS = 6 MAX_MATCH_PLAYERS = 6
WIN_REASONS = ("objectives", "worlds", "plastic", "resources") LAST_STANDING = "last_standing"
WIN_REASONS = ("objectives", "worlds", "plastic", "resources", LAST_STANDING)
MAX_COUNT = 99 # отсечка мусора в целях/мирах (та же, что в схеме API)
@dataclass @dataclass
@@ -42,11 +45,13 @@ class FinishInput:
eliminated: bool = False eliminated: bool = False
comment: str | None = None comment: str | None = None
faction_id: int | None = None # опц. смена фракции при завершении faction_id: int | None = None # опц. смена фракции при завершении
objectives: int | None = None
worlds: int | None = None
@dataclass @dataclass
class ParticipantInput: class ParticipantInput:
"""Полный участник (для правки завершённой партии админом).""" """Полный участник (для правки результатов завершённой партии)."""
user_id: int user_id: int
faction_id: int faction_id: int
@@ -54,6 +59,8 @@ class ParticipantInput:
eliminated: bool = False eliminated: bool = False
was_random: bool = False was_random: bool = False
comment: str | None = None comment: str | None = None
objectives: int | None = None
worlds: int | None = None
def round_to_30(minutes: float) -> int: def round_to_30(minutes: float) -> int:
@@ -154,6 +161,43 @@ def _resolve_finish_places(rows: list[tuple[int, int | None, bool]]) -> dict[int
return {uid: (elim_place if elim else place) for uid, place, elim in rows} # type: ignore[misc] return {uid: (elim_place if elim else place) for uid, place, elim in rows} # type: ignore[misc]
# ─── Итоги партии для рейтинга ────────────────────────────────────────────────
# Сервер проверяет только диапазоны и явные противоречия. Согласованность итогов между
# собой (тип победы и цели лидеров и т.п.) — предупреждения формы, а не отказ.
def _check_end_round(end_round: int | None, player_count: int, nine_rounds_rule: bool) -> None:
if end_round is None:
return
rmax = max_rounds(player_count, nine_rounds_rule)
if not 1 <= end_round <= rmax:
raise ValidationError(f"Раунд окончания — от 1 до {rmax}.")
def _worlds_of(eliminated: bool, worlds: int | None) -> int | None:
"""У выбывшего миров нет: пустое поле записывается нулём, иное число — противоречие."""
if not eliminated:
return worlds
if worlds:
raise ValidationError("У выбывшего игрока не может быть миров.")
return 0
def _check_last_standing(win_reason: str | None, eliminated: list[bool]) -> None:
"""Причина «последний выживший» ⇔ невыбывший участник ровно один (решение по #22).
Выбрать её вручную нельзя, и забыть поставить тоже: форма проставляет её сама,
сервер лишь не пропускает расхождение."""
alone = sum(1 for e in eliminated if not e) == 1
if alone and win_reason != LAST_STANDING:
raise ValidationError(
"Остался один невыбывший игрок — причина победы «последний выживший»."
)
if not alone and win_reason == LAST_STANDING:
raise ValidationError(
"«Последний выживший» возможен, только когда все, кроме победителя, выбыли."
)
def _group_member_ids(session: Session, group_id: int) -> set[int]: def _group_member_ids(session: Session, group_id: int) -> set[int]:
return { return {
m.user_id m.user_id
@@ -208,12 +252,15 @@ def create_match(
) )
now = utcnow() now = utcnow()
group = group_service.get_group(session, group_id)
match = Match( match = Match(
group_id=group_id, group_id=group_id,
status="in_progress", status="in_progress",
played_at=app_today(), # дата игры — в поясе приложения (+3) played_at=app_today(), # дата игры — в поясе приложения (+3)
started_at=now, started_at=now,
player_count=len(roster), player_count=len(roster),
# Снимок: смена настройки группы потом не переписывает лимит раундов этой партии.
nine_rounds_rule=group.nine_rounds_rule,
created_by=creator.id, # type: ignore[arg-type] created_by=creator.id, # type: ignore[arg-type]
) )
session.add(match) session.add(match)
@@ -242,6 +289,7 @@ def finish_match(
*, *,
finish: list[FinishInput], finish: list[FinishInput],
win_reason: str, win_reason: str,
end_round: int | None = None,
overall_comment: str | None = None, overall_comment: str | None = None,
overall_comment_set: bool = False, overall_comment_set: bool = False,
expected_version: str | None = None, expected_version: str | None = None,
@@ -251,6 +299,7 @@ def finish_match(
raise ConflictError("Партия уже завершена.") raise ConflictError("Партия уже завершена.")
if win_reason not in WIN_REASONS: if win_reason not in WIN_REASONS:
raise ValidationError("Укажите корректную причину победы.") raise ValidationError("Укажите корректную причину победы.")
_check_end_round(end_round, match.player_count, match.nine_rounds_rule)
existing = { existing = {
p.user_id: p p.user_id: p
@@ -273,12 +322,16 @@ def finish_match(
raise FactionNotAvailableError() raise FactionNotAvailableError()
places = _resolve_finish_places([(f.user_id, f.place, f.eliminated) for f in finish]) places = _resolve_finish_places([(f.user_id, f.place, f.eliminated) for f in finish])
_check_last_standing(win_reason, [f.eliminated for f in finish])
worlds = {f.user_id: _worlds_of(f.eliminated, f.worlds) for f in finish}
for f in finish: for f in finish:
p = existing[f.user_id] p = existing[f.user_id]
p.place = places[f.user_id] p.place = places[f.user_id]
p.eliminated = f.eliminated p.eliminated = f.eliminated
p.comment = f.comment or None p.comment = f.comment or None
p.objectives = f.objectives
p.worlds = worlds[f.user_id]
if f.faction_id is not None: if f.faction_id is not None:
p.faction_id = f.faction_id p.faction_id = f.faction_id
session.add(p) session.add(p)
@@ -294,6 +347,7 @@ def finish_match(
match.duration_minutes = round_to_30(elapsed_min) match.duration_minutes = round_to_30(elapsed_min)
match.status = "finished" match.status = "finished"
match.win_reason = win_reason match.win_reason = win_reason
match.end_round = end_round
if overall_comment_set: if overall_comment_set:
match.overall_comment = overall_comment or None match.overall_comment = overall_comment or None
@@ -312,22 +366,44 @@ def get_finish_draft(session: Session, match_id: int) -> MatchFinishDraft | None
return session.get(MatchFinishDraft, match_id) return session.get(MatchFinishDraft, match_id)
def _draft_counts(value: object) -> dict[str, int]:
"""Цели/миры черновика: {user_id строкой: число}. Пустые поля в словарь не попадают."""
if not isinstance(value, dict):
raise ValidationError("Некорректный черновик.")
out: dict[str, int] = {}
for k, v in value.items():
if isinstance(v, bool) or not isinstance(v, int) or not 0 <= v <= MAX_COUNT:
raise ValidationError("Некорректный черновик.")
out[str(k)] = v
return out
def _validate_draft(session: Session, match: Match, data: dict) -> dict: def _validate_draft(session: Session, match: Match, data: dict) -> dict:
"""Черновик — свободная форма, но не мусор: состав обязан совпадать с участниками """Черновик — свободная форма, но не мусор: состав обязан совпадать с участниками
партии, а причина победы быть из известных. Места здесь НЕ валидируются: человек партии, а причина победы быть из известных. Места здесь НЕ валидируются: человек
раскладывает их постепенно, и промежуточное состояние может быть любым.""" раскладывает их постепенно, и промежуточное состояние может быть любым. По той же
причине не проверяется и правило «последнего выжившего»."""
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValidationError("Некорректный черновик.") raise ValidationError("Некорректный черновик.")
blocks = data.get("blocks") or [] blocks = data.get("blocks") or []
eliminated = data.get("eliminated") or [] eliminated = data.get("eliminated") or []
comments = data.get("comments") or {} comments = data.get("comments") or {}
win_reason = data.get("win_reason") win_reason = data.get("win_reason")
end_round = data.get("end_round")
if not isinstance(blocks, list) or not isinstance(eliminated, list): if not isinstance(blocks, list) or not isinstance(eliminated, list):
raise ValidationError("Некорректный черновик.") raise ValidationError("Некорректный черновик.")
if not isinstance(comments, dict): if not isinstance(comments, dict):
raise ValidationError("Некорректный черновик.") raise ValidationError("Некорректный черновик.")
if win_reason is not None and win_reason not in WIN_REASONS: if win_reason is not None and win_reason not in WIN_REASONS:
raise ValidationError("Некорректная причина победы.") raise ValidationError("Некорректная причина победы.")
if end_round is not None and (
isinstance(end_round, bool)
or not isinstance(end_round, int)
or not 1 <= end_round <= EXTENDED_ROUNDS
):
raise ValidationError("Некорректный черновик.")
objectives = _draft_counts(data.get("objectives") or {})
worlds = _draft_counts(data.get("worlds") or {})
participant_ids = { participant_ids = {
p.user_id p.user_id
@@ -354,6 +430,9 @@ def _validate_draft(session: Session, match: Match, data: dict) -> dict:
"comments": {str(k): str(v) for k, v in comments.items()}, "comments": {str(k): str(v) for k, v in comments.items()},
"win_reason": win_reason, "win_reason": win_reason,
"overall_comment": overall, "overall_comment": overall,
"end_round": end_round,
"objectives": objectives,
"worlds": worlds,
} }
@@ -409,35 +488,41 @@ def update_match(
overall_comment_set: bool = False, overall_comment_set: bool = False,
win_reason: str | None = None, win_reason: str | None = None,
win_reason_set: bool = False, win_reason_set: bool = False,
end_round: int | None = None,
end_round_set: bool = False,
participants: list[ParticipantInput] | None = None, participants: list[ParticipantInput] | None = None,
expected_version: str | None = None, expected_version: str | None = None,
) -> Match: ) -> Match:
"""Правка партии: состав с местами, дата, комментарий, причина победы. """Правка партии: состав с местами и итогами, дата, комментарий, причина победы, раунд.
Результаты (места и причина победы) пишутся только в завершённую партию: иначе они Результаты (места, итоги, причина победы, раунд) пишутся только в завершённую партию:
оседали бы в партии со статусом in_progress, которая остаётся в «Незавершённых» и не иначе они оседали бы в партии со статусом in_progress, которая остаётся
попадает ни в одну витрину статистики (SCORED_CTE считает только status='finished'). в «Незавершённых» и не попадает ни в одну витрину статистики (рейтинг проигрывает
Дату и общий комментарий править можно и по ходу партии — двойственного состояния только status='finished'). Дату и общий комментарий править можно и по ходу
они не создают.""" партии — двойственного состояния они не создают."""
results_touched = participants is not None or win_reason_set results_touched = participants is not None or win_reason_set or end_round_set
if results_touched and match.status != "finished": if results_touched and match.status != "finished":
raise ConflictError( raise ConflictError(
"Результаты незавершённой партии нельзя править — сначала завершите её." "Результаты незавершённой партии нельзя править — сначала завершите её."
) )
assert_version(match, expected_version) assert_version(match, expected_version)
if played_at is not None: if win_reason_set and win_reason is not None and win_reason not in WIN_REASONS:
match.played_at = played_at
if overall_comment_set:
match.overall_comment = overall_comment or None
if win_reason_set:
if win_reason is not None and win_reason not in WIN_REASONS:
raise ValidationError("Некорректная причина победы.") raise ValidationError("Некорректная причина победы.")
match.win_reason = win_reason
if participants is not None:
saved = session.exec( saved = session.exec(
select(MatchParticipant).where(MatchParticipant.match_id == match.id) select(MatchParticipant).where(MatchParticipant.match_id == match.id)
).all() ).all()
# Проверки — по состоянию партии ПОСЛЕ правки: частичный запрос сверяется
# с тем, что уже записано.
new_reason = win_reason if win_reason_set else match.win_reason
new_end_round = end_round if end_round_set else match.end_round
new_count = len(participants) if participants is not None else match.player_count
if end_round_set or participants is not None:
_check_end_round(new_end_round, new_count, match.nine_rounds_rule)
places: dict[int, int] = {}
worlds: dict[int, int | None] = {}
if participants is not None:
# Что уже записано в партии, остаётся допустимым: состав группы и набор # Что уже записано в партии, остаётся допустимым: состав группы и набор
# дополнений с тех пор могли поменяться, но историю это чинить не мешает. # дополнений с тех пор могли поменяться, но историю это чинить не мешает.
_validate_roster_basics( _validate_roster_basics(
@@ -451,6 +536,25 @@ def update_match(
places = _resolve_finish_places( places = _resolve_finish_places(
[(p.user_id, p.place, p.eliminated) for p in participants] [(p.user_id, p.place, p.eliminated) for p in participants]
) )
worlds = {p.user_id: _worlds_of(p.eliminated, p.worlds) for p in participants}
if participants is not None or win_reason_set:
flags = (
[p.eliminated for p in participants]
if participants is not None
else [p.eliminated for p in saved]
)
_check_last_standing(new_reason, flags)
if played_at is not None:
match.played_at = played_at
if overall_comment_set:
match.overall_comment = overall_comment or None
if win_reason_set:
match.win_reason = win_reason
if end_round_set:
match.end_round = end_round
if participants is not None:
for old in saved: for old in saved:
session.delete(old) session.delete(old)
session.flush() session.flush()
@@ -464,6 +568,8 @@ def update_match(
eliminated=p.eliminated, eliminated=p.eliminated,
was_random=p.was_random, was_random=p.was_random,
comment=p.comment or None, comment=p.comment or None,
objectives=p.objectives,
worlds=worlds[p.user_id],
) )
) )
match.player_count = len(participants) match.player_count = len(participants)
+193 -36
View File
@@ -1,58 +1,215 @@
"""Метрика рейтинга. Вынесена отдельно — легко заменить. """Метрика рейтинга: многопользовательский Elo с множителем отрыва (#22, #23).
По умолчанию: League Points — нормированные очки за место с учётом размера стола Полное описание, обоснование коэффициентов и примеры — docs/rating/rating-system.md;
и ничьих (competition ranking). За партию из N игроков: эталонная реализация тех же формул — docs/rating/simulate.py (тесты сверяют с ней).
points = (N - place - (tie_size - 1)/2) / (N - 1)
1-е место = 1.0, последнее = 0.0; равные места делят сумму очков поровну.
Рейтинговый счёт игрока — сглаженное среднее (байесовское, формула IMDB): Партия раскладывается на пары игроков. Для пары a (выше или наравне) и b:
score = (PRIOR_GAMES * PRIOR_MEAN + SUM(points)) / (PRIOR_GAMES + games) * 100 E_ab = 1 / (1 + 10^((R_b − R_a) / D)) ожидание по рейтингам ДО партии
К реальным партиям «дописываются» PRIOR_GAMES виртуальных со средним PRIOR_MEAN: S_ab = 1 / 0.5 / 0 выше / поровну / ниже
на малой выборке рейтинг держится около 50 и лишь с опытом сходится к чистому Δ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.
Модуль — только константы и чистые функции без БД: калибровка на реальных данных —
правка констант, пересчёт выполняется сам (рейтинг — функция упорядоченной истории).
""" """
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass, field
from datetime import date, datetime
from itertools import combinations
# Порог числа игр для попадания в ранжированный топ (ниже — «Новички»/provisional). # Порог числа игр для попадания в ранжированный топ (ниже — «Новички»/provisional).
MIN_GAMES = 10 MIN_GAMES = 10
# Порог числа игр на фракцию для расчёта лучшей/худшей фракции. # Порог числа игр на фракцию для расчёта лучшей/худшей фракции.
FACTION_MIN_GAMES = 2 FACTION_MIN_GAMES = 2
# Сглаживание рейтинга: сколько «виртуальных» партий и с каким средним добавляем. # ─── Правила игры ────────────────────────────────────────────────────────────
PRIOR_GAMES = 10
PRIOR_MEAN = 0.5
# SQL-выражение сглаженного рейтинга поверх агрегата по строкам scored (s.points). # Размер поля в тайлах по числу игроков (дуэль — 2×3, шестеро — 4×5).
# При 0 партий SUM = NULL → score = NULL (рейтинга без игр нет). BOARD_TILES = {2: 6, 3: 9, 4: 12, 5: 16, 6: 20}
SMOOTHED_SCORE_SQL = ( WORLDS_PER_TILE = 2.2
f"({PRIOR_GAMES} * {PRIOR_MEAN} + SUM(s.points)) / ({PRIOR_GAMES} + COUNT(*)) * 100" BASE_ROUNDS = 8
) # Домашнее правило группы: при 5–6 игроках играется 9 раундов.
EXTENDED_ROUNDS = 9
EXTENDED_MIN_PLAYERS = 5
# SQL-выражение очков за участие (tie-aware). Использует поля m.player_count, # ─── Коэффициенты (документ, 4.10) ───────────────────────────────────────────
# mp.place и t.tie_size (размер группы игроков с тем же местом в партии).
MATCH_POINTS_SQL = ( R0 = 1500.0 # стартовый рейтинг
"CASE WHEN m.player_count > 1 " D = 400.0 # масштаб: разница 400 пунктов — шансы 10:1
"THEN (m.player_count - mp.place - (t.tie_size - 1) / 2.0) " K_MAX = 64.0 # K новичка (0 партий)
"/ (m.player_count - 1) " K_MIN = 16.0 # K опытного игрока
"ELSE 1.0 END" K_GAMES = 20 # за сколько партий K линейно спускается от K_MAX к K_MIN
) W_TABLE = 0.5 # вес размера стола
W_TEMPO = 1.0 # вес темпа победы
W_OBJ = 0.5 # вес отрыва по целям
W_WORLDS = 0.5 # вес отрыва по мирам
MU_OBJ = 0.5 # типичный отрыв по целям
MU_WORLDS = 0.5 # типичный отрыв по мирам
M_MIN = 0.5 # страховка: одна партия не легче половины обычной…
M_MAX = 2.0 # …и не тяжелее двух
# Близость партии по типу победы — множитель пар с победителем.
CLOSENESS = {
"objectives": 1.0,
"worlds": 0.85,
"plastic": 0.7,
"resources": 0.6,
"last_standing": 1.0,
}
def smoothed_score(points_sum: float, games: int) -> float | None: def max_rounds(player_count: int, nine_rounds_rule: bool) -> int:
"""Тот же сглаженный рейтинг, что и SMOOTHED_SCORE_SQL, но в Python. """Лимит раундов партии: 9 при хоумруле группы и 5+ игроках, иначе 8."""
if nine_rounds_rule and player_count >= EXTENDED_MIN_PLAYERS:
return EXTENDED_ROUNDS
return BASE_ROUNDS
Нужен там, где строки уже вытащены и агрегировать в SQL нечего (профиль игрока).
Держим рядом с SQL-версией и на одних константах: разъехавшиеся реализации одной def fair_worlds(player_count: int) -> float:
формулы — источник расхождений, который потом ловится только глазами.""" """«Честная доля» миров на игрока — масштаб для разницы миров."""
if games <= 0: return BOARD_TILES[player_count] * WORLDS_PER_TILE / player_count
return None # рейтинга без игр нет — как SUM(...) = NULL в SQL
return (PRIOR_GAMES * PRIOR_MEAN + points_sum) / (PRIOR_GAMES + games) * 100
def mu_tempo(rmax: int) -> float:
"""Типичный темп: партия закончилась в предпоследнем раунде."""
return 1.0 / (rmax - 1)
def expected(r_a: float, r_b: float) -> float:
"""Ожидаемый результат a против b (вероятность, что a окажется выше)."""
return 1.0 / (1.0 + 10.0 ** ((r_b - r_a) / D))
def k_factor(games: int) -> float:
left = max(0.0, 1.0 - games / K_GAMES)
return K_MIN + (K_MAX - K_MIN) * left
def table_weight(player_count: int) -> float:
return 1.0 + W_TABLE * (player_count - 2) / 4.0
def _clamp(x: float, lo: float, hi: float) -> float:
return max(lo, min(hi, x))
# ─── Партия как вход расчёта ─────────────────────────────────────────────────
@dataclass(frozen=True)
class RatedSeat:
user_id: int
place: int
faction_id: int = 0
eliminated: bool = False
objectives: int | None = None # маркеры целей на конец партии
worlds: int | None = None # дружественные миры на конец партии
@dataclass(frozen=True)
class RatedMatch:
seats: tuple[RatedSeat, ...]
win_reason: str | None = None
end_round: int | None = None # раунд, в котором партия закончилась
nine_rounds_rule: bool = False # снимок настройки группы на момент партии
id: int = 0
group_id: int = 0
played_at: date | None = None
finished_at: datetime | None = None
def pair_multiplier(m: RatedMatch, a: RatedSeat, b: RatedSeat) -> float:
"""Множитель отрыва пары; a — выше или наравне с b."""
n = len(m.seats)
tie = a.place == b.place
winner_pair = a.place == 1
add = 1.0
def diff(x: int, y: int) -> float:
return abs(x - y) if tie else max(0, x - y)
if winner_pair:
rmax = max_rounds(n, m.nine_rounds_rule)
mu = mu_tempo(rmax)
tempo = mu if m.end_round is None else (rmax - m.end_round) / (rmax - 1)
add += W_TEMPO * (tempo - mu)
if winner_pair and m.win_reason == "last_standing":
obj = 1.0 # все соперники устранены — отрыв максимальный, сколько бы ни было маркеров
elif a.objectives is not None and b.objectives is not None:
obj = _clamp(diff(a.objectives, b.objectives) / n, 0.0, 1.0)
else:
obj = MU_OBJ
add += W_OBJ * (obj - MU_OBJ)
if a.worlds is not None and b.worlds is not None:
wor = _clamp(diff(a.worlds, b.worlds) / fair_worlds(n), 0.0, 1.0)
else:
wor = MU_WORLDS
add += W_WORLDS * (wor - MU_WORLDS)
close = CLOSENESS.get(m.win_reason, 1.0) if winner_pair and m.win_reason else 1.0
return _clamp(add, M_MIN, M_MAX) * close
def rate_match(
ratings: dict[int, float], games: dict[int, int], m: RatedMatch
) -> tuple[dict[int, float], dict[int, float]]:
"""Изменения рейтинга участников и их результат относительно ожидания.
Возвращает (ΔR, perf): perf_i = Σ_j (S_ij − E_ij) / (N − 1) — насколько игрок
выступил выше ожидания, без множителя отрыва и K. Входные словари не мутирует."""
n = len(m.seats)
delta = {s.user_id: 0.0 for s in m.seats}
perf = {s.user_id: 0.0 for s in m.seats}
if n < 2:
return delta, perf
g = table_weight(n)
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.place > b.place:
a, b = b, a
s_ab = 0.5 if a.place == b.place else 1.0
e_ab = expected(r[a.user_id], r[b.user_id])
x = pair_multiplier(m, a, b) * (s_ab - e_ab)
delta[a.user_id] += k[a.user_id] * g / (n - 1) * x
delta[b.user_id] -= k[b.user_id] * g / (n - 1) * x
perf[a.user_id] += (s_ab - e_ab) / (n - 1)
perf[b.user_id] -= (s_ab - e_ab) / (n - 1)
return delta, perf
@dataclass
class Replay:
"""Итог проигрывания истории: рейтинги без округления и следы каждой партии."""
ratings: dict[int, float] = field(default_factory=dict)
games: dict[int, int] = field(default_factory=dict)
delta: dict[tuple[int, int], float] = field(default_factory=dict) # (match_id, user_id)
perf: dict[tuple[int, int], float] = field(default_factory=dict) # (match_id, user_id)
def replay(matches: Iterable[RatedMatch]) -> Replay:
"""Проигрывает партии в переданном порядке (хронологию задаёт вызывающий)."""
out = Replay()
for m in matches:
delta, perf = rate_match(out.ratings, out.games, m)
for uid, dv in delta.items():
out.ratings[uid] = out.ratings.get(uid, R0) + dv
out.games[uid] = out.games.get(uid, 0) + 1
out.delta[(m.id, uid)] = dv
out.perf[(m.id, uid)] = perf[uid]
return out
def leaderboard_sort_key(row: dict) -> tuple: def leaderboard_sort_key(row: dict) -> tuple:
"""Ключ сортировки топа: счёт ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑.""" """Ключ сортировки топа: рейтинг ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑.
row["rating"] — рейтинг без округления: два игрока с одинаковым целым в топе
всё равно упорядочены по настоящему значению."""
return ( return (
-(row["score"] or 0.0), -(row["rating"] or 0.0),
-(row["win_rate"] or 0.0), -(row["win_rate"] or 0.0),
-(row["games"] or 0), -(row["games"] or 0),
(row["avg_place"] or 0.0), (row["avg_place"] or 0.0),
+214 -197
View File
@@ -1,105 +1,151 @@
"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен).""" """Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен).
Рейтинг — функция упорядоченной истории (scoring.replay), поэтому витрины не агрегируют
SQL, а проигрывают завершённые партии: одна загрузка истории на запрос, из неё же
считаются игры, победы, среднее место и разбивки. Цепочек две: общая — по всем партиям
приложения, групповая — только по партиям группы (это разные числа)."""
from __future__ import annotations from __future__ import annotations
from typing import Any from collections import defaultdict
from sqlalchemy import func, text from sqlalchemy import func
from sqlmodel import Session, select from sqlmodel import Session, select
from app.core.timeutil import iso_utc from app.core.timeutil import iso_utc
from app.models import Faction, Group, GroupMember, Match, MatchParticipant, User from app.models import Expansion, Faction, Group, GroupMember, Match, MatchParticipant, User
from app.services import faction_service, group_service, membership_service, user_service from app.services import faction_service, group_service, membership_service, user_service
from app.services.scoring import ( from app.services.scoring import (
FACTION_MIN_GAMES, FACTION_MIN_GAMES,
MATCH_POINTS_SQL,
MIN_GAMES, MIN_GAMES,
SMOOTHED_SCORE_SQL, RatedMatch,
RatedSeat,
Replay,
leaderboard_sort_key, leaderboard_sort_key,
smoothed_score, replay,
) )
# Базовый блок: одна строка на участие с tie-aware очками.
# Учитываются только ЗАВЕРШЁННЫЕ партии (in_progress без мест в статистику не входят). def load_history(session: Session, group_id: int | None = None) -> list[RatedMatch]:
SCORED_CTE = f""" """Завершённые партии в порядке проигрывания: дата игры, момент завершения, id.
WITH tie AS (
SELECT mp.match_id AS match_id, mp.place AS place, COUNT(*) AS tie_size Один запрос на партии с участниками. In_progress в рейтинг не входят: мест у них нет."""
FROM match_participants mp stmt = (
JOIN matches m ON m.id = mp.match_id select(Match, MatchParticipant)
WHERE m.status = 'finished' AND mp.place IS NOT NULL .join(MatchParticipant, MatchParticipant.match_id == Match.id)
GROUP BY mp.match_id, mp.place .where(Match.status == "finished")
), )
scored AS ( if group_id is not None:
SELECT mp.user_id AS user_id, stmt = stmt.where(Match.group_id == group_id)
mp.faction_id AS faction_id, stmt = stmt.order_by(Match.played_at, Match.finished_at, Match.id, MatchParticipant.id)
m.id AS match_id,
m.group_id AS group_id, history: list[RatedMatch] = []
m.played_at AS played_at, current: Match | None = None
mp.place AS place, seats: list[RatedSeat] = []
mp.was_random AS was_random,
m.player_count AS player_count, def flush() -> None:
({MATCH_POINTS_SQL}) AS points, if current is not None and seats:
CASE WHEN mp.place = 1 THEN 1 ELSE 0 END AS is_win history.append(
FROM match_participants mp RatedMatch(
JOIN matches m ON m.id = mp.match_id seats=tuple(seats),
JOIN tie t ON t.match_id = mp.match_id AND t.place = mp.place win_reason=current.win_reason,
WHERE m.status = 'finished' end_round=current.end_round,
) nine_rounds_rule=current.nine_rounds_rule,
""" id=current.id, # type: ignore[arg-type]
group_id=current.group_id,
played_at=current.played_at,
finished_at=current.finished_at,
)
)
for m, p in session.exec(stmt).all():
if current is None or m.id != current.id:
flush()
current, seats = m, []
if p.place is None: # у завершённой партии мест без значения не бывает
continue
seats.append(
RatedSeat(
user_id=p.user_id,
place=p.place,
faction_id=p.faction_id,
eliminated=p.eliminated,
objectives=p.objectives,
worlds=p.worlds,
)
)
flush()
return history
def _round(value: Any, ndigits: int) -> float | None: def _for_group(history: list[RatedMatch], group_id: int) -> list[RatedMatch]:
return None if value is None else round(float(value), ndigits) return [m for m in history if m.group_id == group_id]
def _normalize(row: dict) -> dict: def _user_seats(history: list[RatedMatch], user_id: int) -> list[tuple[RatedMatch, RatedSeat]]:
# avatar_url мирроринг user_service.avatar_url_for: версия = epoch(updated_at) из SQL. return [(m, s) for m in history for s in m.seats if s.user_id == user_id]
avatar_url = None
if row.get("avatar_path"):
avatar_url = f"/api/users/{row['user_id']}/avatar?v={int(row.get('avatar_version') or 0)}" def _summary(seats: list[tuple[RatedMatch, RatedSeat]], rating: float | None) -> dict:
"""Итог игрока: игры, победы, винрейт, среднее место и рейтинг целым числом.
В расчёте рейтинг без округления (иначе ошибка копилась бы по цепочке партий),
показывается — целым."""
games = len(seats)
if games == 0:
return {"games": 0, "wins": 0, "win_rate": 0.0, "avg_place": None, "score": None}
wins = sum(1 for _m, s in seats if s.place == 1)
return { return {
"user_id": row["user_id"], "games": games,
"nickname": row["nickname"], "wins": wins,
"games": int(row["games"] or 0), "win_rate": round(wins / games, 4),
"wins": int(row["wins"] or 0), "avg_place": round(sum(s.place for _m, s in seats) / games, 2),
"win_rate": _round(row["win_rate"] or 0.0, 4), "score": None if rating is None else round(rating),
"avg_place": _round(row["avg_place"], 2),
"score": _round(row["score"], 1),
"avatar_url": avatar_url,
} }
def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]: def leaderboard(
where = "WHERE s.group_id = :gid" if group_id is not None else "" session: Session,
sql = f""" group_id: int | None = None,
{SCORED_CTE} *,
SELECT u.id AS user_id, u.nickname AS nickname, history: list[RatedMatch] | None = None,
u.avatar_path AS avatar_path, rep: Replay | None = None,
CAST(strftime('%s', u.updated_at) AS INTEGER) AS avatar_version, ) -> dict:
COUNT(*) AS games, """Топ: общий или группы. history/rep — уже посчитанные (home их переиспользует)."""
SUM(s.is_win) AS wins, if history is None:
AVG(CAST(s.is_win AS FLOAT)) AS win_rate, history = load_history(session, group_id)
AVG(s.place) AS avg_place, if rep is None:
{SMOOTHED_SCORE_SQL} AS score rep = replay(history)
FROM scored s
JOIN users u ON u.id = s.user_id
{where}
GROUP BY u.id, u.nickname, u.avatar_path, u.updated_at
"""
params = {"gid": group_id} if group_id is not None else {}
result = session.execute(text(sql), params).mappings().all()
return [_normalize(dict(r)) for r in result]
by_user: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list)
for m in history:
for s in m.seats:
by_user[s.user_id].append((m, s))
users = (
{u.id: u for u in session.exec(select(User).where(User.id.in_(list(by_user)))).all()}
if by_user
else {}
)
def leaderboard(session: Session, group_id: int | None = None) -> dict: rows = []
rows = _leaderboard_rows(session, group_id) for uid, seats in by_user.items():
qualified = [r for r in rows if r["games"] >= MIN_GAMES] u = users[uid]
provisional = [r for r in rows if r["games"] < MIN_GAMES] rows.append(
qualified.sort(key=leaderboard_sort_key) {
provisional.sort(key=leaderboard_sort_key) "user_id": uid,
"nickname": u.nickname,
**_summary(seats, rep.ratings[uid]),
"rating": rep.ratings[uid], # только для сортировки
"avatar_url": user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type]
}
)
qualified = sorted((r for r in rows if r["games"] >= MIN_GAMES), key=leaderboard_sort_key)
provisional = sorted((r for r in rows if r["games"] < MIN_GAMES), key=leaderboard_sort_key)
for i, r in enumerate(qualified, start=1): for i, r in enumerate(qualified, start=1):
r["rank"] = i r["rank"] = i
for r in provisional: for r in provisional:
r["rank"] = None r["rank"] = None
for r in rows:
del r["rating"]
return { return {
"entries": qualified, "entries": qualified,
"provisional": provisional, "provisional": provisional,
@@ -107,72 +153,38 @@ def leaderboard(session: Session, group_id: int | None = None) -> dict:
} }
def _user_scored_rows(session: Session, user_id: int) -> list[dict]: def _faction_breakdown(
"""Строки участия игрока со всеми полями, нужными витринам профиля. session: Session, seats: list[tuple[RatedMatch, RatedSeat]], rep: Replay, user_id: int
) -> list[dict]:
Один проход по SCORED_CTE вместо трёх: общий итог, разбивка по фракциям и форма by_faction: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list)
последних партий считаются из одного и того же набора строк. CTE джойнит участия for m, s in seats:
со всеми партиями приложения, поэтому каждый лишний проход дорожает вместе с by_faction[s.faction_id].append((m, s))
общим числом партий, а не с числом партий игрока.""" if not by_faction:
sql = f""" return []
{SCORED_CTE} meta = {
SELECT s.group_id AS group_id, s.match_id AS match_id, s.played_at AS played_at, f.id: (f, code)
s.place AS place, s.player_count AS player_count, for f, code in session.exec(
s.points AS points, s.is_win AS is_win, select(Faction, Expansion.code)
f.id AS faction_id, f.code AS code, f.name_ru AS name_ru, .join(Expansion, Expansion.id == Faction.expansion_id)
e.code AS expansion_code .where(Faction.id.in_(list(by_faction)))
FROM scored s ).all()
JOIN factions f ON f.id = s.faction_id
JOIN expansions e ON e.id = f.expansion_id
WHERE s.user_id = :uid
"""
rows = session.execute(text(sql), {"uid": user_id}).mappings().all()
return [dict(r) for r in rows]
def _for_group(rows: list[dict], group_id: int | None) -> list[dict]:
return rows if group_id is None else [r for r in rows if r["group_id"] == group_id]
def _overall_from_rows(rows: list[dict]) -> dict:
"""Тот же итог, что раньше считал SQL: COUNT/SUM/AVG плюс сглаженный рейтинг."""
games = len(rows)
if games == 0:
return {"games": 0, "wins": 0, "win_rate": 0.0, "avg_place": None, "score": None}
wins = sum(int(r["is_win"]) for r in rows)
return {
"games": games,
"wins": wins,
"win_rate": _round(wins / games, 4),
"avg_place": _round(sum(r["place"] for r in rows) / games, 2),
"score": _round(smoothed_score(sum(float(r["points"]) for r in rows), games), 1),
} }
def _faction_breakdown_from_rows(rows: list[dict]) -> list[dict]:
by_faction: dict[int, list[dict]] = {}
for r in rows:
by_faction.setdefault(r["faction_id"], []).append(r)
out = [] out = []
for fid, group in by_faction.items(): for fid, group in by_faction.items():
games = len(group) faction, expansion_code = meta[fid]
wins = sum(int(r["is_win"]) for r in group) perf = sum(rep.perf[(m.id, user_id)] for m, _s in group) / len(group)
meta = group[0]
out.append( out.append(
{ {
"faction_id": fid, "faction_id": fid,
"code": meta["code"], "code": faction.code,
"name_ru": meta["name_ru"], "name_ru": faction.name_ru,
"expansion_code": meta["expansion_code"], "expansion_code": expansion_code,
"games": games, **{k: v for k, v in _summary(group, None).items() if k != "score"},
"wins": wins, # Средний результат относительно ожидания (S − E) × 100: насколько игрок
"win_rate": _round(wins / games, 4), # на фракции выступает выше рейтинговых шансов — без привязки к рейтингу.
"avg_place": _round(sum(r["place"] for r in group) / games, 2), "score": round(perf * 100, 1),
# Фракции: чистое среднее (служебная метрика «лучшая/худшая»,
# сглаживание задавило бы её к 50).
"score": _round(sum(float(r["points"]) for r in group) / games * 100, 1),
"name_ru_prepositional": faction_service.prepositional( "name_ru_prepositional": faction_service.prepositional(
meta["code"], meta["name_ru"] faction.code, faction.name_ru
), ),
} }
) )
@@ -180,15 +192,15 @@ def _faction_breakdown_from_rows(rows: list[dict]) -> list[dict]:
return out return out
def _recent_form_from_rows(rows: list[dict], limit: int = 5) -> list[dict]: def _recent_form(seats: list[tuple[RatedMatch, RatedSeat]], limit: int = 5) -> list[dict]:
recent = sorted(rows, key=lambda r: (str(r["played_at"]), r["match_id"]), reverse=True) recent = sorted(seats, key=lambda ms: (str(ms[0].played_at), ms[0].id), reverse=True)
return [ return [
{ {
"place": r["place"], "place": s.place,
"player_count": r["player_count"], "player_count": len(m.seats),
"played_at": str(r["played_at"]), "played_at": str(m.played_at),
} }
for r in recent[:limit] for m, s in recent[:limit]
] ]
@@ -208,69 +220,73 @@ def _favorite_faction(session: Session, user_id: int) -> dict | None:
} }
def _overall(history: list[RatedMatch], rep: Replay, user_id: int) -> dict:
return _summary(_user_seats(history, user_id), rep.ratings.get(user_id))
def profile_stats( def profile_stats(
session: Session, session: Session,
user_id: int, user_id: int,
group_id: int | None = None, group_id: int | None = None,
*, *,
rows: list[dict] | None = None, history: list[RatedMatch] | None = None,
rep: Replay | None = None,
) -> dict: ) -> dict:
"""Витрина профиля. rows — уже вытащенные строки игрока (home() их переиспользует).""" """Витрина профиля. history/rep — общая цепочка, уже посчитанная (home их переиспользует);
scoped = _for_group(rows if rows is not None else _user_scored_rows(session, user_id), group_id) с group_id считается групповая цепочка."""
overall = _overall_from_rows(scoped) if history is None:
factions = _faction_breakdown_from_rows(scoped) history = load_history(session)
if group_id is not None:
history, rep = _for_group(history, group_id), None
if rep is None:
rep = replay(history)
seats = _user_seats(history, user_id)
factions = _faction_breakdown(session, seats, rep, user_id)
qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES] qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES]
best = max(qualified, key=lambda f: (f["score"] or 0)) if qualified else None best = max(qualified, key=lambda f: f["score"]) if qualified else None
worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None worst = min(qualified, key=lambda f: f["score"]) if qualified else None
# «Чаще всего играет на» — самая игранная фракция по всей истории, включая # «Чаще всего играет на» — самая игранная фракция по всей истории, включая
# рандомные раздачи. # рандомные раздачи.
main = max(factions, key=lambda f: f["games"]) if factions else None main = max(factions, key=lambda f: f["games"]) if factions else None
return { return {
"user_id": user_id, "user_id": user_id,
"overall": overall, "overall": _summary(seats, rep.ratings.get(user_id)),
"factions": factions, "factions": factions,
"best_faction": best, "best_faction": best,
"worst_faction": worst, "worst_faction": worst,
"favorite_faction": _favorite_faction(session, user_id), "favorite_faction": _favorite_faction(session, user_id),
"main_faction": main, "main_faction": main,
"recent_form": _recent_form_from_rows(scoped), "recent_form": _recent_form(seats),
"min_games": MIN_GAMES, "min_games": MIN_GAMES,
} }
def group_stats(session: Session, group_id: int) -> dict: def group_stats(session: Session, group_id: int) -> dict:
board = leaderboard(session, group_id=group_id) history = load_history(session, group_id)
# Нужны только счётчик и дата последней партии — тянуть строки целиком незачем. board = leaderboard(session, group_id, history=history, rep=replay(history))
games_count, last_played = session.exec( last_played = max((m.played_at for m in history), default=None)
select(func.count(), func.max(Match.played_at)).where(
Match.group_id == group_id, Match.status == "finished"
)
).one()
last_at = str(last_played) if last_played else None
games: dict[int, int] = defaultdict(int)
wins: dict[int, int] = defaultdict(int)
for m in history:
for s in m.seats:
games[s.faction_id] += 1
wins[s.faction_id] += s.place == 1
available_ids = group_service.available_faction_ids(session, group_id) available_ids = group_service.available_faction_ids(session, group_id)
faction_meta = [] factions = sorted(
sql = f""" session.exec(select(Faction)).all(), key=lambda f: (-games[f.id], f.sort_order) # type: ignore[index]
{SCORED_CTE}
SELECT f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
COUNT(s.user_id) AS games, SUM(s.is_win) AS wins
FROM factions f
LEFT JOIN scored s ON s.faction_id = f.id AND s.group_id = :gid
GROUP BY f.id, f.code, f.name_ru
ORDER BY games DESC, f.sort_order
"""
rows = session.execute(text(sql), {"gid": group_id}).mappings().all()
for r in rows:
faction_meta.append(
{
"faction_id": r["faction_id"],
"code": r["code"],
"name_ru": r["name_ru"],
"games": int(r["games"] or 0),
"wins": int(r["wins"] or 0),
"available": r["faction_id"] in available_ids,
}
) )
faction_meta = [
{
"faction_id": f.id,
"code": f.code,
"name_ru": f.name_ru,
"games": games[f.id], # type: ignore[index]
"wins": wins[f.id], # type: ignore[index]
"available": f.id in available_ids,
}
for f in factions
]
# Участники без завершённых партий — отдельным блоком (нули, rank=null). # Участники без завершённых партий — отдельным блоком (нули, rank=null).
played_ids = {e["user_id"] for e in board["entries"]} | { played_ids = {e["user_id"] for e in board["entries"]} | {
@@ -296,8 +312,8 @@ def group_stats(session: Session, group_id: int) -> dict:
return { return {
"group_id": group_id, "group_id": group_id,
"total_matches": games_count, "total_matches": len(history),
"last_match_at": last_at, "last_match_at": str(last_played) if last_played else None,
"leaderboard": board["entries"], "leaderboard": board["entries"],
"provisional": board["provisional"], "provisional": board["provisional"],
"inactive": inactive, "inactive": inactive,
@@ -316,6 +332,8 @@ def _participant_row(p: MatchParticipant, u: User, f: Faction) -> dict:
"eliminated": p.eliminated, "eliminated": p.eliminated,
"was_random": p.was_random, "was_random": p.was_random,
"comment": p.comment, "comment": p.comment,
"objectives": p.objectives,
"worlds": p.worlds,
} }
@@ -390,19 +408,15 @@ def user_match_list(
) -> dict: ) -> dict:
"""История партий игрока: только ЗАВЕРШЁННЫЕ, свежие сверху. """История партий игрока: только ЗАВЕРШЁННЫЕ, свежие сверху.
best_only — одна лучшая партия по League Points (s.points из SCORED_CTE учитывает best_only — одна лучшая партия: наибольший прирост рейтинга в общей цепочке
место и размер стола); при равных очках берём более свежую.""" (учитывает и соперников, и ход партии); при равенстве берём более свежую."""
if best_only: if best_only:
sql = f""" history = load_history(session)
{SCORED_CTE} rep = replay(history)
SELECT s.match_id AS match_id candidates = [
FROM scored s (rep.delta[(m.id, user_id)], m.played_at, m.id) for m, _s in _user_seats(history, user_id)
WHERE s.user_id = :uid ]
ORDER BY s.points DESC, s.played_at DESC, s.match_id DESC matches = [session.get(Match, max(candidates)[2])] if candidates else []
LIMIT 1
"""
row = session.execute(text(sql), {"uid": user_id}).mappings().first()
matches = [session.get(Match, row["match_id"])] if row else []
return { return {
"items": _match_items(session, matches), "items": _match_items(session, matches),
"total": len(matches), "total": len(matches),
@@ -464,18 +478,21 @@ def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
def home(session: Session, user_id: int, active_group_id: int | None, leaderboard_limit: int = 10) -> dict: def home(session: Session, user_id: int, active_group_id: int | None, leaderboard_limit: int = 10) -> dict:
board = leaderboard(session, group_id=None) # История грузится и общая цепочка проигрывается один раз: из них и топ, и профиль;
# Строки игрока тянем один раз: из них считается и профиль, и итог по активной группе. # итог по активной группе — отдельная (групповая) цепочка по срезу той же истории.
rows = _user_scored_rows(session, user_id) history = load_history(session)
profile = profile_stats(session, user_id, group_id=None, rows=rows) rep = replay(history)
board = leaderboard(session, history=history, rep=rep)
profile = profile_stats(session, user_id, history=history, rep=rep)
active_group_brief = None active_group_brief = None
if active_group_id is not None: if active_group_id is not None:
group = session.get(Group, active_group_id) group = session.get(Group, active_group_id)
if group is not None: if group is not None:
group_history = _for_group(history, active_group_id)
active_group_brief = { active_group_brief = {
"id": group.id, "id": group.id,
"name": group.name, "name": group.name,
**_overall_from_rows(_for_group(rows, active_group_id)), **_overall(group_history, replay(group_history), user_id),
} }
return { return {
"leaderboard": board["entries"][:leaderboard_limit], "leaderboard": board["entries"][:leaderboard_limit],
+3 -2
View File
@@ -322,9 +322,10 @@ def test_history_excludes_matches_without_the_player(client: TestClient, engine)
def test_history_best_mode_picks_highest_points(client: TestClient, engine): def test_history_best_mode_picks_highest_points(client: TestClient, engine):
"""Режим best берёт партию с максимальными League Points, а не самую свежую. """Режим best берёт партию с наибольшим приростом рейтинга, а не самую свежую.
Второе место из четырёх даёт (4-2)/3 ≈ 0.67, второе из двух — (2-2)/1 = 0.""" Второе место из четырёх равных приносит рейтинг (обыграны двое), второе место
в дуэли — отнимает."""
me = login(client, "Лучший") me = login(client, "Лучший")
gid, (a, b, c), fids = _group_with(client, engine, "А", "Б", "В") gid, (a, b, c), fids = _group_with(client, engine, "А", "Б", "В")
+197
View File
@@ -0,0 +1,197 @@
"""Движок рейтинга: примеры docs/rating/rating-system.md и сверка с эталоном simulate.py.
Числа примеров — те же, что в документе (раздел 6) и в EXPECTED эталона: разъехаться
документ, эталон и приложение не должны. Сверка с simulate.py дополнительно гоняет
синтетический сезон и требует совпадения каждого изменения рейтинга."""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
from app.services import scoring
from app.services.scoring import RatedMatch, RatedSeat, rate_match, replay
VETERAN = 40 # партий у «опытного» игрока: K = K_MIN
A, B, C, D, E, F = 1, 2, 3, 4, 5, 6
def _vets(*ids: int) -> dict[int, int]:
return dict.fromkeys(ids, VETERAN)
def _duel(first: int, second: int, **kw) -> RatedMatch:
return RatedMatch((RatedSeat(first, 1), RatedSeat(second, 2)), **kw)
def _seat(uid: int, place: int, objectives=None, worlds=None, eliminated=False) -> RatedSeat:
return RatedSeat(uid, place, objectives=objectives, worlds=worlds, eliminated=eliminated)
FIVE = tuple(RatedSeat(uid, i + 1) for i, uid in enumerate((A, B, C, D, E)))
SIX = tuple(RatedSeat(uid, i + 1) for i, uid in enumerate((A, B, C, D, E, F)))
# (ключ, рейтинги, сыграно партий, партия, ожидаемые ΔR с точностью до 0.01)
EXAMPLES = [
("1a", {A: 1600, B: 1400}, _vets(A, B), _duel(A, B, win_reason="objectives"),
{A: 3.84, B: -3.84}),
("1b", {A: 1600, B: 1400}, _vets(A, B), _duel(B, A, win_reason="objectives"),
{B: 12.16, A: -12.16}),
("2a", {A: 1500, B: 1500}, _vets(A, B),
RatedMatch((_seat(A, 1, 2, 5), _seat(B, 2, 1, 4)), "objectives", end_round=3),
{A: 11.18, B: -11.18}),
("2b", {A: 1500, B: 1500}, _vets(A, B),
RatedMatch((_seat(A, 1, 2, 5), _seat(B, 2, 1, 4)), "objectives", end_round=8),
{A: 5.46, B: -5.46}),
("3a", {A: 1500, B: 1500}, _vets(A, B), _duel(A, B, win_reason="objectives"),
{A: 8.0, B: -8.0}),
("3b", dict.fromkeys((A, B, C, D, E), 1500), _vets(A, B, C, D, E),
RatedMatch(FIVE, "objectives"),
{A: 11.0, B: 5.5, C: 0.0, D: -5.5, E: -11.0}),
("4a", {A: 1500, B: 1500}, _vets(A, B), _duel(A, B, win_reason="worlds"),
{A: 6.8, B: -6.8}),
("4b", {A: 1500, B: 1500}, _vets(A, B), _duel(A, B, win_reason="plastic"),
{A: 5.6, B: -5.6}),
("4c", {A: 1500, B: 1500}, _vets(A, B), _duel(A, B, win_reason="resources"),
{A: 4.8, B: -4.8}),
("4d", {A: 1500, B: 1500}, _vets(A, B),
RatedMatch((_seat(A, 1, 2, 6), _seat(B, 2, 2, 5)), "worlds", end_round=8),
{A: 3.4, B: -3.4}),
("4e", {A: 1500, B: 1500}, _vets(A, B),
RatedMatch((_seat(A, 1, 2, 8), _seat(B, 2, 0, 2)), "objectives", end_round=3),
{A: 16.0, B: -16.0}),
("5", {A: 1550, B: 1500, C: 1480, D: 1450}, _vets(A, B, C, D),
RatedMatch(
(_seat(A, 1, 4, 8), _seat(B, 2, 3, 7),
_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}),
("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}),
("6b", dict.fromkeys(range(A, F + 1), 1500), _vets(*range(A, F + 1)),
RatedMatch(SIX, "objectives", end_round=8, nine_rounds_rule=False),
{A: 10.29, B: 7.54, C: 2.74, D: -2.06, E: -6.86, F: -11.66}),
("7", {A: 1500, B: 1500}, {A: 0, B: VETERAN}, _duel(A, B, win_reason="objectives"),
{A: 32.0, B: -8.0}),
]
@pytest.mark.parametrize(
"ratings,games,match,want", [e[1:] for e in EXAMPLES], ids=[e[0] for e in EXAMPLES]
)
def test_document_examples(ratings, games, match, want):
delta, _perf = rate_match(ratings, games, match)
assert {uid: round(v, 2) for uid, v in delta.items()} == want
def test_examples_cover_whole_section():
assert len(EXAMPLES) == 15
def test_last_standing_counts_full_objective_gap():
"""Победа last_standing: отрыв победителя по целям = 1, сколько бы маркеров ни было."""
seats = (_seat(A, 1, 1, 6), _seat(B, 2, 1, 0, eliminated=True))
ordinary = rate_match({}, _vets(A, B), RatedMatch(seats, "objectives"))[0][A]
standing = rate_match({}, _vets(A, B), RatedMatch(seats, "last_standing"))[0][A]
# Отрыв по целям 1 вместо 0 → множитель больше на W_OBJ·1 = 0.5; ΔR = K·ΔM·(S − E).
assert standing - ordinary == pytest.approx(16 * 0.5 * 0.5)
# ─── Сверка с эталоном ───────────────────────────────────────────────────────
SIMULATE = Path(__file__).resolve().parents[2] / "docs" / "rating" / "simulate.py"
@pytest.fixture(scope="module")
def sim():
if not SIMULATE.exists():
pytest.skip("docs/rating/simulate.py недоступен")
spec = importlib.util.spec_from_file_location("rating_simulate", SIMULATE)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module # dataclasses ищут модуль по имени
spec.loader.exec_module(module) # type: ignore[union-attr]
return module
def _convert(m, ids: dict[str, int], match_id: int) -> RatedMatch:
return RatedMatch(
tuple(
RatedSeat(
ids[s.player], s.place, eliminated=s.eliminated,
objectives=s.objectives, worlds=s.worlds,
)
for s in m.seats
),
m.win_reason,
end_round=m.round,
nine_rounds_rule=m.nine_rounds,
id=match_id,
)
def test_constants_match_reference(sim):
p = sim.PROPOSED
assert (p.r0, p.d, p.k_max, p.k_min, p.k_games) == (
scoring.R0, scoring.D, scoring.K_MAX, scoring.K_MIN, scoring.K_GAMES
)
assert (p.w_table, p.w_tempo, p.w_obj, p.w_worlds) == (
scoring.W_TABLE, scoring.W_TEMPO, scoring.W_OBJ, scoring.W_WORLDS
)
assert (p.mu_obj, p.mu_worlds, p.m_min, p.m_max) == (
scoring.MU_OBJ, scoring.MU_WORLDS, scoring.M_MIN, scoring.M_MAX
)
assert dict(p.closeness) == scoring.CLOSENESS
assert sim.BOARD_TILES == scoring.BOARD_TILES
assert not p.autocorr
@pytest.mark.parametrize("scenario", ["сигнал", "клубы", "рост"])
@pytest.mark.parametrize("stripped", [False, True], ids=["full", "history"])
def test_replay_matches_reference_season(sim, scenario, stripped):
"""Весь сезон: каждое изменение рейтинга совпадает с эталоном до 1e-9."""
cfg = sim.SCENARIOS[scenario]
_skill, matches = sim.generate_season(
cfg["seed"], sim.SEASON_MATCHES, cfg["informative"], cfg["clubs"], cfg["learning"]
)
if stripped:
matches = [sim.strip_details(m) for m in matches]
ids: dict[str, int] = {}
for m in matches:
for s in m.seats:
ids.setdefault(s.player, len(ids) + 1)
reference = sim.Elo(sim.PROPOSED)
ours = replay(_convert(m, ids, i) for i, m in enumerate(matches))
for i, m in enumerate(matches):
for player, dv in reference.update(m).items():
assert ours.delta[(i, ids[player])] == pytest.approx(dv, abs=1e-9)
for player, uid in ids.items():
assert ours.ratings[uid] == pytest.approx(reference.rating(player), abs=1e-6)
def test_monotone_and_zero_sum(sim):
"""Победитель без ничьей не теряет, последний без ничьей не получает; при равных K
сумма изменений за партию — ноль."""
cfg = sim.SCENARIOS["сигнал"]
_skill, matches = sim.generate_season(cfg["seed"] + 7, 200, True)
ids: dict[str, int] = {}
for m in matches:
for s in m.seats:
ids.setdefault(s.player, len(ids) + 1)
veterans = dict.fromkeys(ids.values(), VETERAN)
for i, m in enumerate(matches):
rm = _convert(m, ids, i)
delta, _ = rate_match({}, veterans, rm)
places = [s.place for s in rm.seats]
for s in rm.seats:
if places.count(s.place) > 1:
continue
if s.place == 1:
assert delta[s.user_id] > 0
if s.place == max(places):
assert delta[s.user_id] < 0
assert sum(delta.values()) == pytest.approx(0.0, abs=1e-9)
+226
View File
@@ -0,0 +1,226 @@
"""Итоги партии для рейтинга (#23): раунд окончания, цели и миры, правило 9 раундов,
причина «последний выживший». Сервер проверяет диапазоны и явные противоречия."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match
def _group(client: TestClient, engine, players: int) -> tuple[int, list[int], list[int]]:
"""Группа со всеми дополнениями и players участниками (первый — вошедший)."""
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"]
uids = [me["id"]] + [add_group_member(engine, gid, f"Игрок{i}") for i in range(2, players + 1)]
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
return gid, uids, fids
def _start(client: TestClient, gid: int, uids: list[int], fids: list[int]) -> dict:
r = start_match(
client, gid, [{"user_id": u, "faction_id": fids[i]} for i, u in enumerate(uids)]
)
assert r.status_code == 200, r.text
return r.json()
def _finish(client: TestClient, mid: int, participants: list[dict], win_reason: str, **extra):
body = {"participants": participants, "win_reason": win_reason, **extra}
return client.post(f"/api/matches/{mid}/finish", json=body, headers=csrf_headers(client))
def _set_rule(client: TestClient, gid: int, enabled: bool) -> dict:
r = client.patch(
f"/api/groups/{gid}", json={"nine_rounds_rule": enabled}, headers=csrf_headers(client)
)
assert r.status_code == 200, r.text
return r.json()
def test_finish_saves_round_objectives_and_worlds(client: TestClient, engine):
gid, (a, b), fids = _group(client, engine, 2)
match = _start(client, gid, [a, b], fids)
assert match["max_rounds"] == 8 and match["end_round"] is None
r = _finish(
client, match["id"],
[
{"user_id": a, "place": 1, "objectives": 2, "worlds": 5},
{"user_id": b, "place": 2, "objectives": 1}, # миры не указаны — так и остаётся
],
"objectives", end_round=6,
)
assert r.status_code == 200, r.text
data = r.json()
assert data["end_round"] == 6
parts = {p["user_id"]: p for p in data["participants"]}
assert (parts[a]["objectives"], parts[a]["worlds"]) == (2, 5)
assert (parts[b]["objectives"], parts[b]["worlds"]) == (1, None)
listed = client.get(f"/api/groups/{gid}/matches").json()["items"][0]["participants"]
assert {p["user_id"]: p["objectives"] for p in listed} == {a: 2, b: 1}
def test_end_round_limited_by_max_rounds(client: TestClient, engine):
gid, (a, b), fids = _group(client, engine, 2)
match = _start(client, gid, [a, b], fids)
rows = [{"user_id": a, "place": 1}, {"user_id": b, "place": 2}]
assert _finish(client, match["id"], rows, "objectives", end_round=9).status_code == 422
assert _finish(client, match["id"], rows, "objectives", end_round=0).status_code == 422
assert _finish(client, match["id"], rows, "objectives", end_round=8).status_code == 200
def test_nine_rounds_rule_is_snapshotted_for_five_players(client: TestClient, engine):
gid, uids, fids = _group(client, engine, 5)
assert _set_rule(client, gid, True)["nine_rounds_rule"] is True
assert client.get(f"/api/groups/{gid}").json()["name"] == "Группа" # имя не тронуто
five = _start(client, gid, uids, fids)
four = _start(client, gid, uids[:4], fids)
assert (five["nine_rounds_rule"], five["max_rounds"]) == (True, 9)
assert (four["nine_rounds_rule"], four["max_rounds"]) == (True, 8) # правило — только с 5
# Смена настройки группы не переписывает уже начатую партию.
_set_rule(client, gid, False)
assert client.get(f"/api/matches/{five['id']}").json()["max_rounds"] == 9
rows = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids)]
assert _finish(client, five["id"], rows, "objectives", end_round=9).status_code == 200
rows4 = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids[:4])]
assert _finish(client, four["id"], rows4, "objectives", end_round=9).status_code == 422
def test_last_standing_required_exactly_when_one_survivor(client: TestClient, engine):
gid, (a, b, c), fids = _group(client, engine, 3)
match = _start(client, gid, [a, b, c], fids)
alone = [
{"user_id": a, "place": 1},
{"user_id": b, "eliminated": True},
{"user_id": c, "eliminated": True},
]
r = _finish(client, match["id"], alone, "objectives")
assert r.status_code == 422 and "последний выживший" in r.json()["error"]["message"]
two = [
{"user_id": a, "place": 1},
{"user_id": b, "place": 2},
{"user_id": c, "eliminated": True},
]
assert _finish(client, match["id"], two, "last_standing").status_code == 422
ok = _finish(client, match["id"], alone, "last_standing")
assert ok.status_code == 200, ok.text
assert ok.json()["win_reason"] == "last_standing"
def test_eliminated_player_has_no_worlds(client: TestClient, engine):
gid, (a, b, c), fids = _group(client, engine, 3)
match = _start(client, gid, [a, b, c], fids)
rows = [
{"user_id": a, "place": 1, "worlds": 7},
{"user_id": b, "place": 2, "worlds": 4},
{"user_id": c, "eliminated": True, "worlds": 2},
]
assert _finish(client, match["id"], rows, "objectives").status_code == 422
rows[2] = {"user_id": c, "eliminated": True, "objectives": 1} # миры не указаны
r = _finish(client, match["id"], rows, "objectives")
assert r.status_code == 200, r.text
parts = {p["user_id"]: p for p in r.json()["participants"]}
assert (parts[c]["worlds"], parts[c]["objectives"]) == (0, 1)
def test_negative_counts_rejected(client: TestClient, engine):
gid, (a, b), fids = _group(client, engine, 2)
match = _start(client, gid, [a, b], fids)
rows = [{"user_id": a, "place": 1, "objectives": -1}, {"user_id": b, "place": 2}]
assert _finish(client, match["id"], rows, "objectives").status_code == 422
def _finished(client: TestClient, gid: int, uids: list[int], fids: list[int]) -> dict:
match = _start(client, gid, uids, fids)
rows = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids)]
r = _finish(client, match["id"], rows, "objectives")
assert r.status_code == 200, r.text
return r.json()
def _edit_rows(match: dict, **by_user) -> list[dict]:
rows = []
for p in match["participants"]:
row = {"user_id": p["user_id"], "faction_id": p["faction_id"], "place": p["place"]}
row.update(by_user.get(str(p["user_id"]), {}))
rows.append(row)
return rows
def test_edit_checks_last_standing_and_round(client: TestClient, engine):
gid, (a, b, c), fids = _group(client, engine, 3)
match = _finished(client, gid, [a, b, c], fids)
mid = match["id"]
def patch(body: dict):
return client.patch(f"/api/matches/{mid}", json=body, headers=csrf_headers(client))
elim = {"place": None, "eliminated": True}
rows = _edit_rows(match, **{str(b): elim, str(c): elim})
assert patch({"participants": rows, "win_reason": "worlds"}).status_code == 422
# Причина не передана — сверяется с записанной («по целям»): тоже противоречие.
assert patch({"participants": rows}).status_code == 422
r = patch({"participants": rows, "win_reason": "last_standing", "end_round": 5})
assert r.status_code == 200, r.text
assert (r.json()["win_reason"], r.json()["end_round"]) == ("last_standing", 5)
# Одна только причина: при одном выжившем вернуть «по целям» нельзя.
assert patch({"win_reason": "objectives"}).status_code == 422
# Один только раунд: в пределах лимита — можно, за лимитом — нет.
assert patch({"end_round": 9}).status_code == 422
assert patch({"end_round": None}).json()["end_round"] is None
def test_admin_edit_saves_counts(client: TestClient, engine, make_admin):
gid, (a, b), fids = _group(client, engine, 2)
match = _finished(client, gid, [a, b], fids)
make_admin("admin", "secret123")
assert client.post(
"/api/admin/auth/login",
json={"username": "admin", "password": "secret123"},
headers=csrf_headers(client),
).status_code == 200
rows = _edit_rows(match, **{str(a): {"objectives": 2, "worlds": 6}})
r = client.patch(
f"/api/admin/matches/{match['id']}",
json={"participants": rows, "win_reason": "objectives", "end_round": 7},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
parts = {p["user_id"]: p for p in r.json()["participants"]}
assert (parts[a]["objectives"], parts[a]["worlds"], r.json()["end_round"]) == (2, 6, 7)
def test_draft_keeps_round_and_counts(client: TestClient, engine):
gid, (a, b), fids = _group(client, engine, 2)
match = _start(client, gid, [a, b], fids)
body = {
"blocks": [[a], [b]],
"win_reason": "last_standing", # черновик — незаконченный ввод, правило не проверяется
"end_round": 4,
"objectives": {str(a): 2},
"worlds": {str(a): 5, str(b): 3},
}
r = client.put(f"/api/matches/{match['id']}/finish-draft", json=body, headers=csrf_headers(client))
assert r.status_code == 200, r.text
data = client.get(f"/api/matches/{match['id']}").json()["finish_draft"]["data"]
assert data["end_round"] == 4
assert data["objectives"] == {str(a): 2}
assert data["worlds"] == {str(a): 5, str(b): 3}
bad = client.put(
f"/api/matches/{match['id']}/finish-draft",
json={"worlds": {str(a): -3}},
headers=csrf_headers(client),
)
assert bad.status_code == 422
+140
View File
@@ -0,0 +1,140 @@
"""Рейтинг в витринах (#23): Elo по упорядоченной истории, общая и групповая цепочки."""
from __future__ import annotations
from fastapi.testclient import TestClient
from app.services.scoring import RatedMatch, RatedSeat, replay
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
def _group(client: TestClient, name: str = "Группа") -> tuple[int, list[int]]:
gid = client.post(
"/api/groups", json={"name": name, "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
return gid, fids
def _duel(client: TestClient, gid: int, fids: list[int], winner: int, loser: int) -> dict:
return create_finished_match(
client, gid,
[
{"user_id": winner, "faction_id": fids[0], "place": 1},
{"user_id": loser, "faction_id": fids[1], "place": 2},
],
)
def _board(client: TestClient, path: str = "/api/stats/leaderboard") -> dict[int, dict]:
data = client.get(path).json()
rows = data["entries"] + data["provisional"] if "entries" in data else (
data["leaderboard"] + data["provisional"]
)
return {e["user_id"]: e for e in rows}
def test_newcomer_duel_moves_rating_by_32(client: TestClient, engine):
"""Первая дуэль новичков на 1500: K = 64, ожидание 0.5, деталей нет → ±32."""
me = login(client, "Хозяин")
gid, fids = _group(client)
b = add_group_member(engine, gid, "Гость")
_duel(client, gid, fids, me["id"], b)
board = client.get("/api/stats/leaderboard").json()
by_id = {e["user_id"]: e for e in board["entries"] + board["provisional"]}
assert by_id[me["id"]]["score"] == 1532
assert by_id[b]["score"] == 1468
# 1 игра < MIN_GAMES=10 → оба пока «Новички», ранжированный топ пуст.
assert board["entries"] == []
assert board["min_games"] == 10
prof = client.get("/api/users/me/stats").json()
assert prof["overall"]["score"] == 1532
# Фракционная метрика — S − E: победа при шансах 0.5 даёт +0.5 → 50.0.
assert prof["factions"][0]["score"] == 50.0
def test_ranked_after_min_games(client: TestClient, engine):
me = login(client, "Чемпион")
gid, fids = _group(client)
b = add_group_member(engine, gid, "Спарринг")
for _ in range(10):
_duel(client, gid, fids, me["id"], b)
board = client.get("/api/stats/leaderboard").json()
ranks = {e["user_id"]: (e["rank"], e["score"]) for e in board["entries"]}
assert ranks[me["id"]][0] == 1 and ranks[b][0] == 2
assert ranks[me["id"]][1] > 1500 > ranks[b][1]
assert isinstance(ranks[me["id"]][1], int)
def test_editing_past_match_recalculates_later_ones(client: TestClient, engine):
"""Рейтинг — функция истории: правка первой партии меняет итог после второй."""
me = login(client, "А")
gid, fids = _group(client)
b = add_group_member(engine, gid, "Б")
first = _duel(client, gid, fids, me["id"], b)
_duel(client, gid, fids, me["id"], b)
def expected(first_winner: int, first_loser: int) -> dict[int, int]:
rep = replay([
RatedMatch((RatedSeat(first_winner, 1), RatedSeat(first_loser, 2)), "objectives", id=1),
RatedMatch((RatedSeat(me["id"], 1), RatedSeat(b, 2)), "objectives", id=2),
])
return {uid: round(r) for uid, r in rep.ratings.items()}
before = expected(me["id"], b)
assert {uid: e["score"] for uid, e in _board(client).items()} == before
rows = [
{"user_id": b, "faction_id": fids[1], "place": 1},
{"user_id": me["id"], "faction_id": fids[0], "place": 2},
]
r = client.patch(
f"/api/matches/{first['id']}",
json={"participants": rows, "win_reason": "objectives"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
after = expected(b, me["id"])
assert after != before
assert {uid: e["score"] for uid, e in _board(client).items()} == after
def test_group_rating_is_its_own_chain(client: TestClient, engine):
"""Групповой рейтинг проигрывает только партии группы, общий — все партии."""
me = login(client, "Путешественник")
g1, f1 = _group(client, "Первая")
g2, f2 = _group(client, "Вторая")
b = add_group_member(engine, g1, "Сосед")
c = add_group_member(engine, g2, "Соседка")
_duel(client, g1, f1, me["id"], b) # в первой группе — победа
_duel(client, g2, f2, c, me["id"]) # во второй — поражение
assert _board(client, f"/api/groups/{g1}/stats")[me["id"]]["score"] == 1532
assert _board(client, f"/api/groups/{g2}/stats")[me["id"]]["score"] == 1468
overall = _board(client)[me["id"]]["score"]
assert overall not in (1532, 1468)
assert overall == round(
replay([
RatedMatch((RatedSeat(me["id"], 1), RatedSeat(b, 2)), "objectives", id=1),
RatedMatch((RatedSeat(c, 1), RatedSeat(me["id"], 2)), "objectives", id=2),
]).ratings[me["id"]]
)
def test_best_match_is_biggest_rating_gain(client: TestClient, engine):
"""Лучшая партия — наибольший прирост рейтинга, а не свежая из равных побед.
Обе партии — победы в дуэли. Первая — новичком над равным (+32), вторая — уже
с рейтингом 1532 и меньшим K над новичком (≈ +28): лучше первая."""
me = login(client, "Лучший")
gid, fids = _group(client)
x = add_group_member(engine, gid, "Икс")
y = add_group_member(engine, gid, "Игрек")
first = _duel(client, gid, fids, me["id"], x)
_duel(client, gid, fids, me["id"], y)
client.patch("/api/users/me/profile", json={"history_mode": "best"}, headers=csrf_headers(client))
data = client.get(f"/api/users/{me['id']}/matches").json()
assert data["total"] == 1
assert data["items"][0]["id"] == first["id"]
-39
View File
@@ -1,39 +0,0 @@
"""Сглаживание рейтинга: score = (C·m + сумма очков) / (C + игр) × 100, C=10, m=0.5."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
def test_leaderboard_and_profile_score_are_smoothed(client: TestClient, engine):
me = login(client, "Хозяин")
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
b = add_group_member(engine, gid, "Гость")
factions = client.get(f"/api/groups/{gid}/factions").json()
f1, f2 = factions[0]["id"], factions[1]["id"]
create_finished_match(
client,
gid,
[
{"user_id": me["id"], "faction_id": f1, "place": 1},
{"user_id": b, "faction_id": f2, "place": 2},
],
)
board = client.get("/api/stats/leaderboard").json()
by_id = {e["user_id"]: e for e in board["entries"] + board["provisional"]}
# Победитель: 1 очко за партию → (10·0.5 + 1) / (10 + 1) × 100 = 54.5.
# Проигравший: 0 очков → (10·0.5 + 0) / 11 × 100 = 45.5.
assert by_id[me["id"]]["score"] == 54.5
assert by_id[b]["score"] == 45.5
# 1 игра < MIN_GAMES=10 → оба пока «Новички», ранжированный топ пуст.
assert board["entries"] == []
assert board["min_games"] == 10
# Профиль показывает тот же сглаженный рейтинг, что и топ.
prof = client.get("/api/users/me/stats").json()
assert prof["overall"]["score"] == 54.5
+13 -24
View File
@@ -1,9 +1,9 @@
"""Статистика профиля: цифры сходятся с лидербордом, а главная не гоняет CTE лишний раз.""" """Статистика профиля: цифры сходятся с лидербордом, а главная не грузит историю лишний раз."""
from __future__ import annotations from __future__ import annotations
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import event
from app.services import stats_service
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
@@ -31,10 +31,7 @@ def _group_with_matches(client: TestClient, engine, games: int = 3) -> tuple[dic
def test_profile_numbers_match_leaderboard(client: TestClient, engine): def test_profile_numbers_match_leaderboard(client: TestClient, engine):
"""Профиль считает в Python, лидерборд — в SQL: цифры обязаны совпадать. """Профиль и лидерборд собирают итог разными путями: цифры обязаны совпадать."""
Формула сглаженного рейтинга живёт в двух видах (SMOOTHED_SCORE_SQL и
scoring.smoothed_score); этот тест ловит их расхождение."""
me, gid, p2 = _group_with_matches(client, engine, games=4) me, gid, p2 = _group_with_matches(client, engine, games=4)
profile = client.get("/api/users/me/stats").json()["overall"] profile = client.get("/api/users/me/stats").json()["overall"]
@@ -47,33 +44,25 @@ def test_profile_numbers_match_leaderboard(client: TestClient, engine):
assert profile[field] == entry[field], field assert profile[field] == entry[field], field
def test_home_does_not_repeat_scored_cte(client: TestClient, engine): def test_home_loads_history_once(client: TestClient, engine, monkeypatch):
"""Главная делает не больше двух проходов по SCORED_CTE. """Главная грузит историю партий один раз: топ, профиль и итог активной группы
считаются из неё. Без этой проверки лишняя загрузка тихо вернётся при правке витрин."""
Было пять: лидерборд, три запроса профиля и итог по активной группе. Без этой
проверки оптимизация тихо отъедет назад при следующей правке витрин."""
me, gid, p2 = _group_with_matches(client, engine, games=2) me, gid, p2 = _group_with_matches(client, engine, games=2)
client.put( client.put(
"/api/users/me/active-group", json={"group_id": gid}, headers=csrf_headers(client) "/api/users/me/active-group", json={"group_id": gid}, headers=csrf_headers(client)
) )
seen: list[str] = [] calls: list[int | None] = []
original = stats_service.load_history
def before_execute(conn, cursor, statement, params, context, executemany): def spy(session, group_id=None):
if "WITH tie AS" in statement: calls.append(group_id)
seen.append(statement) return original(session, group_id)
event.listen(engine, "before_cursor_execute", before_execute) monkeypatch.setattr(stats_service, "load_history", spy)
try:
r = client.get("/api/home") r = client.get("/api/home")
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
finally: assert calls == [None]
event.remove(engine, "before_cursor_execute", before_execute)
# Сейчас ровно два: лидерборд и один проход по строкам игрока. Нижняя граница не
# для красоты — без неё тест пройдёт и когда счётчик молча перестанет что-либо
# ловить (сменился путь, переименован CTE).
assert 1 <= len(seen) <= 2, f"ожидали 1–2 прохода, получили {len(seen)}"
# Главная всё ещё показывает и профиль, и блок активной группы. # Главная всё ещё показывает и профиль, и блок активной группы.
body = r.json() body = r.json()
assert body["profile"]["overall"]["games"] == 2 assert body["profile"]["overall"]["games"] == 2
+2 -2
View File
@@ -1,7 +1,7 @@
# Новая рейтинговая система # Новая рейтинговая система
> **Статус:** предложение на утверждение (#22). Решения владельца по открытым вопросам > **Статус:** утверждено (#22), реализовано в #23 (`backend/app/services/scoring.py`).
> (2026-09-14) внесены — [раздел 9](#9-решения-владельца). Реализация — #23. > Решения владельца по открытым вопросам (2026-09-14) внесены — [раздел 9](#9-решения-владельца).
> **Приложение:** [`simulate.py`](simulate.py) — эталонная реализация формул, все примеры > **Приложение:** [`simulate.py`](simulate.py) — эталонная реализация формул, все примеры
> этого документа (с `assert`), симуляция и перебор коэффициентов. Только стандартная > этого документа (с `assert`), симуляция и перебор коэффициентов. Только стандартная
> библиотека Python, вывод воспроизводим: > библиотека Python, вывод воспроизводим:
+70 -15
View File
@@ -377,8 +377,11 @@ export interface paths {
delete?: never; delete?: never;
options?: never; options?: never;
head?: never; head?: never;
/** Rename Group */ /**
patch: operations["rename_group_api_groups__group_id__patch"]; * Update Group
* @description Название и домашние правила группы; меняются только переданные поля.
*/
patch: operations["update_group_api_groups__group_id__patch"];
trace?: never; trace?: never;
}; };
"/api/groups/{group_id}/expansions": { "/api/groups/{group_id}/expansions": {
@@ -1307,7 +1310,7 @@ export interface components {
/** Duration Minutes */ /** Duration Minutes */
duration_minutes?: number | null; duration_minutes?: number | null;
/** Win Reason */ /** Win Reason */
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
/** Player Count */ /** Player Count */
player_count: number; player_count: number;
/** Created By */ /** Created By */
@@ -1576,6 +1579,11 @@ export interface components {
* @default [] * @default []
*/ */
expansion_ids: number[]; expansion_ids: number[];
/**
* Nine Rounds Rule
* @default false
*/
nine_rounds_rule: boolean;
}; };
/** GroupExpansionsUpdate */ /** GroupExpansionsUpdate */
GroupExpansionsUpdate: { GroupExpansionsUpdate: {
@@ -1585,11 +1593,6 @@ export interface components {
*/ */
expansion_ids: number[]; expansion_ids: number[];
}; };
/** GroupRename */
GroupRename: {
/** Name */
name: string;
};
/** GroupStats */ /** GroupStats */
GroupStats: { GroupStats: {
/** Group Id */ /** Group Id */
@@ -1621,6 +1624,13 @@ export interface components {
/** Min Games */ /** Min Games */
min_games: number; min_games: number;
}; };
/** GroupUpdate */
GroupUpdate: {
/** Name */
name?: string | null;
/** Nine Rounds Rule */
nine_rounds_rule?: boolean | null;
};
/** HTTPValidationError */ /** HTTPValidationError */
HTTPValidationError: { HTTPValidationError: {
/** Detail */ /** Detail */
@@ -1730,7 +1740,9 @@ export interface components {
* Win Reason * Win Reason
* @enum {string} * @enum {string}
*/ */
win_reason: "objectives" | "worlds" | "plastic" | "resources"; win_reason: "objectives" | "worlds" | "plastic" | "resources" | "last_standing";
/** End Round */
end_round?: number | null;
/** Overall Comment */ /** Overall Comment */
overall_comment?: string | null; overall_comment?: string | null;
/** Expected Version */ /** Expected Version */
@@ -1761,9 +1773,25 @@ export interface components {
[key: string]: string; [key: string]: string;
}; };
/** Win Reason */ /** Win Reason */
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
/** Overall Comment */ /** Overall Comment */
overall_comment?: string | null; overall_comment?: string | null;
/** End Round */
end_round?: number | null;
/**
* Objectives
* @default {}
*/
objectives: {
[key: string]: number;
};
/**
* Worlds
* @default {}
*/
worlds: {
[key: string]: number;
};
}; };
/** MatchFinishDraftRead */ /** MatchFinishDraftRead */
MatchFinishDraftRead: { MatchFinishDraftRead: {
@@ -1790,6 +1818,10 @@ export interface components {
comment?: string | null; comment?: string | null;
/** Faction Id */ /** Faction Id */
faction_id?: number | null; faction_id?: number | null;
/** Objectives */
objectives?: number | null;
/** Worlds */
worlds?: number | null;
}; };
/** /**
* MatchHistory * MatchHistory
@@ -1844,7 +1876,7 @@ export interface components {
/** Duration Minutes */ /** Duration Minutes */
duration_minutes?: number | null; duration_minutes?: number | null;
/** Win Reason */ /** Win Reason */
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
/** Player Count */ /** Player Count */
player_count: number; player_count: number;
/** Overall Comment */ /** Overall Comment */
@@ -1878,6 +1910,10 @@ export interface components {
was_random: boolean; was_random: boolean;
/** Comment */ /** Comment */
comment?: string | null; comment?: string | null;
/** Objectives */
objectives?: number | null;
/** Worlds */
worlds?: number | null;
}; };
/** MatchParticipantRead */ /** MatchParticipantRead */
MatchParticipantRead: { MatchParticipantRead: {
@@ -1900,6 +1936,10 @@ export interface components {
was_random: boolean; was_random: boolean;
/** Comment */ /** Comment */
comment?: string | null; comment?: string | null;
/** Objectives */
objectives?: number | null;
/** Worlds */
worlds?: number | null;
/** Avatar Url */ /** Avatar Url */
avatar_url?: string | null; avatar_url?: string | null;
}; };
@@ -1923,7 +1963,16 @@ export interface components {
/** Duration Minutes */ /** Duration Minutes */
duration_minutes?: number | null; duration_minutes?: number | null;
/** Win Reason */ /** Win Reason */
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
/** End Round */
end_round?: number | null;
/**
* Nine Rounds Rule
* @default false
*/
nine_rounds_rule: boolean;
/** Max Rounds */
max_rounds: number;
/** Player Count */ /** Player Count */
player_count: number; player_count: number;
/** Overall Comment */ /** Overall Comment */
@@ -1956,7 +2005,9 @@ export interface components {
/** Overall Comment */ /** Overall Comment */
overall_comment?: string | null; overall_comment?: string | null;
/** Win Reason */ /** Win Reason */
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
/** End Round */
end_round?: number | null;
/** Participants */ /** Participants */
participants?: components["schemas"]["ParticipantInput"][] | null; participants?: components["schemas"]["ParticipantInput"][] | null;
/** Expected Version */ /** Expected Version */
@@ -2104,6 +2155,10 @@ export interface components {
was_random: boolean; was_random: boolean;
/** Comment */ /** Comment */
comment?: string | null; comment?: string | null;
/** Objectives */
objectives?: number | null;
/** Worlds */
worlds?: number | null;
}; };
/** PasswordChange */ /** PasswordChange */
PasswordChange: { PasswordChange: {
@@ -2987,7 +3042,7 @@ export interface operations {
}; };
}; };
}; };
rename_group_api_groups__group_id__patch: { update_group_api_groups__group_id__patch: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -2998,7 +3053,7 @@ export interface operations {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["GroupRename"]; "application/json": components["schemas"]["GroupUpdate"];
}; };
}; };
responses: { responses: {
@@ -0,0 +1,76 @@
import { PickerSelect } from "./PickerSelect";
import { LAST_STANDING, WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label }));
const NO_ROUND = 0;
/**
* Итог партии в форме завершения и правки результатов: причина победы, раунд окончания
* и предупреждения о несогласованном вводе. «Последний выживший» не выбирается —
* пока выживший один, причина зафиксирована (её ставит родитель, см. reasonForSurvivors).
*/
export function MatchOutcomeFields({
winReason,
onReason,
endRound,
onEndRound,
maxRounds,
warnings,
}: {
winReason: WinReason | null;
onReason: (reason: WinReason) => void;
endRound: number | null;
onEndRound: (round: number | null) => void;
maxRounds: number;
warnings: string[];
}) {
const roundOptions = [
{ id: NO_ROUND, label: "не указан" },
...Array.from({ length: maxRounds }, (_, i) => ({ id: i + 1, label: `${i + 1}-й` })),
];
return (
<div className="card">
<h3>Итог партии</h3>
<div className="field">
<label className="label">Причина победы</label>
{winReason === LAST_STANDING ? (
<div className="picker-trigger" aria-disabled>
{winReasonLabel(LAST_STANDING)}
<span className="muted small" style={{ marginLeft: "auto" }}>
все соперники выбыли
</span>
</div>
) : (
<PickerSelect
selected={REASON_OPTIONS.find((o) => o.id === winReason) ?? null}
options={REASON_OPTIONS}
placeholder="— выберите причину —"
renderOption={(o) => o.label}
onPick={(o) => onReason(o.id)}
/>
)}
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="label">Раунд окончания (из {maxRounds})</label>
<PickerSelect
selected={roundOptions.find((o) => o.id === (endRound ?? NO_ROUND)) ?? null}
options={roundOptions}
placeholder="не указан"
renderOption={(o) => o.label}
onPick={(o) => onEndRound(o.id === NO_ROUND ? null : o.id)}
/>
</div>
<p className="muted small" style={{ marginBottom: 0 }}>
Раунд, цели и миры необязательны, но делают рейтинг точнее: быстрая и крупная победа
весит больше.
</p>
{warnings.length > 0 && (
<ul className="warn-list">
{warnings.map((w) => (
<li key={w}>{w}</li>
))}
</ul>
)}
</div>
);
}
+45 -2
View File
@@ -10,6 +10,10 @@ export interface PlacePlayer {
avatar_url?: string | null; avatar_url?: string | null;
} }
export type CountField = "objectives" | "worlds";
/** Цели и миры на конец партии по user_id; отсутствие ключа или null — не указано. */
export type Counts = Record<number, { objectives: number | null; worlds: number | null }>;
type Target = type Target =
| { type: "merge"; idx: number } | { type: "merge"; idx: number }
| { type: "insert"; idx: number } | { type: "insert"; idx: number }
@@ -28,7 +32,8 @@ interface DragState {
* Редактор мест перетаскиванием (макет A2): вертикальный список блоков игроков, * Редактор мест перетаскиванием (макет A2): вертикальный список блоков игроков,
* верхний — 1-е место. Бросок между блоками — порядок, на середину чужого блока — * верхний — 1-е место. Бросок между блоками — порядок, на середину чужого блока —
* слияние в ничью (общая рамка и место, ✂ выносит обратно), в пунктирную зону — * слияние в ничью (общая рамка и место, ✂ выносит обратно), в пунктирную зону —
* выбывший. В каждом блоке — строка комментария об игроке. * выбывший. В каждом блоке — строка комментария об игроке и (если переданы counts)
* цели и миры на конец партии; у выбывшего миров нет — поле заблокировано нулём.
* *
* Контролируемый: blocks (упорядоченные группы user_id, длина >1 = ничья) и * Контролируемый: blocks (упорядоченные группы user_id, длина >1 = ничья) и
* eliminated живут у родителя. Во время drag DOM двигается напрямую (transform, * eliminated живут у родителя. Во время drag DOM двигается напрямую (transform,
@@ -41,6 +46,8 @@ export function PlaceEditor({
comments, comments,
onChange, onChange,
onComment, onComment,
counts,
onCount,
}: { }: {
players: PlacePlayer[]; players: PlacePlayer[];
blocks: number[][]; blocks: number[][];
@@ -48,6 +55,8 @@ export function PlaceEditor({
comments: Record<number, string>; comments: Record<number, string>;
onChange: (blocks: number[][], eliminated: number[]) => void; onChange: (blocks: number[][], eliminated: number[]) => void;
onComment: (userId: number, text: string) => void; onComment: (userId: number, text: string) => void;
counts?: Counts;
onCount?: (userId: number, field: CountField, value: number | null) => void;
}) { }) {
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const elimRef = useRef<HTMLDivElement>(null); const elimRef = useRef<HTMLDivElement>(null);
@@ -225,7 +234,32 @@ export function PlaceEditor({
); );
}; };
const commentInput = (userId: number) => ( const countInput = (userId: number, field: CountField, label: string, locked = false) => (
<label className="rank-count">
<span>{label}</span>
<input
type="number"
inputMode="numeric"
min={0}
max={99}
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))),
);
}}
/>
</label>
);
const commentInput = (userId: number) => {
const comment = (
<input <input
className="rank-comment" className="rank-comment"
placeholder="Комментарий об игроке" placeholder="Комментарий об игроке"
@@ -233,6 +267,15 @@ export function PlaceEditor({
onChange={(e) => onComment(userId, e.target.value)} onChange={(e) => onComment(userId, e.target.value)}
/> />
); );
if (!counts) return comment;
return (
<div className="rank-details">
{comment}
{countInput(userId, "objectives", "цели")}
{countInput(userId, "worlds", "миры", eliminated.includes(userId))}
</div>
);
};
return ( return (
<div> <div>
+1 -1
View File
@@ -35,7 +35,7 @@ export function ProfileStatsCard({
<b>{o.avg_place ?? "—"}</b> <b>{o.avg_place ?? "—"}</b>
</div> </div>
<div className="row-between"> <div className="row-between">
<div>Очки (рейтинг)</div> <div>Рейтинг</div>
<b className={"lb-score" + (provisional ? " provisional" : "")}>{o.score ?? "—"}</b> <b className={"lb-score" + (provisional ? " provisional" : "")}>{o.score ?? "—"}</b>
</div> </div>
{/* Любимая — личный выбор игрока в профиле; ниже — статистика по партиям. */} {/* Любимая — личный выбор игрока в профиле; ниже — статистика по партиям. */}
+101
View File
@@ -0,0 +1,101 @@
import { LAST_STANDING, type WinReason, winReasonLabel } from "./winReasons";
export interface OutcomeSeat {
userId: number;
nickname: string;
/** Место невыбывшего; у выбывшего не учитывается. */
place: number;
eliminated: boolean;
objectives: number | null;
worlds: number | null;
}
/**
* Подсказки о несогласованных итогах партии. Это предупреждения, а не отказ: сервер
* проверяет только диапазоны и явные противоречия, а здесь — сочетания, которые по
* правилам почти наверняка опечатка. Проверка идёт только по заполненным полям.
*/
export function finishWarnings({
seats,
winReason,
endRound,
maxRounds,
}: {
seats: OutcomeSeat[];
winReason: WinReason | null;
endRound: number | null;
maxRounds: number;
}): string[] {
const out: string[] = [];
const n = seats.length;
const survivors = seats.filter((s) => !s.eliminated).sort((a, b) => a.place - b.place);
const winners = survivors.filter((s) => s.place === 1);
if (winners.length === 0 || winReason === LAST_STANDING) return out;
// Досрочно партия заканчивается, только когда кто-то набрал столько целей, сколько
// игроков за столом.
const winnerObjectives = winners.map((w) => w.objectives).filter((v): v is number => v != null);
if (
endRound != null &&
endRound < maxRounds &&
winnerObjectives.length > 0 &&
Math.max(...winnerObjectives) < n
) {
out.push(
`Партия закончилась в ${endRound}-м раунде из ${maxRounds}, но у победителя меньше ${n} целей — ` +
`досрочно побеждает тот, кто набрал ${n}.`,
);
}
// Остальное сравнивает победителя с соперниками — при ничьей за 1-е место не с кем.
if (winners.length > 1) return out;
const winner = winners[0];
const secondPlace = survivors.find((s) => s.place > 1)?.place;
const runnersUp = survivors.filter((s) => s.place === secondPlace);
const label = winReasonLabel(winReason).toLowerCase();
for (const r of runnersUp) {
const objKnown = winner.objectives != null && r.objectives != null;
const worldsKnown = winner.worlds != null && r.worlds != null;
if (winReason === "objectives" && objKnown && winner.objectives === r.objectives) {
out.push(
`У ${winner.nickname} и ${r.nickname} поровну целей (${winner.objectives}) — ` +
"при равенстве победа определяется по мирам, пластику или ресурсам.",
);
}
if (winReason && winReason !== "objectives" && objKnown && winner.objectives !== r.objectives) {
out.push(
`Победа ${label} предполагает равные цели у лидеров: у ${winner.nickname} — ` +
`${winner.objectives}, у ${r.nickname} — ${r.objectives}.`,
);
}
if (winReason === "worlds" && worldsKnown && winner.worlds! <= r.worlds!) {
out.push(
`Победа по мирам, но у ${winner.nickname} миров не больше, чем у ${r.nickname} ` +
`(${winner.worlds} и ${r.worlds}).`,
);
}
if (
(winReason === "plastic" || winReason === "resources") &&
worldsKnown &&
winner.worlds !== r.worlds
) {
out.push(
`Победа ${label} предполагает равные миры у лидеров: у ${winner.nickname} — ` +
`${winner.worlds}, у ${r.nickname} — ${r.worlds}.`,
);
}
}
if (winner.objectives != null) {
for (const s of seats) {
if (s.userId !== winner.userId && s.objectives != null && s.objectives > winner.objectives) {
out.push(
`У ${s.nickname} целей больше, чем у победителя ${winner.nickname} ` +
`(${s.objectives} против ${winner.objectives}).`,
);
}
}
}
return out;
}
+18 -4
View File
@@ -2,7 +2,10 @@ import type { components } from "../api/schema";
export type WinReason = components["schemas"]["MatchFinish"]["win_reason"]; export type WinReason = components["schemas"]["MatchFinish"]["win_reason"];
// Фиксированный порядок (сверху вниз). export const LAST_STANDING: WinReason = "last_standing";
// Причины для ручного выбора, фиксированный порядок (сверху вниз). «Последний выживший»
// сюда не входит: она ставится сама, когда невыбывший игрок остался один.
export const WIN_REASONS: { code: WinReason; label: string }[] = [ export const WIN_REASONS: { code: WinReason; label: string }[] = [
{ code: "objectives", label: "По целям" }, { code: "objectives", label: "По целям" },
{ code: "worlds", label: "По мирам" }, { code: "worlds", label: "По мирам" },
@@ -10,10 +13,21 @@ export const WIN_REASONS: { code: WinReason; label: string }[] = [
{ code: "resources", label: "По ресурсам" }, { code: "resources", label: "По ресурсам" },
]; ];
const LABELS: Record<string, string> = Object.fromEntries( const LABELS: Record<string, string> = {
WIN_REASONS.map((w) => [w.code, w.label]), ...Object.fromEntries(WIN_REASONS.map((w) => [w.code, w.label])),
); [LAST_STANDING]: "Последний выживший",
};
export function winReasonLabel(code: string | null | undefined): string { export function winReasonLabel(code: string | null | undefined): string {
return code ? LABELS[code] ?? code : "—"; return code ? LABELS[code] ?? code : "—";
} }
/**
* Причина победы после изменения состава выбывших. Правило сервера: невыбывший ровно
* один ⇔ «последний выживший». Поэтому при одном выжившем причина ставится сама, а когда
* выживших снова двое — сбрасывается (null): её нужно выбрать заново, а не угадывать.
*/
export function reasonForSurvivors(current: WinReason | null, survivors: number): WinReason | null {
if (survivors === 1) return LAST_STANDING;
return current === LAST_STANDING ? null : current;
}
+4 -3
View File
@@ -75,14 +75,15 @@ export function useCreateGroup() {
}); });
} }
export function useRenameGroup(groupId: number) { export function useUpdateGroup(groupId: number) {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (name: string) => // Частичная правка: название и/или домашние правила (например, 9 раундов).
mutationFn: async (body: { name?: string; nine_rounds_rule?: boolean }) =>
unwrap( unwrap(
await api.PATCH("/api/groups/{group_id}", { await api.PATCH("/api/groups/{group_id}", {
params: { path: { group_id: groupId } }, params: { path: { group_id: groupId } },
body: { name }, body,
}), }),
), ),
onSuccess: () => { onSuccess: () => {
+29 -4
View File
@@ -13,8 +13,8 @@ import {
useGroup, useGroup,
useGroupMembers, useGroupMembers,
useRemoveMember, useRemoveMember,
useRenameGroup,
useSetGroupExpansions, useSetGroupExpansions,
useUpdateGroup,
} from "../hooks/groups"; } from "../hooks/groups";
import { useExpansions } from "../hooks/reference"; import { useExpansions } from "../hooks/reference";
@@ -25,7 +25,7 @@ export function GroupSettingsPage() {
const { data: expansions } = useExpansions(); const { data: expansions } = useExpansions();
const { data: members } = useGroupMembers(groupId); const { data: members } = useGroupMembers(groupId);
const setExpansions = useSetGroupExpansions(groupId ?? 0); const setExpansions = useSetGroupExpansions(groupId ?? 0);
const renameGroup = useRenameGroup(groupId ?? 0); const updateGroup = useUpdateGroup(groupId ?? 0);
const removeMember = useRemoveMember(groupId ?? 0); const removeMember = useRemoveMember(groupId ?? 0);
const toast = useToast(); const toast = useToast();
@@ -55,7 +55,7 @@ export function GroupSettingsPage() {
const trimmed = nameValue.trim(); const trimmed = nameValue.trim();
if (!trimmed || trimmed === group.name) return; if (!trimmed || trimmed === group.name) return;
try { try {
await renameGroup.mutateAsync(trimmed); await updateGroup.mutateAsync({ name: trimmed });
setName(null); setName(null);
toast.show("Название сохранено"); toast.show("Название сохранено");
} catch (e) { } catch (e) {
@@ -63,6 +63,15 @@ export function GroupSettingsPage() {
} }
}; };
const saveNineRounds = async (enabled: boolean) => {
try {
await updateGroup.mutateAsync({ nine_rounds_rule: enabled });
toast.show(enabled ? "9 раундов при 5–6 игроках включено" : "Правило 9 раундов выключено");
} catch (e) {
toast.error(e instanceof ApiError ? e.message : "Ошибка");
}
};
const saveExpansions = async () => { const saveExpansions = async () => {
try { try {
await setExpansions.mutateAsync([...selected]); await setExpansions.mutateAsync([...selected]);
@@ -95,7 +104,7 @@ export function GroupSettingsPage() {
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={saveName} onClick={saveName}
disabled={renameGroup.isPending || !nameValue.trim() || nameValue.trim() === group.name} disabled={updateGroup.isPending || !nameValue.trim() || nameValue.trim() === group.name}
> >
Сохранить Сохранить
</button> </button>
@@ -127,6 +136,22 @@ export function GroupSettingsPage() {
)} )}
</div> </div>
<div className="card">
<h3>Домашние правила</h3>
<label className="row" style={{ justifyContent: "space-between" }}>
<span>9 раундов при 5–6 игроках</span>
<Switch
checked={group.nine_rounds_rule}
disabled={updateGroup.isPending}
onChange={saveNineRounds}
/>
</label>
<p className="muted small" style={{ marginBottom: 0 }}>
Действует на партии, начатые после смены: лимит раундов уже идущих и сыгранных
партий не меняется.
</p>
</div>
<div className="card"> <div className="card">
{/* Приглашение нового игрока — на странице группы («Список игроков»). */} {/* Приглашение нового игрока — на странице группы («Список игроков»). */}
<h3>Участники ({(members ?? []).length}/{MAX_GROUP_SIZE})</h3> <h3>Участники ({(members ?? []).length}/{MAX_GROUP_SIZE})</h3>
+97 -32
View File
@@ -2,8 +2,9 @@ import { ChevronDown, ChevronUp } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
/** /**
* Справка — разделы, раскрывающиеся по кнопке. Пока раздел один: «Рейтинг» * Справка — разделы, раскрывающиеся по кнопке. Пока раздел один: «Рейтинг».
* (как считаются очки игроков). Текст соответствует backend/app/services/scoring.py. * Текст соответствует backend/app/services/scoring.py и docs/rating/rating-system.md:
* коэффициенты, пороги и примеры менять вместе с ними.
*/ */
export function HelpPage() { export function HelpPage() {
const [showRating, setShowRating] = useState(false); const [showRating, setShowRating] = useState(false);
@@ -32,50 +33,113 @@ export function HelpPage() {
{showRating && ( {showRating && (
<> <>
<div className="card"> <div className="card">
<h3>Очки игроков (рейтинг)</h3> <h3>Рейтинг игроков</h3>
<p className="small"> <p className="small">
Рейтинг считается только по <b>завершённым</b> партиям. Незавершённые и отменённые Рейтинг — оценка силы игрока относительно соперников (система Elo). Каждый начинает
в зачёт не идут. с <b>1500</b>. Разница в <b>400</b> пунктов означает шансы 10 к 1 в пользу более
сильного.
</p> </p>
<p className="small"> <p className="small">
За каждую партию игрок получает «очки за место» — они нормированы по числу игроков Считаются только <b>завершённые</b> партии, по порядку их игры. Правка или удаление
за столом, поэтому победа за большим столом ценится выше, чем за маленьким: старой партии пересчитывает и все последующие.
</p> </p>
<div className="formula"> <p className="small">
очки = (N − место − (размер_ничьей − 1) / 2) / (N − 1) Партия раскладывается на пары игроков. В каждой паре фактический результат (выше —
1, поровну — 0.5, ниже — 0) сравнивается с ожидаемым по рейтингам до партии:
</p>
<div className="formula">ожидание = 1 / (1 + 10^((R соперника − R игрока) / 400))</div>
<div className="formula" style={{ marginTop: 6 }}>
ΔR = K × G / (N − 1) × Σ M × (результат − ожидание)
</div> </div>
<p className="small" style={{ marginTop: 10 }}> <p className="small" style={{ marginTop: 10 }}>
где <b>N</b> — число игроков в партии. Первое место даёт <b>1.0</b>, последнее — <b>0.0</b>. где <b>N</b> — число игроков, сумма — по всем соперникам. Поэтому победа над сильным
</p> приносит больше, чем над слабым, а поражение от слабого отнимает больше. Выбывшие
<ul className="small" style={{ marginTop: 0, paddingLeft: 18 }}> делят последнее место.
<li>Если несколько игроков делят место (ничья) — они делят сумму очков поровну.</li>
<li>Выбывшие из партии считаются как последнее место.</li>
</ul>
<p className="small">
Итоговый <b>рейтинг</b> игрока — сглаженное среднее: к реальным партиям
«дописываются» 10 виртуальных со средним результатом 0.5 (шкала от 0 до 100):
</p>
<div className="formula">
рейтинг = (10 × 0.5 + сумма очков) / (10 + число партий) × 100
</div>
<p className="small" style={{ marginTop: 10 }}>
Пока партий мало, рейтинг держится около 50 и с опытом сходится к реальному
среднему — стабильные результаты на длинной дистанции ценятся выше короткой
удачной серии.
</p> </p>
</div> </div>
<div className="card"> <div className="card">
<h3>Пример (стол на 4 игроков)</h3> <h3>От чего зависит изменение</h3>
<ul className="small" style={{ margin: 0, paddingLeft: 18 }}>
<li>
<b>K — скорость.</b> У новичка 64, к 20-й партии плавно снижается до 16: новичок
быстро находит свой уровень, а рейтинг опытного игрока не скачет.
</li>
<li>
<b>G — размер стола:</b> 1 + 0.5 × (N − 2) / 4. В дуэли 1.0, вчетвером 1.25,
вшестером 1.5.
</li>
<li>
<b>M — отрыв в паре</b>, от 0.5 до 2. Обычная партия даёт 1. Больше, если:
<ul style={{ paddingLeft: 16 }}>
<li>
партия закончилась раньше — для пар с победителем; обычной считается конец
в предпоследнем раунде;
</li>
<li>разница целей велика относительно числа игроков;</li>
<li>разница миров велика относительно доли миров на игрока.</li>
</ul>
</li>
<li>
<b>Тип победы</b> — для пар с победителем, чем ближе партия, тем меньше:
по целям ×1, по мирам ×0.85, по пластику ×0.7, по ресурсам ×0.6,
последний выживший ×1.
</li>
</ul>
<p className="small" style={{ marginTop: 10 }}>
Раунд окончания, цели и миры вводить <b>необязательно</b>: пропущенное считается
обычным значением и множитель не меняет. «Последний выживший» ставится сам, когда
все соперники выбыли, — отрыв по целям тогда максимальный. Лимит раундов — 8,
с правилом группы «9 раундов» при 5–6 игроках — 9.
</p>
<p className="small" style={{ marginBottom: 0 }}>
Победитель никогда не теряет рейтинг, а последнее место никогда его не приносит.
</p>
</div>
<div className="card">
<h3>Примеры (опытные игроки)</h3>
<div className="stack" style={{ gap: 6 }}> <div className="stack" style={{ gap: 6 }}>
<div className="row-between small"><span>1-е место</span><b>(4−1−0)/3 = 1.00 → 100</b></div>
<div className="row-between small"><span>2-е место</span><b>(4−2−0)/3 = 0.67 → 67</b></div>
<div className="row-between small"><span>3-е место</span><b>(4−3−0)/3 = 0.33 → 33</b></div>
<div className="row-between small"><span>4-е место</span><b>(4−4−0)/3 = 0.00 → 0</b></div>
<div className="row-between small"> <div className="row-between small">
<span>Ничья за 1-е (двое)</span><b>(4−1−0.5)/3 = 0.83 каждому</b> <span>Дуэль 1600 и 1400: победил сильный</span>
<b>+3.8 / −3.8</b>
</div>
<div className="row-between small">
<span>Дуэль 1600 и 1400: победил слабый</span>
<b>+12.2 / −12.2</b>
</div>
<div className="row-between small">
<span>Равные, победа по целям без деталей</span>
<b>+8.0</b>
</div>
<div className="row-between small">
<span>Равные, победа по мирам без деталей</span>
<b>+6.8</b>
</div>
<div className="row-between small">
<span>Разгром на 3-м раунде (цели 2:0, миры 8:2)</span>
<b>+16.0</b>
</div>
<div className="row-between small">
<span>1-е место за столом на 5 равных</span>
<b>+11.0</b>
</div>
<div className="row-between small">
<span>Новичок обыграл равного опытного</span>
<b>+32 / −8</b>
</div> </div>
</div> </div>
<p className="muted small" style={{ marginBottom: 0 }}>
Рейтинг показывается целым числом, но считается без округления.
</p>
</div>
<div className="card">
<h3>Общий и групповой рейтинг</h3>
<p className="small" style={{ margin: 0 }}>
Общий рейтинг считается по всем партиям приложения, групповой — только по партиям
группы. Поэтому числа в общем топе и в топе группы различаются.
</p>
</div> </div>
<div className="card"> <div className="card">
@@ -91,6 +155,7 @@ export function HelpPage() {
<ul className="small" style={{ margin: 0, paddingLeft: 18 }}> <ul className="small" style={{ margin: 0, paddingLeft: 18 }}>
<li><b>Винрейт (WR)</b> — доля партий, в которых игрок занял 1-е место.</li> <li><b>Винрейт (WR)</b> — доля партий, в которых игрок занял 1-е место.</li>
<li><b>Среднее место</b> — среднее по завершённым партиям.</li> <li><b>Среднее место</b> — среднее по завершённым партиям.</li>
<li><b>Лучшая партия</b> в профиле — та, что принесла больше всего рейтинга.</li>
<li> <li>
При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту
и затем по нику. и затем по нику.
+189 -81
View File
@@ -4,13 +4,15 @@ import { useNavigate, useParams } from "react-router-dom";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
import { ConfirmDialog } from "../components/ConfirmDialog"; import { ConfirmDialog } from "../components/ConfirmDialog";
import { MatchMedia } from "../components/MatchMedia"; import { MatchMedia } from "../components/MatchMedia";
import { MatchOutcomeFields } from "../components/MatchOutcomeFields";
import { PickerSelect } from "../components/PickerSelect"; import { PickerSelect } from "../components/PickerSelect";
import { PlaceEditor } from "../components/PlaceEditor"; import { type CountField, type Counts, PlaceEditor } from "../components/PlaceEditor";
import { PlayerLink } from "../components/PlayerLink"; import { PlayerLink } from "../components/PlayerLink";
import { Spinner } from "../components/Spinner"; import { Spinner } from "../components/Spinner";
import { finishWarnings, type OutcomeSeat } from "../domain/finishWarnings";
import { formatDate, formatDuration, formatTime } from "../domain/format"; import { formatDate, formatDuration, formatTime } from "../domain/format";
import type { MatchFinishDraftData } from "../domain/types"; import type { MatchFinishDraftData, MatchRead } from "../domain/types";
import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons"; import { reasonForSurvivors, type WinReason, winReasonLabel } from "../domain/winReasons";
import { useToast } from "../context/ToastContext"; import { useToast } from "../context/ToastContext";
import { useMe } from "../hooks/auth"; import { useMe } from "../hooks/auth";
import { import {
@@ -24,7 +26,31 @@ import {
} from "../hooks/matches"; } from "../hooks/matches";
import { useGroupFactions } from "../hooks/reference"; import { useGroupFactions } from "../hooks/reference";
const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label })); type Participant = MatchRead["participants"][number];
const countsOf = (participants: Participant[]): Counts =>
Object.fromEntries(
participants.map((p) => [p.user_id, { objectives: p.objectives ?? null, worlds: p.worlds ?? null }]),
);
const countsFromDraft = (participants: Participant[], data: MatchFinishDraftData): Counts =>
Object.fromEntries(
participants.map((p) => [
p.user_id,
{
objectives: data.objectives?.[String(p.user_id)] ?? null,
worlds: data.worlds?.[String(p.user_id)] ?? null,
},
]),
);
// Черновик хранит только заполненные поля: {user_id строкой: число}.
const countDict = (counts: Counts, field: CountField): Record<string, number> =>
Object.fromEntries(
Object.entries(counts).flatMap(([uid, c]) => (c[field] == null ? [] : [[uid, c[field]]])),
);
const survivorsIn = (blocks: number[][]) => blocks.reduce((sum, ids) => sum + ids.length, 0);
export function MatchDetailPage() { export function MatchDetailPage() {
const { matchId } = useParams(); const { matchId } = useParams();
@@ -47,7 +73,10 @@ export function MatchDetailPage() {
const [blocks, setBlocks] = useState<number[][] | null>(null); const [blocks, setBlocks] = useState<number[][] | null>(null);
const [elim, setElim] = useState<number[]>([]); const [elim, setElim] = useState<number[]>([]);
const [comments, setComments] = useState<Record<number, string> | null>(null); const [comments, setComments] = useState<Record<number, string> | null>(null);
const [winReason, setWinReason] = useState<WinReason>("objectives"); const [counts, setCounts] = useState<Counts | null>(null);
// null — причина не выбрана: сброшена после «последнего выжившего» (см. reasonForSurvivors).
const [winReason, setWinReason] = useState<WinReason | null>("objectives");
const [endRound, setEndRound] = useState<number | null>(null);
const [overall, setOverall] = useState(""); const [overall, setOverall] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [confirmRemove, setConfirmRemove] = useState(false); const [confirmRemove, setConfirmRemove] = useState(false);
@@ -86,23 +115,26 @@ export function MatchDetailPage() {
// Чужой черновик применяем, только если человек сейчас ничего не двигает: // Чужой черновик применяем, только если человек сейчас ничего не двигает:
// иначе правка соседа перетёрла бы тайл прямо под рукой. // иначе правка соседа перетёрла бы тайл прямо под рукой.
const incoming = match?.finish_draft; const incoming = match?.finish_draft;
const participants = match?.participants;
useEffect(() => { useEffect(() => {
if (!incoming || match?.status !== "in_progress") return; if (!incoming || !participants || match?.status !== "in_progress") return;
if (incoming.updated_by === me?.id) return; if (incoming.updated_by === me?.id) return;
if (incoming.updated_at === appliedDraftAt.current) return; if (incoming.updated_at === appliedDraftAt.current) return;
if (Date.now() - lastLocalEdit.current < 1500) return; if (Date.now() - lastLocalEdit.current < 1500) return;
setBlocks(incoming.data.blocks); setBlocks(incoming.data.blocks);
setElim(incoming.data.eliminated); setElim(incoming.data.eliminated);
setComments(incoming.data.comments); setComments(incoming.data.comments);
setWinReason((incoming.data.win_reason ?? "objectives") as WinReason); setCounts(countsFromDraft(participants, incoming.data));
setWinReason(incoming.data.win_reason ?? null);
setEndRound(incoming.data.end_round ?? null);
setOverall(incoming.data.overall_comment ?? ""); setOverall(incoming.data.overall_comment ?? "");
appliedDraftAt.current = incoming.updated_at; appliedDraftAt.current = incoming.updated_at;
}, [incoming, match?.status, me?.id]); }, [incoming, participants, match?.status, me?.id]);
if (isLoading) return <Spinner />; if (isLoading) return <Spinner />;
if (!match) return <div className="muted">Партия не найдена.</div>; if (!match) return <div className="muted">Партия не найдена.</div>;
const canModify = !!match.can_modify; // авторитетный флаг с бэкенда (создатель/owner/admin) const canModify = !!match.can_modify; // авторитетный флаг с бэкенда (любой участник группы или админ)
const inProgress = match.status === "in_progress"; const inProgress = match.status === "in_progress";
// Ленивая инициализация раскладки из участников: каждый — отдельным блоком. // Ленивая инициализация раскладки из участников: каждый — отдельным блоком.
@@ -110,6 +142,7 @@ export function MatchDetailPage() {
const finishComments: Record<number, string> = const finishComments: Record<number, string> =
comments ?? comments ??
Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])); Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""]));
const finishCounts: Counts = counts ?? countsOf(match.participants);
// Конфликт версий (кто-то изменил партию с другого устройства) → сообщаем и обновляем. // Конфликт версий (кто-то изменил партию с другого устройства) → сообщаем и обновляем.
const isStale = (e: unknown) => e instanceof ApiError && e.code === "STALE_WRITE"; const isStale = (e: unknown) => e instanceof ApiError && e.code === "STALE_WRITE";
@@ -123,19 +156,64 @@ export function MatchDetailPage() {
), ),
win_reason: winReason, win_reason: winReason,
overall_comment: overall.trim() || null, overall_comment: overall.trim() || null,
end_round: endRound,
objectives: countDict(finishCounts, "objectives"),
worlds: countDict(finishCounts, "worlds"),
...patch, ...patch,
}); });
const submitFinish = async () => { // Раскладка изменилась: при одном выжившем причина становится «последний выживший»,
if (!id || !match) return; // при возврате второго — сбрасывается.
setError(null); const applyLayout = (b: number[][], e: number[]): WinReason | null => {
// Дожимаем отложенную запись: иначе последняя правка ушла бы в результаты, const reason = reasonForSurvivors(winReason, survivorsIn(b));
// но не в черновик, и второй участник увидел бы не то, что записалось. setBlocks(b);
if (draftTimer.current) { setElim(e);
clearTimeout(draftTimer.current); setWinReason(reason);
sendDraft(); return reason;
} };
const applyCount = (uid: number, field: CountField, value: number | null): Counts => {
const current = finishCounts[uid] ?? { objectives: null, worlds: null };
const next = { ...finishCounts, [uid]: { ...current, [field]: value } };
setCounts(next);
return next;
};
const outcomeSeats = (): OutcomeSeat[] => {
const byId = new Map(match.participants.map((p) => [p.user_id, p]));
const seat = (uid: number, place: number, eliminated: boolean): OutcomeSeat => ({
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 = () =>
finishWarnings({
seats: outcomeSeats(),
winReason,
endRound,
maxRounds: match.max_rounds,
});
// Строки результатов для API: места по блокам (competition ranking), затем выбывшие.
const resultRows = () => {
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null; 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; let place = 1;
const survivors = finishBlocks.flatMap((ids) => { const survivors = finishBlocks.flatMap((ids) => {
const rows = ids.map((uid) => ({ const rows = ids.map((uid) => ({
@@ -143,24 +221,41 @@ export function MatchDetailPage() {
place, place,
eliminated: false, eliminated: false,
comment: commentOf(uid), comment: commentOf(uid),
...countsFor(uid, false),
})); }));
place += ids.length; // competition ranking: ничья съедает следующие места place += ids.length; // competition ranking: ничья съедает следующие места
return rows; return rows;
}); });
try { const eliminated = elim.map((uid) => ({
await finish.mutateAsync({
matchId: id,
body: {
participants: [
...survivors,
...elim.map((uid) => ({
user_id: uid, user_id: uid,
place: null, place: null,
eliminated: true, eliminated: true,
comment: commentOf(uid), comment: commentOf(uid),
})), ...countsFor(uid, true),
], }));
return [...survivors, ...eliminated];
};
const submitFinish = async () => {
if (!id || !match) return;
setError(null);
if (!winReason) {
setError("Выберите причину победы.");
return;
}
// Дожимаем отложенную запись: иначе последняя правка ушла бы в результаты,
// но не в черновик, и второй участник увидел бы не то, что записалось.
if (draftTimer.current) {
clearTimeout(draftTimer.current);
sendDraft();
}
try {
await finish.mutateAsync({
matchId: id,
body: {
participants: resultRows(),
win_reason: winReason, win_reason: winReason,
end_round: endRound,
overall_comment: overall.trim() || null, overall_comment: overall.trim() || null,
expected_version: match.version, expected_version: match.version,
}, },
@@ -188,7 +283,9 @@ export function MatchDetailPage() {
setComments( setComments(
Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])), Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])),
); );
setWinReason((match.win_reason ?? "objectives") as WinReason); setCounts(countsOf(match.participants));
setWinReason((match.win_reason ?? null) as WinReason | null);
setEndRound(match.end_round ?? null);
setOverall(match.overall_comment ?? ""); setOverall(match.overall_comment ?? "");
setEditFactions(Object.fromEntries(match.participants.map((p) => [p.user_id, p.faction_id]))); setEditFactions(Object.fromEntries(match.participants.map((p) => [p.user_id, p.faction_id])));
setError(null); setError(null);
@@ -199,36 +296,31 @@ export function MatchDetailPage() {
setEditing(false); setEditing(false);
setBlocks(null); setBlocks(null);
setComments(null); setComments(null);
setCounts(null);
setError(null); setError(null);
}; };
const submitEdit = async () => { const submitEdit = async () => {
if (!id || !match) return; if (!id || !match) return;
setError(null); setError(null);
if (!winReason) {
setError("Выберите причину победы.");
return;
}
const wasRandom = Object.fromEntries( const wasRandom = Object.fromEntries(
match.participants.map((p) => [p.user_id, p.was_random]), match.participants.map((p) => [p.user_id, p.was_random]),
); );
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
const rowOf = (uid: number, place: number | null, eliminated: boolean) => ({
user_id: uid,
faction_id: editFactions[uid],
place,
eliminated,
was_random: wasRandom[uid] ?? false,
comment: commentOf(uid),
});
let place = 1;
const survivors = finishBlocks.flatMap((ids) => {
const rows = ids.map((uid) => rowOf(uid, place, false));
place += ids.length; // competition ranking: ничья съедает следующие места
return rows;
});
try { try {
await updateMatch.mutateAsync({ await updateMatch.mutateAsync({
matchId: id, matchId: id,
body: { body: {
participants: [...survivors, ...elim.map((uid) => rowOf(uid, null, true))], participants: resultRows().map((r) => ({
...r,
faction_id: editFactions[r.user_id],
was_random: wasRandom[r.user_id] ?? false,
})),
win_reason: winReason, win_reason: winReason,
end_round: endRound,
overall_comment: overall.trim() || null, overall_comment: overall.trim() || null,
expected_version: match.version, expected_version: match.version,
}, },
@@ -276,6 +368,13 @@ export function MatchDetailPage() {
.map((p) => ({ id: p.faction_id, code: "", name_ru: p.faction_name, expansion_id: 0 })), .map((p) => ({ id: p.faction_id, code: "", name_ru: p.faction_name, expansion_id: 0 })),
]; ];
const placeHint = (
<p className="muted small">
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого игрока, чтобы
разделить место (ничья). Цели и миры — на конец партии.
</p>
);
return ( return (
<div> <div>
<div className="card"> <div className="card">
@@ -294,6 +393,9 @@ export function MatchDetailPage() {
{!inProgress && ( {!inProgress && (
<div className="small" style={{ marginTop: 4 }}> <div className="small" style={{ marginTop: 4 }}>
Победа: <b>{winReasonLabel(match.win_reason)}</b> Победа: <b>{winReasonLabel(match.win_reason)}</b>
{match.end_round != null && (
<span className="muted"> · конец в {match.end_round}-м раунде из {match.max_rounds}</span>
)}
</div> </div>
)} )}
{match.overall_comment && <p className="small" style={{ marginTop: 6 }}>{match.overall_comment}</p>} {match.overall_comment && <p className="small" style={{ marginTop: 6 }}>{match.overall_comment}</p>}
@@ -304,20 +406,16 @@ export function MatchDetailPage() {
<> <>
<div className="card"> <div className="card">
<h3>Правка результатов</h3> <h3>Правка результатов</h3>
<p className="muted small"> {placeHint}
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
игрока, чтобы разделить место (ничья).
</p>
<PlaceEditor <PlaceEditor
players={match.participants} players={match.participants}
blocks={finishBlocks} blocks={finishBlocks}
eliminated={elim} eliminated={elim}
comments={finishComments} comments={finishComments}
onChange={(b, e) => { counts={finishCounts}
setBlocks(b); onChange={applyLayout}
setElim(e);
}}
onComment={(uid, text) => setComments({ ...finishComments, [uid]: text })} onComment={(uid, text) => setComments({ ...finishComments, [uid]: text })}
onCount={applyCount}
/> />
</div> </div>
@@ -345,16 +443,14 @@ export function MatchDetailPage() {
</div> </div>
</div> </div>
<div className="card"> <MatchOutcomeFields
<h3>Причина победы</h3> winReason={winReason}
<PickerSelect onReason={setWinReason}
selected={REASON_OPTIONS.find((o) => o.id === winReason) ?? null} endRound={endRound}
options={REASON_OPTIONS} onEndRound={setEndRound}
placeholder="— причина —" maxRounds={match.max_rounds}
renderOption={(o) => o.label} warnings={warnings()}
onPick={(o) => setWinReason(o.id)}
/> />
</div>
<div className="card"> <div className="card">
<h3>О партии</h3> <h3>О партии</h3>
@@ -413,6 +509,11 @@ export function MatchDetailPage() {
<span className="muted small">· {p.faction_name}</span> <span className="muted small">· {p.faction_name}</span>
{p.was_random && <span className="badge">🎲</span>} {p.was_random && <span className="badge">🎲</span>}
</PlayerLink> </PlayerLink>
{(p.objectives != null || p.worlds != null) && (
<div className="small muted" style={{ marginLeft: 32 }}>
цели {p.objectives ?? "—"} · миры {p.worlds ?? "—"}
</div>
)}
{p.comment && <div className="small muted" style={{ marginLeft: 32 }}>{p.comment}</div>} {p.comment && <div className="small muted" style={{ marginLeft: 32 }}>{p.comment}</div>}
</div> </div>
))} ))}
@@ -448,10 +549,7 @@ export function MatchDetailPage() {
<> <>
<div className="card"> <div className="card">
<h3>Места</h3> <h3>Места</h3>
<p className="muted small"> {placeHint}
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
игрока, чтобы разделить место (ничья).
</p>
{match.finish_draft && match.finish_draft.updated_by !== me?.id && ( {match.finish_draft && match.finish_draft.updated_by !== me?.id && (
<p className="small" style={{ color: "var(--accent-2)" }}> <p className="small" style={{ color: "var(--accent-2)" }}>
Результаты заполняет также {match.finish_draft.updated_by_nickname ?? "другой игрок"} Результаты заполняет также {match.finish_draft.updated_by_nickname ?? "другой игрок"}
@@ -464,10 +562,10 @@ export function MatchDetailPage() {
blocks={finishBlocks} blocks={finishBlocks}
eliminated={elim} eliminated={elim}
comments={finishComments} comments={finishComments}
counts={finishCounts}
onChange={(b, e) => { onChange={(b, e) => {
setBlocks(b); const reason = applyLayout(b, e);
setElim(e); queueDraft(draftOf({ blocks: b, eliminated: e, win_reason: reason }));
queueDraft(draftOf({ blocks: b, eliminated: e }));
}} }}
onComment={(uid, text) => { onComment={(uid, text) => {
const next = { ...finishComments, [uid]: text }; const next = { ...finishComments, [uid]: text };
@@ -480,23 +578,33 @@ export function MatchDetailPage() {
}), }),
); );
}} }}
/> onCount={(uid, field, value) => {
</div> const next = applyCount(uid, field, value);
queueDraft(
<div className="card"> draftOf({
<h3>Причина победы</h3> objectives: countDict(next, "objectives"),
<PickerSelect worlds: countDict(next, "worlds"),
selected={REASON_OPTIONS.find((o) => o.id === winReason) ?? null} }),
options={REASON_OPTIONS} );
placeholder="— причина —"
renderOption={(o) => o.label}
onPick={(o) => {
setWinReason(o.id);
queueDraft(draftOf({ win_reason: o.id }));
}} }}
/> />
</div> </div>
<MatchOutcomeFields
winReason={winReason}
onReason={(reason) => {
setWinReason(reason);
queueDraft(draftOf({ win_reason: reason }));
}}
endRound={endRound}
onEndRound={(round) => {
setEndRound(round);
queueDraft(draftOf({ end_round: round }));
}}
maxRounds={match.max_rounds}
warnings={warnings()}
/>
<div className="card"> <div className="card">
<h3>О партии</h3> <h3>О партии</h3>
<textarea <textarea
+4 -4
View File
@@ -6,7 +6,9 @@ import { Spinner } from "../components/Spinner";
import type { LeaderboardEntry } from "../domain/types"; import type { LeaderboardEntry } from "../domain/types";
import { useLeaderboard } from "../hooks/stats"; import { useLeaderboard } from "../hooks/stats";
type SortKey = "rank" | "games" | "wins" | "win_rate" | "score"; // Столбца побед нет: четырёхзначный рейтинг иначе не помещается в строку, а доля побед
// уже есть в WR.
type SortKey = "rank" | "games" | "win_rate" | "score";
function pct(v: number | null | undefined): string { function pct(v: number | null | undefined): string {
return v == null ? "—" : `${Math.round(v * 100)}%`; return v == null ? "—" : `${Math.round(v * 100)}%`;
@@ -50,7 +52,6 @@ export function OverallStatsPage() {
<span className="nick">{e.nickname}</span> <span className="nick">{e.nickname}</span>
</span> </span>
<span className="st-num">{e.games}</span> <span className="st-num">{e.games}</span>
<span className="st-num">{e.wins}</span>
<span className="st-num">{pct(e.win_rate)}</span> <span className="st-num">{pct(e.win_rate)}</span>
<span className={"st-num lb-score" + (isProvisional || e.score == null ? " provisional" : "")}> <span className={"st-num lb-score" + (isProvisional || e.score == null ? " provisional" : "")}>
{e.score ?? "—"} {e.score ?? "—"}
@@ -70,9 +71,8 @@ export function OverallStatsPage() {
<button onClick={() => toggleSort("rank")}>#{arrow("rank")}</button> <button onClick={() => toggleSort("rank")}>#{arrow("rank")}</button>
<span>Игрок</span> <span>Игрок</span>
<button onClick={() => toggleSort("games")}>Игр{arrow("games")}</button> <button onClick={() => toggleSort("games")}>Игр{arrow("games")}</button>
<button onClick={() => toggleSort("wins")}>Поб{arrow("wins")}</button>
<button onClick={() => toggleSort("win_rate")}>WR{arrow("win_rate")}</button> <button onClick={() => toggleSort("win_rate")}>WR{arrow("win_rate")}</button>
<button onClick={() => toggleSort("score")}>Очки{arrow("score")}</button> <button onClick={() => toggleSort("score")}>Рейтинг{arrow("score")}</button>
</div> </div>
{entries.length === 0 ? ( {entries.length === 0 ? (
<div className="muted small" style={{ paddingTop: 8 }}> <div className="muted small" style={{ paddingTop: 8 }}>
+112 -6
View File
@@ -5,7 +5,14 @@ import { ApiError } from "../../api/client";
import { MatchMedia } from "../../components/MatchMedia"; 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 { WIN_REASONS, type WinReason } from "../../domain/winReasons"; import { finishWarnings } from "../../domain/finishWarnings";
import {
LAST_STANDING,
reasonForSurvivors,
WIN_REASONS,
type WinReason,
winReasonLabel,
} from "../../domain/winReasons";
import { import {
useAdminDeleteAttachment, useAdminDeleteAttachment,
useAdminFactions, useAdminFactions,
@@ -23,8 +30,16 @@ interface Row {
eliminated: boolean; eliminated: boolean;
was_random: boolean; was_random: boolean;
comment: string; comment: string;
objectives: 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();
@@ -37,7 +52,8 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
const [rows, setRows] = useState<Row[]>([]); const [rows, setRows] = useState<Row[]>([]);
const [playedAt, setPlayedAt] = useState(""); const [playedAt, setPlayedAt] = useState("");
const [overall, setOverall] = useState(""); const [overall, setOverall] = useState("");
const [winReason, setWinReason] = useState<WinReason>("objectives"); const [winReason, setWinReason] = useState<WinReason | null>("objectives");
const [endRound, setEndRound] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -51,19 +67,32 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
eliminated: p.eliminated, eliminated: p.eliminated,
was_random: p.was_random, was_random: p.was_random,
comment: p.comment ?? "", comment: p.comment ?? "",
objectives: p.objectives ?? null,
worlds: p.worlds ?? null,
})), })),
); );
setPlayedAt(match.played_at); setPlayedAt(match.played_at);
setOverall(match.overall_comment ?? ""); setOverall(match.overall_comment ?? "");
if (match.win_reason) setWinReason(match.win_reason); setWinReason(match.win_reason ?? null);
setEndRound(match.end_round ?? null);
} }
}, [match]); }, [match]);
const upd = (i: number, patch: Partial<Row>) => const upd = (i: number, patch: Partial<Row>) => {
setRows((rs) => rs.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); const next = rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r));
setRows(next);
// Выбывание меняет число выживших: «последний выживший» ставится и снимается сам.
if ("eliminated" in patch) {
setWinReason(reasonForSurvivors(winReason, next.filter((r) => !r.eliminated).length));
}
};
const save = async () => { const save = async () => {
setError(null); setError(null);
if (!winReason) {
setError("Выберите причину победы.");
return;
}
try { try {
await update.mutateAsync({ await update.mutateAsync({
matchId, matchId,
@@ -71,6 +100,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
played_at: playedAt, played_at: playedAt,
overall_comment: overall.trim() || null, overall_comment: overall.trim() || null,
win_reason: winReason, win_reason: winReason,
end_round: endRound,
participants: rows.map((r) => ({ participants: rows.map((r) => ({
user_id: r.user_id, user_id: r.user_id,
faction_id: r.faction_id, faction_id: r.faction_id,
@@ -78,6 +108,8 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
eliminated: r.eliminated, eliminated: r.eliminated,
was_random: r.was_random, was_random: r.was_random,
comment: r.comment.trim() || null, comment: r.comment.trim() || null,
objectives: r.objectives,
worlds: r.eliminated ? null : r.worlds,
})), })),
}, },
}); });
@@ -87,6 +119,22 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
} }
}; };
const warnings = match
? finishWarnings({
seats: rows.map((r) => ({
userId: r.user_id,
nickname: r.nickname,
place: r.place,
eliminated: r.eliminated,
objectives: r.objectives,
worlds: r.eliminated ? 0 : r.worlds,
})),
winReason,
endRound,
maxRounds: match.max_rounds,
})
: [];
return ( return (
// Полноэкранная страница правки (не нижний «лист»): занимает весь экран. // Полноэкранная страница правки (не нижний «лист»): занимает весь экран.
<div <div
@@ -159,23 +207,81 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
выбыл выбыл
</label> </label>
</div> </div>
<div className="row">
<input <input
style={{ flex: 1 }}
placeholder="Комментарий об игроке" placeholder="Комментарий об игроке"
value={r.comment} value={r.comment}
onChange={(e) => upd(i, { comment: e.target.value })} onChange={(e) => upd(i, { comment: e.target.value })}
/> />
<input
style={{ flex: "0 0 70px" }}
type="number"
min={0}
max={99}
placeholder="цели"
title="Цели на конец партии"
value={r.objectives ?? ""}
onChange={(e) => upd(i, { objectives: parseCount(e.target.value) })}
/>
<input
style={{ flex: "0 0 70px" }}
type="number"
min={0}
max={99}
placeholder="миры"
title="Миры на конец партии"
disabled={r.eliminated}
value={r.eliminated ? 0 : (r.worlds ?? "")}
onChange={(e) => upd(i, { worlds: parseCount(e.target.value) })}
/>
</div>
</div> </div>
))} ))}
<div className="field"> <div className="field">
<label className="label">Причина победы</label> <label className="label">Причина победы</label>
<select value={winReason} onChange={(e) => setWinReason(e.target.value as WinReason)}> {winReason === LAST_STANDING ? (
<select value={LAST_STANDING} disabled>
<option value={LAST_STANDING}>{winReasonLabel(LAST_STANDING)}</option>
</select>
) : (
<select
value={winReason ?? ""}
onChange={(e) => setWinReason(e.target.value as WinReason)}
>
<option value="" disabled>
— выберите причину —
</option>
{WIN_REASONS.map((w) => ( {WIN_REASONS.map((w) => (
<option key={w.code} value={w.code}> <option key={w.code} value={w.code}>
{w.label} {w.label}
</option> </option>
))} ))}
</select> </select>
)}
</div>
<div className="field">
<label className="label">Раунд окончания (из {match.max_rounds})</label>
<select
value={endRound ?? ""}
onChange={(e) => setEndRound(e.target.value === "" ? null : Number(e.target.value))}
>
<option value="">не указан</option>
{Array.from({ length: match.max_rounds }, (_, k) => k + 1).map((n) => (
<option key={n} value={n}>
{n}-й
</option>
))}
</select>
{warnings.length > 0 && (
<ul className="warn-list">
{warnings.map((w) => (
<li key={w}>{w}</li>
))}
</ul>
)}
</div> </div>
<div className="field"> <div className="field">
+24
View File
@@ -371,6 +371,30 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
padding: 7px 10px; padding: 7px 10px;
border-radius: 8px; border-radius: 8px;
} }
/* Строка под игроком: комментарий + цели и миры на конец партии */
.rank-details { display: flex; gap: 6px; align-items: flex-end; }
.rank-details .rank-comment { flex: 1; min-width: 0; }
.rank-count { flex: 0 0 58px; margin-top: 8px; }
.rank-count span { display: block; font-size: 10px; color: var(--text-muted); text-align: center; }
.rank-count input {
width: 100%;
font-size: 13px;
padding: 7px 4px;
border-radius: 8px;
text-align: center;
}
/* Предупреждения о несогласованном вводе (не блокируют отправку) */
.warn-list {
margin: 10px 0 0;
padding: 8px 10px 8px 26px;
border: 1px solid var(--accent-2);
border-radius: var(--radius-sm);
background: rgba(240, 160, 75, 0.08);
color: var(--accent-2);
font-size: 13px;
}
.warn-list li + li { margin-top: 4px; }
.rank-unlink { .rank-unlink {
flex: none; flex: none;
width: 30px; height: 30px; width: 30px; height: 30px;
+1 -1
View File
@@ -115,7 +115,7 @@
/* Таблица общей статистики (сортируемая, кликабельные строки) */ /* Таблица общей статистики (сортируемая, кликабельные строки) */
.st-row, .st-head { .st-row, .st-head {
display: grid; display: grid;
grid-template-columns: 26px 1fr 40px 40px 48px 52px; grid-template-columns: 26px 1fr 40px 48px 60px;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 8px 2px; padding: 8px 2px;