Реализация новой рейтинговой системы (#23) #75
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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, "А", "Б", "В")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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, вывод воспроизводим:
|
||||||
|
|||||||
Vendored
+70
-15
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
{/* Любимая — личный выбор игрока в профиле; ниже — статистика по партиям. */}
|
{/* Любимая — личный выбор игрока в профиле; ниже — статистика по партиям. */}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту
|
При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту
|
||||||
и затем по нику.
|
и затем по нику.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user