diff --git a/build.bat b/build.bat index 8e74180..edc5ea5 100644 --- a/build.bat +++ b/build.bat @@ -10,11 +10,16 @@ if exist "%OUT%\plugin_manager" rmdir /s /q "%OUT%\plugin_manager" echo. echo [1/3] metaminsweeper.exe -pyinstaller --noconfirm --name metaminsweeper --windowed --distpath %OUT% src\main.py +pyinstaller --noconfirm --name metaminsweeper --windowed --distpath %OUT% ^ + --icon src/media/cat.ico ^ + --clean ^ + --paths src ^ + --add-data "src/media;media" ^ + src\main.py echo. echo [2/3] plugin_manager.exe -pyinstaller --noconfirm --name plugin_manager --windowed --hidden-import code --hidden-import xmlrpc --hidden-import xmlrpc.server --hidden-import xmlrpc.client --hidden-import http.server --hidden-import socketserver --hidden-import email --hidden-import email.utils --distpath %OUT% src\plugin_manager\_run.py +pyinstaller --noconfirm --name plugin_manager --windowed --hidden-import sqlite3 --hidden-import code --hidden-import xmlrpc --hidden-import xmlrpc.server --hidden-import xmlrpc.client --hidden-import http.server --hidden-import socketserver --hidden-import email --hidden-import email.utils --distpath %OUT% src\plugin_manager\_run.py echo. echo [3/3] Copy resources to metaminsweeper\ diff --git a/src/lib_zmq_plugins/client/zmq_client.py b/src/lib_zmq_plugins/client/zmq_client.py index e08f0b2..ded70e5 100644 --- a/src/lib_zmq_plugins/client/zmq_client.py +++ b/src/lib_zmq_plugins/client/zmq_client.py @@ -283,8 +283,11 @@ def _handle_sub_message(self) -> None: topic = msg[0].decode("utf-8", errors="replace") try: event = self._serializer.decode_event(msg[1]) - except Exception: - self._log.warning("Failed to decode event for topic: %s", topic, exc_info=True) + except Exception as e: + self._log.warning( + f"Failed to decode event for topic: {topic}, Exception: {e}", + exc_info=True, + ) return self._notify_subscribers(topic, event) diff --git a/src/main.py b/src/main.py index e362d28..8e3baba 100644 --- a/src/main.py +++ b/src/main.py @@ -156,6 +156,7 @@ def cli_check_file(file_path: str) -> int: # ── 启动 ZMQ Server + 插件管理器 ── game_server = GameServerBridge(ui) + ui.gameServerBridge = game_server # 打包后直接调用 plugin_manager.exe,开发模式用 python -m if getattr(sys, 'frozen', False): diff --git a/src/mineSweeperGUI.py b/src/mineSweeperGUI.py index 7e93521..b33835d 100644 --- a/src/mineSweeperGUI.py +++ b/src/mineSweeperGUI.py @@ -6,6 +6,8 @@ # from PyQt5.QtWidgets import QLineEdit, QInputDialog, QShortcut # from PyQt5.QtWidgets import QApplication, QFileDialog, QWidget import gameDefinedParameter +from plugin_manager.server_bridge import GameServerBridge +from shared_types.events import VideoSaveEvent import superGUI import gameAbout import gameSettings @@ -146,6 +148,7 @@ def save_evf_file_integrated(): # 不带后缀、有绝对路径的、不含最后次数的文件名 # C:/path/zhangsan_20251111_190114_ self.old_evfs_filename = "" + self.gameServerBridge: GameServerBridge = None @property def pixSize(self): @@ -557,6 +560,16 @@ def gameFinished(self): status = utils.GameBoardState(ms_board.game_board_state) if status == utils.GameBoardState.Win: self.dump_evf_file_data() + event = VideoSaveEvent() + data = msgspec.structs.asdict(event) + for key in data: + if hasattr(ms_board, key): + if key == "raw_data": + data[key] = base64.b64encode(ms_board.raw_data).decode("utf-8") + continue + data[key] = getattr(ms_board, key) + event = VideoSaveEvent(**data) + self.gameServerBridge._server.publish(VideoSaveEvent, event) def gameWin(self): # 成功后改脸和状态变量,停时间 self.timer_10ms.stop() diff --git a/src/plugin_manager/app_paths.py b/src/plugin_manager/app_paths.py index 409d1b6..4f37b6c 100644 --- a/src/plugin_manager/app_paths.py +++ b/src/plugin_manager/app_paths.py @@ -87,7 +87,7 @@ def get_builtin_plugin_dirs() -> list[Path]: plugin_dir = bundle / "plugins" if plugin_dir.is_dir(): return [plugin_dir] - logger.warning("内置插件目录不存在: %s", plugin_dir) + logger.warning(f"内置插件目录不存在: {plugin_dir}") return [] @@ -112,6 +112,32 @@ def get_all_plugin_dirs() -> list[Path]: return get_builtin_plugin_dirs() + get_user_plugin_dirs() +def get_plugin_data_dir(plugin_class: type | str) -> Path: + """ + 获取指定插件的专属数据目录(可写) + + 根据传入的插件类或名称,在 data/plugin_data/ 下创建对应子目录。 + 每个插件拥有独立的数据空间,互不干扰。 + + - 开发模式: /data/plugin_data// + - 打包模式: /data/plugin_data// + + Args: + plugin_class: 插件类或插件名称字符串 + + Returns: + 插件的专属数据目录路径 + """ + if isinstance(plugin_class, type): + name = plugin_class.__name__ + else: + name = str(plugin_class) + + plugin_data_dir = get_data_dir() / "plugin_data" / name + plugin_data_dir.mkdir(parents=True, exist_ok=True) + return plugin_data_dir + + # ── 环境变量补丁(给子进程使用) ─────────────────────── def patch_sys_path_for_frozen() -> None: @@ -127,7 +153,7 @@ def patch_sys_path_for_frozen() -> None: bundle = str(get_bundle_dir()) if bundle not in sys.path: sys.path.insert(0, bundle) - logger.debug("已将 bundle 目录加入 sys.path: %s", bundle) + logger.debug(f"已将 bundle 目录加入 sys.path: {bundle}") def get_env_for_subprocess(env: dict | None = None) -> dict: @@ -149,7 +175,7 @@ def get_env_for_subprocess(env: dict | None = None) -> dict: paths.append(existing) env["PYTHONPATH"] = os.pathsep.join(paths) - logger.debug("子进程环境: PYTHONPATH=%s", env.get("PYTHONPATH")) + logger.debug(f"子进程环境: PYTHONPATH={env.get('PYTHONPATH')}") return env diff --git a/src/plugin_manager/main_window.py b/src/plugin_manager/main_window.py index e205f0b..98b6192 100644 --- a/src/plugin_manager/main_window.py +++ b/src/plugin_manager/main_window.py @@ -889,7 +889,7 @@ def _open_plugin_log(self, name: str) -> None: else: subprocess.Popen(["xdg-open", str(log_file)]) except Exception as e: - logger.warning("Failed to open log file %s: %s", log_file, e) + logger.warning(f"Failed to open log file {log_file}: {e}") def _open_plugin_settings(self, name: str) -> None: """打开插件设置对话框""" @@ -977,13 +977,13 @@ def _on_list_double_clicked(self, item) -> None: # ── 标签页弹出/嵌回 ───────────────────────────────── def _on_tab_detached(self, name: str) -> None: - logger.debug("Tab detached: %s", name) + logger.debug(f"Tab detached: {name}") def _on_tab_attached(self, name: str) -> None: - logger.debug("Tab attached back: %s", name) + logger.debug(f"Tab attached back: {name}") def _on_tab_closed(self, name: str) -> None: - logger.debug("Tab closed: %s", name) + logger.debug(f"Tab closed: {name}") self._closed_plugins.add(name) # ── 窗口事件 ──────────────────────────────────────── diff --git a/src/plugin_manager/plugin_base.py b/src/plugin_manager/plugin_base.py index 586e5f5..e729e64 100644 --- a/src/plugin_manager/plugin_base.py +++ b/src/plugin_manager/plugin_base.py @@ -18,7 +18,7 @@ if TYPE_CHECKING: from PyQt5.QtGui import QIcon -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, QObject from PyQt5.QtGui import QIcon, QPixmap, QPainter, QPen, QColor, QBrush, QFont from lib_zmq_plugins.shared.base import BaseEvent, get_event_tag @@ -169,31 +169,41 @@ def __init__(self, info: PluginInfo): log_config=info.log_config, # 插件可自定义轮转策略 ) self._log_level: LogLevel = info.log_level # 当前日志级别 - + # ═══════════════════════════════════════════════════════════════════ # 属性 # ═══════════════════════════════════════════════════════════════════ - + @property def info(self) -> PluginInfo: return self._info - + @property def name(self) -> str: return self._info.name - + @property def is_enabled(self) -> bool: return self._info.enabled - + @property def widget(self) -> QWidget | None: return self._widget - + @property def client(self) -> ZMQClient | None: return self._client + @property + def data_dir(self) -> "Path": + """插件专属数据目录(可写),自动根据插件类名创建""" + from pathlib import Path + from .app_paths import get_plugin_data_dir + + if not hasattr(self, "_data_dir"): + self._data_dir = get_plugin_data_dir(type(self)) + return self._data_dir + @property def log_level(self) -> LogLevel: """当前日志级别""" @@ -211,7 +221,7 @@ def set_log_level(self, level: LogLevel | str) -> None: level = LogLevel(level.upper()) self._log_level = level set_plugin_log_level(self._log_sink_id, level) - self.logger.debug("Log level changed to %s", level) + self.logger.debug(f"Log level changed to {level}") @property def plugin_icon(self) -> QIcon: @@ -219,47 +229,47 @@ def plugin_icon(self) -> QIcon: if self._info.icon: return self._info.icon return make_plugin_icon() - + # ═══════════════════════════════════════════════════════════════════ # 生命周期 # ═══════════════════════════════════════════════════════════════════ - + def set_client(self, client: ZMQClient) -> None: self._client = client - + def set_event_dispatcher(self, dispatcher: EventDispatcher) -> None: self._event_dispatcher = dispatcher - + def initialize(self) -> None: """初始化插件""" if self._initialized: return - + self._setup_subscriptions() self._widget = self._create_widget() self._initialized = True self.on_initialized() - + def shutdown(self) -> None: """关闭插件""" if not self._initialized: return - + self.on_shutdown() - + if self._event_dispatcher: self._event_dispatcher.unsubscribe_all(self) - + if self._widget: self._widget.deleteLater() self._widget = None - + self._initialized = False - + # ═══════════════════════════════════════════════════════════════════ # 抽象方法 # ═══════════════════════════════════════════════════════════════════ - + @abstractmethod def _setup_subscriptions(self) -> None: """ @@ -270,27 +280,27 @@ def _setup_subscriptions(self) -> None: self.subscribe(BoardUpdateEvent, self._on_board_update) """ pass - + # ═══════════════════════════════════════════════════════════════════ # 可选重写 # ═══════════════════════════════════════════════════════════════════ - + def _create_widget(self) -> QWidget | None: """创建界面组件,返回 None 表示无界面""" return None - + def on_initialized(self) -> None: """插件初始化完成回调""" pass - + def on_shutdown(self) -> None: """插件关闭前回调""" pass - + # ═══════════════════════════════════════════════════════════════════ # 事件订阅(使用事件类) # ═══════════════════════════════════════════════════════════════════ - + def subscribe( self, event_class: type[_E], @@ -306,43 +316,43 @@ def subscribe( if self._event_dispatcher: tag = get_event_tag(event_class) self._event_dispatcher.subscribe(tag, handler, self._info.priority, self) - + def unsubscribe(self, event_class: type[BaseEvent]) -> None: """取消订阅事件""" if self._event_dispatcher: tag = get_event_tag(event_class) self._event_dispatcher.unsubscribe(tag, self) - + # ═══════════════════════════════════════════════════════════════════ # 指令发送 # ═══════════════════════════════════════════════════════════════════ - + def send_command(self, command: Any) -> None: """发送控制指令到主进程(异步)""" if self._client: self._client.send_command(command) - + def request(self, command: Any, timeout: float = 5.0) -> Any: """发送请求并等待响应(同步)""" if self._client: return self._client.request(command, timeout) return None - + # ═══════════════════════════════════════════════════════════════════ # 辅助 # ═══════════════════════════════════════════════════════════════════ - + def enable(self) -> None: """启用插件""" self._info.enabled = True if not self._initialized: self.initialize() - + def disable(self) -> None: """禁用插件""" self._info.enabled = False if self._initialized: self.shutdown() - + def __repr__(self) -> str: return f"" diff --git a/src/plugin_manager/plugin_manager.py b/src/plugin_manager/plugin_manager.py index d4c6e41..63c37d4 100644 --- a/src/plugin_manager/plugin_manager.py +++ b/src/plugin_manager/plugin_manager.py @@ -28,6 +28,23 @@ logger = loguru.logger.bind(name="PluginManager") +class _LogHandler(LogHandler): + def debug(self, msg: str, /, *args: object, **kwargs: object) -> None: + logger.debug(msg, *args, **kwargs) + + def info(self, msg: str, /, *args: object, **kwargs: object) -> None: + logger.info(msg, *args, **kwargs) + + def warning(self, msg: str, /, *args: object, **kwargs: object) -> None: + logger.warning(msg, *args, **kwargs) + + def error(self, msg: str, /, *args: object, **kwargs: object) -> None: + logger.error(msg, *args, **kwargs) + + def critical(self, msg: str, /, *args: object, **kwargs: object) -> None: + logger.critical(msg, *args, **kwargs) + + class PluginManager: """ 插件管理器 @@ -171,7 +188,7 @@ def start_with_gui(self, app: QApplication = None, *, show_main_window: bool = T if show_main_window: self._main_window.show() - logger.info("Plugin manager started with GUI (window=%s)", show_main_window) + logger.info(f"Plugin manager started with GUI (window={show_main_window})") def exec_gui(self, *, show_main_window: bool = True) -> int: """ @@ -307,10 +324,9 @@ def run_plugin_manager_process( 退出代码 """ manager = PluginManager( - endpoint=endpoint, - plugin_dirs=plugin_dirs, + endpoint=endpoint, plugin_dirs=plugin_dirs, log_handler=_LogHandler() ) - + try: if with_gui: return manager.exec_gui(show_main_window=show_main_window) @@ -323,5 +339,5 @@ def run_plugin_manager_process( pass finally: manager.stop() - + return 0 diff --git a/src/plugin_manager/plugin_state.py b/src/plugin_manager/plugin_state.py index 15e6043..498bd2a 100644 --- a/src/plugin_manager/plugin_state.py +++ b/src/plugin_manager/plugin_state.py @@ -45,15 +45,15 @@ def __init__(self, file_path: str | Path): def load(self) -> None: """从 JSON 文件加载状态""" if not self._file.exists(): - logger.info("State file not found: %s (will create on save)", self._file) + logger.info(f"State file not found: {self._file} (will create on save)") return try: raw: dict[str, dict[str, Any]] = json.loads(self._file.read_text("utf-8")) for name, d in raw.items(): self._states[name] = PluginState(**{k: v for k, v in d.items() if k in asdict(_DEFAULT)}) - logger.info("Loaded state for %d plugin(s)", len(self._states)) + logger.info(f"Loaded state for {len(self._states)} plugin(s)") except Exception as e: - logger.error("Failed to load state from %s: %s", self._file, e) + logger.error(f"Failed to load state from {self._file}: {e}") def save(self) -> None: """写入 JSON 文件(仅在有变更时)""" @@ -67,9 +67,9 @@ def save(self) -> None: encoding="utf-8", ) self._dirty = False - logger.info("Saved state for %d plugin(s)", len(self._states)) + logger.info(f"Saved state for {len(self._states)} plugin(s)") except Exception as e: - logger.error("Failed to save state to %s: %s", self._file, e) + logger.error(f"Failed to save state to {self._file}: {e}") # ── 查询 / 修改 ───────────────────────────────────── diff --git a/src/plugins/history.py b/src/plugins/history.py new file mode 100644 index 0000000..2f12562 --- /dev/null +++ b/src/plugins/history.py @@ -0,0 +1,945 @@ +""" +历史记录插件 + +功能: +- 监听 VideoSaveEvent,将游戏录像数据持久化到 SQLite 数据库 +- 提供 GUI 界面:表格浏览、筛选、分页、播放/导出录像 +- 使用 self.data_dir 存储数据库文件(每个插件独立目录) +""" + +from __future__ import annotations + +import base64 +import json +import math +import sqlite3 +import subprocess +import sys +import inspect +from datetime import datetime +from pathlib import Path +from typing import Any + +import msgspec + +from plugin_manager import BasePlugin, PluginInfo, make_plugin_icon, WindowMode +from plugin_manager.app_paths import get_executable_dir +from shared_types.events import VideoSaveEvent + +from PyQt5.QtCore import ( + QObject, + Qt, + QCoreApplication, + QAbstractTableModel, + QModelIndex, + pyqtSignal, +) +from PyQt5.QtGui import QCloseEvent as _QCloseEvent +from PyQt5.QtWidgets import ( + QWidget, + QVBoxLayout, + QTableWidget, + QMenu, + QAction, + QTableWidgetItem, + QHeaderView, + QTableView, + QMessageBox, + QFileDialog, + QComboBox, + QLineEdit, + QSpinBox, + QDoubleSpinBox, + QDateTimeEdit, + QHBoxLayout, + QPushButton, + QSpacerItem, + QSizePolicy, + QLabel, +) + +_translate = QCoreApplication.translate + + +# ── 枚举定义(替代原 utils 中的枚举)───────────────────── + + +class LogicSymbol: + And = 0 + Or = 1 + + _LABELS = {0: _translate("Form", "与"), 1: _translate("Form", "或")} + _SQL = {0: "and", 1: "or"} + + @classmethod + def display_names(cls): + return [cls._LABELS[cls.And], cls._LABELS[cls.Or]] + + @classmethod + def from_display_name(cls, name: str): + for v, n in cls._LABELS.items(): + if n == name: + return cls(v) + raise ValueError(name) + + def __init__(self, value: int): + self.value = value + + @property + def display_name(self): + return self._LABELS[self.value] + + @property + def to_sql(self): + return self._SQL[self.value] + + +class CompareSymbol: + Equal = 0 + NotEqual = 1 + GreaterThan = 2 + LessThan = 3 + GreaterThanOrEqual = 4 + LessThanOrEqual = 5 + Contains = 6 + NotContains = 7 + + _LABELS = { + 0: _translate("Form", "等于"), + 1: _translate("Form", "不等于"), + 2: _translate("Form", "大于"), + 3: _translate("Form", "小于"), + 4: _translate("Form", "大于等于"), + 5: _translate("Form", "小于等于"), + 6: _translate("Form", "包含"), + 7: _translate("Form", "不包含"), + } + _SQL = { + 0: "=", + 1: "!=", + 2: ">", + 3: "<", + 4: ">=", + 5: "<=", + 6: "in", + 7: "not in", + } + + @classmethod + def display_names(cls): + return [cls._LABELS[i] for i in range(len(cls._LABELS))] + + @classmethod + def from_display_name(cls, name: str): + for v, n in cls._LABELS.items(): + if n == name: + return cls(v) + raise ValueError(name) + + def __init__(self, value: int): + self.value = value + + @property + def display_name(self): + return self._LABELS[self.value] + + @property + def to_sql(self): + return self._SQL[self.value] + + +# ── 数据模型 ─────────────────────────────────────────────── + + +class HistoryData: + """历史记录数据行(纯数据类,用类属性定义字段)""" + + replay_id: int = 0 + game_board_state: int = 0 + rtime: float = 0 + left: int = 0 + right: int = 0 + double: int = 0 + left_s: float = 0.0 + right_s: float = 0.0 + double_s: float = 0.0 + level: int = 0 + cl: int = 0 + cl_s: float = 0.0 + ce: int = 0 + ce_s: float = 0.0 + rce: int = 0 + lce: int = 0 + dce: int = 0 + bbbv: int = 0 + bbbv_solved: int = 0 + bbbv_s: float = 0.0 + flag: int = 0 + path: float = 0.0 + etime: float = 0 + start_time: int = 0 + end_time: int = 0 + mode: int = 0 + software: str = "" + player_identifier: str = "" + race_identifier: str = "" + uniqueness_identifier: str = "" + stnb: float = 0.0 + corr: float = 0.0 + thrp: float = 0.0 + ioe: float = 0.0 + is_official: int = 0 + is_fair: int = 0 + op: int = 0 + isl: int = 0 + pluck: float = 0.0 + + @classmethod + def get_field_value(cls, field_name: str): + for name, value in inspect.getmembers(cls): + if ( + not name.startswith("__") + and not callable(value) + and not name.startswith("_") + and name == field_name + ): + return value + + @classmethod + def fields(cls): + return [ + name + for name, value in inspect.getmembers(cls) + if not name.startswith("__") + and not callable(value) + and not name.startswith("_") + ] + + @classmethod + def query_all(cls): + return f"select {','.join(cls.fields())} from history" + + @classmethod + def from_dict(cls, data: dict): + instance = cls() + for name, value in inspect.getmembers(cls): + if ( + not name.startswith("__") + and not callable(value) + and not name.startswith("_") + ): + new_value = data.get(name) + # 时间戳字段转换 + if ( + name in ("etime",) + and isinstance(new_value, (int, float)) + and new_value + ): + value = new_value + elif ( + name in ("start_time", "end_time") + and isinstance(new_value, (int, float)) + and new_value + ): + value = datetime.fromtimestamp(new_value / 1_000_000) + elif isinstance(value, float): + value = round(new_value, 4) + else: + value = new_value + setattr(instance, name, value) + return instance + + +# ── Table Model ──────────────────────────────────────────── + + +class HistoryTableModel(QAbstractTableModel): + def __init__( + self, + data: list[HistoryData], + headers: list[str], + show_fields: set[str], + parent=None, + ): + super().__init__(parent) + self._data = data + self._headers = headers + self._show_fields = show_fields + self._visible_headers = [h for h in headers if h in show_fields] + + def rowCount(self, parent=None): + return len(self._data) + + def columnCount(self, parent=None): + return len(self._visible_headers) + + def data(self, index: QModelIndex, role=Qt.DisplayRole): + if not index.isValid(): + return None + row = index.row() + col = index.column() + if row >= len(self._data) or col >= len(self._visible_headers): + return None + + if role == Qt.DisplayRole: + field_name = self._visible_headers[col] + value = getattr(self._data[row], field_name) + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d %H:%M:%S.%f") + else: + return str(value) + + elif role == Qt.UserRole: + field_name = self._visible_headers[col] + return getattr(self._data[row], field_name) + + elif role == Qt.TextAlignmentRole: + return Qt.AlignCenter | Qt.AlignVCenter + + return None + + def headerData( + self, section: int, orientation: Qt.Orientation, role=Qt.DisplayRole + ): + if role == Qt.DisplayRole and orientation == Qt.Horizontal: + if section < len(self._visible_headers): + return self._visible_headers[section] + return None + + def update_data(self, data: list[HistoryData]): + self.beginResetModel() + self._data = data + self.endResetModel() + + def update_show_fields(self, show_fields: set[str]): + self.beginResetModel() + self._show_fields = show_fields + self._visible_headers = [h for h in self._headers if h in show_fields] + self.endResetModel() + + +# ── 筛选控件 ────────────────────────────────────────────── + + +class FilterWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + vbox = QVBoxLayout(self) + self.table = QTableWidget(self) + self.table.setColumnCount(6) + self.table.setHorizontalHeaderLabels( + ["左括号", "字段", "比较符", "值", "右括号", "逻辑符"] + ) + self.table.horizontalHeader().setDefaultAlignment(Qt.AlignCenter) + self.table.setContextMenuPolicy(Qt.CustomContextMenu) + self.table.customContextMenuRequested.connect(self.show_context_menu) + self.table.setSelectionBehavior(QTableView.SelectRows) + self.table.setSelectionMode(QTableView.SingleSelection) + vbox.addWidget(self.table) + self.setLayout(vbox) + + def show_context_menu(self, pos): + menu = QMenu(self) + menu.addAction(_translate("Form", "添加"), self.add_row) + menu.addAction(_translate("Form", "删除"), self.del_row) + menu.addAction( + _translate("Form", "插入"), lambda: self.insert_row(self.table.currentRow()) + ) + menu.exec_(self.table.mapToGlobal(pos)) + + def _build_left_bracket(self): + w = QComboBox(self) + w.addItems(["", "(", "(("]) + return w + + def _build_field(self): + w = QComboBox(self) + w.addItems(HistoryData.fields()) + w.currentIndexChanged.connect(self.on_field_changed) + return w + + def _build_compare(self): + w = QComboBox(self) + w.addItems(CompareSymbol.display_names()) + w.currentIndexChanged.connect(self.on_compare_changed) + return w + + def _build_right_bracket(self): + w = QComboBox(self) + w.addItems(["", ")", "))"]) + return w + + def _build_logic(self): + w = QComboBox(self) + w.addItems(LogicSymbol.display_names()) + return w + + def on_field_changed(self, index): + combo: QComboBox = self.sender() + item_index = self.table.indexAt(combo.pos()) + if not item_index.isValid(): + return + row = item_index.row() + field_name = combo.currentText() + compare_w: QComboBox = self.table.cellWidget(row, 2) + compare = CompareSymbol.from_display_name(compare_w.currentText()) + field_cls = HistoryData.get_field_value(field_name) + self.table.setCellWidget(row, 3, self._build_value_widget(compare, field_cls)) + + def on_compare_changed(self, index): + combo: QComboBox = self.sender() + item_index = self.table.indexAt(combo.pos()) + if not item_index.isValid(): + return + row = item_index.row() + field_w: QComboBox = self.table.cellWidget(row, 1) + field_name = field_w.currentText() + compare = CompareSymbol.from_display_name(combo.currentText()) + field_cls = HistoryData.get_field_value(field_name) + self.table.setCellWidget(row, 3, self._build_value_widget(compare, field_cls)) + + def _build_value_widget(self, compare: CompareSymbol, field_value: Any): + if compare not in (CompareSymbol.Contains, CompareSymbol.NotContains): + if isinstance(field_value, int): + return QSpinBox(self) + elif isinstance(field_value, float): + return QDoubleSpinBox(self) + elif isinstance(field_value, str): + return QLineEdit(self) + elif isinstance(field_value, datetime): + return QDateTimeEdit(self) + return QLineEdit(self) + + def add_row(self): + self.insert_row(self.table.rowCount()) + + def del_row(self): + self.table.removeRow(self.table.currentRow()) + + def insert_row(self, row: int): + self.table.insertRow(row) + field_w = self._build_field() + compare_w = self._build_compare() + compare = CompareSymbol.from_display_name(compare_w.currentText()) + field_value = HistoryData.get_field_value(field_w.currentText()) + self.table.setCellWidget(row, 0, self._build_left_bracket()) + self.table.setCellWidget(row, 1, field_w) + self.table.setCellWidget(row, 2, compare_w) + self.table.setCellWidget(row, 3, self._build_value_widget(compare, field_value)) + self.table.setCellWidget(row, 4, self._build_right_bracket()) + self.table.setCellWidget(row, 5, self._build_logic()) + + def gen_filter_str(self): + filter_str = "" + left_count = 0 + right_count = 0 + for row in range(self.table.rowCount()): + left_bracket_w = self.table.cellWidget(row, 0) + field_w = self.table.cellWidget(row, 1) + compare_w = self.table.cellWidget(row, 2) + value_w = self.table.cellWidget(row, 3) + right_bracket_w = self.table.cellWidget(row, 4) + logic_w = self.table.cellWidget(row, 5) + + left_bracket = left_bracket_w.currentText() + field = field_w.currentText() + field_init_value = HistoryData.get_field_value(field) + compare = CompareSymbol.from_display_name(compare_w.currentText()) + right_bracket = right_bracket_w.currentText() + logic = LogicSymbol.from_display_name(logic_w.currentText()).to_sql + + if left_bracket == "(": + left_count += 1 + elif left_bracket == "((": + left_count += 2 + if right_bracket == ")": + right_count += 1 + elif right_bracket == "))": + right_count += 2 + + if right_count > left_count: + QMessageBox.warning( + self, "错误", f"第{row}行 右括号数量大于左括号数量,请检查" + ) + return None + + # 获取值 + if isinstance(value_w, QComboBox): + value = value_w.currentText() + elif isinstance(value_w, QDateTimeEdit): + value = int(value_w.dateTime().toPyDateTime().timestamp() * 1_000_000) + elif isinstance(value_w, QSpinBox): + value = str(value_w.value()) + elif isinstance(value_w, QDoubleSpinBox): + value = str(value_w.value()) + elif isinstance(value_w, QLineEdit): + if compare in (CompareSymbol.Contains, CompareSymbol.NotContains): + if isinstance(field_init_value, (int, float)): + values = value_w.text().split(",") + for v in values: + if not v.replace("-", "").isdigit(): + QMessageBox.warning( + self, "错误", f"第{row}行 {v} 不是数字" + ) + return None + value = ",".join(v for v in values) + elif isinstance(field_init_value, datetime): + values = value_w.text().split(",") + for v in values: + try: + datetime.strptime(v, "%Y-%m-%d %H:%M:%S") + except ValueError: + QMessageBox.warning( + self, "错误", f"第{row}行 {v} 不是合法的日期时间" + ) + return None + values = [ + int( + datetime.strptime(v, "%Y-%m-%d %H:%M:%S").timestamp() + * 1_000_000 + ) + for v in values + ] + value = ",".join(str(v) for v in values) + else: + value = ",".join(f"'{v}'" for v in value_w.text().split(",")) + value = f"({value})" + else: + value = f"'{value_w.text()}'" + else: + value = str( + getattr(value_w, "value", value_w.text()) + if hasattr(value_w, "value") + else "" + ) + + is_last = row == self.table.rowCount() - 1 + filter_str += ( + f" {left_bracket} {field} {compare.to_sql} {value} {right_bracket} " + ) + if not is_last: + filter_str += logic + + if left_count != right_count: + QMessageBox.warning(self, "错误", "左括号数量和右括号数量不匹配,请检查") + return None + return filter_str + + +# ── 历史记录表格 ────────────────────────────────────────── + + +class HistoryTable(QWidget): + HEADERS = [ + "replay_id", + "game_board_state", + "rtime", + "left", + "right", + "double", + "left_s", + "right_s", + "double_s", + "level", + "cl", + "cl_s", + "ce", + "ce_s", + "rce", + "lce", + "dce", + "bbbv", + "bbbv_solved", + "bbbv_s", + "flag", + "path", + "etime", + "start_time", + "end_time", + "mode", + "software", + "player_identifier", + "race_identifier", + "uniqueness_identifier", + "stnb", + "corr", + "thrp", + "ioe", + "is_official", + "is_fair", + "op", + "isl", + "pluck", + ] + + def __init__(self, show_fields: set[str], db_path: Path, parent=None): + super().__init__(parent) + self._db_path = db_path + layout = QVBoxLayout(self) + self.table = QTableView(self) + layout.addWidget(self.table) + self.setLayout(layout) + + self.table.setEditTriggers(QTableView.NoEditTriggers) + self.table.setContextMenuPolicy(Qt.CustomContextMenu) + self.table.customContextMenuRequested.connect(self.show_context_menu) + self.showFields: set[str] = show_fields + self.headers = self.HEADERS + + self.model = HistoryTableModel([], self.headers, self.showFields, self) + self.table.setModel(self.model) + self.table.horizontalHeader().setDefaultAlignment(Qt.AlignCenter) + self.table.setSelectionBehavior(QTableView.SelectRows) + self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) + + def load(self, data: list[HistoryData]): + self.model.update_data(data) + + def refresh(self): + parent_widget = self.parent() + if hasattr(parent_widget, "load_data"): + parent_widget.load_data() + + def show_context_menu(self, pos): + menu = QMenu(self) + menu.addAction(_translate("Form", "播放"), self.play_row) + menu.addAction(_translate("Form", "导出"), self.export_row) + menu.addAction(_translate("Form", "刷新"), self.refresh) + submenu = QMenu(_translate("Form", "显示字段"), self) + for field in self.headers: + action = QAction(field, self) + action.setCheckable(True) + action.setChecked(field in self.showFields) + action.triggered.connect(lambda checked, a=action: self._on_toggle_field(a)) + submenu.addAction(action) + menu.addMenu(submenu) + menu.exec_(self.table.mapToGlobal(pos)) + + def _on_toggle_field(self, action: QAction): + name = action.text() + if action.isChecked(): + self.showFields.add(name) + else: + self.showFields.discard(name) + self.model.update_show_fields(self.showFields) + + def _get_current_replay_id(self) -> int | None: + row_idx = self.table.currentIndex().row() + if row_idx < 0: + return None + visible = self.model._visible_headers + if "replay_id" in visible: + col = visible.index("replay_id") + rid = self.model.data(self.model.index(row_idx, col), Qt.UserRole) + return rid + return getattr(self.model._data[row_idx], "replay_id", None) + + def _read_raw_data(self, replay_id: int) -> bytes | None: + conn = sqlite3.connect(self._db_path) + try: + cursor = conn.cursor() + cursor.execute( + "SELECT raw_data FROM history WHERE replay_id = ?", (replay_id,) + ) + row = cursor.fetchone() + return row[0] if row else None + finally: + conn.close() + + def save_evf(self, evf_path: str): + replay_id = self._get_current_replay_id() + if replay_id is None: + return + raw_data = self._read_raw_data(replay_id) + if raw_data is None: + return + with open(evf_path, "wb") as f: + f.write(raw_data) + + def play_row(self): + exec_dir = get_executable_dir() + temp_filename = exec_dir / "tmp.evf" + self.save_evf(str(temp_filename)) + + exe = exec_dir / "metaminsweeper.exe" + main_py = exec_dir / "main.py" + + if main_py.exists(): + subprocess.Popen([sys.executable, str(main_py), str(temp_filename)]) + elif exe.exists(): + subprocess.Popen([str(exe), str(temp_filename)]) + else: + QMessageBox.warning( + self, "错误", "找不到主程序 (main.py 或 metaminsweeper.exe)" + ) + + def export_row(self): + file_path, _ = QFileDialog.getSaveFileName( + self, + _translate("Form", "导出evf文件"), + str(get_executable_dir()), + "evf文件 (*.evf)", + ) + if file_path: + self.save_evf(file_path) + + +# ── 主界面容器 ──────────────────────────────────────────── + + +class HistoryMainWidget(QWidget): + """历史记录插件的主界面(作为插件的 widget 返回)""" + + def __init__(self, db_path: Path, config_path: Path, parent=None): + super().__init__(parent) + self._db_path = db_path + self._config_path = config_path + + self.setWindowTitle(_translate("Form", "历史记录")) + self.resize(800, 600) + + layout = QVBoxLayout(self) + + # 查询按钮 + btn_layout = QHBoxLayout() + self.query_button = QPushButton(_translate("Form", "查询")) + btn_layout.addWidget(self.query_button) + btn_layout.addItem( + QSpacerItem(10, 10, QSizePolicy.Expanding, QSizePolicy.Minimum) + ) + + # 筛选 + 表格 + self.filter_widget = FilterWidget(self) + self.table = HistoryTable(self._get_show_fields(), db_path, self) + + # 分页 + limit_layout = QHBoxLayout() + self.previous_button = QPushButton(_translate("Form", "上一页")) + self.page_spin = QSpinBox() + self.page_spin.setMinimum(1) + self.page_spin.setValue(1) + self.next_button = QPushButton(_translate("Form", "下一页")) + self.one_page_combo = QComboBox() + self.one_page_combo.addItems(["10", "20", "50", "100", "200", "500", "1000"]) + self.limit_label = QLabel("") + limit_layout.addItem( + QSpacerItem(10, 10, QSizePolicy.Expanding, QSizePolicy.Minimum) + ) + limit_layout.addWidget(self.limit_label) + limit_layout.addWidget(self.previous_button) + limit_layout.addWidget(self.page_spin) + limit_layout.addWidget(self.next_button) + limit_layout.addWidget(self.one_page_combo) + + layout.addLayout(btn_layout) + layout.addWidget(self.filter_widget) + layout.addWidget(self.table) + layout.addLayout(limit_layout) + self.setLayout(layout) + + self._connect_signals() + self.load_data() + + def _connect_signals(self): + self.query_button.clicked.connect(self._on_query) + self.previous_button.clicked.connect( + lambda: self.page_spin.setValue(self.page_spin.value() - 1) + ) + self.next_button.clicked.connect( + lambda: self.page_spin.setValue(self.page_spin.value() + 1) + ) + self.one_page_combo.currentTextChanged.connect(self.load_data) + self.page_spin.valueChanged.connect(self.load_data) + + def _on_query(self): + if self.page_spin.value() > 1: + self.page_spin.setValue(1) + else: + self.load_data() + + def _get_limit_str(self): + per_page = int(self.one_page_combo.currentText()) + offset = (self.page_spin.value() - 1) * per_page + return f" LIMIT {per_page} OFFSET {offset}" + + def _get_show_fields(self) -> set[str]: + if not self._config_path.exists(): + return set(HistoryData.fields()) + with open(self._config_path, "r") as f: + return set(json.load(f)) + + def load_data(self): + if not self._db_path.exists(): + QMessageBox.warning(self, "错误", "历史记录数据库不存在") + return + + try: + conn = sqlite3.connect(self._db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + filter_str = self.filter_widget.gen_filter_str() + sql = "SELECT *, COUNT(*) OVER() AS total_count FROM history" + if filter_str: + sql += " WHERE " + filter_str + elif filter_str is None: + return + sql += self._get_limit_str() + cursor.execute(sql) + datas = cursor.fetchall() + + if not datas: + self.page_spin.setMaximum(1) + self.limit_label.setText("共0行,0页") + else: + per_page = int(self.one_page_combo.currentText()) + total = datas[0]["total_count"] + max_page = math.ceil(total / per_page) + self.page_spin.setMaximum(max_page) + self.limit_label.setText(f"共{total}行,{max_page}页") + + history_data = [HistoryData.from_dict(dict(d)) for d in datas] + conn.close() + except sqlite3.Error as e: + QMessageBox.warning(self, "错误", f"加载历史记录失败: {e}") + return + + self.table.load(history_data) + + def closeEvent(self, event: _QCloseEvent): + """关闭时保存列显示配置""" + with open(self._config_path, "w") as f: + json.dump(list(self.table.showFields), f) + super().closeEvent(event) + + +# ═══════════════════════════════════════════════════════════════════ +# 插件主体 +# ═══════════════════════════════════════════════════════════════════ +class History_Signal(QObject): + video_save_over = pyqtSignal() + + +class HistoryPlugin(BasePlugin): + """ + 历史记录插件 + + - 后台:监听 VideoSaveEvent,写入 SQLite + - 界面:提供筛选、分页、播放/导出功能 + """ + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="history", + description="游戏历史记录(SQLite 持久化)", + author="ljzloser", + version="1.0.0", + icon=make_plugin_icon("#7b1fa2", "\N{SCROLL}"), + window_mode=WindowMode.DETACHED, + ) + + def __init__(self, info): + super().__init__(info) + self._signal = History_Signal() + + def _setup_subscriptions(self) -> None: + self.subscribe(VideoSaveEvent, self._on_video_save) + + def _create_widget(self) -> QWidget: + db_path = self.data_dir / "history.db" + config_path = self.data_dir / "history_show_fields.json" + self._widget = HistoryMainWidget(db_path, config_path) + self._signal.video_save_over.connect(self._widget.query_button.click) + return self._widget + + def on_initialized(self) -> None: + self._init_db() + self.logger.info("历史记录插件已初始化") + + # ── 数据库 ────────────────────────────────────────────── + + def _init_db(self) -> None: + db_path = self.data_dir / "history.db" + if db_path.exists(): + return + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute( + """ + CREATE TABLE history ( + replay_id INTEGER PRIMARY KEY AUTOINCREMENT, + game_board_state INTEGER, + rtime REAL, + left INTEGER, + right INTEGER, + double INTEGER, + left_s REAL, + right_s REAL, + double_s REAL, + level INTEGER, + cl INTEGER, + cl_s REAL, + ce REAL, + ce_s REAL, + rce INTEGER, + lce INTEGER, + dce INTEGER, + bbbv INTEGER, + bbbv_solved INTEGER, + bbbv_s REAL, + flag INTEGER, + path REAL, + etime INTEGER, + start_time INTEGER, + end_time INTEGER, + mode INTEGER, + software TEXT, + player_identifier TEXT, + race_identifier TEXT, + uniqueness_identifier TEXT, + stnb REAL, + corr REAL, + thrp REAL, + ioe REAL, + is_official INTEGER, + is_fair INTEGER, + op INTEGER, + isl INTEGER, + pluck REAL, + raw_data BLOB + ) + """ + ) + conn.commit() + conn.close() + self.logger.info(f"Database created: {db_path}") + + # ── 事件处理 ────────────────────────────────────────── + + def _on_video_save(self, event: VideoSaveEvent) -> None: + data: dict[str, Any] = msgspec.structs.asdict(event) + raw_b64 = data.get("raw_data", "") + try: + data["raw_data"] = base64.b64decode(raw_b64) if raw_b64 else None + except Exception as e: + self.logger.warning(f"base64 decode failed: {e}") + data["raw_data"] = None + del data["timestamp"] + columns = ", ".join(data.keys()) + placeholders = ", ".join(f":{k}" for k in data.keys()) + + db_path = self.data_dir / "history.db" + conn = sqlite3.connect(db_path) + try: + cursor = conn.cursor() + cursor.execute( + f"INSERT INTO history ({columns}) VALUES ({placeholders})", + data, + ) + conn.commit() + self.logger.info( + f"Saved: board_state={event.game_board_state} time={event.rtime:.1f}s" + ) + finally: + conn.close() + self._signal.video_save_over.emit() diff --git a/src/shared_types/events.py b/src/shared_types/events.py index 5db77e2..45e8985 100644 --- a/src/shared_types/events.py +++ b/src/shared_types/events.py @@ -24,8 +24,53 @@ class BoardUpdateEvent(BaseEvent, tag="board_update"): board: list[list[int]] = [] +class VideoSaveEvent(BaseEvent, tag="video_save"): + """录像保存事件""" + + game_board_state: int = 0 + rtime: float = 0 + left: int = 126 + right: int = 11 + double: int = 14 + left_s: float = 2.5583756345177666 + right_s: float = 0.2233502538071066 + double_s: float = 0.28426395939086296 + level: int = 5 + cl: int = 151 + cl_s: float = 3.065989847715736 + ce: int = 144 + ce_s: float = 2.9238578680203045 + rce: int = 11 + lce: int = 119 + dce: int = 14 + bbbv: int = 127 + bbbv_solved: int = 127 + bbbv_s: float = 2.5786802030456855 + flag: int = 11 + path: float = 6082.352554578606 + etime: float = 1666124184868000 + start_time: int = 1666124135606000 + end_time: int = 1666124184868000 + mode: int = 0 + software: str = "Arbiter" + player_identifier: str = "Wang Jianing G01825" + race_identifier: str = "" + uniqueness_identifier: str = "" + stnb: float = 0 + corr: float = 0 + thrp: float = 0 + ioe: float = 0 + is_official: bool = 0 + is_fair: bool = 0 + op: int = 0 + isl: int = 0 + pluck: float = 0 + raw_data: str = "" + + EVENT_TYPES = [ GameStartedEvent, GameEndedEvent, BoardUpdateEvent, -] \ No newline at end of file + VideoSaveEvent, +]