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

View File

@@ -1,3 +1,5 @@
from datetime import timedelta
from arclet.alconna import Arg from arclet.alconna import Arg
from nonebot_plugin_alconna import Option, Subcommand from nonebot_plugin_alconna import Option, Subcommand
from nonebot_plugin_alconna.uniseg import UniMessage from nonebot_plugin_alconna.uniseg import UniMessage
@@ -9,6 +11,7 @@ from sqlalchemy import select
from ...db import trigger from ...db import trigger
from ...i18n import Lang from ...i18n import Lang
from ...utils.duration import parse_duration
from . import alc, command from . import alc, command
from .constant import GAME_TYPE from .constant import GAME_TYPE
from .models import TETRIOUserConfig from .models import TETRIOUserConfig
@@ -23,6 +26,12 @@ command.add(
alias=['-DT', 'DefaultTemplate'], alias=['-DT', 'DefaultTemplate'],
help_text='设置默认查询模板', help_text='设置默认查询模板',
), ),
Option(
'--default-compare',
Arg('compare', parse_duration, notice='对比时间距离'),
alias=['-DC', 'DefaultCompare'],
help_text='设置默认对比时间距离',
),
help_text='TETR.IO 查询个性化配置', help_text='TETR.IO 查询个性化配置',
), ),
) )
@@ -35,18 +44,28 @@ alc.shortcut(
@alc.assign('TETRIO.config') @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( async with trigger(
session_persist_id=await get_session_persist_id(event_session), session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE, game_platform=GAME_TYPE,
command_type='config', 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() config = (await session.scalars(select(TETRIOUserConfig).where(TETRIOUserConfig.id == user.id))).one_or_none()
if config is 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) session.add(config)
else: 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 session.commit()
await UniMessage(Lang.bind.config_success()).finish() 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 uuid import UUID
from nonebot_plugin_orm import Model 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 sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column, relationship
from ...db.models import PydanticType from ...db.models import PydanticType
@@ -16,6 +16,7 @@ class TETRIOUserConfig(MappedAsDataclass, Model):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
query_template: Mapped[Template] = mapped_column(String(2)) query_template: Mapped[Template] = mapped_column(String(2))
compare_delta: Mapped[timedelta | None] = mapped_column(Interval(native=True), nullable=True)
class TETRIOLeagueStats(MappedAsDataclass, Model): class TETRIOLeagueStats(MappedAsDataclass, Model):
@@ -59,3 +60,21 @@ class TETRIOLeagueStatsField(MappedAsDataclass, Model):
high_vs: Mapped[Entry] = mapped_column(entry_type) high_vs: Mapped[Entry] = mapped_column(entry_type)
stats_id: Mapped[int] = mapped_column(ForeignKey('nb_t_io_tl_stats.id'), init=False) stats_id: Mapped[int] = mapped_column(ForeignKey('nb_t_io_tl_stats.id'), init=False)
stats: Mapped['TETRIOLeagueStats'] = relationship(back_populates='fields') 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 arclet.alconna import Arg, ArgFlag
from nonebot import get_driver 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 nonebot_plugin_user import get_user
from sqlalchemy import select 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 ....i18n import Lang
from ....utils.duration import parse_duration
from ....utils.exception import FallbackError from ....utils.exception import FallbackError
from ....utils.typedefs import Me from ....utils.typedefs import Me
from ... import add_block_handlers, alc from ... import add_block_handlers, alc
@@ -53,6 +54,12 @@ command.add(
alias=['-T'], alias=['-T'],
help_text='要使用的查询模板', help_text='要使用的查询模板',
), ),
Option(
'--compare',
Arg('compare', parse_duration),
alias=['-C'],
help_text='指定对比时间距离',
),
help_text='查询 TETR.IO 游戏信息', help_text='查询 TETR.IO 游戏信息',
), ),
) )
@@ -73,10 +80,10 @@ alc.shortcut(
add_block_handlers(alc.assign('TETRIO.query')) 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': if template == 'v1':
try: 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: except FallbackError:
template = 'v2' template = 'v2'
if template == 'v2': if template == 'v2':
@@ -92,12 +99,18 @@ async def _( # noqa: PLR0913
target: At | Me, target: At | Me,
event_session: Uninfo, event_session: Uninfo,
template: Template | None = None, 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( async with trigger(
session_persist_id=await get_session_persist_id(event_session), session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE, game_platform=GAME_TYPE,
command_type='query', command_type='query',
command_args=[f'--template {template}'] if template is not None else [], command_args=command_args,
): ):
async with get_session() as session: async with get_session() as session:
bind = await query_bind_info( bind = await query_bind_info(
@@ -111,6 +124,7 @@ async def _( # noqa: PLR0913
template = await session.scalar( template = await session.scalar(
select(TETRIOUserConfig.query_template).where(TETRIOUserConfig.id == user.id) select(TETRIOUserConfig.query_template).where(TETRIOUserConfig.id == user.id)
) )
compare_delta = await resolve_compare_delta(TETRIOUserConfig, session, user.id, compare)
if bind is None: if bind is None:
await matcher.finish(Lang.bind.not_found()) await matcher.finish(Lang.bind.not_found())
player = Player(user_id=bind.game_account, trust=True) player = Player(user_id=bind.game_account, trust=True)
@@ -118,7 +132,7 @@ async def _( # noqa: PLR0913
UniMessage.i18n(Lang.interaction.warning.unverified) UniMessage.i18n(Lang.interaction.warning.unverified)
+ ( + (
UniMessage('\n') 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() else UniMessage()
) )
+ result + result
@@ -126,16 +140,28 @@ async def _( # noqa: PLR0913
@alc.assign('TETRIO.query') @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( async with trigger(
session_persist_id=await get_session_persist_id(event_session), session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE, game_platform=GAME_TYPE,
command_type='query', command_type='query',
command_args=[f'--template {template}'] if template is not None else [], command_args=command_args,
): ):
async with get_session() as session: async with get_session() as session:
if template is None: if template is None:
template = await session.scalar( template = await session.scalar(
select(TETRIOUserConfig.query_template).where(TETRIOUserConfig.id == user.id) 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 asyncio import gather
from datetime import timedelta from datetime import datetime, timedelta, timezone
from hashlib import md5 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 yarl import URL
from ....utils.chart import get_split, get_value_bounds, handle_history_data from ....utils.chart import get_split, get_value_bounds, handle_history_data
from ....utils.host import get_self_netloc from ....utils.host import get_self_netloc
from ....utils.lang import get_lang 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 import render_image
from ....utils.render.schemas.base import Avatar, Trending from ....utils.render.schemas.base import Avatar, Trending
from ....utils.render.schemas.v1.base import History from ....utils.render.schemas.v1.base import History
from ....utils.render.schemas.v1.tetrio.info import Info, Multiplayer, Singleplayer, User from ....utils.render.schemas.v1.tetrio.info import Info, Multiplayer, Singleplayer, User
from ..api import Player 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 ..constant import TR_MAX, TR_MIN
from ..models import TETRIOLeagueHistorical, TETRIOLeagueUserMap, TETRIOUserUniqueIdentifier
from .tools import flow_to_history, get_league_data 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), (user, user_info, league, sprint, blitz, leagueflow),
(avatar_revision,), (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, lpm=(metrics := get_metrics(pps=league_data.pps, apm=league_data.apm, vs=league_data.vs)).lpm,
pps=metrics.pps, pps=metrics.pps,
lpm_trending=Trending.KEEP, lpm_trending=(trends := (await get_trends(player, compare_delta))).pps,
apm=metrics.apm, apm=metrics.apm,
apl=metrics.apl, apl=metrics.apl,
apm_trending=Trending.KEEP, apm_trending=trends.apm,
adpm=metrics.adpm, adpm=metrics.adpm,
vs=metrics.vs, vs=metrics.vs,
adpl=metrics.adpl, adpl=metrics.adpl,
adpm_trending=Trending.KEEP, adpm_trending=trends.adpm,
app=(app := (league_data.apm / (60 * league_data.pps))), app=(app := (league_data.apm / (60 * league_data.pps))),
dsps=(dsps := ((league_data.vs / 100) - (league_data.apm / 60))), dsps=(dsps := ((league_data.vs / 100) - (league_data.apm / 60))),
dspp=(dspp := (dsps / league_data.pps)), dspp=(dspp := (dsps / league_data.pps)),

View File

@@ -1,9 +1,9 @@
from collections import defaultdict from collections import defaultdict
from collections.abc import Callable, Sequence from collections.abc import Callable, Iterator, Sequence
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from math import floor from math import floor
from statistics import mean from statistics import mean
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, TypeVar
from uuid import uuid4 from uuid import uuid4
from nonebot import get_driver 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 import Parameter
from ..api.schemas.leaderboards.by import Entry from ..api.schemas.leaderboards.by import Entry
from ..constant import RANK_PERCENTILE from ..constant import RANK_PERCENTILE
from ..models import TETRIOLeagueHistorical, TETRIOLeagueStats, TETRIOLeagueStatsField from ..models import (
TETRIOLeagueHistorical,
TETRIOLeagueStats,
TETRIOLeagueStatsField,
TETRIOLeagueUserMap,
TETRIOUserUniqueIdentifier,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from ..api.schemas.leaderboards.by import BySuccessModel from ..api.schemas.leaderboards.by import BySuccessModel
@@ -81,6 +87,14 @@ def find_special_player(
return sort(users, field) 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) @scheduler.scheduled_job('cron', hour='0,6,12,18', minute=0)
async def get_tetra_league_data() -> None: async def get_tetra_league_data() -> None:
x_session_id = uuid4() x_session_id = uuid4()
@@ -94,9 +108,7 @@ async def get_tetra_league_data() -> None:
if len(model.data.entries) < 100: # 分页值 # noqa: PLR2004 if len(model.data.entries) < 100: # 分页值 # noqa: PLR2004
break break
players: list[Entry] = [] players = [i for result in results for i in result.data.entries if isinstance(i, Entry)]
for result in results:
players.extend([i for i in result.data.entries if isinstance(i, Entry)])
players.sort(key=lambda x: x.league.tr, reverse=True) players.sort(key=lambda x: x.league.tr, reverse=True)
rank_player_mapping: defaultdict[Rank, list[Entry]] = defaultdict(list) rank_player_mapping: defaultdict[Rank, list[Entry]] = defaultdict(list)
@@ -132,8 +144,37 @@ async def get_tetra_league_data() -> None:
] ]
stats.raw = historicals stats.raw = historicals
stats.fields = fields stats.fields = fields
player_ids = {i.id for result in results for i in result.data.entries}
async with get_session() as session: async with get_session() as session:
session.add(stats) 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() await session.commit()

View File

@@ -1,6 +1,7 @@
from arclet.alconna import Arg, ArgFlag 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.exception import MessageFormatError
from ...utils.typedefs import Me from ...utils.typedefs import Me
from .. import add_block_handlers, alc, command from .. import add_block_handlers, alc, command
@@ -33,6 +34,16 @@ command.add(
'unbind', 'unbind',
help_text='解除绑定 TOP 账号', help_text='解除绑定 TOP 账号',
), ),
Subcommand(
'config',
Option(
'--default-compare',
Arg('compare', parse_duration, notice='对比时间距离'),
alias=['-DC', 'DefaultCompare'],
help_text='设置默认对比时间距离',
),
help_text='TOP 查询个性化配置',
),
Subcommand( Subcommand(
'query', 'query',
Args( Args(
@@ -49,6 +60,12 @@ command.add(
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL], flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
), ),
), ),
Option(
'--compare',
Arg('compare', parse_duration),
alias=['-C'],
help_text='指定对比时间距离',
),
help_text='查询 TOP 游戏信息', help_text='查询 TOP 游戏信息',
), ),
help_text='TOP 游戏相关指令', help_text='TOP 游戏相关指令',
@@ -70,7 +87,12 @@ alc.shortcut(
command='tstats TOP query', command='tstats TOP query',
humanized='top查', humanized='top查',
) )
alc.shortcut(
'(?i:top)(?i:配置|配|config)',
command='tstats TOP config',
humanized='top配置',
)
add_block_handlers(alc.assign('TOP.query')) 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.adapters import Event
from nonebot.matcher import Matcher from nonebot.matcher import Matcher
from nonebot_plugin_alconna import At from nonebot_plugin_alconna import At
from nonebot_plugin_alconna.uniseg import Image, UniMessage 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 import Uninfo
from nonebot_plugin_uninfo.orm import get_session_persist_id 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 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 ...i18n import Lang
from ...utils.exception import FallbackError from ...utils.exception import FallbackError
from ...utils.lang import get_lang 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 ...utils.typedefs import Me
from . import alc from . import alc
from .api import Player from .api import Player
from .api.models import TOPHistoricalData
from .api.schemas.user_profile import Data, UserProfile from .api.schemas.user_profile import Data, UserProfile
from .constant import GAME_TYPE 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') @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( async with trigger(
session_persist_id=await get_session_persist_id(event_session), session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE, game_platform=GAME_TYPE,
command_type='query', command_type='query',
command_args=[], command_args=[f'--compare {compare}'] if compare is not None else [],
): ):
async with get_session() as session: async with get_session() as session:
bind = await query_bind_info( 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, game_platform=GAME_TYPE,
) )
if bind is None: if bind is None:
await matcher.finish(Lang.bind.not_found()) 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 ( await (
UniMessage.i18n(Lang.interaction.warning.unverified) UniMessage.i18n(Lang.interaction.warning.unverified)
+ ( + (
UniMessage('\n') UniMessage('\n')
if not ( if not (result := await make_query_result(profile, compare_profile)).has(Image)
result := await make_query_result(
await Player(user_name=bind.game_account, trust=True).get_profile()
)
).has(Image)
else UniMessage() else UniMessage()
) )
+ result + result
@@ -58,14 +122,22 @@ async def _(event: Event, matcher: Matcher, target: At | Me, event_session: Unin
@alc.assign('TOP.query') @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( async with trigger(
session_persist_id=await get_session_persist_id(event_session), session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE, game_platform=GAME_TYPE,
command_type='query', 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: 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) 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: if profile.today is None or profile.total is None:
raise FallbackError raise FallbackError
today = get_metrics(lpm=profile.today.lpm, apm=profile.today.apm) today = get_metrics(lpm=profile.today.lpm, apm=profile.today.apm)
history = get_avg_metrics(profile.total) 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( return await render_image(
Info( Info(
user=People(avatar=get_avatar(profile.user_name), name=profile.user_name), user=People(avatar=get_avatar(profile.user_name), name=profile.user_name),
today=InfoData( today=InfoData(
pps=today.pps, pps=today.pps,
lpm=today.lpm, lpm=today.lpm,
lpm_trending=Trending.KEEP, lpm_trending=today_lpm_trending,
apm=today.apm, apm=today.apm,
apl=today.apl, apl=today.apl,
apm_trending=Trending.KEEP, apm_trending=today_apm_trending,
), ),
historical=InfoData( historical=InfoData(
pps=history.pps, pps=history.pps,
lpm=history.lpm, lpm=history.lpm,
lpm_trending=Trending.KEEP, lpm_trending=history_lpm_trending,
apm=history.apm, apm=history.apm,
apl=history.apl, apl=history.apl,
apm_trending=Trending.KEEP, apm_trending=history_apm_trending,
), ),
lang=get_lang(), lang=get_lang(),
), ),
@@ -125,9 +201,9 @@ def make_query_text(profile: UserProfile) -> UniMessage:
return UniMessage(message) return UniMessage(message)
async def make_query_result(profile: UserProfile) -> UniMessage: async def make_query_result(profile: UserProfile, compare: UserProfile | None) -> UniMessage:
try: try:
return UniMessage.image(raw=await make_query_image(profile)) return UniMessage.image(raw=await make_query_image(profile, compare))
except FallbackError: except FallbackError:
... ...
return make_query_text(profile) return make_query_text(profile)

View File

@@ -1,6 +1,7 @@
from arclet.alconna import Arg, ArgFlag 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.exception import MessageFormatError
from ...utils.typedefs import Me from ...utils.typedefs import Me
from .. import add_block_handlers, alc, command from .. import add_block_handlers, alc, command
@@ -38,6 +39,16 @@ command.add(
'unbind', 'unbind',
help_text='解除绑定 TOS 账号', help_text='解除绑定 TOS 账号',
), ),
Subcommand(
'config',
Option(
'--default-compare',
Arg('compare', parse_duration, notice='对比时间距离'),
alias=['-DC', 'DefaultCompare'],
help_text='设置默认对比时间距离',
),
help_text='茶服 查询个性化配置',
),
Subcommand( Subcommand(
'query', 'query',
Args( Args(
@@ -54,6 +65,12 @@ command.add(
flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL], flags=[ArgFlag.HIDDEN, ArgFlag.OPTIONAL],
), ),
), ),
Option(
'--compare',
Arg('compare', parse_duration),
alias=['-C'],
help_text='指定对比时间距离',
),
help_text='查询 茶服 游戏信息', help_text='查询 茶服 游戏信息',
), ),
help_text='茶服 游戏相关指令', help_text='茶服 游戏相关指令',
@@ -75,7 +92,12 @@ alc.shortcut(
command='tstats TOS query', command='tstats TOS query',
humanized='茶服查', humanized='茶服查',
) )
alc.shortcut(
'(?i:tos|茶服)(?i:配置|配|config)',
command='tstats TOS config',
humanized='茶服配置',
)
add_block_handlers(alc.assign('TOS.query')) 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 asyncio import gather
from collections.abc import Iterable
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from http import HTTPStatus from http import HTTPStatus
from typing import Literal, NamedTuple from typing import Literal, NamedTuple
@@ -7,17 +8,18 @@ from zoneinfo import ZoneInfo
from nonebot.adapters import Event from nonebot.adapters import Event
from nonebot.matcher import Matcher from nonebot.matcher import Matcher
from nonebot_plugin_alconna import At from nonebot_plugin_alconna import At
from nonebot_plugin_alconna.uniseg import UniMessage 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, User from nonebot_plugin_uninfo import Uninfo, User
from nonebot_plugin_uninfo.orm import get_session_persist_id 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 nonebot_plugin_user import get_user
from sqlalchemy import select 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 ...i18n import Lang
from ...utils.chart import get_split, get_value_bounds, handle_history_data 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.image import get_avatar
from ...utils.lang import get_lang from ...utils.lang import get_lang
from ...utils.metrics import TetrisMetricsProWithLPMADPM, get_metrics from ...utils.metrics import TetrisMetricsProWithLPMADPM, get_metrics
@@ -32,7 +34,12 @@ from . import alc
from .api import Player from .api import Player
from .api.models import TOSHistoricalData from .api.models import TOSHistoricalData
from .api.schemas.user_info import UserInfoSuccess 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 .constant import GAME_TYPE
from .models import TOSUserConfig
UTC = timezone.utc
def add_special_handlers( def add_special_handlers(
@@ -40,16 +47,18 @@ def add_special_handlers(
) -> None: ) -> None:
@alc.assign('TOS.query') @alc.assign('TOS.query')
async def _( async def _(
user: NBUser,
event: Event, event: Event,
target: At | Me, target: At | Me,
event_session: Uninfo, event_session: Uninfo,
compare: timedelta | None = None,
): ):
if isinstance(event, match_event): if isinstance(event, match_event):
async with trigger( async with trigger(
session_persist_id=await get_session_persist_id(event_session), session_persist_id=await get_session_persist_id(event_session),
game_platform=GAME_TYPE, game_platform=GAME_TYPE,
command_type='query', command_type='query',
command_args=[], command_args=[f'--compare {compare}'] if compare is not None else [],
): ):
player = Player( player = Player(
teaid=f'{teaid_prefix}{target.target}' teaid=f'{teaid_prefix}{target.target}'
@@ -58,16 +67,14 @@ def add_special_handlers(
trust=True, trust=True,
) )
try: try:
user_info, game_data = await gather(player.get_info(), get_game_data(player)) async with get_session() as session:
if game_data is not None: await (
await UniMessage.image( await make_query_result(
raw=await make_query_image( player,
user_info, await resolve_compare_delta(TOSUserConfig, session, user.id, compare),
game_data,
None if isinstance(target, At) else event_session.user, None if isinstance(target, At) else event_session.user,
) )
).finish() ).finish()
await make_query_text(user_info, game_data).finish()
except RequestError as e: except RequestError as e:
if e.status_code == HTTPStatus.BAD_REQUEST and '未找到此用户' in e.message: if e.status_code == HTTPStatus.BAD_REQUEST and '未找到此用户' in e.message:
return return
@@ -106,58 +113,66 @@ except ImportError:
@alc.assign('TOS.query') @alc.assign('TOS.query')
async def _( async def _( # noqa: PLR0913
user: NBUser,
event: Event, event: Event,
matcher: Matcher, matcher: Matcher,
target: At | Me, target: At | Me,
event_session: Uninfo, event_session: Uninfo,
compare: timedelta | None = None,
): ):
async with trigger( async with (
session_persist_id=await get_session_persist_id(event_session), trigger(
game_platform=GAME_TYPE, session_persist_id=await get_session_persist_id(event_session),
command_type='query', game_platform=GAME_TYPE,
command_args=[], 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(
bind = await query_bind_info( session=session,
session=session, user=await get_user(event_session.scope, target.target if isinstance(target, At) else event.get_user_id()),
user=await get_user( game_platform=GAME_TYPE,
event_session.scope, target.target if isinstance(target, At) else event.get_user_id() )
),
game_platform=GAME_TYPE,
)
if bind is None: if bind is None:
await matcher.finish(Lang.bind.not_found()) await matcher.finish(Lang.bind.not_found())
message = UniMessage.i18n(Lang.interaction.warning.unverified)
player = Player(teaid=bind.game_account, trust=True) player = Player(teaid=bind.game_account, trust=True)
user_info, game_data = await gather(player.get_info(), get_game_data(player)) await (
if game_data is not None: UniMessage.i18n(Lang.interaction.warning.unverified)
await ( + (
message UniMessage('\n')
+ UniMessage.image( if not (
raw=await make_query_image( result := await make_query_result(
user_info, player,
game_data, await resolve_compare_delta(TOSUserConfig, session, user.id, compare),
None if isinstance(target, At) else event_session.user, None if isinstance(target, At) else event_session.user,
) )
) ).has(Image)
).finish() else UniMessage()
await (message + UniMessage('\n') + make_query_text(user_info, game_data)).finish() )
+ result
).finish()
@alc.assign('TOS.query') @alc.assign('TOS.query')
async def _(account: Player, event_session: Uninfo): async def _(user: NBUser, account: Player, event_session: Uninfo, compare: timedelta | None = None):
async with trigger( async with (
session_persist_id=await get_session_persist_id(event_session), trigger(
game_platform=GAME_TYPE, session_persist_id=await get_session_persist_id(event_session),
command_type='query', game_platform=GAME_TYPE,
command_args=[], 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 (
await get_historical_data(user_info.data.teaid) await make_query_result(
if game_data is not None: account,
await UniMessage.image(raw=await make_query_image(user_info, game_data, None)).finish() await resolve_compare_delta(TOSUserConfig, session, user.id, compare),
await make_query_text(user_info, game_data).finish() None,
)
).finish()
class GameData(NamedTuple): class GameData(NamedTuple):
@@ -168,48 +183,125 @@ class GameData(NamedTuple):
ge: Number 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: async def get_game_data(player: Player, query_num: int = 50) -> GameData | None:
"""获取游戏数据""" """获取游戏数据"""
user_profile = await player.get_profile() user_profile = await player.get_profile()
if user_profile.data == []: return get_game_data_from_profile(user_profile, query_num)
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 async def get_compare_profile(
for i in user_profile.data: session: AsyncSession, unique_identifier: str, target_time: datetime
# 排除单人局和时间为0的游戏 ) -> UserProfile | None:
# 茶: 不计算没挖掘的局, 即使apm和lpm也如此 before = await session.scalar(
if i.num_players == 1 or i.time == 0: select(TOSHistoricalData)
continue .where(
# 加权计算 TOSHistoricalData.user_unique_identifier == unique_identifier,
time = i.time / 1000 TOSHistoricalData.api_type == 'User Profile',
lpm = 24 * (i.pieces / time) TOSHistoricalData.update_time <= target_time,
apm = (i.attack / time) * 60 )
adpm = ((i.attack + i.dig) / time) * 60 .order_by(TOSHistoricalData.update_time.desc())
weighted_total_lpm += lpm * time .limit(1)
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 GameData( after = await session.scalar(
game_num=num, select(TOSHistoricalData)
metrics=metrics, .where(
or_=total_offset / total_receive * 100, TOSHistoricalData.user_unique_identifier == unique_identifier,
dspp=total_dig / total_pieses, TOSHistoricalData.api_type == 'User Profile',
ge=2 * ((total_attack * total_dig) / total_pieses**2), 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 @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 metrics = game_data.metrics
trends = await get_trends(player, compare_delta)
sprint_value = ( sprint_value = (
( (
f'{duration:.3f}s' 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), rd=round(float(user_info.data.rd_now), 2),
lpm=metrics.lpm, lpm=metrics.lpm,
pps=metrics.pps, pps=metrics.pps,
lpm_trending=Trending.KEEP, lpm_trending=trends.lpm,
apm=metrics.apm, apm=metrics.apm,
apl=metrics.apl, apl=metrics.apl,
apm_trending=Trending.KEEP, apm_trending=trends.apm,
adpm=metrics.adpm, adpm=metrics.adpm,
vs=metrics.vs, vs=metrics.vs,
adpl=metrics.adpl, adpl=metrics.adpl,
adpm_trending=Trending.KEEP, adpm_trending=trends.adpm,
app=(app := (metrics.apm / (60 * metrics.pps))), app=(app := (metrics.apm / (60 * metrics.pps))),
or_=game_data.or_, or_=game_data.or_,
dspp=game_data.dspp, 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 user_data = user_info.data
message = Lang.stats.user_info(name=user_data.name, id=user_data.teaid) message = Lang.stats.user_info(name=user_data.name, id=user_data.teaid)
if user_data.ranked_games == '0': 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': if user_data.pb_challenge != '0':
message += Lang.stats.challenge_pb(score=user_data.pb_challenge) message += Lang.stats.challenge_pb(score=user_data.pb_challenge)
return UniMessage(message) 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' UP = 'up'
KEEP = 'keep' KEEP = 'keep'
DOWN = 'down' 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 Number: TypeAlias = float | int
GameType: TypeAlias = Literal['IO', 'TOP', 'TOS'] GameType: TypeAlias = Literal['IO', 'TOP', 'TOS']
BaseCommandType: TypeAlias = Literal['bind', 'unbind', 'query'] BaseCommandType: TypeAlias = Literal[
TETRIOCommandType: TypeAlias = BaseCommandType | Literal['rank', 'config', 'list', 'record', 'verify'] 'bind',
'config',
'query',
'unbind',
]
TETRIOCommandType: TypeAlias = (
BaseCommandType
| Literal[
'list',
'rank',
'record',
'verify',
]
)
AllCommandType: TypeAlias = BaseCommandType | TETRIOCommandType AllCommandType: TypeAlias = BaseCommandType | TETRIOCommandType
Me: TypeAlias = Literal[ Me: TypeAlias = Literal[
'', '',