适配 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

@@ -0,0 +1,353 @@
"""add io tl map
迁移 ID: 3a294ff14610
父迁移: 6ecf383d646a
创建时间: 2026-01-28 03:25:40.714853
"""
from __future__ import annotations
import os
import re
import time
from typing import TYPE_CHECKING
import sqlalchemy as sa
from alembic import op
from nonebot.log import logger
from rich.progress import (
BarColumn,
MofNCompleteColumn,
Progress,
ProgressColumn,
Task,
TaskProgressColumn,
TextColumn,
TimeRemainingColumn,
filesize,
)
from rich.text import Text
from sqlalchemy import Connection, text
from sqlalchemy.ext.automap import automap_base
from sqlalchemy.orm import Session
from typing_extensions import override
if TYPE_CHECKING:
from collections.abc import Sequence
revision: str = '3a294ff14610'
down_revision: str | Sequence[str] | None = '6ecf383d646a'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
class RateColumn(ProgressColumn):
"""Renders human readable processing rate."""
@override
def render(self, task: Task) -> Text:
"""Render the speed in iterations per second."""
def calculate_speed() -> float | None:
now = time.monotonic()
if task.start_time is not None:
elapsed = (task.finished_time or now) - task.start_time
if elapsed > 0:
return task.completed / elapsed
return None
speed = task.finished_speed or task.speed or calculate_speed()
if speed is None:
return Text('', style='progress.percentage')
unit, suffix = filesize.pick_unit_and_suffix(
int(speed),
['', '×10³', '×10⁶', '×10⁹', '×10¹²'], # noqa: RUF001
1000,
)
data_speed = speed / unit
return Text(f'{data_speed:.1f}{suffix} it/s', style='progress.percentage')
def _backfill_postgresql(conn: Connection, chunk_size: int = 20000) -> None:
result = conn.execute(text('SELECT min(id), max(id) FROM nb_t_io_tl_hist')).one()
if result[0] is None or result[1] is None:
return
min_id, max_id = result
total = max_id - min_id + 1
logger.warning('PG backfill: Disabling foreign key constraints...')
work_mem = os.getenv('TETRIS_STATS_MIGRATION_WORK_MEM', '256MB')
if not re.fullmatch(r'\d+(kB|MB|GB)', work_mem):
work_mem = '256MB'
conn.execute(
text("SELECT set_config('work_mem', :work_mem, true)"),
{'work_mem': work_mem},
)
temp_buffers = os.getenv('TETRIS_STATS_MIGRATION_TEMP_BUFFERS', '128MB')
if not re.fullmatch(r'\d+(kB|MB|GB)', temp_buffers):
temp_buffers = '128MB'
conn.execute(
text("SELECT set_config('temp_buffers', :temp_buffers, true)"),
{'temp_buffers': temp_buffers},
)
conn.execute(text('SET LOCAL synchronous_commit = off'))
logger.warning('tetris_stats: PG backfill synchronous_commit=off')
logger.warning(f'tetris_stats: PG backfill work_mem={work_mem}')
logger.warning(f'tetris_stats: PG backfill temp_buffers={temp_buffers}')
conn.execute(text('SET LOCAL max_parallel_workers_per_gather = 8'))
conn.execute(text('SET LOCAL parallel_setup_cost = 10'))
conn.execute(text('SET LOCAL parallel_tuple_cost = 0.01'))
logger.warning('tetris_stats: PG backfill max_parallel_workers_per_gather=8')
logger.warning('tetris_stats: PG backfill parallel_setup_cost=10')
logger.warning('tetris_stats: PG backfill parallel_tuple_cost=0.01')
with Progress(
TextColumn('[progress.description]{task.description}'),
BarColumn(),
MofNCompleteColumn(),
TaskProgressColumn(),
RateColumn(),
TimeRemainingColumn(),
) as progress:
task = progress.add_task('生成索引...', total=total)
for start_id in range(min_id, max_id + 1, chunk_size):
end_id = min(start_id + chunk_size - 1, max_id)
conn.execute(
text(
"""
WITH entries AS (
SELECT
h.stats_id,
h.id AS hist_id,
e.ordinality - 1 AS entry_index,
COALESCE(e.entry->>'_id', e.entry->>'id') AS uid_str
FROM nb_t_io_tl_hist h
CROSS JOIN LATERAL jsonb_array_elements(h.data::jsonb->'data'->'entries')
WITH ORDINALITY AS e(entry, ordinality)
WHERE h.id BETWEEN :start_id AND :end_id
AND COALESCE(e.entry->>'_id', e.entry->>'id') IS NOT NULL
),
upserted_uids AS (
INSERT INTO nb_t_io_uid (user_unique_identifier)
SELECT DISTINCT uid_str FROM entries
ON CONFLICT (user_unique_identifier)
DO UPDATE SET user_unique_identifier = EXCLUDED.user_unique_identifier
RETURNING id, user_unique_identifier
)
INSERT INTO nb_t_io_tl_map (stats_id, uid_id, hist_id, entry_index)
SELECT e.stats_id, u.id, e.hist_id, e.entry_index
FROM entries e
JOIN upserted_uids u ON u.user_unique_identifier = e.uid_str
"""
),
{'start_id': start_id, 'end_id': end_id},
)
progress.update(task, advance=end_id - start_id + 1)
def _add_foreign_keys_postgresql(conn: Connection) -> None:
logger.warning('PG backfill: Re-adding foreign key constraints (validating)...')
conn.execute(
text("""
ALTER TABLE nb_t_io_tl_map
ADD CONSTRAINT fk_nb_t_io_tl_map_hist_id_nb_t_io_tl_hist
FOREIGN KEY (hist_id) REFERENCES nb_t_io_tl_hist(id)
NOT VALID
""")
)
conn.execute(
text("""
ALTER TABLE nb_t_io_tl_map
VALIDATE CONSTRAINT fk_nb_t_io_tl_map_hist_id_nb_t_io_tl_hist
""")
)
conn.execute(
text("""
ALTER TABLE nb_t_io_tl_map
ADD CONSTRAINT fk_nb_t_io_tl_map_stats_id_nb_t_io_tl_stats
FOREIGN KEY (stats_id) REFERENCES nb_t_io_tl_stats(id)
NOT VALID
""")
)
conn.execute(
text("""
ALTER TABLE nb_t_io_tl_map
VALIDATE CONSTRAINT fk_nb_t_io_tl_map_stats_id_nb_t_io_tl_stats
""")
)
conn.execute(
text("""
ALTER TABLE nb_t_io_tl_map
ADD CONSTRAINT fk_nb_t_io_tl_map_uid_id_nb_t_io_uid
FOREIGN KEY (uid_id) REFERENCES nb_t_io_uid(id)
NOT VALID
""")
)
conn.execute(
text("""
ALTER TABLE nb_t_io_tl_map
VALIDATE CONSTRAINT fk_nb_t_io_tl_map_uid_id_nb_t_io_uid
""")
)
logger.success('PG backfill: Foreign keys validated successfully')
def _backfill_generic(conn: Connection) -> None:
Base = automap_base() # noqa: N806
Base.prepare(autoload_with=conn)
Hist = Base.classes.nb_t_io_tl_hist # noqa: N806
Uid = Base.classes.nb_t_io_uid # noqa: N806
Map = Base.classes.nb_t_io_tl_map # noqa: N806
with Session(conn) as session:
count = session.query(Hist).count()
if count == 0:
return
logger.warning('tetris_stats: 正在生成 TETR.IO 玩家分页索引, 请不要关闭程序...')
uid_map: dict[str, int] = {}
def refresh_uid_map() -> None:
uids = session.query(Uid).all()
uid_map.clear()
uid_map.update({uid.user_unique_identifier: uid.id for uid in uids})
with Progress(
TextColumn('[progress.description]{task.description}'),
BarColumn(),
MofNCompleteColumn(),
TaskProgressColumn(),
RateColumn(),
TimeRemainingColumn(),
) as progress:
total = progress.add_task('生成索引...', total=count)
for hist in session.query(Hist).yield_per(1):
data = hist.data
if isinstance(data, str | bytes):
msg = 'io tl map migration requires json object data'
raise TypeError(msg)
entries = data.get('data', {}).get('entries', []) if isinstance(data, dict) else []
entry_info: list[tuple[str, int]] = []
for index, entry in enumerate(entries):
if isinstance(entry, dict):
uid = entry.get('_id')
if isinstance(uid, str):
entry_info.append((uid, index))
if not entry_info:
progress.update(total, advance=1)
continue
session.add_all([Uid(user_unique_identifier=uid) for uid, _ in entry_info if uid not in uid_map])
session.flush()
refresh_uid_map()
session.add_all(
[
Map(
stats_id=hist.stats_id,
uid_id=uid_map[uid],
hist_id=hist.id,
entry_index=index,
)
for uid, index in entry_info
]
)
session.flush()
progress.update(total, advance=1)
def backfill_mapping(conn: Connection) -> None:
if conn.dialect.name == 'postgresql':
logger.warning('tetris_stats: 检测到 PostgreSQL, 使用快速索引回填...')
_backfill_postgresql(conn)
_add_foreign_keys_postgresql(conn)
return
_backfill_generic(conn)
def upgrade(name: str = '') -> None:
if name:
return
conn = op.get_bind()
op.create_table(
'nb_t_io_uid',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_unique_identifier', sa.String(length=24), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_nb_t_io_uid')),
info={'bind_key': 'nonebot_plugin_tetris_stats'},
)
with op.batch_alter_table('nb_t_io_uid', schema=None) as batch_op:
batch_op.create_index(
batch_op.f('ix_nb_t_io_uid_user_unique_identifier'),
['user_unique_identifier'],
unique=True,
)
if conn.dialect.name == 'postgresql':
op.create_table(
'nb_t_io_tl_map',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('stats_id', sa.Integer(), nullable=False),
sa.Column('uid_id', sa.Integer(), nullable=False),
sa.Column('hist_id', sa.Integer(), nullable=False),
sa.Column('entry_index', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_nb_t_io_tl_map')),
sa.UniqueConstraint('uid_id', 'hist_id', name='uq_nb_t_io_tl_map_uid_hist'),
info={'bind_key': 'nonebot_plugin_tetris_stats'},
)
else:
op.create_table(
'nb_t_io_tl_map',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('stats_id', sa.Integer(), nullable=False),
sa.Column('uid_id', sa.Integer(), nullable=False),
sa.Column('hist_id', sa.Integer(), nullable=False),
sa.Column('entry_index', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
['stats_id'],
['nb_t_io_tl_stats.id'],
name=op.f('fk_nb_t_io_tl_map_stats_id_nb_t_io_tl_stats'),
),
sa.ForeignKeyConstraint(
['uid_id'],
['nb_t_io_uid.id'],
name=op.f('fk_nb_t_io_tl_map_uid_id_nb_t_io_uid'),
),
sa.ForeignKeyConstraint(
['hist_id'],
['nb_t_io_tl_hist.id'],
name=op.f('fk_nb_t_io_tl_map_hist_id_nb_t_io_tl_hist'),
),
sa.PrimaryKeyConstraint('id', name=op.f('pk_nb_t_io_tl_map')),
sa.UniqueConstraint('uid_id', 'hist_id', name='uq_nb_t_io_tl_map_uid_hist'),
info={'bind_key': 'nonebot_plugin_tetris_stats'},
)
backfill_mapping(conn)
with op.batch_alter_table('nb_t_io_tl_map', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_nb_t_io_tl_map_stats_id'), ['stats_id'], unique=False)
batch_op.create_index(batch_op.f('ix_nb_t_io_tl_map_uid_id'), ['uid_id'], unique=False)
def downgrade(name: str = '') -> None:
if name:
return
with op.batch_alter_table('nb_t_io_tl_map', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_nb_t_io_tl_map_uid_id'))
batch_op.drop_index(batch_op.f('ix_nb_t_io_tl_map_stats_id'))
op.drop_table('nb_t_io_tl_map')
with op.batch_alter_table('nb_t_io_uid', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_nb_t_io_uid_user_unique_identifier'))
op.drop_table('nb_t_io_uid')

View File

@@ -0,0 +1,53 @@
"""add compare delta config
迁移 ID: 6ecf383d646a
父迁移: 1c5346b657d4
创建时间: 2026-01-27 06:05:04.481654
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import sqlalchemy as sa
from alembic import op
if TYPE_CHECKING:
from collections.abc import Sequence
revision: str = '6ecf383d646a'
down_revision: str | Sequence[str] | None = '1c5346b657d4'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade(name: str = '') -> None:
if name:
return
op.create_table(
'nb_t_top_u_cfg',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('compare_delta', sa.Interval(), nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('pk_nb_t_top_u_cfg')),
info={'bind_key': 'nonebot_plugin_tetris_stats'},
)
op.create_table(
'nb_t_tos_u_cfg',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('compare_delta', sa.Interval(), nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('pk_nb_t_tos_u_cfg')),
info={'bind_key': 'nonebot_plugin_tetris_stats'},
)
with op.batch_alter_table('nb_t_io_u_cfg', schema=None) as batch_op:
batch_op.add_column(sa.Column('compare_delta', sa.Interval(), nullable=True))
def downgrade(name: str = '') -> None:
if name:
return
with op.batch_alter_table('nb_t_io_u_cfg', schema=None) as batch_op:
batch_op.drop_column('compare_delta')
op.drop_table('nb_t_tos_u_cfg')
op.drop_table('nb_t_top_u_cfg')

View File

@@ -1,7 +1,7 @@
from asyncio import Lock
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from enum import Enum, auto
from typing import TYPE_CHECKING, Literal, TypeVar, overload
@@ -11,6 +11,7 @@ from nonebot_plugin_orm import AsyncSession, get_session
from nonebot_plugin_user import User
from sqlalchemy import select
from ..utils.duration import DEFAULT_COMPARE_DELTA
from ..utils.typedefs import AllCommandType, BaseCommandType, GameType, TETRIOCommandType
from .models import Bind, TriggerHistoricalDataV2
@@ -18,8 +19,11 @@ UTC = timezone.utc
if TYPE_CHECKING:
from ..games.tetrio.api.models import TETRIOHistoricalData
from ..games.tetrio.models import TETRIOUserConfig
from ..games.top.api.models import TOPHistoricalData
from ..games.top.models import TOPUserConfig
from ..games.tos.api.models import TOSHistoricalData
from ..games.tos.models import TOSUserConfig
class BindStatus(Enum):
@@ -84,12 +88,12 @@ async def remove_bind(
return False
T = TypeVar('T', 'TETRIOHistoricalData', 'TOPHistoricalData', 'TOSHistoricalData')
T_HistoricalData = TypeVar('T_HistoricalData', 'TETRIOHistoricalData', 'TOPHistoricalData', 'TOSHistoricalData')
lock = Lock()
async def anti_duplicate_add(model: T) -> None:
async def anti_duplicate_add(model: T_HistoricalData) -> None:
async with lock, get_session() as session:
result = (
await session.scalars(
@@ -108,6 +112,19 @@ async def anti_duplicate_add(model: T) -> None:
await session.commit()
T_CONFIG = TypeVar('T_CONFIG', 'TETRIOUserConfig', 'TOPUserConfig', 'TOSUserConfig')
async def resolve_compare_delta(
config: type[T_CONFIG], session: AsyncSession, user_id: int, compare: timedelta | None
) -> timedelta:
return (
compare
or await session.scalar(select(config.compare_delta).where(config.id == user_id))
or DEFAULT_COMPARE_DELTA
)
@asynccontextmanager
@overload
async def trigger(

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

View File

@@ -1,6 +1,7 @@
from arclet.alconna import Arg, ArgFlag
from nonebot_plugin_alconna import Args, At, Subcommand
from nonebot_plugin_alconna import Args, At, Option, Subcommand
from ...utils.duration import parse_duration
from ...utils.exception import MessageFormatError
from ...utils.typedefs import Me
from .. import add_block_handlers, alc, command
@@ -33,6 +34,16 @@ command.add(
'unbind',
help_text='解除绑定 TOP 账号',
),
Subcommand(
'config',
Option(
'--default-compare',
Arg('compare', parse_duration, notice='对比时间距离'),
alias=['-DC', 'DefaultCompare'],
help_text='设置默认对比时间距离',
),
help_text='TOP 查询个性化配置',
),
Subcommand(
'query',
Args(
@@ -49,6 +60,12 @@ command.add(
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
),
),
Option(
'--compare',
Arg('compare', parse_duration),
alias=['-C'],
help_text='指定对比时间距离',
),
help_text='查询 TOP 游戏信息',
),
help_text='TOP 游戏相关指令',
@@ -70,7 +87,12 @@ alc.shortcut(
command='tstats TOP query',
humanized='top查',
)
alc.shortcut(
'(?i:top)(?i:配置|配|config)',
command='tstats TOP config',
humanized='top配置',
)
add_block_handlers(alc.assign('TOP.query'))
from . import bind, query, unbind # noqa: E402, F401
from . import bind, config, query, unbind # noqa: E402, F401

View File

@@ -0,0 +1,32 @@
from datetime import timedelta
from nonebot_plugin_alconna.uniseg import UniMessage
from nonebot_plugin_orm import async_scoped_session
from nonebot_plugin_uninfo import Uninfo
from nonebot_plugin_uninfo.orm import get_session_persist_id
from nonebot_plugin_user import User
from sqlalchemy import select
from ...db import trigger
from ...i18n import Lang
from . import alc
from .constant import GAME_TYPE
from .models import TOPUserConfig
@alc.assign('TOP.config')
async def _(user: User, session: async_scoped_session, event_session: Uninfo, compare: timedelta):
async with trigger(
session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE,
command_type='config',
command_args=[f'--default-compare {compare}'],
):
config = (await session.scalars(select(TOPUserConfig).where(TOPUserConfig.id == user.id))).one_or_none()
if config is None:
config = TOPUserConfig(id=user.id, compare_delta=compare)
session.add(config)
else:
config.compare_delta = compare
await session.commit()
await UniMessage(Lang.bind.config_success()).finish()

View File

@@ -0,0 +1,12 @@
from datetime import timedelta
from nonebot_plugin_orm import Model
from sqlalchemy import Interval
from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column
class TOPUserConfig(MappedAsDataclass, Model):
__tablename__ = 'nb_t_top_u_cfg'
id: Mapped[int] = mapped_column(primary_key=True)
compare_delta: Mapped[timedelta | None] = mapped_column(Interval(native=True), nullable=True)

View File

@@ -1,13 +1,17 @@
from datetime import datetime, timedelta, timezone
from nonebot.adapters import Event
from nonebot.matcher import Matcher
from nonebot_plugin_alconna import At
from nonebot_plugin_alconna.uniseg import Image, UniMessage
from nonebot_plugin_orm import get_session
from nonebot_plugin_orm import AsyncSession, get_session
from nonebot_plugin_uninfo import Uninfo
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.exception import FallbackError
from ...utils.lang import get_lang
@@ -20,17 +24,73 @@ from ...utils.render.schemas.v1.top.info import Info
from ...utils.typedefs import Me
from . import alc
from .api import Player
from .api.models import TOPHistoricalData
from .api.schemas.user_profile import Data, UserProfile
from .constant import GAME_TYPE
from .models import TOPUserConfig
UTC = timezone.utc
async def get_compare_profile(session: AsyncSession, user_name: str, target_time: datetime) -> UserProfile | None:
before = await session.scalar(
select(TOPHistoricalData)
.where(
TOPHistoricalData.user_unique_identifier == user_name,
TOPHistoricalData.api_type == 'User Profile',
TOPHistoricalData.update_time <= target_time,
)
.order_by(TOPHistoricalData.update_time.desc())
.limit(1)
)
after = await session.scalar(
select(TOPHistoricalData)
.where(
TOPHistoricalData.user_unique_identifier == user_name,
TOPHistoricalData.api_type == 'User Profile',
TOPHistoricalData.update_time >= target_time,
)
.order_by(TOPHistoricalData.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
def compare_metrics(
current: TetrisMetricsBasicWithLPM, compare: TetrisMetricsBasicWithLPM | None
) -> tuple[Trending, Trending]:
if compare is None:
return Trending.KEEP, Trending.KEEP
return Trending.compare(compare.lpm, current.lpm), Trending.compare(compare.apm, current.apm)
@alc.assign('TOP.query')
async def _(event: Event, matcher: Matcher, target: At | Me, event_session: Uninfo):
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=[],
command_args=[f'--compare {compare}'] if compare is not None else [],
):
async with get_session() as session:
bind = await query_bind_info(
@@ -40,17 +100,21 @@ async def _(event: Event, matcher: Matcher, target: At | Me, event_session: Unin
),
game_platform=GAME_TYPE,
)
if bind is None:
await matcher.finish(Lang.bind.not_found())
if bind is None:
await matcher.finish(Lang.bind.not_found())
compare_delta = await resolve_compare_delta(TOPUserConfig, session, user.id, compare)
player = Player(user_name=bind.game_account, trust=True)
profile = await player.get_profile()
compare_profile = await get_compare_profile(
session,
profile.user_name,
datetime.now(tz=UTC) - compare_delta,
)
await (
UniMessage.i18n(Lang.interaction.warning.unverified)
+ (
UniMessage('\n')
if not (
result := await make_query_result(
await Player(user_name=bind.game_account, trust=True).get_profile()
)
).has(Image)
if not (result := await make_query_result(profile, compare_profile)).has(Image)
else UniMessage()
)
+ result
@@ -58,14 +122,22 @@ async def _(event: Event, matcher: Matcher, target: At | Me, event_session: Unin
@alc.assign('TOP.query')
async def _(account: Player, event_session: Uninfo):
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=[],
command_args=[f'--compare {compare}'] if compare is not None else [],
):
await (await make_query_result(await account.get_profile())).finish()
async with get_session() as session:
compare_delta = await resolve_compare_delta(TOPUserConfig, session, user.id, compare)
profile = await account.get_profile()
compare_profile = await get_compare_profile(
session,
profile.user_name,
datetime.now(tz=UTC) - compare_delta,
)
await (await make_query_result(profile, compare_profile)).finish()
def get_avg_metrics(data: list[Data]) -> TetrisMetricsBasicWithLPM:
@@ -77,29 +149,33 @@ def get_avg_metrics(data: list[Data]) -> TetrisMetricsBasicWithLPM:
return get_metrics(lpm=total_lpm / num, apm=total_apm / num)
async def make_query_image(profile: UserProfile) -> bytes:
async def make_query_image(profile: UserProfile, compare: UserProfile | None) -> bytes:
if profile.today is None or profile.total is None:
raise FallbackError
today = get_metrics(lpm=profile.today.lpm, apm=profile.today.apm)
history = get_avg_metrics(profile.total)
compare_today = get_metrics(lpm=compare.today.lpm, apm=compare.today.apm) if compare and compare.today else None
compare_history = get_avg_metrics(compare.total) if compare is not None and compare.total is not None else None
today_lpm_trending, today_apm_trending = compare_metrics(today, compare_today)
history_lpm_trending, history_apm_trending = compare_metrics(history, compare_history)
return await render_image(
Info(
user=People(avatar=get_avatar(profile.user_name), name=profile.user_name),
today=InfoData(
pps=today.pps,
lpm=today.lpm,
lpm_trending=Trending.KEEP,
lpm_trending=today_lpm_trending,
apm=today.apm,
apl=today.apl,
apm_trending=Trending.KEEP,
apm_trending=today_apm_trending,
),
historical=InfoData(
pps=history.pps,
lpm=history.lpm,
lpm_trending=Trending.KEEP,
lpm_trending=history_lpm_trending,
apm=history.apm,
apl=history.apl,
apm_trending=Trending.KEEP,
apm_trending=history_apm_trending,
),
lang=get_lang(),
),
@@ -125,9 +201,9 @@ def make_query_text(profile: UserProfile) -> UniMessage:
return UniMessage(message)
async def make_query_result(profile: UserProfile) -> UniMessage:
async def make_query_result(profile: UserProfile, compare: UserProfile | None) -> UniMessage:
try:
return UniMessage.image(raw=await make_query_image(profile))
return UniMessage.image(raw=await make_query_image(profile, compare))
except FallbackError:
...
return make_query_text(profile)

View File

@@ -1,6 +1,7 @@
from arclet.alconna import Arg, ArgFlag
from nonebot_plugin_alconna import Args, At, Subcommand
from nonebot_plugin_alconna import Args, At, Option, Subcommand
from ...utils.duration import parse_duration
from ...utils.exception import MessageFormatError
from ...utils.typedefs import Me
from .. import add_block_handlers, alc, command
@@ -38,6 +39,16 @@ command.add(
'unbind',
help_text='解除绑定 TOS 账号',
),
Subcommand(
'config',
Option(
'--default-compare',
Arg('compare', parse_duration, notice='对比时间距离'),
alias=['-DC', 'DefaultCompare'],
help_text='设置默认对比时间距离',
),
help_text='茶服 查询个性化配置',
),
Subcommand(
'query',
Args(
@@ -54,6 +65,12 @@ command.add(
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
),
),
Option(
'--compare',
Arg('compare', parse_duration),
alias=['-C'],
help_text='指定对比时间距离',
),
help_text='查询 茶服 游戏信息',
),
help_text='茶服 游戏相关指令',
@@ -75,7 +92,12 @@ alc.shortcut(
command='tstats TOS query',
humanized='茶服查',
)
alc.shortcut(
'(?i:tos|茶服)(?i:配置|配|config)',
command='tstats TOS config',
humanized='茶服配置',
)
add_block_handlers(alc.assign('TOS.query'))
from . import bind, query, unbind # noqa: E402, F401
from . import bind, config, query, unbind # noqa: E402, F401

View File

@@ -0,0 +1,32 @@
from datetime import timedelta
from nonebot_plugin_alconna.uniseg import UniMessage
from nonebot_plugin_orm import async_scoped_session
from nonebot_plugin_uninfo import Uninfo
from nonebot_plugin_uninfo.orm import get_session_persist_id
from nonebot_plugin_user import User
from sqlalchemy import select
from ...db import trigger
from ...i18n import Lang
from . import alc
from .constant import GAME_TYPE
from .models import TOSUserConfig
@alc.assign('TOS.config')
async def _(user: User, session: async_scoped_session, event_session: Uninfo, compare: timedelta):
async with trigger(
session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE,
command_type='config',
command_args=[f'--default-compare {compare}'],
):
config = (await session.scalars(select(TOSUserConfig).where(TOSUserConfig.id == user.id))).one_or_none()
if config is None:
config = TOSUserConfig(id=user.id, compare_delta=compare)
session.add(config)
else:
config.compare_delta = compare
await session.commit()
await UniMessage(Lang.bind.config_success()).finish()

View File

@@ -0,0 +1,12 @@
from datetime import timedelta
from nonebot_plugin_orm import Model
from sqlalchemy import Interval
from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column
class TOSUserConfig(MappedAsDataclass, Model):
__tablename__ = 'nb_t_tos_u_cfg'
id: Mapped[int] = mapped_column(primary_key=True)
compare_delta: Mapped[timedelta | None] = mapped_column(Interval(native=True), nullable=True)

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)

View File

@@ -0,0 +1,28 @@
from datetime import timedelta
from .exception import MessageFormatError
DEFAULT_COMPARE_DELTA = timedelta(days=7)
_MIN_DURATION_LEN = 2
_DURATION_UNITS = {
'w': 'weeks',
'd': 'days',
'h': 'hours',
'm': 'minutes',
's': 'seconds',
}
def parse_duration(value: str) -> timedelta | MessageFormatError:
raw = value.strip().lower()
if raw.isdigit():
return timedelta(days=int(raw))
if len(raw) < _MIN_DURATION_LEN or not raw[:-1].isdigit():
return MessageFormatError('时间格式不正确')
amount = int(raw[:-1])
if amount <= 0:
return MessageFormatError('时间格式不正确')
unit = _DURATION_UNITS.get(raw[-1])
if unit is None:
return MessageFormatError('时间格式不正确')
return timedelta(**{unit: amount})

View File

@@ -41,3 +41,11 @@ class Trending(StrEnum):
UP = 'up'
KEEP = 'keep'
DOWN = 'down'
@classmethod
def compare(cls, old: float, new: float) -> 'Trending':
if old > new:
return cls.DOWN
if old < new:
return cls.UP
return cls.KEEP

View File

@@ -2,8 +2,21 @@ from typing import Literal, TypeAlias
Number: TypeAlias = float | int
GameType: TypeAlias = Literal['IO', 'TOP', 'TOS']
BaseCommandType: TypeAlias = Literal['bind', 'unbind', 'query']
TETRIOCommandType: TypeAlias = BaseCommandType | Literal['rank', 'config', 'list', 'record', 'verify']
BaseCommandType: TypeAlias = Literal[
'bind',
'config',
'query',
'unbind',
]
TETRIOCommandType: TypeAlias = (
BaseCommandType
| Literal[
'list',
'rank',
'record',
'verify',
]
)
AllCommandType: TypeAlias = BaseCommandType | TETRIOCommandType
Me: TypeAlias = Literal[
'',