mirror of
https://github.com/A-Minos/nonebot-plugin-tetris-stats.git
synced 2026-03-05 05:36:54 +08:00
✨ 适配 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:
@@ -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()
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user