适配 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,5 @@
from asyncio import gather
from collections.abc import Iterable
from datetime import datetime, timedelta, timezone
from http import HTTPStatus
from typing import Literal, NamedTuple
@@ -7,17 +8,18 @@ from zoneinfo import ZoneInfo
from nonebot.adapters import Event
from nonebot.matcher import Matcher
from nonebot_plugin_alconna import At
from nonebot_plugin_alconna.uniseg import UniMessage
from nonebot_plugin_orm import get_session
from nonebot_plugin_alconna.uniseg import Image, UniMessage
from nonebot_plugin_orm import AsyncSession, get_session
from nonebot_plugin_uninfo import Uninfo, User
from nonebot_plugin_uninfo.orm import get_session_persist_id
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.chart import get_split, get_value_bounds, handle_history_data
from ...utils.exception import RequestError
from ...utils.exception import FallbackError, RequestError
from ...utils.image import get_avatar
from ...utils.lang import get_lang
from ...utils.metrics import TetrisMetricsProWithLPMADPM, get_metrics
@@ -32,7 +34,12 @@ from . import alc
from .api import Player
from .api.models import TOSHistoricalData
from .api.schemas.user_info import UserInfoSuccess
from .api.schemas.user_profile import Data as UserProfileData
from .api.schemas.user_profile import UserProfile
from .constant import GAME_TYPE
from .models import TOSUserConfig
UTC = timezone.utc
def add_special_handlers(
@@ -40,16 +47,18 @@ def add_special_handlers(
) -> None:
@alc.assign('TOS.query')
async def _(
user: NBUser,
event: Event,
target: At | Me,
event_session: Uninfo,
compare: timedelta | None = None,
):
if isinstance(event, match_event):
async with trigger(
session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE,
command_type='query',
command_args=[],
command_args=[f'--compare {compare}'] if compare is not None else [],
):
player = Player(
teaid=f'{teaid_prefix}{target.target}'
@@ -58,16 +67,14 @@ def add_special_handlers(
trust=True,
)
try:
user_info, game_data = await gather(player.get_info(), get_game_data(player))
if game_data is not None:
await UniMessage.image(
raw=await make_query_image(
user_info,
game_data,
async with get_session() as session:
await (
await make_query_result(
player,
await resolve_compare_delta(TOSUserConfig, session, user.id, compare),
None if isinstance(target, At) else event_session.user,
)
).finish()
await make_query_text(user_info, game_data).finish()
except RequestError as e:
if e.status_code == HTTPStatus.BAD_REQUEST and '未找到此用户' in e.message:
return
@@ -106,58 +113,66 @@ except ImportError:
@alc.assign('TOS.query')
async def _(
async def _( # noqa: PLR0913
user: NBUser,
event: Event,
matcher: Matcher,
target: At | Me,
event_session: Uninfo,
compare: timedelta | None = None,
):
async with trigger(
session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE,
command_type='query',
command_args=[],
async with (
trigger(
session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE,
command_type='query',
command_args=[f'--compare {compare}'] if compare is not None else [],
),
get_session() as session,
):
async with get_session() as session:
bind = await query_bind_info(
session=session,
user=await get_user(
event_session.scope, target.target if isinstance(target, At) else event.get_user_id()
),
game_platform=GAME_TYPE,
)
bind = await query_bind_info(
session=session,
user=await get_user(event_session.scope, target.target if isinstance(target, At) else event.get_user_id()),
game_platform=GAME_TYPE,
)
if bind is None:
await matcher.finish(Lang.bind.not_found())
message = UniMessage.i18n(Lang.interaction.warning.unverified)
player = Player(teaid=bind.game_account, trust=True)
user_info, game_data = await gather(player.get_info(), get_game_data(player))
if game_data is not None:
await (
message
+ UniMessage.image(
raw=await make_query_image(
user_info,
game_data,
await (
UniMessage.i18n(Lang.interaction.warning.unverified)
+ (
UniMessage('\n')
if not (
result := await make_query_result(
player,
await resolve_compare_delta(TOSUserConfig, session, user.id, compare),
None if isinstance(target, At) else event_session.user,
)
)
).finish()
await (message + UniMessage('\n') + make_query_text(user_info, game_data)).finish()
).has(Image)
else UniMessage()
)
+ result
).finish()
@alc.assign('TOS.query')
async def _(account: Player, event_session: Uninfo):
async with trigger(
session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE,
command_type='query',
command_args=[],
async def _(user: NBUser, account: Player, event_session: Uninfo, compare: timedelta | None = None):
async with (
trigger(
session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE,
command_type='query',
command_args=[f'--compare {compare}'] if compare is not None else [],
),
get_session() as session,
):
user_info, game_data = await gather(account.get_info(), get_game_data(account))
await get_historical_data(user_info.data.teaid)
if game_data is not None:
await UniMessage.image(raw=await make_query_image(user_info, game_data, None)).finish()
await make_query_text(user_info, game_data).finish()
await (
await make_query_result(
account,
await resolve_compare_delta(TOSUserConfig, session, user.id, compare),
None,
)
).finish()
class GameData(NamedTuple):
@@ -168,48 +183,125 @@ class GameData(NamedTuple):
ge: Number
class GameAccumulator:
def __init__(self, target_num: int) -> None:
self._target_num = max(1, target_num)
self._weighted_total_lpm = 0.0
self._weighted_total_apm = 0.0
self._weighted_total_adpm = 0.0
self._total_time = 0.0
self._total_attack = 0
self._total_dig = 0
self._total_offset = 0
self._total_pieces = 0
self._total_receive = 0
self._num = 0
@property
def num(self) -> int:
return self._num
@property
def reached_target(self) -> bool:
return self._num >= self._target_num
def add(self, data: UserProfileData) -> bool:
# 排除单人局和时间为 0 的游戏
# 茶: 不计算没挖掘的局, 即使 apm 和 lpm 也如此
if data.num_players == 1 or data.time == 0:
return False
seconds = data.time / 1000
self._weighted_total_lpm += 24 * data.pieces
self._weighted_total_apm += 60 * data.attack
self._weighted_total_adpm += 60 * (data.attack + data.dig)
self._total_attack += data.attack
self._total_dig += data.dig
self._total_offset += data.offset
self._total_pieces += data.pieces
self._total_receive += data.receive
self._total_time += seconds
self._num += 1
return True
def to_game_data(self) -> GameData | None:
if self._num == 0 or self._total_time == 0:
return None
metrics = get_metrics(
lpm=self._weighted_total_lpm / self._total_time,
apm=self._weighted_total_apm / self._total_time,
adpm=self._weighted_total_adpm / self._total_time,
)
return GameData(
game_num=self._num,
metrics=metrics,
or_=self._total_offset / self._total_receive * 100 if self._total_receive else 0.0,
dspp=self._total_dig / self._total_pieces if self._total_pieces else 0.0,
ge=2 * ((self._total_attack * self._total_dig) / self._total_pieces**2) if self._total_pieces else 0.0,
)
def get_game_data_from_profile(profile: UserProfile, query_num: int = 50) -> GameData | None:
accumulator = GameAccumulator(query_num)
for row in profile.data:
if accumulator.reached_target:
break
accumulator.add(row)
return accumulator.to_game_data()
def get_game_data_from_profiles(profiles: Iterable[UserProfile], query_num: int = 50) -> GameData | None:
accumulator = GameAccumulator(query_num)
for profile in profiles:
for row in profile.data:
if accumulator.reached_target:
return accumulator.to_game_data()
accumulator.add(row)
return accumulator.to_game_data()
async def get_game_data(player: Player, query_num: int = 50) -> GameData | None:
"""获取游戏数据"""
user_profile = await player.get_profile()
if user_profile.data == []:
return None
weighted_total_lpm = weighted_total_apm = weighted_total_adpm = total_time = 0.0
total_attack = total_dig = total_offset = total_pieses = total_receive = num = 0
for i in user_profile.data:
# 排除单人局和时间为0的游戏
# 茶: 不计算没挖掘的局, 即使apm和lpm也如此
if i.num_players == 1 or i.time == 0:
continue
# 加权计算
time = i.time / 1000
lpm = 24 * (i.pieces / time)
apm = (i.attack / time) * 60
adpm = ((i.attack + i.dig) / time) * 60
weighted_total_lpm += lpm * time
weighted_total_apm += apm * time
weighted_total_adpm += adpm * time
total_attack += i.attack
total_dig += i.dig
total_offset += i.offset
total_pieses += i.pieces
total_receive += i.receive
total_time += time
num += 1
if num >= query_num:
break
if num == 0:
return None
# TODO)) 如果有效局数小于 {查询数} , 并且没有无dig信息的局, 且 user_profile.data 内有{请求数}个局, 则继续往前获取信息
metrics = get_metrics(
lpm=weighted_total_lpm / total_time, apm=weighted_total_apm / total_time, adpm=weighted_total_adpm / total_time
return get_game_data_from_profile(user_profile, query_num)
async def get_compare_profile(
session: AsyncSession, unique_identifier: str, target_time: datetime
) -> UserProfile | None:
before = await session.scalar(
select(TOSHistoricalData)
.where(
TOSHistoricalData.user_unique_identifier == unique_identifier,
TOSHistoricalData.api_type == 'User Profile',
TOSHistoricalData.update_time <= target_time,
)
.order_by(TOSHistoricalData.update_time.desc())
.limit(1)
)
return GameData(
game_num=num,
metrics=metrics,
or_=total_offset / total_receive * 100,
dspp=total_dig / total_pieses,
ge=2 * ((total_attack * total_dig) / total_pieses**2),
after = await session.scalar(
select(TOSHistoricalData)
.where(
TOSHistoricalData.user_unique_identifier == unique_identifier,
TOSHistoricalData.api_type == 'User Profile',
TOSHistoricalData.update_time >= target_time,
)
.order_by(TOSHistoricalData.update_time.asc())
.limit(1)
)
if before is None:
selected = after
elif after is None:
selected = before
else:
selected = (
before
if abs((target_time - before.update_time).total_seconds())
<= abs((target_time - after.update_time).total_seconds())
else after
)
if selected is None or not isinstance(selected.data, UserProfile):
return None
return selected.data
@time_it
@@ -249,8 +341,41 @@ async def get_historical_data(unique_identifier: str) -> list[HistoryData]:
]
async def make_query_image(user_info: UserInfoSuccess, game_data: GameData, event_user_info: User | None) -> bytes:
class Trends(NamedTuple):
lpm: Trending = Trending.KEEP
apm: Trending = Trending.KEEP
adpm: Trending = Trending.KEEP
async def get_trends(player: Player, compare_delta: timedelta) -> Trends:
game_data = await get_game_data(player)
if game_data is None:
raise FallbackError
async with get_session() as session:
compare_profile = await get_compare_profile(
session,
(await player.user).teaid,
datetime.now(tz=UTC) - compare_delta,
)
if compare_profile is None or (old_game_data := get_game_data_from_profile(compare_profile)) is None:
raise FallbackError
return Trends(
lpm=Trending.compare(old_game_data.metrics.lpm, game_data.metrics.lpm),
apm=Trending.compare(old_game_data.metrics.apm, game_data.metrics.apm),
adpm=Trending.compare(old_game_data.metrics.adpm, game_data.metrics.adpm),
)
async def make_query_image(
player: Player,
compare_delta: timedelta,
event_user_info: User | None,
) -> bytes:
user_info, game_data = await gather(player.get_info(), get_game_data(player))
if game_data is None:
raise FallbackError
metrics = game_data.metrics
trends = await get_trends(player, compare_delta)
sprint_value = (
(
f'{duration:.3f}s'
@@ -282,14 +407,14 @@ async def make_query_image(user_info: UserInfoSuccess, game_data: GameData, even
rd=round(float(user_info.data.rd_now), 2),
lpm=metrics.lpm,
pps=metrics.pps,
lpm_trending=Trending.KEEP,
lpm_trending=trends.lpm,
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 := (metrics.apm / (60 * metrics.pps))),
or_=game_data.or_,
dspp=game_data.dspp,
@@ -306,7 +431,8 @@ async def make_query_image(user_info: UserInfoSuccess, game_data: GameData, even
)
def make_query_text(user_info: UserInfoSuccess, game_data: GameData | None) -> UniMessage:
async def make_query_text(player: Player) -> UniMessage:
user_info, game_data = await gather(player.get_info(), get_game_data(player))
user_data = user_info.data
message = Lang.stats.user_info(name=user_data.name, id=user_data.teaid)
if user_data.ranked_games == '0':
@@ -331,3 +457,11 @@ def make_query_text(user_info: UserInfoSuccess, game_data: GameData | None) -> U
if user_data.pb_challenge != '0':
message += Lang.stats.challenge_pb(score=user_data.pb_challenge)
return UniMessage(message)
async def make_query_result(player: Player, compare_delta: timedelta, event_user_info: User | None) -> UniMessage:
try:
return UniMessage.image(raw=await make_query_image(player, compare_delta, event_user_info))
except FallbackError:
...
return await make_query_text(player)