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,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)
|
||||
|
||||
Reference in New Issue
Block a user