diff --git a/nonebot_plugin_tetris_stats/config/migrations/3a294ff14610_add_io_tl_map.py b/nonebot_plugin_tetris_stats/config/migrations/3a294ff14610_add_io_tl_map.py new file mode 100644 index 0000000..53d0276 --- /dev/null +++ b/nonebot_plugin_tetris_stats/config/migrations/3a294ff14610_add_io_tl_map.py @@ -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') diff --git a/nonebot_plugin_tetris_stats/config/migrations/6ecf383d646a_add_compare_delta_config.py b/nonebot_plugin_tetris_stats/config/migrations/6ecf383d646a_add_compare_delta_config.py new file mode 100644 index 0000000..9ebea86 --- /dev/null +++ b/nonebot_plugin_tetris_stats/config/migrations/6ecf383d646a_add_compare_delta_config.py @@ -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') diff --git a/nonebot_plugin_tetris_stats/db/__init__.py b/nonebot_plugin_tetris_stats/db/__init__.py index d60ec02..736e21f 100644 --- a/nonebot_plugin_tetris_stats/db/__init__.py +++ b/nonebot_plugin_tetris_stats/db/__init__.py @@ -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( diff --git a/nonebot_plugin_tetris_stats/games/tetrio/config.py b/nonebot_plugin_tetris_stats/games/tetrio/config.py index f5de700..b1c23e2 100644 --- a/nonebot_plugin_tetris_stats/games/tetrio/config.py +++ b/nonebot_plugin_tetris_stats/games/tetrio/config.py @@ -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() diff --git a/nonebot_plugin_tetris_stats/games/tetrio/models.py b/nonebot_plugin_tetris_stats/games/tetrio/models.py index e087822..3ae09a1 100644 --- a/nonebot_plugin_tetris_stats/games/tetrio/models.py +++ b/nonebot_plugin_tetris_stats/games/tetrio/models.py @@ -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) diff --git a/nonebot_plugin_tetris_stats/games/tetrio/query/__init__.py b/nonebot_plugin_tetris_stats/games/tetrio/query/__init__.py index 1e93e7c..05b98d7 100644 --- a/nonebot_plugin_tetris_stats/games/tetrio/query/__init__.py +++ b/nonebot_plugin_tetris_stats/games/tetrio/query/__init__.py @@ -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() diff --git a/nonebot_plugin_tetris_stats/games/tetrio/query/v1.py b/nonebot_plugin_tetris_stats/games/tetrio/query/v1.py index 4b90452..b2977f7 100644 --- a/nonebot_plugin_tetris_stats/games/tetrio/query/v1.py +++ b/nonebot_plugin_tetris_stats/games/tetrio/query/v1.py @@ -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)), diff --git a/nonebot_plugin_tetris_stats/games/tetrio/rank/__init__.py b/nonebot_plugin_tetris_stats/games/tetrio/rank/__init__.py index 1e50665..0b5512d 100644 --- a/nonebot_plugin_tetris_stats/games/tetrio/rank/__init__.py +++ b/nonebot_plugin_tetris_stats/games/tetrio/rank/__init__.py @@ -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() diff --git a/nonebot_plugin_tetris_stats/games/top/__init__.py b/nonebot_plugin_tetris_stats/games/top/__init__.py index aa535dc..3c46136 100644 --- a/nonebot_plugin_tetris_stats/games/top/__init__.py +++ b/nonebot_plugin_tetris_stats/games/top/__init__.py @@ -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 diff --git a/nonebot_plugin_tetris_stats/games/top/config.py b/nonebot_plugin_tetris_stats/games/top/config.py new file mode 100644 index 0000000..3b9334b --- /dev/null +++ b/nonebot_plugin_tetris_stats/games/top/config.py @@ -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() diff --git a/nonebot_plugin_tetris_stats/games/top/models.py b/nonebot_plugin_tetris_stats/games/top/models.py new file mode 100644 index 0000000..1117999 --- /dev/null +++ b/nonebot_plugin_tetris_stats/games/top/models.py @@ -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) diff --git a/nonebot_plugin_tetris_stats/games/top/query.py b/nonebot_plugin_tetris_stats/games/top/query.py index fc855ed..edf7752 100644 --- a/nonebot_plugin_tetris_stats/games/top/query.py +++ b/nonebot_plugin_tetris_stats/games/top/query.py @@ -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) diff --git a/nonebot_plugin_tetris_stats/games/tos/__init__.py b/nonebot_plugin_tetris_stats/games/tos/__init__.py index 25cf786..3649623 100644 --- a/nonebot_plugin_tetris_stats/games/tos/__init__.py +++ b/nonebot_plugin_tetris_stats/games/tos/__init__.py @@ -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 diff --git a/nonebot_plugin_tetris_stats/games/tos/config.py b/nonebot_plugin_tetris_stats/games/tos/config.py new file mode 100644 index 0000000..772f9e4 --- /dev/null +++ b/nonebot_plugin_tetris_stats/games/tos/config.py @@ -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() diff --git a/nonebot_plugin_tetris_stats/games/tos/models.py b/nonebot_plugin_tetris_stats/games/tos/models.py new file mode 100644 index 0000000..6ad9801 --- /dev/null +++ b/nonebot_plugin_tetris_stats/games/tos/models.py @@ -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) diff --git a/nonebot_plugin_tetris_stats/games/tos/query.py b/nonebot_plugin_tetris_stats/games/tos/query.py index 0f679f7..ce5a2cd 100644 --- a/nonebot_plugin_tetris_stats/games/tos/query.py +++ b/nonebot_plugin_tetris_stats/games/tos/query.py @@ -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) diff --git a/nonebot_plugin_tetris_stats/utils/duration.py b/nonebot_plugin_tetris_stats/utils/duration.py new file mode 100644 index 0000000..ad38afe --- /dev/null +++ b/nonebot_plugin_tetris_stats/utils/duration.py @@ -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}) diff --git a/nonebot_plugin_tetris_stats/utils/render/schemas/base.py b/nonebot_plugin_tetris_stats/utils/render/schemas/base.py index 5b65f0b..d2bd7a7 100644 --- a/nonebot_plugin_tetris_stats/utils/render/schemas/base.py +++ b/nonebot_plugin_tetris_stats/utils/render/schemas/base.py @@ -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 diff --git a/nonebot_plugin_tetris_stats/utils/typedefs.py b/nonebot_plugin_tetris_stats/utils/typedefs.py index d497293..7c02b2d 100644 --- a/nonebot_plugin_tetris_stats/utils/typedefs.py +++ b/nonebot_plugin_tetris_stats/utils/typedefs.py @@ -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[ '我',