mirror of
https://github.com/A-Minos/nonebot-plugin-tetris-stats.git
synced 2026-03-05 05:36:54 +08:00
* ✨ 适配 v1 tetrio 的 Trending * 🗃️ 添加 compare_delta 配置项 * 🗃️ 添加 TETRIOLeagueUserMap 索引表 * ✨ 添加对比时间配置项 * ✨ 添加 compare_delta 解析函数 * ✨ 添加 Trending 类的 compare 方法 * 🗃️ 移除不正确的复合索引 * ✨ 定时任务拉取tl数据时同步更新索引 * ✨ 适配 trending * 🐛 修复 find_entry 在无 uid 时的索引返回逻辑 * 📝 修正 compare_delta 迁移父迁移注释 * 🗃️ 为非 PostgreSQL 回填迁移补充外键约束 * 🔒 迁移中使用参数绑定设置 PG 内存参数 * ✨ 修正 Trends 的 vs 为 adpm * 🐛 修正获取玩家 ID 的范围
292 lines
10 KiB
Python
292 lines
10 KiB
Python
from asyncio import gather
|
|
from datetime import datetime, timedelta, timezone
|
|
from hashlib import md5
|
|
from typing import Literal, NamedTuple
|
|
|
|
from nonebot_plugin_orm import AsyncSession, get_session
|
|
from sqlalchemy import func, select
|
|
from yarl import URL
|
|
|
|
from ....utils.chart import get_split, get_value_bounds, handle_history_data
|
|
from ....utils.host import get_self_netloc
|
|
from ....utils.lang import get_lang
|
|
from ....utils.metrics import TetrisMetricsProWithPPSVS, get_metrics
|
|
from ....utils.render import render_image
|
|
from ....utils.render.schemas.base import Avatar, Trending
|
|
from ....utils.render.schemas.v1.base import History
|
|
from ....utils.render.schemas.v1.tetrio.info import Info, Multiplayer, Singleplayer, User
|
|
from ..api import Player
|
|
from ..api.models import TETRIOHistoricalData
|
|
from ..api.schemas.leaderboards.by import Entry, InvalidEntry
|
|
from ..api.schemas.summaries.league import LeagueSuccessModel, NeverRatedData, RatedData
|
|
from ..constant import TR_MAX, TR_MIN
|
|
from ..models import TETRIOLeagueHistorical, TETRIOLeagueUserMap, TETRIOUserUniqueIdentifier
|
|
from .tools import flow_to_history, get_league_data
|
|
|
|
UTC = timezone.utc
|
|
|
|
|
|
class Trends(NamedTuple):
|
|
pps: Trending = Trending.KEEP
|
|
apm: Trending = Trending.KEEP
|
|
adpm: Trending = Trending.KEEP
|
|
|
|
|
|
class HistoricalSnapshot(NamedTuple):
|
|
metrics: TetrisMetricsProWithPPSVS
|
|
delta: timedelta
|
|
|
|
|
|
async def get_nearest_historical(
|
|
session: AsyncSession,
|
|
unique_identifier: str,
|
|
target_time: datetime,
|
|
) -> HistoricalSnapshot | None:
|
|
before = await session.scalar(
|
|
select(TETRIOHistoricalData)
|
|
.where(
|
|
TETRIOHistoricalData.user_unique_identifier == unique_identifier,
|
|
TETRIOHistoricalData.api_type == 'league',
|
|
TETRIOHistoricalData.update_time <= target_time,
|
|
)
|
|
.order_by(TETRIOHistoricalData.update_time.desc())
|
|
.limit(1)
|
|
)
|
|
after = await session.scalar(
|
|
select(TETRIOHistoricalData)
|
|
.where(
|
|
TETRIOHistoricalData.user_unique_identifier == unique_identifier,
|
|
TETRIOHistoricalData.api_type == 'league',
|
|
TETRIOHistoricalData.update_time >= target_time,
|
|
)
|
|
.order_by(TETRIOHistoricalData.update_time.asc())
|
|
.limit(1)
|
|
)
|
|
candidates = [i for i in (before, after) if i is not None]
|
|
if not candidates:
|
|
return None
|
|
|
|
delta_seconds, selected = min(
|
|
(
|
|
abs((target_time - i.update_time.astimezone(UTC)).total_seconds()),
|
|
i,
|
|
)
|
|
for i in candidates
|
|
)
|
|
delta = timedelta(seconds=delta_seconds)
|
|
|
|
if not isinstance(selected.data, LeagueSuccessModel) or not isinstance(
|
|
selected.data.data, RatedData | NeverRatedData
|
|
):
|
|
return None
|
|
|
|
data = selected.data.data
|
|
return HistoricalSnapshot(get_metrics(pps=data.pps, apm=data.apm, vs=data.vs), delta)
|
|
|
|
|
|
async def _get_boundary_league_historical(
|
|
session: AsyncSession,
|
|
uid_id: int,
|
|
target_time: datetime,
|
|
*,
|
|
time_direction: Literal['before', 'after'],
|
|
) -> tuple[TETRIOLeagueUserMap, datetime] | None:
|
|
boundary_time = await session.scalar(
|
|
select((func.max if time_direction == 'before' else func.min)(TETRIOLeagueHistorical.update_time))
|
|
.select_from(TETRIOLeagueUserMap)
|
|
.join(TETRIOLeagueHistorical, TETRIOLeagueUserMap.hist_id == TETRIOLeagueHistorical.id)
|
|
.where(
|
|
TETRIOLeagueUserMap.uid_id == uid_id,
|
|
TETRIOLeagueHistorical.update_time <= target_time
|
|
if time_direction == 'before'
|
|
else TETRIOLeagueHistorical.update_time >= target_time,
|
|
)
|
|
)
|
|
if boundary_time is None:
|
|
return None
|
|
|
|
return (
|
|
(
|
|
await session.execute(
|
|
select(TETRIOLeagueUserMap, TETRIOLeagueHistorical.update_time)
|
|
.join(TETRIOLeagueHistorical, TETRIOLeagueUserMap.hist_id == TETRIOLeagueHistorical.id)
|
|
.where(
|
|
TETRIOLeagueUserMap.uid_id == uid_id,
|
|
TETRIOLeagueHistorical.update_time == boundary_time,
|
|
)
|
|
.order_by(TETRIOLeagueHistorical.id.desc())
|
|
.limit(1)
|
|
)
|
|
)
|
|
.tuples()
|
|
.first()
|
|
)
|
|
|
|
|
|
async def get_nearest_league_historical(
|
|
session: AsyncSession,
|
|
unique_identifier: str,
|
|
target_time: datetime,
|
|
) -> HistoricalSnapshot | None:
|
|
uid_id = await session.scalar(
|
|
select(TETRIOUserUniqueIdentifier.id).where(
|
|
TETRIOUserUniqueIdentifier.user_unique_identifier == unique_identifier
|
|
)
|
|
)
|
|
if uid_id is None:
|
|
return None
|
|
|
|
before = await _get_boundary_league_historical(
|
|
session,
|
|
uid_id,
|
|
target_time,
|
|
time_direction='before',
|
|
)
|
|
after = await _get_boundary_league_historical(
|
|
session,
|
|
uid_id,
|
|
target_time,
|
|
time_direction='after',
|
|
)
|
|
|
|
candidates = [i for i in (before, after) if i is not None]
|
|
if not candidates:
|
|
return None
|
|
delta_seconds, selected = min(
|
|
(
|
|
abs((target_time - i[1].astimezone(UTC)).total_seconds()),
|
|
i[0],
|
|
)
|
|
for i in candidates
|
|
)
|
|
delta = timedelta(seconds=delta_seconds)
|
|
|
|
historical = await session.get(TETRIOLeagueHistorical, selected.hist_id)
|
|
if historical is None or not isinstance(
|
|
(entry := find_entry(historical.data.data.entries, selected.entry_index, unique_identifier)), Entry
|
|
):
|
|
return None
|
|
return HistoricalSnapshot(get_metrics(pps=entry.league.pps, apm=entry.league.apm, vs=entry.league.vs), delta)
|
|
|
|
|
|
def find_entry(
|
|
entries: list[Entry | InvalidEntry],
|
|
entry_index: int,
|
|
unique_identifier: str | None = None,
|
|
) -> Entry | InvalidEntry | None:
|
|
if 0 <= entry_index < len(entries):
|
|
entry = entries[entry_index]
|
|
if unique_identifier is None or entry.id == unique_identifier:
|
|
return entry
|
|
if unique_identifier is None:
|
|
return None
|
|
for entry in entries:
|
|
if entry.id == unique_identifier:
|
|
return entry
|
|
return None
|
|
|
|
|
|
async def get_trends(player: Player, compare_delta: timedelta) -> Trends:
|
|
league = await player.league
|
|
if not isinstance(league.data, RatedData | NeverRatedData):
|
|
return Trends()
|
|
user = await player.user
|
|
|
|
async with get_session() as session:
|
|
target_time = (league.cache.cached_at - compare_delta).astimezone(UTC)
|
|
historical, league_historical = await gather(
|
|
get_nearest_historical(
|
|
session,
|
|
user.unique_identifier,
|
|
target_time,
|
|
),
|
|
get_nearest_league_historical(
|
|
session,
|
|
user.unique_identifier,
|
|
target_time,
|
|
),
|
|
)
|
|
selected = min((historical, league_historical), key=lambda x: x.delta if x is not None else timedelta.max)
|
|
if selected is None:
|
|
return Trends()
|
|
metrics = get_metrics(pps=league.data.pps, apm=league.data.apm, vs=league.data.vs)
|
|
return Trends(
|
|
pps=Trending.compare(selected.metrics.pps, metrics.pps),
|
|
apm=Trending.compare(selected.metrics.apm, metrics.apm),
|
|
adpm=Trending.compare(selected.metrics.adpm, metrics.adpm),
|
|
)
|
|
|
|
|
|
async def make_query_image_v1(player: Player, compare_delta: timedelta) -> bytes:
|
|
(
|
|
(user, user_info, league, sprint, blitz, leagueflow),
|
|
(avatar_revision,),
|
|
) = await gather(
|
|
gather(player.user, player.get_info(), player.league, player.sprint, player.blitz, player.get_leagueflow()),
|
|
gather(player.avatar_revision),
|
|
)
|
|
league_data = get_league_data(league, RatedData)
|
|
histories = flow_to_history(leagueflow, handle_history_data)
|
|
values = get_value_bounds([i.score for i in histories])
|
|
split_value, offset = get_split(values, TR_MAX, TR_MIN)
|
|
if sprint.data.record is not None:
|
|
duration = timedelta(milliseconds=sprint.data.record.results.stats.finaltime).total_seconds()
|
|
sprint_value = f'{duration:.3f}s' if duration < 60 else f'{duration // 60:.0f}m {duration % 60:.3f}s' # noqa: PLR2004
|
|
else:
|
|
sprint_value = 'N/A'
|
|
blitz_value = f'{blitz.data.record.results.stats.score:,}' if blitz.data.record is not None else 'N/A'
|
|
dsps: float
|
|
dspp: float
|
|
# make mypy happy
|
|
return await render_image(
|
|
Info(
|
|
user=User(
|
|
avatar=str(
|
|
URL(f'http://{get_self_netloc()}/host/resource/tetrio/avatars/{user.ID}')
|
|
% {'revision': avatar_revision}
|
|
)
|
|
if avatar_revision is not None and avatar_revision != 0
|
|
else Avatar(
|
|
type='identicon',
|
|
hash=md5(user.ID.encode()).hexdigest(), # noqa: S324
|
|
),
|
|
name=user.name.upper(),
|
|
bio=user_info.data.bio,
|
|
),
|
|
multiplayer=Multiplayer(
|
|
glicko=f'{round(league_data.glicko, 2):,}',
|
|
rd=round(league_data.rd, 2),
|
|
rank=league_data.rank,
|
|
tr=f'{round(league_data.tr, 2):,}',
|
|
global_rank=league_data.standing,
|
|
history=History(
|
|
data=histories,
|
|
split_interval=split_value,
|
|
min_value=values.value_min,
|
|
max_value=values.value_max,
|
|
offset=offset,
|
|
),
|
|
lpm=(metrics := get_metrics(pps=league_data.pps, apm=league_data.apm, vs=league_data.vs)).lpm,
|
|
pps=metrics.pps,
|
|
lpm_trending=(trends := (await get_trends(player, compare_delta))).pps,
|
|
apm=metrics.apm,
|
|
apl=metrics.apl,
|
|
apm_trending=trends.apm,
|
|
adpm=metrics.adpm,
|
|
vs=metrics.vs,
|
|
adpl=metrics.adpl,
|
|
adpm_trending=trends.adpm,
|
|
app=(app := (league_data.apm / (60 * league_data.pps))),
|
|
dsps=(dsps := ((league_data.vs / 100) - (league_data.apm / 60))),
|
|
dspp=(dspp := (dsps / league_data.pps)),
|
|
ci=150 * dspp - 125 * app + 50 * (league_data.vs / league_data.apm) - 25,
|
|
ge=2 * ((app * dsps) / league_data.pps),
|
|
),
|
|
singleplayer=Singleplayer(
|
|
sprint=sprint_value,
|
|
blitz=blitz_value,
|
|
),
|
|
lang=get_lang(),
|
|
),
|
|
)
|