适配 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 的范围
This commit is contained in:
呵呵です
2026-02-23 01:04:01 +08:00
committed by GitHub
parent 14f3e6960e
commit ba0d1677cf
19 changed files with 1267 additions and 149 deletions

View File

@@ -1,4 +1,4 @@
from datetime import timezone
from datetime import timedelta, timezone
from arclet.alconna import Arg, ArgFlag
from nonebot import get_driver
@@ -13,8 +13,9 @@ from nonebot_plugin_user import User as NBUser
from nonebot_plugin_user import get_user
from sqlalchemy import select
from ....db import query_bind_info, trigger
from ....db import query_bind_info, resolve_compare_delta, trigger
from ....i18n import Lang
from ....utils.duration import parse_duration
from ....utils.exception import FallbackError
from ....utils.typedefs import Me
from ... import add_block_handlers, alc
@@ -53,6 +54,12 @@ command.add(
alias=['-T'],
help_text='要使用的查询模板',
),
Option(
'--compare',
Arg('compare', parse_duration),
alias=['-C'],
help_text='指定对比时间距离',
),
help_text='查询 TETR.IO 游戏信息',
),
)
@@ -73,10 +80,10 @@ alc.shortcut(
add_block_handlers(alc.assign('TETRIO.query'))
async def make_query_result(player: Player, template: Template) -> UniMessage:
async def make_query_result(player: Player, template: Template, compare_delta: timedelta) -> UniMessage:
if template == 'v1':
try:
return UniMessage.image(raw=await make_query_image_v1(player))
return UniMessage.image(raw=await make_query_image_v1(player, compare_delta))
except FallbackError:
template = 'v2'
if template == 'v2':
@@ -92,12 +99,18 @@ async def _( # noqa: PLR0913
target: At | Me,
event_session: Uninfo,
template: Template | None = None,
compare: timedelta | None = None,
):
command_args: list[str] = []
if template is not None:
command_args.append(f'--template {template}')
if compare is not None:
command_args.append(f'--compare {compare}')
async with trigger(
session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE,
command_type='query',
command_args=[f'--template {template}'] if template is not None else [],
command_args=command_args,
):
async with get_session() as session:
bind = await query_bind_info(
@@ -111,6 +124,7 @@ async def _( # noqa: PLR0913
template = await session.scalar(
select(TETRIOUserConfig.query_template).where(TETRIOUserConfig.id == user.id)
)
compare_delta = await resolve_compare_delta(TETRIOUserConfig, session, user.id, compare)
if bind is None:
await matcher.finish(Lang.bind.not_found())
player = Player(user_id=bind.game_account, trust=True)
@@ -118,7 +132,7 @@ async def _( # noqa: PLR0913
UniMessage.i18n(Lang.interaction.warning.unverified)
+ (
UniMessage('\n')
if not (result := await make_query_result(player, template or 'v1')).has(Image)
if not (result := await make_query_result(player, template or 'v1', compare_delta)).has(Image)
else UniMessage()
)
+ result
@@ -126,16 +140,28 @@ async def _( # noqa: PLR0913
@alc.assign('TETRIO.query')
async def _(user: NBUser, account: Player, event_session: Uninfo, template: Template | None = None):
async def _(
user: NBUser,
account: Player,
event_session: Uninfo,
template: Template | None = None,
compare: timedelta | None = None,
):
command_args: list[str] = []
if template is not None:
command_args.append(f'--template {template}')
if compare is not None:
command_args.append(f'--compare {compare}')
async with trigger(
session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE,
command_type='query',
command_args=[f'--template {template}'] if template is not None else [],
command_args=command_args,
):
async with get_session() as session:
if template is None:
template = await session.scalar(
select(TETRIOUserConfig.query_template).where(TETRIOUserConfig.id == user.id)
)
await (await make_query_result(account, template or 'v1')).finish()
compare_delta = await resolve_compare_delta(TETRIOUserConfig, session, user.id, compare)
await (await make_query_result(account, template or 'v1', compare_delta)).finish()

View File

@@ -1,24 +1,223 @@
from asyncio import gather
from datetime import timedelta
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 get_metrics
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.schemas.summaries.league import RatedData
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
async def make_query_image_v1(player: Player) -> bytes:
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,),
@@ -69,14 +268,14 @@ async def make_query_image_v1(player: Player) -> bytes:
),
lpm=(metrics := get_metrics(pps=league_data.pps, apm=league_data.apm, vs=league_data.vs)).lpm,
pps=metrics.pps,
lpm_trending=Trending.KEEP,
lpm_trending=(trends := (await get_trends(player, compare_delta))).pps,
apm=metrics.apm,
apl=metrics.apl,
apm_trending=Trending.KEEP,
apm_trending=trends.apm,
adpm=metrics.adpm,
vs=metrics.vs,
adpl=metrics.adpl,
adpm_trending=Trending.KEEP,
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)),