Files
nonebot-plugin-tetris-stats/nonebot_plugin_tetris_stats/games/tetrio/query/v1.py
呵呵です ba0d1677cf 适配 Trending (#539)
*  适配 v1 tetrio 的 Trending

* 🗃️ 添加 compare_delta 配置项

* 🗃️ 添加 TETRIOLeagueUserMap 索引表

*  添加对比时间配置项

*  添加 compare_delta 解析函数

*  添加 Trending 类的 compare 方法

* 🗃️ 移除不正确的复合索引

*  定时任务拉取tl数据时同步更新索引

*  适配 trending

* 🐛 修复 find_entry 在无 uid 时的索引返回逻辑

* 📝 修正 compare_delta 迁移父迁移注释

* 🗃️ 为非 PostgreSQL 回填迁移补充外键约束

* 🔒 迁移中使用参数绑定设置 PG 内存参数

*  修正 Trends 的 vs 为 adpm

* 🐛 修正获取玩家 ID 的范围
2026-02-23 01:04:01 +08:00

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(),
),
)