适配 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,3 +1,5 @@
from datetime import timedelta
from arclet.alconna import Arg
from nonebot_plugin_alconna import Option, Subcommand
from nonebot_plugin_alconna.uniseg import UniMessage
@@ -9,6 +11,7 @@ from sqlalchemy import select
from ...db import trigger
from ...i18n import Lang
from ...utils.duration import parse_duration
from . import alc, command
from .constant import GAME_TYPE
from .models import TETRIOUserConfig
@@ -23,6 +26,12 @@ command.add(
alias=['-DT', 'DefaultTemplate'],
help_text='设置默认查询模板',
),
Option(
'--default-compare',
Arg('compare', parse_duration, notice='对比时间距离'),
alias=['-DC', 'DefaultCompare'],
help_text='设置默认对比时间距离',
),
help_text='TETR.IO 查询个性化配置',
),
)
@@ -35,18 +44,28 @@ alc.shortcut(
@alc.assign('TETRIO.config')
async def _(user: User, session: async_scoped_session, event_session: Uninfo, template: Template):
async def _(
user: User,
session: async_scoped_session,
event_session: Uninfo,
template: Template | None = None,
compare: timedelta | None = None,
):
async with trigger(
session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE,
command_type='config',
command_args=[f'--default-template {template}'],
command_args=([f'--default-template {template}'] if template is not None else [])
+ ([f'--default-compare {compare}'] if compare is not None else []),
):
config = (await session.scalars(select(TETRIOUserConfig).where(TETRIOUserConfig.id == user.id))).one_or_none()
if config is None:
config = TETRIOUserConfig(id=user.id, query_template=template)
config = TETRIOUserConfig(id=user.id, query_template=template or 'v1', compare_delta=compare)
session.add(config)
else:
config.query_template = template
if template is not None:
config.query_template = template
if compare is not None:
config.compare_delta = compare
await session.commit()
await UniMessage(Lang.bind.config_success()).finish()

View File

@@ -1,8 +1,8 @@
from datetime import datetime
from datetime import datetime, timedelta
from uuid import UUID
from nonebot_plugin_orm import Model
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy import DateTime, ForeignKey, Integer, Interval, String, UniqueConstraint
from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column, relationship
from ...db.models import PydanticType
@@ -16,6 +16,7 @@ class TETRIOUserConfig(MappedAsDataclass, Model):
id: Mapped[int] = mapped_column(primary_key=True)
query_template: Mapped[Template] = mapped_column(String(2))
compare_delta: Mapped[timedelta | None] = mapped_column(Interval(native=True), nullable=True)
class TETRIOLeagueStats(MappedAsDataclass, Model):
@@ -59,3 +60,21 @@ class TETRIOLeagueStatsField(MappedAsDataclass, Model):
high_vs: Mapped[Entry] = mapped_column(entry_type)
stats_id: Mapped[int] = mapped_column(ForeignKey('nb_t_io_tl_stats.id'), init=False)
stats: Mapped['TETRIOLeagueStats'] = relationship(back_populates='fields')
class TETRIOUserUniqueIdentifier(MappedAsDataclass, Model):
__tablename__ = 'nb_t_io_uid'
id: Mapped[int] = mapped_column(init=False, primary_key=True)
user_unique_identifier: Mapped[str] = mapped_column(String(24), unique=True, index=True)
class TETRIOLeagueUserMap(MappedAsDataclass, Model):
__tablename__ = 'nb_t_io_tl_map'
__table_args__ = (UniqueConstraint('uid_id', 'hist_id', name='uq_nb_t_io_tl_map_uid_hist'),)
id: Mapped[int] = mapped_column(init=False, primary_key=True)
stats_id: Mapped[int] = mapped_column(ForeignKey('nb_t_io_tl_stats.id'), index=True)
uid_id: Mapped[int] = mapped_column(ForeignKey('nb_t_io_uid.id'), index=True)
hist_id: Mapped[int] = mapped_column(ForeignKey('nb_t_io_tl_hist.id'))
entry_index: Mapped[int] = mapped_column(Integer)

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

View File

@@ -1,9 +1,9 @@
from collections import defaultdict
from collections.abc import Callable, Sequence
from collections.abc import Callable, Iterator, Sequence
from datetime import datetime, timedelta, timezone
from math import floor
from statistics import mean
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, TypeVar
from uuid import uuid4
from nonebot import get_driver
@@ -22,7 +22,13 @@ from ..api.schemas.base import P
from ..api.schemas.leaderboards import Parameter
from ..api.schemas.leaderboards.by import Entry
from ..constant import RANK_PERCENTILE
from ..models import TETRIOLeagueHistorical, TETRIOLeagueStats, TETRIOLeagueStatsField
from ..models import (
TETRIOLeagueHistorical,
TETRIOLeagueStats,
TETRIOLeagueStatsField,
TETRIOLeagueUserMap,
TETRIOUserUniqueIdentifier,
)
if TYPE_CHECKING:
from ..api.schemas.leaderboards.by import BySuccessModel
@@ -81,6 +87,14 @@ def find_special_player(
return sort(users, field)
T = TypeVar('T')
def _chunked(values: list[T], size: int) -> Iterator[list[T]]:
for i in range(0, len(values), size):
yield values[i : i + size]
@scheduler.scheduled_job('cron', hour='0,6,12,18', minute=0)
async def get_tetra_league_data() -> None:
x_session_id = uuid4()
@@ -94,9 +108,7 @@ async def get_tetra_league_data() -> None:
if len(model.data.entries) < 100: # 分页值 # noqa: PLR2004
break
players: list[Entry] = []
for result in results:
players.extend([i for i in result.data.entries if isinstance(i, Entry)])
players = [i for result in results for i in result.data.entries if isinstance(i, Entry)]
players.sort(key=lambda x: x.league.tr, reverse=True)
rank_player_mapping: defaultdict[Rank, list[Entry]] = defaultdict(list)
@@ -132,8 +144,37 @@ async def get_tetra_league_data() -> None:
]
stats.raw = historicals
stats.fields = fields
player_ids = {i.id for result in results for i in result.data.entries}
async with get_session() as session:
session.add(stats)
existing_ids: list[TETRIOUserUniqueIdentifier] = []
for chunk in _chunked(list(player_ids), 500):
existing_ids.extend(
(
await session.scalars(
select(TETRIOUserUniqueIdentifier).filter(
TETRIOUserUniqueIdentifier.user_unique_identifier.in_(chunk)
)
)
).all()
)
new_ids = [
TETRIOUserUniqueIdentifier(user_unique_identifier=i)
for i in player_ids - {i.user_unique_identifier for i in existing_ids}
]
session.add_all(new_ids)
await session.flush()
uid_mapping = {i.user_unique_identifier: i.id for i in list(existing_ids) + new_ids}
maps: list[TETRIOLeagueUserMap] = []
for i in stats.raw:
for index, entry in enumerate(i.data.data.entries):
maps.append(
TETRIOLeagueUserMap(
stats_id=stats.id, uid_id=uid_mapping[entry.id], hist_id=i.id, entry_index=index
)
)
session.add_all(maps)
maps.clear()
await session.commit()